Skip to content

Commit ed59da3

Browse files
heroku migration with defang init (#1354)
* rough cut of PaaS import * setup protos * use fabric for llm invocations * s/DeriveCompose/GenerateCompose/g * retry if compose file is not valid yaml * refactor to expose mockable surveyor interface * upgrade secret-detector * fix: correct retry behavior in generateComposeFile and fix test expectations * remove secrets from config vars before sending to server * update defang vendor hash * remove version from generated compose file * write compose file after generating it * factor out surveyor package * avoid proto getter * handle windows newlines * avoid stat before write--open with O_CREAT & O_EXCL * use any instead of interface{} * refactor extractFirstCodeBlock to return an error if code block is not found or incomplete * use our http client, and reduce timeout * refactor SourcePlatform type to support cobra help text * prefix SourcePlatform * rename proto enum avlue * generate cmd ctx * factor out handleGenerate * combine new and setup commands * use init as the primary name for new command * factor out afterGenerate * promote const * remove redundant test if sample exists. cli.InitFromSamples already handles this * promote default folder derivation * extract promptForSample * extract beforeGenerate * partition sample and generate code paths * invert handleGenerate so that cloneSample prompts and delegates to ai generate if necessary * invoke cloneFromSample, aiGenerate, and migrateFromHerok in init * fold beforeGenerateWithAI back into aiGenerate * use a constant for tracking event name * fold beforeGenerateFromSample back into cloneSample * rename setup pkg to migrate * move setup code into setup package * fix panic * add managed db tags to generate heroku compose file * improve prompt for heroku auth token Co-authored-by: Lio李歐 <[email protected]> * preserve comments when cleaning yaml * automatically generate heroku token * interactive login before heroku migration --------- Co-authored-by: Lio李歐 <[email protected]>
1 parent b6a7f36 commit ed59da3

File tree

19 files changed

+2281
-694
lines changed

19 files changed

+2281
-694
lines changed

pkgs/defang/cli.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ buildGoModule {
77
pname = "defang-cli";
88
version = "git";
99
src = ../../src;
10-
vendorHash = "sha256-4QMrneh4I2gTFf7erVnakqPDZakFzceGaN+ieAMNPX0="; # TODO: use fetchFromGitHub
10+
vendorHash = "sha256-SfjkSc0Upaa12+GrRCpEqCSp4+6F/J+jEzVoE2ELdNY="; # TODO: use fetchFromGitHub
1111

1212
subPackages = [ "cmd/cli" ];
1313

src/cmd/cli/command/commands.go

Lines changed: 76 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"os/exec"
1010
"path/filepath"
1111
"regexp"
12-
"slices"
1312
"strings"
1413
"time"
1514

@@ -23,7 +22,10 @@ import (
2322
"github.com/DefangLabs/defang/src/pkg/clouds/aws"
2423
"github.com/DefangLabs/defang/src/pkg/logs"
2524
"github.com/DefangLabs/defang/src/pkg/mcp"
25+
"github.com/DefangLabs/defang/src/pkg/migrate"
2626
"github.com/DefangLabs/defang/src/pkg/scope"
27+
"github.com/DefangLabs/defang/src/pkg/setup"
28+
"github.com/DefangLabs/defang/src/pkg/surveyor"
2729
"github.com/DefangLabs/defang/src/pkg/term"
2830
"github.com/DefangLabs/defang/src/pkg/track"
2931
"github.com/DefangLabs/defang/src/pkg/types"
@@ -43,6 +45,7 @@ var (
4345
client *cliClient.GrpcClient
4446
cluster string
4547
colorMode = ColorAuto
48+
sourcePlatform = migrate.SourcePlatformUnspecified // default to auto-detecting the source platform
4649
doDebug = false
4750
hasTty = term.IsTerminal() && !pkg.GetenvBool("CI")
4851
hideUpdate = pkg.GetenvBool("DEFANG_HIDE_UPDATE")
@@ -227,7 +230,9 @@ func SetupCommands(ctx context.Context, version string) {
227230
// Generate Command
228231
generateCmd.Flags().StringVar(&modelId, "model", modelId, "LLM model to use for generating the code (Pro users only)")
229232
RootCmd.AddCommand(generateCmd)
230-
RootCmd.AddCommand(newCmd)
233+
// new command
234+
initCmd.PersistentFlags().Var(&sourcePlatform, "from", fmt.Sprintf(`the platform from which to migrate the project; one of %v`, migrate.AllSourcePlatforms))
235+
RootCmd.AddCommand(initCmd)
231236

232237
// Get Services Command
233238
lsCommand := makeComposePsCmd()
@@ -507,205 +512,101 @@ var certGenerateCmd = &cobra.Command{
507512
},
508513
}
509514

515+
func afterGenerate(ctx context.Context, result setup.SetupResult) {
516+
term.Info("Code generated successfully in folder", result.Folder)
517+
editor := pkg.Getenv("EDITOR", "code")
518+
cmdd := exec.Command(editor, result.Folder)
519+
err := cmdd.Start()
520+
if err != nil {
521+
term.Debugf("unable to launch editor %q: %v", editor, err)
522+
}
523+
524+
cd := ""
525+
if result.Folder != "." {
526+
cd = "`cd " + result.Folder + "` and "
527+
}
528+
529+
// Load the project and check for empty environment variables
530+
loader := compose.NewLoader(compose.WithPath(filepath.Join(result.Folder, "compose.yaml")))
531+
project, err := loader.LoadProject(ctx)
532+
if err != nil {
533+
term.Debugf("unable to load new project: %v", err)
534+
}
535+
536+
var envInstructions []string
537+
for _, envVar := range collectUnsetEnvVars(project) {
538+
envInstructions = append(envInstructions, "config create "+envVar)
539+
}
540+
541+
if len(envInstructions) > 0 {
542+
printDefangHint("Check the files in your favorite editor.\nTo configure the service, do "+cd, envInstructions...)
543+
} else {
544+
printDefangHint("Check the files in your favorite editor.\nTo deploy the service, do "+cd, "compose up")
545+
}
546+
}
547+
510548
var generateCmd = &cobra.Command{
511549
Use: "generate",
512550
Args: cobra.MaximumNArgs(1),
513551
Aliases: []string{"gen"},
514552
Short: "Generate a sample Defang project",
515553
RunE: func(cmd *cobra.Command, args []string) error {
516-
var sample, language, defaultFolder string
517-
if len(args) > 0 {
518-
sample = args[0]
519-
}
554+
ctx := cmd.Context()
520555

521-
if nonInteractive {
522-
if sample == "" {
523-
return errors.New("cannot run in non-interactive mode")
524-
}
525-
return cli.InitFromSamples(cmd.Context(), "", []string{sample})
526-
}
527-
528-
sampleList, fetchSamplesErr := cli.FetchSamples(cmd.Context())
529-
if sample == "" {
530-
// Fetch the list of samples from the Defang repository
531-
if fetchSamplesErr != nil {
532-
term.Debug("unable to fetch samples:", fetchSamplesErr)
533-
} else if len(sampleList) > 0 {
534-
const generateWithAI = "Generate with AI"
535-
536-
sampleNames := []string{generateWithAI}
537-
sampleTitles := []string{"Generate a sample from scratch using a language prompt"}
538-
sampleIndex := []string{"unused first entry because we always show genAI option"}
539-
for _, sample := range sampleList {
540-
sampleNames = append(sampleNames, sample.Name)
541-
sampleTitles = append(sampleTitles, sample.Title)
542-
sampleIndex = append(sampleIndex, strings.ToLower(sample.Name+" "+sample.Title+" "+
543-
strings.Join(sample.Tags, " ")+" "+strings.Join(sample.Languages, " ")))
544-
}
545-
546-
if err := survey.AskOne(&survey.Select{
547-
Message: "Choose a sample service:",
548-
Options: sampleNames,
549-
Help: "The project code will be based on the sample you choose here.",
550-
Filter: func(filter string, value string, i int) bool {
551-
return i == 0 || strings.Contains(sampleIndex[i], strings.ToLower(filter))
552-
},
553-
Description: func(value string, i int) string {
554-
return sampleTitles[i]
555-
},
556-
}, &sample, survey.WithStdio(term.DefaultTerm.Stdio())); err != nil {
557-
return err
558-
}
559-
if sample == generateWithAI {
560-
if err := survey.AskOne(&survey.Select{
561-
Message: "Choose the language you'd like to use:",
562-
Options: cli.SupportedLanguages,
563-
Help: "The project code will be in the language you choose here.",
564-
}, &language, survey.WithStdio(term.DefaultTerm.Stdio())); err != nil {
565-
return err
566-
}
567-
sample = ""
568-
defaultFolder = "project1"
569-
} else {
570-
defaultFolder = sample
571-
}
572-
}
556+
setupClient := setup.SetupClient{
557+
Surveyor: surveyor.NewDefaultSurveyor(),
558+
Heroku: migrate.NewHerokuClient(),
559+
ModelID: modelId,
560+
Fabric: client,
561+
Cluster: getCluster(),
573562
}
574563

575-
var qs = []*survey.Question{
576-
{
577-
Name: "description",
578-
Prompt: &survey.Input{
579-
Message: "Please describe the service you'd like to build:",
580-
Help: `Here are some example prompts you can use:
581-
"A simple 'hello world' function"
582-
"A service with 2 endpoints, one to upload and the other to download a file from AWS S3"
583-
"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"`,
584-
},
585-
Validate: survey.MinLength(5),
586-
},
587-
{
588-
Name: "folder",
589-
Prompt: &survey.Input{
590-
Message: "What folder would you like to create the project in?",
591-
Default: defaultFolder, // dynamically set based on chosen sample
592-
Help: "The generated code will be in the folder you choose here. If the folder does not exist, it will be created.",
593-
},
594-
Validate: survey.Required,
595-
},
596-
}
597-
598-
if sample != "" {
599-
qs = qs[1:] // user picked a sample, so we skip the description question
600-
sampleExists := slices.ContainsFunc(sampleList, func(s cli.Sample) bool {
601-
return s.Name == sample
602-
})
603-
604-
if !sampleExists {
605-
return cli.ErrSampleNotFound
606-
}
564+
sample := ""
565+
if len(args) > 0 {
566+
sample = args[0]
607567
}
608-
609-
prompt := struct {
610-
Description string // or you can tag fields to match a specific name
611-
Folder string
612-
}{}
613-
614-
// ask the remaining questions
615-
err := survey.Ask(qs, &prompt, survey.WithStdio(term.DefaultTerm.Stdio()))
568+
result, err := setupClient.CloneSample(ctx, sample)
616569
if err != nil {
617570
return err
618571
}
572+
afterGenerate(ctx, result)
573+
return nil
574+
},
575+
}
619576

620-
if client.CheckLoginAndToS(cmd.Context()) != nil {
621-
// The user is either not logged in or has not agreed to the terms of service; ask for agreement to the terms now
622-
if err := cli.InteractiveAgreeToS(cmd.Context(), client); err != nil {
623-
// This might fail because the user did not log in. This is fine: server won't save the terms agreement, but can proceed with the generation
624-
if connect.CodeOf(err) != connect.CodeUnauthenticated {
625-
return err
626-
}
627-
}
628-
}
629-
630-
track.Evt("Generate Started", P("language", language), P("sample", sample), P("description", prompt.Description), P("folder", prompt.Folder), P("model", modelId))
631-
632-
// Check if the current folder is empty
633-
if empty, err := pkg.IsDirEmpty(prompt.Folder); !os.IsNotExist(err) && !empty {
634-
nonEmptyFolder := fmt.Sprintf("The folder %q is not empty. We recommend running this command in an empty folder.", prompt.Folder)
635-
636-
var confirm bool
637-
err := survey.AskOne(&survey.Confirm{
638-
Message: nonEmptyFolder + " Continue creating project?",
639-
}, &confirm, survey.WithStdio(term.DefaultTerm.Stdio()))
640-
if err == nil && !confirm {
641-
os.Exit(1)
642-
}
643-
}
644-
645-
if sample != "" {
646-
term.Info("Fetching sample from the Defang repository...")
647-
err := cli.InitFromSamples(cmd.Context(), prompt.Folder, []string{sample})
648-
if err != nil {
649-
return err
650-
}
651-
} else {
652-
term.Info("Working on it. This may take 1 or 2 minutes...")
653-
args := cli.GenerateArgs{
654-
Description: prompt.Description,
655-
Folder: prompt.Folder,
656-
Language: language,
657-
ModelId: modelId,
658-
}
659-
_, err := cli.GenerateWithAI(cmd.Context(), client, args)
660-
if err != nil {
661-
return err
662-
}
577+
var initCmd = &cobra.Command{
578+
Use: "init [SAMPLE]",
579+
Args: cobra.MaximumNArgs(1),
580+
Aliases: []string{"new"},
581+
Short: "Create a new Defang project from a sample",
582+
RunE: func(cmd *cobra.Command, args []string) error {
583+
ctx := cmd.Context()
584+
setupClient := setup.SetupClient{
585+
Surveyor: surveyor.NewDefaultSurveyor(),
586+
Heroku: migrate.NewHerokuClient(),
587+
ModelID: modelId,
588+
Fabric: client,
589+
Cluster: getCluster(),
663590
}
664591

665-
term.Info("Code generated successfully in folder", prompt.Folder)
666-
667-
editor := pkg.Getenv("DEFANG_EDITOR", "code") // TODO: should we use EDITOR env var instead?
668-
cmdd := exec.Command(editor, prompt.Folder)
669-
err = cmdd.Start()
670-
if err != nil {
671-
term.Debugf("unable to launch editor %q: %v", editor, err)
592+
if len(args) > 0 {
593+
_, err := setupClient.CloneSample(ctx, args[0])
594+
return err
672595
}
673596

674-
cd := ""
675-
if prompt.Folder != "." {
676-
cd = "`cd " + prompt.Folder + "` and "
597+
if nonInteractive {
598+
return errors.New("cannot run in non-interactive mode")
677599
}
678600

679-
// Load the project and check for empty environment variables
680-
loader := compose.NewLoader(compose.WithPath(filepath.Join(prompt.Folder, "compose.yaml")))
681-
project, err := loader.LoadProject(cmd.Context())
601+
result, err := setupClient.Start(ctx)
682602
if err != nil {
683-
term.Debugf("unable to load new project: %v", err)
684-
}
685-
686-
var envInstructions []string
687-
for _, envVar := range collectUnsetEnvVars(project) {
688-
envInstructions = append(envInstructions, "config create "+envVar)
689-
}
690-
691-
if len(envInstructions) > 0 {
692-
printDefangHint("Check the files in your favorite editor.\nTo configure the service, do "+cd, envInstructions...)
693-
} else {
694-
printDefangHint("Check the files in your favorite editor.\nTo deploy the service, do "+cd, "compose up")
603+
return err
695604
}
696-
605+
afterGenerate(ctx, result)
697606
return nil
698607
},
699608
}
700609

701-
var newCmd = &cobra.Command{
702-
Use: "new [SAMPLE]",
703-
Args: cobra.MaximumNArgs(1),
704-
Aliases: []string{"init"},
705-
Short: "Create a new Defang project from a sample",
706-
RunE: generateCmd.RunE,
707-
}
708-
709610
func collectUnsetEnvVars(project *composeTypes.Project) []string {
710611
if project == nil {
711612
return nil // in case loading failed

src/go.mod

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ require (
1616
cloud.google.com/go/secretmanager v1.14.5
1717
cloud.google.com/go/storage v1.50.0
1818
github.com/AlecAivazis/survey/v2 v2.3.7
19-
github.com/DefangLabs/secret-detector v0.0.0-20250108223530-c2b44d4c1f8f
19+
github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679
2020
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883
2121
github.com/aws/aws-sdk-go-v2 v1.32.6
2222
github.com/aws/aws-sdk-go-v2/config v1.26.6
@@ -51,6 +51,8 @@ require (
5151
github.com/sirupsen/logrus v1.9.3
5252
github.com/spf13/cobra v1.8.0
5353
github.com/spf13/pflag v1.0.6
54+
github.com/stretchr/testify v1.10.0
55+
go.yaml.in/yaml/v3 v3.0.4
5456
golang.org/x/mod v0.18.0
5557
golang.org/x/oauth2 v0.29.0
5658
golang.org/x/sys v0.32.0
@@ -83,7 +85,6 @@ require (
8385
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
8486
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
8587
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
86-
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
8788
github.com/google/go-querystring v1.1.0 // indirect
8889
github.com/google/s2a-go v0.1.9 // indirect
8990
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
@@ -101,14 +102,14 @@ require (
101102
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
102103
github.com/sergi/go-diff v1.3.1 // indirect
103104
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
105+
github.com/stretchr/objx v0.5.2 // indirect
104106
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
105107
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
106108
github.com/zeebo/errs v1.4.0 // indirect
107109
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
108110
go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
109111
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
110112
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
111-
go.yaml.in/yaml/v3 v3.0.4 // indirect
112113
golang.org/x/crypto v0.37.0 // indirect
113114
golang.org/x/net v0.39.0 // indirect
114115
google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect

src/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
3636
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3737
github.com/DefangLabs/cobra v1.8.0-defang h1:rTzAg1XbEk3yXUmQPumcwkLgi8iNCby5CjyG3sCwzKk=
3838
github.com/DefangLabs/cobra v1.8.0-defang/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
39-
github.com/DefangLabs/secret-detector v0.0.0-20250108223530-c2b44d4c1f8f h1:RTbUqLhPxejgK92ifVdMTIW9H23QLlscy8QXPDTfaL4=
40-
github.com/DefangLabs/secret-detector v0.0.0-20250108223530-c2b44d4c1f8f/go.mod h1:2UjtD/G/Sy2FxoHpxKnzHTXMpRURecwYal8HgbxcvkY=
39+
github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 h1:qNT7R4qrN+5u5ajSbqSW1opHP4LA8lzA+ASyw5MQZjs=
40+
github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
4141
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw=
4242
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A=
4343
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ=
@@ -164,8 +164,6 @@ github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+d
164164
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
165165
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
166166
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
167-
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
168-
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
169167
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
170168
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
171169
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -289,6 +287,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
289287
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
290288
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
291289
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
290+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
291+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
292292
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
293293
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
294294
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

0 commit comments

Comments
 (0)