mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
377df7e546
* 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.
142 lines
5.6 KiB
TypeScript
142 lines
5.6 KiB
TypeScript
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 {
|
|
private static instance: WebhookService;
|
|
|
|
public static getInstance(): WebhookService {
|
|
if (!WebhookService.instance) {
|
|
WebhookService.instance = new WebhookService();
|
|
}
|
|
return WebhookService.instance;
|
|
}
|
|
|
|
public generateSecret(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
public validateSignature(payload: string, secret: string, signature: string): boolean {
|
|
// Expect format: sha256=<hex>
|
|
const parts = signature.split('=');
|
|
if (parts.length !== 2 || parts[0] !== 'sha256') return false;
|
|
|
|
const expected = crypto
|
|
.createHmac('sha256', secret)
|
|
.update(payload)
|
|
.digest('hex');
|
|
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(expected, 'hex'),
|
|
Buffer.from(parts[1], 'hex')
|
|
);
|
|
}
|
|
|
|
public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> {
|
|
const db = DatabaseService.getInstance();
|
|
const webhook = db.getWebhook(webhookId);
|
|
if (!webhook) throw new Error('Webhook not found');
|
|
|
|
const defaultNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
|
|
// Validate the stack still exists
|
|
const stacks = await FileSystemService.getInstance(defaultNodeId).getStacks();
|
|
if (!stacks.includes(webhook.stack_name)) {
|
|
const error = `Stack "${webhook.stack_name}" not found`;
|
|
db.addWebhookExecution({
|
|
webhook_id: webhookId,
|
|
action,
|
|
status: 'failure',
|
|
trigger_source: triggerSource,
|
|
duration_ms: 0,
|
|
error,
|
|
executed_at: Date.now(),
|
|
});
|
|
return { success: false, error, duration_ms: 0 };
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
try {
|
|
const compose = ComposeService.getInstance(defaultNodeId);
|
|
switch (action) {
|
|
case 'deploy':
|
|
await compose.deployStack(webhook.stack_name, undefined, atomic);
|
|
break;
|
|
case 'restart':
|
|
await compose.runCommand(webhook.stack_name, 'restart');
|
|
break;
|
|
case 'stop':
|
|
await compose.runCommand(webhook.stack_name, 'stop');
|
|
break;
|
|
case 'start':
|
|
await compose.runCommand(webhook.stack_name, 'start');
|
|
break;
|
|
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}`);
|
|
}
|
|
|
|
const duration_ms = Date.now() - startTime;
|
|
db.addWebhookExecution({
|
|
webhook_id: webhookId,
|
|
action,
|
|
status: 'success',
|
|
trigger_source: triggerSource,
|
|
duration_ms,
|
|
error: null,
|
|
executed_at: Date.now(),
|
|
});
|
|
return { success: true, duration_ms };
|
|
} catch (err) {
|
|
const duration_ms = Date.now() - startTime;
|
|
const error = (err as Error).message || 'Unknown error';
|
|
db.addWebhookExecution({
|
|
webhook_id: webhookId,
|
|
action,
|
|
status: 'failure',
|
|
trigger_source: triggerSource,
|
|
duration_ms,
|
|
error,
|
|
executed_at: Date.now(),
|
|
});
|
|
return { success: false, error, duration_ms };
|
|
}
|
|
}
|
|
|
|
public maskSecret(secret: string): string {
|
|
if (secret.length <= 8) return '••••••••';
|
|
return '••••••••' + secret.slice(-4);
|
|
}
|
|
}
|