-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdestroy.go
More file actions
259 lines (242 loc) · 10.4 KB
/
Copy pathdestroy.go
File metadata and controls
259 lines (242 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
package cli
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/open-source-cloud/devstack/internal/docker"
"github.com/open-source-cloud/devstack/internal/generate"
"github.com/open-source-cloud/devstack/internal/lock"
"github.com/open-source-cloud/devstack/internal/orchestrate"
"github.com/open-source-cloud/devstack/internal/resource"
)
// newWorkspaceCmd wires `devstack workspace <sub>` — workspace-scoped lifecycle
// (spec 13 teardown). Today it carries `destroy`; `uninstall` (machine-global
// teardown incl. CA removal) is a separate top-level verb.
func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
cmd := &cobra.Command{
Use: "workspace",
Short: "Workspace-level lifecycle (teardown)",
}
cmd.AddCommand(newWorkspaceDestroyCmd(g), newWorkspaceListCmd(g))
return cmd
}
// newWorkspaceDestroyCmd wires `devstack workspace destroy` (spec 13): tear down
// THIS workspace's project stacks, release its ref/port ledger rows, warm-stop
// any shared service left at zero refs, and remove generated `.devstack/`
// artifacts. It leaves machine-global state (the shared network, the CA, alias
// symlinks) intact for other workspaces, and — by design in this first cut —
// PRESERVES data: named volumes and provisioned DBs/roles survive (shared
// per-service volume removal is `uninstall`/`db gc` territory).
func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
var yes, purgeData bool
cmd := &cobra.Command{
Use: "destroy",
Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)",
Long: "destroy reverses what `up` created for this workspace: it `compose down`s every\n" +
"project stack, drops the workspace's ref + port ledger rows (under the lock),\n" +
"warm-stops any shared service now at zero references, and removes the generated\n" +
"`.devstack/` artifacts.\n\n" +
"It is data-preserving: named volumes and provisioned databases SURVIVE, and the\n" +
"shared network / local CA / alias symlinks are left for other workspaces. Full\n" +
"data + machine-global removal is `uninstall`.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
// Fail fast before touching Docker: a scripted (--json) run can't answer
// an interactive prompt, so it must pass --yes explicitly.
if g.JSON && !yes {
return fmt.Errorf("refusing to destroy without confirmation: pass --yes for --json/non-interactive use")
}
d, closeFn, err := buildUpDeps(cmd)
if err != nil {
return err
}
defer closeFn()
projects := sortedProjectNames(d.Model)
if !yes {
dataLine := "Volumes and databases are PRESERVED."
if purgeData {
dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc AND removes the shared volumes (DATA DESTROYED)."
}
prompt := fmt.Sprintf(
"This tears down workspace %q (%d project stack(s)) and releases its refs/ports.\n"+
"%s Type 'yes' to continue: ",
d.Model.Workspace.Name, len(projects), dataLine)
if !confirm(cmd, prompt) {
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
return nil
}
}
res := destroyWorkspace(cmd.Context(), d, projects, purgeData)
if g.JSON {
if err := writeJSON(cmd, res); err != nil {
return err
}
} else {
w := cmd.OutOrStdout()
for _, p := range res.Projects {
fmt.Fprintf(w, "[ok] down %s\n", p)
}
for _, s := range res.SharedStopped {
fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s)
}
for _, p := range res.PurgedResources {
fmt.Fprintf(w, "[ok] dropped %s %s\n", p["kind"], p["name"])
}
for _, v := range res.PurgedVolumes {
fmt.Fprintf(w, "[ok] removed volumes for %s\n", v)
}
for _, e := range res.Errors {
fmt.Fprintf(w, "[warn] %s\n", e)
}
dataNote := "volumes/DBs preserved"
if purgeData {
dataNote = fmt.Sprintf("%d resource(s) purged", len(res.PurgedResources))
}
fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (%s)\n",
d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped), dataNote)
}
if len(res.Errors) > 0 {
return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors))
}
return nil
},
}
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)")
cmd.Flags().BoolVar(&purgeData, "purge-data", false, "also DROP every provisioned resource (databases/buckets/…) — DESTRUCTIVE")
return cmd
}
// DestroyResult is the machine-readable outcome of `workspace destroy`.
type DestroyResult struct {
Workspace string `json:"workspace"`
Projects []string `json:"projects"` // project stacks brought down
SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped
PurgedResources []map[string]string `json:"purged_resources,omitempty"` // --purge-data: resources dropped
PurgedVolumes []string `json:"purged_volumes,omitempty"` // --purge-data: shared volumes removed (compose down -v)
Errors []string `json:"errors,omitempty"`
}
// destroyWorkspace performs the teardown mechanics (no prompting) so it is
// unit-testable with injected mocks. It is best-effort: a failure on one project
// is recorded and the rest proceed, so a partially-broken workspace can still be
// cleaned up.
func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string, purgeData bool) DestroyResult {
res := DestroyResult{Workspace: d.Model.Workspace.Name}
runner := d.Runner
if runner == nil {
runner = docker.ExecRunner{}
}
// 0. --purge-data (opt-in, DESTRUCTIVE): while the shared engine is still up,
// DROP every resource this workspace provisioned, then remove its ledger rows.
// The data-preserving default skips this entirely (spec 27).
if purgeData {
for _, p := range projects {
rows, err := d.DB.ProvisionedFor(p)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("list resources for %s: %v", p, err))
continue
}
for _, row := range rows {
if row.Kind == "role" {
continue // dropped alongside its database
}
r := resource.Resource{Engine: engineForKindGuess(row.Kind), Kind: row.Kind, Name: row.Name, Owner: p}
if err := orchestrate.DropResource(ctx, d, r, true); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("drop %s %s: %v", row.Kind, row.Name, err))
continue
}
res.PurgedResources = append(res.PurgedResources, map[string]string{"project": p, "kind": row.Kind, "name": row.Name})
}
// Remove any straggler rows (e.g. redis_index) and the overlay ports.
if err := lock.WithLock(ctx, d.LockPath, func() error {
if _, err := d.DB.RemoveProvisionedForProject(p); err != nil {
return err
}
return nil
}); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("clear provisioned rows for %s: %v", p, err))
}
}
}
// 1. compose down each project stack (containers + project default network;
// named volumes survive — never -v here).
for _, p := range projects {
outDir := filepath.Join(d.Model.ProjectDir(p), generate.GenDir)
composeFile := filepath.Join(outDir, generate.ComposeFile)
if _, err := os.Stat(composeFile); err != nil {
composeFile = "" // label-driven `compose -p devstack-<p> down`
}
cp := docker.Compose{Project: "devstack-" + p, File: composeFile, Dir: outDir, Runner: runner}
if err := cp.Down(ctx, false); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("down %s: %v", p, err))
continue
}
res.Projects = append(res.Projects, p)
}
// 2. drop this workspace's ledger rows (refs + ports) under the flock.
if err := lock.WithLock(ctx, d.LockPath, func() error {
for _, p := range projects {
if _, err := d.DB.RemoveProjectRefs(p); err != nil {
return err
}
if err := d.DB.ReleasePortsFor(p); err != nil {
return err
}
}
return nil
}); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("ledger cleanup: %v", err))
}
// 3. warm-stop any shared service now at zero refs (reversible; volumes
// survive). GC enumerates by ledger ∩ live labels, so it only touches
// services no OTHER workspace references.
if gc, err := d.Manager.GC(ctx, true); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("shared gc: %v", err))
} else {
res.SharedStopped = gc.Stopped
}
// 3b. --purge-data ALSO removes the shared stack's named volumes (the postgres
// PGDATA, MinIO object store, …) via `compose down -v`. This is gated on the
// shared stack being fully orphaned (no ref rows remain from ANY workspace), so
// destroy can never drop data another workspace still depends on. The
// data-preserving default never reaches here.
if purgeData {
remaining, err := d.DB.AllRefs()
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("check remaining refs: %v", err))
} else if len(remaining) == 0 {
outDir := filepath.Join(d.Model.Root, generate.GenDir, "shared")
composeFile := filepath.Join(outDir, generate.ComposeFile)
if _, err := os.Stat(composeFile); err != nil {
composeFile = "" // label-driven `compose -p devstack-shared down -v`
}
cp := docker.Compose{Project: generate.SharedStackName, File: composeFile, Dir: outDir, Runner: runner}
if err := cp.Down(ctx, true); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("purge shared volumes: %v", err))
} else {
// Report the services whose volumes were removed (fall back to the
// stack name if nothing was warm-stopped this pass).
if len(res.SharedStopped) > 0 {
res.PurgedVolumes = append(res.PurgedVolumes, res.SharedStopped...)
} else {
res.PurgedVolumes = append(res.PurgedVolumes, generate.SharedStackName)
}
}
}
}
// 4. remove generated artifacts (.devstack): the workspace-root shared dir and
// each project's dir. Best-effort — a missing dir is fine.
_ = os.RemoveAll(filepath.Join(d.Model.Root, generate.GenDir))
for _, p := range projects {
_ = os.RemoveAll(filepath.Join(d.Model.ProjectDir(p), generate.GenDir))
}
return res
}
// confirm prompts on stdout and returns true only if the user types "yes".
func confirm(cmd *cobra.Command, prompt string) bool {
fmt.Fprint(cmd.OutOrStdout(), prompt)
line, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
return strings.EqualFold(strings.TrimSpace(line), "yes")
}