appmanager: fetch app configs from app-repository
diff --git a/core/installer/Makefile b/core/installer/Makefile
index ec56861..1762a41 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -21,7 +21,7 @@
 	./pcloud --kubeconfig=../../priv/kubeconfig install --ssh-key=/Users/lekva/.ssh/id_rsa --app=rpuppy --repo-addr=ssh://localhost:2222/lekva
 
 appmanager:
-	./pcloud --kubeconfig=../../priv/kubeconfig appmanager --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.211/qwe --port=9090
+	./pcloud --kubeconfig=../../priv/kubeconfig appmanager --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.100.210:22/rkcr --port=9090 --app-repo-addr=https://apprepo.dodo.cloud
 
 welc:
 	./pcloud --kubeconfig=../../priv/kubeconfig welcome --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.211/lekva --port=9090
diff --git a/core/installer/app.go b/core/installer/app.go
index 902e686..de294cb 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -1,15 +1,21 @@
 package installer
 
 import (
+	"archive/tar"
+	"compress/gzip"
 	"embed"
 	"encoding/json"
 	"fmt"
 	htemplate "html/template"
+	"io"
 	"log"
+	"net/http"
 	"strings"
 	"text/template"
 
 	"github.com/Masterminds/sprig/v3"
+	"github.com/go-git/go-billy/v5"
+	"sigs.k8s.io/yaml"
 )
 
 //go:embed values-tmpl
@@ -19,6 +25,14 @@
 	Nam() string
 }
 
+type appConfig struct {
+	Name        string         `json:"name"`
+	Version     string         `json:"version"`
+	Description string         `json:"description"`
+	Namespaces  []string       `json:"namespaces"`
+	Icon        htemplate.HTML `json:"icon"`
+}
+
 type App struct {
 	Name       string
 	Namespaces []string
@@ -58,8 +72,8 @@
 	apps []A
 }
 
-func NewInMemoryAppRepository[A Named](apps []A) AppRepository[A] {
-	return &InMemoryAppRepository[A]{
+func NewInMemoryAppRepository[A Named](apps []A) InMemoryAppRepository[A] {
+	return InMemoryAppRepository[A]{
 		apps,
 	}
 }
@@ -559,3 +573,191 @@
 		tmpls.Lookup("headscale-controller.md"),
 	}
 }
