feat(git-sources): link stacks to Git repositories with diff-and-apply workflow (#600)

* feat(git-sources): link stacks to Git repositories with diff-and-apply workflow

Add Git Sources so any stack can point at an HTTPS Git repository, branch, and
compose file path. Pulls fetch + validate the incoming commit, store a
diffable pending snapshot, and apply writes only after explicit confirmation
(or automatically, per the configured apply mode). Sibling .env sync is
optional. Works on the Community tier.

Apply modes:
- Review only: mark pending, wait for manual apply in the diff dialog
- Auto-write: write compose + env to disk, do not redeploy
- Auto-deploy: write files and run docker compose up -d

Webhook integration: webhooks can target the new "git-pull" action to trigger
a sync from CI. Per-source debounce prevents runaway pipelines from hammering
the repository host. Tokens are encrypted at rest and never returned to the
frontend.

Docs and tests included. Screenshots and Playwright E2E flows to follow.

* fix(git-sources): drop unnecessary useMemo on commit sha slice

React Compiler's lint rule rejected the manual dependency list because the
inferred dep ('pull') was less specific than the written one ('pull?.commitSha').
The computation is a cheap 7-char slice, so drop the useMemo entirely rather
than fight the rule.

* test(git-sources): add Playwright E2E flows and drop orphan source rows on stack delete

- E2E coverage: non-HTTPS URL rejected client-side, unreachable repo surfaces
  a toast error on save, and configure+remove walks the AlertDialog confirm path.
- Deleting a stack now also drops its linked Git source row so a future stack
  with the same name starts clean rather than inheriting a stale config.
This commit is contained in:
Anso
2026-04-14 21:13:17 -04:00
committed by GitHub
parent d7ff706e50
commit 377df7e546
14 changed files with 2974 additions and 6 deletions
@@ -0,0 +1,448 @@
/**
* Unit tests for GitSourceService.
*
* Covers:
* - hashContent determinism and env separation
* - validateCompose YAML pre-check (empty / non-object / syntax error)
* - Token round-trip via upsert: encryption, has_token projection, undefined/null/empty/non-empty semantics
* - Apply-matrix rejection (auto_deploy requires auto_apply)
* - Error code mapping from isomorphic-git failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
* - Credential scrubbing in surfaced error messages
* - Pending state lifecycle (setPending -> apply clears -> dismissPending clears)
* - Webhook debounce enforcement
* - Per-stack mutex serialization ordering
*/
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockGitClone, mockGitLog } = vi.hoisted(() => ({
mockGitClone: vi.fn(),
mockGitLog: vi.fn(),
}));
vi.mock('isomorphic-git', () => {
const api = { clone: mockGitClone, log: mockGitLog };
return { default: api, clone: mockGitClone, log: mockGitLog };
});
vi.mock('isomorphic-git/http/node', () => ({ default: {} }));
let tmpDir: string;
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
let GitSourceError: typeof import('../services/GitSourceService').GitSourceError;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ GitSourceService, GitSourceError } = await import('../services/GitSourceService'));
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockGitClone.mockReset();
mockGitLog.mockReset();
// Wipe persisted git sources between tests
const db = DatabaseService.getInstance();
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
});
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Stub out isomorphic-git so that `clone` writes a minimal compose file into
* the caller's temp dir and `log` returns a deterministic commit sha. Returns
* the sha so tests can compare.
*/
function mockSuccessfulClone(options: {
compose?: string;
env?: string | null;
composePath?: string;
envPath?: string | null;
sha?: string;
} = {}) {
const {
compose = 'services:\n web:\n image: nginx\n',
env = null,
composePath = 'compose.yaml',
envPath = null,
sha = 'abc1234567890abc1234567890abc1234567890a',
} = options;
mockGitClone.mockImplementation(async (args: { dir: string }) => {
const { promises: fsp } = await import('fs');
const path = await import('path');
await fsp.writeFile(path.join(args.dir, composePath), compose, 'utf-8');
if (env !== null && envPath) {
await fsp.writeFile(path.join(args.dir, envPath), env, 'utf-8');
}
});
mockGitLog.mockResolvedValue([{ oid: sha }]);
return sha;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GitSourceService.hashContent', () => {
it('produces stable hashes for identical inputs', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
const b = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
it('distinguishes env=null from env=""', () => {
const svc = GitSourceService.getInstance();
const nullHash = svc.hashContent('x: 1', null);
const emptyHash = svc.hashContent('x: 1', '');
// Both hash-empty-string after null-coalesce, so they should match by design.
expect(nullHash).toBe(emptyHash);
});
it('changes when compose content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('x: 1', null);
const b = svc.hashContent('x: 2', null);
expect(a).not.toBe(b);
});
it('changes when env content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent('x: 1', 'A=1');
const b = svc.hashContent('x: 1', 'A=2');
expect(a).not.toBe(b);
});
it('does not confuse compose|env boundary (uses NUL separator)', () => {
const svc = GitSourceService.getInstance();
// If the separator were absent, "ab" + "cd" would equal "abc" + "d".
const a = svc.hashContent('ab', 'cd');
const b = svc.hashContent('abc', 'd');
expect(a).not.toBe(b);
});
});
describe('GitSourceService.validateCompose (YAML pre-check)', () => {
const svc = () => GitSourceService.getInstance();
it('rejects empty content', async () => {
const r = await svc().validateCompose('', null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/empty/i);
});
it('rejects a YAML array at the root', async () => {
const r = await svc().validateCompose('- one\n- two\n', null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects a YAML scalar at the root', async () => {
const r = await svc().validateCompose('42', null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects malformed YAML syntax', async () => {
const r = await svc().validateCompose('services:\n web:\n image: "unterminated\n', null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/YAML parse error/i);
});
});
describe('GitSourceService.upsert (encryption + reachability)', () => {
it('stores an encrypted token and exposes has_token without leaking the value', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
const created = await svc.upsert({
stackName: 'enc-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'token',
token: 'ghp_secret_token_value',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(created.has_token).toBe(true);
// Public projection should not contain the raw token
const serialized = JSON.stringify(created);
expect(serialized).not.toContain('ghp_secret_token_value');
// DB row holds an encrypted blob distinct from the plaintext
const row = DatabaseService.getInstance().getGitSource('enc-stack');
expect(row?.encrypted_token).toBeTruthy();
expect(row?.encrypted_token).not.toBe('ghp_secret_token_value');
});
it('preserves an existing token when update omits token (undefined)', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'token',
token: 'initial-token',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const originalEnc = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
await svc.upsert({
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'token',
// token omitted on purpose
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const after = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
expect(after).toBe(originalEnc);
});
it('clears the token when authType switches to "none"', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'clear-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'token',
token: 'will-be-cleared',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
await svc.upsert({
stackName: 'clear-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const row = DatabaseService.getInstance().getGitSource('clear-stack');
expect(row?.encrypted_token).toBeNull();
expect(row?.auth_type).toBe('none');
});
it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.upsert({
stackName: 'bad-matrix',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: true,
})).rejects.toBeInstanceOf(GitSourceError);
// Dry-run clone must not have been attempted for the invalid matrix
expect(mockGitClone).not.toHaveBeenCalled();
});
it('does not persist when dry-run fetch fails', async () => {
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('404 not found'), { code: 'NotFoundError' }));
const svc = GitSourceService.getInstance();
await expect(svc.upsert({
stackName: 'unreachable',
repoUrl: 'https://github.com/example/nope.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
})).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
expect(DatabaseService.getInstance().getGitSource('unreachable')).toBeUndefined();
});
});
describe('GitSourceService error mapping', () => {
const svc = () => GitSourceService.getInstance();
const fetchParams = {
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
};
it('maps 401/auth errors to AUTH_FAILED', async () => {
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP 401 Unauthorized'), { code: 'HttpError' }));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'AUTH_FAILED' });
});
it('maps 404/not-found errors to REPO_NOT_FOUND', async () => {
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('Repository not found'), { code: 'NotFoundError' }));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
});
it('maps resolve-ref errors to BRANCH_NOT_FOUND', async () => {
// Message phrased to miss the REPO_NOT_FOUND regex ("could not resolve")
// so the BRANCH_NOT_FOUND branch is exercised.
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('unknown ref nonexistent'), { code: 'ResolveRefError' }));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'BRANCH_NOT_FOUND' });
});
it('maps timeout errors to NETWORK_TIMEOUT', async () => {
mockGitClone.mockRejectedValueOnce(new Error('ETIMEDOUT connecting to host'));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('surfaces FILE_NOT_FOUND when the compose path is missing from the clone', async () => {
mockGitClone.mockImplementation(async () => { /* clone empty repo */ });
mockGitLog.mockResolvedValue([{ oid: 'deadbeef' }]);
await expect(svc().fetchFromGit({
...fetchParams,
composePath: 'missing/compose.yaml',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('scrubs inline credentials from surfaced error messages', async () => {
mockGitClone.mockRejectedValueOnce(new Error('Failed: https://user:supersecret@github.com/example/repo.git 500'));
try {
await svc().fetchFromGit(fetchParams);
expect.fail('should have thrown');
} catch (e) {
const err = e as Error;
expect(err.message).not.toContain('supersecret');
expect(err.message).toContain('***');
}
});
});
describe('GitSourceService pending lifecycle', () => {
it('dismissPending clears pending columns', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'pending-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const db = DatabaseService.getInstance();
db.setGitSourcePending('pending-stack', 'sha-xxx', 'services: {}', null);
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBe('sha-xxx');
svc.dismissPending('pending-stack');
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBeNull();
});
});
describe('GitSourceService.handleWebhookPull debounce', () => {
it('returns skipped when invoked within the debounce window', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'debounce-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// Stamp a recent debounce timestamp directly
DatabaseService.getInstance().touchGitSourceDebounce('debounce-stack');
const result = await svc.handleWebhookPull('debounce-stack');
expect(result.status).toBe('skipped');
expect(result.message).toMatch(/rate limited/i);
});
it('returns error when stack has no Git source configured', async () => {
const svc = GitSourceService.getInstance();
const result = await svc.handleWebhookPull('does-not-exist');
expect(result.status).toBe('error');
expect(result.message).toMatch(/no git source/i);
});
});
describe('GitSourceService per-stack mutex', () => {
it('serializes concurrent apply calls on the same stack', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance() as unknown as {
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
};
const order: string[] = [];
const makeJob = (label: string, delayMs: number) => async () => {
order.push(`start:${label}`);
await new Promise(r => setTimeout(r, delayMs));
order.push(`end:${label}`);
return label;
};
const [a, b, c] = await Promise.all([
svc.withStackLock('serialized', makeJob('A', 30)),
svc.withStackLock('serialized', makeJob('B', 10)),
svc.withStackLock('serialized', makeJob('C', 5)),
]);
expect([a, b, c]).toEqual(['A', 'B', 'C']);
// Each job must fully complete before the next one starts.
expect(order).toEqual([
'start:A', 'end:A',
'start:B', 'end:B',
'start:C', 'end:C',
]);
});
it('does not block work on a different stack', async () => {
const svc = GitSourceService.getInstance() as unknown as {
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
};
const order: string[] = [];
const slow = svc.withStackLock('alpha', async () => {
order.push('alpha:start');
await new Promise(r => setTimeout(r, 40));
order.push('alpha:end');
});
const fast = svc.withStackLock('beta', async () => {
order.push('beta:start');
order.push('beta:end');
});
await Promise.all([slow, fast]);
// beta should have started and finished before alpha finished
expect(order.indexOf('beta:end')).toBeLessThan(order.indexOf('alpha:end'));
});
});
+197 -2
View File
@@ -35,6 +35,7 @@ import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CacheService } from './services/CacheService';
import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry';
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, type GitSourceErrorCode } from './services/GitSourceService';
// ── Hot-path cache TTLs ────────────────────────────────────────────────
// Short TTLs collapse concurrent polling pressure across browser tabs and
@@ -2254,11 +2255,15 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P
res.status(400).json({ error: 'name, stack_name, and action are required' });
return;
}
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
if (!validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) {
res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' });
return;
}
const svc = WebhookService.getInstance();
const secret = svc.generateSecret();
@@ -2283,11 +2288,18 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response)
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
const { name, stack_name, action, enabled } = req.body;
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
if (action && !validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
if (action === 'git-pull') {
const targetStack = stack_name || webhook.stack_name;
if (!GitSourceService.getInstance().get(targetStack)) {
res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' });
return;
}
}
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
res.json({ success: true });
@@ -3681,6 +3693,182 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
}
});
// ── Git sources ────────────────────────────────────────────────────────
// Map GitSourceError codes to HTTP statuses so the UI can tell apart things
// a user can fix (bad token, missing file) from transient failures.
function gitSourceStatus(code: GitSourceErrorCode): number {
switch (code) {
case 'AUTH_FAILED': return 401;
case 'REPO_NOT_FOUND':
case 'BRANCH_NOT_FOUND':
case 'FILE_NOT_FOUND':
return 404;
case 'NETWORK_TIMEOUT': return 504;
default: return 400;
}
}
function sendGitSourceError(res: Response, err: unknown): void {
if (err instanceof GitSourceError) {
res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code });
return;
}
console.error('[GitSource] Unexpected error:', err);
res.status(500).json({ error: 'Git source operation failed' });
}
app.get('/api/git-sources', async (_req: Request, res: Response) => {
try {
const sources = GitSourceService.getInstance().list();
res.json(sources);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.get('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const source = GitSourceService.getInstance().get(stackName);
if (!source) return res.status(404).json({ error: 'No Git source configured for this stack' });
res.json(source);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.put('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const {
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
token,
auto_apply_on_webhook,
auto_deploy_on_apply,
} = req.body ?? {};
if (typeof repo_url !== 'string' || !repo_url.trim()) {
return res.status(400).json({ error: 'repo_url is required' });
}
if (typeof branch !== 'string' || !branch.trim()) {
return res.status(400).json({ error: 'branch is required' });
}
if (typeof compose_path !== 'string' || !compose_path.trim()) {
return res.status(400).json({ error: 'compose_path is required' });
}
if (auth_type !== 'none' && auth_type !== 'token') {
return res.status(400).json({ error: 'auth_type must be "none" or "token"' });
}
if (!/^https?:\/\//i.test(repo_url)) {
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
}
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
? (typeof env_path === 'string' && env_path.trim()
? env_path
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
: null;
const source = await GitSourceService.getInstance().upsert({
stackName,
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
syncEnv,
envPath: resolvedEnvPath,
authType: auth_type,
token: typeof token === 'string' ? token : undefined,
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
autoDeployOnApply: Boolean(auto_deploy_on_apply),
});
console.log(`[GitSource] Configured git source for ${stackName}`);
res.json(source);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.delete('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
GitSourceService.getInstance().delete(stackName);
console.log(`[GitSource] Removed git source for ${stackName}`);
res.json({ success: true });
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/pull', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const result = await GitSourceService.getInstance().pull(stackName);
res.json(result);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const { commitSha, deploy } = req.body ?? {};
if (typeof commitSha !== 'string' || !commitSha.trim()) {
return res.status(400).json({ error: 'commitSha is required' });
}
const result = await GitSourceService.getInstance().apply(
stackName,
commitSha.trim(),
{ deploy: typeof deploy === 'boolean' ? deploy : undefined }
);
invalidateNodeCaches(req.nodeId);
console.log(`[GitSource] Applied commit ${commitSha.trim().slice(0, 7)} to ${stackName}${result.deployed ? ' (deployed)' : ''}`);
res.json(result);
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks/:stackName/git-source/dismiss-pending', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
GitSourceService.getInstance().dismissPending(stackName);
res.json({ success: true });
} catch (error) {
sendGitSourceError(res, error);
}
});
app.post('/api/stacks', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
try {
@@ -3726,6 +3914,8 @@ app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
// Clean up any scoped role assignments referencing this stack
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
// Remove any linked Git source so it does not resurface on a future stack with the same name
DatabaseService.getInstance().deleteGitSource(stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${stackName}`);
@@ -6476,6 +6666,11 @@ async function startServer() {
// Start Scheduled Operations Service
SchedulerService.getInstance().start();
// Sweep any leftover git-source temp clones from a crashed prior run
sweepStaleGitTempDirs().catch((err) => {
console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message);
});
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+172 -1
View File
@@ -45,17 +45,44 @@ export interface Label {
color: string;
}
export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | 'git-pull';
export interface Webhook {
id?: number;
name: string;
stack_name: string;
action: 'deploy' | 'restart' | 'stop' | 'start' | 'pull';
action: WebhookAction;
secret: string;
enabled: boolean;
created_at: number;
updated_at: number;
}
export type GitSourceAuthType = 'none' | 'token';
export interface StackGitSource {
id?: number;
stack_name: string;
repo_url: string;
branch: string;
compose_path: string;
sync_env: boolean;
env_path: string | null;
auth_type: GitSourceAuthType;
encrypted_token: string | null;
auto_apply_on_webhook: boolean;
auto_deploy_on_apply: boolean;
last_applied_commit_sha: string | null;
last_applied_content_hash: string | null;
pending_commit_sha: string | null;
pending_compose_content: string | null;
pending_env_content: string | null;
pending_fetched_at: number | null;
last_debounce_at: number | null;
created_at: number;
updated_at: number;
}
export interface WebhookExecution {
id?: number;
webhook_id: number;
@@ -459,6 +486,29 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
ON stack_label_assignments(stack_name, node_id);
CREATE TABLE IF NOT EXISTS stack_git_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL UNIQUE,
repo_url TEXT NOT NULL,
branch TEXT NOT NULL,
compose_path TEXT NOT NULL,
sync_env INTEGER NOT NULL DEFAULT 0,
env_path TEXT,
auth_type TEXT NOT NULL DEFAULT 'none',
encrypted_token TEXT,
auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
last_applied_commit_sha TEXT,
last_applied_content_hash TEXT,
pending_commit_sha TEXT,
pending_compose_content TEXT,
pending_env_content TEXT,
pending_fetched_at INTEGER,
last_debounce_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Apply migrations safely (ignore if columns already exist)
@@ -1467,6 +1517,127 @@ export class DatabaseService {
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
}
// --- Stack Git Sources ---
private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
if (!row) return undefined;
return {
id: row.id as number,
stack_name: row.stack_name as string,
repo_url: row.repo_url as string,
branch: row.branch as string,
compose_path: row.compose_path as string,
sync_env: Number(row.sync_env) === 1,
env_path: (row.env_path as string | null) ?? null,
auth_type: row.auth_type as GitSourceAuthType,
encrypted_token: (row.encrypted_token as string | null) ?? null,
auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1,
last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null,
last_applied_content_hash: (row.last_applied_content_hash as string | null) ?? null,
pending_commit_sha: (row.pending_commit_sha as string | null) ?? null,
pending_compose_content: (row.pending_compose_content as string | null) ?? null,
pending_env_content: (row.pending_env_content as string | null) ?? null,
pending_fetched_at: (row.pending_fetched_at as number | null) ?? null,
last_debounce_at: (row.last_debounce_at as number | null) ?? null,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getGitSource(stackName: string): StackGitSource | undefined {
const row = this.db.prepare('SELECT * FROM stack_git_sources WHERE stack_name = ?').get(stackName) as Record<string, unknown> | undefined;
return this.parseGitSource(row);
}
public getGitSources(): StackGitSource[] {
const rows = this.db.prepare('SELECT * FROM stack_git_sources ORDER BY stack_name ASC').all() as Record<string, unknown>[];
return rows.map(r => this.parseGitSource(r)!);
}
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at'>): number {
const now = Date.now();
const existing = this.getGitSource(source.stack_name);
if (existing) {
this.db.prepare(
`UPDATE stack_git_sources SET
repo_url = ?, branch = ?, compose_path = ?, sync_env = ?, env_path = ?,
auth_type = ?, encrypted_token = ?,
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
updated_at = ?
WHERE stack_name = ?`
).run(
source.repo_url, source.branch, source.compose_path,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
now, source.stack_name
);
return existing.id!;
}
const result = this.db.prepare(
`INSERT INTO stack_git_sources
(stack_name, repo_url, branch, compose_path, sync_env, env_path,
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
source.stack_name, source.repo_url, source.branch, source.compose_path,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
now, now
);
return result.lastInsertRowid as number;
}
public deleteGitSource(stackName: string): void {
this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
}
public setGitSourcePending(stackName: string, commitSha: string, composeContent: string, envContent: string | null): void {
this.db.prepare(
`UPDATE stack_git_sources SET
pending_commit_sha = ?,
pending_compose_content = ?,
pending_env_content = ?,
pending_fetched_at = ?,
updated_at = ?
WHERE stack_name = ?`
).run(commitSha, composeContent, envContent, Date.now(), Date.now(), stackName);
}
public clearGitSourcePending(stackName: string): void {
this.db.prepare(
`UPDATE stack_git_sources SET
pending_commit_sha = NULL,
pending_compose_content = NULL,
pending_env_content = NULL,
pending_fetched_at = NULL,
updated_at = ?
WHERE stack_name = ?`
).run(Date.now(), stackName);
}
public markGitSourceApplied(stackName: string, commitSha: string, contentHash: string): void {
this.db.prepare(
`UPDATE stack_git_sources SET
last_applied_commit_sha = ?,
last_applied_content_hash = ?,
pending_commit_sha = NULL,
pending_compose_content = NULL,
pending_env_content = NULL,
pending_fetched_at = NULL,
updated_at = ?
WHERE stack_name = ?`
).run(commitSha, contentHash, Date.now(), stackName);
}
public touchGitSourceDebounce(stackName: string): void {
this.db.prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?')
.run(Date.now(), stackName);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {
+639
View File
@@ -0,0 +1,639 @@
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import crypto from 'crypto';
import os from 'os';
import path from 'path';
import git from 'isomorphic-git';
import gitHttp from 'isomorphic-git/http/node';
import YAML from 'yaml';
import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
/**
* GitSourceService - fetch compose files from a Git repository and apply
* them to local stacks. Tokens are encrypted via CryptoService. Shallow
* single-branch clones land in a per-fetch temp dir and are cleaned up
* in a `finally` block. A startup sweep removes any leftover temp dirs
* older than 1 hour in case a previous process crashed.
*/
// ─── Types ───────────────────────────────────────────────────────────────────
export type GitSourceErrorCode =
| 'REPO_NOT_FOUND'
| 'AUTH_FAILED'
| 'BRANCH_NOT_FOUND'
| 'FILE_NOT_FOUND'
| 'NETWORK_TIMEOUT'
| 'GIT_ERROR';
export class GitSourceError extends Error {
constructor(public code: GitSourceErrorCode, message: string) {
super(message);
this.name = 'GitSourceError';
}
}
export interface FetchParams {
repoUrl: string;
branch: string;
composePath: string;
envPath?: string | null;
token?: string | null;
timeoutMs?: number;
}
export interface FetchResult {
composeContent: string;
envContent: string | null;
commitSha: string;
}
export interface UpsertInput {
stackName: string;
repoUrl: string;
branch: string;
composePath: string;
syncEnv: boolean;
envPath: string | null;
authType: GitSourceAuthType;
token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace
autoApplyOnWebhook: boolean;
autoDeployOnApply: boolean;
}
export interface PullResult {
commitSha: string;
incomingCompose: string;
incomingEnv: string | null;
currentCompose: string;
currentEnv: string | null;
validation: { ok: boolean; error?: string };
hasLocalChanges: boolean;
}
export interface PublicGitSource {
id: number;
stack_name: string;
repo_url: string;
branch: string;
compose_path: string;
sync_env: boolean;
env_path: string | null;
auth_type: GitSourceAuthType;
has_token: boolean;
auto_apply_on_webhook: boolean;
auto_deploy_on_apply: boolean;
last_applied_commit_sha: string | null;
pending_commit_sha: string | null;
pending_fetched_at: number | null;
created_at: number;
updated_at: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const TEMP_DIR_PREFIX = 'sencho-git-';
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
const TEMP_DIR_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
const WEBHOOK_DEBOUNCE_MS = 10_000;
// ─── Credential scrubbing ────────────────────────────────────────────────────
/**
* Remove any inline credentials and Authorization headers from an error
* message before it lands in a log or an API response. isomorphic-git
* tends to include the fetch URL in thrown errors; if a PAT ever leaks
* into that URL (we try to avoid it via `onAuth`, but be defensive),
* strip it here.
*/
function scrubCredentials(message: string): string {
return message
.replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://***:***@')
.replace(/(authorization[:=]\s*)[^\s,;]+/gi, '$1***')
.replace(/(token[:=]\s*)[^\s,;]+/gi, '$1***')
.replace(/(password[:=]\s*)[^\s,;]+/gi, '$1***');
}
// ─── Temp dir helpers ────────────────────────────────────────────────────────
async function createTempDir(): Promise<string> {
const prefix = path.join(os.tmpdir(), TEMP_DIR_PREFIX);
return fsPromises.mkdtemp(prefix);
}
async function removeTempDir(dir: string): Promise<void> {
try {
await fsPromises.rm(dir, { recursive: true, force: true });
} catch (e) {
console.warn('[GitSourceService] Failed to remove temp dir:', (e as Error).message);
}
}
/**
* Sweep any leftover sencho-git-* temp dirs older than 1 hour. Runs once at
* service boot to clean up after a crashed process.
*/
export async function sweepStaleTempDirs(): Promise<void> {
const tmp = os.tmpdir();
let entries: string[];
try {
entries = await fsPromises.readdir(tmp);
} catch {
return;
}
const cutoff = Date.now() - TEMP_DIR_MAX_AGE_MS;
for (const entry of entries) {
if (!entry.startsWith(TEMP_DIR_PREFIX)) continue;
const full = path.join(tmp, entry);
try {
const stat = await fsPromises.stat(full);
if (stat.mtimeMs < cutoff) {
await fsPromises.rm(full, { recursive: true, force: true });
}
} catch {
// best effort
}
}
}
// ─── Service ─────────────────────────────────────────────────────────────────
export class GitSourceService {
private static instance: GitSourceService;
private crypto: CryptoService;
/** Per-stack serialization for the apply path. */
private stackLocks = new Map<string, Promise<unknown>>();
private constructor() {
this.crypto = CryptoService.getInstance();
}
public static getInstance(): GitSourceService {
if (!GitSourceService.instance) {
GitSourceService.instance = new GitSourceService();
}
return GitSourceService.instance;
}
// ─── Public projections ──────────────────────────────────────────────────
private toPublic(src: StackGitSource): PublicGitSource {
return {
id: src.id!,
stack_name: src.stack_name,
repo_url: src.repo_url,
branch: src.branch,
compose_path: src.compose_path,
sync_env: src.sync_env,
env_path: src.env_path,
auth_type: src.auth_type,
has_token: !!src.encrypted_token,
auto_apply_on_webhook: src.auto_apply_on_webhook,
auto_deploy_on_apply: src.auto_deploy_on_apply,
last_applied_commit_sha: src.last_applied_commit_sha,
pending_commit_sha: src.pending_commit_sha,
pending_fetched_at: src.pending_fetched_at,
created_at: src.created_at,
updated_at: src.updated_at,
};
}
public get(stackName: string): PublicGitSource | undefined {
const row = DatabaseService.getInstance().getGitSource(stackName);
return row ? this.toPublic(row) : undefined;
}
public list(): PublicGitSource[] {
return DatabaseService.getInstance().getGitSources().map(s => this.toPublic(s));
}
// ─── CRUD ────────────────────────────────────────────────────────────────
public async upsert(input: UpsertInput): Promise<PublicGitSource> {
const db = DatabaseService.getInstance();
const existing = db.getGitSource(input.stackName);
// Determine the stored token.
let encryptedToken: string | null;
if (input.authType === 'none') {
encryptedToken = null;
} else if (input.token === undefined) {
// Keep existing
encryptedToken = existing?.encrypted_token ?? null;
} else if (input.token === null || input.token === '') {
encryptedToken = null;
} else {
encryptedToken = this.crypto.encrypt(input.token);
}
// Apply-matrix sanity: auto_deploy requires auto_apply.
if (input.autoDeployOnApply && !input.autoApplyOnWebhook) {
throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.');
}
// Dry-run reachability check before persisting.
const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null;
await this.fetchFromGit({
repoUrl: input.repoUrl,
branch: input.branch,
composePath: input.composePath,
envPath: input.syncEnv ? input.envPath : null,
token,
});
db.upsertGitSource({
stack_name: input.stackName,
repo_url: input.repoUrl,
branch: input.branch,
compose_path: input.composePath,
sync_env: input.syncEnv,
env_path: input.syncEnv ? input.envPath : null,
auth_type: input.authType,
encrypted_token: encryptedToken,
auto_apply_on_webhook: input.autoApplyOnWebhook,
auto_deploy_on_apply: input.autoDeployOnApply,
last_applied_commit_sha: existing?.last_applied_commit_sha ?? null,
last_applied_content_hash: existing?.last_applied_content_hash ?? null,
pending_commit_sha: existing?.pending_commit_sha ?? null,
pending_compose_content: existing?.pending_compose_content ?? null,
pending_env_content: existing?.pending_env_content ?? null,
pending_fetched_at: existing?.pending_fetched_at ?? null,
last_debounce_at: existing?.last_debounce_at ?? null,
});
return this.get(input.stackName)!;
}
public delete(stackName: string): void {
DatabaseService.getInstance().deleteGitSource(stackName);
}
// ─── Fetch ───────────────────────────────────────────────────────────────
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
const { repoUrl, branch, composePath, envPath, token } = params;
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
const dir = await createTempDir();
// isomorphic-git's onAuth callback hands credentials to the HTTP
// layer without them touching the URL string, which keeps tokens
// out of any error messages generated during the clone.
const onAuth = token
? () => ({ username: 'x-access-token', password: token })
: undefined;
try {
// isomorphic-git does not natively accept an AbortSignal, so we
// wrap the clone in a Promise.race against a timeout rejection.
// The clone will keep running in the background until the socket
// resolves, but we will not block the caller indefinitely.
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' })),
timeoutMs,
);
});
try {
await Promise.race([
git.clone({
fs: { promises: fsPromises },
http: gitHttp,
dir,
url: repoUrl,
ref: branch,
singleBranch: true,
depth: 1,
noTags: true,
onAuth,
}),
timeout,
]);
} catch (e) {
throw this.mapGitError(e as Error);
} finally {
if (timer) clearTimeout(timer);
}
const log = await git.log({ fs: { promises: fsPromises }, dir, ref: branch, depth: 1 });
if (!log.length) {
throw new GitSourceError('GIT_ERROR', 'Repository has no commits on the requested branch.');
}
const commitSha = log[0].oid;
const composeAbs = path.resolve(dir, composePath);
if (!composeAbs.startsWith(path.resolve(dir))) {
throw new GitSourceError('FILE_NOT_FOUND', 'Compose path resolves outside the repository.');
}
let composeContent: string;
try {
composeContent = await fsPromises.readFile(composeAbs, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${composePath}`);
}
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
}
let envContent: string | null = null;
if (envPath) {
const envAbs = path.resolve(dir, envPath);
if (!envAbs.startsWith(path.resolve(dir))) {
throw new GitSourceError('FILE_NOT_FOUND', 'Env path resolves outside the repository.');
}
try {
envContent = await fsPromises.readFile(envAbs, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
// A missing sibling .env is legitimate (repo may not carry one
// in the requested directory). Return null so the caller can
// decide whether to warn.
envContent = null;
} else {
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
}
}
}
return { composeContent, envContent, commitSha };
} finally {
await removeTempDir(dir);
}
}
private mapGitError(err: Error): GitSourceError {
const raw = scrubCredentials(err.message || String(err));
const code = (err as Error & { code?: string }).code;
// isomorphic-git error codes
if (code === 'HttpError' || /401|403|authentication/i.test(raw)) {
return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.');
}
if (code === 'NotFoundError' || /404|not found|could not resolve/i.test(raw)) {
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found or not accessible.');
}
if (code === 'ResolveRefError' || /resolve ref|unknown ref|couldn't find remote ref|reference not found/i.test(raw)) {
return new GitSourceError('BRANCH_NOT_FOUND', 'Branch not found in the repository.');
}
if (code === 'ECONNABORTED' || /timeout|timed out|ETIMEDOUT|ENOTFOUND|ECONNREFUSED/i.test(raw)) {
return new GitSourceError('NETWORK_TIMEOUT', 'Network timeout or host unreachable.');
}
return new GitSourceError('GIT_ERROR', raw);
}
// ─── Validation ──────────────────────────────────────────────────────────
/**
* Validate a compose file by (a) parsing YAML and (b) handing the content
* to `docker compose config --quiet` in a throwaway temp dir. This is the
* same validator Compose runs at deploy time, so it catches interpolation
* errors, invalid `include:` references, etc., which a shallow schema
* check would miss.
*/
public async validateCompose(composeContent: string, envContent: string | null): Promise<{ ok: boolean; error?: string }> {
// Cheap syntax pre-check
try {
const parsed = YAML.parse(composeContent);
if (parsed === null || parsed === undefined) {
return { ok: false, error: 'Compose file is empty.' };
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, error: 'Compose file must be a YAML mapping.' };
}
} catch (e) {
return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
}
// Semantic check via `docker compose config`
const dir = await createTempDir();
try {
const composeFile = path.join(dir, 'compose.yaml');
await fsPromises.writeFile(composeFile, composeContent, 'utf-8');
const args = ['compose', '-f', composeFile];
if (envContent !== null) {
const envFile = path.join(dir, '.env');
await fsPromises.writeFile(envFile, envContent, 'utf-8');
args.push('--env-file', envFile);
}
args.push('config', '--quiet');
const result = await this.runDockerCompose(args, dir, 10_000);
if (result.code === 0) return { ok: true };
return { ok: false, error: result.stderr.trim() || `docker compose exited with code ${result.code}` };
} finally {
await removeTempDir(dir);
}
}
private runDockerCompose(args: string[], cwd: string, timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn('docker', args, { cwd });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* best effort */ }
resolve({ code: -1, stdout, stderr: stderr + '\nValidation timed out.' });
}, timeoutMs);
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('close', (code) => {
clearTimeout(timer);
resolve({ code: code ?? -1, stdout, stderr });
});
child.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, stdout, stderr: stderr + '\n' + err.message });
});
});
}
// ─── Hashing + diff ──────────────────────────────────────────────────────
public hashContent(compose: string, env: string | null): string {
return crypto.createHash('sha256')
.update(compose)
.update('\x00')
.update(env ?? '')
.digest('hex');
}
private async readDiskContent(stackName: string, syncEnv: boolean): Promise<{ compose: string; env: string | null }> {
const fsSvc = FileSystemService.getInstance();
let compose: string;
try {
compose = await fsSvc.getStackContent(stackName);
} catch {
compose = '';
}
let env: string | null = null;
if (syncEnv) {
try {
env = await fsSvc.getEnvContent(stackName);
} catch {
env = null;
}
}
return { compose, env };
}
// ─── Pull / apply ────────────────────────────────────────────────────────
public async pull(stackName: string): Promise<PullResult> {
const db = DatabaseService.getInstance();
const src = db.getGitSource(stackName);
if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.');
const token = src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null;
const fetched = await this.fetchFromGit({
repoUrl: src.repo_url,
branch: src.branch,
composePath: src.compose_path,
envPath: src.sync_env ? src.env_path : null,
token,
});
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
const disk = await this.readDiskContent(stackName, src.sync_env);
const currentHash = this.hashContent(disk.compose, disk.env);
const hasLocalChanges = src.last_applied_content_hash !== null
&& src.last_applied_content_hash !== currentHash;
// Store pending so a subsequent apply doesn't re-fetch. Compose files
// routinely contain secrets inlined as env interpolations or passwords,
// so encrypt the pending buffers at rest.
db.setGitSourcePending(
stackName,
fetched.commitSha,
this.crypto.encrypt(fetched.composeContent),
fetched.envContent !== null ? this.crypto.encrypt(fetched.envContent) : null,
);
return {
commitSha: fetched.commitSha,
incomingCompose: fetched.composeContent,
incomingEnv: fetched.envContent,
currentCompose: disk.compose,
currentEnv: disk.env,
validation,
hasLocalChanges,
};
}
/**
* Apply a pending pull. Idempotent under the per-stack mutex: if two
* clients hit /apply concurrently, the second one sees cleared pending
* columns and gets a clean error rather than double-writing.
*/
public async apply(stackName: string, commitSha: string, opts: { deploy?: boolean } = {}): Promise<{ applied: boolean; deployed: boolean }> {
return this.withStackLock(stackName, async () => {
const db = DatabaseService.getInstance();
const src = db.getGitSource(stackName);
if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.');
if (!src.pending_commit_sha || !src.pending_compose_content) {
throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.');
}
if (src.pending_commit_sha !== commitSha) {
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
}
// Pending buffers are stored encrypted; decrypt is a no-op for any
// legacy plaintext rows (isEncrypted check inside CryptoService).
const composeContent = this.crypto.decrypt(src.pending_compose_content);
const envContent = src.pending_env_content !== null
? this.crypto.decrypt(src.pending_env_content)
: null;
// Re-validate before writing.
const validation = await this.validateCompose(composeContent, envContent);
if (!validation.ok) {
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
}
const fsSvc = FileSystemService.getInstance();
await fsSvc.saveStackContent(stackName, composeContent);
if (src.sync_env && envContent !== null) {
await fsSvc.saveEnvContent(stackName, envContent);
}
const hash = this.hashContent(composeContent, envContent);
db.markGitSourceApplied(stackName, commitSha, hash);
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
if (shouldDeploy) {
try {
await ComposeService.getInstance().deployStack(stackName);
return { applied: true, deployed: true };
} catch (e) {
console.error(`[GitSourceService] Auto-deploy failed for ${stackName}:`, (e as Error).message);
throw new GitSourceError('GIT_ERROR', `Applied file but deploy failed: ${(e as Error).message}`);
}
}
return { applied: true, deployed: false };
});
}
public dismissPending(stackName: string): void {
DatabaseService.getInstance().clearGitSourcePending(stackName);
}
// ─── Webhook-triggered pull ──────────────────────────────────────────────
/**
* Invoked by the webhook dispatcher. Returns a short status string to
* record in webhook_executions. Enforces the per-source debounce.
*/
public async handleWebhookPull(stackName: string): Promise<{ status: 'success' | 'skipped' | 'error'; message: string }> {
const db = DatabaseService.getInstance();
const src = db.getGitSource(stackName);
if (!src) {
return { status: 'error', message: 'No Git source configured for this stack.' };
}
const now = Date.now();
if (src.last_debounce_at !== null && (now - src.last_debounce_at) < WEBHOOK_DEBOUNCE_MS) {
return { status: 'skipped', message: 'Rate limited (debounced).' };
}
try {
const pullResult = await this.pull(stackName);
// Only burn the debounce window once the fetch actually produced
// something. A transient network failure should be retriable
// immediately rather than locked out for the debounce interval.
db.touchGitSourceDebounce(stackName);
if (!pullResult.validation.ok) {
return { status: 'error', message: `Validation failed: ${pullResult.validation.error}` };
}
if (!src.auto_apply_on_webhook) {
return { status: 'success', message: `Pending update ready at ${pullResult.commitSha.slice(0, 7)}.` };
}
const applied = await this.apply(stackName, pullResult.commitSha, { deploy: src.auto_deploy_on_apply });
const suffix = applied.deployed ? ' and deployed' : '';
return { status: 'success', message: `Applied commit ${pullResult.commitSha.slice(0, 7)}${suffix}.` };
} catch (e) {
const msg = e instanceof GitSourceError ? `${e.code}: ${e.message}` : (e as Error).message;
return { status: 'error', message: scrubCredentials(msg) };
}
}
// ─── Concurrency ─────────────────────────────────────────────────────────
private async withStackLock<T>(stackName: string, fn: () => Promise<T>): Promise<T> {
const prev = this.stackLocks.get(stackName) ?? Promise.resolve();
const next = prev.catch(() => { /* swallow previous errors */ }).then(fn);
this.stackLocks.set(stackName, next);
try {
return await next;
} finally {
// Only clear if the current chain tip is still our promise; otherwise a
// later caller has already queued behind us.
if (this.stackLocks.get(stackName) === next) {
this.stackLocks.delete(stackName);
}
}
}
}
+27
View File
@@ -2,6 +2,7 @@ import crypto from 'crypto';
import { DatabaseService } from './DatabaseService';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { GitSourceService } from './GitSourceService';
import { NodeRegistry } from './NodeRegistry';
export class WebhookService {
@@ -76,6 +77,32 @@ export class WebhookService {
case 'pull':
await compose.updateStack(webhook.stack_name, undefined, atomic);
break;
case 'git-pull': {
const result = await GitSourceService.getInstance().handleWebhookPull(webhook.stack_name);
const duration_ms = Date.now() - startTime;
if (result.status === 'error') {
db.addWebhookExecution({
webhook_id: webhookId,
action,
status: 'failure',
trigger_source: triggerSource,
duration_ms,
error: result.message,
executed_at: Date.now(),
});
return { success: false, error: result.message, duration_ms };
}
db.addWebhookExecution({
webhook_id: webhookId,
action,
status: result.status === 'skipped' ? 'failure' : 'success',
trigger_source: triggerSource,
duration_ms,
error: result.status === 'skipped' ? result.message : null,
executed_at: Date.now(),
});
return { success: result.status === 'success', error: result.status === 'skipped' ? result.message : undefined, duration_ms };
}
default:
throw new Error(`Unknown action: ${action}`);
}