Skip to content

Commit 3ed248c

Browse files
Copilotewega
andauthored
feat: add gh devlake stop command (#140)
* Initial plan * feat: add gh devlake stop command (#128) Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> * fix: guard Azure MySQL start behind startService == "" in start command Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
1 parent 68863eb commit 3ed248c

11 files changed

Lines changed: 685 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ See [Token Handling](docs/token-handling.md) for env key names and multi-plugin
228228
| `gh devlake configure project delete` | Delete a project | [configure-project.md](docs/configure-project.md) |
229229
| `gh devlake configure full` | Connections + scopes + project in one step | [configure-full.md](docs/configure-full.md) |
230230
| `gh devlake start` | Start stopped or exited DevLake services | [start.md](docs/start.md) |
231+
| `gh devlake stop` | Stop running services (preserves containers and data) | [stop.md](docs/stop.md) |
231232
| `gh devlake cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) |
232233

233234
### Global Flags

cmd/deploy.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,8 @@ func init() {
5454
startCmd := newStartCmd()
5555
startCmd.GroupID = "operate"
5656
rootCmd.AddCommand(startCmd)
57+
58+
stopCmd := newStopCmd()
59+
stopCmd.GroupID = "operate"
60+
rootCmd.AddCommand(stopCmd)
5761
}

cmd/start.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ func runAzureStart() error {
260260
}
261261
fmt.Fprintln(prog, " ✅ Logged in")
262262

263-
// ── Start MySQL ──
264-
if state.Resources.MySQL != "" {
263+
// ── Start MySQL (only when starting all services) ──
264+
if startService == "" && state.Resources.MySQL != "" {
265265
fmt.Fprintf(prog, "\n🐳 Starting MySQL server %q...\n", state.Resources.MySQL)
266266
if err := azurepkg.MySQLStart(state.Resources.MySQL, state.ResourceGroup); err != nil {
267267
fmt.Fprintf(prog, " ⚠️ Could not start MySQL: %v\n", err)

cmd/stop.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
azurepkg "github.com/DevExpGBB/gh-devlake/internal/azure"
12+
dockerpkg "github.com/DevExpGBB/gh-devlake/internal/docker"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var (
17+
stopService string
18+
stopAzure bool
19+
stopLocal bool
20+
stopState string
21+
)
22+
23+
func newStopCmd() *cobra.Command {
24+
cmd := &cobra.Command{
25+
Use: "stop",
26+
Short: "Stop running DevLake services (preserves containers and data)",
27+
Long: `Gracefully stops running DevLake services without removing containers, volumes, or state.
28+
29+
For local deployments (Docker Compose), runs 'docker compose stop', which preserves
30+
containers and volumes so they can be quickly restarted with 'gh devlake start'.
31+
32+
For Azure deployments, stops Container Instances and the MySQL server using the Azure CLI.
33+
34+
Auto-detects deployment type from state files in the current directory.
35+
36+
This is the non-destructive counterpart to 'gh devlake start'.
37+
Use 'gh devlake cleanup' to permanently tear down resources.`,
38+
RunE: runStop,
39+
}
40+
41+
cmd.Flags().StringVar(&stopService, "service", "", "Stop only a specific service (e.g., grafana)")
42+
cmd.Flags().BoolVar(&stopAzure, "azure", false, "Force Azure stop mode")
43+
cmd.Flags().BoolVar(&stopLocal, "local", false, "Force local (Docker Compose) stop mode")
44+
cmd.Flags().StringVar(&stopState, "state-file", "", "Path to state file (auto-detected if omitted)")
45+
46+
return cmd
47+
}
48+
49+
func runStop(cmd *cobra.Command, args []string) error {
50+
mode := detectStopMode()
51+
switch mode {
52+
case "local":
53+
return runLocalStop()
54+
case "azure":
55+
return runAzureStop()
56+
default:
57+
return fmt.Errorf("no deployment found — no state file or docker-compose.yml in current directory\nRun 'gh devlake deploy' to create a new deployment")
58+
}
59+
}
60+
61+
// detectStopMode determines whether to stop local (Docker Compose) or Azure resources.
62+
// Priority: explicit flags → explicit state file (inspected for method) → auto-detect files.
63+
func detectStopMode() string {
64+
if stopAzure {
65+
return "azure"
66+
}
67+
if stopLocal {
68+
return "local"
69+
}
70+
71+
// Check explicit state file — inspect its content rather than guessing from the filename.
72+
if stopState != "" {
73+
if data, err := os.ReadFile(stopState); err == nil {
74+
var meta struct {
75+
Method string `json:"method"`
76+
ResourceGroup string `json:"resourceGroup"`
77+
}
78+
if json.Unmarshal(data, &meta) == nil {
79+
switch strings.ToLower(meta.Method) {
80+
case "azure":
81+
return "azure"
82+
case "local", "docker-compose":
83+
return "local"
84+
}
85+
// If method is absent but resourceGroup is set, it's an Azure state file.
86+
if meta.ResourceGroup != "" {
87+
return "azure"
88+
}
89+
}
90+
}
91+
// File exists but could not be parsed, or method is unknown — fall through.
92+
return "local"
93+
}
94+
95+
// Auto-detect from well-known state file names.
96+
if _, err := os.Stat(".devlake-azure.json"); err == nil {
97+
return "azure"
98+
}
99+
if _, err := os.Stat(".devlake-local.json"); err == nil {
100+
return "local"
101+
}
102+
// Fall back to docker-compose.yml in cwd.
103+
if _, err := os.Stat("docker-compose.yml"); err == nil {
104+
return "local"
105+
}
106+
return ""
107+
}
108+
109+
func runLocalStop() error {
110+
// In JSON mode, all progress goes to stderr to keep stdout clean for JSON.
111+
var prog io.Writer = os.Stdout
112+
if outputJSON {
113+
prog = os.Stderr
114+
}
115+
116+
fmt.Fprintln(prog)
117+
fmt.Fprintln(prog, "════════════════════════════════════════")
118+
fmt.Fprintln(prog, " DevLake — Stop Services")
119+
fmt.Fprintln(prog, "════════════════════════════════════════")
120+
121+
// ── Check Docker ──
122+
fmt.Fprintln(prog, "\n🐳 Checking Docker...")
123+
if err := dockerpkg.CheckAvailable(); err != nil {
124+
return fmt.Errorf("Docker is not available: %w\nMake sure Docker Desktop or the Docker daemon is running", err)
125+
}
126+
fmt.Fprintln(prog, " ✅ Docker is running")
127+
128+
// ── Find deployment directory ──
129+
// When --state-file is provided, run docker compose from that file's directory
130+
// so it finds the correct docker-compose.yml.
131+
cwd, _ := os.Getwd()
132+
dir := cwd
133+
if stopState != "" {
134+
absState, err := filepath.Abs(stopState)
135+
if err != nil {
136+
fmt.Fprintf(prog, " ⚠️ Could not resolve --state-file path: %v — using current directory\n", err)
137+
} else {
138+
dir = filepath.Dir(absState)
139+
}
140+
}
141+
142+
// ── Determine services to stop ──
143+
var services []string
144+
if stopService != "" {
145+
services = []string{stopService}
146+
}
147+
148+
// ── Run docker compose stop ──
149+
if len(services) > 0 {
150+
fmt.Fprintf(prog, "\n🐳 Stopping service %q in %s...\n", stopService, dir)
151+
} else {
152+
fmt.Fprintf(prog, "\n🐳 Stopping containers in %s...\n", dir)
153+
}
154+
if err := dockerpkg.ComposeStop(dir, services...); err != nil {
155+
return fmt.Errorf("failed to stop containers: %w", err)
156+
}
157+
fmt.Fprintln(prog, " ✅ Containers stopped (data preserved)")
158+
159+
if outputJSON {
160+
return printJSON(map[string]string{"status": "stopped", "mode": "local"})
161+
}
162+
163+
fmt.Fprintln(prog)
164+
fmt.Fprintln(prog, "════════════════════════════════════════")
165+
fmt.Fprintln(prog, " ✅ Services Stopped!")
166+
fmt.Fprintln(prog, "════════════════════════════════════════")
167+
fmt.Fprintln(prog)
168+
fmt.Fprintln(prog, " Containers and volumes are preserved.")
169+
fmt.Fprintln(prog, " Run 'gh devlake start' to bring them back up.")
170+
fmt.Fprintln(prog)
171+
return nil
172+
}
173+
174+
func runAzureStop() error {
175+
// In JSON mode, all progress goes to stderr to keep stdout clean for JSON.
176+
var prog io.Writer = os.Stdout
177+
if outputJSON {
178+
prog = os.Stderr
179+
}
180+
181+
fmt.Fprintln(prog)
182+
fmt.Fprintln(prog, "════════════════════════════════════════")
183+
fmt.Fprintln(prog, " DevLake Azure — Stop Services")
184+
fmt.Fprintln(prog, "════════════════════════════════════════")
185+
186+
stateFile := stopState
187+
if stateFile == "" {
188+
stateFile = ".devlake-azure.json"
189+
}
190+
191+
var state azureStateData
192+
data, err := os.ReadFile(stateFile)
193+
if err != nil {
194+
if os.IsNotExist(err) {
195+
return fmt.Errorf("state file not found: %s\nUse --state-file to specify the path", stateFile)
196+
}
197+
return fmt.Errorf("failed to read state file %s: %w", stateFile, err)
198+
}
199+
if err := json.Unmarshal(data, &state); err != nil {
200+
return fmt.Errorf("invalid state file: %w", err)
201+
}
202+
if state.ResourceGroup == "" {
203+
return fmt.Errorf("state file %s has no resource group — cannot stop Azure resources", stateFile)
204+
}
205+
206+
// ── Check Azure CLI login ──
207+
fmt.Fprintln(prog, "\n🔑 Checking Azure login...")
208+
if _, err := azurepkg.CheckLogin(); err != nil {
209+
return fmt.Errorf("not logged in to Azure CLI — run 'az login' first")
210+
}
211+
fmt.Fprintln(prog, " ✅ Logged in")
212+
213+
// ── Stop containers ──
214+
containers := state.Resources.Containers
215+
if stopService != "" {
216+
var filtered []string
217+
for _, c := range containers {
218+
if strings.Contains(c, stopService) {
219+
filtered = append(filtered, c)
220+
}
221+
}
222+
if len(filtered) == 0 {
223+
return fmt.Errorf("no container matching %q found in state file", stopService)
224+
}
225+
containers = filtered
226+
}
227+
228+
for _, container := range containers {
229+
fmt.Fprintf(prog, "\n📦 Stopping container %q...\n", container)
230+
if err := azurepkg.ContainerStop(container, state.ResourceGroup); err != nil {
231+
fmt.Fprintf(prog, " ⚠️ Could not stop %s: %v\n", container, err)
232+
} else {
233+
fmt.Fprintln(prog, " ✅ Stop initiated")
234+
}
235+
}
236+
237+
// ── Stop MySQL (only when stopping all services) ──
238+
if stopService == "" && state.Resources.MySQL != "" {
239+
fmt.Fprintf(prog, "\n🐳 Stopping MySQL server %q...\n", state.Resources.MySQL)
240+
if err := azurepkg.MySQLStop(state.Resources.MySQL, state.ResourceGroup); err != nil {
241+
fmt.Fprintf(prog, " ⚠️ Could not stop MySQL: %v\n", err)
242+
} else {
243+
fmt.Fprintln(prog, " ✅ MySQL stop initiated")
244+
}
245+
}
246+
247+
if outputJSON {
248+
return printJSON(map[string]string{"status": "stopped", "mode": "azure"})
249+
}
250+
251+
fmt.Fprintln(prog)
252+
fmt.Fprintln(prog, "════════════════════════════════════════")
253+
fmt.Fprintln(prog, " ✅ Services Stopped!")
254+
fmt.Fprintln(prog, "════════════════════════════════════════")
255+
fmt.Fprintln(prog)
256+
fmt.Fprintln(prog, " Run 'gh devlake start --azure' to bring them back up.")
257+
fmt.Fprintln(prog)
258+
return nil
259+
}

0 commit comments

Comments
 (0)