fix(git-sources): harden validation, RBAC, concurrency, and deploy recovery (#603)

* fix(git-sources): harden validation, RBAC, concurrency, and deploy recovery

Tightens the surface area around the Git source feature:

- Enforce HTTPS-only repo URLs server-side (regex was permissive).
- Add stack:read permission check on git-source reads and filter the
  list endpoint by callable permission.
- Validate stack names before permission checks on mutation routes so
  scoped lookups never see unvalidated input.
- Cap repo_url / branch / compose_path / env_path / token lengths and
  require the stack directory to exist before upsert.
- Wrap pull() in the per-stack mutex to eliminate the pull/delete race
  that could orphan pending data.
- Block .git/ path components in compose_path / env_path so a
  misconfigured clone cannot leak repo metadata.
- Return {applied, deployed, deployError?} on deploy failure instead of
  throwing, and surface deployError as a warning toast so the user can
  retry deploy without re-pulling.
- Always clean the stack_git_sources row on stack delete even when the
  file deletion step fails.
- Add shadow-card-bevel to the pending alert and metadata card per the
  design system.
- Handle the new 403 response on the panel fetch gracefully.
- Add diagnostic logging gated on developer_mode (isDebugEnabled) across
  fetch / pull / apply / webhook paths with credential scrubbing.

* test(git-sources): expand coverage for hardening and route validation

- New route-level suite covers HTTPS enforcement, required fields,
  max-length caps on repo_url / branch / compose_path / env_path /
  token, the stack-existence 404 guard, and GET authz.
- Service tests cover the .git metadata guard on compose and env
  paths (including nested and substring-containing "git"), pull and
  apply rejections when no source is configured or pending is
  cleared, the sha-mismatch branch, and the deploy-failure return
  shape that now carries deployError.
- E2E adds three server-side contract assertions: PUT against a
  missing stack returns 404, http:// is rejected with 400, and
  .git/config is rejected as compose_path.

* docs(git-sources): document deploy-failure recovery path

Adds a Troubleshooting entry explaining that when apply succeeds but
the subsequent deploy fails, the compose content is already on disk
and the user can retry deploy from the stack editor without
re-pulling.

* docs(git-sources): add configuration, diff, pending, and webhook screenshots
This commit is contained in:
Anso
2026-04-14 22:32:42 -04:00
committed by GitHub
parent 789437cc46
commit 00901cf5bf
11 changed files with 575 additions and 59 deletions
@@ -0,0 +1,181 @@
/**
* Route-layer tests for the git-source API.
*
* Covers input-validation and guard behavior that lives in the Express
* handlers (not in GitSourceService), specifically:
* - HTTPS-only repo URL enforcement
* - Max-length caps on repo_url / branch / compose_path / env_path / token
* - Stack-existence 404 guard on PUT
* - 400 on invalid stack names
*
* Service-layer logic (encryption, error mapping, mutex, pending lifecycle)
* is covered in git-source-service.test.ts.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
function adminToken(): string {
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
// Seed a real stack directory so the PUT handler's existence guard is satisfied
// for tests that need to exercise validation past that point.
const composeDir = process.env.COMPOSE_DIR!;
fs.mkdirSync(path.join(composeDir, 'existing-stack'), { recursive: true });
fs.writeFileSync(path.join(composeDir, 'existing-stack', 'compose.yaml'), 'services:\n x:\n image: nginx\n');
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('PUT /api/stacks/:stackName/git-source — URL validation', () => {
it('rejects http:// URLs with 400', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'http://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/HTTPS/i);
});
it('rejects missing repo_url with 400', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
branch: 'main',
compose_path: 'compose.yaml',
auth_type: 'none',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/repo_url/i);
});
});
describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => {
const baseBody = {
branch: 'main',
compose_path: 'compose.yaml',
auth_type: 'none' as const,
};
it('rejects oversized repo_url', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ ...baseBody, repo_url: 'https://example.com/' + 'a'.repeat(2048) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/repo_url/i);
});
it('rejects oversized branch', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
...baseBody,
repo_url: 'https://github.com/example/repo.git',
branch: 'b'.repeat(300),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/branch/i);
});
it('rejects oversized compose_path', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
...baseBody,
repo_url: 'https://github.com/example/repo.git',
compose_path: 'c'.repeat(1100),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/compose_path/i);
});
it('rejects oversized env_path', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
...baseBody,
repo_url: 'https://github.com/example/repo.git',
env_path: 'e'.repeat(1100),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/env_path/i);
});
it('rejects oversized token', async () => {
const res = await request(app)
.put('/api/stacks/existing-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
...baseBody,
repo_url: 'https://github.com/example/repo.git',
auth_type: 'token',
token: 't'.repeat(9000),
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/token/i);
});
});
describe('PUT /api/stacks/:stackName/git-source — stack existence guard', () => {
it('returns 404 when the stack does not exist on the active node', async () => {
const res = await request(app)
.put('/api/stacks/ghost-stack/git-source')
.set('Authorization', `Bearer ${adminToken()}`)
.send({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
auth_type: 'none',
});
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/stack not found/i);
});
});
describe('git-source routes — invalid stack names', () => {
it('returns 400 for traversal attempts on GET per-stack', async () => {
const res = await request(app)
.get('/api/stacks/..%2fescape/git-source')
.set('Authorization', `Bearer ${adminToken()}`);
// URL-decoded name `../escape` fails isValidStackName.
expect([400, 404]).toContain(res.status);
});
});
describe('GET /api/git-sources', () => {
it('returns 200 and a JSON array for an authenticated admin', async () => {
const res = await request(app)
.get('/api/git-sources')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 401 without a valid token', async () => {
const res = await request(app).get('/api/git-sources');
expect(res.status).toBe(401);
});
});
@@ -446,3 +446,110 @@ describe('GitSourceService per-stack mutex', () => {
expect(order.indexOf('beta:end')).toBeLessThan(order.indexOf('alpha:end'));
});
});
describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
const svc = () => GitSourceService.getInstance();
it('rejects compose paths that target the .git directory', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: '.git/config',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
expect(mockGitClone).not.toHaveBeenCalled();
});
it('rejects nested .git paths', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'subdir/.git/HEAD',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('rejects env paths that target the .git directory', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
envPath: '.git/config',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('allows paths that merely contain the substring "git"', async () => {
mockSuccessfulClone({ composePath: 'gitops.yaml' });
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'gitops.yaml',
})).resolves.toBeDefined();
});
});
describe('GitSourceService.pull', () => {
it('rejects when no Git source is configured for the stack', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.pull('does-not-exist')).rejects.toMatchObject({ code: 'GIT_ERROR' });
});
});
describe('GitSourceService.apply', () => {
async function seedPending(stackName: string, composeContent: string, commitSha: string) {
mockSuccessfulClone({ compose: composeContent, sha: commitSha });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName,
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
await svc.pull(stackName);
return svc;
}
it('throws when pending has been cleared between pull and apply', async () => {
const svc = await seedPending('apply-cleared', 'services:\n x:\n image: alpine\n', 'aaaa111aaaa111aaaa111aaaa111aaaa111aaaa1');
DatabaseService.getInstance().clearGitSourcePending('apply-cleared');
await expect(svc.apply('apply-cleared', 'aaaa111aaaa111aaaa111aaaa111aaaa111aaaa1'))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/no pending pull/i) });
});
it('throws when the commit sha does not match the pending sha', async () => {
const svc = await seedPending('apply-mismatch', 'services:\n x:\n image: alpine\n', 'bbbb222bbbb222bbbb222bbbb222bbbb222bbbb2');
await expect(svc.apply('apply-mismatch', 'deadbeef1234567890deadbeef1234567890dead'))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/pending commit has changed/i) });
});
it('returns deployError when the deploy step fails after writing to disk', async () => {
const sha = 'cccc333cccc333cccc333cccc333cccc333cccc3';
const svc = await seedPending('apply-deploy-fail', 'services:\n x:\n image: alpine\n', sha);
// Stub validation (docker compose config is expensive and not needed here)
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
// Stub file write (FileSystemService expects a real stack dir)
const { FileSystemService } = await import('../services/FileSystemService');
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
// Deploy will fail organically (docker CLI unavailable in the test env).
// We only assert the return SHAPE: apply must not throw, deployError must
// carry the failure detail so the UI can surface "applied but not deployed".
const result = await svc.apply('apply-deploy-fail', sha, { deploy: true });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toBeTruthy();
// Disk write happened; DB was marked applied even though deploy failed.
expect(saveSpy).toHaveBeenCalled();
const row = DatabaseService.getInstance().getGitSource('apply-deploy-fail');
expect(row?.last_applied_commit_sha).toBe(sha);
expect(row?.pending_commit_sha).toBeNull();
validateSpy.mockRestore();
saveSpy.mockRestore();
});
});
+61 -15
View File
@@ -3717,10 +3717,13 @@ function sendGitSourceError(res: Response, err: unknown): void {
res.status(500).json({ error: 'Git source operation failed' });
}
app.get('/api/git-sources', async (_req: Request, res: Response) => {
app.get('/api/git-sources', async (req: Request, res: Response) => {
try {
const sources = GitSourceService.getInstance().list();
res.json(sources);
const all = GitSourceService.getInstance().list();
// Filter to the subset of stacks the caller can read. Keeps scoped
// Admiral roles from discovering git config for stacks outside their grant.
const visible = all.filter(src => checkPermission(req, 'stack:read', 'stack', src.stack_name));
res.json(visible);
} catch (error) {
sendGitSourceError(res, error);
}
@@ -3731,6 +3734,7 @@ app.get('/api/stacks/:stackName/git-source', async (req: Request, res: Response)
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const source = GitSourceService.getInstance().get(stackName);
if (!source) return res.status(404).json({ error: 'No Git source configured for this stack' });
@@ -3742,10 +3746,10 @@ app.get('/api/stacks/:stackName/git-source', async (req: Request, res: Response)
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' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const {
repo_url,
@@ -3771,9 +3775,36 @@ app.put('/api/stacks/:stackName/git-source', async (req: Request, res: Response)
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)) {
if (!/^https:\/\//i.test(repo_url)) {
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
}
// Bound each field so a caller cannot flood the service with huge
// payloads. These limits are generous compared to anything a real Git
// provider would produce.
if (repo_url.length > 2048) {
return res.status(400).json({ error: 'repo_url is too long' });
}
if (branch.length > 256) {
return res.status(400).json({ error: 'branch is too long' });
}
if (compose_path.length > 1024) {
return res.status(400).json({ error: 'compose_path is too long' });
}
if (typeof env_path === 'string' && env_path.length > 1024) {
return res.status(400).json({ error: 'env_path is too long' });
}
if (typeof token === 'string' && token.length > 8192) {
return res.status(400).json({ error: 'token is too long' });
}
// Confirm the stack actually exists on the active node. Without this
// guard, a caller can stash a git-source row for a name that does not
// exist yet and have it auto-link when a stack with that name is
// later created.
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
if (!stacks.includes(stackName)) {
return res.status(404).json({ error: 'Stack not found' });
}
const syncEnv = Boolean(sync_env);
const resolvedEnvPath = syncEnv
@@ -3804,10 +3835,10 @@ app.put('/api/stacks/:stackName/git-source', async (req: Request, res: Response)
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' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
GitSourceService.getInstance().delete(stackName);
console.log(`[GitSource] Removed git source for ${stackName}`);
@@ -3819,10 +3850,10 @@ app.delete('/api/stacks/:stackName/git-source', async (req: Request, res: Respon
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' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const result = await GitSourceService.getInstance().pull(stackName);
res.json(result);
@@ -3833,10 +3864,10 @@ app.post('/api/stacks/:stackName/git-source/pull', async (req: Request, res: Res
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' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const { commitSha, deploy } = req.body ?? {};
if (typeof commitSha !== 'string' || !commitSha.trim()) {
@@ -3848,7 +3879,14 @@ app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Re
{ deploy: typeof deploy === 'boolean' ? deploy : undefined }
);
invalidateNodeCaches(req.nodeId);
console.log(`[GitSource] Applied commit ${commitSha.trim().slice(0, 7)} to ${stackName}${result.deployed ? ' (deployed)' : ''}`);
const shortSha = commitSha.trim().slice(0, 7);
if (result.deployed) {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName} (deployed)`);
} else if (result.deployError) {
console.warn(`[GitSource] Applied commit ${shortSha} to ${stackName}, deploy failed: ${result.deployError}`);
} else {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
}
res.json(result);
} catch (error) {
sendGitSourceError(res, error);
@@ -3857,10 +3895,10 @@ app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Re
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' });
}
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
GitSourceService.getInstance().dismissPending(stackName);
res.json({ success: true });
@@ -3907,16 +3945,24 @@ app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
// Stage 2: Obliterate the files
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
// Stage 2: Obliterate the files. Capture failure so Stage 3 still runs.
let fsErr: unknown = null;
try {
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
} catch (err) {
fsErr = err;
console.error(`[Stacks] File deletion failed for ${stackName}, continuing with DB cleanup:`, err);
}
// Prevent stale update badges for deleted stacks
// Stage 3: DB cleanup. Runs unconditionally because an orphan git_source
// row would silently auto-link to a future stack with the same name, and
// stale scoped role assignments / update badges confuse the dashboard.
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);
if (fsErr) throw fsErr;
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${stackName}`);
res.json({ success: true });
+146 -39
View File
@@ -10,6 +10,7 @@ import { CryptoService } from './CryptoService';
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import { isDebugEnabled } from '../utils/debug';
/**
* GitSourceService - fetch compose files from a Git repository and apply
@@ -117,6 +118,35 @@ function scrubCredentials(message: string): string {
.replace(/(password[:=]\s*)[^\s,;]+/gi, '$1***');
}
/**
* Extract just the hostname for log lines so we never echo a full
* repo URL that could contain an inline credential. Falls back to
* `unknown` for malformed URLs.
*/
function repoHost(url: string): string {
try {
return new URL(url).host || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Reject any relative path that resolves into the `.git` metadata
* directory. The path-traversal check in `fetchFromGit` already bounds
* paths to the clone dir, but without this guard a caller could still
* target `.git/config` (remote URL, potentially mis-configured inline
* credentials) via a path that stays inside the clone. Matches on any
* `.git` segment, case-insensitive, so `.GIT/config` is also rejected.
*/
function assertNotGitMeta(relPath: string, fieldName: string): void {
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
const segments = normalized.split('/').filter(Boolean);
if (segments.some(seg => seg === '.git')) {
throw new GitSourceError('FILE_NOT_FOUND', `${fieldName} cannot target the .git metadata directory.`);
}
}
// ─── Temp dir helpers ────────────────────────────────────────────────────────
async function createTempDir(): Promise<string> {
@@ -276,7 +306,22 @@ export class GitSourceService {
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
const { repoUrl, branch, composePath, envPath, token } = params;
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
// Reject any compose/env target that resolves inside the `.git`
// metadata directory BEFORE we spin up a clone. This blocks a
// caller from reading `.git/config` (which leaks the remote URL
// and any mis-configured inline credentials) via the fetch path.
assertNotGitMeta(composePath, 'compose_path');
if (envPath) assertNotGitMeta(envPath, 'env_path');
const dir = await createTempDir();
const startedAt = Date.now();
const diag = isDebugEnabled();
if (diag) {
console.log(
`[GitSource:diag] fetch start host=${repoHost(repoUrl)} branch=${branch} compose=${composePath} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
);
}
// isomorphic-git's onAuth callback hands credentials to the HTTP
// layer without them touching the URL string, which keeps tokens
@@ -358,7 +403,20 @@ export class GitSourceService {
}
}
if (diag) {
console.log(
`[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} elapsedMs=${Date.now() - startedAt}`
);
}
return { composeContent, envContent, commitSha };
} catch (err) {
if (diag) {
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
console.log(
`[GitSource:diag] fetch fail host=${repoHost(repoUrl)} branch=${branch} elapsedMs=${Date.now() - startedAt} err=${scrubCredentials(msg)}`
);
}
throw err;
} finally {
await removeTempDir(dir);
}
@@ -481,53 +539,83 @@ export class GitSourceService {
// ─── 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.');
// Guarded by the same per-stack mutex as apply(). Without this, a
// concurrent delete-source + pull can land a pending row on a
// stack whose config row has just been removed; the DELETE clears
// the row but the subsequent setGitSourcePending re-inserts via
// UPDATE failing silently (no row), and a later upsert would
// inherit stale pending columns on read.
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.');
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 diag = isDebugEnabled();
if (diag) {
console.log(`[GitSource:diag] pull start stack=${stackName} branch=${src.branch} host=${repoHost(src.repo_url)}`);
}
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,
);
console.log(`[GitSource] Pending update ready for ${stackName} at ${fetched.commitSha.slice(0, 7)} (validation=${validation.ok ? 'ok' : 'fail'}, localEdits=${hasLocalChanges})`);
if (diag) {
console.log(`[GitSource:diag] pull done stack=${stackName} sha=${fetched.commitSha.slice(0, 7)} validation=${validation.ok} localEdits=${hasLocalChanges}`);
}
return {
commitSha: fetched.commitSha,
incomingCompose: fetched.composeContent,
incomingEnv: fetched.envContent,
currentCompose: disk.compose,
currentEnv: disk.env,
validation,
hasLocalChanges,
};
});
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.
*
* Deploy failure policy: once the compose file has been written to
* disk, we never throw. Instead we return `deployed: false,
* deployError: <message>` so the UI can clearly show "applied, but
* deploy failed" and the caller can retry the deploy without having
* to re-pull. Throwing here would leave the user with a changed disk
* file and a confusing "apply failed" error message.
*/
public async apply(stackName: string, commitSha: string, opts: { deploy?: boolean } = {}): Promise<{ applied: boolean; deployed: boolean }> {
public async apply(
stackName: string,
commitSha: string,
opts: { deploy?: boolean } = {},
): Promise<{ applied: boolean; deployed: boolean; deployError?: string }> {
return this.withStackLock(stackName, async () => {
const diag = isDebugEnabled();
const db = DatabaseService.getInstance();
const src = db.getGitSource(stackName);
if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.');
@@ -536,6 +624,7 @@ export class GitSourceService {
throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.');
}
if (src.pending_commit_sha !== commitSha) {
if (diag) console.log(`[GitSource:diag] apply sha mismatch stack=${stackName} expected=${commitSha.slice(0, 7)} pending=${src.pending_commit_sha.slice(0, 7)}`);
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
}
@@ -549,6 +638,7 @@ export class GitSourceService {
// Re-validate before writing.
const validation = await this.validateCompose(composeContent, envContent);
if (!validation.ok) {
if (diag) console.log(`[GitSource:diag] apply validation fail stack=${stackName}`);
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
}
@@ -562,15 +652,23 @@ export class GitSourceService {
db.markGitSourceApplied(stackName, commitSha, hash);
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
if (diag) console.log(`[GitSource:diag] apply wrote stack=${stackName} sha=${commitSha.slice(0, 7)} deploy=${shouldDeploy}`);
if (shouldDeploy) {
try {
await ComposeService.getInstance().deployStack(stackName);
console.log(`[GitSource] Applied and deployed ${stackName} at ${commitSha.slice(0, 7)}`);
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}`);
// File is on disk, DB is marked applied. Returning the
// error separately lets the UI flag it as a partial
// success rather than rolling back the disk.
const scrubbed = scrubCredentials((e as Error).message || String(e));
console.error(`[GitSource] Auto-deploy failed for ${stackName}: ${scrubbed}`);
return { applied: true, deployed: false, deployError: scrubbed };
}
}
console.log(`[GitSource] Applied ${stackName} at ${commitSha.slice(0, 7)}`);
return { applied: true, deployed: false };
});
}
@@ -586,6 +684,7 @@ export class GitSourceService {
* record in webhook_executions. Enforces the per-source debounce.
*/
public async handleWebhookPull(stackName: string): Promise<{ status: 'success' | 'skipped' | 'error'; message: string }> {
const diag = isDebugEnabled();
const db = DatabaseService.getInstance();
const src = db.getGitSource(stackName);
if (!src) {
@@ -594,6 +693,7 @@ export class GitSourceService {
const now = Date.now();
if (src.last_debounce_at !== null && (now - src.last_debounce_at) < WEBHOOK_DEBOUNCE_MS) {
if (diag) console.log(`[GitSource:diag] webhook debounced stack=${stackName} age=${now - src.last_debounce_at}ms`);
return { status: 'skipped', message: 'Rate limited (debounced).' };
}
@@ -608,10 +708,17 @@ export class GitSourceService {
}
if (!src.auto_apply_on_webhook) {
if (diag) console.log(`[GitSource:diag] webhook pending-only stack=${stackName} sha=${pullResult.commitSha.slice(0, 7)}`);
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 });
if (applied.deployError) {
// Apply wrote to disk but deploy failed. Surface it so the
// webhook_executions row records a degraded outcome instead
// of a clean success.
return { status: 'error', message: `Applied commit ${pullResult.commitSha.slice(0, 7)} but deploy failed: ${applied.deployError}` };
}
const suffix = applied.deployed ? ' and deployed' : '';
return { status: 'success', message: `Applied commit ${pullResult.commitSha.slice(0, 7)}${suffix}.` };
} catch (e) {
+4
View File
@@ -153,6 +153,10 @@ The in-browser editor and the Git Source panel both write to the same files, so
<Accordion title="Network timeout">
The clone did not finish in time. Check that the Sencho host can reach the repository host (proxies, firewalls, DNS) and try again.
</Accordion>
<Accordion title="Applied but deploy failed">
The incoming compose file was written to disk successfully, but the subsequent `docker compose up -d` did not complete. The toast message includes the underlying reason (for example, an image pull failure or a port conflict). The stack is already on the new content, so you can retry the deploy directly from the editor's **Deploy** button without re-pulling. Fix the root cause first (image availability, host resources, network config) and redeploy.
</Accordion>
</AccordionGroup>
<Note>
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+64
View File
@@ -92,6 +92,70 @@ test.describe('Git Sources', () => {
).toBeVisible({ timeout: 15_000 });
});
test('PUT against a non-existent stack returns 404', async ({ page }) => {
const status = await page.evaluate(async () => {
const res = await fetch(`/api/stacks/nonexistent-ghost-stack/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
});
expect(status).toBe(404);
});
test('backend rejects http:// URLs on PUT with 400', async ({ page }) => {
const status = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'http://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, TEST_STACK);
expect(status).toBe(400);
});
test('backend rejects .git/config as compose_path', async ({ page }) => {
const body = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: '.git/config',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}, TEST_STACK);
expect(body.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(body.body)).toMatch(/\.git|file/i);
});
test('configure, view pending-empty state, and remove via AlertDialog', async ({ page }) => {
// Seed a git source directly via API so we can exercise the remove-confirm
// flow without depending on a reachable upstream.
@@ -112,6 +112,9 @@ export function GitSourcePanel({
setAuthType('none');
setToken('');
setApplyModeOverride(null);
} else if (res.status === 403) {
setSource(null);
toast.error('You do not have permission to view this stack\'s Git source.');
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to load Git source.');
@@ -134,7 +137,7 @@ export function GitSourcePanel({
toast.error('Repository URL, branch, and compose path are required.');
return;
}
if (!/^https?:\/\//i.test(repoUrl.trim())) {
if (!/^https:\/\//i.test(repoUrl.trim())) {
toast.error('Only HTTPS repository URLs are supported.');
return;
}
@@ -235,8 +238,12 @@ export function GitSourcePanel({
body: JSON.stringify({ commitSha, deploy }),
});
if (res.ok) {
const data: { applied: boolean; deployed: boolean } = await res.json();
toast.success(data.deployed ? 'Applied and deployed.' : 'Applied successfully.');
const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json();
if (data.deployError) {
toast.warning(`Applied, but deploy failed: ${data.deployError}`);
} else {
toast.success(data.deployed ? 'Applied and deployed.' : 'Applied successfully.');
}
setDiffOpen(false);
setPull(null);
await load();
@@ -323,7 +330,7 @@ export function GitSourcePanel({
) : (
<>
{source?.pending_commit_sha && (
<div className="flex items-start gap-2 rounded-md border border-brand/30 bg-brand/5 px-3 py-2 text-xs">
<div className="flex items-start gap-2 rounded-md border border-brand/30 bg-brand/5 px-3 py-2 text-xs shadow-card-bevel">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-brand" strokeWidth={1.5} />
<div className="flex-1">
<p className="font-medium">Pending update</p>
@@ -450,7 +457,7 @@ export function GitSourcePanel({
</div>
{source && (
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5">
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5 shadow-card-bevel">
<div className="flex justify-between gap-2">
<span>Last applied commit</span>
<span className="font-mono tabular-nums">