From 1f6c0e8c4b3772b19142cf3612391cf3417a40de Mon Sep 17 00:00:00 2001 From: decentral1se Date: Wed, 21 Jul 2021 18:21:45 +0200 Subject: [PATCH] feat: support local apps.json loading This logic supports the following cases: - Download a fresh apps.json and load it if missing - Check if a local apps.json is old and get a fresh one if so - Always save a local copy after downloading a fresh apps.json The http.Head() call is faster than a http.Get() call (only carries back respones headers) and aims to make the more general case more performant: you have the latest copy of the apps.json and don't need to download another one. This a direct port of our Bash implementation logic. Closes https://git.autonomic.zone/coop-cloud/go-abra/issues/9. --- cli/recipe.go | 136 ++++++++++++++++++++++++++++++++++++++------------ config/env.go | 1 + 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/cli/recipe.go b/cli/recipe.go index 832db8a..e54bd2f 100644 --- a/cli/recipe.go +++ b/cli/recipe.go @@ -3,53 +3,60 @@ package cli import ( "encoding/json" "fmt" + "io/ioutil" "net/http" + "os" "sort" "time" + "coopcloud.tech/abra/config" + "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) -type Image struct { +type AppImage struct { Image string `json:"image"` Rating string `json:"rating"` Source string `json:"source"` URL string `json:"url"` } -type AppFeatureSpec 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 Feature struct { + Backups string `json:"backups"` + Email string `json:"email"` + Healthcheck string `json:"healthcheck"` + Image AppImage `json:"image"` + Status int `json:"status"` + Tests string `json:"tests"` } -type AppVersionSpec struct { +type Version struct { Digest string `json:"digest"` Image string `json:"image"` Tag string `json:"tag"` } -type AppSpec struct { - Category string `json:"category"` - DefaultBranch string `json:"default_branch"` - Description string `json:"description"` - Features AppFeatureSpec `json:"features"` - Icon string `json:"icon"` - Name string `json:"name"` - Repository string `json:"repository"` - Versions map[string]map[string]AppVersionSpec `json:"versions"` - Website string `json:"website"` +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[string]map[string]Version `json:"versions"` + Website string `json:"website"` } -type AppsJson map[string]AppSpec +type Apps map[string]App -func getJson(url string, target interface{}) error { - client := &http.Client{Timeout: 5 * time.Second} - res, err := client.Get(url) +var httpClient = &http.Client{Timeout: 5 * time.Second} + +var AppsUrl = "https://apps.coopcloud.tech" + +func readJson(url string, target interface{}) error { + res, err := httpClient.Get(url) if err != nil { return err } @@ -57,16 +64,80 @@ func getJson(url string, target interface{}) error { return json.NewDecoder(res.Body).Decode(target) } -func GetAppsJSON() (AppsJson, error) { - url := "https://apps.coopcloud.tech" - apps := make(AppsJson) - if err := getJson(url, &apps); err != nil { +func AppsFSIsLatest() (bool, error) { + res, err := httpClient.Head(AppsUrl) + 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 ReadAppsFS(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 ReadAppsWeb() (Apps, error) { + apps := make(Apps) + + appsFSIsLatest, err := AppsFSIsLatest() + if err != nil { return nil, err } + + if !appsFSIsLatest { + if err := readJson(AppsUrl, &apps); err != nil { + return nil, err + } + + appsJson, err := json.MarshalIndent(apps, "", " ") + if err != nil { + return nil, err + } + + if err := ioutil.WriteFile(config.APPS_JSON, appsJson, 0644); err != nil { + return nil, err + } + + return apps, nil + } + + if err := ReadAppsFS(&apps); err != nil { + return nil, err + } + return apps, nil } -func sortByAppName(apps AppsJson) []string { +func SortByAppName(apps Apps) []string { var names []string for name := range apps { names = append(names, name) @@ -79,15 +150,16 @@ var recipeListCommand = &cli.Command{ Name: "list", Aliases: []string{"ls"}, Action: func(c *cli.Context) error { - apps, err := GetAppsJSON() + appSpecs, err := ReadAppsWeb() if err != nil { logrus.Fatal(err.Error()) } tableCol := []string{"Name", "Category", "Status"} table := createTable(tableCol) - for _, name := range sortByAppName(apps) { - appSpec := apps[name] - tableRow := []string{appSpec.Name, appSpec.Category, fmt.Sprintf("%v", appSpec.Features.Status)} + for _, appName := range SortByAppName(appSpecs) { + appSpec := appSpecs[appName] + status := fmt.Sprintf("%v", appSpec.Features.Status) + tableRow := []string{appSpec.Name, appSpec.Category, status} table.Append(tableRow) } table.Render() diff --git a/config/env.go b/config/env.go index 810ce49..643e041 100644 --- a/config/env.go +++ b/config/env.go @@ -16,6 +16,7 @@ import ( var ABRA_DIR = os.ExpandEnv("$HOME/.abra") var ABRA_SERVER_FOLDER = path.Join(ABRA_DIR, "servers") +var APPS_JSON = path.Join(ABRA_DIR, "apps.json") // Type aliases to make code hints easier to understand type AppEnv = map[string]string