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