appmanager: replace svelte implementation with go based one
diff --git a/core/installer/app.go b/core/installer/app.go
index 4ed1549..f3d465b 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -2,8 +2,11 @@
import (
"embed"
+ "encoding/json"
"fmt"
+ htemplate "html/template"
"log"
+ "strings"
"text/template"
"github.com/Masterminds/sprig/v3"
@@ -24,9 +27,17 @@
Readme *template.Template
}
+func (a App) ConfigSchema() map[string]any {
+ ret := make(map[string]any)
+ if err := json.NewDecoder(strings.NewReader(a.Schema)).Decode(&ret); err != nil {
+ panic(err) // TODO(giolekva): prevalidate
+ }
+ return ret
+}
+
type StoreApp struct {
App
- Icon string
+ Icon htemplate.HTML
ShortDescription string
}
@@ -81,6 +92,7 @@
CreateMetallbConfigEnv(valuesTmpls, tmpls),
CreateEnvManager(valuesTmpls, tmpls),
CreateWelcome(valuesTmpls, tmpls),
+ CreateAppManager(valuesTmpls, tmpls),
CreateIngressPublic(valuesTmpls, tmpls),
CreateCertManager(valuesTmpls, tmpls),
CreateCertManagerWebhookGandi(valuesTmpls, tmpls),
@@ -192,7 +204,7 @@
string(schema),
tmpls.Lookup("vaultwarden.md"),
},
- Icon: "arcticons:bitwarden",
+ Icon: `<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="M35.38 25.63V9.37H24v28.87a34.93 34.93 0 0 0 5.41-3.48q6-4.66 6-9.14Zm4.87-19.5v19.5A11.58 11.58 0 0 1 39.4 30a16.22 16.22 0 0 1-2.11 3.81a23.52 23.52 0 0 1-3 3.24a34.87 34.87 0 0 1-3.22 2.62c-1 .69-2 1.35-3.07 2s-1.82 1-2.27 1.26l-1.08.51a1.53 1.53 0 0 1-1.32 0l-1.08-.51c-.45-.22-1.21-.64-2.27-1.26s-2.09-1.27-3.07-2A34.87 34.87 0 0 1 13.7 37a23.52 23.52 0 0 1-3-3.24A16.22 16.22 0 0 1 8.6 30a11.58 11.58 0 0 1-.85-4.32V6.13A1.64 1.64 0 0 1 9.38 4.5h29.24a1.64 1.64 0 0 1 1.63 1.63Z"/></svg>`,
ShortDescription: "Open source implementation of Bitwarden password manager. Can be used with official client applications.",
}
}
@@ -213,7 +225,7 @@
string(schema),
nil,
},
- "simple-icons:matrix",
+ `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24"><path fill="currentColor" d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033a3.312 3.312 0 0 1 1.117-1.024c.433-.245.936-.365 1.5-.365c.54 0 1.033.107 1.481.314c.448.208.785.582 1.02 1.108c.254-.374.6-.706 1.034-.992c.434-.287.95-.43 1.546-.43c.453 0 .872.056 1.26.167c.388.11.716.286.993.53c.276.245.489.559.646.951c.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66a1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166c-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499a1.946 1.946 0 0 0-.231.696a5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688a1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19c-.111 0-.259.024-.439.074c-.18.051-.36.143-.53.282a1.637 1.637 0 0 0-.439.595c-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>`,
"An open network for secure, decentralised communication",
}
}
@@ -233,7 +245,8 @@
string(schema),
tmpls.Lookup("pihole.md"),
},
- "simple-icons:pihole",
+ // "simple-icons:pihole",
+ `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24"><path fill="currentColor" d="M4.344 0c.238 4.792 3.256 7.056 6.252 7.376c.165-1.692-4.319-5.6-4.319-5.6c-.008-.011.009-.025.019-.014c0 0 4.648 4.01 5.423 5.645c2.762-.15 5.196-1.947 5-4.912c0 0-4.12-.613-5 4.618C11.48 2.753 8.993 0 4.344 0zM12 7.682v.002a3.68 3.68 0 0 0-2.591 1.077L4.94 13.227a3.683 3.683 0 0 0-.86 1.356a3.31 3.31 0 0 0-.237 1.255A3.681 3.681 0 0 0 4.92 18.45l4.464 4.466a3.69 3.69 0 0 0 2.251 1.06l.002.001c.093.01.187.015.28.017l-.1-.008c.06.003.117.009.177.009l-.077-.001L12 24l-.004-.005a3.68 3.68 0 0 0 2.61-1.077l4.469-4.465a3.683 3.683 0 0 0 1.006-1.888l.012-.063a3.682 3.682 0 0 0 .057-.541l.003-.061c0-.017.003-.05.004-.06h-.002a3.683 3.683 0 0 0-1.077-2.607l-4.466-4.468a3.694 3.694 0 0 0-1.564-.927l-.07-.02a3.43 3.43 0 0 0-.946-.133L12 7.682zm3.165 3.357c.023 1.748-1.33 3.078-1.33 4.806c.164 2.227 1.733 3.207 3.266 3.146c-.035.003-.068.007-.104.009c-1.847.135-3.209-1.326-5.002-1.326c-2.23.164-3.21 1.736-3.147 3.27l-.008-.104c-.133-1.847 1.328-3.21 1.328-5.002c-.173-2.32-1.867-3.284-3.46-3.132c.1-.011.203-.021.31-.027c1.847-.133 3.209 1.328 5.002 1.328c2.082-.155 3.074-1.536 3.145-2.968zM4.344 0c.238 4.792 3.256 7.056 6.252 7.376c.165-1.692-4.319-5.6-4.319-5.6c-.008-.011.009-.025.019-.014c0 0 4.648 4.01 5.423 5.645c2.762-.15 5.196-1.947 5-4.912c0 0-4.12-.613-5 4.618C11.48 2.753 8.993 0 4.344 0zM12 7.682v.002a3.68 3.68 0 0 0-2.591 1.077L4.94 13.227a3.683 3.683 0 0 0-.86 1.356a3.31 3.31 0 0 0-.237 1.255A3.681 3.681 0 0 0 4.92 18.45l4.464 4.466a3.69 3.69 0 0 0 2.251 1.06l.002.001c.093.01.187.015.28.017l-.1-.008c.06.003.117.009.177.009l-.077-.001L12 24l-.004-.005a3.68 3.68 0 0 0 2.61-1.077l4.469-4.465a3.683 3.683 0 0 0 1.006-1.888l.012-.063a3.682 3.682 0 0 0 .057-.541l.003-.061c0-.017.003-.05.004-.06h-.002a3.683 3.683 0 0 0-1.077-2.607l-4.466-4.468a3.694 3.694 0 0 0-1.564-.927l-.07-.02a3.43 3.43 0 0 0-.946-.133L12 7.682zm3.165 3.357c.023 1.748-1.33 3.078-1.33 4.806c.164 2.227 1.733 3.207 3.266 3.146c-.035.003-.068.007-.104.009c-1.847.135-3.209-1.326-5.002-1.326c-2.23.164-3.21 1.736-3.147 3.27l-.008-.104c-.133-1.847 1.328-3.21 1.328-5.002c-.173-2.32-1.867-3.284-3.46-3.132c.1-.011.203-.021.31-.027c1.847-.133 3.209 1.328 5.002 1.328c2.082-.155 3.074-1.536 3.145-2.968z"/></svg>`,
"Pi-hole is a Linux network-level advertisement and Internet tracker blocking application which acts as a DNS sinkhole and optionally a DHCP server, intended for use on a private network.",
}
}
@@ -253,7 +266,7 @@
string(schema),
nil,
},
- "arcticons:huawei-email",
+ `<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.",
}
}
@@ -273,7 +286,7 @@
string(schema),
tmpls.Lookup("qbittorrent.md"),
},
- "arcticons:qbittorrent-remote",
+ `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M26.651 22.364a5.034 5.034 0 0 1 5.035-5.035h0a5.034 5.034 0 0 1 5.034 5.035v3.272a5.034 5.034 0 0 1-5.034 5.035h0a5.034 5.034 0 0 1-5.035-5.035m0 5.035V10.533m-5.302 15.103a5.034 5.034 0 0 1-5.035 5.035h0a5.034 5.034 0 0 1-5.034-5.035v-3.272a5.034 5.034 0 0 1 5.034-5.035h0a5.034 5.034 0 0 1 5.035 5.035m0-5.035v20.138"/></svg>`,
"qBittorrent is a cross-platform free and open-source BitTorrent client written in native C++. It relies on Boost, Qt 6 toolkit and the libtorrent-rasterbar library, with an optional search engine written in Python.",
}
}
@@ -293,7 +306,7 @@
string(schema),
nil,
},
- "arcticons:jellyfin",
+ `<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="M24 20c-1.62 0-6.85 9.48-6.06 11.08s11.33 1.59 12.12 0S25.63 20 24 20Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M24 5.5c-4.89 0-20.66 28.58-18.25 33.4s34.13 4.77 36.51 0S28.9 5.5 24 5.5Zm12 29.21c-1.56 3.13-22.35 3.17-23.93 0S20.8 12.83 24 12.83s13.52 18.76 12 21.88Z"/></svg>`,
"Jellyfin is a free and open-source media server and suite of multimedia applications designed to organize, manage, and share digital media files to networked devices.",
}
}
@@ -313,7 +326,7 @@
string(schema),
tmpls.Lookup("rpuppy.md"),
},
- "ph:dog-thin",
+ `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 256 256"><path fill="currentColor" d="M100 140a8 8 0 1 1-8-8a8 8 0 0 1 8 8Zm64 8a8 8 0 1 0-8-8a8 8 0 0 0 8 8Zm64.94-9.11a12.12 12.12 0 0 1-5 1.11a11.83 11.83 0 0 1-9.35-4.62l-2.59-3.29V184a36 36 0 0 1-36 36H80a36 36 0 0 1-36-36v-51.91l-2.53 3.27A11.88 11.88 0 0 1 32.1 140a12.08 12.08 0 0 1-5-1.11a11.82 11.82 0 0 1-6.84-13.14l16.42-88a12 12 0 0 1 14.7-9.43h.16L104.58 44h46.84l53.08-15.6h.16a12 12 0 0 1 14.7 9.43l16.42 88a11.81 11.81 0 0 1-6.84 13.06ZM97.25 50.18L49.34 36.1a4.18 4.18 0 0 0-.92-.1a4 4 0 0 0-3.92 3.26l-16.42 88a4 4 0 0 0 7.08 3.22ZM204 121.75L150 52h-44l-54 69.75V184a28 28 0 0 0 28 28h44v-18.34l-14.83-14.83a4 4 0 0 1 5.66-5.66L128 186.34l13.17-13.17a4 4 0 0 1 5.66 5.66L132 193.66V212h44a28 28 0 0 0 28-28Zm23.92 5.48l-16.42-88a4 4 0 0 0-4.84-3.16l-47.91 14.11l62.11 80.28a4 4 0 0 0 7.06-3.23Z"/></svg>`,
"Delights users with randomly generate puppy pictures. Can be configured to be reachable only from private network or publicly.",
}
}
@@ -398,6 +411,22 @@
}
}
+func CreateAppManager(fs embed.FS, tmpls *template.Template) App {
+ schema, err := fs.ReadFile("values-tmpl/appmanager.jsonschema")
+ if err != nil {
+ panic(err)
+ }
+ return App{
+ "app-manager",
+ []string{"core-appmanager"},
+ []*template.Template{
+ tmpls.Lookup("appmanager.yaml"),
+ },
+ string(schema),
+ tmpls.Lookup("appmanager.md"),
+ }
+}
+
func CreateIngressPublic(fs embed.FS, tmpls *template.Template) App {
schema, err := fs.ReadFile("values-tmpl/ingress-public.jsonschema")
if err != nil {
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index c0a619f..84b87a1 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -1,6 +1,7 @@
package installer
import (
+ "fmt"
"io/ioutil"
"path/filepath"
@@ -31,8 +32,8 @@
return m.repoIO.FindAllInstances(appDir, name)
}
-func (m *AppManager) FindInstance(name string) (AppConfig, error) {
- return m.repoIO.FindInstance(appDir, name)
+func (m *AppManager) FindInstance(id string) (AppConfig, error) {
+ return m.repoIO.FindInstance(appDir, id)
}
func (m *AppManager) AppConfig(name string) (AppConfig, error) {
@@ -75,19 +76,27 @@
if err != nil {
return err
}
- all := map[string]any{
- "Global": globalConfig.Values,
- "Values": config,
+ derivedValues, err := deriveValues(config, app.ConfigSchema(), CreateNetworks(globalConfig))
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+ derived := Derived{
+ Global: globalConfig.Values,
+ Values: derivedValues,
}
if len(namespaces) > 0 {
- all["Release"] = map[string]any{
- "Namespace": namespaces[0],
- }
+ derived.Release.Namespace = namespaces[0]
}
- return m.repoIO.InstallApp(
+ fmt.Printf("%+v\n", derived)
+ err = m.repoIO.InstallApp(
app,
filepath.Join(appDir, app.Name+suffix),
- all)
+ config,
+ derived,
+ )
+ fmt.Println(err)
+ return err
}
func (m *AppManager) Update(app App, instanceId string, config map[string]any) error {
@@ -104,10 +113,37 @@
if err != nil {
return err
}
- all := map[string]any{
- "Global": globalConfig.Values,
- "Values": config,
- "Release": appConfig.Config["Release"],
+ derivedValues, err := deriveValues(config, app.ConfigSchema(), CreateNetworks(globalConfig))
+ if err != nil {
+ return err
}
- return m.repoIO.InstallApp(app, instanceDir, all)
+ derived := Derived{
+ Global: globalConfig.Values,
+ Release: appConfig.Derived.Release,
+ Values: derivedValues,
+ }
+ return m.repoIO.InstallApp(app, instanceDir, config, derived)
+}
+
+func (m *AppManager) Remove(instanceId string) error {
+ // if err := m.repoIO.Fetch(); err != nil {
+ // return err
+ // }
+ return m.repoIO.RemoveApp(filepath.Join(appDir, instanceId))
+}
+
+func CreateNetworks(global Config) []Network {
+ return []Network{
+ {
+ Name: "Public",
+ IngressClass: fmt.Sprintf("%s-ingress-public", global.Values.PCloudEnvName),
+ CertificateIssuer: fmt.Sprintf("%s-public", global.Values.Id),
+ Domain: global.Values.Domain,
+ },
+ {
+ Name: "Private",
+ IngressClass: fmt.Sprintf("%s-ingress-private", global.Values.Id),
+ Domain: global.Values.PrivateDomain,
+ },
+ }
}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 90f2c4b..6027204 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -1,32 +1,25 @@
package main
import (
- "bytes"
- "encoding/json"
- "fmt"
- "io/ioutil"
- "log"
"net"
- "net/http"
- "net/http/httputil"
- "net/url"
"os"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
- "github.com/labstack/echo/v4"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/welcome"
)
var appManagerFlags struct {
- sshKey string
- repoAddr string
- port int
+ sshKey string
+ repoAddr string
+ port int
+ webAppAddr string
}
func appManagerCmd() *cobra.Command {
@@ -52,6 +45,12 @@
8080,
"",
)
+ cmd.Flags().StringVar(
+ &appManagerFlags.webAppAddr,
+ "web-app-addr",
+ "",
+ "",
+ )
return cmd
}
@@ -80,261 +79,16 @@
return err
}
r := installer.NewInMemoryAppRepository[installer.StoreApp](installer.CreateStoreApps())
- s := &server{
- port: appManagerFlags.port,
- m: m,
- r: r,
- }
- s.start()
+ s := welcome.NewAppManagerServer(
+ appManagerFlags.port,
+ appManagerFlags.webAppAddr,
+ m,
+ r,
+ )
+ s.Start()
return nil
}
-type server struct {
- port int
- m *installer.AppManager
- r installer.AppRepository[installer.StoreApp]
-}
-
-func (s *server) start() {
- e := echo.New()
- e.GET("/api/app-repo", s.handleAppRepo)
- e.POST("/api/app/:slug/render", s.handleAppRender)
- e.POST("/api/app/:slug/install", s.handleAppInstall)
- e.GET("/api/app/:slug", s.handleApp)
- e.GET("/api/instance/:slug", s.handleInstance)
- e.POST("/api/instance/:slug/update", s.handleAppUpdate)
- webapp, err := url.Parse("http://localhost:5173")
- if err != nil {
- panic(err)
- }
- // var f ff
- e.Any("/*", echo.WrapHandler(httputil.NewSingleHostReverseProxy(webapp)))
- // e.Any("/*", echo.WrapHandler(&f))
- fmt.Printf("Starting HTTP server on port: %d\n", s.port)
- log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
-}
-
-type app struct {
- Name string `json:"name"`
- Icon string `json:"icon"`
- ShortDescription string `json:"shortDescription"`
- Slug string `json:"slug"`
- Schema string `json:"schema"`
- Instances []installer.AppConfig `json:"instances,omitempty"`
-}
-
-func (s *server) handleAppRepo(c echo.Context) error {
- all, err := s.r.GetAll()
- if err != nil {
- return err
- }
- resp := make([]app, len(all))
- for i, a := range all {
- resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
- }
- return c.JSON(http.StatusOK, resp)
-}
-
-func (s *server) handleApp(c echo.Context) error {
- slug := c.Param("slug")
- a, err := s.r.Find(slug)
- if err != nil {
- return err
- }
- instances, err := s.m.FindAllInstances(slug)
- if err != nil {
- return err
- }
- return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
-}
-
-func (s *server) handleInstance(c echo.Context) error {
- slug := c.Param("slug")
- instance, err := s.m.FindInstance(slug)
- if err != nil {
- return err
- }
- values, ok := instance.Config["Values"].(map[string]any)
- if !ok {
- return fmt.Errorf("Expected map")
- }
- for k, v := range values {
- if k == "Network" {
- n, ok := v.(map[string]any)
- if !ok {
- return fmt.Errorf("Expected map")
- }
- values["Network"], ok = n["Name"]
- if !ok {
- return fmt.Errorf("Missing Name")
- }
- break
- }
-
- }
- a, err := s.r.Find(instance.Id)
- if err != nil {
- return err
- }
- return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
-}
-
-type file struct {
- Name string `json:"name"`
- Contents string `json:"contents"`
-}
-
-type rendered struct {
- Readme string `json:"readme"`
- Files []file `json:"files"`
-}
-
-type network struct {
- Name string
- IngressClass string
- CertificateIssuer string
- Domain string
-}
-
-func createNetworks(global installer.Config) []network {
- return []network{
- {
- Name: "Public",
- IngressClass: fmt.Sprintf("%s-ingress-public", global.Values.PCloudEnvName),
- CertificateIssuer: fmt.Sprintf("%s-public", global.Values.Id),
- Domain: global.Values.Domain,
- },
- {
- Name: "Private",
- IngressClass: fmt.Sprintf("%s-ingress-private", global.Values.Id),
- Domain: global.Values.PrivateDomain,
- },
- }
-}
-
-func (s *server) handleAppRender(c echo.Context) error {
- slug := c.Param("slug")
- contents, err := ioutil.ReadAll(c.Request().Body)
- if err != nil {
- return err
- }
- global, err := s.m.Config()
- if err != nil {
- return err
- }
- var values map[string]any
- if err := json.Unmarshal(contents, &values); err != nil {
- return err
- }
- if network, ok := values["Network"]; ok {
- for _, n := range createNetworks(global) {
- if n.Name == network { // TODO(giolekva): handle not found
- values["Network"] = n
- }
- }
- }
- all := map[string]any{
- "Global": global.Values,
- "Values": values,
- }
- a, err := s.r.Find(slug)
- if err != nil {
- return err
- }
- var readme bytes.Buffer
- if err := a.Readme.Execute(&readme, all); err != nil {
- return err
- }
- var resp rendered
- resp.Readme = readme.String()
- for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
- var f bytes.Buffer
- if err := tmpl.Execute(&f, all); err != nil {
- fmt.Printf("%+v\n", all)
- fmt.Println(err.Error())
- return err
- } else {
- resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
- }
- }
- out, err := json.Marshal(resp)
- if err != nil {
- return err
- }
- if _, err := c.Response().Writer.Write(out); err != nil {
- return err
- }
- return nil
-}
-
-func (s *server) handleAppInstall(c echo.Context) error {
- slug := c.Param("slug")
- contents, err := ioutil.ReadAll(c.Request().Body)
- if err != nil {
- return err
- }
- var values map[string]any
- if err := json.Unmarshal(contents, &values); err != nil {
- return err
- }
- a, err := s.r.Find(slug)
- if err != nil {
- return err
- }
- config, err := s.m.Config()
- if err != nil {
- return err
- }
- if network, ok := values["Network"]; ok {
- for _, n := range createNetworks(config) {
- if n.Name == network { // TODO(giolekva): handle not found
- values["Network"] = n
- }
- }
- }
- nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
- suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
- if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
- return err
- }
- return c.String(http.StatusOK, "Installed")
-}
-
-func (s *server) handleAppUpdate(c echo.Context) error {
- slug := c.Param("slug")
- appConfig, err := s.m.AppConfig(slug)
- if err != nil {
- return err
- }
- contents, err := ioutil.ReadAll(c.Request().Body)
- if err != nil {
- return err
- }
- var values map[string]any
- if err := json.Unmarshal(contents, &values); err != nil {
- return err
- }
- a, err := s.r.Find(appConfig.Id)
- if err != nil {
- return err
- }
- config, err := s.m.Config()
- if err != nil {
- return err
- }
- if network, ok := values["Network"]; ok {
- for _, n := range createNetworks(config) {
- if n.Name == network { // TODO(giolekva): handle not found
- values["Network"] = n
- }
- }
- }
- if err := s.m.Update(a.App, slug, values); err != nil {
- return err
- }
- return c.String(http.StatusOK, "Installed")
-}
-
func cloneRepo(address string, signer ssh.Signer) (*git.Repository, error) {
return git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
URL: address,
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index d007ed2..805a5a0 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -110,8 +110,8 @@
return err
}
// TODO(giolekva): commit this to the repo above
- global := map[string]any{
- "PCloudEnvName": bootstrapFlags.pcloudEnvName,
+ global := installer.Values{
+ PCloudEnvName: bootstrapFlags.pcloudEnvName,
}
nsCreator, err := newNSCreator()
if err != nil {
@@ -376,7 +376,7 @@
return nil
}
-func installInfrastructureServices(repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global map[string]any) error {
+func installInfrastructureServices(repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
install := func(name string) error {
app, err := appRepo.Find(name)
@@ -395,15 +395,13 @@
return err
}
}
- values := map[string]any{
- "Global": global,
+ derived := installer.Derived{
+ Global: global,
}
if len(namespaces) > 0 {
- values["Release"] = map[string]any{
- "Namespace": namespaces[0],
- }
+ derived.Release.Namespace = namespaces[0]
}
- return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), values)
+ return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), map[string]any{}, derived)
}
appsToInstall := []string{
"resource-renderer-controller",
@@ -466,7 +464,7 @@
return nil
}
-func installEnvManager(ss *soft.Client, repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global map[string]any) error {
+func installEnvManager(ss *soft.Client, repo installer.RepoIO, nsGen installer.NamespaceGenerator, nsCreator installer.NamespaceCreator, global installer.Values) error {
keys, err := installer.NewSSHKeyPair()
if err != nil {
return err
@@ -495,16 +493,17 @@
return err
}
}
- return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), map[string]any{
- "Global": global,
- "Values": map[string]any{
+ derived := installer.Derived{
+ Global: global,
+ Values: map[string]any{
"RepoIP": bootstrapFlags.softServeIP,
"SSHPrivateKey": keys.Private,
},
- "Release": map[string]any{
- "Namespace": namespaces[0],
- },
- })
+ }
+ if len(namespaces) > 0 {
+ derived.Release.Namespace = namespaces[0]
+ }
+ return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
}
func createActionConfig(namespace string) (*action.Configuration, error) {
diff --git a/core/installer/kustomization.go b/core/installer/kustomization.go
index 93f806a..1db9614 100644
--- a/core/installer/kustomization.go
+++ b/core/installer/kustomization.go
@@ -53,3 +53,14 @@
}
}
}
+
+func (k *Kustomization) RemoveResources(names ...string) {
+ for _, name := range names {
+ for i, r := range k.Resources {
+ if r == name {
+ k.Resources = slices.Delete(k.Resources, i, i+1)
+ break
+ }
+ }
+ }
+}
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index b01cd82..0582594 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -31,9 +31,10 @@
Writer(path string) (io.WriteCloser, error)
CreateDir(path string) error
RemoveDir(path string) error
- InstallApp(app App, path string, values map[string]any) error
- FindAllInstances(root string, name string) ([]AppConfig, error)
- FindInstance(root string, name string) (AppConfig, error)
+ InstallApp(app App, path string, values map[string]any, derived Derived) error
+ RemoveApp(path string) error
+ FindAllInstances(root string, appId string) ([]AppConfig, error)
+ FindInstance(root string, id string) (AppConfig, error)
}
type repoIO struct {
@@ -181,12 +182,24 @@
return err
}
-type AppConfig struct {
- Id string `json:"id"`
- Config map[string]any `json:"config"`
+type Release struct {
+ Namespace string `json:"Namespace"`
}
-func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any) error {
+type Derived struct {
+ Release Release `json:"Release"`
+ Global Values `json:"Global"`
+ Values map[string]any `json:"Values"`
+}
+
+type AppConfig struct {
+ Id string `json:"id"`
+ AppId string `json:"appId"`
+ Config map[string]any `json:"config"`
+ Derived Derived `json:"derived"`
+}
+
+func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any, derived Derived) error {
if !filepath.IsAbs(appRootDir) {
return fmt.Errorf("Expected absolute path: %s", appRootDir)
}
@@ -217,8 +230,9 @@
return err
}
cfg := AppConfig{
- Id: app.Name,
- Config: values,
+ AppId: app.Name,
+ Config: values,
+ Derived: derived,
}
if err := r.WriteYaml(path.Join(appRootDir, configFileName), cfg); err != nil {
return err
@@ -233,7 +247,7 @@
return err
}
defer out.Close()
- if err := t.Execute(out, values); err != nil {
+ if err := t.Execute(out, derived); err != nil {
return err
}
}
@@ -244,6 +258,19 @@
return r.CommitAndPush(fmt.Sprintf("install: %s", app.Name))
}
+func (r *repoIO) RemoveApp(appRootDir string) error {
+ r.RemoveDir(appRootDir)
+ parent, child := filepath.Split(appRootDir)
+ kustPath := filepath.Join(parent, "kustomization.yaml")
+ kust, err := r.ReadKustomization(kustPath)
+ if err != nil {
+ return err
+ }
+ kust.RemoveResources(child)
+ r.WriteKustomization(kustPath, *kust)
+ return r.CommitAndPush(fmt.Sprintf("uninstall: %s", child))
+}
+
func (r *repoIO) FindAllInstances(root string, name string) ([]AppConfig, error) {
if !filepath.IsAbs(root) {
return nil, fmt.Errorf("Expected absolute path: %s", root)
@@ -258,14 +285,15 @@
if err != nil {
return nil, err
}
- if cfg.Id == name {
+ cfg.Id = app
+ if cfg.AppId == name {
ret = append(ret, cfg)
}
}
return ret, nil
}
-func (r *repoIO) FindInstance(root string, name string) (AppConfig, error) {
+func (r *repoIO) FindInstance(root string, id string) (AppConfig, error) {
if !filepath.IsAbs(root) {
return AppConfig{}, fmt.Errorf("Expected absolute path: %s", root)
}
@@ -274,8 +302,13 @@
return AppConfig{}, err
}
for _, app := range kust.Resources {
- if app == name {
- return r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+ if app == id {
+ cfg, err := r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+ if err != nil {
+ return AppConfig{}, err
+ }
+ cfg.Id = id
+ return cfg, nil
}
}
return AppConfig{}, nil
@@ -301,3 +334,71 @@
return yaml.UnmarshalStrict(contents, o)
}
}
+
+func deriveValues(values any, schema map[string]any, networks []Network) (map[string]any, error) {
+ ret := make(map[string]any)
+ for k, v := range values.(map[string]any) { // TODO(giolekva): validate
+ def, err := fieldSchema(schema, k)
+ if err != nil {
+ return nil, err
+ }
+ t, ok := def["type"]
+ if !ok {
+ return nil, fmt.Errorf("Found field with undefined type: %s", k)
+ }
+ if t == "string" {
+ role, ok := def["role"]
+ if ok && role == "network" {
+ n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
+ if err != nil {
+ return nil, err
+ }
+ ret[k] = n
+ } else {
+ ret[k] = v
+ }
+ } else {
+ ret[k], err = deriveValues(v, def, networks)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ return ret, nil
+}
+
+func findNetwork(networks []Network, name string) (Network, error) {
+ for _, n := range networks {
+ if n.Name == name {
+ return n, nil
+ }
+ }
+ return Network{}, fmt.Errorf("Network not found: %s", name)
+}
+
+func fieldSchema(schema map[string]any, key string) (map[string]any, error) {
+ properties, ok := schema["properties"]
+ if !ok {
+ return nil, fmt.Errorf("Properties not found")
+ }
+ propMap, ok := properties.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("Expected properties to be map")
+ }
+ def, ok := propMap[key]
+ if !ok {
+ return nil, fmt.Errorf("Unknown field: %s", key)
+ }
+ ret, ok := def.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("Invalid schema")
+ }
+ return ret, nil
+}
+
+type Network struct {
+ Name string
+ IngressClass string
+ CertificateIssuer string
+ Domain string
+}
diff --git a/core/installer/values-tmpl/appmanager.yaml b/core/installer/values-tmpl/appmanager.yaml
index 2ca79b9..c630a76 100644
--- a/core/installer/values-tmpl/appmanager.yaml
+++ b/core/installer/values-tmpl/appmanager.yaml
@@ -16,7 +16,7 @@
repoAddr: {{ .Values.RepoAddr }}
sshPrivateKey: {{ .Values.SSHPrivateKey | b64enc }}
ingress:
- className: {{ .Global.Id }}-ingress-public
+ className: {{ .Global.Id }}-ingress-private
domain: apps.{{ .Global.PrivateDomain }}
certificateIssuer: ""
clusterRoleName: {{ .Global.Id }}-appmanager
diff --git a/core/installer/values-tmpl/certificate-issuer-private.jsonschema b/core/installer/values-tmpl/certificate-issuer-private.jsonschema
index 46ae9c3..cb7e4dc 100644
--- a/core/installer/values-tmpl/certificate-issuer-private.jsonschema
+++ b/core/installer/values-tmpl/certificate-issuer-private.jsonschema
@@ -1,7 +1,7 @@
{
"type": "object",
"properties": {
- "GandiAPIToken": { "type": "string" },
+ "GandiAPIToken": { "type": "string" }
},
"additionalProperties": false
}
diff --git a/core/installer/values-tmpl/core-auth.yaml b/core/installer/values-tmpl/core-auth.yaml
index 7a1b7a1..986c13d 100644
--- a/core/installer/values-tmpl/core-auth.yaml
+++ b/core/installer/values-tmpl/core-auth.yaml
@@ -75,7 +75,7 @@
enabled: false
config:
version: v0.7.1-alpha.1
- dsn: postgres://kratos:kratos@postgres.{{ .Globa.Id }}-core-auth.svc:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
+ dsn: postgres://kratos:kratos@postgres.{{ .Global.Id }}-core-auth.svc:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
serve:
public:
base_url: https://accounts.{{ .Global.Domain }}
@@ -246,7 +246,7 @@
enabled: true
config:
version: v1.10.6
- dsn: postgres://hydra:hydra@postgres.{{ .Globa.Id }}-core-auth.svc:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
+ dsn: postgres://hydra:hydra@postgres.{{ .Global.Id }}-core-auth.svc:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4
serve:
cookies:
same_site_mode: None
@@ -298,4 +298,4 @@
ingressClassName: {{ .Global.PCloudEnvName }}-ingress-public
domain: {{ .Global.Domain }}
internalDomain: p.{{ .Global.Domain }}
- hydra: hydra-admin.{{ .Globa.Id }}-core-auth.svc.cluster.local
+ hydra: hydra-admin.{{ .Global.Id }}-core-auth.svc.cluster.local
diff --git a/core/installer/values-tmpl/metallb-config-env.jsonschema b/core/installer/values-tmpl/metallb-config-env.jsonschema
index f42d895..6eca710 100644
--- a/core/installer/values-tmpl/metallb-config-env.jsonschema
+++ b/core/installer/values-tmpl/metallb-config-env.jsonschema
@@ -1,6 +1,17 @@
{
"type": "object",
"properties": {
+ "IngressPrivate": { "type": "string" },
+ "Headscale": { "type": "string" },
+ "SoftServe": { "type": "string" },
+ "Rest": {
+ "type": "object",
+ "properties": {
+ "From": { "type": "string" },
+ "To": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
},
"additionalProperties": false
}
diff --git a/core/installer/values-tmpl/rpuppy.jsonschema b/core/installer/values-tmpl/rpuppy.jsonschema
index e21b570..d6e0d0d 100644
--- a/core/installer/values-tmpl/rpuppy.jsonschema
+++ b/core/installer/values-tmpl/rpuppy.jsonschema
@@ -1,16 +1,7 @@
{
- "definitions": {
- "network": {
- "type": "object",
- "properties": {
- "name": { "type": "string" },
- "domain": { "type": "string" }
- }
- }
- },
"type": "object",
"properties": {
- "Network": { "$ref": "#/definitions/network", "default": "Public" },
+ "Network": { "type": "string", "default": "Public", "role": "network" },
"Subdomain": { "type": "string", "default": "woof" }
},
"additionalProperties": false
diff --git a/core/installer/values-tmpl/rpuppy.md b/core/installer/values-tmpl/rpuppy.md
index 2633167..ddd992f 100644
--- a/core/installer/values-tmpl/rpuppy.md
+++ b/core/installer/values-tmpl/rpuppy.md
@@ -1 +1 @@
-rpuppy application will be installed on public network and be accessible to any user on https://{{ .Values.Subdomain }}.{{ .Values.Network.Domain }}
+rpuppy application will be installed on {{ .Values.Network.Name }} network and be accessible to any user on https://{{ .Values.Subdomain }}.{{ .Values.Network.Domain }}
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
new file mode 100644
index 0000000..df7501b
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -0,0 +1,214 @@
+{{ define "schema-form" }}
+ {{ $readonly := .ReadOnly }}
+ {{ $networks := .AvailableNetworks }}
+ {{ $data := .Data }}
+ {{ range $name, $schema := .Schema.properties }}
+ {{ if eq $schema.type "string" }}
+ <label for="{{ $name }}">
+ <span>{{ $name }}</span>
+ </label>
+ {{ if eq (index $schema "role") "network" }}
+ <select oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
+ {{ if not $readonly }}<option disabled selected value> -- select an option -- </option>{{ end }}
+ {{ range $networks }}
+ <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
+ {{ end }}
+ </select>
+ {{ else }}
+ <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}"/>
+ {{ end }}
+ {{ end }}
+ {{ end }}
+{{ end }}
+
+{{ define "main" }}
+{{ $instance := .Instance }}
+<h1>{{ .App.Icon }}{{ .App.Name }}</h1>
+<pre id="readme"></pre>
+
+{{ $schema := .App.ConfigSchema }}
+{{ $networks := .AvailableNetworks }}
+
+<form id="config-form">
+ {{ if $instance }}
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" $instance.Config) }}
+ {{ else }}
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" (dict)) }}
+ {{ end }}
+ {{ if $instance }}
+ <div class="grid">
+ <button type="submit" id="submit" name="update">Update</button>
+ <button type="submit" id="uninstall" name="remove">Uninstall</button>
+ </div>
+ {{ else }}
+ <button type="submit" id="submit">{{ if $instance }}Update{{ else }}Install{{ end }}</button>
+ {{ end }}
+</form>
+
+{{ range .Instances }}
+ {{ if or (not $instance) (ne $instance.Id .Id)}}
+ <details>
+ <summary>{{ .Id }}</summary>
+ {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" .Config ) }}
+ <a href="/instance/{{ .Id }}" role="button" class="secondary">View</a>
+ </details>
+ {{ end }}
+{{ end }}
+
+
+<div id="toast-success" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> {{ if $instance }}Update succeeded{{ else }}Install succeeded{{ end}}
+</div>
+
+<div id="toast-failure" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> {{ if $instance }}Update failed{{ else}}Install failed{{ end }}
+</div>
+
+<div id="toast-uninstall-success" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> Uninstalled application
+</div>
+
+<div id="toast-uninstall-failure" class="toast hidden">
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> Failed to uninstall application
+</div>
+
+<style>
+ pre {
+ white-space: pre-wrap; /* Since CSS 2.1 */
+ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
+ white-space: -pre-wrap; /* Opera 4-6 */
+ white-space: -o-pre-wrap; /* Opera 7 */
+ word-wrap: break-word; /* Internet Explorer 5.5+ */
+ background-color: transparent;
+ }
+
+ .hidden {
+ visibility: hidden;
+ }
+
+ .toast {
+ position: fixed;
+ z-index: 999;
+ bottom: 10px;
+ }
+</style>
+
+<script>
+ let readme = "";
+ let config = {{ if $instance }}JSON.parse({{ toJson $instance.Config }}){{ else }}{}{{ end }};
+
+ function valueChanged(name, value) {
+ config[name] = value;
+ renderReadme();
+ }
+
+ async function renderReadme() {
+ const resp = await fetch("/api/app/{{ .App.Name }}/render", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(config),
+ });
+ const app = await resp.json();
+ document.getElementById("readme").innerHTML = app.readme;
+ }
+
+ {{ if $instance }}renderReadme();{{ end }}
+
+ function disableForm() {
+ document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
+ document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
+ document.querySelectorAll("#config-form button").forEach((i) => i.setAttribute("disabled", ""));
+ }
+
+ function enableForm() {
+ document.querySelectorAll("[aria-busy]").forEach((i) => i.removeAttribute("aria-busy"));
+ document.querySelectorAll("#config-form input").forEach((i) => i.removeAttribute("disabled"));
+ document.querySelectorAll("#config-form select").forEach((i) => i.removeAttribute("disabled"));
+ document.querySelectorAll("#config-form button").forEach((i) => i.removeAttribute("disabled"));
+ }
+
+ function installStarted() {
+ const submit = document.getElementById("submit");
+ submit.setAttribute("aria-busy", true);
+ submit.innerHTML = {{ if $instance }}"Updating ..."{{ else }}"Installing ..."{{ end }};
+ disableForm();
+ }
+
+ function uninstallStarted() {
+ const submit = document.getElementById("uninstall");
+ submit.setAttribute("aria-busy", true);
+ submit.innerHTML = "Uninstalling ...";
+ disableForm();
+ }
+
+ function actionFinished(toast) {
+ enableForm();
+ toast.classList.remove("hidden");
+ setTimeout(
+ () => toast.classList.add("hidden"),
+ 2000,
+ );
+ }
+
+ function installSucceeded() {
+ actionFinished(document.getElementById("toast-success"));
+ }
+
+ function installFailed() {
+ actionFinished(document.getElementById("toast-failure"));
+ }
+
+ function uninstallSucceeded() {
+ actionFinished(document.getElementById("toast-uninstall-success"));
+ }
+
+ function uninstallFailed() {
+ actionFinished(document.getElementById("toast-uninstall-failure"));
+ }
+
+ const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Name }}/install"{{ end }};
+
+ async function install() {
+ installStarted();
+ const resp = await fetch(submitAddr, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(config),
+ });
+ if (resp.status === 200) {
+ installSucceeded();
+ } else {
+ installFailed();
+ }
+ }
+
+ async function uninstall() {
+ {{ if $instance }}
+ uninstallStarted();
+ const resp = await fetch("/api/instance/{{ $instance.Id }}/remove", {
+ method: "POST",
+ });
+ if (resp.status === 200) {
+ uninstallSucceeded();
+ } else {
+ uninstallFailed();
+ }
+ {{ end }}
+ }
+
+ document.getElementById("config-form").addEventListener("submit", (event) => {
+ event.preventDefault();
+ if (event.submitter.id === "submit") {
+ install();
+ } if (event.submitter.id === "uninstall") {
+ uninstall();
+ }
+ });
+</script>
+{{ end }}
diff --git a/core/installer/welcome/appmanager-tmpl/base.html b/core/installer/welcome/appmanager-tmpl/base.html
new file mode 100644
index 0000000..9e4bb49
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/base.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="/static/pico.min.css">
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <style>
+ #main {
+ margin-top: 10px;
+ }
+
+ @media screen and (min-width:801px) {
+ #main {
+ width: 600px;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div id="main" class="container">{{ block "main" . }}CHILD MUST OVERRIDE THIS{{ end }}</div>
+ </body>
+</html>
diff --git a/core/installer/welcome/appmanager-tmpl/index.html b/core/installer/welcome/appmanager-tmpl/index.html
new file mode 100644
index 0000000..02150ff
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/index.html
@@ -0,0 +1,50 @@
+{{ define "main" }}
+<form>
+ <input type="search" placeholder="Search" />
+</form>
+
+<aside>
+ <nav>
+ <ul>
+ {{ range . }}
+ <li>
+ <article>
+ <div>
+ <a href="/app/{{ .Slug }}" class="logo">
+ {{ .Icon }}
+ </a>
+ </div>
+ <div>
+ <a href="/app/{{ .Slug }}">{{ .Name }}</a>
+ {{ .ShortDescription }}
+ </div>
+ </article>
+ </li>
+ {{ end }}
+ </ul>
+ </nav>
+</aside>
+
+<style>
+ article {
+ margin: 0.3em;
+ margin-bottom: 0.3em;
+
+ display: flex;
+ flex-direction: row;
+ }
+
+ .logo {
+ display: table-cell;
+ vertical-align: middle;
+ }
+ nav li {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ input[type="search"] {
+ margin-bottom: 0;
+ }
+</style>
+{{ end }}
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
new file mode 100644
index 0000000..242faed
--- /dev/null
+++ b/core/installer/welcome/appmanager.go
@@ -0,0 +1,370 @@
+package welcome
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/ioutil"
+ "log"
+ "net/http"
+ // "net/http/httputil"
+ // "net/url"
+
+ "github.com/Masterminds/sprig/v3"
+ "github.com/labstack/echo/v4"
+
+ "github.com/giolekva/pcloud/core/installer"
+)
+
+//go:embed appmanager-tmpl
+var mgrTmpl embed.FS
+
+//go:embed appmanager-tmpl/base.html
+var baseHtmlTmpl string
+
+//go:embed appmanager-tmpl/app.html
+var appHtmlTmpl string
+
+type AppManagerServer struct {
+ port int
+ webAppAddr string
+ 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() {
+ e := echo.New()
+ e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
+ e.GET("/api/app-repo", s.handleAppRepo)
+ e.POST("/api/app/:slug/render", s.handleAppRender)
+ e.POST("/api/app/:slug/install", s.handleAppInstall)
+ e.GET("/api/app/:slug", s.handleApp)
+ e.GET("/api/instance/:slug", s.handleInstance)
+ e.POST("/api/instance/:slug/update", s.handleAppUpdate)
+ e.POST("/api/instance/:slug/remove", s.handleAppRemove)
+ e.GET("/", s.handleIndex)
+ 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)))
+}
+
+type app struct {
+ Name string `json:"name"`
+ Icon template.HTML `json:"icon"`
+ ShortDescription string `json:"shortDescription"`
+ Slug string `json:"slug"`
+ Schema string `json:"schema"`
+ Instances []installer.AppConfig `json:"instances,omitempty"`
+}
+
+func (s *AppManagerServer) handleAppRepo(c echo.Context) error {
+ all, err := s.r.GetAll()
+ if err != nil {
+ return err
+ }
+ resp := make([]app, len(all))
+ for i, a := range all {
+ resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+ }
+ return c.JSON(http.StatusOK, resp)
+}
+
+func (s *AppManagerServer) handleApp(c echo.Context) error {
+ slug := c.Param("slug")
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(slug)
+ if err != nil {
+ return err
+ }
+ for _, instance := range instances {
+ values, ok := instance.Config["Values"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ for k, v := range values {
+ if k == "Network" {
+ n, ok := v.(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ values["Network"], ok = n["Name"]
+ if !ok {
+ return fmt.Errorf("Missing Name")
+ }
+ break
+ }
+ }
+ }
+ return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
+}
+
+func (s *AppManagerServer) handleInstance(c echo.Context) error {
+ slug := c.Param("slug")
+ instance, err := s.m.FindInstance(slug)
+ if err != nil {
+ return err
+ }
+ values, ok := instance.Config["Values"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ for k, v := range values {
+ if k == "Network" {
+ n, ok := v.(map[string]any)
+ if !ok {
+ return fmt.Errorf("Expected map")
+ }
+ values["Network"], ok = n["Name"]
+ if !ok {
+ return fmt.Errorf("Missing Name")
+ }
+ break
+ }
+ }
+ a, err := s.r.Find(instance.AppId)
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
+}
+
+type file struct {
+ Name string `json:"name"`
+ Contents string `json:"contents"`
+}
+
+type rendered struct {
+ Readme string `json:"readme"`
+ Files []file `json:"files"`
+}
+
+func (s *AppManagerServer) handleAppRender(c echo.Context) error {
+ slug := c.Param("slug")
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ if network, ok := values["Network"]; ok {
+ for _, n := range installer.CreateNetworks(global) {
+ if n.Name == network { // TODO(giolekva): handle not found
+ values["Network"] = n
+ }
+ }
+ }
+ all := map[string]any{
+ "Global": global.Values,
+ "Values": values,
+ }
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ var readme bytes.Buffer
+ if err := a.Readme.Execute(&readme, all); err != nil {
+ return err
+ }
+ var resp rendered
+ resp.Readme = readme.String()
+ for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
+ var f bytes.Buffer
+ if err := tmpl.Execute(&f, all); err != nil {
+ fmt.Printf("%+v\n", all)
+ fmt.Println(err.Error())
+ return err
+ } else {
+ resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
+ }
+ }
+ out, err := json.Marshal(resp)
+ if err != nil {
+ return err
+ }
+ if _, err := c.Response().Writer.Write(out); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
+ slug := c.Param("slug")
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ fmt.Println(values)
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ config, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
+ suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+ if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppUpdate(c echo.Context) error {
+ slug := c.Param("slug")
+ appConfig, err := s.m.AppConfig(slug)
+ if err != nil {
+ return err
+ }
+ contents, err := ioutil.ReadAll(c.Request().Body)
+ if err != nil {
+ return err
+ }
+ var values map[string]any
+ if err := json.Unmarshal(contents, &values); err != nil {
+ return err
+ }
+ a, err := s.r.Find(appConfig.AppId)
+ if err != nil {
+ return err
+ }
+ if err := s.m.Update(a.App, slug, values); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppRemove(c echo.Context) error {
+ slug := c.Param("slug")
+ if err := s.m.Remove(slug); err != nil {
+ return err
+ }
+ return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleIndex(c echo.Context) error {
+ tmpl, err := template.ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/index.html")
+ if err != nil {
+ return err
+ }
+ all, err := s.r.GetAll()
+ if err != nil {
+ return err
+ }
+ resp := make([]app, len(all))
+ for i, a := range all {
+ resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+ }
+ return tmpl.Execute(c.Response(), resp)
+}
+
+type appContext[T any] struct {
+ App *T
+ Instance *installer.AppConfig
+ Instances []installer.AppConfig
+ AvailableNetworks []installer.Network
+}
+
+func (s *AppManagerServer) handleAppUI(c echo.Context) error {
+ baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+ if err != nil {
+ return err
+ }
+ appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ slug := c.Param("slug")
+ a, err := s.r.Find(slug)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(slug)
+ if err != nil {
+ return err
+ }
+ err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+ App: a,
+ Instances: instances,
+ AvailableNetworks: installer.CreateNetworks(global),
+ })
+ fmt.Println(err)
+ return err
+}
+
+func (s *AppManagerServer) handleInstanceUI(c echo.Context) error {
+ baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+ if err != nil {
+ return err
+ }
+ appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+ // tmpl, err := newTemplate().ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/app.html")
+ if err != nil {
+ fmt.Println(err)
+ return err
+ }
+ global, err := s.m.Config()
+ if err != nil {
+ return err
+ }
+ slug := c.Param("slug")
+ instance, err := s.m.FindInstance(slug)
+ if err != nil {
+ return err
+ }
+ a, err := s.r.Find(instance.AppId)
+ if err != nil {
+ return err
+ }
+ instances, err := s.m.FindAllInstances(a.Name)
+ if err != nil {
+ return err
+ }
+ err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+ App: a,
+ Instance: &instance,
+ Instances: instances,
+ AvailableNetworks: installer.CreateNetworks(global),
+ })
+ fmt.Println(err)
+ return err
+}
+
+func newTemplate() *template.Template {
+ return template.New("base").Funcs(template.FuncMap(sprig.FuncMap()))
+}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index ab1504d..6779035 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -262,6 +262,29 @@
return err
}
}
+ {
+ keys, err := installer.NewSSHKeyPair()
+ if err != nil {
+ return err
+ }
+ user := fmt.Sprintf("%s-appmanager", req.Name)
+ if err := ss.AddUser(user, keys.Public); err != nil {
+ return err
+ }
+ if err := ss.AddCollaborator(req.Name, user); err != nil {
+ return err
+ }
+ app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
+ if err != nil {
+ return err
+ }
+ if err := appManager.Install(*app, nsGen, suffixGen, map[string]any{
+ "RepoAddr": ss.GetRepoAddress(req.Name),
+ "SSHPrivateKey": keys.Private,
+ }); err != nil {
+ return err
+ }
+ }
return nil
}