package config import ( "fmt" "html/template" "io/ioutil" "os" "path" "strings" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/upstream/convert" loader "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack" composetypes "github.com/docker/cli/cli/compose/types" "github.com/docker/docker/api/types/filters" "github.com/sirupsen/logrus" ) // Type aliases to make code hints easier to understand // AppEnv is a map of the values in an apps env config type AppEnv = map[string]string // AppName is AppName type AppName = string // AppFile represents app env files on disk without reading the contents type AppFile struct { Path string Server string } // AppFiles is a slice of appfiles type AppFiles map[AppName]AppFile // App reprents an app with its env file read into memory type App struct { Name AppName Recipe string Domain string Env AppEnv Server string Path string } // StackName gets what the docker safe stack name is for the app. This should // not not shown to the user, use a.Name for that. Give the output of this // command to Docker only. func (a App) StackName() string { if _, exists := a.Env["STACK_NAME"]; exists { return a.Env["STACK_NAME"] } stackName := SanitiseAppName(a.Name) if len(stackName) > 45 { logrus.Debugf("trimming %s to %s to avoid runtime limits", stackName, stackName[:45]) stackName = stackName[:45] } a.Env["STACK_NAME"] = stackName return stackName } // Filters retrieves exact app filters for querying the container runtime. Due // to upstream issues, filtering works different depending on what you're // querying. So, for example, secrets don't work with regex! The caller needs // to implement their own validation that the right secrets are matched. In // order to handle these cases, we provide the `appendServiceNames` / // `exactMatch` modifiers. func (a App) Filters(appendServiceNames, exactMatch bool) (filters.Args, error) { filters := filters.NewArgs() composeFiles, err := GetAppComposeFiles(a.Recipe, a.Env) if err != nil { return filters, err } opts := stack.Deploy{Composefiles: composeFiles} compose, err := GetAppComposeConfig(a.Recipe, opts, a.Env) if err != nil { return filters, err } for _, service := range compose.Services { var filter string if appendServiceNames { if exactMatch { filter = fmt.Sprintf("^%s_%s", a.StackName(), service.Name) } else { filter = fmt.Sprintf("%s_%s", a.StackName(), service.Name) } } else { if exactMatch { filter = fmt.Sprintf("^%s", a.StackName()) } else { filter = fmt.Sprintf("%s", a.StackName()) } } filters.Add("name", filter) } return filters, nil } // ByServer sort a slice of Apps type ByServer []App func (a ByServer) Len() int { return len(a) } func (a ByServer) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByServer) Less(i, j int) bool { return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server) } // ByServerAndRecipe sort a slice of Apps type ByServerAndRecipe []App func (a ByServerAndRecipe) Len() int { return len(a) } func (a ByServerAndRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByServerAndRecipe) Less(i, j int) bool { if a[i].Server == a[j].Server { return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe) } return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server) } // ByRecipe sort a slice of Apps type ByRecipe []App func (a ByRecipe) Len() int { return len(a) } func (a ByRecipe) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByRecipe) Less(i, j int) bool { return strings.ToLower(a[i].Recipe) < strings.ToLower(a[j].Recipe) } // ByName sort a slice of Apps type ByName []App func (a ByName) Len() int { return len(a) } func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByName) Less(i, j int) bool { return strings.ToLower(a[i].Name) < strings.ToLower(a[j].Name) } func readAppEnvFile(appFile AppFile, name AppName) (App, error) { env, err := ReadEnv(appFile.Path) if err != nil { return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error()) } logrus.Debugf("read env %s from %s", env, appFile.Path) app, err := newApp(env, name, appFile) if err != nil { return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error()) } return app, nil } // newApp creates new App object func newApp(env AppEnv, name string, appFile AppFile) (App, error) { domain := env["DOMAIN"] recipe, exists := env["RECIPE"] if !exists { recipe, exists = env["TYPE"] if !exists { return App{}, fmt.Errorf("%s is missing the RECIPE env var", name) } } return App{ Name: name, Domain: domain, Recipe: recipe, Env: env, Server: appFile.Server, Path: appFile.Path, }, nil } // LoadAppFiles gets all app files for a given set of servers or all servers func LoadAppFiles(servers ...string) (AppFiles, error) { appFiles := make(AppFiles) if len(servers) == 1 { if servers[0] == "" { // Empty servers flag, one string will always be passed var err error servers, err = GetAllFoldersInDirectory(SERVERS_DIR) if err != nil { return nil, err } } } logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", ")) for _, server := range servers { serverDir := path.Join(SERVERS_DIR, server) files, err := getAllFilesInDirectory(serverDir) if err != nil { return nil, fmt.Errorf("server %s doesn't exist? Run \"abra server ls\" to check", server) } for _, file := range files { appName := strings.TrimSuffix(file.Name(), ".env") appFilePath := path.Join(SERVERS_DIR, server, file.Name()) appFiles[appName] = AppFile{ Path: appFilePath, Server: server, } } } return appFiles, nil } // GetApp loads an apps settings, reading it from file, in preparation to use it // // ONLY use when ready to use the env file to keep IO down func GetApp(apps AppFiles, name AppName) (App, error) { appFile, exists := apps[name] if !exists { return App{}, fmt.Errorf("cannot find app with name %s", name) } app, err := readAppEnvFile(appFile, name) if err != nil { return App{}, err } return app, nil } // GetApps returns a slice of Apps with their env files read from a given slice of AppFiles func GetApps(appFiles AppFiles) ([]App, error) { var apps []App for name := range appFiles { app, err := GetApp(appFiles, name) if err != nil { return nil, err } apps = append(apps, app) } return apps, nil } // GetAppServiceNames retrieves a list of app service names. func GetAppServiceNames(appName string) ([]string, error) { var serviceNames []string appFiles, err := LoadAppFiles("") if err != nil { return serviceNames, err } app, err := GetApp(appFiles, appName) if err != nil { return serviceNames, err } composeFiles, err := GetAppComposeFiles(app.Recipe, app.Env) if err != nil { return serviceNames, err } opts := stack.Deploy{Composefiles: composeFiles} compose, err := GetAppComposeConfig(app.Recipe, opts, app.Env) if err != nil { return serviceNames, err } for _, service := range compose.Services { serviceNames = append(serviceNames, service.Name) } return serviceNames, nil } // GetAppNames retrieves a list of app names. func GetAppNames() ([]string, error) { var appNames []string appFiles, err := LoadAppFiles("") if err != nil { return appNames, err } apps, err := GetApps(appFiles) if err != nil { return appNames, err } for _, app := range apps { appNames = append(appNames, app.Name) } return appNames, nil } // TemplateAppEnvSample copies the example env file for the app into the users env files func TemplateAppEnvSample(recipeName, appName, server, domain string) error { envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample") envSample, err := ioutil.ReadFile(envSamplePath) if err != nil { return err } appEnvPath := path.Join(ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName)) if _, err := os.Stat(appEnvPath); os.IsExist(err) { return fmt.Errorf("%s already exists?", appEnvPath) } err = ioutil.WriteFile(appEnvPath, envSample, 0664) if err != nil { return err } file, err := os.OpenFile(appEnvPath, os.O_RDWR, 0664) if err != nil { return err } defer file.Close() tpl, err := template.ParseFiles(appEnvPath) if err != nil { return err } type templateVars struct { Name string Domain string } tvars := templateVars{Name: recipeName, Domain: domain} if err := tpl.Execute(file, tvars); err != nil { return err } logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath) return nil } // SanitiseAppName makes a app name usable with Docker by replacing illegal characters func SanitiseAppName(name string) string { return strings.ReplaceAll(name, ".", "_") } // GetAppStatuses queries servers to check the deployment status of given apps func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) { statuses := make(map[string]map[string]string) var unique []string servers := make(map[string]struct{}) for _, appFile := range appFiles { if _, ok := servers[appFile.Server]; !ok { servers[appFile.Server] = struct{}{} unique = append(unique, appFile.Server) } } bar := formatter.CreateProgressbar(len(servers), "querying remote servers...") ch := make(chan stack.StackStatus, len(servers)) for server := range servers { go func(s string) { ch <- stack.GetAllDeployedServices(s) bar.Add(1) }(server) } for range servers { status := <-ch for _, service := range status.Services { result := make(map[string]string) name := service.Spec.Labels[convert.LabelNamespace] if _, ok := statuses[name]; !ok { result["status"] = "deployed" } labelKey := fmt.Sprintf("coop-cloud.%s.version", name) if version, ok := service.Spec.Labels[labelKey]; ok { result["version"] = version } else { continue } statuses[name] = result } } logrus.Debugf("retrieved app statuses: %s", statuses) return statuses, nil } // GetAppComposeFiles gets the list of compose files for an app which should be // merged into a composetypes.Config while respecting the COMPOSE_FILE env var. func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) { var composeFiles []string if _, ok := appEnv["COMPOSE_FILE"]; !ok { logrus.Debug("no COMPOSE_FILE detected, loading compose.yml") path := fmt.Sprintf("%s/%s/compose.yml", RECIPES_DIR, recipe) composeFiles = append(composeFiles, path) return composeFiles, nil } composeFileEnvVar := appEnv["COMPOSE_FILE"] envVars := strings.Split(composeFileEnvVar, ":") logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", ")) for _, file := range strings.Split(composeFileEnvVar, ":") { path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, file) composeFiles = append(composeFiles, path) } logrus.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), recipe) return composeFiles, nil } // GetAppComposeConfig retrieves a compose specification for a recipe. This // specification is the result of a merge of all the compose.**.yml files in // the recipe repository. func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*composetypes.Config, error) { compose, err := loader.LoadComposefile(opts, appEnv) if err != nil { return &composetypes.Config{}, err } logrus.Debugf("retrieved %s for %s", compose.Filename, recipe) return compose, nil }