package app import ( "fmt" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/log" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/secret" "github.com/AlecAivazis/survey/v2" "github.com/charmbracelet/lipgloss/table" dockerClient "github.com/docker/docker/client" "github.com/spf13/cobra" ) var appNewDescription = `Creates a new app from a default recipe. This new app configuration is stored in your $ABRA_DIR directory under the appropriate server. This command does not deploy your app for you. You will need to run "abra app deploy " to do so. You can see what recipes are available (i.e. values for the [recipe] argument) by running "abra recipe ls". Recipe commit hashes are supported values for "[version]". Passing the "--secrets/-S" flag will automatically generate secrets for your app and store them encrypted at rest on the chosen target server. These generated secrets are only visible at generation time, so please take care to store them somewhere safe. You can use the "--pass/-P" to store these generated passwords locally in a pass store (see passwordstore.org for more). The pass command must be available on your $PATH.` var AppNewCommand = &cobra.Command{ Use: "new [recipe] [version] [flags]", Aliases: []string{"n"}, Short: "Create a new app", Long: appNewDescription, Args: cobra.RangeArgs(0, 2), ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { switch l := len(args); l { case 0: return autocomplete.RecipeNameComplete() case 1: recipe := internal.ValidateRecipe(args, cmd.Name()) return autocomplete.RecipeVersionComplete(recipe.Name) default: return nil, cobra.ShellCompDirectiveDefault } }, Run: func(cmd *cobra.Command, args []string) { recipe := internal.ValidateRecipe(args, cmd.Name()) if len(args) == 2 && internal.Chaos { log.Fatal("cannot use [version] and --chaos together") } var recipeVersion string if len(args) == 2 { recipeVersion = args[1] } chaosVersion := config.CHAOS_DEFAULT if internal.Chaos { var err error chaosVersion, err = recipe.ChaosVersion() if err != nil { log.Fatal(err) } recipeVersion = chaosVersion } else { if err := recipe.EnsureIsClean(); err != nil { log.Fatal(err) } var recipeVersions recipePkg.RecipeVersions if recipeVersion == "" { var err error recipeVersions, _, err = recipe.GetRecipeVersions() if err != nil { log.Fatal(err) } } if len(recipeVersions) > 0 { latest := recipeVersions[len(recipeVersions)-1] for tag := range latest { recipeVersion = tag } if _, err := recipe.EnsureVersion(recipeVersion); err != nil { log.Fatal(err) } } else { if err := recipe.EnsureLatest(); err != nil { log.Fatal(err) } if recipeVersion == "" { head, err := recipe.Head() if err != nil { log.Fatalf("failed to retrieve latest commit for %s: %s", recipe.Name, err) } recipeVersion = formatter.SmallSHA(head.String()) } } } if err := ensureServerFlag(); err != nil { log.Fatal(err) } if err := ensureDomainFlag(recipe, newAppServer); err != nil { log.Fatal(err) } sanitisedAppName := appPkg.SanitiseAppName(appDomain) log.Debugf("%s sanitised as %s for new app", appDomain, sanitisedAppName) if err := appPkg.TemplateAppEnvSample( recipe, appDomain, newAppServer, appDomain, ); err != nil { log.Fatal(err) } var appSecrets AppSecrets var secretsTable *table.Table if generateSecrets { sampleEnv, err := recipe.SampleEnv() if err != nil { log.Fatal(err) } composeFiles, err := recipe.GetComposeFiles(sampleEnv) if err != nil { log.Fatal(err) } secretsConfig, err := secret.ReadSecretsConfig( recipe.SampleEnvPath, composeFiles, appPkg.StackName(appDomain), ) if err != nil { log.Fatal(err) } if err := promptForSecrets(recipe.Name, secretsConfig); err != nil { log.Fatal(err) } cl, err := client.New(newAppServer) if err != nil { log.Fatal(err) } appSecrets, err = createSecrets(cl, secretsConfig, sanitisedAppName) if err != nil { log.Fatal(err) } secretsTable, err = formatter.CreateTable() if err != nil { log.Fatal(err) } headers := []string{"NAME", "VALUE"} secretsTable.Headers(headers...) for name, val := range appSecrets { secretsTable.Row(name, val) } } if newAppServer == "default" { newAppServer = "local" } log.Infof("%s created (version: %s)", appDomain, recipeVersion) if len(appSecrets) > 0 { rows := [][]string{} for k, v := range appSecrets { rows = append(rows, []string{k, v}) } overview := formatter.CreateOverview("SECRETS OVERVIEW", rows) fmt.Println(overview) log.Warnf( "secrets are %s shown again, please save them %s", formatter.BoldUnderlineStyle.Render("NOT"), formatter.BoldUnderlineStyle.Render("NOW"), ) } app, err := app.Get(appDomain) if err != nil { log.Fatal(err) } if err := app.WriteRecipeVersion(recipeVersion, false); err != nil { log.Fatalf("writing recipe version failed: %s", err) } }, } // AppSecrets represents all app secrest type AppSecrets map[string]string // createSecrets creates all secrets for a new app. func createSecrets(cl *dockerClient.Client, secretsConfig map[string]secret.Secret, sanitisedAppName string) (AppSecrets, error) { // NOTE(d1): trim to match app.StackName() implementation if len(sanitisedAppName) > config.MAX_SANITISED_APP_NAME_LENGTH { log.Debugf("trimming %s to %s to avoid runtime limits", sanitisedAppName, sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH]) sanitisedAppName = sanitisedAppName[:config.MAX_SANITISED_APP_NAME_LENGTH] } secrets, err := secret.GenerateSecrets(cl, secretsConfig, newAppServer) if err != nil { return nil, err } if saveInPass { for secretName := range secrets { secretValue := secrets[secretName] if err := secret.PassInsertSecret( secretValue, secretName, appDomain, newAppServer, ); err != nil { return nil, err } } } return secrets, nil } // ensureDomainFlag checks if the domain flag was used. if not, asks the user for it/ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error { if appDomain == "" && !internal.NoInput { prompt := &survey.Input{ Message: "Specify app domain", Default: fmt.Sprintf("%s.%s", recipe.Name, server), } if err := survey.AskOne(prompt, &appDomain); err != nil { return err } } if appDomain == "" { return fmt.Errorf("no domain provided") } return nil } // promptForSecrets asks if we should generate secrets for a new app. func promptForSecrets(recipeName string, secretsConfig map[string]secret.Secret) error { if len(secretsConfig) == 0 { log.Debugf("%s has no secrets to generate, skipping...", recipeName) return nil } if !generateSecrets && !internal.NoInput { prompt := &survey.Confirm{ Message: "Generate app secrets?", } if err := survey.AskOne(prompt, &generateSecrets); err != nil { return err } } return nil } // ensureServerFlag checks if the server flag was used. if not, asks the user for it. func ensureServerFlag() error { servers, err := config.GetServers() if err != nil { return err } if newAppServer == "" && !internal.NoInput { prompt := &survey.Select{ Message: "Select app server:", Options: servers, } if err := survey.AskOne(prompt, &newAppServer); err != nil { return err } } if newAppServer == "" { return fmt.Errorf("no server provided") } return nil } var ( newAppServer string appDomain string saveInPass bool generateSecrets bool ) func init() { AppNewCommand.Flags().StringVarP( &newAppServer, "server", "s", "", "specify server for new app", ) AppNewCommand.RegisterFlagCompletionFunc( "server", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return autocomplete.ServerNameComplete() }, ) AppNewCommand.Flags().StringVarP( &appDomain, "domain", "D", "", "domain name for app", ) AppNewCommand.Flags().BoolVarP( &saveInPass, "pass", "p", false, "store secrets in a local pass store", ) AppNewCommand.Flags().BoolVarP( &generateSecrets, "secrets", "S", false, "automatically generate secrets", ) AppNewCommand.Flags().BoolVarP( &internal.Chaos, "chaos", "C", false, "ignore uncommitted recipes changes", ) }