diff --git a/internal/app/cli.go b/internal/app/cli.go index 3ea6f70..c281c40 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -46,6 +46,8 @@ func RunCLI(args []string) error { switch args[0] { case "provision": return runCLIProvision(args[1:]) + case "unprovision": + return runCLIUnprovision(args[1:]) case "login": return runCLILogin(args[1:]) case "help", "--help", "-h": @@ -121,6 +123,44 @@ func runCLIProvision(args []string) error { return nil } +func runCLIUnprovision(args []string) error { + state, _ := loadCLIState() + fs := flag.NewFlagSet("unprovision", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var apiURL, name string + fs.StringVar(&apiURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL") + fs.StringVar(&name, "name", "", "project name (required)") + if err := fs.Parse(args); err != nil { + return err + } + apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") + name = strings.TrimSpace(name) + if apiURL == "" || name == "" { + return errors.New("required flags: --api-url, --name") + } + state.APIURL = apiURL + c := cliClient{baseURL: apiURL, state: &state} + if err := c.ensureAccessToken(); err != nil { + return err + } + if err := saveCLIState(state); err != nil { + return err + } + existing, err := c.findProjectBySlug(slugify(name)) + if err != nil { + return err + } + if existing == nil { + return errors.New("project not found") + } + if err := c.postEmpty(fmt.Sprintf("/api/projects/%d/unprovision", existing.ID)); err != nil { + return err + } + fmt.Printf("project unprovisioned id=%d name=%s slug=%s\n", existing.ID, existing.Name, existing.Slug) + return nil +} + func runCLILogin(args []string) error { state, _ := loadCLIState() fs := flag.NewFlagSet("login", flag.ContinueOnError) @@ -448,6 +488,28 @@ func (c *cliClient) postJSON(path string, payload any, out any) error { return json.Unmarshal(body, out) } +func (c *cliClient) postEmpty(path string) error { + if err := c.ensureAccessToken(); err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader([]byte("{}"))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.state.AccessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + func printCLIProject(action string, p Project) { fmt.Printf("project %s id=%d name=%s slug=%s\n", action, p.ID, p.Name, p.Slug) fmt.Printf("workspace=%s unix_user=%s\n", p.Workspace, p.UnixUser) @@ -533,9 +595,11 @@ func cliUsageText() string { go run . # server go run ./cmd/boxctl login --api-url https://box.example.com --issuer https://auth.example.com/application/o/box --client-id box-cli [--workspace ] [--unix-user ] go run ./cmd/boxctl provision --name [--repo-url ] [--description ] [--private=true|false] + go run ./cmd/boxctl unprovision --name Commands: login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json provision Upsert project via API using bearer token + unprovision Tear down project service/db/route and remove project record ` } diff --git a/internal/app/db_provisioning.go b/internal/app/db_provisioning.go index d138ea0..7638af2 100644 --- a/internal/app/db_provisioning.go +++ b/internal/app/db_provisioning.go @@ -62,6 +62,32 @@ END $$;`, sqlQuoteLiteral(p.DBUser), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p. return nil } +func (a *App) dropProjectDatabase(ctx context.Context, p Project) error { + if !a.cfg.DBProvisionEnabled { + return nil + } + if strings.TrimSpace(p.DBName) == "" || strings.TrimSpace(p.DBUser) == "" { + return nil + } + adminDB, err := sql.Open("pgx", a.cfg.DBProvisionAdminURL) + if err != nil { + return fmt.Errorf("db provisioning connect failed: %w", err) + } + defer adminDB.Close() + if err := adminDB.PingContext(ctx); err != nil { + return fmt.Errorf("db provisioning ping failed: %w", err) + } + // Terminate active sessions so DROP DATABASE succeeds. + _, _ = adminDB.ExecContext(ctx, `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, p.DBName) + if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %s`, sqlQuoteIdent(p.DBName))); err != nil { + return fmt.Errorf("drop database failed: %w", err) + } + if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`DROP ROLE IF EXISTS %s`, sqlQuoteIdent(p.DBUser))); err != nil { + return fmt.Errorf("drop role failed: %w", err) + } + return nil +} + func (a *App) projectDatabaseURL(p Project) (string, error) { if p.DBName == "" || p.DBUser == "" || p.DBPassword == "" { return "", nil diff --git a/internal/app/project_service.go b/internal/app/project_service.go index a124534..9bda2f6 100644 --- a/internal/app/project_service.go +++ b/internal/app/project_service.go @@ -211,6 +211,26 @@ func (a *App) reprovisionProject(ctx context.Context, userID string, projectID i return p, nil } +func (a *App) unprovisionProject(ctx context.Context, userID string, projectID int64) error { + p, err := a.getProject(ctx, userID, projectID) + if err != nil { + return err + } + if err := a.removeSystemdForProject(p); err != nil { + return err + } + if err := a.dropProjectDatabase(ctx, p); err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `DELETE FROM projects WHERE id=$1 AND user_id=$2`, p.ID, userID); err != nil { + return err + } + if err := a.syncCaddyConfigFragment(ctx); err != nil { + return err + } + return 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 2e191bd..0b4a504 100644 --- a/internal/app/routes_handlers.go +++ b/internal/app/routes_handlers.go @@ -143,6 +143,8 @@ func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, use a.handleServiceRegenerate(w, r, user, projectID) case "reprovision": a.handleProjectReprovision(w, r, user, projectID) + case "unprovision": + a.handleProjectUnprovision(w, r, user, projectID) case "status": a.handleProjectStatus(w, r, user, projectID) case "logs": @@ -165,6 +167,18 @@ func (a *App) handleProjectReprovision(w http.ResponseWriter, r *http.Request, u writeJSON(w, http.StatusOK, p) } +func (a *App) handleProjectUnprovision(w http.ResponseWriter, r *http.Request, user User, projectID int64) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := a.unprovisionProject(r.Context(), user.Username, projectID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unprovisioned"}) +} + 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/systemd_service.go b/internal/app/systemd_service.go index 1ff34d0..de834d2 100644 --- a/internal/app/systemd_service.go +++ b/internal/app/systemd_service.go @@ -257,3 +257,20 @@ func (a *App) getServiceStatus(p Project) (ServiceStatus, error) { func (a *App) getServiceLogs(p Project, lines int) (string, error) { return runJournalctlUser(p.UnixUser, "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-r", "-o", "short-iso") } + +func (a *App) removeSystemdForProject(p Project) error { + homeDir, err := homeForUnixUser(p.UnixUser) + if err != nil { + return err + } + servicePath := filepath.Join(homeDir, ".config", "systemd", "user", p.ServiceName) + projectDir, err := a.projectDirFor(p) + if err != nil { + return err + } + _ = runSystemctlUser(p.UnixUser, "disable", "--now", p.ServiceName) + _ = os.Remove(servicePath) + _ = runSystemctlUser(p.UnixUser, "daemon-reload") + _ = os.Remove(filepath.Join(projectDir, "app.sock")) + return nil +} diff --git a/web/app.js b/web/app.js index 1fbe829..f185d60 100644 --- a/web/app.js +++ b/web/app.js @@ -74,6 +74,7 @@ function renderProjects() { +
status: loading...
logs: not loaded
@@ -103,6 +104,12 @@ function renderProjects() { await api(`/projects/${p.id}/reprovision`, { method: 'POST' }); await refreshProjects(); }); + div.querySelector('[data-unprovision]').addEventListener('click', async () => { + const ok = confirm(`Unprovision ${p.name}? This removes service/db/route and project record.`); + if (!ok) return; + await api(`/projects/${p.id}/unprovision`, { method: 'POST' }); + await refreshProjects(); + }); root.appendChild(div); loadEnvList(div, p.id);