forked from toolshed/abra
		
	
		
			
				
	
	
		
			456 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			456 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package app
 | 
						|
 | 
						|
import (
 | 
						|
	"context"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
	"sort"
 | 
						|
	"strings"
 | 
						|
 | 
						|
	"coopcloud.tech/abra/cli/internal"
 | 
						|
	"coopcloud.tech/abra/pkg/app"
 | 
						|
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
						|
	"coopcloud.tech/abra/pkg/config"
 | 
						|
	"coopcloud.tech/abra/pkg/envfile"
 | 
						|
	"coopcloud.tech/abra/pkg/secret"
 | 
						|
 | 
						|
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
						|
	"coopcloud.tech/abra/pkg/client"
 | 
						|
	"coopcloud.tech/abra/pkg/dns"
 | 
						|
	"coopcloud.tech/abra/pkg/formatter"
 | 
						|
	"coopcloud.tech/abra/pkg/i18n"
 | 
						|
	"coopcloud.tech/abra/pkg/lint"
 | 
						|
	"coopcloud.tech/abra/pkg/log"
 | 
						|
	"coopcloud.tech/abra/pkg/upstream/stack"
 | 
						|
	dockerClient "github.com/docker/docker/client"
 | 
						|
	"github.com/spf13/cobra"
 | 
						|
)
 | 
						|
 | 
						|
// translators: `abra app deploy` aliases. use a comma separated list of aliases with
 | 
						|
// no spaces in between
 | 
						|
var appDeployAliases = i18n.G("d")
 | 
						|
 | 
						|
