init
This commit is contained in:
commit
b38c39030b
22 changed files with 1925 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/box?sslmode=disable
|
||||
LISTEN_ADDR=:8080
|
||||
UNIX_SOCKET_PATH=
|
||||
DEV_MODE=true
|
||||
DEV_DEMO_USER=demo
|
||||
DEV_DEMO_EMAIL=demo@example.local
|
||||
AUTH_HEADER_USER=X-authentik-username
|
||||
AUTH_HEADER_EMAIL=X-authentik-email
|
||||
FORGEJO_BASE_URL=
|
||||
FORGEJO_TOKEN=
|
||||
FORGEJO_ORG=
|
||||
PROJECTS_ROOT=$HOME/projects
|
||||
DEFAULT_RUN_COMMAND=/usr/bin/env bash -lc 'sleep infinity'
|
||||
CADDY_ROUTE_ENABLED=false
|
||||
CADDY_ADMIN_URL=http://localhost:2019
|
||||
CADDY_SERVER_ID=srv0
|
||||
CADDY_DOMAIN_SUFFIX=
|
||||
WEBHOOK_BASE_URL=
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.env
|
||||
bin/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
/tmp/
|
||||
|
||||
box
|
||||
112
README.md
Normal file
112
README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Project Box
|
||||
|
||||
Project Box is a Go + vanilla web app for managing user projects.
|
||||
|
||||
Features:
|
||||
- Workspace model mapping each app-user workspace to a host Unix account
|
||||
- Create projects per authenticated user
|
||||
- Auto-create Forgejo repository or onboard an existing repository URL
|
||||
- Auto-create Caddy reverse-proxy route via Admin API (optional)
|
||||
- Manage project env vars
|
||||
- Auto-generate user-scoped systemd service per project
|
||||
- PostgreSQL persistence with startup migration
|
||||
- Authentik forward-auth header support + dev mode demo user
|
||||
- HTTP server on TCP and optional Unix socket
|
||||
- Request/access logs
|
||||
|
||||
## Run
|
||||
|
||||
1. Copy config:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. Start PostgreSQL and create database `box`.
|
||||
3. Start app:
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
4. Open `http://localhost:8080`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example`.
|
||||
|
||||
Important:
|
||||
- `DEV_MODE=true` uses a demo user and bypasses forward-auth headers.
|
||||
- `DEV_MODE=false` expects auth headers from your reverse proxy:
|
||||
- `X-authentik-username`
|
||||
- `X-authentik-email`
|
||||
- `FORGEJO_BASE_URL` + `FORGEJO_TOKEN` enable repository creation.
|
||||
- `FORGEJO_ORG` switches creation from personal repos to org repos.
|
||||
- `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`.
|
||||
- `CADDY_ROUTE_ENABLED=true` enables route provisioning.
|
||||
- `CADDY_ADMIN_URL` points to the Caddy Admin API (default `http://localhost:2019`).
|
||||
- `CADDY_SERVER_ID` is the HTTP server object id under `apps.http.servers` (default `srv0`).
|
||||
- `CADDY_DOMAIN_SUFFIX` controls generated host as `<slug>.<suffix>` (if empty: `<user>-<slug>.local`).
|
||||
- `WEBHOOK_BASE_URL` sets absolute webhook URLs returned by API/UI (recommended behind reverse proxy).
|
||||
|
||||
## Caddy API Behavior
|
||||
|
||||
On project creation, when Caddy routing is enabled, the app appends a route to:
|
||||
- `POST /config/apps/http/servers/<CADDY_SERVER_ID>/routes`
|
||||
|
||||
Route shape:
|
||||
- `match.host = [<project host>]`
|
||||
- `handle[0].handler = reverse_proxy`
|
||||
- `handle[0].upstreams[0].dial = 127.0.0.1:<target_port>`
|
||||
|
||||
## Workspaces and Unix Users
|
||||
|
||||
- Create one or more workspaces per authenticated user.
|
||||
- Each workspace stores a `unix_user` (must exist on the host).
|
||||
- Project creation requires selecting a workspace.
|
||||
- Service/env files are written under that Unix user's home:
|
||||
- `~/.config/systemd/user/<service>.service`
|
||||
- `~/.config/project-manager/<app-user>/<project>/service.env`
|
||||
- The app attempts to run user-systemd commands as that Unix user via:
|
||||
- `sudo -n -u <unix_user> systemctl --user ...`
|
||||
|
||||
## Existing Repos
|
||||
|
||||
- In project creation, set `repo_url` (or fill \"Existing Repo URL\" in UI) to onboard an existing repo.
|
||||
- If `repo_url` is provided, Forgejo repo creation is skipped.
|
||||
- If `repo_url` is empty, the app tries to create a Forgejo repo when Forgejo env vars are configured.
|
||||
|
||||
## Auto Deploy via Webhook
|
||||
|
||||
- Each project gets a unique deploy webhook URL, exposed as `webhook_url` in project API responses and shown in UI.
|
||||
- For repos hosted on the configured Forgejo instance, the app now auto-provisions the repository webhook via Forgejo API.
|
||||
- If auto-provisioning cannot apply (for example external non-Forgejo repo), configure a webhook manually to call `webhook_url`.
|
||||
- Deploy runs only when payload `ref` equals `refs/heads/main`.
|
||||
- Deploy behavior:
|
||||
1. Clone repo to `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/repo` if missing.
|
||||
2. Otherwise `fetch origin main` and `reset --hard origin/main`.
|
||||
3. Restart the project user service via `systemctl --user restart`.
|
||||
|
||||
Notes:
|
||||
- `WEBHOOK_BASE_URL` should be set to an externally reachable base URL so Forgejo can call webhook endpoints.
|
||||
|
||||
## Runtime Status and Logs
|
||||
|
||||
- Per-project runtime endpoints:
|
||||
- `GET /api/projects/:id/status`
|
||||
- `GET /api/projects/:id/logs?lines=200`
|
||||
- Status is read via `systemctl --user show <service>`.
|
||||
- Logs are read via `journalctl --user -u <service>`.
|
||||
- Both commands are executed as the workspace Unix user via `sudo -n -u <unix_user> ...`.
|
||||
|
||||
## Systemd
|
||||
|
||||
For each project in workspace `<ws>` owned by unix user `<unix_user>`, the app writes:
|
||||
- env file: `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/.env`
|
||||
- unit file: `<home-of-unix-user>/.config/systemd/user/projectmgr-<app-user>-<project>.service`
|
||||
|
||||
Then it attempts as that unix user:
|
||||
- `systemctl --user daemon-reload`
|
||||
- `systemctl --user enable --now <service>`
|
||||
|
||||
The generated `.env` is synced from the project env-var configuration and always includes:
|
||||
- `LISTEN_NETWORK=unix`
|
||||
- `LISTEN_ADDRESS=<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/app.sock`
|
||||
|
||||
If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated.
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module box
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
30
go.sum
Normal file
30
go.sum
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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
|
||||
}
|
||||
13
main.go
Normal file
13
main.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"box/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
202
web/app.js
Normal file
202
web/app.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const state = { projects: [], workspaces: [] };
|
||||
|
||||
async function init() {
|
||||
const me = await api('/api/me');
|
||||
document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`;
|
||||
|
||||
document.getElementById('workspaceForm').addEventListener('submit', createWorkspace);
|
||||
document.getElementById('createForm').addEventListener('submit', createProject);
|
||||
await refreshWorkspaces();
|
||||
await refreshProjects();
|
||||
}
|
||||
|
||||
async function refreshWorkspaces() {
|
||||
state.workspaces = await api('/api/workspaces');
|
||||
const select = document.getElementById('workspaceSelect');
|
||||
if (state.workspaces.length === 0) {
|
||||
select.innerHTML = '<option value="">Create a workspace first</option>';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = state.workspaces.map((w) => (
|
||||
`<option value="${w.id}">${escapeHtml(w.name)} (${escapeHtml(w.unix_user)})</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
state.projects = await api('/api/projects');
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const root = document.getElementById('projects');
|
||||
if (state.projects.length === 0) {
|
||||
root.innerHTML = '<p class="muted">No projects yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = '';
|
||||
state.projects.forEach((p) => {
|
||||
const div = document.createElement('article');
|
||||
div.className = 'project';
|
||||
div.innerHTML = `
|
||||
<h3>${escapeHtml(p.name)}</h3>
|
||||
<p class="meta">workspace: ${escapeHtml(p.workspace)} (${escapeHtml(p.unix_user)})</p>
|
||||
<p class="meta">slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}</p>
|
||||
<p class="meta">app target: 127.0.0.1:${escapeHtml(p.target_port)} | route host: ${escapeHtml(p.route_host || 'not set')}</p>
|
||||
<p class="meta">deploy webhook: <code>${escapeHtml(p.webhook_url || 'n/a')}</code></p>
|
||||
<p>${escapeHtml(p.description || '')}</p>
|
||||
${p.repo_url ? `<p><a class="link" href="${p.repo_url}" target="_blank" rel="noreferrer">Open Repository</a></p>` : '<p class="muted">No repository URL set.</p>'}
|
||||
<div class="env-row">
|
||||
<input class="small" placeholder="ENV_KEY" data-k />
|
||||
<input class="small" placeholder="value" data-v />
|
||||
<button class="small" data-add>Add/Update Env</button>
|
||||
<button class="small" data-regen>Regenerate Service</button>
|
||||
</div>
|
||||
<div class="env-list muted" data-env>Loading env vars...</div>
|
||||
<div class="env-row">
|
||||
<button class="small" data-status>Refresh Status</button>
|
||||
<button class="small" data-logs>Load Logs</button>
|
||||
</div>
|
||||
<div class="meta" data-status-out>status: loading...</div>
|
||||
<pre class="meta" data-logs-out style="white-space: pre-wrap; max-height: 220px; overflow: auto;">logs: not loaded</pre>
|
||||
`;
|
||||
|
||||
div.querySelector('[data-add]').addEventListener('click', async () => {
|
||||
const key = div.querySelector('[data-k]').value.trim();
|
||||
const value = div.querySelector('[data-v]').value;
|
||||
await api(`/api/projects/${p.id}/env`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
await loadEnvList(div, p.id);
|
||||
});
|
||||
|
||||
div.querySelector('[data-regen]').addEventListener('click', async () => {
|
||||
await api(`/api/projects/${p.id}/service`, { method: 'POST' });
|
||||
alert('Service regenerated and systemctl --user reload attempted.');
|
||||
});
|
||||
div.querySelector('[data-status]').addEventListener('click', async () => {
|
||||
await loadStatus(div, p.id);
|
||||
});
|
||||
div.querySelector('[data-logs]').addEventListener('click', async () => {
|
||||
await loadLogs(div, p.id);
|
||||
});
|
||||
|
||||
root.appendChild(div);
|
||||
loadEnvList(div, p.id);
|
||||
loadStatus(div, p.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEnvList(projectNode, projectID) {
|
||||
const envs = await api(`/api/projects/${projectID}/env`);
|
||||
const envRoot = projectNode.querySelector('[data-env]');
|
||||
if (envs.length === 0) {
|
||||
envRoot.innerHTML = 'No env vars';
|
||||
return;
|
||||
}
|
||||
envRoot.innerHTML = envs.map((e) => {
|
||||
return `<div><code>${escapeHtml(e.key)}</code>=<code>${escapeHtml(e.value)}</code> <button class="small danger" data-del="${escapeHtml(e.key)}">Delete</button></div>`;
|
||||
}).join('');
|
||||
|
||||
envRoot.querySelectorAll('[data-del]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const key = btn.getAttribute('data-del');
|
||||
await api(`/api/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
await loadEnvList(projectNode, projectID);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStatus(projectNode, projectID) {
|
||||
const out = projectNode.querySelector('[data-status-out]');
|
||||
out.textContent = 'status: loading...';
|
||||
try {
|
||||
const s = await api(`/api/projects/${projectID}/status`);
|
||||
out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`;
|
||||
} catch (err) {
|
||||
out.textContent = `status error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs(projectNode, projectID) {
|
||||
const out = projectNode.querySelector('[data-logs-out]');
|
||||
out.textContent = 'loading logs...';
|
||||
try {
|
||||
const res = await api(`/api/projects/${projectID}/logs?lines=200`);
|
||||
out.textContent = res.logs || '(no logs)';
|
||||
} catch (err) {
|
||||
out.textContent = `logs error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const payload = {
|
||||
workspace_id: Number(form.workspace_id.value),
|
||||
name: form.name.value,
|
||||
description: form.description.value,
|
||||
repo_url: form.repo_url.value.trim(),
|
||||
repo_private: form.repo_private.checked,
|
||||
target_port: Number(form.target_port.value || 8000)
|
||||
};
|
||||
const msg = document.getElementById('createMsg');
|
||||
msg.textContent = 'Creating project...';
|
||||
try {
|
||||
await api('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = 'Project created.';
|
||||
form.reset();
|
||||
form.repo_private.checked = true;
|
||||
await refreshProjects();
|
||||
} catch (err) {
|
||||
msg.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspace(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const payload = {
|
||||
name: form.name.value.trim(),
|
||||
unix_user: form.unix_user.value.trim()
|
||||
};
|
||||
const msg = document.getElementById('workspaceMsg');
|
||||
msg.textContent = 'Creating workspace...';
|
||||
try {
|
||||
await api('/api/workspaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = 'Workspace created.';
|
||||
form.reset();
|
||||
await refreshWorkspaces();
|
||||
} catch (err) {
|
||||
msg.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
document.getElementById('projects').innerHTML = `<p class="muted">Failed to load: ${escapeHtml(err.message)}</p>`;
|
||||
});
|
||||
51
web/index.html
Normal file
51
web/index.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Project Box</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="hero">
|
||||
<h1>Project Box</h1>
|
||||
<p>Create projects, provision Forgejo repositories, and keep service env vars in one place.</p>
|
||||
<div id="me" class="pill"></div>
|
||||
</header>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>Workspaces</h2>
|
||||
<form id="workspaceForm">
|
||||
<label>Workspace Name<input name="name" required placeholder="client-a" /></label>
|
||||
<label>Unix User<input name="unix_user" required placeholder="pavel" /></label>
|
||||
<button type="submit">Create Workspace</button>
|
||||
</form>
|
||||
<p id="workspaceMsg" class="muted"></p>
|
||||
</section>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>Create Project</h2>
|
||||
<form id="createForm">
|
||||
<label>Workspace<select name="workspace_id" id="workspaceSelect" required></select></label>
|
||||
<label>Name<input name="name" required placeholder="analytics-api" /></label>
|
||||
<label>Description<textarea name="description" placeholder="optional"></textarea></label>
|
||||
<label>Existing Repo URL (optional)<input name="repo_url" placeholder="https://forgejo.example.com/org/repo" /></label>
|
||||
<label>Target Port<input name="target_port" type="number" min="1" max="65535" value="8000" required /></label>
|
||||
<label class="inline"><input type="checkbox" name="repo_private" checked />Private Forgejo repo</label>
|
||||
<button type="submit">Create Project</button>
|
||||
</form>
|
||||
<p id="createMsg" class="muted"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Your Projects</h2>
|
||||
<div id="projects" class="projects"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
97
web/styles.css
Normal file
97
web/styles.css
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
:root {
|
||||
--bg: radial-gradient(circle at 10% 20%, #223 0%, #10131d 35%, #080a10 100%);
|
||||
--panel: rgba(255, 255, 255, 0.08);
|
||||
--panel-border: rgba(255, 255, 255, 0.2);
|
||||
--text: #ecf0ff;
|
||||
--muted: #b8bfd8;
|
||||
--accent: #5ee8b5;
|
||||
--danger: #ff7f8f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Manrope, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(980px, 92vw);
|
||||
margin: 2rem auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
margin: 0;
|
||||
}
|
||||
.hero p { color: var(--muted); }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
margin-top: .5rem;
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: .8rem;
|
||||
}
|
||||
|
||||
label { display: grid; gap: .4rem; font-weight: 500; }
|
||||
.inline { display: flex; align-items: center; gap: .5rem; }
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: rgba(0,0,0,.25);
|
||||
color: var(--text);
|
||||
padding: .65rem .75rem;
|
||||
}
|
||||
textarea { min-height: 80px; }
|
||||
button {
|
||||
background: linear-gradient(120deg, #4bcf9e, #63a8ff);
|
||||
color: #07111f;
|
||||
border: 0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.projects { display: grid; gap: .8rem; }
|
||||
.project {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: .8rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.project h3 { margin: 0 0 .4rem; font-family: "Space Grotesk", sans-serif; }
|
||||
.meta { color: var(--muted); font-size: .9rem; }
|
||||
.env-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: .5rem;
|
||||
margin-top: .6rem;
|
||||
}
|
||||
.small { padding: .45rem .55rem; font-size: .88rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.link { color: var(--accent); text-decoration: none; }
|
||||
.danger { background: var(--danger); color: #200; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.env-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue