unprovision
This commit is contained in:
parent
22c2f68c2a
commit
1e53bba6e7
6 changed files with 148 additions and 0 deletions
|
|
@ -46,6 +46,8 @@ func RunCLI(args []string) error {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "provision":
|
case "provision":
|
||||||
return runCLIProvision(args[1:])
|
return runCLIProvision(args[1:])
|
||||||
|
case "unprovision":
|
||||||
|
return runCLIUnprovision(args[1:])
|
||||||
case "login":
|
case "login":
|
||||||
return runCLILogin(args[1:])
|
return runCLILogin(args[1:])
|
||||||
case "help", "--help", "-h":
|
case "help", "--help", "-h":
|
||||||
|
|
@ -121,6 +123,44 @@ func runCLIProvision(args []string) error {
|
||||||
return nil
|
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 {
|
func runCLILogin(args []string) error {
|
||||||
state, _ := loadCLIState()
|
state, _ := loadCLIState()
|
||||||
fs := flag.NewFlagSet("login", flag.ContinueOnError)
|
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)
|
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) {
|
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("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)
|
fmt.Printf("workspace=%s unix_user=%s\n", p.Workspace, p.UnixUser)
|
||||||
|
|
@ -533,9 +595,11 @@ func cliUsageText() string {
|
||||||
go run . # server
|
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 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 provision --name <project_name> [--repo-url <url>] [--description <text>] [--private=true|false]
|
||||||
|
go run ./cmd/boxctl unprovision --name <project_name>
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json
|
login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json
|
||||||
provision Upsert project via API using bearer token
|
provision Upsert project via API using bearer token
|
||||||
|
unprovision Tear down project service/db/route and remove project record
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,32 @@ END $$;`, sqlQuoteLiteral(p.DBUser), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p.
|
||||||
return nil
|
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) {
|
func (a *App) projectDatabaseURL(p Project) (string, error) {
|
||||||
if p.DBName == "" || p.DBUser == "" || p.DBPassword == "" {
|
if p.DBName == "" || p.DBUser == "" || p.DBPassword == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,26 @@ func (a *App) reprovisionProject(ctx context.Context, userID string, projectID i
|
||||||
return p, nil
|
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) {
|
func (a *App) listEnvVars(ctx context.Context, userID string, projectID int64) ([]EnvVar, error) {
|
||||||
if _, err := a.getProject(ctx, userID, projectID); err != nil {
|
if _, err := a.getProject(ctx, userID, projectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,8 @@ func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, use
|
||||||
a.handleServiceRegenerate(w, r, user, projectID)
|
a.handleServiceRegenerate(w, r, user, projectID)
|
||||||
case "reprovision":
|
case "reprovision":
|
||||||
a.handleProjectReprovision(w, r, user, projectID)
|
a.handleProjectReprovision(w, r, user, projectID)
|
||||||
|
case "unprovision":
|
||||||
|
a.handleProjectUnprovision(w, r, user, projectID)
|
||||||
case "status":
|
case "status":
|
||||||
a.handleProjectStatus(w, r, user, projectID)
|
a.handleProjectStatus(w, r, user, projectID)
|
||||||
case "logs":
|
case "logs":
|
||||||
|
|
@ -165,6 +167,18 @@ func (a *App) handleProjectReprovision(w http.ResponseWriter, r *http.Request, u
|
||||||
writeJSON(w, http.StatusOK, p)
|
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) {
|
func (a *App) handleProjectEnv(w http.ResponseWriter, r *http.Request, user User, projectID int64) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|
|
||||||
|
|
@ -257,3 +257,20 @@ func (a *App) getServiceStatus(p Project) (ServiceStatus, error) {
|
||||||
func (a *App) getServiceLogs(p Project, lines int) (string, 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")
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ function renderProjects() {
|
||||||
<button class="small" data-status>Refresh Status</button>
|
<button class="small" data-status>Refresh Status</button>
|
||||||
<button class="small" data-logs>Load Logs</button>
|
<button class="small" data-logs>Load Logs</button>
|
||||||
<button class="small" data-reprovision>Reprovision</button>
|
<button class="small" data-reprovision>Reprovision</button>
|
||||||
|
<button class="small danger" data-unprovision>Unprovision</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta" data-status-out>status: loading...</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>
|
<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 api(`/projects/${p.id}/reprovision`, { method: 'POST' });
|
||||||
await refreshProjects();
|
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);
|
root.appendChild(div);
|
||||||
loadEnvList(div, p.id);
|
loadEnvList(div, p.id);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue