-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/git autodeploy #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8501dd3
3b73df9
dfb54e5
e6d0e69
052b3ab
b9d0a96
a3037ad
875eabd
d9bbe74
e925856
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { describe, it, expect, mock, beforeEach } from 'bun:test'; | ||
|
|
||
| const fileUrl = (relPath: string) => new URL(relPath, import.meta.url).toString(); | ||
|
|
||
| const tryRunCalls: { cmd: string; args: string[] }[] = []; | ||
| const mockTryRun = mock(async (cmd: string, args: string[]) => { | ||
| tryRunCalls.push({ cmd, args }); | ||
| }); | ||
| const mockRedisDel = mock(async () => {}); | ||
| const mockRedisLlLen = mock(async () => 0); | ||
| const mockRedisQuit = mock(async () => {}); | ||
|
|
||
| beforeEach(() => { | ||
| tryRunCalls.length = 0; | ||
| mockTryRun.mockClear(); | ||
| mockRedisDel.mockClear(); | ||
| mockRedisLlLen.mockClear(); | ||
| mockRedisQuit.mockClear(); | ||
| }); | ||
|
|
||
| mock.module(fileUrl('../runtime'), () => ({ | ||
| tryRun: mockTryRun, | ||
| })); | ||
|
|
||
| mock.module(fileUrl('../utils/config'), () => ({ | ||
|
|
||
| config: { | ||
| redisUrl: 'redis://localhost:6379', | ||
| }, | ||
| })); | ||
|
|
||
| mock.module(fileUrl('../utils/docker-bin'), () => ({ | ||
|
|
||
| dockerBin: '/usr/bin/docker', | ||
| })); | ||
|
|
||
| mock.module('ioredis', () => ({ | ||
| default: class FakeRedis { | ||
| llen = mockRedisLlLen; | ||
| del = mockRedisDel; | ||
| quit = mockRedisQuit; | ||
| }, | ||
| })); | ||
|
|
||
| const { pruneDocker, pruneDlq } = await import('../cleanup'); | ||
|
|
||
| describe('pruneDocker', () => { | ||
| beforeEach(() => { | ||
| tryRunCalls.length = 0; | ||
| }); | ||
|
|
||
| it('prunes containers with dequel label filter only', async () => { | ||
| await pruneDocker(); | ||
|
|
||
| const containerPrune = tryRunCalls.find( | ||
| c => c.args[0] === 'container' && c.args[1] === 'prune', | ||
| ); | ||
| expect(containerPrune).toBeDefined(); | ||
| expect(containerPrune!.args).toContain('-f'); | ||
| expect(containerPrune!.args).toContain('--filter'); | ||
| expect(containerPrune!.args.some(a => a.includes('com.dequel.managed'))).toBe(true); | ||
| }); | ||
|
|
||
| it('prunes dangling images without -a flag (preserves cache)', async () => { | ||
| await pruneDocker(); | ||
|
|
||
| const imagePrune = tryRunCalls.find( | ||
| c => c.args[0] === 'image' && c.args[1] === 'prune', | ||
| ); | ||
| expect(imagePrune).toBeDefined(); | ||
| expect(imagePrune!.args).not.toContain('-a'); | ||
|
|
||
| const buildxPrune = tryRunCalls.find( | ||
| c => c.args[0] === 'buildx' && c.args[1] === 'prune', | ||
| ); | ||
| expect(buildxPrune).toBeDefined(); | ||
| expect(buildxPrune!.args).not.toContain('-a'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('pruneDlq', () => { | ||
| it('does not need a Redis connection per tick (shared connection)', async () => { | ||
| await pruneDlq(); | ||
|
|
||
| expect(mockRedisLlLen).toHaveBeenCalled(); | ||
| expect(mockRedisQuit).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { describe, it, expect, mock, beforeEach } from 'bun:test'; | ||
|
|
||
| const fileUrl = (relPath: string) => new URL(relPath, import.meta.url).toString(); | ||
|
|
||
| let cleanupFailedDeploymentCalled = false; | ||
| let buildShouldFail = false; | ||
|
|
||
| beforeEach(() => { | ||
| cleanupFailedDeploymentCalled = false; | ||
| buildShouldFail = false; | ||
| }); | ||
|
|
||
| const mockDb = { | ||
| getDeploymentById: mock(() => Promise.resolve({ | ||
| id: 'dep-1', | ||
| projectId: 'proj-1', | ||
| sourceType: 'git', | ||
| sourceRef: 'https://github.com/test/repo.git', | ||
| branch: 'main', | ||
| status: 'pending', | ||
| commitSha: null, | ||
| clearCache: false, | ||
| environment: null, | ||
| imageTag: null, | ||
| containerName: null, | ||
| })), | ||
| getProjectById: mock(() => Promise.resolve({ | ||
| id: 'proj-1', | ||
| name: 'Test Project', | ||
| sourceDir: null, | ||
| port: 3000, | ||
| cpuLimit: null, | ||
| memoryLimitMb: null, | ||
| repoUrl: 'https://github.com/test/repo.git', | ||
| })), | ||
| updateDeploymentStatus: mock(() => Promise.resolve()), | ||
| updateDeploymentCommitSha: mock(() => Promise.resolve()), | ||
| appendLog: mock(async (_depId: string, stage: string, message: string) => { | ||
| if (stage === 'system' && message === 'Deployment is running') { | ||
| throw new Error('Simulated DB write failure'); | ||
| } | ||
| return { sequence: 1 }; | ||
| }), | ||
| listEnvironmentVariablesForDeploy: mock(() => Promise.resolve([])), | ||
| listVolumes: mock(() => Promise.resolve([])), | ||
| listDeployments: mock(() => Promise.resolve([])), | ||
| listAllDatabases: mock(() => Promise.resolve([])), | ||
| deleteDeploymentAndLogs: mock(() => Promise.resolve()), | ||
| getScalingPolicy: mock(() => Promise.resolve(null)), | ||
| updateDomainValidation: mock(() => Promise.resolve()), | ||
| listDomains: mock(() => Promise.resolve([])), | ||
| }; | ||
|
|
||
| mock.module(fileUrl('../../db/repo'), () => mockDb); | ||
|
|
||
| mock.module(fileUrl('../runtime'), () => ({ | ||
| deployContainer: mock(() => Promise.resolve({ | ||
| containerName: 'test-project-abc12345', | ||
| liveUrl: 'http://test-project.localhost', | ||
| })), | ||
| cleanupFailedDeployment: mock(async () => { | ||
| cleanupFailedDeploymentCalled = true; | ||
| }), | ||
| ensureContainerRunning: mock(() => Promise.resolve()), | ||
| reloadCaddy: mock(() => Promise.resolve()), | ||
| tryRun: mock(() => Promise.resolve('')), | ||
| })); | ||
|
|
||
| mock.module(fileUrl('../railpack'), () => ({ | ||
| buildWithRailpack: mock(async ( | ||
| _workspace: string, | ||
| _imageTag: string, | ||
| _onLog: (line: string) => Promise<void>, | ||
| _opts?: unknown, | ||
| ) => { | ||
| if (buildShouldFail) throw new Error('Build failed'); | ||
| }), | ||
| CancelledError: class CancelledError extends Error { | ||
| constructor() { super('Cancelled'); } | ||
| }, | ||
| })); | ||
|
|
||
| mock.module(fileUrl('../source'), () => ({ | ||
| prepareSourceWorkspace: mock(() => Promise.resolve('/tmp/workspace/dep-1')), | ||
| prepareUploadWorkspace: mock(() => Promise.resolve('/tmp/workspace/dep-1')), | ||
| cleanupWorkspace: mock(() => Promise.resolve()), | ||
| getHeadSha: mock(() => Promise.resolve('abc123def456')), | ||
| })); | ||
|
|
||
| mock.module(fileUrl('../../utils/grafana'), () => ({ | ||
| ensureProjectDashboard: mock(() => Promise.resolve()), | ||
| })); | ||
|
|
||
| mock.module('ioredis', () => ({ | ||
| default: class FakeRedis { | ||
| queue: string[] = []; | ||
| rpush = mock(async (_key: string, ...values: string[]) => { this.queue.push(...values); }); | ||
| blpop = mock(async () => { const v = this.queue.shift(); return v ? ['queue', v] : null; }); | ||
| lrem = mock(async () => {}); | ||
| zadd = mock(async () => {}); | ||
| zrem = mock(async () => {}); | ||
| zrangebyscore = mock(async () => []); | ||
| quit = mock(async () => {}); | ||
| }, | ||
| })); | ||
|
|
||
| const { PipelineOrchestrator } = await import('../pipeline'); | ||
|
|
||
| describe('runDeployment cleanup behavior', () => { | ||
| it('does NOT call cleanupFailedDeployment when failure occurs after deployContainer succeeds', async () => { | ||
| const orchestrator = new PipelineOrchestrator(); | ||
| await (orchestrator as any).runDeployment('dep-1'); | ||
|
|
||
| expect(cleanupFailedDeploymentCalled).toBe(false); | ||
| }); | ||
|
|
||
| it('calls cleanupFailedDeployment when failure occurs during build (before deployContainer)', async () => { | ||
| buildShouldFail = true; | ||
| const orchestrator = new PipelineOrchestrator(); | ||
| await (orchestrator as any).runDeployment('dep-1'); | ||
|
|
||
| expect(cleanupFailedDeploymentCalled).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||
| import Redis from 'ioredis'; | ||||||||||
| import { config } from '../utils/config'; | ||||||||||
| import { dockerBin } from '../utils/docker-bin'; | ||||||||||
| import { tryRun } from './runtime'; | ||||||||||
| import { DEQUEL_MANAGED_LABEL } from '../utils/dequel-labels'; | ||||||||||
|
|
||||||||||
| const DLQ_KEY = 'dequel:deploy:dlq'; | ||||||||||
| const GC_INTERVAL_MS = 1_800_000; | ||||||||||
|
|
||||||||||
| let interval: ReturnType<typeof setInterval> | null = null; | ||||||||||
| let redis: Redis | null = null; | ||||||||||
|
|
||||||||||
| const getRedis = () => { | ||||||||||
| if (!redis) redis = new Redis(config.redisUrl, { maxRetriesPerRequest: null }); | ||||||||||
| return redis; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const pruneDocker = async () => { | ||||||||||
| await tryRun(dockerBin, ['container', 'prune', '-f', '--filter', `label=${DEQUEL_MANAGED_LABEL}`]); | ||||||||||
| await tryRun(dockerBin, ['image', 'prune', '-f']); | ||||||||||
| await tryRun(dockerBin, ['buildx', 'prune', '-f']); | ||||||||||
|
Comment on lines
+20
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Avoid pruning intermediate build layers. Running Add a time filter (e.g., 🛠️ Proposed fix- await tryRun(dockerBin, ['image', 'prune', '-f']);
- await tryRun(dockerBin, ['buildx', 'prune', '-f']);
+ await tryRun(dockerBin, ['image', 'prune', '-f', '--filter', 'until=24h']);
+ await tryRun(dockerBin, ['buildx', 'prune', '-f', '--filter', 'until=24h']);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const pruneDlq = async () => { | ||||||||||
| try { | ||||||||||
| const r = getRedis(); | ||||||||||
| const size = await r.llen(DLQ_KEY); | ||||||||||
| if (size > 0) { | ||||||||||
| await r.del(DLQ_KEY); | ||||||||||
| console.log(`[Cleanup] Purged ${size} items from dead letter queue`); | ||||||||||
| } | ||||||||||
| } catch (e) { | ||||||||||
| console.warn('[Cleanup] DLQ prune failed:', e); | ||||||||||
| } | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const startBuildCleanup = () => { | ||||||||||
| if (interval) return; | ||||||||||
| console.log('[Cleanup] Docker garbage collector started (every 30min)'); | ||||||||||
| interval = setInterval(async () => { | ||||||||||
| await pruneDocker().catch(e => console.warn('[Cleanup] Docker prune failed:', e)); | ||||||||||
| await pruneDlq().catch(e => console.warn('[Cleanup] DLQ prune failed:', e)); | ||||||||||
| }, GC_INTERVAL_MS); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const stopBuildCleanup = () => { | ||||||||||
| if (interval) { clearInterval(interval); interval = null; } | ||||||||||
| if (redis) { redis.quit(); redis = null; } | ||||||||||
| }; | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Implement pagination to retrieve all repositories.
Fetching only the first page with
per_page=100limits the results. Users who own more than 100 repositories will not see their complete list. Consider iterating through the pages to fetch all available repositories.💡 Proposed fix with pagination
📝 Committable suggestion
🤖 Prompt for AI Agents