+
+type httpAppRepository struct {
+	apps []StoreApp
+}
+
+type appVersion struct {
+	Version string   `json:"version"`
+	Urls    []string `json:"urls"`
+}
+
+type allAppsResp struct {
+	ApiVersion string                  `json:"apiVersion"`
+	Entries    map[string][]appVersion `json:"entries"`
+}
+
+func FetchAppsFromHTTPRepository(addr string, fs billy.Filesystem) error {
+	resp, err := http.Get(addr)
+	if err != nil {
+		return err
+	}
+	b, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	var apps allAppsResp
+	if err := yaml.Unmarshal(b, &apps); err != nil {
+		return err
+	}
+	for name, conf := range apps.Entries {
+		for _, version := range conf {
+			resp, err := http.Get(version.Urls[0])
+			if err != nil {
+				return err
+			}
+			nameVersion := fmt.Sprintf("%s-%s", name, version.Version)
+			if err := fs.MkdirAll(nameVersion, 0700); err != nil {
+				return err
+			}
+			sub, err := fs.Chroot(nameVersion)
+			if err != nil {
+				return err
+			}
+			if err := extractApp(resp.Body, sub); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func extractApp(archive io.Reader, fs billy.Filesystem) error {
+	uncompressed, err := gzip.NewReader(archive)
+	if err != nil {
+		return err
+	}
+	tarReader := tar.NewReader(uncompressed)
+	for true {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := fs.MkdirAll(header.Name, 0755); err != nil {
+				return err
+			}
+		case tar.TypeReg:
+			out, err := fs.Create(header.Name)
+			if err != nil {
+				return err
+			}
+			defer out.Close()
+			if _, err := io.Copy(out, tarReader); err != nil {
+				return err
+			}
+		default:
+			return fmt.Errorf("Uknown type: %s", header.Name)
+		}
+	}
+	return nil
+}
+
+type fsAppRepository struct {
+	InMemoryAppRepository[StoreApp]
+	fs billy.Filesystem
+}
+
+func NewFSAppRepository(fs billy.Filesystem) (AppRepository[StoreApp], error) {
+	all, err := fs.ReadDir(".")
+	if err != nil {
+		return nil, err
+	}
+	apps := make([]StoreApp, 0)
+	for _, e := range all {
+		if !e.IsDir() {
+			continue
+		}
+		appFS, err := fs.Chroot(e.Name())
+		if err != nil {
+			return nil, err
+		}
+		app, err := loadApp(appFS)
+		if err != nil {
+			log.Printf("Ignoring directory %s: %s", e.Name(), err)
+			continue
+		}
+		apps = append(apps, app)
+	}
+	return &fsAppRepository{
+		NewInMemoryAppRepository[StoreApp](apps),
+		fs,
+	}, nil
+}
+
+func loadApp(fs billy.Filesystem) (StoreApp, error) {
+	cfg, err := fs.Open("Chart.yaml")
+	if err != nil {
+		return StoreApp{}, err
+	}
+	defer cfg.Close()
+	b, err := io.ReadAll(cfg)
+	if err != nil {
+		return StoreApp{}, err
+	}
+	var appCfg appConfig
+	if err := yaml.Unmarshal(b, &appCfg); err != nil {
+		return StoreApp{}, err
+	}
+	rb, err := fs.Open("README.md")
+	if err != nil {
+		return StoreApp{}, err
+	}
+	defer rb.Close()
+	readme, err := io.ReadAll(rb)
+	if err != nil {
+		return StoreApp{}, err
+	}
+	readmeTmpl, err := template.New("README.md").Parse(string(readme))
+	if err != nil {
+		return StoreApp{}, err
+	}
+	sb, err := fs.Open("schema.json")
+	if err != nil {
+		return StoreApp{}, err
+	}
+	defer sb.Close()
+	schema, err := io.ReadAll(sb)
+	if err != nil {
+		return StoreApp{}, err
+	}
+	tFiles, err := fs.ReadDir("templates")
+	if err != nil {
+		return StoreApp{}, err
+	}
+	tmpls := make([]*template.Template, 0)
+	for _, t := range tFiles {
+		if !strings.HasSuffix(t.Name(), ".yaml") {
+			continue
+		}
+		inp, err := fs.Open(fs.Join("templates", t.Name()))
+		if err != nil {
+			return StoreApp{}, err
+		}
+		b, err := io.ReadAll(inp)
+		if err != nil {
+			return StoreApp{}, err
+		}
+		tmpl, err := template.New(t.Name()).Parse(string(b))
+		if err != nil {
+			return StoreApp{}, err
+		}
+		tmpls = append(tmpls, tmpl)
+	}
+	return StoreApp{
+		App: App{
+			Name:       appCfg.Name,
+			Readme:     readmeTmpl,
+			Schema:     string(schema),
+			Namespaces: appCfg.Namespaces,
+			Templates:  tmpls,
+		},
+		ShortDescription: appCfg.Description,
+		Icon:             appCfg.Icon,
+	}, nil
+}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 769ea5a..cfb69a4 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -1,21 +1,24 @@
 package main
 
 import (
+	"log"
 	"os"
 
-	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh"
 
 	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
+
+	"github.com/go-git/go-billy/v5/memfs"
+	"github.com/spf13/cobra"
 )
 
 var appManagerFlags struct {
-	sshKey     string
-	repoAddr   string
-	port       int
-	webAppAddr string
+	sshKey      string
+	repoAddr    string
+	port        int
+	appRepoAddr string
 }
 
 func appManagerCmd() *cobra.Command {
@@ -42,8 +45,8 @@
 		"",
 	)
 	cmd.Flags().StringVar(
-		&appManagerFlags.webAppAddr,
-		"web-app-addr",
+		&appManagerFlags.appRepoAddr,
+		"app-repo-addr",
 		"",
 		"",
 	)
@@ -67,6 +70,7 @@
 	if err != nil {
 		return err
 	}
+	log.Println("Cloned repository")
 	kube, err := newNSCreator()
 	if err != nil {
 		return err
@@ -78,13 +82,25 @@
 	if err != nil {
 		return err
 	}
-	r := installer.NewInMemoryAppRepository[installer.StoreApp](installer.CreateStoreApps())
+	log.Println("Creating repository")
+	var r installer.AppRepository[installer.StoreApp]
+	if appManagerFlags.appRepoAddr != "" {
+		fs := memfs.New()
+		err = installer.FetchAppsFromHTTPRepository(appManagerFlags.appRepoAddr, fs)
+		if err != nil {
+			return err
+		}
+		r, err = installer.NewFSAppRepository(fs)
+		if err != nil {
+			return err
+		}
+	} else {
+		r = installer.NewInMemoryAppRepository[installer.StoreApp](installer.CreateStoreApps())
+	}
 	s := welcome.NewAppManagerServer(
 		appManagerFlags.port,
-		appManagerFlags.webAppAddr,
 		m,
 		r,
 	)
-	s.Start()
-	return nil
+	return s.Start()
 }
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 242faed..033e1db 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -28,27 +28,24 @@
 var appHtmlTmpl string
 
 type AppManagerServer struct {
-	port       int
-	webAppAddr string
-	m          *installer.AppManager
-	r          installer.AppRepository[installer.StoreApp]
+	port int
+	m    *installer.AppManager
+	r    installer.AppRepository[installer.StoreApp]
 }
 
 func NewAppManagerServer(
 	port int,
-	webAppAddr string,
 	m *installer.AppManager,
 	r installer.AppRepository[installer.StoreApp],
 ) *AppManagerServer {
 	return &AppManagerServer{
 		port,
-		webAppAddr,
 		m,
 		r,
 	}
 }
 
-func (s *AppManagerServer) Start() {
+func (s *AppManagerServer) Start() error {
 	e := echo.New()
 	e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
 	e.GET("/api/app-repo", s.handleAppRepo)
@@ -62,7 +59,7 @@
 	e.GET("/app/:slug", s.handleAppUI)
 	e.GET("/instance/:slug", s.handleInstanceUI)
 	fmt.Printf("Starting HTTP server on port: %d\n", s.port)
-	log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
+	return e.Start(fmt.Sprintf(":%d", s.port))
 }
 
 type app struct {
@@ -223,18 +220,21 @@
 	if err := json.Unmarshal(contents, &values); err != nil {
 		return err
 	}
-	fmt.Println(values)
+	log.Printf("Values: %+v\n", values)
 	a, err := s.r.Find(slug)
 	if err != nil {
 		return err
 	}
+	log.Printf("Found application: %s\n", slug)
 	config, err := s.m.Config()
 	if err != nil {
 		return err
 	}
+	log.Printf("Configuration: %+v\n", config)
 	nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 	if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
+		log.Printf("%s\n", err.Error())
 		return err
 	}
 	return c.String(http.StatusOK, "Installed")