package cli import ( "encoding/json" "fmt" "io/ioutil" "net/http" "os" "path" "sort" "strings" "text/template" "time" "coopcloud.tech/abra/config" "github.com/go-git/go-git/v5" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) type Image struct { Image string `json:"image"` Rating string `json:"rating"` Source string `json:"source"` URL string `json:"url"` } type Feature 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"` } type Tag = string type Service = string type ServiceMeta struct { Digest string `json:"digest"` Image string `json:"image"` Tag string `json:"tag"` } type App struct { Category string `json:"category"` DefaultBranch string `json:"default_branch"` Description string `json:"description"` Features Feature `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"` } type Name = string type AppsCatalogue map[Name]App func (a AppsCatalogue) Flatten() []App { apps := make([]App, 0, len(a)) for name := range a { apps = append(apps, a[name]) } return apps } type ByAppName []App func (a ByAppName) Len() int { return len(a) } func (a ByAppName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByAppName) Less(i, j int) bool { return strings.ToLower(a[i].Name) < strings.ToLower(a[j].Name) } var httpClient = &http.Client{Timeout: 5 * time.Second} var AppsCatalogueURL = "https://apps.coopcloud.tech" func readJson(url string, target interface{}) error { res, err := httpClient.Get(url) if err != nil { return err } defer res.Body.Close() return json.NewDecoder(res.Body).Decode(target) } func AppsCatalogueFSIsLatest() (bool, error) { res, err := httpClient.Head(AppsCatalogueURL) 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) { return false, nil } return false, err } localModifiedTime := info.ModTime().Unix() remoteModifiedTime := parsed.Unix() if localModifiedTime < remoteModifiedTime { return false, nil } return true, nil } func ReadAppsCatalogue() (AppsCatalogue, error) { apps := make(AppsCatalogue) appsFSIsLatest, err := AppsCatalogueFSIsLatest() if err != nil { return nil, err } if !appsFSIsLatest { if err := ReadAppsCatalogueWeb(&apps); err != nil { return nil, err } return apps, nil } if err := ReadAppsCatalogueFS(&apps); err != nil { return nil, err } return apps, nil } func ReadAppsCatalogueFS(target interface{}) error { appsJsonFS, err := ioutil.ReadFile(config.APPS_JSON) if err != nil { return err } if err := json.Unmarshal(appsJsonFS, &target); err != nil { return err } return nil } func ReadAppsCatalogueWeb(target interface{}) error { if err := readJson(AppsCatalogueURL, &target); err != nil { return err } appsJson, err := json.MarshalIndent(target, "", " ") if err != nil { return err } if err := ioutil.WriteFile(config.APPS_JSON, appsJson, 0644); err != nil { return err } return nil } var recipeListCommand = &cli.Command{ Name: "list", Aliases: []string{"ls"}, Action: func(c *cli.Context) error { catalogue, err := ReadAppsCatalogue() if err != nil { logrus.Fatal(err.Error()) } apps := catalogue.Flatten() sort.Sort(ByAppName(apps)) tableCol := []string{"Name", "Category", "Status"} table := createTable(tableCol) for _, app := range apps { status := fmt.Sprintf("%v", app.Features.Status) tableRow := []string{app.Name, app.Category, status} table.Append(tableRow) } table.Render() return nil }, } var recipeVersionCommand = &cli.Command{ Name: "versions", Usage: "List available versions for ", ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { cli.ShowSubcommandHelp(c) return nil } apps, err := ReadAppsCatalogue() if err != nil { logrus.Fatal(err) return nil } if app, ok := apps[recipe]; ok { tableCol := []string{"Version", "Service", "Image", "Digest"} table := createTable(tableCol) for version := range app.Versions { for service := range app.Versions[version] { meta := app.Versions[version][service] table.Append([]string{version, service, meta.Image, meta.Digest}) } } table.SetAutoMergeCells(true) table.Render() return nil } logrus.Fatalf("'%s' recipe doesn't exist?", recipe) return nil }, } var recipeCreateCommand = &cli.Command{ Name: "create", Usage: "Create a new recipe", ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { cli.ShowSubcommandHelp(c) return nil } directory := path.Join(config.APPS_DIR, recipe) if _, err := os.Stat(directory); !os.IsNotExist(err) { logrus.Fatalf("'%s' recipe directory already exists?", directory) return nil } url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL) _, err := git.PlainClone(directory, false, &git.CloneOptions{URL: url}) if err != nil { logrus.Fatal(err) return nil } gitRepo := path.Join(config.APPS_DIR, recipe, ".git") if err := os.RemoveAll(gitRepo); err != nil { logrus.Fatal(err) return nil } toParse := []string{ path.Join(config.APPS_DIR, recipe, "README.md"), path.Join(config.APPS_DIR, recipe, ".env.sample"), path.Join(config.APPS_DIR, recipe, ".drone.yml"), } for _, path := range toParse { file, err := os.OpenFile(path, os.O_RDWR, 0755) if err != nil { logrus.Fatal(err) return nil } tpl, err := template.ParseFiles(path) if err != nil { logrus.Fatal(err) return nil } // TODO: ask for description and probably other things so that the // template repository is more "ready" to go than the current best-guess // mode of templating if err := tpl.Execute(file, struct { Name string Description string }{recipe, "TODO"}); err != nil { logrus.Fatal(err) return nil } } fmt.Printf( "New recipe '%s' created in %s, happy hacking!", recipe, path.Join(config.APPS_DIR, recipe), ) return nil }, } var RecipeCommand = &cli.Command{ Name: "recipe", HideHelp: true, Subcommands: []*cli.Command{ recipeListCommand, recipeVersionCommand, recipeCreateCommand, }, }