github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/cmd/cli/command/commands.go (about) 1 package command 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "math/rand" 9 "os" 10 "os/exec" 11 "regexp" 12 "strings" 13 "time" 14 15 "github.com/AlecAivazis/survey/v2" 16 "github.com/aws/smithy-go" 17 "github.com/bufbuild/connect-go" 18 "github.com/defang-io/defang/src/pkg" 19 "github.com/defang-io/defang/src/pkg/cli" 20 cliClient "github.com/defang-io/defang/src/pkg/cli/client" 21 "github.com/defang-io/defang/src/pkg/scope" 22 "github.com/defang-io/defang/src/pkg/term" 23 "github.com/defang-io/defang/src/pkg/types" 24 defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1" 25 "github.com/spf13/cobra" 26 "golang.org/x/mod/semver" 27 ) 28 29 const DEFANG_PORTAL_HOST = "portal.defang.dev" 30 const SERVICE_PORTAL_URL = "https://" + DEFANG_PORTAL_HOST + "/service" 31 32 const authNeeded = "auth-needed" // annotation to indicate that a command needs authorization 33 var authNeededAnnotation = map[string]string{authNeeded: ""} 34 35 // GLOBALS 36 var ( 37 client cliClient.Client 38 cluster string 39 colorMode = ColorAuto 40 gitHubClientId = pkg.Getenv("DEFANG_CLIENT_ID", "7b41848ca116eac4b125") // GitHub OAuth app 41 hasTty = term.IsTerminal && !pkg.GetenvBool("CI") 42 nonInteractive = !hasTty 43 provider = cliClient.Provider(pkg.Getenv("DEFANG_PROVIDER", "auto")) 44 ) 45 46 func prettyError(err error) error { 47 // To avoid printing the internal gRPC error code 48 var cerr *connect.Error 49 if errors.As(err, &cerr) { 50 term.Debug(" - Server error:", err) 51 err = errors.Unwrap(err) 52 } 53 return err 54 55 } 56 57 func Execute(ctx context.Context) error { 58 if term.CanColor { // TODO: should use DoColor(…) instead 59 restore := term.EnableANSI() 60 defer restore() 61 } 62 63 if err := RootCmd.ExecuteContext(ctx); err != nil { 64 if !errors.Is(err, context.Canceled) { 65 term.Error("Error:", prettyError(err)) 66 } 67 68 var derr *cli.ComposeError 69 if errors.As(err, &derr) { 70 compose := "compose" 71 fileFlag := composeCmd.Flag("file") 72 if fileFlag.Changed { 73 compose += " -f " + fileFlag.Value.String() 74 } 75 printDefangHint("Fix the error and try again. To validate the compose file, use:", compose+" config") 76 } 77 78 if strings.Contains(err.Error(), "secret") { 79 printDefangHint("To manage sensitive service config, use:", "config") 80 } 81 82 var cerr *cli.CancelError 83 if errors.As(err, &cerr) { 84 printDefangHint("Detached. The process will keep running.\nTo continue the logs from where you left off, do:", cerr.Error()) 85 } 86 87 code := connect.CodeOf(err) 88 if code == connect.CodeUnauthenticated { 89 // All AWS errors are wrapped in OperationError 90 var oe *smithy.OperationError 91 if errors.As(err, &oe) { 92 fmt.Println("Could not authenticate to the AWS service. Please check your aws credentials and try again.") 93 } else { 94 printDefangHint("Please use the following command to log in:", "login") 95 } 96 } 97 if code == connect.CodeFailedPrecondition && (strings.Contains(err.Error(), "EULA") || strings.Contains(err.Error(), "terms")) { 98 printDefangHint("Please use the following command to see the Defang terms of service:", "terms") 99 } 100 101 return ExitCode(code) 102 } 103 104 if hasTty && term.HadWarnings { 105 fmt.Println("For help with warnings, check our FAQ at https://docs.defang.io/docs/faq") 106 } 107 108 if hasTty && !pkg.GetenvBool("DEFANG_HIDE_UPDATE") && rand.Intn(10) == 0 { 109 if latest, err := GetLatestVersion(ctx); err == nil && semver.Compare(GetCurrentVersion(), latest) < 0 { 110 term.Debug(" - Latest Version:", latest, "Current Version:", GetCurrentVersion()) 111 fmt.Println("A newer version of the CLI is available at https://github.com/defang-io/defang/releases/latest") 112 if rand.Intn(10) == 0 && !pkg.GetenvBool("DEFANG_HIDE_HINTS") { 113 fmt.Println("To silence these notices, do: export DEFANG_HIDE_UPDATE=1") 114 } 115 } 116 } 117 118 return nil 119 } 120 121 func SetupCommands(version string) { 122 defangFabric := pkg.Getenv("DEFANG_FABRIC", cli.DefaultCluster) 123 124 RootCmd.Version = version 125 RootCmd.PersistentFlags().Var(&colorMode, "color", `colorize output; "auto", "always" or "never"`) 126 RootCmd.PersistentFlags().StringVarP(&cluster, "cluster", "s", defangFabric, "Defang cluster to connect to") 127 RootCmd.PersistentFlags().VarP(&provider, "provider", "P", `cloud provider to use; use "aws" for bring-your-own-cloud`) 128 RootCmd.PersistentFlags().BoolVarP(&cli.DoVerbose, "verbose", "v", false, "verbose logging") // backwards compat: only used by tail 129 RootCmd.PersistentFlags().BoolVar(&term.DoDebug, "debug", false, "debug logging for troubleshooting the CLI") 130 RootCmd.PersistentFlags().BoolVar(&cli.DoDryRun, "dry-run", false, "dry run (don't actually change anything)") 131 RootCmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "T", !hasTty, "disable interactive prompts / no TTY") 132 RootCmd.PersistentFlags().StringP("cwd", "C", "", "change directory before running the command") 133 RootCmd.MarkPersistentFlagDirname("cwd") 134 RootCmd.PersistentFlags().StringP("file", "f", "", `compose file path`) 135 RootCmd.MarkPersistentFlagFilename("file", "yml", "yaml") 136 137 // Bootstrap command 138 RootCmd.AddCommand(bootstrapCmd) 139 bootstrapCmd.AddCommand(bootstrapDestroyCmd) 140 bootstrapCmd.AddCommand(bootstrapDownCmd) 141 bootstrapCmd.AddCommand(bootstrapRefreshCmd) 142 bootstrapCmd.AddCommand(bootstrapTearDownCmd) 143 bootstrapCmd.AddCommand(bootstrapListCmd) 144 bootstrapCmd.AddCommand(bootstrapCancelCmd) 145 146 // Eula command 147 tosCmd.Flags().Bool("agree-tos", false, "Agree to the Defang terms of service") 148 RootCmd.AddCommand(tosCmd) 149 150 // Token command 151 tokenCmd.Flags().Duration("expires", 24*time.Hour, "Validity duration of the token") 152 tokenCmd.Flags().String("scope", "", fmt.Sprintf("Scope of the token; one of %v (required)", scope.All())) 153 tokenCmd.MarkFlagRequired("scope") 154 RootCmd.AddCommand(tokenCmd) 155 156 // Login Command 157 // loginCmd.Flags().Bool("skip-prompt", false, "Skip the login prompt if already logged in"); TODO: Implement this 158 RootCmd.AddCommand(loginCmd) 159 160 // Whoami Command 161 RootCmd.AddCommand(whoamiCmd) 162 163 // Logout Command 164 RootCmd.AddCommand(logoutCmd) 165 166 // Generate Command 167 //generateCmd.Flags().StringP("name", "n", "service1", "Name of the service") 168 RootCmd.AddCommand(generateCmd) 169 170 // Get Services Command 171 getServicesCmd.Flags().BoolP("long", "l", false, "Show more details") 172 RootCmd.AddCommand(getServicesCmd) 173 174 // Get Status Command 175 RootCmd.AddCommand(getVersionCmd) 176 177 // Config Command (was: secrets) 178 configSetCmd.Flags().BoolP("name", "n", false, "Name of the config (backwards compat)") 179 configSetCmd.Flags().MarkHidden("name") 180 configCmd.AddCommand(configSetCmd) 181 182 configDeleteCmd.Flags().BoolP("name", "n", false, "Name of the config(s) (backwards compat)") 183 configDeleteCmd.Flags().MarkHidden("name") 184 configCmd.AddCommand(configDeleteCmd) 185 186 configCmd.AddCommand(configListCmd) 187 188 RootCmd.AddCommand(configCmd) 189 RootCmd.AddCommand(restartCmd) 190 191 // Compose Command 192 // composeCmd.Flags().Bool("compatibility", false, "Run compose in backward compatibility mode"); TODO: Implement compose option 193 // composeCmd.Flags().String("env-file", "", "Specify an alternate environment file."); TODO: Implement compose option 194 // composeCmd.Flags().Int("parallel", -1, "Control max parallelism, -1 for unlimited (default -1)"); TODO: Implement compose option 195 // composeCmd.Flags().String("profile", "", "Specify a profile to enable"); TODO: Implement compose option 196 // composeCmd.Flags().String("project-directory", "", "Specify an alternate working directory"); TODO: Implement compose option 197 // composeCmd.Flags().StringP("project", "p", "", "Compose project name"); TODO: Implement compose option 198 composeUpCmd.Flags().Bool("tail", false, "Tail the service logs after updating") // obsolete, but keep for backwards compatibility 199 composeUpCmd.Flags().MarkHidden("tail") 200 composeUpCmd.Flags().Bool("force", false, "Force a build of the image even if nothing has changed") 201 composeUpCmd.Flags().BoolP("detach", "d", false, "Run in detached mode") 202 composeCmd.AddCommand(composeUpCmd) 203 composeCmd.AddCommand(composeConfigCmd) 204 composeDownCmd.Flags().Bool("tail", false, "Tail the service logs after deleting") // obsolete, but keep for backwards compatibility 205 composeDownCmd.Flags().BoolP("detach", "d", false, "Run in detached mode") 206 composeDownCmd.Flags().MarkHidden("tail") 207 composeCmd.AddCommand(composeDownCmd) 208 composeStartCmd.Flags().Bool("force", false, "Force a build of the image even if nothing has changed") 209 composeCmd.AddCommand(composeStartCmd) 210 RootCmd.AddCommand(composeCmd) 211 composeCmd.AddCommand(composeRestartCmd) 212 composeCmd.AddCommand(composeStopCmd) 213 214 // Tail Command 215 tailCmd.Flags().StringP("name", "n", "", "Name of the service") 216 tailCmd.Flags().String("etag", "", "ETag or deployment ID of the service") 217 tailCmd.Flags().BoolP("raw", "r", false, "Show raw (unparsed) logs") 218 tailCmd.Flags().String("since", "5s", "Show logs since duration/time") 219 RootCmd.AddCommand(tailCmd) 220 221 // Delete Command 222 deleteCmd.Flags().BoolP("name", "n", false, "Name of the service(s) (backwards compat)") 223 deleteCmd.Flags().MarkHidden("name") 224 deleteCmd.Flags().Bool("tail", false, "Tail the service logs after deleting") 225 RootCmd.AddCommand(deleteCmd) 226 227 // Send Command 228 sendCmd.Flags().StringP("subject", "n", "", "Subject to send the message to (required)") 229 sendCmd.Flags().StringP("type", "t", "", "Type of message to send (required)") 230 sendCmd.Flags().String("id", "", "ID of the message") 231 sendCmd.Flags().StringP("data", "d", "", "String data to send") 232 sendCmd.Flags().StringP("content-type", "c", "", "Content-Type of the data") 233 sendCmd.MarkFlagRequired("subject") 234 sendCmd.MarkFlagRequired("type") 235 RootCmd.AddCommand(sendCmd) 236 237 // Cert management 238 // TODO: Add list, renew etc. 239 certCmd.AddCommand(certGenerateCmd) 240 RootCmd.AddCommand(certCmd) 241 242 if term.CanColor { // TODO: should use DoColor(…) instead 243 // Add some emphasis to the help command 244 re := regexp.MustCompile(`(?m)^[A-Za-z ]+?:`) 245 templ := re.ReplaceAllString(RootCmd.UsageTemplate(), "\033[1m$0\033[0m") 246 RootCmd.SetUsageTemplate(templ) 247 } 248 249 origHelpFunc := RootCmd.HelpFunc() 250 RootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { 251 trackCmd(cmd, "Help", P{"args", args}) 252 origHelpFunc(cmd, args) 253 }) 254 } 255 256 var RootCmd = &cobra.Command{ 257 SilenceUsage: true, 258 SilenceErrors: true, 259 Use: "defang", 260 Args: cobra.NoArgs, 261 Short: "Defang CLI manages services on the Defang cluster", 262 PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) { 263 // Use "defer" to track any errors that occur during the command 264 defer func() { 265 trackCmd(cmd, "Invoked", P{"args", args}, P{"err", err}, P{"non-interactive", nonInteractive}, P{"provider", provider}) 266 }() 267 268 // Do this first, since any errors will be printed to the console 269 switch colorMode { 270 case ColorNever: 271 term.ForceColor(false) 272 case ColorAlways: 273 term.ForceColor(true) 274 } 275 276 switch provider { 277 case cliClient.ProviderAuto: 278 if awsInEnv() { 279 provider = cliClient.ProviderAWS 280 } else { 281 provider = cliClient.ProviderDefang 282 } 283 case cliClient.ProviderAWS: 284 if !awsInEnv() { 285 term.Warn(" ! AWS provider was selected, but AWS environment variables are not set") 286 } 287 case cliClient.ProviderDefang: 288 if awsInEnv() { 289 term.Warn(" ! Using Defang provider, but AWS environment variables were detected; use --provider") 290 } 291 } 292 293 cwd, _ := cmd.Flags().GetString("cwd") 294 if cwd != "" { 295 // Change directory before running the command 296 if err = os.Chdir(cwd); err != nil { 297 return err 298 } 299 } 300 301 composeFilePath, _ := cmd.Flags().GetString("file") 302 loader := cli.ComposeLoader{ComposeFilePath: composeFilePath} 303 client = cli.NewClient(cluster, provider, loader) 304 305 if v, err := client.GetVersions(cmd.Context()); err == nil { 306 version := "v" + cmd.Root().Version // HACK to avoid circular dependency with RootCmd 307 term.Debug(" - Fabric:", v.Fabric, "CLI:", version, "Min CLI:", v.CliMin) 308 if hasTty && semver.Compare(version, v.CliMin) < 0 { 309 term.Warn(" ! Your CLI version is outdated. Please update to the latest version.") 310 os.Setenv("DEFANG_HIDE_UPDATE", "1") // hide the update hint at the end 311 } 312 } 313 314 // Check if we are correctly logged in, but only if the command needs authorization 315 if _, ok := cmd.Annotations[authNeeded]; !ok { 316 return nil 317 } 318 319 if err = client.CheckLoginAndToS(cmd.Context()); err != nil { 320 if nonInteractive { 321 return err 322 } 323 // Login interactively now; only do this for authorization-related errors 324 if connect.CodeOf(err) == connect.CodeUnauthenticated { 325 term.Warn(" !", prettyError(err)) 326 327 if err = cli.InteractiveLogin(cmd.Context(), client, gitHubClientId, cluster); err != nil { 328 return err 329 } 330 331 // FIXME: the new login might have changed the tenant, so we should reload the project 332 client = cli.NewClient(cluster, provider, loader) // reconnect with the new token 333 if err = client.CheckLoginAndToS(cmd.Context()); err == nil { // recheck (new token = new user) 334 return nil // success 335 } 336 } 337 338 // Check if the user has agreed to the terms of service and show a prompt if needed 339 if connect.CodeOf(err) == connect.CodeFailedPrecondition { 340 term.Warn(" !", prettyError(err)) 341 if err = cli.InteractiveAgreeToS(cmd.Context(), client); err != nil { 342 return err 343 } 344 } 345 } 346 return err 347 }, 348 } 349 350 var loginCmd = &cobra.Command{ 351 Use: "login", 352 Args: cobra.NoArgs, 353 Short: "Authenticate to the Defang cluster", 354 RunE: func(cmd *cobra.Command, args []string) error { 355 if nonInteractive { 356 if err := cli.NonInteractiveLogin(cmd.Context(), client, cluster); err != nil { 357 return err 358 } 359 } else { 360 err := cli.InteractiveLogin(cmd.Context(), client, gitHubClientId, cluster) 361 if err != nil { 362 return err 363 } 364 365 printDefangHint("To generate a sample service, do:", "generate") 366 } 367 return nil 368 }, 369 } 370 371 var whoamiCmd = &cobra.Command{ 372 Use: "whoami", 373 Args: cobra.NoArgs, 374 Short: "Show the current user", 375 RunE: func(cmd *cobra.Command, args []string) error { 376 err := cli.Whoami(cmd.Context(), client) // always prints 377 if err != nil { 378 return err 379 } 380 return nil 381 }, 382 } 383 384 var certCmd = &cobra.Command{ 385 Use: "cert", 386 Args: cobra.NoArgs, 387 Short: "Manage certificates", 388 } 389 390 var certGenerateCmd = &cobra.Command{ 391 Use: "generate", 392 Aliases: []string{"gen"}, 393 Args: cobra.NoArgs, 394 Short: "Generate an letsencrypt certificate", 395 RunE: func(cmd *cobra.Command, args []string) error { 396 err := cli.GenerateLetsEncryptCert(cmd.Context(), client) 397 if err != nil { 398 return err 399 } 400 return nil 401 }, 402 } 403 404 var generateCmd = &cobra.Command{ 405 Use: "generate", 406 Args: cobra.NoArgs, 407 Aliases: []string{"gen", "new", "init"}, 408 Short: "Generate a sample Defang project in the current folder", 409 RunE: func(cmd *cobra.Command, args []string) error { 410 if nonInteractive { 411 return errors.New("cannot run in non-interactive mode") 412 } 413 414 var qs = []*survey.Question{ 415 { 416 Name: "language", 417 Prompt: &survey.Select{ 418 Message: "Choose the language you'd like to use:", 419 Options: []string{"Nodejs", "Golang", "Python"}, 420 Default: "Nodejs", 421 Help: "The generated code will be in the language you choose here.", 422 }, 423 }, 424 { 425 Name: "description", 426 Prompt: &survey.Input{ 427 Message: "Please describe the service you'd like to build:", 428 Help: `Here are some example prompts you can use: 429 "A simple 'hello world' function" 430 "A service with 2 endpoints, one to upload and the other to download a file from AWS S3" 431 "A service with a default endpoint that returns an HTML page with a form asking for the user's name and then a POST endpoint to handle the form post when the user clicks the 'submit' button\" 432 Generate will write files in the current folder. You can edit them and then deploy using 'defang compose up --tail' when ready.`, 433 }, 434 Validate: survey.MinLength(5), 435 }, 436 { 437 Name: "folder", 438 Prompt: &survey.Input{ 439 Message: "What folder would you like to create the service in?", 440 Default: "service1", 441 Help: "The generated code will be in the folder you choose here. If the folder does not exist, it will be created.", 442 }, 443 Validate: survey.Required, 444 }, 445 } 446 447 prompt := struct { 448 Language string // or you can tag fields to match a specific name 449 Description string 450 Folder string 451 }{} 452 453 // ask the questions 454 err := survey.Ask(qs, &prompt) 455 if err != nil { 456 return err 457 } 458 459 if client.CheckLoginAndToS(cmd.Context()) != nil { 460 // The user is either not logged in or has not agreed to the terms of service; ask for agreement to the terms now 461 if err := cli.InteractiveAgreeToS(cmd.Context(), client); err != nil { 462 // This might fail because the user did not log in. This is fine: we won't persist the terms agreement, but can proceed with the generation 463 if connect.CodeOf(err) != connect.CodeUnauthenticated { 464 return err 465 } 466 } 467 } 468 469 Track("Generate Started", P{"language", prompt.Language}, P{"description", prompt.Description}, P{"folder", prompt.Folder}) 470 471 // create the folder if needed 472 cd := "" 473 if prompt.Folder != "." { 474 cd = "`cd " + prompt.Folder + "` and " 475 os.MkdirAll(prompt.Folder, 0755) 476 if err := os.Chdir(prompt.Folder); err != nil { 477 return err 478 } 479 } 480 481 // Check if the current folder is empty 482 if empty, err := pkg.IsDirEmpty("."); !empty || err != nil { 483 term.Warn(" ! The folder is not empty. Files may be overwritten. Press Ctrl+C to abort.") 484 } 485 486 term.Info(" * Working on it. This may take 1 or 2 minutes...") 487 _, err = cli.Generate(cmd.Context(), client, prompt.Language, prompt.Description) 488 if err != nil { 489 return err 490 } 491 492 term.Info(" * Code generated successfully in folder", prompt.Folder) 493 494 // TODO: should we use EDITOR env var instead? 495 cmdd := exec.Command("code", ".") 496 err = cmdd.Start() 497 if err != nil { 498 term.Debug(" - unable to launch VS Code:", err) 499 } 500 501 printDefangHint("Check the files in your favorite editor.\nTo deploy the service, "+cd+"do:", "compose up") 502 return nil 503 }, 504 } 505 506 var getServicesCmd = &cobra.Command{ 507 Use: "services", 508 Annotations: authNeededAnnotation, 509 Args: cobra.NoArgs, 510 Aliases: []string{"getServices", "ls", "list"}, 511 Short: "Get list of services on the cluster", 512 RunE: func(cmd *cobra.Command, args []string) error { 513 long, _ := cmd.Flags().GetBool("long") 514 515 err := cli.GetServices(cmd.Context(), client, long) 516 if err != nil { 517 return err 518 } 519 520 if !long { 521 printDefangHint("To see more information about your services, do:", cmd.CalledAs()+" -l") 522 } 523 524 return nil 525 }, 526 } 527 528 var getVersionCmd = &cobra.Command{ 529 Use: "version", 530 Args: cobra.NoArgs, 531 Aliases: []string{"ver", "stat", "status"}, // for backwards compatibility 532 Short: "Get version information for the CLI and Fabric service", 533 RunE: func(cmd *cobra.Command, args []string) error { 534 term.Print(term.BrightCyan, "Defang CLI: ") 535 fmt.Println(GetCurrentVersion()) 536 537 term.Print(term.BrightCyan, "Latest CLI: ") 538 ver, err := GetLatestVersion(cmd.Context()) 539 fmt.Println(ver) 540 541 term.Print(term.BrightCyan, "Defang Fabric: ") 542 ver, err2 := cli.GetVersion(cmd.Context(), client) 543 fmt.Println(ver) 544 return errors.Join(err, err2) 545 }, 546 } 547 548 var tailCmd = &cobra.Command{ 549 Use: "tail", 550 Annotations: authNeededAnnotation, 551 Args: cobra.NoArgs, 552 Short: "Tail logs from one or more services", 553 RunE: func(cmd *cobra.Command, args []string) error { 554 var name, _ = cmd.Flags().GetString("name") 555 var etag, _ = cmd.Flags().GetString("etag") 556 var raw, _ = cmd.Flags().GetBool("raw") 557 var since, _ = cmd.Flags().GetString("since") 558 559 ts, err := cli.ParseTimeOrDuration(since) 560 if err != nil { 561 return fmt.Errorf("invalid duration or time: %w", err) 562 } 563 564 ts = ts.UTC() 565 term.Info(" * Showing logs since", ts.Format(time.RFC3339Nano), "; press Ctrl+C to stop:") 566 return cli.Tail(cmd.Context(), client, name, etag, ts, raw) 567 }, 568 } 569 570 var configCmd = &cobra.Command{ 571 Use: "config", // like Docker 572 Args: cobra.NoArgs, 573 Aliases: []string{"secrets", "secret"}, 574 Short: "Add, update, or delete service config", 575 } 576 577 var configSetCmd = &cobra.Command{ 578 Use: "create CONFIG", // like Docker 579 Annotations: authNeededAnnotation, 580 Args: cobra.ExactArgs(1), 581 Aliases: []string{"set", "add", "put"}, 582 Short: "Adds or updates a sensitive config value", 583 RunE: func(cmd *cobra.Command, args []string) error { 584 name := args[0] 585 586 var value string 587 if !nonInteractive { 588 // Prompt for sensitive value 589 var sensitivePrompt = &survey.Password{ 590 Message: fmt.Sprintf("Enter value for %q:", name), 591 Help: "The value will be stored securely and cannot be retrieved later.", 592 } 593 594 err := survey.AskOne(sensitivePrompt, &value) 595 if err != nil { 596 return err 597 } 598 } else { 599 bytes, err := io.ReadAll(os.Stdin) 600 if err != nil && err != io.EOF { 601 return fmt.Errorf("failed reading the value from non-terminal: %w", err) 602 } 603 value = strings.TrimSuffix(string(bytes), "\n") 604 } 605 606 if err := cli.ConfigSet(cmd.Context(), client, name, value); err != nil { 607 return err 608 } 609 term.Info(" * Updated value for", name) 610 611 printDefangHint("To update the deployed values, do:", "compose start") 612 return nil 613 }, 614 } 615 616 var configDeleteCmd = &cobra.Command{ 617 Use: "rm CONFIG...", // like Docker 618 Annotations: authNeededAnnotation, 619 Args: cobra.MinimumNArgs(1), 620 Aliases: []string{"del", "delete", "remove"}, 621 Short: "Removes one or more config values", 622 RunE: func(cmd *cobra.Command, names []string) error { 623 if err := cli.ConfigDelete(cmd.Context(), client, names...); err != nil { 624 // Show a warning (not an error) if the config was not found 625 if connect.CodeOf(err) == connect.CodeNotFound { 626 term.Warn(" !", prettyError(err)) 627 return nil 628 } 629 return err 630 } 631 term.Info(" * Deleted", names) 632 633 printDefangHint("To list the configs (but not their values), do:", "config ls") 634 return nil 635 }, 636 } 637 638 var configListCmd = &cobra.Command{ 639 Use: "ls", // like Docker 640 Annotations: authNeededAnnotation, 641 Args: cobra.NoArgs, 642 Aliases: []string{"list"}, 643 Short: "List configs", 644 RunE: func(cmd *cobra.Command, args []string) error { 645 return cli.ConfigList(cmd.Context(), client) 646 }, 647 } 648 649 var composeCmd = &cobra.Command{ 650 Use: "compose", 651 Aliases: []string{"stack"}, 652 Args: cobra.NoArgs, 653 Short: "Work with local Compose files", 654 } 655 656 func printPlaygroundPortalServiceURLs(serviceInfos []*defangv1.ServiceInfo) { 657 // We can only show services deployed to the prod1 defang SaaS environment. 658 if provider == cliClient.ProviderDefang && cluster == cli.DefaultCluster { 659 term.Info(" * Monitor your services' status in the defang portal") 660 for _, serviceInfo := range serviceInfos { 661 fmt.Println(" -", SERVICE_PORTAL_URL+"/"+serviceInfo.Service.Name) 662 } 663 } 664 } 665 666 func printEndpoints(serviceInfos []*defangv1.ServiceInfo) { 667 for _, serviceInfo := range serviceInfos { 668 andEndpoints := "" 669 if len(serviceInfo.Endpoints) > 0 { 670 andEndpoints = "and will be available at:" 671 } 672 term.Info(" * Service", serviceInfo.Service.Name, "is in state", serviceInfo.Status, andEndpoints) 673 for i, endpoint := range serviceInfo.Endpoints { 674 if serviceInfo.Service.Ports[i].Mode == defangv1.Mode_INGRESS { 675 endpoint = "https://" + endpoint 676 } 677 fmt.Println(" -", endpoint) 678 } 679 if serviceInfo.Service.Domainname != "" { 680 if serviceInfo.ZoneId != "" { 681 fmt.Println(" -", "https://"+serviceInfo.Service.Domainname) 682 } else { 683 fmt.Println(" -", "https://"+serviceInfo.Service.Domainname+" (after ACME cert activation)") 684 } 685 } 686 } 687 } 688 689 var composeUpCmd = &cobra.Command{ 690 Use: "up", 691 Annotations: authNeededAnnotation, 692 Args: cobra.NoArgs, // TODO: takes optional list of service names 693 Short: "Like 'start' but immediately tracks the progress of the deployment", 694 RunE: func(cmd *cobra.Command, args []string) error { 695 var force, _ = cmd.Flags().GetBool("force") 696 var detach, _ = cmd.Flags().GetBool("detach") 697 698 since := time.Now() 699 deploy, err := cli.ComposeStart(cmd.Context(), client, force) 700 if err != nil { 701 return err 702 } 703 704 printPlaygroundPortalServiceURLs(deploy.Services) 705 printEndpoints(deploy.Services) // TODO: do this at the end 706 707 if detach { 708 term.Info(" * Done.") 709 return nil 710 } 711 712 etag := deploy.Etag 713 services := "all services" 714 if etag != "" { 715 services = "deployment ID " + etag 716 } 717 718 term.Info(" * Tailing logs for", services, "; press Ctrl+C to detach:") 719 err = cli.Tail(cmd.Context(), client, "", etag, since, false) 720 if err != nil { 721 return err 722 } 723 term.Info(" * Done.") 724 return nil 725 }, 726 } 727 728 var composeStartCmd = &cobra.Command{ 729 Use: "start", 730 Aliases: []string{"deploy"}, 731 Annotations: authNeededAnnotation, 732 Args: cobra.NoArgs, // TODO: takes optional list of service names 733 Short: "Reads a Compose file and deploys services to the cluster", 734 RunE: func(cmd *cobra.Command, args []string) error { 735 var force, _ = cmd.Flags().GetBool("force") 736 737 deploy, err := cli.ComposeStart(cmd.Context(), client, force) 738 if err != nil { 739 return err 740 } 741 742 printPlaygroundPortalServiceURLs(deploy.Services) 743 printEndpoints(deploy.Services) // TODO: do this at the end 744 745 command := "tail" 746 if deploy.Etag != "" { 747 command += " --etag " + deploy.Etag 748 } 749 printDefangHint("To track the update, do:", command) 750 return nil 751 }, 752 } 753 754 var composeRestartCmd = &cobra.Command{ 755 Use: "restart", 756 Annotations: authNeededAnnotation, 757 Args: cobra.NoArgs, // TODO: takes optional list of service names 758 Short: "Reads a Compose file and restarts its services", 759 RunE: func(cmd *cobra.Command, args []string) error { 760 etag, err := cli.ComposeRestart(cmd.Context(), client) 761 if err != nil { 762 return err 763 } 764 term.Info(" * Restarted services with deployment ID", etag) 765 return nil 766 }, 767 } 768 769 var composeStopCmd = &cobra.Command{ 770 Use: "stop", 771 Annotations: authNeededAnnotation, 772 Args: cobra.NoArgs, // TODO: takes optional list of service names 773 Short: "Reads a Compose file and stops its services", 774 RunE: func(cmd *cobra.Command, args []string) error { 775 etag, err := cli.ComposeStop(cmd.Context(), client) 776 if err != nil { 777 return err 778 } 779 term.Info(" * Stopped services with deployment ID", etag) 780 return nil 781 }, 782 } 783 784 var composeDownCmd = &cobra.Command{ 785 Use: "down", 786 Aliases: []string{"rm"}, 787 Annotations: authNeededAnnotation, 788 Args: cobra.NoArgs, // TODO: takes optional list of service names 789 Short: "Like 'stop' but also deprovisions the services from the cluster", 790 RunE: func(cmd *cobra.Command, args []string) error { 791 var detach, _ = cmd.Flags().GetBool("detach") 792 793 since := time.Now() 794 etag, err := cli.ComposeDown(cmd.Context(), client) 795 if err != nil { 796 if connect.CodeOf(err) == connect.CodeNotFound { 797 // Show a warning (not an error) if the service was not found 798 term.Warn(" !", prettyError(err)) 799 return nil 800 } 801 return err 802 } 803 804 term.Info(" * Deleted services, deployment ID", etag) 805 806 if detach { 807 printDefangHint("To track the update, do:", "tail --etag "+etag) 808 return nil 809 } 810 811 err = cli.Tail(cmd.Context(), client, "", etag, since, false) 812 if err != nil { 813 return err 814 } 815 term.Info(" * Done.") 816 return nil 817 818 }, 819 } 820 821 var composeConfigCmd = &cobra.Command{ 822 Use: "config", 823 Args: cobra.NoArgs, // TODO: takes optional list of service names 824 Short: "Reads a Compose file and shows the generated config", 825 RunE: func(cmd *cobra.Command, args []string) error { 826 cli.DoDryRun = true // config is like start in a dry run 827 // force=false to calculate the digest 828 if _, err := cli.ComposeStart(cmd.Context(), client, false); !errors.Is(err, cli.ErrDryRun) { 829 return err 830 } 831 return nil 832 }, 833 } 834 835 var deleteCmd = &cobra.Command{ 836 Use: "delete SERVICE...", 837 Annotations: authNeededAnnotation, 838 Args: cobra.MinimumNArgs(1), 839 Aliases: []string{"del", "rm", "remove"}, 840 Short: "Delete a service from the cluster", 841 RunE: func(cmd *cobra.Command, names []string) error { 842 var tail, _ = cmd.Flags().GetBool("tail") 843 844 since := time.Now() 845 etag, err := cli.Delete(cmd.Context(), client, names...) 846 if err != nil { 847 if connect.CodeOf(err) == connect.CodeNotFound { 848 // Show a warning (not an error) if the service was not found 849 term.Warn(" !", prettyError(err)) 850 return nil 851 } 852 return err 853 } 854 855 term.Info(" * Deleted service", names, "with deployment ID", etag) 856 857 if !tail { 858 printDefangHint("To track the update, do:", "tail --etag "+etag) 859 return nil 860 } 861 862 term.Info(" * Tailing logs for update; press Ctrl+C to detach:") 863 return cli.Tail(cmd.Context(), client, "", etag, since, false) 864 }, 865 } 866 867 var restartCmd = &cobra.Command{ 868 Use: "restart SERVICE...", 869 Annotations: authNeededAnnotation, 870 Args: cobra.MinimumNArgs(1), 871 Short: "Restart one or more services", 872 RunE: func(cmd *cobra.Command, args []string) error { 873 etag, err := cli.Restart(cmd.Context(), client, args...) 874 if err != nil { 875 return err 876 } 877 term.Info(" * Restarted service", args, "with deployment ID", etag) 878 return nil 879 }, 880 } 881 882 var sendCmd = &cobra.Command{ 883 Use: "send", 884 Hidden: true, // not available in private beta 885 Annotations: authNeededAnnotation, 886 Args: cobra.NoArgs, 887 Aliases: []string{"msg", "message", "publish", "pub"}, 888 Short: "Send a message to a service", 889 RunE: func(cmd *cobra.Command, args []string) error { 890 var id, _ = cmd.Flags().GetString("id") 891 var _type, _ = cmd.Flags().GetString("type") 892 var data, _ = cmd.Flags().GetString("data") 893 var contenttype, _ = cmd.Flags().GetString("content-type") 894 var subject, _ = cmd.Flags().GetString("subject") 895 896 return cli.SendMsg(cmd.Context(), client, subject, _type, id, []byte(data), contenttype) 897 }, 898 } 899 900 var tokenCmd = &cobra.Command{ 901 Use: "token", 902 Annotations: authNeededAnnotation, 903 Args: cobra.NoArgs, 904 Short: "Manage personal access tokens", 905 RunE: func(cmd *cobra.Command, args []string) error { 906 var s, _ = cmd.Flags().GetString("scope") 907 var expires, _ = cmd.Flags().GetDuration("expires") 908 909 // TODO: should default to use the current tenant, not the default tenant 910 return cli.Token(cmd.Context(), client, gitHubClientId, types.DEFAULT_TENANT, expires, scope.Scope(s)) 911 }, 912 } 913 914 var logoutCmd = &cobra.Command{ 915 Use: "logout", 916 Args: cobra.NoArgs, 917 Aliases: []string{"logoff", "revoke"}, 918 Short: "Log out", 919 RunE: func(cmd *cobra.Command, args []string) error { 920 if err := cli.Logout(cmd.Context(), client); err != nil { 921 return err 922 } 923 term.Info(" * Successfully logged out") 924 return nil 925 }, 926 } 927 928 var bootstrapCmd = &cobra.Command{ 929 Use: "cd", 930 Aliases: []string{"bootstrap"}, 931 Args: cobra.NoArgs, 932 Short: "Manually run a command with the CD task", 933 } 934 935 var bootstrapDestroyCmd = &cobra.Command{ 936 Use: "destroy", 937 Args: cobra.NoArgs, 938 Short: "Destroy the service stack", 939 RunE: func(cmd *cobra.Command, args []string) error { 940 return cli.BootstrapCommand(cmd.Context(), client, "destroy") 941 }, 942 } 943 944 var bootstrapDownCmd = &cobra.Command{ 945 Use: "down", 946 Args: cobra.NoArgs, 947 Short: "Refresh and then destroy the service stack", 948 RunE: func(cmd *cobra.Command, args []string) error { 949 return cli.BootstrapCommand(cmd.Context(), client, "down") 950 }, 951 } 952 953 var bootstrapRefreshCmd = &cobra.Command{ 954 Use: "refresh", 955 Args: cobra.NoArgs, 956 Short: "Refresh the service stack", 957 RunE: func(cmd *cobra.Command, args []string) error { 958 return cli.BootstrapCommand(cmd.Context(), client, "refresh") 959 }, 960 } 961 962 var bootstrapTearDownCmd = &cobra.Command{ 963 Use: "teardown", 964 Args: cobra.NoArgs, 965 Short: "Destroy the CD cluster without destroying the services", 966 RunE: func(cmd *cobra.Command, args []string) error { 967 term.Warn(` ! Deleting the CD cluster; this does not delete the services!`) 968 return cli.TearDown(cmd.Context(), client) 969 }, 970 } 971 972 var bootstrapListCmd = &cobra.Command{ 973 Use: "ls", 974 Args: cobra.NoArgs, 975 Aliases: []string{"list"}, 976 Short: "List all the projects and stacks in the CD cluster", 977 RunE: func(cmd *cobra.Command, args []string) error { 978 return cli.BootstrapList(cmd.Context(), client) 979 }, 980 } 981 982 var bootstrapCancelCmd = &cobra.Command{ 983 Use: "cancel", 984 Args: cobra.NoArgs, 985 Short: "Cancel the current CD operation", 986 RunE: func(cmd *cobra.Command, args []string) error { 987 return cli.BootstrapCommand(cmd.Context(), client, "cancel") 988 }, 989 } 990 991 var tosCmd = &cobra.Command{ 992 Use: "terms", 993 Aliases: []string{"tos", "eula", "tac", "tou"}, 994 Annotations: authNeededAnnotation, // TODO: only need auth when agreeing to the terms 995 Args: cobra.NoArgs, 996 Short: "Read and/or agree the Defang terms of service", 997 RunE: func(cmd *cobra.Command, args []string) error { 998 agree, _ := cmd.Flags().GetBool("agree-tos") 999 1000 if agree { 1001 return cli.NonInteractiveAgreeToS(cmd.Context(), client) 1002 } 1003 1004 if !nonInteractive { 1005 return cli.InteractiveAgreeToS(cmd.Context(), client) 1006 } 1007 1008 printDefangHint("To agree to the terms of service, do:", cmd.CalledAs()+" --agree-tos") 1009 return nil 1010 }, 1011 } 1012 1013 func awsInEnv() bool { 1014 return os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_SECRET_ACCESS_KEY") != "" 1015 }