Skip to content

Commit b53283e

Browse files
committed
v1.0.9: fix PullImage on host, wait health none, push fallback to local image
1 parent ace68dd commit b53283e

17 files changed

Lines changed: 265 additions & 45 deletions

File tree

cli/cmd/push.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,21 @@ func runPush(cmd *cobra.Command, args []string) error {
7373
if err != nil {
7474
return fmt.Errorf("list containers for %s: %w", name, err)
7575
}
76+
var sourceImage string
7677
if len(containers) == 0 {
77-
fmt.Fprintf(os.Stderr, " Warning: no container found for service %s\n", name)
78-
continue
78+
// No running container — try to find a local image built by devbox build/start
79+
sourceImage, _ = findLocalImage(ctx, dockerClient, "devbox-"+name+":latest")
80+
if sourceImage == "" {
81+
sourceImage, _ = findLocalImage(ctx, dockerClient, "devboxos-"+name+":latest")
82+
}
83+
if sourceImage == "" {
84+
fmt.Fprintf(os.Stderr, " Warning: no container or local image found for %s (run 'devbox build %s' first)\n", name, name)
85+
continue
86+
}
87+
} else {
88+
sourceImage = containers[0].Image
7989
}
8090

81-
sourceImage := containers[0].Image
82-
8391
fmt.Printf(" Tagging %s -> %s...\n", sourceImage, pushTag)
8492
if err := dockerClient.ImageTag(ctx, sourceImage, pushTag); err != nil {
8593
return fmt.Errorf("tag image %s: %w", sourceImage, err)
@@ -127,3 +135,17 @@ func runPush(cmd *cobra.Command, args []string) error {
127135
fmt.Println("✓ Push complete")
128136
return nil
129137
}
138+
139+
// findLocalImage checks if a Docker image exists locally by tag.
140+
func findLocalImage(ctx context.Context, dockerClient *client.Client, tag string) (string, error) {
141+
images, err := dockerClient.ImageList(ctx, image.ListOptions{
142+
Filters: filters.NewArgs(filters.Arg("reference", tag)),
143+
})
144+
if err != nil {
145+
return "", err
146+
}
147+
if len(images) > 0 {
148+
return tag, nil
149+
}
150+
return "", nil
151+
}

cli/cmd/restart.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/devboxos/devboxos/cli/internal/client"
8+
"github.com/devboxos/devboxos/cli/internal/output"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var restartCmd = &cobra.Command{
13+
Use: "restart",
14+
Short: "Stop and restart all services",
15+
Long: `Stop all running services and start them again fresh from devbox.yml.`,
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
dir, err := os.Getwd()
18+
if err != nil {
19+
return fmt.Errorf("get working directory: %w", err)
20+
}
21+
22+
conn, err := client.New()
23+
if err != nil {
24+
return fmt.Errorf("connect to engine: %w", err)
25+
}
26+
defer conn.Close()
27+
28+
output.Info("Stopping services...")
29+
if err := conn.Stop(dir, ""); err != nil {
30+
output.Warning("Stop failed: %v", err)
31+
}
32+
33+
output.Info("Starting services...")
34+
err = conn.Start(dir, func(status, msg string) {
35+
switch status {
36+
case "info":
37+
output.Info("%s", msg)
38+
case "error":
39+
output.Error("%s", msg)
40+
case "warning":
41+
output.Warning("%s", msg)
42+
default:
43+
fmt.Println(msg)
44+
}
45+
})
46+
if err != nil {
47+
return fmt.Errorf("restart: %w", err)
48+
}
49+
output.Success("Environment restarted successfully")
50+
51+
printServiceURLs(dir)
52+
return nil
53+
},
54+
}
55+
56+
func init() {
57+
rootCmd.AddCommand(restartCmd)
58+
}

cli/cmd/start.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package cmd
33
import (
44
"fmt"
55
"os"
6+
"sort"
67

78
"github.com/devboxos/devboxos/cli/internal/client"
89
"github.com/devboxos/devboxos/cli/internal/output"
10+
"github.com/devboxos/devboxos/shared/config"
911
"github.com/spf13/cobra"
1012
)
1113

@@ -58,5 +60,51 @@ func runStart(cmd *cobra.Command, args []string) error {
5860
}
5961

6062
output.Success("Environment started successfully")
63+
64+
printServiceURLs(dir)
6165
return nil
6266
}
67+
68+
func printServiceURLs(dir string) {
69+
parser := config.NewParser()
70+
cfg, err := parser.Parse(dir)
71+
if err != nil {
72+
return
73+
}
74+
75+
names := make([]string, 0, len(cfg.Services))
76+
for name := range cfg.Services {
77+
names = append(names, name)
78+
}
79+
sort.Strings(names)
80+
81+
hasURLs := false
82+
for _, name := range names {
83+
svc := cfg.Services[name]
84+
portStr := svc.Port
85+
if portStr == "" && len(svc.Ports) > 0 {
86+
portStr = svc.Ports[0]
87+
}
88+
if portStr == "" {
89+
continue
90+
}
91+
92+
hostPort := extractHostPort(portStr)
93+
if hostPort == "" {
94+
continue
95+
}
96+
97+
protocol := svc.Protocol
98+
if protocol == "" {
99+
protocol = "http"
100+
}
101+
102+
if !hasURLs {
103+
fmt.Println()
104+
output.Title("Access URLs")
105+
hasURLs = true
106+
}
107+
108+
fmt.Printf(" %-15s → %s://localhost:%s\n", name, protocol, hostPort)
109+
}
110+
}

