unprovision

This commit is contained in:
pavel 2026-05-15 18:41:27 +02:00
commit 1e53bba6e7
6 changed files with 148 additions and 0 deletions

View file

@ -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 <name>] [--unix-user <unix_user>]
go run ./cmd/boxctl provision --name <project_name> [--repo-url <url>] [--description <text>] [--private=true|false]
go run ./cmd/boxctl unprovision --name <project_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
`
}

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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
}

View file

@ -74,6 +74,7 @@ function renderProjects() {
<button class="small" data-status>Refresh Status</button>
<button class="small" data-logs>Load Logs</button>
<button class="small" data-reprovision>Reprovision</button>
<button class="small danger" data-unprovision>Unprovision</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>
@ -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);