package app import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "os" "path/filepath" "strconv" "strings" ) func (a *App) handleDeployWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } path := strings.TrimPrefix(r.URL.Path, "/api/webhooks/deploy/") parts := strings.Split(path, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { http.Error(w, "bad path", http.StatusBadRequest) return } projectID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { http.Error(w, "invalid project id", http.StatusBadRequest) return } p, err := a.getProjectForWebhook(r.Context(), projectID, parts[1]) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var payload struct { Ref string `json:"ref"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if payload.Ref != "refs/heads/main" { writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "not main branch"}) return } if err := a.deployProjectFromMain(r.Context(), p); err != nil { log.Printf("deploy failed project_id=%d err=%v", p.ID, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"}) } func (a *App) getProjectForWebhook(ctx context.Context, projectID int64, token string) (Project, error) { var p Project err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.created_at FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.id=$1 AND p.deploy_token=$2`, projectID, token). Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Project{}, errors.New("project not found") } return Project{}, err } p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) return p, nil } func (a *App) deployProjectFromMain(_ context.Context, p Project) error { if p.RepoURL == "" { return errors.New("project has no repo_url to deploy from") } projectDir, err := a.projectDirFor(p) if err != nil { return err } repoDir := filepath.Join(projectDir, "repo") if _, err := os.Stat(repoDir); err != nil { return fmt.Errorf("repo checkout missing at %s (trigger deploy webhook/push first)", repoDir) } binDir := filepath.Join(projectDir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return err } if _, err := os.Stat(repoDir); os.IsNotExist(err) { if err := runCmd("git", "clone", "--branch", "main", "--single-branch", p.RepoURL, repoDir); err != nil { return err } } else { if err := runCmd("git", "-C", repoDir, "remote", "set-url", "origin", p.RepoURL); err != nil { return err } if err := runCmd("git", "-C", repoDir, "fetch", "origin", "main", "--prune"); err != nil { return err } if err := runCmd("git", "-C", repoDir, "reset", "--hard", "origin/main"); err != nil { return err } } if err := a.buildProjectBinary(p); err != nil { return err } return runSystemctlUser(p.UnixUser, "restart", p.ServiceName) } func (a *App) buildProjectBinary(p Project) error { projectDir, err := a.projectDirFor(p) if err != nil { return err } repoDir := filepath.Join(projectDir, "repo") binDir := filepath.Join(projectDir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return err } if err := ensureOwnedByUnixUser(binDir, p.UnixUser, 0o755); err != nil { return err } binaryPath := filepath.Join(binDir, "app") if err := runCmd("go", "-C", repoDir, "build", "-o", binaryPath, "."); err != nil { return fmt.Errorf("build failed: %w", err) } if err := ensureOwnedByUnixUser(binaryPath, p.UnixUser, 0o755); err != nil { return err } return nil }