package recipe import ( "fmt" "io/ioutil" "os" "path" "path/filepath" "strings" "coopcloud.tech/abra/pkg/compose" "coopcloud.tech/abra/pkg/config" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack" composetypes "github.com/docker/cli/cli/compose/types" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" ) // Image represents a recipe container image. type Image struct { Image string `json:"image"` Rating string `json:"rating"` Source string `json:"source"` URL string `json:"url"` } // Features represent what top-level features a recipe supports (e.g. does this recipe support backups?). type Features struct { Backups string `json:"backups"` Email string `json:"email"` Healthcheck string `json:"healthcheck"` Image Image `json:"image"` Status int `json:"status"` Tests string `json:"tests"` SSO string `json:"sso"` } // Recipe represents a recipe. type Recipe struct { Name string Config *composetypes.Config } // Dir retrieves the recipe repository path func (r Recipe) Dir() string { return path.Join(config.RECIPES_DIR, r.Name) } // UpdateLabel updates a recipe label func (r Recipe) UpdateLabel(pattern, serviceName, label string) error { fullPattern := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, r.Name, pattern) if err := compose.UpdateLabel(fullPattern, serviceName, label, r.Name); err != nil { return err } return nil } // UpdateTag updates a recipe tag func (r Recipe) UpdateTag(image, tag string) error { pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name) if err := compose.UpdateTag(pattern, image, tag, r.Name); err != nil { return err } return nil } // Tags list the recipe tags func (r Recipe) Tags() ([]string, error) { var tags []string repo, err := git.PlainOpen(r.Dir()) if err != nil { return tags, err } gitTags, err := repo.Tags() if err != nil { return tags, err } if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) { tags = append(tags, strings.TrimPrefix(string(ref.Name()), "refs/tags/")) return nil }); err != nil { return tags, err } logrus.Debugf("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name) return tags, nil } // Get retrieves a recipe. func Get(recipeName string) (Recipe, error) { if err := EnsureExists(recipeName); err != nil { return Recipe{}, err } pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, recipeName) composeFiles, err := filepath.Glob(pattern) if err != nil { return Recipe{}, err } if len(composeFiles) == 0 { return Recipe{}, fmt.Errorf("%s is missing a compose.yml or compose.*.yml file?", recipeName) } envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") sampleEnv, err := config.ReadEnv(envSamplePath) if err != nil { return Recipe{}, err } opts := stack.Deploy{Composefiles: composeFiles} config, err := loader.LoadComposefile(opts, sampleEnv) if err != nil { return Recipe{}, err } return Recipe{Name: recipeName, Config: config}, nil } // EnsureExists ensures that a recipe is locally cloned func EnsureExists(recipeName string) error { recipeDir := path.Join(config.RECIPES_DIR, recipeName) if _, err := os.Stat(recipeDir); os.IsNotExist(err) { logrus.Debugf("%s does not exist, attemmpting to clone", recipeDir) url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipeName) if err := gitPkg.Clone(recipeDir, url); err != nil { return err } } if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } return nil } // EnsureVersion checks whether a specific version exists for a recipe. func EnsureVersion(recipeName, version string) error { recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeName) if err != nil { return err } if !isClean { return fmt.Errorf("%s has locally unstaged changes", recipeName) } if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } repo, err := git.PlainOpen(recipeDir) if err != nil { return err } tags, err := repo.Tags() if err != nil { return nil } var parsedTags []string var tagRef plumbing.ReferenceName if err := tags.ForEach(func(ref *plumbing.Reference) (err error) { parsedTags = append(parsedTags, ref.Name().Short()) if ref.Name().Short() == version { tagRef = ref.Name() } return nil }); err != nil { return err } logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName) if tagRef.String() == "" { logrus.Warnf("no git tag discovered for %s, assuming unreleased recipe", recipeName) return nil } worktree, err := repo.Worktree() if err != nil { return err } opts := &git.CheckoutOptions{ Branch: tagRef, Create: false, Force: true, } if err := worktree.Checkout(opts); err != nil { return err } logrus.Debugf("successfully checked %s out to %s in %s", recipeName, tagRef.Short(), recipeDir) return nil } // EnsureLatest makes sure the latest commit is checked out for a local recipe repository func EnsureLatest(recipeName string) error { recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeName) if err != nil { return err } if !isClean { return fmt.Errorf("%s has locally unstaged changes", recipeName) } if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } logrus.Debugf("attempting to open git repository in %s", recipeDir) repo, err := git.PlainOpen(recipeDir) if err != nil { return err } worktree, err := repo.Worktree() if err != nil { return err } branch, err := gitPkg.GetCurrentBranch(repo) if err != nil { return err } checkOutOpts := &git.CheckoutOptions{ Create: false, Force: true, Branch: plumbing.ReferenceName(branch), } if err := worktree.Checkout(checkOutOpts); err != nil { logrus.Debugf("failed to check out %s in %s", branch, recipeDir) return err } return nil } // ChaosVersion constructs a chaos mode recipe version. func ChaosVersion(recipeName string) (string, error) { var version string head, err := gitPkg.GetRecipeHead(recipeName) if err != nil { return version, err } version = head.String()[:8] isClean, err := gitPkg.IsClean(recipeName) if err != nil { return version, err } if !isClean { version = fmt.Sprintf("%s + unstaged changes", version) } return version, nil } // GetRecipesLocal retrieves all local recipe directories func GetRecipesLocal() ([]string, error) { var recipes []string recipes, err := config.GetAllFoldersInDirectory(config.RECIPES_DIR) if err != nil { return recipes, err } return recipes, nil } // GetVersionLabelLocal retrieves the version label on the local recipe config func GetVersionLabelLocal(recipe Recipe) (string, error) { var label string for _, service := range recipe.Config.Services { for label, value := range service.Deploy.Labels { if strings.HasPrefix(label, "coop-cloud") { return value, nil } } } if label == "" { return label, fmt.Errorf("%s has no version label? try running \"abra recipe sync %s\" first?", recipe.Name, recipe.Name) } return label, nil } func GetRecipeFeaturesAndCategory(recipeName string) (Features, string, error) { feat := Features{} var category string readmePath := path.Join(config.RECIPES_DIR, recipeName, "README.md") logrus.Debugf("attempting to open %s for recipe metadata parsing", readmePath) readmeFS, err := ioutil.ReadFile(readmePath) if err != nil { return feat, category, err } readmeMetadata, err := GetStringInBetween( // Find text between delimiters string(readmeFS), "", "", ) if err != nil { return feat, category, err } readmeLines := strings.Split( // Array item from lines strings.ReplaceAll( // Remove \t tabs readmeMetadata, "\t", "", ), "\n") for _, val := range readmeLines { if strings.Contains(val, "**Category**") { category = strings.TrimSpace( strings.TrimPrefix(val, "* **Category**:"), ) } if strings.Contains(val, "**Backups**") { feat.Backups = strings.TrimSpace( strings.TrimPrefix(val, "* **Backups**:"), ) } if strings.Contains(val, "**Email**") { feat.Email = strings.TrimSpace( strings.TrimPrefix(val, "* **Email**:"), ) } if strings.Contains(val, "**SSO**") { feat.SSO = strings.TrimSpace( strings.TrimPrefix(val, "* **SSO**:"), ) } if strings.Contains(val, "**Healthcheck**") { feat.Healthcheck = strings.TrimSpace( strings.TrimPrefix(val, "* **Healthcheck**:"), ) } if strings.Contains(val, "**Tests**") { feat.Tests = strings.TrimSpace( strings.TrimPrefix(val, "* **Tests**:"), ) } if strings.Contains(val, "**Image**") { imageMetadata, err := GetImageMetadata(strings.TrimSpace( strings.TrimPrefix(val, "* **Image**:"), ), recipeName) if err != nil { continue } feat.Image = imageMetadata } } return feat, category, nil } func GetImageMetadata(imageRowString, recipeName string) (Image, error) { img := Image{} imgFields := strings.Split(imageRowString, ",") for i, elem := range imgFields { imgFields[i] = strings.TrimSpace(elem) } if len(imgFields) < 3 { if imageRowString != "" { logrus.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString) } else { logrus.Warnf("%s image meta is empty?", recipeName) } return img, nil } img.Rating = imgFields[1] img.Source = imgFields[2] imgString := imgFields[0] imageName, err := GetStringInBetween(imgString, "[", "]") if err != nil { logrus.Fatal(err) } img.Image = strings.ReplaceAll(imageName, "`", "") imageURL, err := GetStringInBetween(imgString, "(", ")") if err != nil { logrus.Fatal(err) } img.URL = imageURL return img, nil } // GetStringInBetween returns empty string if no start or end string found func GetStringInBetween(str, start, end string) (result string, err error) { s := strings.Index(str, start) if s == -1 { return "", fmt.Errorf("marker string %s not found", start) } s += len(start) e := strings.Index(str[s:], end) if e == -1 { return "", fmt.Errorf("end marker %s not found", end) } return str[s : s+e], nil } // EnsureUpToDate ensures that the local repo is synced to the remote func EnsureUpToDate(recipeName string) error { recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeName) if err != nil { return err } if !isClean { return fmt.Errorf("%s has locally unstaged changes", recipeName) } repo, err := git.PlainOpen(recipeDir) if err != nil { return err } remotes, err := repo.Remotes() if err != nil { return err } if len(remotes) == 0 { logrus.Debugf("cannot ensure %s is up-to-date, no git remotes configured", recipeName) return nil } worktree, err := repo.Worktree() if err != nil { return err } branch, err := CheckoutDefaultBranch(repo, recipeName) if err != nil { return err } opts := &git.PullOptions{ Force: true, ReferenceName: branch, } if err := worktree.Pull(opts); err != nil { if !strings.Contains(err.Error(), "already up-to-date") { return err } } logrus.Debugf("fetched latest git changes for %s", recipeName) return nil } func GetDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) { recipeDir := path.Join(config.RECIPES_DIR, recipeName) branch := "master" if _, err := repo.Branch("master"); err != nil { if _, err := repo.Branch("main"); err != nil { logrus.Debugf("failed to select branch in %s", recipeDir) return "", err } branch = "main" } return plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)), nil } func CheckoutDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) { recipeDir := path.Join(config.RECIPES_DIR, recipeName) branch, err := GetDefaultBranch(repo, recipeName) if err != nil { return plumbing.ReferenceName(""), err } worktree, err := repo.Worktree() if err != nil { return plumbing.ReferenceName(""), err } checkOutOpts := &git.CheckoutOptions{ Create: false, Force: true, Branch: branch, } if err := worktree.Checkout(checkOutOpts); err != nil { recipeDir := path.Join(config.RECIPES_DIR, recipeName) logrus.Debugf("failed to check out %s in %s", branch, recipeDir) return branch, err } logrus.Debugf("successfully checked out %v in %s", branch, recipeDir) return branch, nil }