299 lines
8.8 KiB
Go
299 lines
8.8 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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.Trim(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
|
|
}
|
|
userToken := strings.TrimSpace(r.URL.Query().Get("ut"))
|
|
ok, err := a.validateUserWebhookToken(r.Context(), p.UserID, userToken)
|
|
if err != nil || !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ref, err := parseWebhookRef(r)
|
|
if err != nil {
|
|
http.Error(w, "invalid webhook payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "no ref in payload"})
|
|
return
|
|
}
|
|
if ref != "refs/heads/main" && ref != "main" {
|
|
log.Printf("webhook ignored project_id=%d ref=%q", p.ID, ref)
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "not main branch"})
|
|
return
|
|
}
|
|
log.Printf("webhook deploy accepted project_id=%d ref=%q", p.ID, ref)
|
|
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 parseWebhookRef(r *http.Request) (string, error) {
|
|
rawBody, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var payload struct {
|
|
Ref string `json:"ref"`
|
|
}
|
|
if err := json.Unmarshal(rawBody, &payload); err == nil {
|
|
return payload.Ref, nil
|
|
}
|
|
formVals, err := url.ParseQuery(string(rawBody))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw := strings.TrimSpace(formVals.Get("payload"))
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return "", err
|
|
}
|
|
return payload.Ref, nil
|
|
}
|
|
|
|
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.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.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
|
|
}
|
|
tok, err := a.ensureUserWebhookToken(ctx, p.UserID)
|
|
if err != nil {
|
|
return Project{}, err
|
|
}
|
|
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token)
|
|
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
|
|
}
|
|
if err := a.syncProjectRepoFromMain(p, projectDir); err != nil {
|
|
return err
|
|
}
|
|
repoDir := filepath.Join(projectDir, "repo")
|
|
if err := a.buildProjectBinaryFromRepoDir(p, repoDir); 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
|
|
}
|
|
if p.RepoURL == "" {
|
|
return errors.New("project has no repo_url to build from")
|
|
}
|
|
if err := a.syncProjectRepoFromMain(p, projectDir); err != nil {
|
|
return err
|
|
}
|
|
repoDir := filepath.Join(projectDir, "repo")
|
|
return a.buildProjectBinaryFromRepoDir(p, repoDir)
|
|
}
|
|
|
|
func (a *App) buildProjectBinaryFromRepoDir(p Project, repoDir string) error {
|
|
projectDir, err := a.projectDirFor(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 := runGoBuildForUser(p.UnixUser, repoDir, binaryPath); err != nil {
|
|
return fmt.Errorf("build failed: %w", err)
|
|
}
|
|
if err := ensureOwnedByUnixUser(binaryPath, p.UnixUser, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := a.syncStaticAssetsForProject(p); err != nil {
|
|
return err
|
|
}
|
|
commit, err := runCmdOut("git", "-C", repoDir, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
return fmt.Errorf("resolve latest commit failed: %w", err)
|
|
}
|
|
if err := a.updateLastDeployment(p.ID, strings.TrimSpace(commit), time.Now().UTC()); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) updateLastDeployment(projectID int64, commit string, deployedAt time.Time) error {
|
|
_, err := a.db.Exec(`UPDATE projects SET last_deployed_at=$1, last_deployed_commit=$2 WHERE id=$3`, deployedAt, strings.TrimSpace(commit), projectID)
|
|
return err
|
|
}
|
|
|
|
func (a *App) syncProjectRepoFromMain(p Project, projectDir string) error {
|
|
repoDir := filepath.Join(projectDir, "repo")
|
|
plainRepoURL := strings.TrimSpace(p.RepoURL)
|
|
repoCloneURL := a.repoURLForGitAuth(p.RepoURL)
|
|
if _, err := os.Stat(repoDir); os.IsNotExist(err) {
|
|
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "init"); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "remote", "add", "origin", plainRepoURL); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "fetch", repoCloneURL, "main", "--prune"); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "reset", "--hard", "FETCH_HEAD"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "fetch", repoCloneURL, "main", "--prune"); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "-C", repoDir, "reset", "--hard", "FETCH_HEAD"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) repoURLForGitAuth(repoURL string) string {
|
|
if repoURL == "" || a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" {
|
|
return repoURL
|
|
}
|
|
ru, err := url.Parse(repoURL)
|
|
if err != nil || ru.Scheme != "https" {
|
|
return repoURL
|
|
}
|
|
fu, err := url.Parse(a.cfg.ForgejoBaseURL)
|
|
if err != nil {
|
|
return repoURL
|
|
}
|
|
if !strings.EqualFold(ru.Hostname(), fu.Hostname()) {
|
|
return repoURL
|
|
}
|
|
u := *ru
|
|
u.User = url.UserPassword(a.cfg.ForgejoGitUsername, a.cfg.ForgejoToken)
|
|
return u.String()
|
|
}
|
|
|
|
func runGoBuildForUser(unixUser, repoDir, binaryPath string) error {
|
|
homeDir, err := homeForUnixUser(unixUser)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
goPath := filepath.Join(homeDir, "go")
|
|
goModCache := filepath.Join(goPath, "pkg", "mod")
|
|
goCache := filepath.Join(homeDir, ".cache", "go-build")
|
|
|
|
for _, dir := range []string{goPath, goModCache, goCache} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureOwnedByUnixUser(dir, unixUser, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return runCmd(
|
|
"sudo", "-n", "-u", unixUser,
|
|
"env",
|
|
"HOME="+homeDir,
|
|
"GOPATH="+goPath,
|
|
"GOMODCACHE="+goModCache,
|
|
"GOCACHE="+goCache,
|
|
"GOTOOLCHAIN=auto",
|
|
"go", "-C", repoDir, "build", "-buildvcs=false", "-o", binaryPath, ".",
|
|
)
|
|
}
|
|
|
|
func (a *App) syncStaticAssetsForProject(p Project) error {
|
|
projectDir, err := a.projectDirFor(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repoStatic := filepath.Join(projectDir, "repo", "static")
|
|
dataStatic := filepath.Join(projectDir, "data", "static")
|
|
|
|
if _, err := os.Stat(repoStatic); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dataStatic, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureOwnedByUnixUser(dataStatic, p.UnixUser, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Preferred path: fast deterministic sync with deletes.
|
|
if err := runCmd("sudo", "-n", "-u", p.UnixUser, "rsync", "-a", "--delete", repoStatic+"/", dataStatic+"/"); err == nil {
|
|
return nil
|
|
}
|
|
// Fallback if rsync is unavailable: recreate target and copy.
|
|
if err := runCmd("sudo", "-n", "-u", p.UnixUser, "rm", "-rf", dataStatic); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("sudo", "-n", "-u", p.UnixUser, "mkdir", "-p", dataStatic); err != nil {
|
|
return err
|
|
}
|
|
return runCmd("sudo", "-n", "-u", p.UnixUser, "cp", "-a", repoStatic+"/.", dataStatic+"/")
|
|
}
|