diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..c03c94f62 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(go test:*)", + "Bash(make:*)", + "Bash(golangci-lint run:*)", + "Bash(go build:*)", + "Bash(find:*)", + "Bash(go:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..051f530b3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/src/cmd/cli", + "console": "integratedTerminal", + "args": [ + "-C", + "/Users/jordan/wk/samples/samples/html-css-js", + "deploy", + "-s", + "beta", + ], + "env": { + // "GCP_PROJECT_ID": "jordan-project-463223" + "DEFANG_PROVIDER": "aws", + "AWS_REGION": "us-west-2", + } + }, + ] +} diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 8621ffa5e..15f267cce 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-A+DwCvfNUKY8TjxyAe+abiT9xIyy5p7VIh5T5ofeZIg="; # TODO: use fetchFromGitHub + vendorHash = "sha256-saGEuoB8Eeh/4SASPeyxW/xWArC7+oW88wUg6EJ39Fc="; # TODO: use fetchFromGitHub subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 82d671982..4802aa64d 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -177,18 +177,46 @@ func makeComposeUpCmd() *cobra.Command { term.Info("Detached.") return nil } - - // show users the current streaming logs - tailSource := "all services" - if deploy.Etag != "" { - tailSource = "deployment ID " + deploy.Etag + tailOptions := cli.TailOptions{ + Deployment: deploy.Etag, + LogType: logs.LogTypeAll, + Since: since, + Verbose: true, + } + + waitTimeoutDuration := time.Duration(waitTimeout) * time.Second + var serviceStates map[string]defangv1.ServiceState + if global.Verbose || global.NonInteractive { + tailOptions.Follow = true + serviceStates, err = cli.TailAndMonitor(ctx, project, session.Provider, waitTimeoutDuration, tailOptions) + if err != nil { + return err + } + } else { + term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") + serviceStates, err = cli.MonitorWithUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } - term.Info("Tailing logs for", tailSource, "; press Ctrl+C to detach:") - - tailOptions := newTailOptionsForDeploy(deploy.Etag, since, global.Verbose) - serviceStates, err := cli.TailAndMonitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, tailOptions) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err + + // if any services failed to build, only show build logs for those + // services + var unbuiltServices = make([]string, 0, len(project.Services)) + for service, state := range serviceStates { + if state <= defangv1.ServiceState_BUILD_STOPPING { + unbuiltServices = append(unbuiltServices, service) + } + } + if len(unbuiltServices) > 0 { + tailOptions.LogType = logs.LogTypeBuild + tailOptions.Services = unbuiltServices + } + err := cli.Tail(ctx, session.Provider, project.Name, tailOptions) + if err != nil && !errors.Is(err, io.EOF) { + term.Warn("Failed to tail logs for deployment error", err) + return deploymentErr + } + debugger, err := debug.NewDebugger(ctx, global.Cluster, &global.Stack) if err != nil { term.Warn("Failed to initialize debugger:", err) @@ -197,8 +225,8 @@ func makeComposeUpCmd() *cobra.Command { handleTailAndMonitorErr(ctx, deploymentErr, debugger, debug.DebugConfig{ Deployment: deploy.Etag, Project: project, - ProviderID: &global.Stack.Provider, - Stack: &global.Stack.Name, + ProviderID: &session.Stack.Provider, + Stack: &session.Stack.Name, Since: since, Until: time.Now(), }) @@ -209,15 +237,9 @@ func makeComposeUpCmd() *cobra.Command { service.State = serviceStates[service.Service.Name] } - services, err := cli.NewServiceFromServiceInfo(deploy.Services) - if err != nil { - return err - } - // Print the current service states of the deployment - err = cli.PrintServiceStatesAndEndpoints(services) - if err != nil { - return err + if err := cli.PrintServices(cmd.Context(), project.Name, session.Provider); err != nil { + term.Warn(err) } term.Info("Done.") @@ -274,7 +296,7 @@ func confirmDeployment(targetDirectory string, existingDeployments []*defangv1.D } func printExistingDeployments(existingDeployments []*defangv1.Deployment) { - term.Info("This project was previously deployed to the following locations:") + term.Info("This project has already deployed to the following locations:") deploymentStrings := make([]string, 0, len(existingDeployments)) for _, dep := range existingDeployments { var providerId client.ProviderID diff --git a/src/go.mod b/src/go.mod index 6e10bdf8c..3f533ccab 100644 --- a/src/go.mod +++ b/src/go.mod @@ -34,6 +34,9 @@ require ( github.com/aws/smithy-go v1.24.0 github.com/awslabs/goformation/v7 v7.14.9 github.com/bufbuild/connect-go v1.10.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 github.com/digitalocean/godo v1.131.1 github.com/docker/cli v27.3.1+incompatible @@ -50,7 +53,7 @@ require ( github.com/miekg/dns v1.1.59 github.com/moby/buildkit v0.17.3 github.com/moby/patternmatcher v0.6.0 - github.com/muesli/termenv v0.15.2 + github.com/muesli/termenv v0.16.0 github.com/openai/openai-go v1.12.0 github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml/v2 v2.2.2 @@ -89,6 +92,10 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect @@ -97,6 +104,7 @@ require ( github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect @@ -111,11 +119,14 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/rivo/uniseg v0.4.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect @@ -132,6 +143,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/src/go.sum b/src/go.sum index cdfadeeaf..e8bbf2b4d 100644 --- a/src/go.sum +++ b/src/go.sum @@ -122,6 +122,20 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 h1:kqvhWCmg3fVAPbfE8aJdV+qX1VqK4oK/DRI5yxeVd4E= @@ -163,6 +177,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -262,8 +278,10 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= @@ -283,8 +301,12 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= @@ -307,8 +329,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= -github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/ross96D/cancelreader v0.2.6 h1:XLPWassoMWRTlHvEoVKS3z0N0a7jHcIupGU0U1gNArw= @@ -365,6 +387,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -408,6 +432,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -436,6 +462,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/src/pkg/agent/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go index 33fae50af..a7d4bb49a 100644 --- a/src/pkg/agent/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -70,7 +70,7 @@ func (DefaultToolCLI) ConfigDelete(ctx context.Context, projectName string, prov return cli.ConfigDelete(ctx, projectName, provider, name) } -func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) { +func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) { return cli.GetServices(ctx, projectName, provider) } diff --git a/src/pkg/agent/tools/interfaces.go b/src/pkg/agent/tools/interfaces.go index b05aaa6d5..3f52acd16 100644 --- a/src/pkg/agent/tools/interfaces.go +++ b/src/pkg/agent/tools/interfaces.go @@ -21,7 +21,7 @@ type CLIInterface interface { Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) CreatePlaygroundProvider(fabric *client.GrpcClient) client.Provider GenerateAuthURL(authPort int) string - GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) + GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) InteractiveLoginMCP(ctx context.Context, cluster string, mcpClient string) error ListConfig(ctx context.Context, provider client.Provider, projectName string) (*defangv1.Secrets, error) LoadProject(ctx context.Context, loader client.Loader) (*compose.Project, error) diff --git a/src/pkg/agent/tools/services_test.go b/src/pkg/agent/tools/services_test.go index 5f7e2b6b7..31075ab7a 100644 --- a/src/pkg/agent/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -29,7 +29,7 @@ type MockCLI struct { MockProjectName string GetServicesError error - MockServices []cli.ServiceLineItem + MockServices []cli.ServiceEndpoint GetServicesCalled bool GetServicesProject string GetServicesProvider client.Provider @@ -56,7 +56,7 @@ func (m *MockCLI) LoadProjectNameWithFallback(ctx context.Context, loader client return "default-project", nil } -func (m *MockCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) { +func (m *MockCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) { m.GetServicesCalled = true m.GetServicesProject = projectName m.GetServicesProvider = provider @@ -227,11 +227,10 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { MockClient: &client.GrpcClient{}, MockProvider: &client.PlaygroundProvider{}, MockProjectName: "test-project", - MockServices: []cli.ServiceLineItem{ + MockServices: []cli.ServiceEndpoint{ { Service: "test-service", Deployment: "test-deployment", - Fqdn: "test.example.com", Status: "running", }, }, diff --git a/src/pkg/cli/client/errors.go b/src/pkg/cli/client/errors.go index 49c831a3b..c9a3b0a75 100644 --- a/src/pkg/cli/client/errors.go +++ b/src/pkg/cli/client/errors.go @@ -1,6 +1,11 @@ package client -import "fmt" +import ( + "errors" + "fmt" +) + +var ErrDeploymentSucceeded = errors.New("deployment succeeded") type ErrDeploymentFailed struct { Message string diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go new file mode 100644 index 000000000..e62acad08 --- /dev/null +++ b/src/pkg/cli/composeUpTui.go @@ -0,0 +1,201 @@ +package cli + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type deploymentModel struct { + services map[string]*serviceState + quitting bool + updateCh chan serviceUpdate +} + +type serviceState struct { + status defangv1.ServiceState + spinner spinner.Model +} + +type serviceUpdate struct { + name string + status defangv1.ServiceState +} + +var ( + spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#bc9724", Dark: "#2ddedc"}) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#a4729d", Dark: "#fae856"}) + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#305897", Dark: "#cdd2c9"}) +) + +func newDeploymentModel(serviceNames []string) *deploymentModel { + services := make(map[string]*serviceState) + + for _, name := range serviceNames { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = spinnerStyle + + services[name] = &serviceState{ + status: defangv1.ServiceState_DEPLOYMENT_PENDING, + spinner: s, + } + } + + return &deploymentModel{ + services: services, + updateCh: make(chan serviceUpdate, 100), + } +} + +func (m *deploymentModel) Init() tea.Cmd { + var cmds []tea.Cmd + for _, svc := range m.services { + cmds = append(cmds, svc.spinner.Tick) + } + return tea.Batch(cmds...) +} + +func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + case serviceUpdate: + if svc, exists := m.services[msg.name]; exists { + svc.status = msg.status + } + return m, nil + case spinner.TickMsg: + var cmds []tea.Cmd + for _, svc := range m.services { + var cmd tea.Cmd + svc.spinner, cmd = svc.spinner.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m *deploymentModel) View() string { + if m.quitting { + return "" + } + + var lines []string + // Sort services by name for consistent ordering + var serviceNames []string + for name := range m.services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + for _, name := range serviceNames { + svc := m.services[name] + + // Stop spinner for completed services + var spinnerOrCheck string + switch svc.status { + case defangv1.ServiceState_DEPLOYMENT_COMPLETED: + spinnerOrCheck = "✓ " + case defangv1.ServiceState_DEPLOYMENT_FAILED: + spinnerOrCheck = "✗ " + default: + spinnerOrCheck = svc.spinner.View() + } + + var statusText string + switch svc.status { + case defangv1.ServiceState_NOT_SPECIFIED: + statusText = "" + case defangv1.ServiceState_DEPLOYMENT_PENDING: + statusText = "DEPLOYING" + default: + statusText = svc.status.String() + } + + line := lipgloss.JoinHorizontal( + lipgloss.Left, + spinnerOrCheck, + " ", + nameStyle.Render("["+name+"]"), + " ", + statusStyle.Render(statusText), + ) + lines = append(lines, line) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func MonitorWithUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { + servicesNames := make([]string, 0, len(project.Services)) + for _, svc := range project.Services { + servicesNames = append(servicesNames, svc.Name) + } + + // Initialize the bubbletea model + model := newDeploymentModel(servicesNames) + + // Create the bubbletea program + p := tea.NewProgram(model) + + var ( + serviceStates map[string]defangv1.ServiceState + monitorErr error + wg sync.WaitGroup + ) + wg.Add(2) // One for UI, one for monitoring + + // Start the bubbletea UI in a goroutine + go func() { + defer wg.Done() + if _, err := p.Run(); err != nil { + // Handle UI errors if needed + } + }() + + // Start monitoring in a goroutine + go func() { + defer wg.Done() + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { + // Send service status updates to the bubbletea model + for name, state := range *states { + p.Send(serviceUpdate{ + name: name, + status: state, + }) + } + return nil + }) + + if monitorErr == nil { + // prevent leaving partial state and spinners on screen after successful completion + for _, serviceName := range servicesNames { + if serviceStates[serviceName] == defangv1.ServiceState_DEPLOYMENT_PENDING { + p.Send(serviceUpdate{ + name: serviceName, + status: defangv1.ServiceState_DEPLOYMENT_COMPLETED, + }) + } + } + } + // Quit the UI when monitoring is done + p.Quit() + }() + + wg.Wait() + + return serviceStates, monitorErr +} diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index f962a8acd..446ebc4d4 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -2,9 +2,13 @@ package cli import ( "context" + "crypto/tls" + "errors" "fmt" + "net" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -14,15 +18,15 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -type ServiceLineItem struct { - Deployment string - Endpoint string - Service string - State defangv1.ServiceState - Status string - Fqdn string - AcmeCertUsed bool - HealthcheckStatus string +type ServiceEndpoint struct { + Deployment string + Endpoint string + Service string + State string + Status string + AcmeCertUsed bool + HealthcheckPath string + Healthcheck string // status } type ErrNoServices struct { @@ -50,7 +54,7 @@ func PrintLongServices(ctx context.Context, projectName string, provider client. return PrintObject("", servicesResponse) } -func GetServices(ctx context.Context, projectName string, provider client.Provider) ([]ServiceLineItem, error) { +func GetServices(ctx context.Context, projectName string, provider client.Provider) ([]ServiceEndpoint, error) { term.Debugf("Listing services in project %q", projectName) servicesResponse, err := provider.GetServices(ctx, &defangv1.GetServicesRequest{Project: projectName}) @@ -58,24 +62,18 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid return nil, err } - numServices := len(servicesResponse.Services) + serviceInfos := servicesResponse.Services + numServices := len(serviceInfos) if numServices == 0 { return nil, ErrNoServices{ProjectName: projectName} } - results := GetHealthcheckResults(ctx, servicesResponse.Services) - services, err := NewServiceFromServiceInfo(servicesResponse.Services) + serviceEndpoints, err := ServiceEndpointsFromServiceInfos(serviceInfos) if err != nil { return nil, err } - for i, svc := range services { - if status, ok := results[svc.Service]; ok { - services[i].HealthcheckStatus = *status - } else { - services[i].HealthcheckStatus = "unknown" - } - } - return services, nil + UpdateHealthcheckResults(ctx, serviceEndpoints) + return serviceEndpoints, nil } func PrintServices(ctx context.Context, projectName string, provider client.Provider) error { @@ -87,42 +85,30 @@ func PrintServices(ctx context.Context, projectName string, provider client.Prov return PrintServiceStatesAndEndpoints(services) } -type HealthCheckResults map[string]*string - -func GetHealthcheckResults(ctx context.Context, serviceInfos []*defangv1.ServiceInfo) HealthCheckResults { +func UpdateHealthcheckResults(ctx context.Context, serviceEndpoints []ServiceEndpoint) { // Create a context with a timeout for HTTP requests ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var wg sync.WaitGroup - results := make(HealthCheckResults) - for _, serviceInfo := range serviceInfos { - results[serviceInfo.Service.Name] = (new(string)) - } - - for _, serviceInfo := range serviceInfos { - for _, endpoint := range serviceInfo.Endpoints { - if strings.Contains(endpoint, ":") { - *results[serviceInfo.Service.Name] = "skipped" - // Skip endpoints with ports because they likely non-HTTP services - continue - } - wg.Add(1) - go func(serviceInfo *defangv1.ServiceInfo) { - defer wg.Done() - result, err := RunHealthcheck(ctx, serviceInfo.Service.Name, "https://"+endpoint, serviceInfo.HealthcheckPath) - if err != nil { - term.Debugf("Healthcheck error for service %q at endpoint %q: %s", serviceInfo.Service.Name, endpoint, err.Error()) - result = "error" - } - *results[serviceInfo.Service.Name] = result - }(serviceInfo) + for i, serviceEndpoint := range serviceEndpoints { + if strings.Contains(serviceEndpoint.Endpoint, ":") && !strings.HasPrefix(serviceEndpoint.Endpoint, "https://") { + serviceEndpoints[i].Healthcheck = "-" + continue } + wg.Add(1) + go func(serviceEndpoint *ServiceEndpoint) { + defer wg.Done() + result, err := RunHealthcheck(ctx, serviceEndpoint.Service, serviceEndpoint.Endpoint, serviceEndpoint.HealthcheckPath) + if err != nil { + term.Debugf("Healthcheck error for service %q at endpoint %q: %s", serviceEndpoint.Service, serviceEndpoint.Endpoint, err.Error()) + result = "error" + } + serviceEndpoint.Healthcheck = result + }(&serviceEndpoints[i]) } wg.Wait() - - return results } func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, error) { @@ -138,6 +124,19 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e term.Debugf("[%s] checking health at %s", name, url) resp, err := http.DefaultClient.Do(req) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return "unknown (timeout)", nil + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) + return "unknown (DNS error)", nil + } + var tlsErr *tls.CertificateVerificationError + if errors.As(err, &tlsErr) { + term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) + return "unknown (TLS certificate error)", nil + } return "", err } defer resp.Body.Close() @@ -150,62 +149,90 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e } } -func NewServiceFromServiceInfo(serviceInfos []*defangv1.ServiceInfo) ([]ServiceLineItem, error) { - var serviceTableItems []ServiceLineItem +func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) []ServiceEndpoint { + endpoints := make([]ServiceEndpoint, 0, len(serviceInfo.Endpoints)+1) + for _, endpoint := range serviceInfo.Endpoints { + _, port, _ := net.SplitHostPort(endpoint) + if port == "" { + endpoint = "https://" + strings.TrimPrefix(endpoint, "https://") + } + endpoints = append(endpoints, ServiceEndpoint{ + Deployment: serviceInfo.Etag, + Service: serviceInfo.Service.Name, + State: serviceInfo.State.String(), + Status: serviceInfo.Status, + Endpoint: endpoint, + HealthcheckPath: serviceInfo.HealthcheckPath, + AcmeCertUsed: serviceInfo.UseAcmeCert, + }) + } + if serviceInfo.Domainname != "" { + endpoints = append(endpoints, ServiceEndpoint{ + Deployment: serviceInfo.Etag, + Service: serviceInfo.Service.Name, + State: serviceInfo.State.String(), + Status: serviceInfo.Status, + Endpoint: "https://" + serviceInfo.Domainname, + HealthcheckPath: serviceInfo.HealthcheckPath, + AcmeCertUsed: serviceInfo.UseAcmeCert, + }) + } + return endpoints +} - // showDomainNameColumn := false +func ServiceEndpointsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceEndpoint, error) { + var serviceTableItems []ServiceEndpoint for _, serviceInfo := range serviceInfos { - fqdn := serviceInfo.PublicFqdn - if fqdn == "" { - fqdn = serviceInfo.PrivateFqdn - } - domainname := "N/A" - if serviceInfo.Domainname != "" { - // showDomainNameColumn = true - domainname = "https://" + serviceInfo.Domainname - } else if serviceInfo.PublicFqdn != "" { - domainname = "https://" + serviceInfo.PublicFqdn - } else if serviceInfo.PrivateFqdn != "" { - domainname = serviceInfo.PrivateFqdn - } - - ps := ServiceLineItem{ - Deployment: serviceInfo.Etag, - Service: serviceInfo.Service.Name, - State: serviceInfo.State, - Status: serviceInfo.Status, - Endpoint: domainname, - Fqdn: fqdn, - AcmeCertUsed: serviceInfo.UseAcmeCert, - } - serviceTableItems = append(serviceTableItems, ps) + serviceTableItems = append(serviceTableItems, ServiceEndpointsFromServiceInfo(serviceInfo)...) } return serviceTableItems, nil } -func PrintServiceStatesAndEndpoints(services []ServiceLineItem) error { +func PrintServiceStatesAndEndpoints(serviceEndpoints []ServiceEndpoint) error { showCertGenerateHint := false printHealthcheckStatus := false - for _, svc := range services { + for _, svc := range serviceEndpoints { if svc.AcmeCertUsed { showCertGenerateHint = true } - if svc.HealthcheckStatus != "" { + if svc.Healthcheck != "" { printHealthcheckStatus = true } } - attrs := []string{"Service", "Deployment", "State", "Fqdn", "Endpoint"} + attrs := []string{"Service", "Deployment", "State"} if printHealthcheckStatus { - attrs = append(attrs, "HealthcheckStatus") + attrs = append(attrs, "Healthcheck", "Endpoint") + } else { + attrs = append(attrs, "Endpoint") } - // if showDomainNameColumn { - // attrs = append(attrs, "DomainName") - // } - err := term.Table(services, attrs...) + // sort serviceEndpoints by Service, Deployment, Endpoint + slices.SortStableFunc(serviceEndpoints, func(a, b ServiceEndpoint) int { + if a.Service != b.Service { + return strings.Compare(a.Service, b.Service) + } + if a.Deployment != b.Deployment { + return strings.Compare(a.Deployment, b.Deployment) + } + return strings.Compare(a.Endpoint, b.Endpoint) + }) + + // to reduce noise, print empty "Service", "Deployment", and "State" columns + // if they are for the same service as the previous row + lastService := "" + for i := range serviceEndpoints { + if serviceEndpoints[i].Service == lastService { + serviceEndpoints[i].Service = "" + serviceEndpoints[i].Deployment = "" + serviceEndpoints[i].State = "" + } else { + lastService = serviceEndpoints[i].Service + } + } + err := term.Table(serviceEndpoints, attrs...) if err != nil { return err } diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 2b2cb8cb1..203755538 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -91,8 +91,8 @@ func TestPrintServices(t *testing.T) { if err != nil { t.Fatalf("PrintServices error = %v", err) } - expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE FQDN ENDPOINT HEALTHCHECKSTATUS\x1b[0m" + ` -foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo.prod1.defang.dev unhealthy (404 Not Found) + expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE HEALTHCHECK ENDPOINT\x1b[0m" + ` +foo a1b2c3 NOT_SPECIFIED unhealthy (404 Not Found) https://test-foo--3000.prod1.defang.dev ` receivedLines := strings.Split(stdout.String(), "\n") @@ -147,25 +147,23 @@ foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo. }) } -func TestGetServiceStatesAndEndpoints(t *testing.T) { +func TestServiceEndpointFromServiceInfo(t *testing.T) { tests := []struct { - name string - serviceinfos []*defangv1.ServiceInfo - expectedServices []ServiceLineItem + name string + serviceinfo *defangv1.ServiceInfo + expectedServiceEndpoints []ServiceEndpoint }{ { name: "empty endpoint list", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Domainname: "example.com", - Endpoints: []string{}, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", }, + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{}, }, - expectedServices: []ServiceLineItem{ + expectedServiceEndpoints: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -175,20 +173,18 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { }, { name: "Service with Domainname", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Domainname: "example.com", - Endpoints: []string{ - "example.com", - "service1.internal:80", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{ + "example.com", + "service1.internal:80", }, }, - expectedServices: []ServiceLineItem{ + expectedServiceEndpoints: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -198,18 +194,16 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { }, { name: "endpoint without port", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Endpoints: []string{ - "service1", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Endpoints: []string{ + "service1", }, }, - expectedServices: []ServiceLineItem{ + expectedServiceEndpoints: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -219,19 +213,17 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { }, { name: "with acme cert", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - UseAcmeCert: true, - Endpoints: []string{ - "service1", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + UseAcmeCert: true, + Endpoints: []string{ + "service1", }, }, - expectedServices: []ServiceLineItem{ + expectedServiceEndpoints: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -240,18 +232,71 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { }, }, }, + { + name: "with multiple endpoints", + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Endpoints: []string{ + "service1:80", + "service1.internal:8080", + }, + }, + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1:80", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1.internal:8080", + }, + }, + }, + { + name: "with multiple endpoints and domainname", + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{ + "service1:80", + "service1.internal:8080", + }, + }, + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1:80", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1.internal:8080", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - services, err := NewServiceFromServiceInfo(tt.serviceinfos) - require.NoError(t, err) - - assert.Len(t, services, len(tt.expectedServices)) - for i, svc := range services { - assert.Equal(t, tt.expectedServices[i].Service, svc.Service) - assert.Equal(t, tt.expectedServices[i].Status, svc.Status) - assert.Equal(t, tt.expectedServices[i].Endpoint, svc.Endpoint) - assert.Equal(t, tt.expectedServices[i].AcmeCertUsed, svc.AcmeCertUsed) + serviceEndpoints := ServiceEndpointsFromServiceInfo(tt.serviceinfo) + for i, endpoint := range serviceEndpoints { + assert.Equal(t, tt.expectedServiceEndpoints[i].Service, endpoint.Service) + assert.Equal(t, tt.expectedServiceEndpoints[i].Status, endpoint.Status) + assert.Equal(t, tt.expectedServiceEndpoints[i].Endpoint, endpoint.Endpoint) + assert.Equal(t, tt.expectedServiceEndpoints[i].AcmeCertUsed, endpoint.AcmeCertUsed) } }) } @@ -268,56 +313,89 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { tests := []struct { name string - services []ServiceLineItem + services []ServiceEndpoint expectedLines []string }{ { name: "empty endpoint list", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + Service: "service1", + State: "DEPLOYMENT_COMPLETED", + Endpoint: "https://example.com", + Deployment: "abcd1234", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED https://example.com", "", }, }, { name: "Service with Domainname", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + Service: "service1", + Endpoint: "https://example.com", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED https://example.com", "", }, }, { name: "with acme cert", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { Service: "service1", - Status: "UNKNOWN", Endpoint: "N/A", AcmeCertUsed: true, + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED N/A", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED N/A", " * Run `defang cert generate` to get a TLS certificate for your service(s)", "", }, }, + { + name: "with multiple endpoints and domainname", + services: []ServiceEndpoint{ + { + Service: "service1", + Endpoint: "http://service1:80", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + { + Service: "service1", + Endpoint: "http://service1.internal:8080", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + { + Service: "service1", + Endpoint: "https://example.com", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + }, + expectedLines: []string{ + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED http://service1.internal:8080", + " http://service1:80", + " https://example.com", + "", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -363,7 +441,6 @@ func TestRunHealthcheck(t *testing.T) { endpoint string healthcheckPath string expectedStatus string - expectedErr bool }{ { name: "Healthy service", @@ -385,23 +462,31 @@ func TestRunHealthcheck(t *testing.T) { }, { name: "Invalid endpoint", - endpoint: "http://invalid-endpoint", - healthcheckPath: "/healthy", - expectedStatus: "", - expectedErr: true, + endpoint: "http://invalid-endpoint-238hf83wfnrewanf.com", + healthcheckPath: "/", + expectedStatus: "unknown (DNS error)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, err := RunHealthcheck(ctx, "test-service", tt.endpoint, tt.healthcheckPath) - if tt.expectedErr { - require.Error(t, err) - return - } else { - require.NoError(t, err) - } + require.NoError(t, err) assert.Equal(t, tt.expectedStatus, status) }) } } + +func TestRunHealthcheckTLSError(t *testing.T) { + ctx := t.Context() + + // Start a test HTTPS server with a self-signed certificate + testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(testServer.Close) + + status, err := RunHealthcheck(ctx, "test-service", testServer.URL, "/healthy") + require.NoError(t, err) + assert.Equal(t, "unknown (TLS certificate error)", status) +} diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 9b05106c7..e79d8e7fa 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -14,16 +14,14 @@ var ErrNothingToMonitor = errors.New("no services to monitor") type ServiceStates = map[string]defangv1.ServiceState -func WaitServiceState( +func WatchServiceState( ctx context.Context, provider client.Provider, - targetState defangv1.ServiceState, projectName string, etag types.ETag, services []string, + cb func(*defangv1.SubscribeResponse, *ServiceStates) error, ) (ServiceStates, error) { - term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine - if len(services) == 0 { return nil, ErrNothingToMonitor } @@ -50,46 +48,96 @@ func WaitServiceState( } // Monitor for when all services are completed to end this command - for { - if !serverStream.Receive() { - // Reconnect on Error: internal: stream error: stream ID 5; INTERNAL_ERROR; received from peer - if isTransientError(serverStream.Err()) { - if err := provider.DelayBeforeRetry(ctx); err != nil { - return serviceStates, err + msgChan := make(chan *defangv1.SubscribeResponse, 1) + errChan := make(chan error, 1) + + // Run stream receiving in a separate goroutine + go func() { + for { + if !serverStream.Receive() { + // Reconnect on Error: internal: stream error: stream ID 5; INTERNAL_ERROR; received from peer + if isTransientError(serverStream.Err()) { + if err := provider.DelayBeforeRetry(ctx); err != nil { + errChan <- err + return + } + serverStream, err = provider.Subscribe(ctx, &subscribeRequest) + if err != nil { + errChan <- err + return + } + continue } - serverStream, err = provider.Subscribe(ctx, &subscribeRequest) - if err != nil { - return serviceStates, err + if err := serverStream.Err(); err != nil { + errChan <- err } + return + } + + msg := serverStream.Msg() + if msg == nil { continue } - return serviceStates, serverStream.Err() - } - msg := serverStream.Msg() - if msg == nil { - continue + select { + case msgChan <- msg: + case <-ctx.Done(): + return + } } + }() - term.Debugf("service %s with state ( %s ) and status: %s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine + for { + select { + case <-ctx.Done(): + return serviceStates, ctx.Err() + case err := <-errChan: + return serviceStates, err + case msg := <-msgChan: + term.Debugf("service %s with state ( %s ) and status: %s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine + + if _, ok := serviceStates[msg.Name]; !ok { + term.Debugf("unexpected service %s update", msg.Name) // TODO: don't print in Go-routine + continue + } - if _, ok := serviceStates[msg.Name]; !ok { - term.Debugf("unexpected service %s update", msg.Name) // TODO: don't print in Go-routine - continue + if msg.State != defangv1.ServiceState_NOT_SPECIFIED { + serviceStates[msg.Name] = msg.State + } + err := cb(msg, &serviceStates) + if err != nil { + if errors.Is(err, client.ErrDeploymentSucceeded) { + return serviceStates, nil + } + return serviceStates, err + } } + } +} - serviceStates[msg.Name] = msg.State +func WaitServiceState( + ctx context.Context, + provider client.Provider, + targetState defangv1.ServiceState, + projectName string, + etag types.ETag, + services []string, +) (ServiceStates, error) { + term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine + return WatchServiceState(ctx, provider, projectName, etag, services, func(msg *defangv1.SubscribeResponse, serviceStates *ServiceStates) error { // exit early on detecting a FAILED state switch msg.State { case defangv1.ServiceState_BUILD_FAILED, defangv1.ServiceState_DEPLOYMENT_FAILED: - return serviceStates, client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} + return client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} } - if allInState(targetState, serviceStates) { - return serviceStates, nil // all services are in the target state + if allInState(targetState, *serviceStates) { + return client.ErrDeploymentSucceeded // signal successful completion } - } + + return nil + }) } func allInState(targetState defangv1.ServiceState, serviceStates ServiceStates) bool { diff --git a/src/pkg/cli/subscribe_test.go b/src/pkg/cli/subscribe_test.go index 8c14318d5..bcf0df907 100644 --- a/src/pkg/cli/subscribe_test.go +++ b/src/pkg/cli/subscribe_test.go @@ -223,7 +223,7 @@ func TestWaitServiceState(t *testing.T) { t.Run("Expect Error", func(t *testing.T) { ss, err := WaitServiceState(ctx, provider, tt.targetState, "testproject", tt.etag, tt.services) if err == nil { - t.Fatalf("Unexpected error: %v", err) + t.Fatal("Expected error but got nil") } if !errors.As(err, &client.ErrDeploymentFailed{}) { t.Errorf("Expected ErrDeploymentFailed but got: %v", err) diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index d21e32932..f9694f054 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -15,12 +15,9 @@ import ( "github.com/bufbuild/connect-go" ) -const targetServiceState = defangv1.ServiceState_DEPLOYMENT_COMPLETED - -func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { - tailOptions.Follow = true - if tailOptions.Deployment == "" { - panic("tailOptions.Deployment must be a valid deployment ID") +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string, watchCallback func(*defangv1.SubscribeResponse, *ServiceStates) error) (ServiceStates, error) { + if deploymentID == "" { + panic("deploymentID must be a valid deployment ID") } if waitTimeout > 0 { var cancelTimeout context.CancelFunc @@ -28,42 +25,59 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie defer cancelTimeout() } - tailCtx, cancelTail := context.WithCancelCause(context.Background()) - defer cancelTail(nil) // to cancel tail and clean-up context - svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) - defer cancelSvcStatus(nil) // to cancel WaitServiceState and clean-up context + defer cancelSvcStatus(nil) _, computeServices := splitManagedAndUnmanagedServices(project.Services) - var serviceStates ServiceStates - var cdErr, svcErr error - + var ( + serviceStates ServiceStates + cdErr, svcErr error + ) wg := &sync.WaitGroup{} wg.Add(2) go func() { defer wg.Done() - // block on waiting for services to reach target state - serviceStates, svcErr = WaitServiceState(svcStatusCtx, provider, targetServiceState, project.Name, tailOptions.Deployment, computeServices) + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, watchCallback) }() go func() { defer wg.Done() - // block on waiting for cdTask to complete if err := WaitForCdTaskExit(ctx, provider); err != nil { cdErr = err - // When CD fails, stop WaitServiceState - cancelSvcStatus(cdErr) } + cancelSvcStatus(cdErr) }() + wg.Wait() + pkg.SleepWithContext(ctx, 2*time.Second) + + return serviceStates, errors.Join(cdErr, svcErr) +} + +func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { + tailOptions.Follow = true + if tailOptions.Deployment == "" { + panic("tailOptions.Deployment must be a valid deployment ID") + } + + tailCtx, cancelTail := context.WithCancelCause(context.Background()) + defer cancelTail(nil) // to cancel tail and clean-up context + errMonitoringDone := errors.New("monitoring done") // pseudo error to signal that monitoring is done + var serviceStates ServiceStates + var monitorErr error + + // Run Monitor in a goroutine go func() { - wg.Wait() + // Pass a NOOP function for the callback since TailAndMonitor doesn't use UI + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment, func(*defangv1.SubscribeResponse, *ServiceStates) error { + return nil // NOOP - no UI updates needed when tailing + }) pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages - cancelTail(errMonitoringDone) // cancel the tail when both goroutines are done + cancelTail(errMonitoringDone) // cancel the tail when monitoring is done }() tailOptions.PrintBookends = false @@ -82,13 +96,13 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie switch { case errors.Is(err, io.EOF): - break // an end condition was detected; cdErr and/or svcErr might be nil + break // an end condition was detected; monitorErr might be nil case errors.Is(context.Cause(ctx), context.Canceled): term.Warn("Deployment is not finished. Service(s) might not be running.") case errors.Is(context.Cause(tailCtx), errMonitoringDone): - break // the monitoring stopped the tail; cdErr and/or svcErr will have been set + break // the monitoring stopped the tail; monitorErr will have been set case errors.Is(context.Cause(ctx), context.DeadlineExceeded): // Tail was canceled when wait-timeout is reached; show a warning and exit with an error @@ -96,11 +110,11 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie fallthrough default: - tailErr = err // report the error, in addition to the cdErr and svcErr + tailErr = err // report the error, in addition to the monitorErr } } - return serviceStates, errors.Join(cdErr, svcErr, tailErr) + return serviceStates, errors.Join(monitorErr, tailErr) } func CanMonitorService(service *compose.ServiceConfig) bool {