feat(git-sources): create a stack from a Git repository (#606)

* refactor(git-sources): extract GitSourceFields from GitSourcePanel

Pure extraction of the repo/branch/path/auth/apply-mode form fields into a
reusable controlled component so the upcoming Create Stack from Git flow can
render the same form in the Create Stack dialog. No behavior change.

* feat(git-sources): create a stack from a Git repository

Add a From Git tab to the Create Stack dialog so users can name a new
stack, point it at a repo + branch + compose path, and have the compose
fetched, validated, written to disk, and linked in one shot. Optional
deploy-after-create runs the initial bring-up when requested.

Backend: new POST /api/stacks/from-git route gated by stack:create.
GitSourceService.createStackFromGit() fetches and validates before
touching disk, then creates the stack, writes the compose (and .env if
sync is enabled), and seeds the git source row with the fetched commit
so future pulls produce a clean diff. Runs under the per-stack lock so
a concurrent webhook cannot race the create. Deploy failure is
non-fatal and surfaced to the caller.

Frontend: the existing Create Stack dialog is now tabbed, with Empty
keeping the original single-field flow unchanged.

* test(git-sources): cover create-from-git endpoint and e2e flow

Service tests verify createStackFromGit seeds the last_applied columns
on success, writes the env file when sync is enabled, refuses an
invalid apply-matrix without fetching, rejects invalid compose
without leaving orphan state, and rolls back the on-disk stack dir
when a post-create step fails.

Route tests cover auth, missing stack_name, invalid stack name,
http:// rejection, oversized repo_url, and the 409 collision guard.

E2E adds a Create-stack-from-Git block covering tab visibility,
client-side HTTPS check, backend .git/config rejection, and a
happy-path fetch against a public demo repo (skipped on network
failure).

* docs(git-sources): document create-stack-from-git tab

Add a new section near the top describing the From Git tab in the
Create Stack dialog: what it does, the Deploy after create checkbox,
and the four failure modes (name collision, unreachable repo,
invalid compose, deploy-after-create failure).
This commit is contained in:
Anso
2026-04-15 08:05:10 -04:00
committed by GitHub
parent a4ec11173b
commit 3955267bbe
10 changed files with 1006 additions and 152 deletions
@@ -165,6 +165,68 @@ describe('git-source routes — invalid stack names', () => {
});
});
describe('POST /api/stacks/from-git', () => {
const validBody = {
stack_name: 'route-from-git',
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
auth_type: 'none' as const,
};
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/stacks/from-git').send(validBody);
expect(res.status).toBe(401);
});
it('rejects missing stack_name with 400', async () => {
const { stack_name: _unused, ...body } = validBody;
void _unused;
const res = await request(app)
.post('/api/stacks/from-git')
.set('Authorization', `Bearer ${adminToken()}`)
.send(body);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/stack_name/i);
});
it('rejects invalid stack name with 400', async () => {
const res = await request(app)
.post('/api/stacks/from-git')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ ...validBody, stack_name: '../escape' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/stack name/i);
});
it('rejects http:// URLs with 400', async () => {
const res = await request(app)
.post('/api/stacks/from-git')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ ...validBody, repo_url: 'http://github.com/example/repo.git' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/HTTPS/i);
});
it('rejects oversized repo_url with 400', async () => {
const res = await request(app)
.post('/api/stacks/from-git')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ ...validBody, repo_url: 'https://example.com/' + 'a'.repeat(2048) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/repo_url/i);
});
it('returns 409 when a stack with that name already exists on disk', async () => {
const res = await request(app)
.post('/api/stacks/from-git')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ ...validBody, stack_name: 'existing-stack' });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/already exists/i);
});
});
describe('GET /api/git-sources', () => {
it('returns 200 and a JSON array for an authenticated admin', async () => {
const res = await request(app)
@@ -493,6 +493,155 @@ describe('GitSourceService.pull', () => {
});
});
describe('GitSourceService.createStackFromGit', () => {
async function cleanupStackDir(name: string) {
const { FileSystemService } = await import('../services/FileSystemService');
try {
await FileSystemService.getInstance().deleteStack(name);
} catch {
// directory may not exist; ignore
}
}
it('creates a stack on disk, writes compose, and seeds last_applied columns', async () => {
const sha = 'fedcba9876543210fedcba9876543210fedcba98';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
sha,
});
const svc = GitSourceService.getInstance();
const result = await svc.createStackFromGit({
stackName: 'create-happy',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.commitSha).toBe(sha);
expect(result.envWritten).toBe(false);
expect(result.source.last_applied_commit_sha).toBe(sha);
expect(result.source.pending_commit_sha).toBeNull();
const { FileSystemService } = await import('../services/FileSystemService');
const onDisk = await FileSystemService.getInstance().getStackContent('create-happy');
expect(onDisk).toContain('image: nginx');
await cleanupStackDir('create-happy');
});
it('writes the env file when sync_env is enabled', async () => {
const sha = '0101010101010101010101010101010101010101';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
env: 'FOO=bar\n',
envPath: '.env',
sha,
});
const svc = GitSourceService.getInstance();
const result = await svc.createStackFromGit({
stackName: 'create-env',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: true,
envPath: '.env',
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.envWritten).toBe(true);
const { FileSystemService } = await import('../services/FileSystemService');
const env = await FileSystemService.getInstance().getEnvContent('create-env');
expect(env).toBe('FOO=bar\n');
await cleanupStackDir('create-env');
});
it('rejects an invalid apply-matrix without fetching or writing disk', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-bad-matrix',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: true,
})).rejects.toBeInstanceOf(GitSourceError);
expect(mockGitClone).not.toHaveBeenCalled();
expect(DatabaseService.getInstance().getGitSource('create-bad-matrix')).toBeUndefined();
});
it('rejects when compose validation fails and leaves no stack/row behind', async () => {
mockSuccessfulClone({
// Non-mapping root is rejected by validateCompose() pre-check
compose: '- not-a-mapping\n',
});
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-bad-yaml',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
})).rejects.toMatchObject({ code: 'GIT_ERROR' });
expect(DatabaseService.getInstance().getGitSource('create-bad-yaml')).toBeUndefined();
const { FileSystemService } = await import('../services/FileSystemService');
const stacks = await FileSystemService.getInstance().getStacks();
expect(stacks).not.toContain('create-bad-yaml');
});
it('rolls back the stack dir when a post-create step fails', async () => {
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
});
const { FileSystemService } = await import('../services/FileSystemService');
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent')
.mockRejectedValueOnce(new Error('simulated disk failure'));
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-rollback',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePath: 'compose.yaml',
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
})).rejects.toThrow(/simulated disk failure/);
expect(DatabaseService.getInstance().getGitSource('create-rollback')).toBeUndefined();
const stacks = await FileSystemService.getInstance().getStacks();
expect(stacks).not.toContain('create-rollback');
saveSpy.mockRestore();
});
});
describe('GitSourceService.apply', () => {
async function seedPending(stackName: string, composeContent: string, commitSha: string) {
mockSuccessfulClone({ compose: composeContent, sha: commitSha });
+113
View File
@@ -3931,6 +3931,119 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
}
});
app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
try {
const {
stack_name,
repo_url,
branch,
compose_path,
sync_env,
env_path,
auth_type,
token,
auto_apply_on_webhook,
auto_deploy_on_apply,
deploy_now,
} = req.body ?? {};
if (typeof stack_name !== 'string' || !stack_name.trim()) {
return res.status(400).json({ error: 'stack_name is required' });
}
if (!isValidStackName(stack_name)) {
return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' });
}
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' });
}
const resolvedAuthType = auth_type === 'token' ? 'token' : 'none';
if (!/^https:\/\//i.test(repo_url)) {
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
}
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' });
}
// Reject if a stack with this name already exists on disk. Without this
// the service would catch it at createStack() time, but erroring early
// avoids spinning up a temp clone we will not use.
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
if (stacks.includes(stack_name)) {
return res.status(409).json({ error: 'Stack already exists' });
}
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 result = await GitSourceService.getInstance().createStackFromGit({
stackName: stack_name.trim(),
repoUrl: repo_url.trim(),
branch: branch.trim(),
composePath: compose_path.trim(),
syncEnv,
envPath: resolvedEnvPath,
authType: resolvedAuthType,
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
autoDeployOnApply: Boolean(auto_deploy_on_apply),
});
invalidateNodeCaches(req.nodeId);
// Deploy is best-effort. The compose file is already on disk and the
// git source is linked, so a deploy failure does not roll back the
// stack; the user can retry the deploy from the editor. This mirrors
// the apply-then-deploy behavior in GitSourceService.apply().
let deployed = false;
let deployError: string | undefined;
if (deploy_now === true) {
try {
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
deployed = true;
invalidateNodeCaches(req.nodeId);
} catch (e) {
deployError = getErrorMessage(e, 'Deploy failed');
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
}
}
console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`);
res.json({
name: stack_name,
source: result.source,
commitSha: result.commitSha,
envWritten: result.envWritten,
deployed,
deployError,
});
} catch (error) {
sendGitSourceError(res, error);
}
});
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
+130
View File
@@ -65,6 +65,25 @@ export interface UpsertInput {
autoDeployOnApply: boolean;
}
export interface CreateStackFromGitInput {
stackName: string;
repoUrl: string;
branch: string;
composePath: string;
syncEnv: boolean;
envPath: string | null;
authType: GitSourceAuthType;
token: string | null;
autoApplyOnWebhook: boolean;
autoDeployOnApply: boolean;
}
export interface CreateStackFromGitResult {
source: PublicGitSource;
commitSha: string;
envWritten: boolean;
}
export interface PullResult {
commitSha: string;
incomingCompose: string;
@@ -677,6 +696,117 @@ export class GitSourceService {
DatabaseService.getInstance().clearGitSourcePending(stackName);
}
// ─── Create stack from Git ───────────────────────────────────────────────
/**
* Fetch a compose file from a Git repository and use it to create a
* brand-new stack on disk + the matching git-source row. The caller is
* responsible for rolling back (deleteStack + deleteGitSource) if a
* later step such as an optional deploy fails; this method itself will
* undo its own partial state if anything *before* the DB insert fails.
*
* Serialized under the same per-stack mutex as pull/apply so a racing
* webhook cannot collide with a fresh create.
*/
public async createStackFromGit(input: CreateStackFromGitInput): Promise<CreateStackFromGitResult> {
return this.withStackLock(input.stackName, async () => {
const fsSvc = FileSystemService.getInstance();
const db = DatabaseService.getInstance();
const diag = isDebugEnabled();
if (input.autoDeployOnApply && !input.autoApplyOnWebhook) {
throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.');
}
// 1. Fetch from git BEFORE touching disk or DB. If the fetch
// fails there is nothing to clean up.
const fetched = await this.fetchFromGit({
repoUrl: input.repoUrl,
branch: input.branch,
composePath: input.composePath,
envPath: input.syncEnv ? input.envPath : null,
token: input.token,
});
// 2. Validate against the same `docker compose config` check the
// apply path uses. Reject before creating anything on disk.
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
if (!validation.ok) {
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
}
// 3. Create directory + boilerplate, then overwrite with the
// fetched content. createStack() throws if the directory
// already exists, so a name collision is caught here.
let stackCreated = false;
try {
await fsSvc.createStack(input.stackName);
stackCreated = true;
await fsSvc.saveStackContent(input.stackName, fetched.composeContent);
let envWritten = false;
if (input.syncEnv && fetched.envContent !== null) {
await fsSvc.saveEnvContent(input.stackName, fetched.envContent);
envWritten = true;
}
// 4. Insert the git-source row, then mark it applied so future
// pulls diff against the fetched commit rather than treating
// it as "local edits detected".
const encryptedToken = input.authType === 'token' && input.token
? this.crypto.encrypt(input.token)
: null;
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: fetched.commitSha,
last_applied_content_hash: this.hashContent(fetched.composeContent, fetched.envContent),
pending_commit_sha: null,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
db.markGitSourceApplied(
input.stackName,
fetched.commitSha,
this.hashContent(fetched.composeContent, fetched.envContent),
);
const source = this.get(input.stackName);
if (!source) {
throw new GitSourceError('GIT_ERROR', 'Failed to read back created git source.');
}
console.log(`[GitSource] Created stack ${input.stackName} from ${repoHost(input.repoUrl)} at ${fetched.commitSha.slice(0, 7)}`);
if (diag) {
console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten}`);
}
return { source, commitSha: fetched.commitSha, envWritten };
} catch (e) {
// Roll back any partial on-disk state so the caller can retry
// cleanly. The DB row is only inserted at step 4, so an error
// earlier leaves nothing to clean in the DB.
if (stackCreated) {
try {
await fsSvc.deleteStack(input.stackName);
} catch (cleanupErr) {
console.error(`[GitSource] Rollback: failed to remove partial stack dir ${input.stackName}:`, cleanupErr);
}
}
db.deleteGitSource(input.stackName);
throw e;
}
});
}
// ─── Webhook-triggered pull ──────────────────────────────────────────────
/**