var AppDeployCommand = &cobra.Command{
 | 
						|
	// translators: `app deploy` command
 | 
						|
	Use:     i18n.G("deploy <domain> [version] [flags]"),
 | 
						|
	Aliases: strings.Split(appDeployAliases, ","),
 | 
						|
	// translators: Short description for `app deploy` command
 | 
						|
	Short: i18n.G("Deploy an app"),
 | 
						|
	Long: i18n.G(`Deploy an app.
 | 
						|
 | 
						|
This command supports chaos operations. Use "--chaos/-C" to deploy your recipe
 | 
						|
checkout as-is. Recipe commit hashes are also supported as values for
 | 
						|
"[version]". Please note, "upgrade"/"rollback" do not support chaos operations.`),
 | 
						|
	Example: i18n.G(`  # standard deployment
 | 
						|
  abra app deploy 1312.net
 | 
						|
 | 
						|
  # chaos deployment
 | 
						|
  abra app deploy 1312.net --chaos
 | 
						|
  
 | 
						|
  # deploy specific version
 | 
						|
  abra app deploy 1312.net 2.0.0+1.2.3
 | 
						|
 | 
						|
  # deploy a specific git hash
 | 
						|
  abra app deploy 1312.net 886db76d`),
 | 
						|
	Args: cobra.RangeArgs(1, 2),
 | 
						|
	ValidArgsFunction: func(
 | 
						|
		cmd *cobra.Command,
 | 
						|
		args []string,
 | 
						|
		toComplete string,
 | 
						|
	) ([]string, cobra.ShellCompDirective) {
 | 
						|
		switch l := len(args); l {
 | 
						|
		case 0:
 | 
						|
			return autocomplete.AppNameComplete()
 | 
						|
		case 1:
 | 
						|
			app, err := appPkg.Get(args[0])
 | 
						|
			if err != nil {
 | 
						|
				errMsg := i18n.G("autocomplete failed: %s", err)
 | 
						|
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
						|
			}
 | 
						|
			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
 | 
						|
		default:
 | 
						|
			return nil, cobra.ShellCompDirectiveDefault
 | 
						|
		}
 | 
						|
	},
 | 
						|
	Run: func(cmd *cobra.Command, args []string) {
 | 
						|
		var (
 | 
						|
			deployWarnMessages []string
 | 
						|
			toDeployVersion    string
 | 
						|
		)
 | 
						|
 | 
						|
		app := internal.ValidateApp(args)
 | 
						|
 | 
						|
		if err := validateArgsAndFlags(args); err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		cl, err := client.New(app.Server)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		log.Debug(i18n.G("checking whether %s is already deployed", app.StackName()))
 | 
						|
 | 
						|
		deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if deployMeta.IsDeployed && !(internal.Force || internal.Chaos) {
 | 
						|
			log.Fatal(i18n.G("%s is already deployed", app.Name))
 | 
						|
		}
 | 
						|
 | 
						|
		toDeployVersion, err = getDeployVersion(args, deployMeta, app)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(i18n.G("get deploy version: %s", err))
 | 
						|
		}
 | 
						|
 | 
						|
		if !internal.Chaos {
 | 
						|
			isChaos, err := app.Recipe.EnsureVersion(toDeployVersion)
 | 
						|
			if err != nil {
 | 
						|
				log.Fatal(i18n.G("ensure recipe: %s", err))
 | 
						|
			}
 | 
						|
			if isChaos {
 | 
						|
				log.Fatal(i18n.G("version '%s' appears to be a chaos commit, but --chaos/-C was not provided", toDeployVersion))
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if err := lint.LintForErrors(app.Recipe); err != nil {
 | 
						|
			if internal.Chaos {
 | 
						|
				log.Warn(err)
 | 
						|
			} else {
 | 
						|
				log.Fatal(err)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if err := validateSecrets(cl, app); err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
		for k, v := range abraShEnv {
 | 
						|
			app.Env[k] = v
 | 
						|
		}
 | 
						|
 | 
						|
		composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		stackName := app.StackName()
 | 
						|
		deployOpts := stack.Deploy{
 | 
						|
			Composefiles: composeFiles,
 | 
						|
			Namespace:    stackName,
 | 
						|
			Prune:        false,
 | 
						|
			ResolveImage: stack.ResolveImageAlways,
 | 
						|
			Detach:       false,
 | 
						|
		}
 | 
						|
		compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		appPkg.ExposeAllEnv(stackName, compose, app.Env)
 | 
						|
		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
 | 
						|
		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
 | 
						|
		if internal.Chaos {
 | 
						|
			appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion)
 | 
						|
		}
 | 
						|
		appPkg.SetUpdateLabel(compose, stackName, app.Env)
 | 
						|
		appPkg.SetVersionLabel(compose, stackName, toDeployVersion)
 | 
						|
 | 
						|
		envVars, err := appPkg.CheckEnv(app)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		for _, envVar := range envVars {
 | 
						|
			if !envVar.Present {
 | 
						|
				deployWarnMessages = append(deployWarnMessages,
 | 
						|
					i18n.G("%s missing from %s.env", envVar.Name, app.Domain),
 | 
						|
				)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if !internal.NoDomainChecks {
 | 
						|
			if domainName, ok := app.Env["DOMAIN"]; ok {
 | 
						|
				if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
 | 
						|
					log.Fatal(err)
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				log.Debug(i18n.G("skipping domain checks, no DOMAIN=... configured"))
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			log.Debug(i18n.G("skipping domain checks"))
 | 
						|
		}
 | 
						|
 | 
						|
		deployedVersion := config.NO_VERSION_DEFAULT
 | 
						|
		if deployMeta.IsDeployed {
 | 
						|
			deployedVersion = deployMeta.Version
 | 
						|
		}
 | 
						|
 | 
						|
		// Gather secrets
 | 
						|
 | 
						|
		secStats, err := secret.PollSecretsStatus(cl, app)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		var secretInfo []string
 | 
						|
 | 
						|
		// Sort secrets to ensure reproducible output
 | 
						|
		sort.Slice(secStats, func(i, j int) bool {
 | 
						|
			return secStats[i].LocalName < secStats[j].LocalName
 | 
						|
		})
 | 
						|
		for _, secStat := range secStats {
 | 
						|
			secretInfo = append(secretInfo, fmt.Sprintf("%s: %s", secStat.LocalName, secStat.Version))
 | 
						|
		}
 | 
						|
 | 
						|
		// Gather configs
 | 
						|
 | 
						|
		// Get current configs from existing deployment
 | 
						|
		currentConfigNames, err := client.GetConfigNamesForStack(cl, app)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		log.Infof("Config names: %v", currentConfigNames)
 | 
						|
 | 
						|
		// Create map of current config base names to versions
 | 
						|
		currentConfigs := make(map[string]string)
 | 
						|
		for _, configName := range currentConfigNames {
 | 
						|
			baseName, version := client.GetConfigNameAndVersion(configName, app.StackName())
 | 
						|
			currentConfigs[baseName] = version
 | 
						|
		}
 | 
						|
 | 
						|
		log.Infof("Configs: %v", currentConfigs)
 | 
						|
 | 
						|
		// Get new configs from the compose specification
 | 
						|
		newConfigs := compose.Configs
 | 
						|
 | 
						|
		var configInfo []string
 | 
						|
		for configName := range newConfigs {
 | 
						|
			log.Debugf("Searching abra.sh for version for %s", configName)
 | 
						|
			versionKey := strings.ToUpper(configName) + "_VERSION"
 | 
						|
			newVersion, exists := abraShEnv[versionKey]
 | 
						|
			if !exists {
 | 
						|
				log.Warnf("No version found for config %s", configName)
 | 
						|
				configInfo = append(configInfo, fmt.Sprintf("%s: ? (missing)", configName))
 | 
						|
				continue
 | 
						|
			}
 | 
						|
			
 | 
						|
			if currentVersion, exists := currentConfigs[configName]; exists {
 | 
						|
				if currentVersion == newVersion {
 | 
						|
					configInfo = append(configInfo, fmt.Sprintf("%s: %s (unchanged)", configName, newVersion))
 | 
						|
				} else {
 | 
						|
					configInfo = append(configInfo, fmt.Sprintf("%s: %s → %s", configName, currentVersion, newVersion))
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				configInfo = append(configInfo, fmt.Sprintf("%s: %s (new)", configName, newVersion))
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		// Gather images
 | 
						|
 | 
						|
		var imageInfo []string
 | 
						|
		for _, service := range compose.Services {
 | 
						|
			imageInfo = append(imageInfo, fmt.Sprintf("%s: %s", service.Name, service.Image))
 | 
						|
		}
 | 
						|
 | 
						|
		// Show deploy overview
 | 
						|
 | 
						|
		if err := internal.DeployOverview(
 | 
						|
			app,
 | 
						|
			deployedVersion,
 | 
						|
			toDeployVersion,
 | 
						|
			"",
 | 
						|
			deployWarnMessages,
 | 
						|
			strings.Join(secretInfo, "\n"),
 | 
						|
			strings.Join(configInfo, "\n"),
 | 
						|
			strings.Join(imageInfo, "\n"),
 | 
						|
		); err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
		// FIXME: just for debugging
 | 
						|
		return
 | 
						|
 | 
						|
		stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		f, err := app.Filters(true, false, serviceNames...)
 | 
						|
		if err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if err := stack.RunDeploy(
 | 
						|
			cl,
 | 
						|
			deployOpts,
 | 
						|
			compose,
 | 
						|
			app.Name,
 | 
						|
			app.Server,
 | 
						|
			internal.DontWaitConverge,
 | 
						|
			f,
 | 
						|
		); err != nil {
 | 
						|
			log.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		postDeployCmds, ok := app.Env["POST_DEPLOY_CMDS"]
 | 
						|
		if ok && !internal.DontWaitConverge {
 | 
						|
			log.Debug(i18n.G("run the following post-deploy commands: %s", postDeployCmds))
 | 
						|
			if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
 | 
						|
				log.Fatal(i18n.G("attempting to run post deploy commands, saw: %s", err))
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if err := app.WriteRecipeVersion(toDeployVersion, false); err != nil {
 | 
						|
			log.Fatal(i18n.G("writing recipe version failed: %s", err))
 | 
						|
		}
 | 
						|
	},
 | 
						|
}
 | 
						|
 | 
						|
func getLatestVersionOrCommit(app app.App) (string, error) {
 | 
						|
	versions, err := app.Recipe.Tags()
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
 | 
						|
	if len(versions) > 0 && !internal.Chaos {
 | 
						|
		return versions[len(versions)-1], nil
 | 
						|
	}
 | 
						|
 | 
						|
	head, err := app.Recipe.Head()
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
 | 
						|
	return formatter.SmallSHA(head.String()), nil
 | 
						|
}
 | 
						|
 | 
						|
// validateArgsAndFlags ensures compatible args/flags.
 | 
						|
func validateArgsAndFlags(args []string) error {
 | 
						|
	if len(args) == 2 && args[1] != "" && internal.Chaos {
 | 
						|
		return errors.New(i18n.G("cannot use [version] and --chaos together"))
 | 
						|
	}
 | 
						|
 | 
						|
	if len(args) == 2 && args[1] != "" && internal.DeployLatest {
 | 
						|
		return errors.New(i18n.G("cannot use [version] and --latest together"))
 | 
						|
	}
 | 
						|
 | 
						|
	if internal.DeployLatest && internal.Chaos {
 | 
						|
		return errors.New(i18n.G("cannot use --chaos and --latest together"))
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func validateSecrets(cl *dockerClient.Client, app app.App) error {
 | 
						|
	secStats, err := secret.PollSecretsStatus(cl, app)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	for _, secStat := range secStats {
 | 
						|
		if !secStat.CreatedOnRemote {
 | 
						|
			return errors.New(i18n.G("secret not generated: %s", secStat.LocalName))
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func getDeployVersion(cliArgs []string, deployMeta stack.DeployMeta, app app.App) (string, error) {
 | 
						|
	// Chaos mode overrides everything
 | 
						|
	if internal.Chaos {
 | 
						|
		v, err := app.Recipe.ChaosVersion()
 | 
						|
		if err != nil {
 | 
						|
			return "", err
 | 
						|
		}
 | 
						|
		log.Debug(i18n.G("version: taking chaos version: %s", v))
 | 
						|
		return v, nil
 | 
						|
	}
 | 
						|
 | 
						|
	// Check if the deploy version is set with a cli argument
 | 
						|
	if len(cliArgs) == 2 && cliArgs[1] != "" {
 | 
						|
		log.Debug(i18n.G("version: taking version from cli arg: %s", cliArgs[1]))
 | 
						|
		return cliArgs[1], nil
 | 
						|
	}
 | 
						|
 | 
						|
	// Check if the recipe has a version in the .env file
 | 
						|
	if app.Recipe.EnvVersion != "" && !internal.DeployLatest {
 | 
						|
		if strings.HasSuffix(app.Recipe.EnvVersionRaw, "+U") {
 | 
						|
			return "", errors.New(i18n.G("version: can not redeploy chaos version %s", app.Recipe.EnvVersionRaw))
 | 
						|
		}
 | 
						|
		log.Debug(i18n.G("version: taking version from .env file: %s", app.Recipe.EnvVersion))
 | 
						|
		return app.Recipe.EnvVersion, nil
 | 
						|
	}
 | 
						|
 | 
						|
	// Take deployed version
 | 
						|
	if deployMeta.IsDeployed && !internal.DeployLatest {
 | 
						|
		log.Debug(i18n.G("version: taking deployed version: %s", deployMeta.Version))
 | 
						|
		return deployMeta.Version, nil
 | 
						|
	}
 | 
						|
 | 
						|
	v, err := getLatestVersionOrCommit(app)
 | 
						|
	log.Debug(i18n.G("version: taking new recipe version: %s", v))
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
	return v, nil
 | 
						|
}
 | 
						|
 | 
						|
func init() {
 | 
						|
	AppDeployCommand.Flags().BoolVarP(
 | 
						|
		&internal.Chaos,
 | 
						|
		i18n.G("chaos"),
 | 
						|
		i18n.G("C"),
 | 
						|
		false,
 | 
						|
		i18n.G("ignore uncommitted recipes changes"),
 | 
						|
	)
 | 
						|
 | 
						|
	AppDeployCommand.Flags().BoolVarP(
 | 
						|
		&internal.Force,
 | 
						|
		i18n.G("force"),
 | 
						|
		i18n.G("f"),
 | 
						|
		false,
 | 
						|
		i18n.G("perform action without further prompt"),
 | 
						|
	)
 | 
						|
 | 
						|
	AppDeployCommand.Flags().BoolVarP(
 | 
						|
		&internal.NoDomainChecks,
 | 
						|
		i18n.G("no-domain-checks"),
 | 
						|
		i18n.G("D"),
 | 
						|
		false,
 | 
						|
		i18n.G("disable public DNS checks"),
 | 
						|
	)
 | 
						|
 | 
						|
	AppDeployCommand.Flags().BoolVarP(
 | 
						|
		&internal.DontWaitConverge,
 | 
						|
		i18n.G("no-converge-checks"),
 | 
						|
		i18n.G("c"),
 | 
						|
		false,
 | 
						|
		i18n.G("disable converge logic checks"),
 | 
						|
	)
 | 
						|
 | 
						|
	AppDeployCommand.PersistentFlags().BoolVarP(
 | 
						|
		&internal.DeployLatest,
 | 
						|
		i18n.G("latest"),
 | 
						|
		i18n.G("l"),
 | 
						|
		false,
 | 
						|
		i18n.G("deploy latest recipe version"),
 | 
						|
	)
 | 
						|
}
 |