package recipe import ( "encoding/json" "fmt" "io/ioutil" "os" "path" "path/filepath" "slices" "sort" "strconv" "strings" "coopcloud.tech/abra/pkg/catalogue" "coopcloud.tech/abra/pkg/compose" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/limit" "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/web" "coopcloud.tech/tagcmp" composetypes "github.com/docker/cli/cli/compose/types" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" ) // RecipeCatalogueURL is the only current recipe catalogue available. const RecipeCatalogueURL = "https://recipes.coopcloud.tech/recipes.json" // ReposMetadataURL is the recipe repository metadata. const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos" // tag represents a git tag. type tag = string // service represents a service within a recipe. type service = string // ServiceMeta represents meta info associated with a service. type ServiceMeta struct { Image string `json:"image"` Tag string `json:"tag"` } // RecipeVersions are the versions associated with a recipe. type RecipeVersions []map[tag]map[service]ServiceMeta // RecipeMeta represents metadata for a recipe in the abra catalogue. type RecipeMeta struct { Category string `json:"category"` DefaultBranch string `json:"default_branch"` Description string `json:"description"` Features Features `json:"features"` Icon string `json:"icon"` Name string `json:"name"` Repository string `json:"repository"` SSHURL string `json:"ssh_url"` Versions RecipeVersions `json:"versions"` Website string `json:"website"` } // TopicMeta represents a list of topics for a repository. type TopicMeta struct { Topics []string `json:"topics"` } // LatestVersion returns the latest version of a recipe. func (r RecipeMeta) LatestVersion() string { var version string // apps.json versions are sorted so the last key is latest latest := r.Versions[len(r.Versions)-1] for tag := range latest { version = tag } logrus.Debugf("choosing %s as latest version of %s", version, r.Name) return version } // Name represents a recipe name. type Name = string // RecipeCatalogue represents the entire recipe catalogue. type RecipeCatalogue map[Name]RecipeMeta // Flatten converts AppCatalogue to slice func (r RecipeCatalogue) Flatten() []RecipeMeta { recipes := make([]RecipeMeta, 0, len(r)) for name := range r { recipes = append(recipes, r[name]) } return recipes } // ByRecipeName sorts recipes by name. type ByRecipeName []RecipeMeta func (r ByRecipeName) Len() int { return len(r) } func (r ByRecipeName) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r ByRecipeName) Less(i, j int) bool { return strings.ToLower(r[i].Name) < strings.ToLower(r[j].Name) } // 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 Meta RecipeMeta } // Push pushes the latest changes to a SSH URL remote. You need to have your // local SSH configuration for git.coopcloud.tech working for this to work func (r Recipe) Push(dryRun bool) error { repo, err := git.PlainOpen(r.Dir()) if err != nil { return err } if err := gitPkg.CreateRemote(repo, "origin-ssh", r.Meta.SSHURL, dryRun); err != nil { return err } if err := gitPkg.Push(r.Dir(), "origin-ssh", true, dryRun); err != nil { return err } return nil } // 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) (bool, error) { pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name) image = formatter.StripTagMeta(image) ok, err := compose.UpdateTag(pattern, image, tag, r.Name) if err != nil { return false, err } return ok, 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, offline bool) (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 } meta, err := GetRecipeMeta(recipeName, offline) if err != nil { switch err.(type) { case RecipeMissingFromCatalogue: meta = RecipeMeta{} default: return Recipe{}, err } } return Recipe{ Name: recipeName, Config: config, Meta: meta, }, nil } func (r Recipe) SampleEnv() (map[string]string, error) { envSamplePath := path.Join(config.RECIPES_DIR, r.Name, ".env.sample") sampleEnv, err := config.ReadEnv(envSamplePath) if err != nil { return sampleEnv, fmt.Errorf("unable to discover .env.sample for %s", r.Name) } return sampleEnv, nil } // Ensure makes sure the recipe exists, is up to date and has the latest version checked out. func Ensure(recipeName string) error { if err := EnsureExists(recipeName); err != nil { return err } if err := EnsureUpToDate(recipeName); err != nil { return err } if err := EnsureLatest(recipeName); err != nil { return err } return 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) 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 } joinedTags := strings.Join(parsedTags, ", ") if joinedTags != "" { logrus.Debugf("read %s as tags for recipe %s", joinedTags, recipeName) } if tagRef.String() == "" { return fmt.Errorf("the local copy of %s doesn't seem to have version %s available?", recipeName, version) } 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 } // EnsureIsClean makes sure that the recipe repository has no unstaged changes. func EnsureIsClean(recipeName string) error { recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeDir) if err != nil { return fmt.Errorf("unable to check git clean status in %s: %s", recipeDir, err) } if !isClean { msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding" return fmt.Errorf(msg, recipeName, 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) if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } repo, err := git.PlainOpen(recipeDir) if err != nil { return err } worktree, err := repo.Worktree() if err != nil { return err } branch, err := gitPkg.GetDefaultBranch(repo, recipeDir) 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 = formatter.SmallSHA(head.String()) recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeDir) 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") && strings.Contains(label, "version") { 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 recipeName, 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, "**Status**") { feat.Status, _ = strconv.Atoi(strings.TrimSpace(strings.Split(strings.TrimPrefix(val, "* **Status**:"), ",")[0])) } 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(recipeName, imgString, "[", "]") if err != nil { logrus.Fatal(err) } img.Image = strings.ReplaceAll(imageName, "`", "") imageURL, err := GetStringInBetween(recipeName, 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(recipeName, str, start, end string) (result string, err error) { s := strings.Index(str, start) if s == -1 { return "", fmt.Errorf("%s: marker string %s not found", recipeName, start) } s += len(start) e := strings.Index(str[s:], end) if e == -1 { return "", fmt.Errorf("%s: end marker %s not found", recipeName, 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) repo, err := git.PlainOpen(recipeDir) if err != nil { return fmt.Errorf("unable to open %s: %s", recipeDir, err) } remotes, err := repo.Remotes() if err != nil { return fmt.Errorf("unable to read remotes in %s: %s", recipeDir, 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 fmt.Errorf("unable to open git work tree in %s: %s", recipeDir, err) } branch, err := gitPkg.CheckoutDefaultBranch(repo, recipeDir) if err != nil { return fmt.Errorf("unable to check out default branch in %s: %s", recipeDir, err) } fetchOpts := &git.FetchOptions{Tags: git.AllTags} if err := repo.Fetch(fetchOpts); err != nil { if !strings.Contains(err.Error(), "already up-to-date") { return fmt.Errorf("unable to fetch tags in %s: %s", recipeDir, err) } } opts := &git.PullOptions{ Force: true, ReferenceName: branch, SingleBranch: true, } if err := worktree.Pull(opts); err != nil { if !strings.Contains(err.Error(), "already up-to-date") { return fmt.Errorf("unable to git pull in %s: %s", recipeDir, err) } } logrus.Debugf("fetched latest git changes for %s", recipeName) return nil } // ReadRecipeCatalogue reads the recipe catalogue. func ReadRecipeCatalogue(offline bool) (RecipeCatalogue, error) { recipes := make(RecipeCatalogue) if err := catalogue.EnsureCatalogue(); err != nil { return nil, err } if !offline { if err := catalogue.EnsureUpToDate(); err != nil { return nil, err } } if err := readRecipeCatalogueFS(&recipes); err != nil { return nil, err } return recipes, nil } // readRecipeCatalogueFS reads the catalogue from the file system. func readRecipeCatalogueFS(target interface{}) error { recipesJSONFS, err := ioutil.ReadFile(config.RECIPES_JSON) if err != nil { return err } if err := json.Unmarshal(recipesJSONFS, &target); err != nil { return err } logrus.Debugf("read recipe catalogue from file system cache in %s", config.RECIPES_JSON) return nil } // VersionsOfService lists the version of a service. func VersionsOfService(recipe, serviceName string, offline bool) ([]string, error) { var versions []string catalogue, err := ReadRecipeCatalogue(offline) if err != nil { return nil, err } rec, ok := catalogue[recipe] if !ok { return versions, nil } alreadySeen := make(map[string]bool) for _, serviceVersion := range rec.Versions { for tag := range serviceVersion { if _, ok := alreadySeen[tag]; !ok { alreadySeen[tag] = true versions = append(versions, tag) } } } logrus.Debugf("detected versions %s for %s", strings.Join(versions, ", "), recipe) return versions, nil } // RecipeMissingFromCatalogue signifies a recipe is not present in the catalogue. type RecipeMissingFromCatalogue struct{ err string } // Error outputs the error message. func (r RecipeMissingFromCatalogue) Error() string { return r.err } // GetRecipeMeta retrieves the recipe metadata from the recipe catalogue. func GetRecipeMeta(recipeName string, offline bool) (RecipeMeta, error) { catl, err := ReadRecipeCatalogue(offline) if err != nil { return RecipeMeta{}, err } recipeMeta, ok := catl[recipeName] if !ok { return RecipeMeta{}, RecipeMissingFromCatalogue{ err: fmt.Sprintf("recipe %s does not exist?", recipeName), } } logrus.Debugf("recipe metadata retrieved for %s", recipeName) return recipeMeta, nil } // RepoMeta is a single recipe repo metadata. type RepoMeta struct { ID int `json:"id"` Owner Owner Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Empty bool `json:"empty"` Private bool `json:"private"` Fork bool `json:"fork"` Template bool `json:"template"` Parent interface{} `json:"parent"` Mirror bool `json:"mirror"` Size int `json:"size"` HTMLURL string `json:"html_url"` SSHURL string `json:"ssh_url"` CloneURL string `json:"clone_url"` OriginalURL string `json:"original_url"` Website string `json:"website"` StarsCount int `json:"stars_count"` ForksCount int `json:"forks_count"` WatchersCount int `json:"watchers_count"` OpenIssuesCount int `json:"open_issues_count"` OpenPRCount int `json:"open_pr_counter"` ReleaseCounter int `json:"release_counter"` DefaultBranch string `json:"default_branch"` Archived bool `json:"archived"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Permissions Permissions HasIssues bool `json:"has_issues"` InternalTracker InternalTracker HasWiki bool `json:"has_wiki"` HasPullRequests bool `json:"has_pull_requests"` HasProjects bool `json:"has_projects"` IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"` AllowMergeCommits bool `json:"allow_merge_commits"` AllowRebase bool `json:"allow_rebase"` AllowRebaseExplicit bool `json:"allow_rebase_explicit"` AllowSquashMerge bool `json:"allow_squash_merge"` AvatarURL string `json:"avatar_url"` Internal bool `json:"internal"` MirrorInterval string `json:"mirror_interval"` } // Owner is the repo organisation owner metadata. type Owner struct { ID int `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` Email string `json:"email"` AvatarURL string `json:"avatar_url"` Language string `json:"language"` IsAdmin bool `json:"is_admin"` LastLogin string `json:"last_login"` Created string `json:"created"` Restricted bool `json:"restricted"` Username string `json:"username"` } // Permissions is perms metadata for a repo. type Permissions struct { Admin bool `json:"admin"` Push bool `json:"push"` Pull bool `json:"pull"` } // InternalTracker is issue tracker metadata for a repo. type InternalTracker struct { EnableTimeTracker bool `json:"enable_time_tracker"` AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"` EnableIssuesDependencies bool `json:"enable_issue_dependencies"` } // RepoCatalogue represents all the recipe repo metadata. type RepoCatalogue map[string]RepoMeta // ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea. func ReadReposMetadata() (RepoCatalogue, error) { reposMeta := make(RepoCatalogue) pageIdx := 1 bar := formatter.CreateProgressbar(-1, "retrieving recipe repos list from git.coopcloud.tech...") for { var reposList []RepoMeta pagedURL := fmt.Sprintf("%s?page=%v", ReposMetadataURL, pageIdx) logrus.Debugf("fetching repo metadata from %s", pagedURL) if err := web.ReadJSON(pagedURL, &reposList); err != nil { return reposMeta, err } if len(reposList) == 0 { bar.Add(1) break } for idx, repo := range reposList { var topicMeta TopicMeta topicsURL := getReposTopicUrl(repo.Name) if err := web.ReadJSON(topicsURL, &topicMeta); err != nil { return reposMeta, err } if slices.Contains(topicMeta.Topics, "recipe") && repo.Name != "example" { reposMeta[repo.Name] = reposList[idx] } } pageIdx++ bar.Add(1) } fmt.Println() // newline for spinner return reposMeta, nil } // GetRecipeVersions retrieves all recipe versions. func GetRecipeVersions(recipeName string, offline bool) (RecipeVersions, error) { versions := RecipeVersions{} recipeDir := path.Join(config.RECIPES_DIR, recipeName) logrus.Debugf("attempting to open git repository in %s", recipeDir) repo, err := git.PlainOpen(recipeDir) if err != nil { return versions, err } worktree, err := repo.Worktree() if err != nil { return versions, err } gitTags, err := repo.Tags() if err != nil { return versions, err } if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) { tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/") logrus.Debugf("processing %s for %s", tag, recipeName) checkOutOpts := &git.CheckoutOptions{ Create: false, Force: true, Branch: plumbing.ReferenceName(ref.Name()), } if err := worktree.Checkout(checkOutOpts); err != nil { logrus.Debugf("failed to check out %s in %s", tag, recipeDir) return err } logrus.Debugf("successfully checked out %s in %s", ref.Name(), recipeDir) recipe, err := Get(recipeName, offline) if err != nil { return err } versionMeta := make(map[string]ServiceMeta) for _, service := range recipe.Config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return err } path := reference.Path(img) path = formatter.StripTagMeta(path) var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: logrus.Warnf("%s service is missing image tag?", path) continue } versionMeta[service.Name] = ServiceMeta{ Image: path, Tag: tag, } } versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta}) return nil }); err != nil { return versions, err } _, err = gitPkg.CheckoutDefaultBranch(repo, recipeDir) if err != nil { return versions, err } sortRecipeVersions(versions) logrus.Debugf("collected %s for %s", versions, recipeName) return versions, nil } // sortRecipeVersions sorts the recipe semver versions func sortRecipeVersions(versions RecipeVersions) { sort.Slice(versions, func(i, j int) bool { version1, err := tagcmp.Parse(getVersionString(versions[i])) if err != nil { panic(err) } version2, err := tagcmp.Parse(getVersionString(versions[j])) if err != nil { panic(err) } return version1.IsLessThan(version2) }) } // getVersionString returns the version string from RecipeVersions func getVersionString(versionMap map[string]map[string]ServiceMeta) string { // Assuming there's only one key in versionMap for k := range versionMap { return k } return "" } // sortVersionStrings sorts a list of semver version strings func sortVersionStrings(versions []string) { sort.Slice(versions, func(i, j int) bool { version1, err := tagcmp.Parse(versions[i]) if err != nil { panic(err) } version2, err := tagcmp.Parse(versions[j]) if err != nil { panic(err) } return version1.IsLessThan(version2) }) } // GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue. func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]string, error) { var versions []string if recipeMeta, exists := catl[recipeName]; exists { for _, versionMeta := range recipeMeta.Versions { for tag := range versionMeta { versions = append(versions, tag) } } } sortVersionStrings(versions) return versions, nil } // UpdateRepositories clones and updates all recipe repositories locally. func UpdateRepositories(repos RepoCatalogue, recipeName string) error { var barLength int if recipeName != "" { barLength = 1 } else { barLength = len(repos) } cloneLimiter := limit.New(10) retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...") ch := make(chan string, barLength) for _, repoMeta := range repos { go func(rm RepoMeta) { cloneLimiter.Begin() defer cloneLimiter.End() if recipeName != "" && recipeName != rm.Name { ch <- rm.Name retrieveBar.Add(1) return } recipeDir := path.Join(config.RECIPES_DIR, rm.Name) if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil { logrus.Fatal(err) } ch <- rm.Name retrieveBar.Add(1) }(repoMeta) } for range repos { <-ch // wait for everything } return nil } // getReposTopicUrl retrieves the repository specific topic listing. func getReposTopicUrl(repoName string) string { return fmt.Sprintf("https://git.coopcloud.tech/api/v1/repos/coop-cloud/%s/topics", repoName) }