init
This commit is contained in:
commit
b38c39030b
22 changed files with 1925 additions and 0 deletions
63
internal/app/config.go
Normal file
63
internal/app/config.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
cfg := Config{
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"),
|
||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||
UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""),
|
||||
DevMode: getenvBool("DEV_MODE", true),
|
||||
DemoUser: getenv("DEV_DEMO_USER", "demo"),
|
||||
DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"),
|
||||
AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"),
|
||||
AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"),
|
||||
ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"),
|
||||
ForgejoToken: getenv("FORGEJO_TOKEN", ""),
|
||||
ForgejoOrg: getenv("FORGEJO_ORG", ""),
|
||||
ProjectRoot: getenv("PROJECTS_ROOT", filepath.Join(userHomeOrDot(), "projects")),
|
||||
DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "/usr/bin/env bash -lc 'sleep infinity'"),
|
||||
CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"),
|
||||
CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"),
|
||||
CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""),
|
||||
CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false),
|
||||
WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"),
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return cfg, errors.New("DATABASE_URL is required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func userHomeOrDot() string {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
77
internal/app/db.go
Normal file
77
internal/app/db.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package app
|
||||
|
||||
import "context"
|
||||
|
||||
func (a *App) migrate(ctx context.Context) error {
|
||||
ddl := []string{
|
||||
`CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
unix_user TEXT NOT NULL,
|
||||
UNIQUE(user_id, name),
|
||||
UNIQUE(user_id, unix_user)
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
workspace_id BIGINT REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL,
|
||||
route_host TEXT NOT NULL DEFAULT '',
|
||||
target_port INT NOT NULL DEFAULT 8000,
|
||||
deploy_token TEXT NOT NULL DEFAULT '',
|
||||
provision_state TEXT NOT NULL DEFAULT 'provisioned',
|
||||
provision_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, slug)
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS project_env_vars (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(project_id, key)
|
||||
);`,
|
||||
}
|
||||
for _, q := range ddl {
|
||||
if _, err := a.db.ExecContext(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
alter := []string{
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS route_host TEXT NOT NULL DEFAULT '';`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS target_port INT NOT NULL DEFAULT 8000;`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id) ON DELETE RESTRICT;`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS deploy_token TEXT NOT NULL DEFAULT '';`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_state TEXT NOT NULL DEFAULT 'provisioned';`,
|
||||
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_error TEXT NOT NULL DEFAULT '';`,
|
||||
}
|
||||
for _, q := range alter {
|
||||
if _, err := a.db.ExecContext(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM projects WHERE deploy_token=''`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
tok, err := newDeployToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE projects SET deploy_token=$1 WHERE id=$2`, tok, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
93
internal/app/deploy_service.go
Normal file
93
internal/app/deploy_service.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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 := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug)
|
||||
repoDir := filepath.Join(projectDir, "repo")
|
||||
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
|
||||
}
|
||||
}
|
||||
return runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "restart", p.ServiceName)
|
||||
}
|
||||
192
internal/app/integrations.go
Normal file
192
internal/app/integrations.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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
|
||||
}
|
||||
route := map[string]any{
|
||||
"@id": "project-route-" + p.UserID + "-" + p.Slug,
|
||||
"match": []any{map[string]any{"host": []string{p.RouteHost}}},
|
||||
"handle": []any{map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": fmt.Sprintf("127.0.0.1:%d", p.TargetPort)}}}},
|
||||
}
|
||||
body, _ := json.Marshal(route)
|
||||
url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caddy request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("caddy error: status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
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
|
||||
}
|
||||
62
internal/app/middleware.go
Normal file
62
internal/app/middleware.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const userKey ctxKey = "user"
|
||||
|
||||
func (a *App) withAuth(next func(http.ResponseWriter, *http.Request, User)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var u User
|
||||
if a.cfg.DevMode {
|
||||
u = User{Username: a.cfg.DemoUser, Email: a.cfg.DemoEmail}
|
||||
} else {
|
||||
u = User{Username: r.Header.Get(a.cfg.AuthHeaderUser), Email: r.Header.Get(a.cfg.AuthHeaderEmail)}
|
||||
if u.Username == "" {
|
||||
http.Error(w, "missing authenticated user header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u)
|
||||
}
|
||||
}
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lrw := &logResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(lrw, r)
|
||||
log.Printf("method=%s path=%s status=%d bytes=%d dur_ms=%d remote=%s ua=%q",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
lrw.status,
|
||||
lrw.bytes,
|
||||
time.Since(start).Milliseconds(),
|
||||
r.RemoteAddr,
|
||||
r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type logResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (l *logResponseWriter) WriteHeader(statusCode int) {
|
||||
l.status = statusCode
|
||||
l.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (l *logResponseWriter) Write(b []byte) (int, error) {
|
||||
n, err := l.ResponseWriter.Write(b)
|
||||
l.bytes += n
|
||||
return n, err
|
||||
}
|
||||
182
internal/app/project_service.go
Normal file
182
internal/app/project_service.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error) {
|
||||
rows, err := a.db.QueryContext(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.provision_state, p.provision_error, p.created_at
|
||||
FROM projects p
|
||||
JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.user_id=$1 ORDER BY p.created_at DESC`, userID)
|
||||
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.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) createProject(ctx context.Context, user User, workspaceID int64, name, description string, private bool, existingRepoURL string, targetPort int) (Project, error) {
|
||||
slug := slugify(name)
|
||||
if slug == "" {
|
||||
return Project{}, errors.New("project name must include letters or numbers")
|
||||
}
|
||||
workspace, err := a.getWorkspace(ctx, user.Username, workspaceID)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
repoURL := existingRepoURL
|
||||
serviceName := fmt.Sprintf("projectmgr-%s-%s.service", slugify(user.Username), slug)
|
||||
routeHost := a.buildRouteHost(slug, user.Username)
|
||||
deployToken, err := newDeployToken()
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
var p Project
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, service_name, route_host, target_port, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending','') RETURNING id, user_id, name, slug, description, repo_url, service_name, route_host, target_port, workspace_id, deploy_token, provision_state, provision_error, created_at`,
|
||||
user.Username, workspaceID, name, slug, description, repoURL, serviceName, routeHost, targetPort, deployToken,
|
||||
).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
return Project{}, errors.New("project with this name already exists")
|
||||
}
|
||||
return Project{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
p.Workspace = workspace.Name
|
||||
p.UnixUser = workspace.UnixUser
|
||||
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
|
||||
|
||||
if p.RepoURL == "" {
|
||||
repoURL, err = a.createForgejoRepo(ctx, slug, description, private)
|
||||
if err != nil {
|
||||
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
p.RepoURL = repoURL
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE projects SET repo_url=$1 WHERE id=$2`, repoURL, p.ID); err != nil {
|
||||
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
if err := a.ensureForgejoDeployWebhook(ctx, p); err != nil {
|
||||
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
|
||||
if err := a.renderSystemdForProject(ctx, p); err != nil {
|
||||
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
if err := a.ensureCaddyRoute(ctx, p); err != nil {
|
||||
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil {
|
||||
p.ProvisionState = "failed"
|
||||
p.ProvisionError = err.Error()
|
||||
return p, err
|
||||
}
|
||||
p.ProvisionState = "provisioned"
|
||||
p.ProvisionError = ""
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *App) getProject(ctx context.Context, userID string, projectID int64) (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.provision_state, p.provision_error, p.created_at
|
||||
FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.user_id=$1 AND p.id=$2`, userID, projectID).
|
||||
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.ProvisionState, &p.ProvisionError, &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) updateProvisioningState(ctx context.Context, projectID int64, state, errText string) error {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE projects SET provision_state=$1, provision_error=$2 WHERE id=$3`, state, errText, projectID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) listEnvVars(ctx context.Context, userID string, projectID int64) ([]EnvVar, error) {
|
||||
if _, err := a.getProject(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id, project_id, key, value FROM project_env_vars WHERE project_id=$1 ORDER BY key`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EnvVar
|
||||
for rows.Next() {
|
||||
var v EnvVar
|
||||
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) upsertEnvVar(ctx context.Context, userID string, projectID int64, key, value string) error {
|
||||
p, err := a.getProject(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `INSERT INTO project_env_vars (project_id, key, value) VALUES ($1,$2,$3)
|
||||
ON CONFLICT(project_id, key) DO UPDATE SET value = EXCLUDED.value`, projectID, key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.renderSystemdForProject(ctx, p)
|
||||
}
|
||||
|
||||
func (a *App) deleteEnvVar(ctx context.Context, userID string, projectID int64, key string) error {
|
||||
p, err := a.getProject(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `DELETE FROM project_env_vars WHERE project_id=$1 AND key=$2`, projectID, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.renderSystemdForProject(ctx, p)
|
||||
}
|
||||
251
internal/app/routes_handlers.go
Normal file
251
internal/app/routes_handlers.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
osuser "os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", http.FileServer(http.Dir("./web")))
|
||||
mux.HandleFunc("/api/me", a.withAuth(a.handleMe))
|
||||
mux.HandleFunc("/api/workspaces", a.withAuth(a.handleWorkspaces))
|
||||
mux.HandleFunc("/api/projects", a.withAuth(a.handleProjects))
|
||||
mux.HandleFunc("/api/projects/", a.withAuth(a.handleProjectSubroutes))
|
||||
mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook)
|
||||
return loggingMiddleware(mux)
|
||||
}
|
||||
|
||||
func (a *App) handleMe(w http.ResponseWriter, _ *http.Request, user User) {
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (a *App) handleWorkspaces(w http.ResponseWriter, r *http.Request, user User) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := a.listWorkspaces(r.Context(), user.Username)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
UnixUser string `json:"unix_user"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.UnixUser = strings.TrimSpace(req.UnixUser)
|
||||
if req.Name == "" || req.UnixUser == "" {
|
||||
http.Error(w, "name and unix_user are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := osuser.Lookup(req.UnixUser); err != nil {
|
||||
http.Error(w, "unix_user does not exist on host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
item, err := a.createWorkspace(r.Context(), user.Username, req.Name, req.UnixUser)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleProjects(w http.ResponseWriter, r *http.Request, user User) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
projects, err := a.listProjects(r.Context(), user.Username)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RepoPrivate bool `json:"repo_private"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
TargetPort int `json:"target_port"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
http.Error(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID <= 0 {
|
||||
http.Error(w, "workspace_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.RepoURL = strings.TrimSpace(req.RepoURL)
|
||||
if req.RepoURL != "" && !isValidRepoURL(req.RepoURL) {
|
||||
http.Error(w, "repo_url must be a valid repo URL (https://..., ssh://..., or git@...)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.TargetPort == 0 {
|
||||
req.TargetPort = 8000
|
||||
}
|
||||
if req.TargetPort < 1 || req.TargetPort > 65535 {
|
||||
http.Error(w, "target_port must be 1-65535", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
project, err := a.createProject(r.Context(), user, req.WorkspaceID, req.Name, req.Description, req.RepoPrivate, req.RepoURL, req.TargetPort)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, project)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/projects/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 || parts[0] == "" {
|
||||
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
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
http.Error(w, "unsupported endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch parts[1] {
|
||||
case "env":
|
||||
a.handleProjectEnv(w, r, user, projectID)
|
||||
case "service":
|
||||
a.handleServiceRegenerate(w, r, user, projectID)
|
||||
case "status":
|
||||
a.handleProjectStatus(w, r, user, projectID)
|
||||
case "logs":
|
||||
a.handleProjectLogs(w, r, user, projectID)
|
||||
default:
|
||||
http.Error(w, "unsupported endpoint", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleProjectEnv(w http.ResponseWriter, r *http.Request, user User, projectID int64) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
envVars, err := a.listEnvVars(r.Context(), user.Username, projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, envVars)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateEnvKey(req.Key); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.upsertEnvVar(r.Context(), user.Username, projectID, req.Key, req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
case http.MethodDelete:
|
||||
key := r.URL.Query().Get("key")
|
||||
if err := validateEnvKey(key); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.deleteEnvVar(r.Context(), user.Username, projectID, key); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServiceRegenerate(w http.ResponseWriter, r *http.Request, user User, projectID int64) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
p, err := a.getProject(r.Context(), user.Username, projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.renderSystemdForProject(r.Context(), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "service regenerated"})
|
||||
}
|
||||
|
||||
func (a *App) handleProjectStatus(w http.ResponseWriter, r *http.Request, user User, projectID int64) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
p, err := a.getProject(r.Context(), user.Username, projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := a.getServiceStatus(p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (a *App) handleProjectLogs(w http.ResponseWriter, r *http.Request, user User, projectID int64) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
p, err := a.getProject(r.Context(), user.Username, projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lines := 200
|
||||
if raw := r.URL.Query().Get("lines"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 1000 {
|
||||
http.Error(w, "lines must be 1-1000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lines = n
|
||||
}
|
||||
logs, err := a.getServiceLogs(p, lines)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"lines": lines, "logs": logs})
|
||||
}
|
||||
56
internal/app/run.go
Normal file
56
internal/app/run.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func Run() error {
|
||||
_ = godotenv.Load()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config error: %w", err)
|
||||
}
|
||||
db, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("database open error: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("database ping error: %w", err)
|
||||
}
|
||||
app := &App{cfg: cfg, db: db}
|
||||
if err := app.migrate(context.Background()); err != nil {
|
||||
return fmt.Errorf("migration error: %w", err)
|
||||
}
|
||||
handler := app.routes()
|
||||
servers, cleanup, err := startServers(cfg, handler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server start error: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
log.Printf("server started: tcp=%q unix=%q dev_mode=%t", cfg.ListenAddr, cfg.UnixSocketPath, cfg.DevMode)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for _, s := range servers {
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown error: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("shutdown complete")
|
||||
return nil
|
||||
}
|
||||
56
internal/app/server.go
Normal file
56
internal/app/server.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func startServers(cfg Config, handler http.Handler) ([]*http.Server, func(), error) {
|
||||
var servers []*http.Server
|
||||
cleanup := func() {}
|
||||
|
||||
if cfg.ListenAddr != "" {
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: handler}
|
||||
servers = append(servers, srv)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("tcp server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if cfg.UnixSocketPath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.UnixSocketPath), 0o755); err != nil {
|
||||
return nil, cleanup, err
|
||||
}
|
||||
_ = os.Remove(cfg.UnixSocketPath)
|
||||
ln, err := net.Listen("unix", cfg.UnixSocketPath)
|
||||
if err != nil {
|
||||
return nil, cleanup, err
|
||||
}
|
||||
if err := os.Chmod(cfg.UnixSocketPath, 0o666); err != nil {
|
||||
return nil, cleanup, err
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: handler}
|
||||
servers = append(servers, srv)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("unix server error: %v", err)
|
||||
}
|
||||
}()
|
||||
cleanup = func() {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(cfg.UnixSocketPath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
return nil, cleanup, errors.New("no listeners configured, set LISTEN_ADDR and/or UNIX_SOCKET_PATH")
|
||||
}
|
||||
return servers, cleanup, nil
|
||||
}
|
||||
149
internal/app/systemd_service.go
Normal file
149
internal/app/systemd_service.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
|
||||
envVars, err := a.listEnvVars(ctx, p.UserID, p.ID)
|
||||
if err != nil {
|
||||
if err.Error() == "project not found" {
|
||||
return err
|
||||
}
|
||||
envVars = []EnvVar{}
|
||||
}
|
||||
projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug)
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dataDir := filepath.Join(projectDir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
socketPath := filepath.Join(projectDir, "app.sock")
|
||||
envPath := filepath.Join(projectDir, ".env")
|
||||
|
||||
values := map[string]string{}
|
||||
for _, v := range envVars {
|
||||
values[v.Key] = v.Value
|
||||
}
|
||||
values["LISTEN_NETWORK"] = "unix"
|
||||
values["LISTEN_ADDRESS"] = socketPath
|
||||
|
||||
var b strings.Builder
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteString("=")
|
||||
b.WriteString(escapeEnvValue(values[k]))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(envPath, []byte(b.String()), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Project %s
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%s
|
||||
EnvironmentFile=%s
|
||||
ExecStart=%s
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`, p.Name, dataDir, envPath, a.cfg.DefaultRunCommand)
|
||||
|
||||
homeDir, err := homeForUnixUser(p.UnixUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serviceDir := filepath.Join(homeDir, ".config", "systemd", "user")
|
||||
if err := os.MkdirAll(serviceDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
servicePath := filepath.Join(serviceDir, p.ServiceName)
|
||||
if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "daemon-reload")
|
||||
_ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "enable", "--now", p.ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func homeForUnixUser(username string) (string, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lookup unix user %q failed: %w", username, err)
|
||||
}
|
||||
if u.HomeDir == "" {
|
||||
return "", fmt.Errorf("unix user %q has no home directory", username)
|
||||
}
|
||||
return u.HomeDir, nil
|
||||
}
|
||||
|
||||
type ServiceStatus struct {
|
||||
Unit string `json:"unit"`
|
||||
ActiveState string `json:"active_state"`
|
||||
SubState string `json:"sub_state"`
|
||||
MainPID int `json:"main_pid"`
|
||||
ExecStatus int `json:"exec_status"`
|
||||
Result string `json:"result"`
|
||||
FragmentPath string `json:"fragment_path"`
|
||||
}
|
||||
|
||||
func (a *App) getServiceStatus(p Project) (ServiceStatus, error) {
|
||||
out, err := runCmdOut(
|
||||
"sudo", "-n", "-u", p.UnixUser,
|
||||
"systemctl", "--user", "show", p.ServiceName, "--no-pager",
|
||||
"--property=ActiveState,SubState,MainPID,ExecMainStatus,Result,FragmentPath",
|
||||
)
|
||||
if err != nil {
|
||||
return ServiceStatus{}, err
|
||||
}
|
||||
status := ServiceStatus{Unit: p.ServiceName}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if line == "" || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
k, v := parts[0], parts[1]
|
||||
switch k {
|
||||
case "ActiveState":
|
||||
status.ActiveState = v
|
||||
case "SubState":
|
||||
status.SubState = v
|
||||
case "MainPID":
|
||||
status.MainPID, _ = strconv.Atoi(v)
|
||||
case "ExecMainStatus":
|
||||
status.ExecStatus, _ = strconv.Atoi(v)
|
||||
case "Result":
|
||||
status.Result = v
|
||||
case "FragmentPath":
|
||||
status.FragmentPath = v
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *App) getServiceLogs(p Project, lines int) (string, error) {
|
||||
return runCmdOut(
|
||||
"sudo", "-n", "-u", p.UnixUser,
|
||||
"journalctl", "--user", "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso",
|
||||
)
|
||||
}
|
||||
71
internal/app/types.go
Normal file
71
internal/app/types.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
ListenAddr string
|
||||
UnixSocketPath string
|
||||
DevMode bool
|
||||
DemoUser string
|
||||
DemoEmail string
|
||||
AuthHeaderUser string
|
||||
AuthHeaderEmail string
|
||||
ForgejoBaseURL string
|
||||
ForgejoToken string
|
||||
ForgejoOrg string
|
||||
ProjectRoot string
|
||||
DefaultRunCommand string
|
||||
CaddyAdminURL string
|
||||
CaddyServerID string
|
||||
CaddyDomainSuffix string
|
||||
CaddyRouteEnabled bool
|
||||
WebhookBaseURL string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
ServiceName string `json:"service_name"`
|
||||
RouteHost string `json:"route_host"`
|
||||
TargetPort int `json:"target_port"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
Workspace string `json:"workspace"`
|
||||
UnixUser string `json:"unix_user"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
DeployToken string `json:"-"`
|
||||
ProvisionState string `json:"provision_state"`
|
||||
ProvisionError string `json:"provision_error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type EnvVar struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
UnixUser string `json:"unix_user"`
|
||||
}
|
||||
72
internal/app/util.go
Normal file
72
internal/app/util.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateEnvKey(key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("key is required")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString(key) {
|
||||
return fmt.Errorf("invalid env key format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidRepoURL(s string) bool {
|
||||
return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "ssh://") || strings.HasPrefix(s, "git@")
|
||||
}
|
||||
|
||||
func escapeEnvValue(v string) string { return strconv.Quote(v) }
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (a *App) deployWebhookURL(projectID int64, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
path := fmt.Sprintf("/api/webhooks/deploy/%d/%s", projectID, token)
|
||||
if a.cfg.WebhookBaseURL != "" {
|
||||
return a.cfg.WebhookBaseURL + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func newDeployToken() (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", buf), nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
_, err := runCmdOut(name, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func runCmdOut(name string, args ...string) (string, error) {
|
||||
out, err := exec.Command(name, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %s %s: %w output=%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
51
internal/app/workspace_service.go
Normal file
51
internal/app/workspace_service.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) listWorkspaces(ctx context.Context, userID string) ([]Workspace, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id, user_id, name, unix_user FROM workspaces WHERE user_id=$1 ORDER BY name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Workspace
|
||||
for rows.Next() {
|
||||
var w Workspace
|
||||
if err := rows.Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) createWorkspace(ctx context.Context, userID, name, unixUser string) (Workspace, error) {
|
||||
var w Workspace
|
||||
err := a.db.QueryRowContext(ctx, `INSERT INTO workspaces (user_id, name, unix_user) VALUES ($1,$2,$3) RETURNING id, user_id, name, unix_user`, userID, name, unixUser).
|
||||
Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
return Workspace{}, errors.New("workspace name or unix_user already exists")
|
||||
}
|
||||
return Workspace{}, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (a *App) getWorkspace(ctx context.Context, userID string, workspaceID int64) (Workspace, error) {
|
||||
var w Workspace
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id, user_id, name, unix_user FROM workspaces WHERE id=$1 AND user_id=$2`, workspaceID, userID).
|
||||
Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Workspace{}, errors.New("workspace not found")
|
||||
}
|
||||
return Workspace{}, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue