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/config" "coopcloud.tech/abra/pkg/formatter" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/limit" "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/web" "coopcloud.tech/tagcmp" "github.com/distribution/reference" composetypes "github.com/docker/cli/cli/compose/types" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) // 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 } log.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 } // Dir retrieves the recipe repository path func (r Recipe) Dir() string { return path.Join(config.RECIPES_DIR, r.Name) } // 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 } log.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) { r := Get2(recipeName) if err := r.EnsureExists(); err != nil { return Recipe{}, err } pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name) 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?", r.Name) } sampleEnv, err := r.SampleEnv() 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(r.Name, 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 Get2(name string) Recipe2 { dir := path.Join(config.RECIPES_DIR, name) return Recipe2{ Name: name, Dir: dir, SSHURL: fmt.Sprintf(config.SSH_URL_TEMPLATE, name), ReadmePath: path.Join(dir, "README.md"), SampleEnvPath: path.Join(dir, ".env.sample"), } } type Recipe2 struct { Name string Dir string SSHURL string ReadmePath string SampleEnvPath string } // 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(r Recipe2) (Features, string, error) { feat := Features{} var category string log.Debugf("attempting to open %s for recipe metadata parsing", r.ReadmePath) readmeFS, err := ioutil.ReadFile(r.ReadmePath) if err != nil { return feat, category, err } readmeMetadata, err := GetStringInBetween( // Find text between delimiters r.Name, 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**:"), ), r.Name) 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 != "" { log.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString) } else { log.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 { log.Fatal(err) } img.Image = strings.ReplaceAll(imageName, "`", "") imageURL, err := GetStringInBetween(recipeName, imgString, "(", ")") if err != nil { log.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 } // 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 } log.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) } } } log.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), } } log.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) log.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) log.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/") log.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 { log.Debugf("failed to check out %s in %s", tag, recipeDir) return err } log.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: log.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) log.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 { log.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) } // ensurePathExists ensures that a path exists. func ensurePathExists(path string) error { if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { return err } return nil } // GetComposeFiles gets the list of compose files for an app (or recipe if you // don't already have an app) which should be merged into a composetypes.Config // while respecting the COMPOSE_FILE env var. func GetComposeFiles(recipe string, appEnv map[string]string) ([]string, error) { var composeFiles []string composeFileEnvVar, ok := appEnv["COMPOSE_FILE"] if !ok { path := fmt.Sprintf("%s/%s/compose.yml", config.RECIPES_DIR, recipe) if err := ensurePathExists(path); err != nil { return composeFiles, err } log.Debugf("no COMPOSE_FILE detected, loading default: %s", path) composeFiles = append(composeFiles, path) return composeFiles, nil } if !strings.Contains(composeFileEnvVar, ":") { path := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, recipe, composeFileEnvVar) if err := ensurePathExists(path); err != nil { return composeFiles, err } log.Debugf("COMPOSE_FILE detected, loading %s", path) composeFiles = append(composeFiles, path) return composeFiles, nil } numComposeFiles := strings.Count(composeFileEnvVar, ":") + 1 envVars := strings.SplitN(composeFileEnvVar, ":", numComposeFiles) if len(envVars) != numComposeFiles { return composeFiles, fmt.Errorf("COMPOSE_FILE (=\"%s\") parsing failed?", composeFileEnvVar) } for _, file := range envVars { path := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, recipe, file) if err := ensurePathExists(path); err != nil { return composeFiles, err } composeFiles = append(composeFiles, path) } log.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", ")) log.Debugf("retrieved %s configs for %s", strings.Join(composeFiles, ", "), recipe) return composeFiles, nil }