cli/cmd/upgrade.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,11 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
7979
}
8080

8181
func getVersion() string {
82-
// This would normally come from build-time ldflags
83-
return "0.1.0-dev"
82+
return version
8483
}
8584

8685
func getLatestRelease() (*GitHubRelease, error) {
87-
url := "https://api.github.com/repos/devboxos/devboxos/releases/latest"
86+
url := "https://api.github.com/repos/parv68/DevBoxOS/releases/latest"
8887
if upgradeVersion != "latest" {
8988
url = fmt.Sprintf("https://api.github.com/repos/devboxos/devboxos/releases/tags/%s", upgradeVersion)
9089
}

cli/cmd/validate.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ func runValidate(cmd *cobra.Command, args []string) error {
5555
}
5656

5757
for name, svc := range cfg.Services {
58-
if svc.Image == "" && (svc.Build == nil || svc.Build.Context == "") {
59-
issues = append(issues, fmt.Sprintf("Service %q: must specify 'image' or 'build'", name))
58+
if svc.Image == "" && (svc.Build == nil || svc.Build.Context == "") && svc.Command == "" && svc.Runtime == "" {
59+
issues = append(issues, fmt.Sprintf("Service %q: must specify 'image', 'build', 'command', or 'runtime'", name))
6060
}
6161

6262
if svc.Port != "" && svc.Ports != nil && len(svc.Ports) > 0 {

cli/cmd/wait.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func waitForServiceViaEngine(ctx context.Context, cl *devboxclient.Client, servi
8080

8181
for _, svc := range resp.Services {
8282
if svc.Name == serviceName {
83-
if svc.Health == "healthy" || (svc.Health == "" && svc.Status == "running") {
83+
if svc.Health == "healthy" || (svc.Health == "" || svc.Health == "none") && svc.Status == "running" {
8484
return nil
8585
}
8686
}

engine/cmd/daemon.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,12 @@ func (s *server) SnapshotList(ctx context.Context, req *pb.SnapshotListRequest)
440440
}
441441

442442
func (s *server) SnapshotDelete(ctx context.Context, req *pb.SnapshotDeleteRequest) (*pb.StatusResponse, error) {
443-
// Delete only needs filesystem access, not Docker
444443
mgr := snapshot.NewManager(host.NewHostRuntime(), req.ProjectPath)
445-
if err := mgr.Delete(req.SnapshotId); err != nil {
444+
id, err := resolveSnapshotID(mgr, req.SnapshotId)
445+
if err != nil {
446+
return &pb.StatusResponse{Status: "error", Error: err.Error()}, nil
447+
}
448+
if err := mgr.Delete(id); err != nil {
446449
return &pb.StatusResponse{Status: "error", Error: err.Error()}, nil
447450
}
448451
return &pb.StatusResponse{Status: "ok"}, nil
@@ -451,7 +454,6 @@ func (s *server) SnapshotDelete(ctx context.Context, req *pb.SnapshotDeleteReque
451454
func (s *server) SnapshotExport(req *pb.SnapshotExportRequest, stream pb.EngineService_SnapshotExportServer) error {
452455
mgr := snapshot.NewManager(host.NewHostRuntime(), req.ProjectPath)
453456

454-
// If no snapshot ID provided, find the latest
455457
snapshotID := req.SnapshotId
456458
if snapshotID == "" {
457459
infos, err := mgr.List()
@@ -460,6 +462,13 @@ func (s *server) SnapshotExport(req *pb.SnapshotExportRequest, stream pb.EngineS
460462
return nil
461463
}
462464
snapshotID = infos[len(infos)-1].ID
465+
} else {
466+
id, err := resolveSnapshotID(mgr, snapshotID)
467+
if err != nil {
468+
stream.Send(&pb.StreamResponse{Status: "error", Error: err.Error(), Done: true})
469+
return nil
470+
}
471+
snapshotID = id
463472
}
464473

465474
statusChan := make(chan string, 64)
@@ -1009,3 +1018,27 @@ func main() {
10091018
log.Fatalf("Failed to serve: %v", err)
10101019
}
10111020
}
1021+
1022+
// resolveSnapshotID resolves a snapshot name or ID to a snapshot directory ID.
1023+
func resolveSnapshotID(mgr *snapshot.Manager, input string) (string, error) {
1024+
// Try as ID first (fast path — directory exists)
1025+
infos, err := mgr.List()
1026+
if err != nil {
1027+
return "", fmt.Errorf("list snapshots: %w", err)
1028+
}
1029+
1030+
for _, info := range infos {
1031+
if info.ID == input || strings.HasPrefix(info.ID, input) {
1032+
return info.ID, nil
1033+
}
1034+
}
1035+
1036+
// Try as name
1037+
for _, info := range infos {
1038+
if info.Name == input {
1039+
return info.ID, nil
1040+
}
1041+
}
1042+
1043+
return "", fmt.Errorf("snapshot %q not found", input)
1044+
}

engine/internal/orchestrator/lifecycle.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"context"
55
"fmt"
66
"path/filepath"
7+
goruntime "runtime"
78
"time"
89

9-
"github.com/devboxos/devboxos/shared/runtime"
10+
sharedRuntime "github.com/devboxos/devboxos/shared/runtime"
1011
"github.com/devboxos/devboxos/shared/secrets"
1112
"github.com/devboxos/devboxos/shared/types"
1213
)
@@ -22,9 +23,9 @@ func NewLifecycle(resolver *secrets.Resolver) *Lifecycle {
2223
}
2324

2425
// StartService starts a single service using the given runtime.
25-
func (l *Lifecycle) StartService(ctx context.Context, rt runtime.Runtime, name string, svc types.Service, networkName string, projectPath string, statusChan chan<- string) (string, error) {
26+
func (l *Lifecycle) StartService(ctx context.Context, rt sharedRuntime.Runtime, name string, svc types.Service, networkName string, projectPath string, statusChan chan<- string) (string, error) {
2627
// Build container config
27-
cfg := runtime.ContainerConfig{
28+
cfg := sharedRuntime.ContainerConfig{
2829
Name: fmt.Sprintf("devbox-%s", name),
2930
Image: svc.Image,
3031
Command: parseCommand(svc.Command),
@@ -108,7 +109,7 @@ func (l *Lifecycle) StartService(ctx context.Context, rt runtime.Runtime, name s
108109
contextDir = filepath.Join(projectPath, contextDir)
109110
}
110111

111-
buildCfg := runtime.BuildConfig{
112+
buildCfg := sharedRuntime.BuildConfig{
112113
ContextDir: contextDir,
113114
Dockerfile: svc.Build.Dockerfile,
114115
BuildArgs: svc.Build.Args,
@@ -120,8 +121,8 @@ func (l *Lifecycle) StartService(ctx context.Context, rt runtime.Runtime, name s
120121
return "", fmt.Errorf("build image for %s: %w", name, err)
121122
}
122123
cfg.Image = builtImage
123-
} else if svc.Image != "" {
124-
// Pull image if no build config
124+
} else if svc.Image != "" && svc.Command == "" {
125+
// Pull image only for pure Docker services (image without command override)
125126
if err := rt.PullImage(ctx, svc.Image); err != nil {
126127
return "", fmt.Errorf("pull image %s: %w", svc.Image, err)
127128
}
@@ -150,20 +151,20 @@ func (l *Lifecycle) StartService(ctx context.Context, rt runtime.Runtime, name s
150151
}
151152

152153
// StopService stops a single service.
153-
func (l *Lifecycle) StopService(ctx context.Context, rt runtime.Runtime, containerID string, gracePeriod int) error {
154+
func (l *Lifecycle) StopService(ctx context.Context, rt sharedRuntime.Runtime, containerID string, gracePeriod int) error {
154155
if err := rt.StopContainer(ctx, containerID, gracePeriod); err != nil {
155156
return fmt.Errorf("stop container %s: %w", containerID, err)
156157
}
157158
return nil
158159
}
159160

160161
// RemoveService removes a service container.
161-
func (l *Lifecycle) RemoveService(ctx context.Context, rt runtime.Runtime, containerID string) error {
162+
func (l *Lifecycle) RemoveService(ctx context.Context, rt sharedRuntime.Runtime, containerID string) error {
162163
return rt.RemoveContainer(ctx, containerID, true)
163164
}
164165

165166
// WaitForHealthy waits for a service to become healthy.
166-
func (l *Lifecycle) WaitForHealthy(ctx context.Context, rt runtime.Runtime, containerID string, svc types.Service) error {
167+
func (l *Lifecycle) WaitForHealthy(ctx context.Context, rt sharedRuntime.Runtime, containerID string, svc types.Service) error {
167168
if svc.Healthcheck == nil {
168169
// No health check defined, assume healthy after brief delay
169170
time.Sleep(2 * time.Second)
@@ -217,6 +218,8 @@ func parseCommand(cmd string) []string {
217218
if cmd == "" {
218219
return nil
219220
}
220-
// Simple split — a real implementation would handle quotes
221+
if goruntime.GOOS == "windows" {
222+
return []string{"cmd", "/C", cmd}
223+
}
221224
return []string{"sh", "-c", cmd}
222225
}

engine/internal/orchestrator/lifecycle_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package orchestrator
22

33
import (
4+
goruntime "runtime"
45
"reflect"
56
"testing"
67
)
@@ -12,25 +13,32 @@ func TestParseCommand_Empty(t *testing.T) {
1213
}
1314
}
1415

16+
func platformCommand(cmd string) []string {
17+
if goruntime.GOOS == "windows" {
18+
return []string{"cmd", "/C", cmd}
19+
}
20+
return []string{"sh", "-c", cmd}
21+
}
22+
1523
func TestParseCommand_Simple(t *testing.T) {
1624
result := parseCommand("npm start")
17-
expected := []string{"sh", "-c", "npm start"}
25+
expected := platformCommand("npm start")
1826
if !reflect.DeepEqual(result, expected) {
1927
t.Errorf("parseCommand() = %v, want %v", result, expected)
2028
}
2129
}
2230

2331
func TestParseCommand_WithArgs(t *testing.T) {
2432
result := parseCommand("node server.js --port 3000")
25-
expected := []string{"sh", "-c", "node server.js --port 3000"}
33+
expected := platformCommand("node server.js --port 3000")
2634
if !reflect.DeepEqual(result, expected) {
2735
t.Errorf("parseCommand() = %v, want %v", result, expected)
2836
}
2937
}
3038

3139
func TestParseCommand_MultiWord(t *testing.T) {
3240
result := parseCommand("echo hello world && sleep 10")
33-
expected := []string{"sh", "-c", "echo hello world && sleep 10"}
41+
expected := platformCommand("echo hello world && sleep 10")
3442
if !reflect.DeepEqual(result, expected) {
3543
t.Errorf("parseCommand() = %v, want %v", result, expected)
3644
}

0 commit comments

Comments
 (0)