331 lines
9.5 KiB
Go
331 lines
9.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
func (a *App) createForgejoRepo(ctx context.Context, name, description string, private bool) (string, error) {
|
|
if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" {
|
|
return "", nil
|
|
}
|
|
payload := map[string]any{"name": name, "description": description, "private": private, "auto_init": true}
|
|
body, _ := json.Marshal(payload)
|
|
endpoint := a.cfg.ForgejoBaseURL + "/api/v1/user/repos"
|
|
if a.cfg.ForgejoOrg != "" {
|
|
endpoint = fmt.Sprintf("%s/api/v1/orgs/%s/repos", a.cfg.ForgejoBaseURL, a.cfg.ForgejoOrg)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("forgejo request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("forgejo error: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return "", nil
|
|
}
|
|
return parsed.HTMLURL, nil
|
|
}
|
|
|
|
func (a *App) buildRouteHost(slug, username string) string {
|
|
if a.cfg.CaddyDomainSuffix == "" {
|
|
return fmt.Sprintf("%s-%s.local", slugify(username), slug)
|
|
}
|
|
return fmt.Sprintf("%s.%s", slug, strings.TrimPrefix(a.cfg.CaddyDomainSuffix, "."))
|
|
}
|
|
|
|
func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error {
|
|
if !a.cfg.CaddyRouteEnabled {
|
|
return nil
|
|
}
|
|
return a.syncCaddyConfigFragment(ctx)
|
|
}
|
|
|
|
func (a *App) syncCaddyConfigFragment(ctx context.Context) error {
|
|
path := strings.TrimSpace(a.cfg.CaddyFragmentPath)
|
|
if path == "" {
|
|
return errors.New("CADDY_FRAGMENT_PATH is required when CADDY_ROUTE_ENABLED=true")
|
|
}
|
|
routes, err := a.listCaddyProjectRoutes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content, err := a.renderCaddyRoutesFragment(routes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(path, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return a.reloadCaddy()
|
|
}
|
|
|
|
func (a *App) reloadCaddy() error {
|
|
reloadCmd := strings.TrimSpace(a.cfg.CaddyReloadCommand)
|
|
if reloadCmd == "" {
|
|
return nil
|
|
}
|
|
cmd := exec.Command("bash", "-lc", reloadCmd)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("caddy reload failed: %w output=%s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) listCaddyProjectRoutes(ctx context.Context) ([]Project, error) {
|
|
rows, err := a.db.QueryContext(ctx, `
|
|
SELECT p.id, p.user_id, p.name, p.slug, p.route_host, p.workspace_id, w.name, w.unix_user
|
|
FROM projects p
|
|
JOIN workspaces w ON w.id = p.workspace_id
|
|
WHERE p.route_host <> ''
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Project
|
|
for rows.Next() {
|
|
var p Project
|
|
if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].RouteHost == out[j].RouteHost {
|
|
return out[i].ID < out[j].ID
|
|
}
|
|
return out[i].RouteHost < out[j].RouteHost
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (a *App) renderCaddyRoutesFragment(projects []Project) (string, error) {
|
|
var b strings.Builder
|
|
b.WriteString("# Managed by project-manager. Do not edit manually.\n")
|
|
for _, p := range projects {
|
|
projectDir, err := a.projectDirFor(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
socketPath := filepath.Join(projectDir, "app.sock")
|
|
b.WriteString("\n")
|
|
b.WriteString(p.RouteHost)
|
|
b.WriteString(" {\n")
|
|
if a.cfg.CaddyAuthEnabled {
|
|
importName := strings.TrimSpace(a.cfg.CaddyAuthImportName)
|
|
if importName == "" {
|
|
importName = "auth"
|
|
}
|
|
b.WriteString(" import ")
|
|
b.WriteString(importName)
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(" log\n")
|
|
b.WriteString(" reverse_proxy unix//")
|
|
b.WriteString(socketPath)
|
|
b.WriteString("\n")
|
|
b.WriteString("}\n")
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("caddy config request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("caddy config error: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, fmt.Errorf("caddy config parse failed: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *App) persistCaddyConfigToDisk(ctx context.Context) error {
|
|
path := strings.TrimSpace(a.cfg.CaddyPersistConfigPath)
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("caddy config fetch for persist failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("caddy config fetch for persist error: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, body, 0o640); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error {
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ensureForgejoDeployWebhook(ctx context.Context, p Project) error {
|
|
if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" {
|
|
return nil
|
|
}
|
|
if a.cfg.WebhookBaseURL == "" {
|
|
return errors.New("WEBHOOK_BASE_URL is required for automatic Forgejo webhook provisioning")
|
|
}
|
|
|
|
owner, repo, err := parseForgejoRepoOwnerRepo(a.cfg.ForgejoBaseURL, p.RepoURL)
|
|
if err != nil {
|
|
// Existing repo is not on configured Forgejo host; skip auto-provision.
|
|
return nil
|
|
}
|
|
|
|
hooksURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", a.cfg.ForgejoBaseURL, owner, repo)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hooksURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("forgejo hooks list failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("forgejo hooks list error: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
var hooks []struct {
|
|
Config struct {
|
|
URL string `json:"url"`
|
|
} `json:"config"`
|
|
}
|
|
_ = json.Unmarshal(body, &hooks)
|
|
for _, h := range hooks {
|
|
if strings.TrimSpace(h.Config.URL) == strings.TrimSpace(p.WebhookURL) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"type": "gitea",
|
|
"active": true,
|
|
"events": []string{"push"},
|
|
"config": map[string]any{
|
|
"url": p.WebhookURL,
|
|
"content_type": "json",
|
|
},
|
|
}
|
|
bodyBytes, _ := json.Marshal(payload)
|
|
createReq, err := http.NewRequestWithContext(ctx, http.MethodPost, hooksURL, strings.NewReader(string(bodyBytes)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
createReq.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createResp, err := http.DefaultClient.Do(createReq)
|
|
if err != nil {
|
|
return fmt.Errorf("forgejo hook create failed: %w", err)
|
|
}
|
|
defer createResp.Body.Close()
|
|
createBody, _ := io.ReadAll(createResp.Body)
|
|
if createResp.StatusCode >= 300 {
|
|
return fmt.Errorf("forgejo hook create error: status=%d body=%s", createResp.StatusCode, string(createBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseForgejoRepoOwnerRepo(forgejoBaseURL, repoURL string) (string, string, error) {
|
|
fBase, err := url.Parse(forgejoBaseURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
normalized := strings.TrimSpace(repoURL)
|
|
if strings.HasPrefix(normalized, "git@") {
|
|
// git@host:owner/repo(.git)
|
|
at := strings.Index(normalized, "@")
|
|
colon := strings.Index(normalized, ":")
|
|
if at < 0 || colon < 0 || colon <= at+1 {
|
|
return "", "", errors.New("invalid ssh repo url")
|
|
}
|
|
host := normalized[at+1 : colon]
|
|
if !strings.EqualFold(host, fBase.Hostname()) {
|
|
return "", "", errors.New("repo host does not match configured forgejo host")
|
|
}
|
|
path := strings.TrimPrefix(normalized[colon+1:], "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repo path does not include owner/repo")
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
u, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if !strings.EqualFold(u.Hostname(), fBase.Hostname()) {
|
|
return "", "", errors.New("repo host does not match configured forgejo host")
|
|
}
|
|
path := strings.TrimPrefix(u.Path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repo path does not include owner/repo")
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|