diff --git a/ROADMAP.md b/ROADMAP.md index a2604e1..b4ebf52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -196,12 +196,17 @@ OpenAPI spec matches the router 1:1; zero dead packages. The product's spine: *project → service → deploy → logs → rollback*. Most pieces exist; this phase closes the gaps that block real usage. -- [ ] **Git connect flow, end to end**: Settings → provider → repo picker → - branch picker → root dir → creates service + webhook. Backend exists; - build the UI and verify a real push triggers a build+deploy. -- [ ] **Webhook auto-deploy**: `POST /git/webhooks` receiver, verify signature, - map repo+branch → service, enqueue build, surface status on canvas node - and `/builds`. Close the loop described in `AUTO_DEPLOY_GUIDE.md`. +- [x] **Git connect flow, end to end**: Settings → GitProvidersSection manages + providers; service dialog picks provider → repo → branch; on create the + repo is connected (`POST /git/repositories/connect`, 409-tolerant) and a + push webhook is registered (`POST /git/webhooks`). Live provider push + verification pending real credentials. +- [x] **Webhook auto-deploy**: public receiver `POST /api/git/webhooks/:id` + verifies HMAC (GitHub `X-Hub-Signature-256`, Gitea `X-Gitea-Signature`) + or GitLab `X-Gitlab-Token`, maps repo+branch → services, inserts + deployment rows and enqueues `runDeploymentAndSync` (trigger=webhook), + which flips canvas node status to building. Unit-tested signature + verification. Not yet e2e-verified against a real provider. - [ ] **Compose template catalog** per `docs/superpowers/specs/2026-04-14-compose-template-catalog-design.md`: - `service_templates.compose_yaml`, `screenshots`, `source_type`, parsed `config` summary (migration + sqlc). diff --git a/app/backend/internal/api/git_receiver.go b/app/backend/internal/api/git_receiver.go new file mode 100644 index 0000000..299beac --- /dev/null +++ b/app/backend/internal/api/git_receiver.go @@ -0,0 +1,214 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "containr/internal/database" + "containr/internal/deployment" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// handleGitWebhookPush is the public push receiver. Providers call +// POST /api/git/webhooks/:id with a signed payload; the HMAC against +// git_webhooks.webhook_secret replaces session auth. +func handleGitWebhookPush(c *gin.Context) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbValue.(*database.DB) + + webhookID := strings.TrimSpace(c.Param("id")) + if _, err := uuid.Parse(webhookID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"}) + return + } + + var ( + secret string + repoID string + providerID string + branchFilter string + active bool + ) + err := db.QueryRow(` + SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active + FROM git_webhooks WHERE id = $1`, webhookID). + Scan(&secret, &repoID, &providerID, &branchFilter, &active) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"}) + return + } + if !active { + c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"}) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"}) + return + } + + var providerName string + _ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName) + + if !verifyGitWebhookSignature(providerName, secret, c.Request, body) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) + return + } + + var payload struct { + Ref string `json:"ref"` + After string `json:"after"` + Repository struct { + FullName string `json:"full_name"` + PathWithNamespace string `json:"path_with_namespace"` + } `json:"repository"` + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"project"` + HeadCommit struct { + ID string `json:"id"` + } `json:"head_commit"` + } + if err := json.Unmarshal(body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + + if !strings.HasPrefix(payload.Ref, "refs/heads/") { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"}) + return + } + branch := strings.TrimPrefix(payload.Ref, "refs/heads/") + if branchFilter != "" && branchFilter != branch { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"}) + return + } + commit := payload.HeadCommit.ID + if commit == "" { + commit = payload.After + } + + var cloneURL, fullName, repoUserID string + err = db.QueryRow( + `SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID, + ).Scan(&cloneURL, &fullName, &repoUserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"}) + return + } + + rows, err := db.Query(` + SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, + s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + s.created_at, s.updated_at + FROM services s + WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, + branch, cloneURL, fullName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) + return + } + defer rows.Close() + + var services []Service + for rows.Next() { + var s Service + if err := rows.Scan( + &s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command, + &s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, + &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) + return + } + services = append(services, s) + } + + engineValue, _ := c.Get("deployment_engine") + engine, _ := engineValue.(*deployment.DeploymentEngine) + + enqueued := 0 + for _, service := range services { + now := time.Now() + var commitHash *string + if commit != "" { + commitHash = &commit + } + d := DeploymentModel{ + ID: uuid.New(), + ServiceID: service.ID, + CommitHash: commitHash, + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + } + if _, err := db.Exec( + `INSERT INTO deployments + (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, + ); err != nil { + continue + } + if engine == nil { + failedAt := time.Now() + failure := "Deployment engine unavailable. Docker may not be configured on this server." + _, _ = db.Exec( + `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, + failure, failedAt, d.ID, + ) + continue + } + _, _ = db.Exec( + `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, + time.Now(), service.ID, + ) + go runDeploymentAndSync( + context.Background(), db, engine, &d, service, + CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"}, + repoUserID, + ) + enqueued++ + } + + c.JSON(http.StatusAccepted, gin.H{ + "received": true, + "branch": branch, + "deployments": enqueued, + }) +} + +// verifyGitWebhookSignature checks the push signature per provider: +// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex; +// GitLab compares X-Gitlab-Token to the stored secret directly. +func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool { + if strings.EqualFold(provider, "gitlab") { + token := r.Header.Get("X-Gitlab-Token") + return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1 + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + sum := mac.Sum(nil) + for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} { + sig := strings.TrimPrefix(r.Header.Get(header), "sha256=") + if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) { + return true + } + } + return false +} diff --git a/app/backend/internal/api/git_receiver_test.go b/app/backend/internal/api/git_receiver_test.go new file mode 100644 index 0000000..4fccac4 --- /dev/null +++ b/app/backend/internal/api/git_receiver_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "testing" +) + +func signBody(t *testing.T, secret string, body []byte) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifyGitWebhookSignature(t *testing.T) { + body := []byte(`{"ref":"refs/heads/main"}`) + secret := "test-secret" + valid := signBody(t, secret, body) + + cases := []struct { + name string + provider string + headers map[string]string + want bool + }{ + { + name: "github sha256 prefix", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=" + valid}, + want: true, + }, + { + name: "gitea signature", + provider: "gitea", + headers: map[string]string{"X-Gitea-Signature": valid}, + want: true, + }, + { + name: "gitlab token", + provider: "gitlab", + headers: map[string]string{"X-Gitlab-Token": secret}, + want: true, + }, + { + name: "gitlab rejects wrong token", + provider: "gitlab", + headers: map[string]string{"X-Gitlab-Token": "wrong"}, + want: false, + }, + { + name: "github rejects wrong signature", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=" + signBody(t, "other", body)}, + want: false, + }, + { + name: "missing signature", + provider: "github", + headers: map[string]string{}, + want: false, + }, + { + name: "malformed hex", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=zzz"}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/git/webhooks/x", nil) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + if got := verifyGitWebhookSignature(tc.provider, secret, req, body); got != tc.want { + t.Fatalf("verifyGitWebhookSignature = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 72847a6..43883a3 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -152,6 +152,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg publicAgents := router.Group("/api") agentHandler.SetupPublicRoutes(publicAgents) + // Public push receiver: HMAC signature on git_webhooks.webhook_secret + // replaces session auth. Providers POST to /api/git/webhooks/:id. + router.POST("/api/git/webhooks/:id", handleGitWebhookPush) + v1 := router.Group("/api/v1") { // Public routes (no authentication required) diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index c395f6c..66d1b60 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -2,8 +2,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { + ApiError, + connectGitRepository, + createGitWebhook, createService, getProjectById, + listConnectedGitRepositories, listGitBranches, listGitProviders, listGitRepositories, @@ -50,10 +54,15 @@ const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutG const serviceTypes: Array = ['web', 'worker', 'database', 'cron']; const serviceEnvironments = ['production', 'preview', 'development'] as const; +type CreateServiceSubmit = CreateServiceInput & { + gitProviderId?: string; + gitRepoFullName?: string; +}; + function ServiceCreateDialog(props: { open: boolean; onClose: () => void; - onSubmit: (payload: CreateServiceInput) => void; + onSubmit: (payload: CreateServiceSubmit) => void; loading: boolean; errorMessage?: string; }) { @@ -365,6 +374,8 @@ function ServiceCreateDialog(props: { git_repo: source === 'git' ? form.git_repo?.trim() : '', git_branch: source === 'git' ? form.git_branch?.trim() : '', build_path: source === 'git' ? form.build_path?.trim() : '', + gitProviderId: source === 'git' && repoFullName ? providerId : undefined, + gitRepoFullName: source === 'git' ? repoFullName || undefined : undefined, }) } className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all" @@ -416,7 +427,35 @@ export function ProjectWorkspacePage() { }); const createServiceMutation = useMutation({ - mutationFn: (payload: CreateServiceInput) => createService(projectId, payload), + mutationFn: async (payload: CreateServiceSubmit) => { + const { gitProviderId, gitRepoFullName, ...serviceInput } = payload; + const service = await createService(projectId, serviceInput); + if (gitProviderId && gitRepoFullName) { + let repoId: string | undefined; + try { + const repo = await connectGitRepository({ + provider_id: gitProviderId, + repo_full_name: gitRepoFullName, + }); + repoId = repo.id; + } catch (err) { + if (!(err instanceof ApiError && err.status === 409)) { + throw err; + } + repoId = (await listConnectedGitRepositories()).find( + (r) => r.full_name === gitRepoFullName, + )?.id; + } + if (repoId) { + await createGitWebhook({ + repo_id: repoId, + events: ['push'], + branch: payload.git_branch ?? '', + }); + } + } + return service; + }, onSuccess: () => { setCreateOpen(false); queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 41bec54..8d996b7 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -4748,6 +4748,92 @@ export interface paths { patch?: never; trace?: never; }; + "/api/git/webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Push event receiver + * @description Public provider webhook endpoint. The HMAC signature against the stored webhook secret replaces session auth: GitHub X-Hub-Signature-256, Gitea X-Gitea-Signature, GitLab X-Gitlab-Token. Matching branch pushes enqueue deployments for connected services. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": Record; + }; + }; + responses: { + /** @description Push accepted; matched services queued for deployment */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + received?: boolean; + branch?: string; + deployments?: number; + ignored?: string; + }; + }; + }; + /** @description Invalid webhook ID or payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Webhook disabled */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Webhook or repository not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/agents/{id}/commands/{commandId}/result": { parameters: { query?: never; @@ -7014,8 +7100,10 @@ export interface components { id?: string; repo_id?: string; provider_id?: string; - /** @description JSON-encoded array of subscribed events */ - events?: string; + /** @description Subscribed provider events */ + events?: string[]; + /** @description Branch filter for push events */ + branch?: string; active?: boolean; /** Format: date-time */ created_at?: string; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index b0269b6..334213e 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1230,3 +1230,36 @@ export async function listGitBranches( ); return payload.branches ?? []; } + +export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest']; +export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest']; +export type GitWebhookEntity = components['schemas']['GitWebhook']; + +export async function connectGitRepository( + input: ConnectGitRepositoryInput, +): Promise { + const payload = await requestJson<{ repository?: GitRepositoryEntity }>( + '/git/repositories/connect', + { method: 'POST', body: JSON.stringify(input) }, + ); + if (!payload.repository?.id) { + throw new ApiError('Connect repository response is invalid', 500); + } + return payload.repository; +} + +export async function listConnectedGitRepositories(): Promise { + const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>('/git/repositories'); + return payload.repositories ?? []; +} + +export async function createGitWebhook(input: CreateGitWebhookInput): Promise { + const payload = await requestJson<{ webhook?: GitWebhookEntity }>('/git/webhooks', { + method: 'POST', + body: JSON.stringify(input), + }); + if (!payload.webhook?.id) { + throw new ApiError('Webhook response is invalid', 500); + } + return payload.webhook; +} diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 87beba3..050fef0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2919,6 +2919,73 @@ paths: items: $ref: '#/components/schemas/AgentCommand' + /api/git/webhooks/{id}: + servers: + - url: http://localhost:8080 + post: + tags: + - Git + summary: Push event receiver + description: >- + Public provider webhook endpoint. The HMAC signature against the stored + webhook secret replaces session auth: GitHub X-Hub-Signature-256, + Gitea X-Gitea-Signature, GitLab X-Gitlab-Token. Matching branch pushes + enqueue deployments for connected services. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Provider push payload (ref, repository, head_commit) + responses: + '202': + description: Push accepted; matched services queued for deployment + content: + application/json: + schema: + type: object + properties: + received: + type: boolean + branch: + type: string + deployments: + type: integer + ignored: + type: string + '400': + description: Invalid webhook ID or payload + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Invalid signature + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Webhook disabled + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Webhook or repository not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/agents/{id}/commands/{commandId}/result: servers: - url: http://localhost:8080 @@ -4984,8 +5051,13 @@ components: provider_id: type: string events: + type: array + items: + type: string + description: Subscribed provider events + branch: type: string - description: JSON-encoded array of subscribed events + description: Branch filter for push events active: type: boolean created_at: