diff --git a/README.md b/README.md index a5188b1..37282b4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Notes: - Per-project runtime endpoints: - `GET /api/projects/:id/status` - `GET /api/projects/:id/logs?lines=200` + - `POST /api/projects/:id/reprovision` (retry provisioning for failed/pending projects) - Status is read via `systemctl --user show `. - Logs are read via `journalctl --user -u `. - Both commands are executed as the workspace Unix user via `sudo -n -u ...`. diff --git a/internal/app/config.go b/internal/app/config.go index b447341..90fdf54 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -9,6 +9,7 @@ import ( ) func loadConfig() (Config, error) { + projectRoot := expandPath(getenv("PROJECTS_ROOT", "$HOME/projects")) cfg := Config{ DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), ListenAddr: getenv("LISTEN_ADDR", ":8080"), @@ -21,7 +22,7 @@ func loadConfig() (Config, error) { ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoOrg: getenv("FORGEJO_ORG", ""), - ProjectRoot: getenv("PROJECTS_ROOT", filepath.Join(userHomeOrDot(), "projects")), + ProjectRoot: projectRoot, DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "go run ."), CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), @@ -35,6 +36,23 @@ func loadConfig() (Config, error) { return cfg, nil } +func expandPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return filepath.Join(userHomeOrDot(), "projects") + } + if strings.HasPrefix(p, "~/") { + p = filepath.Join(userHomeOrDot(), strings.TrimPrefix(p, "~/")) + } + p = os.ExpandEnv(p) + if !filepath.IsAbs(p) { + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + } + return filepath.Clean(p) +} + func userHomeOrDot() string { h, err := os.UserHomeDir() if err != nil { diff --git a/internal/app/db.go b/internal/app/db.go index 2a057bf..a68f50d 100644 --- a/internal/app/db.go +++ b/internal/app/db.go @@ -20,6 +20,7 @@ func (a *App) migrate(ctx context.Context) error { slug TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL DEFAULT '', + repo_private BOOLEAN NOT NULL DEFAULT true, service_name TEXT NOT NULL, route_host TEXT NOT NULL DEFAULT '', target_port INT NOT NULL DEFAULT 8000, @@ -47,6 +48,7 @@ func (a *App) migrate(ctx context.Context) error { `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 repo_private BOOLEAN NOT NULL DEFAULT true;`, `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 '';`, } diff --git a/internal/app/project_service.go b/internal/app/project_service.go index b4fd804..5373faf 100644 --- a/internal/app/project_service.go +++ b/internal/app/project_service.go @@ -9,7 +9,7 @@ import ( ) 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 + rows, err := a.db.QueryContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, 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) @@ -20,7 +20,7 @@ func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error 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 { + if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &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) @@ -54,9 +54,9 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n } 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) + err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending','') RETURNING id, user_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, workspace_id, deploy_token, provision_state, provision_error, created_at`, + user.Username, workspaceID, name, slug, description, repoURL, private, serviceName, routeHost, targetPort, deployToken, + ).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &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") @@ -70,57 +70,67 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n p.Workspace = workspace.Name p.UnixUser = workspace.UnixUser p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + return p, a.provisionProject(ctx, &p) +} +func (a *App) provisionProject(ctx context.Context, p *Project) error { if p.RepoURL == "" { - repoURL, err = a.createForgejoRepo(ctx, slug, description, private) + if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { + err := errors.New("repo_url is empty and Forgejo is not configured; set FORGEJO_BASE_URL/FORGEJO_TOKEN or provide repo_url") + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return err + } + repoURL, err := a.createForgejoRepo(ctx, p.Slug, p.Description, p.RepoPrivate) if err != nil { _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) p.ProvisionState = "failed" p.ProvisionError = err.Error() - return p, err + return 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 + return err } } - if err := a.ensureForgejoDeployWebhook(ctx, p); err != nil { + 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 + return err } - if err := a.renderSystemdForProject(ctx, p); err != nil { + 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 + return err } - if err := a.ensureCaddyRoute(ctx, p); err != nil { + 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 + return err } if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil { p.ProvisionState = "failed" p.ProvisionError = err.Error() - return p, err + return err } p.ProvisionState = "provisioned" p.ProvisionError = "" - return p, nil + return 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 + err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, 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) + Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &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") @@ -136,6 +146,22 @@ func (a *App) updateProvisioningState(ctx context.Context, projectID int64, stat return err } +func (a *App) reprovisionProject(ctx context.Context, userID string, projectID int64) (Project, error) { + p, err := a.getProject(ctx, userID, projectID) + if err != nil { + return Project{}, err + } + if err := a.updateProvisioningState(ctx, p.ID, "pending", ""); err != nil { + return Project{}, err + } + p.ProvisionState = "pending" + p.ProvisionError = "" + if err := a.provisionProject(ctx, &p); err != nil { + return p, err + } + return p, nil +} + 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 diff --git a/internal/app/routes_handlers.go b/internal/app/routes_handlers.go index 5fc1b69..27af924 100644 --- a/internal/app/routes_handlers.go +++ b/internal/app/routes_handlers.go @@ -136,6 +136,8 @@ func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, use a.handleProjectEnv(w, r, user, projectID) case "service": a.handleServiceRegenerate(w, r, user, projectID) + case "reprovision": + a.handleProjectReprovision(w, r, user, projectID) case "status": a.handleProjectStatus(w, r, user, projectID) case "logs": @@ -145,6 +147,19 @@ func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, use } } +func (a *App) handleProjectReprovision(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.reprovisionProject(r.Context(), user.Username, projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, p) +} + func (a *App) handleProjectEnv(w http.ResponseWriter, r *http.Request, user User, projectID int64) { switch r.Method { case http.MethodGet: diff --git a/internal/app/types.go b/internal/app/types.go index 8f46238..4d112ce 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -43,6 +43,7 @@ type Project struct { Slug string `json:"slug"` Description string `json:"description"` RepoURL string `json:"repo_url"` + RepoPrivate bool `json:"repo_private"` ServiceName string `json:"service_name"` RouteHost string `json:"route_host"` TargetPort int `json:"target_port"` diff --git a/web/app.js b/web/app.js index 50cc317..ba3d207 100644 --- a/web/app.js +++ b/web/app.js @@ -53,6 +53,7 @@ function renderProjects() {

slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}

app target: 127.0.0.1:${escapeHtml(p.target_port)} | route host: ${escapeHtml(p.route_host || 'not set')}

deploy webhook: ${escapeHtml(p.webhook_url || 'n/a')}

+

provisioning: ${escapeHtml(p.provision_state || 'unknown')}${p.provision_error ? ` | error: ${escapeHtml(p.provision_error)}` : ''}

${escapeHtml(p.description || '')}

${p.repo_url ? `

Open Repository

` : '

No repository URL set.

'}
@@ -65,6 +66,7 @@ function renderProjects() {
+
status: loading...
logs: not loaded
@@ -90,6 +92,10 @@ function renderProjects() { div.querySelector('[data-logs]').addEventListener('click', async () => { await loadLogs(div, p.id); }); + div.querySelector('[data-reprovision]').addEventListener('click', async () => { + await api(`/api/projects/${p.id}/reprovision`, { method: 'POST' }); + await refreshProjects(); + }); root.appendChild(div); loadEnvList(div, p.id);