// Package catalogue provides ways of interacting with recipe catalogues which // are JSON data structures which contain meta information about recipes (e.g. // what versions of the Nextcloud recipe are available?). package catalogue import ( "encoding/json" "fmt" "io/ioutil" "net/http" "os" "strings" "time" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/web" "github.com/sirupsen/logrus" ) // RecipeCatalogueURL is the only current recipe catalogue available. const RecipeCatalogueURL = "https://apps.coopcloud.tech" // 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"` } // 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 { Digest string `json:"digest"` Image string `json:"image"` Tag string `json:"tag"` } // 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"` Versions []map[tag]map[service]serviceMeta `json:"versions"` Website string `json:"website"` } // 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) } // recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally // is up to date. func recipeCatalogueFSIsLatest() (bool, error) { httpClient := &http.Client{Timeout: web.Timeout} res, err := httpClient.Head(RecipeCatalogueURL) if err != nil { return false, err } lastModified := res.Header["Last-Modified"][0] parsed, err := time.Parse(time.RFC1123, lastModified) if err != nil { return false, err } info, err := os.Stat(config.APPS_JSON) if err != nil { if os.IsNotExist(err) { logrus.Debugf("no recipe catalogue found in file system cache") return false, nil } return false, err } localModifiedTime := info.ModTime().Unix() remoteModifiedTime := parsed.Unix() if localModifiedTime < remoteModifiedTime { logrus.Debug("file system cached recipe catalogue is out-of-date") return false, nil } logrus.Debug("file system cached recipe catalogue is up-to-date") return true, nil } // ReadRecipeCatalogue reads the recipe catalogue. func ReadRecipeCatalogue() (RecipeCatalogue, error) { recipes := make(RecipeCatalogue) recipeFSIsLatest, err := recipeCatalogueFSIsLatest() if err != nil { return nil, err } if !recipeFSIsLatest { logrus.Debugf("reading recipe catalogue from web to get latest") if err := readRecipeCatalogueWeb(&recipes); err != nil { return nil, err } return recipes, nil } logrus.Debugf("reading recipe catalogue from file system cache to get latest") 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.APPS_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.APPS_JSON) return nil } // readRecipeCatalogueWeb reads the catalogue from the web. func readRecipeCatalogueWeb(target interface{}) error { if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil { return err } recipesJSON, err := json.MarshalIndent(target, "", " ") if err != nil { return err } if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0644); err != nil { return err } logrus.Debugf("read recipe catalogue from web at '%s'", RecipeCatalogueURL) return nil } // VersionsOfService lists the version of a service. func VersionsOfService(recipe, serviceName string) ([]string, error) { catalogue, err := ReadRecipeCatalogue() if err != nil { return nil, err } rec, ok := catalogue[recipe] if !ok { return nil, fmt.Errorf("recipe '%s' does not exist?", recipe) } versions := []string{} 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 } // GetRecipeMeta retrieves the recipe metadata from the recipe catalogue. func GetRecipeMeta(recipeName string) (RecipeMeta, error) { catl, err := ReadRecipeCatalogue() if err != nil { return RecipeMeta{}, err } recipeMeta, ok := catl[recipeName] if !ok { err := fmt.Errorf("recipe '%s' does not exist?", recipeName) return RecipeMeta{}, err } if err := recipe.EnsureExists(recipeName); err != nil { return RecipeMeta{}, err } logrus.Debugf("recipe metadata retrieved for '%s'", recipeName) return recipeMeta, nil }