Installer: Separate infrastructure and environment apps.
Have two separate application managers, one for installing apps on the
dodo infra, and nother installing on individual environments.
Change-Id: I1b24f008e30c5533c48c22ea92328bc4bb7abc54
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
new file mode 100644
index 0000000..70cdb8e
--- /dev/null
+++ b/core/installer/app_repository.go
@@ -0,0 +1,332 @@
+package installer
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "embed"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/cuecontext"
+ "github.com/go-git/go-billy/v5"
+ "sigs.k8s.io/yaml"
+)
+
+//go:embed values-tmpl
+var valuesTmpls embed.FS
+
+var storeAppConfigs = []string{
+ "values-tmpl/jellyfin.cue",
+ // "values-tmpl/maddy.cue",
+ "values-tmpl/matrix.cue",
+ "values-tmpl/penpot.cue",
+ "values-tmpl/pihole.cue",
+ "values-tmpl/qbittorrent.cue",
+ "values-tmpl/rpuppy.cue",
+ "values-tmpl/soft-serve.cue",
+ "values-tmpl/vaultwarden.cue",
+ "values-tmpl/url-shortener.cue",
+ "values-tmpl/gerrit.cue",
+ "values-tmpl/jenkins.cue",
+ "values-tmpl/zot.cue",
+ // TODO(gio): should be part of env infra
+ "values-tmpl/certificate-issuer-private.cue",
+ "values-tmpl/certificate-issuer-public.cue",
+ "values-tmpl/appmanager.cue",
+ "values-tmpl/core-auth.cue",
+ "values-tmpl/headscale-user.cue",
+ "values-tmpl/metallb-ipaddresspool.cue",
+ "values-tmpl/private-network.cue",
+ "values-tmpl/welcome.cue",
+ "values-tmpl/memberships.cue",
+ "values-tmpl/headscale.cue",
+}
+
+var infraAppConfigs = []string{
+ "values-tmpl/cert-manager.cue",
+ "values-tmpl/config-repo.cue",
+ "values-tmpl/csi-driver-smb.cue",
+ "values-tmpl/dns-zone-manager.cue",
+ "values-tmpl/env-manager.cue",
+ "values-tmpl/fluxcd-reconciler.cue",
+ "values-tmpl/headscale-controller.cue",
+ "values-tmpl/ingress-public.cue",
+ "values-tmpl/resource-renderer-controller.cue",
+ "values-tmpl/hydra-maester.cue",
+}
+
+type AppRepository interface {
+ GetAll() ([]App, error)
+ Find(name string) (App, error)
+}
+
+type InMemoryAppRepository struct {
+ apps []App
+}
+
+func NewInMemoryAppRepository(apps []App) InMemoryAppRepository {
+ return InMemoryAppRepository{apps}
+}
+
+func (r InMemoryAppRepository) Find(name string) (App, error) {
+ for _, a := range r.apps {
+ if a.Name() == name {
+ return a, nil
+ }
+ }
+ return nil, fmt.Errorf("Application not found: %s", name)
+}
+
+func (r InMemoryAppRepository) GetAll() ([]App, error) {
+ return r.apps, nil
+}
+
+func CreateAllApps() []App {
+ return append(
+ createInfraApps(),
+ CreateStoreApps()...,
+ )
+}
+
+func CreateStoreApps() []App {
+ ret := make([]App, 0)
+ for _, cfgFile := range storeAppConfigs {
+ cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
+ if err != nil {
+ panic(err)
+ }
+ if app, err := NewCueEnvApp(cfg); err != nil {
+ panic(err)
+ } else {
+ ret = append(ret, app)
+ }
+ }
+ return ret
+}
+
+func createInfraApps() []App {
+ ret := make([]App, 0)
+ for _, cfgFile := range infraAppConfigs {
+ cfg, err := readCueConfigFromFile(valuesTmpls, cfgFile)
+ if err != nil {
+ panic(err)
+ }
+ if app, err := NewCueInfraApp(cfg); err != nil {
+ panic(err)
+ } else {
+ ret = append(ret, app)
+ }
+ }
+ return ret
+}
+
+type httpAppRepository struct {
+ apps []App
+}
+
+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
+ fs billy.Filesystem
+}
+
+func NewFSAppRepository(fs billy.Filesystem) (AppRepository, error) {
+ all, err := fs.ReadDir(".")
+ if err != nil {
+ return nil, err
+ }
+ apps := make([]App, 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(apps),
+ fs,
+ }, nil
+}
+
+func loadApp(fs billy.Filesystem) (App, error) {
+ items, err := fs.ReadDir(".")
+ if err != nil {
+ return nil, err
+ }
+ var contents bytes.Buffer
+ for _, i := range items {
+ if i.IsDir() {
+ continue
+ }
+ f, err := fs.Open(i.Name())
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ if _, err := io.Copy(&contents, f); err != nil {
+ return nil, err
+ }
+ }
+ cfg, err := processCueConfig(contents.String())
+ if err != nil {
+ return nil, err
+ }
+ return NewCueEnvApp(cfg)
+}
+
+func readCueConfigFromFile(fs embed.FS, f string) (*cue.Value, error) {
+ contents, err := fs.ReadFile(f)
+ if err != nil {
+ return nil, err
+ }
+ return processCueConfig(string(contents))
+}
+
+func processCueConfig(contents string) (*cue.Value, error) {
+ ctx := cuecontext.New()
+ cfg := ctx.CompileString(contents + cueBaseConfig)
+ if err := cfg.Err(); err != nil {
+ return nil, err
+ }
+ if err := cfg.Validate(); err != nil {
+ return nil, err
+ }
+ return &cfg, nil
+}
+
+// func CreateAppMaddy(fs embed.FS, tmpls *template.Template) App {
+// schema, err := readJSONSchemaFromFile(fs, "values-tmpl/maddy.jsonschema")
+// if err != nil {
+// panic(err)
+// }
+// return StoreApp{
+// App{
+// "maddy",
+// []string{"app-maddy"},
+// []*template.Template{
+// tmpls.Lookup("maddy.yaml"),
+// },
+// schema,
+// nil,
+// nil,
+// },
+// `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M9.5 13c13.687 13.574 14.825 13.09 29 0"/><rect width="37" height="31" x="5.5" y="8.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" rx="2"/></svg>`,
+// "SMPT/IMAP server to communicate via email.",
+// }
+// }
+
+func FindEnvApp(r AppRepository, name string) (EnvApp, error) {
+ app, err := r.Find(name)
+ if err != nil {
+ return nil, err
+ }
+ if a, ok := app.(EnvApp); ok {
+ return a, nil
+ } else {
+ return nil, fmt.Errorf("not found")
+ }
+}
+
+func FindInfraApp(r AppRepository, name string) (InfraApp, error) {
+ app, err := r.Find(name)
+ if err != nil {
+ return nil, err
+ }
+ if a, ok := app.(InfraApp); ok {
+ return a, nil
+ } else {
+ return nil, fmt.Errorf("not found")
+ }
+}