Installer: Refactor and give each searver its own directory
Change-Id: I1db2929e7a35b6f92022dec0c6506d68e0297563
diff --git a/core/installer/server/env/server.go b/core/installer/server/env/server.go
new file mode 100644
index 0000000..9bd0a34
--- /dev/null
+++ b/core/installer/server/env/server.go
@@ -0,0 +1,487 @@
+package env
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "net"
+ "net/http"
+ "net/netip"
+ "strings"
+
+ "github.com/gomarkdown/markdown"
+ "github.com/gorilla/mux"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/dns"
+ phttp "github.com/giolekva/pcloud/core/installer/http"
+ "github.com/giolekva/pcloud/core/installer/server"
+ "github.com/giolekva/pcloud/core/installer/soft"
+ "github.com/giolekva/pcloud/core/installer/tasks"
+)
+
+//go:embed templates/*
+var tmpls embed.FS
+
+var tmplsParsed templates
+
+//go:embed static/*
+var staticAssets embed.FS
+
+func init() {
+ if t, err := parseTemplates(tmpls); err != nil {
+ panic(err)
+ } else {
+ tmplsParsed = t
+ }
+}
+
+type templates struct {
+ form *template.Template
+ status *template.Template
+}
+
+func parseTemplates(fs embed.FS) (templates, error) {
+ base, err := template.ParseFS(fs, "templates/base.html")
+ if err != nil {
+ return templates{}, err
+ }
+ parse := func(path string) (*template.Template, error) {
+ if b, err := base.Clone(); err != nil {
+ return nil, err
+ } else {
+ return b.ParseFS(fs, path)
+ }
+ }
+ form, err := parse("templates/form.html")
+ if err != nil {
+ return templates{}, err
+ }
+ status, err := parse("templates/status.html")
+ if err != nil {
+ return templates{}, err
+ }
+ return templates{form, status}, nil
+}
+
+type Status string
+
+const (
+ StatusActive Status = "ACTIVE"
+ StatusAccepted Status = "ACCEPTED"
+)
+
+// TODO(giolekva): add CreatedAt and ValidUntil
+type invitation struct {
+ Token string `json:"token"`
+ Status Status `json:"status"`
+}
+
+type EnvServer struct {
+ srv *http.Server
+ port int
+ ss soft.Client
+ repo soft.RepoIO
+ repoClient soft.ClientGetter
+ nsCreator installer.NamespaceCreator
+ jc installer.JobCreator
+ hf installer.HelmFetcher
+ dnsFetcher installer.ZoneStatusFetcher
+ nameGenerator installer.NameGenerator
+ httpClient phttp.Client
+ dnsClient dns.Client
+ Tasks tasks.TaskManager
+ envInfo map[string]template.HTML
+ dns map[string]installer.EnvDNS
+ dnsPublished map[string]struct{}
+}
+
+func NewEnvServer(
+ port int,
+ ss soft.Client,
+ repo soft.RepoIO,
+ repoClient soft.ClientGetter,
+ nsCreator installer.NamespaceCreator,
+ jc installer.JobCreator,
+ hf installer.HelmFetcher,
+ dnsFetcher installer.ZoneStatusFetcher,
+ nameGenerator installer.NameGenerator,
+ httpClient phttp.Client,
+ dnsClient dns.Client,
+ tm tasks.TaskManager,
+) *EnvServer {
+ return &EnvServer{
+ nil,
+ port,
+ ss,
+ repo,
+ repoClient,
+ nsCreator,
+ jc,
+ hf,
+ dnsFetcher,
+ nameGenerator,
+ httpClient,
+ dnsClient,
+ tm,
+ make(map[string]template.HTML),
+ make(map[string]installer.EnvDNS),
+ make(map[string]struct{}),
+ }
+}
+
+func (s *EnvServer) Start() error {
+ r := mux.NewRouter()
+ r.PathPrefix("/static/").Handler(server.NewCachingHandler(http.FileServer(http.FS(staticAssets))))
+ r.Path("/env/{key}").Methods("GET").HandlerFunc(s.monitorTask)
+ r.Path("/env/{key}").Methods("POST").HandlerFunc(s.publishDNSRecords)
+ r.Path("/").Methods("GET").HandlerFunc(s.createEnvForm)
+ r.Path("/").Methods("POST").HandlerFunc(s.createEnv)
+ r.Path("/create-invitation").Methods("GET").HandlerFunc(s.createInvitation)
+ srv := &http.Server{
+ Addr: fmt.Sprintf(":%d", s.port),
+ Handler: r,
+ }
+ s.srv = srv
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+}
+
+func (s *EnvServer) Stop() error {
+ return s.srv.Shutdown(context.Background())
+}
+
+func (s *EnvServer) monitorTask(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ key, ok := vars["key"]
+ if !ok {
+ http.Error(w, "Task key not provided", http.StatusBadRequest)
+ return
+ }
+ t, err := s.Tasks.Get(key)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ dnsRecords := ""
+ if _, ok := s.dnsPublished[key]; !ok {
+ dnsRef, ok := s.dns[key]
+ if !ok {
+ http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
+ return
+ }
+ if records, err := s.dnsFetcher.Fetch(dnsRef.Address); err == nil {
+ dnsRecords = records
+ }
+ }
+ data := map[string]any{
+ "Root": t,
+ "EnvInfo": s.envInfo[key],
+ "DNSRecords": dnsRecords,
+ }
+ if err := tmplsParsed.status.Execute(w, data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s *EnvServer) publishDNSRecords(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ key, ok := vars["key"]
+ if !ok {
+ http.Error(w, "Task key not provided", http.StatusBadRequest)
+ return
+ }
+ dnsRef, ok := s.dns[key]
+ if !ok {
+ http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
+ return
+ }
+ records, err := s.dnsFetcher.Fetch(dnsRef.Address)
+ if err != nil {
+ http.Error(w, "Task dns configuration not found", http.StatusInternalServerError)
+ return
+ }
+ r.ParseForm()
+ if apiToken, err := server.GetFormValue(r.PostForm, "api-token"); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ } else {
+ p := NewGandiUpdater(apiToken)
+ zone := strings.Join(strings.Split(dnsRef.Zone, ".")[1:], ".") // TODO(gio): this is not gonna work with no subdomain case
+ if err := p.Update(zone, strings.Split(records, "\n")); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+ s.envInfo[key] = "Successfully published DNS records, waiting to propagate."
+ s.dnsPublished[key] = struct{}{}
+ http.Redirect(w, r, fmt.Sprintf("/env/%s", key), http.StatusSeeOther)
+}
+
+func (s *EnvServer) createEnvForm(w http.ResponseWriter, r *http.Request) {
+ if err := tmplsParsed.form.Execute(w, nil); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (s *EnvServer) createInvitation(w http.ResponseWriter, r *http.Request) {
+ invitations, err := s.readInvitations()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ token, err := installer.NewFixedLengthRandomNameGenerator(100).Generate() // TODO(giolekva): use cryptographic tokens
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+
+ }
+ invitations = append(invitations, invitation{token, StatusActive})
+ if err := s.writeInvitations(invitations); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, err := w.Write([]byte("OK")); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+type createEnvReq struct {
+ Name string
+ ContactEmail string `json:"contactEmail"`
+ Domain string `json:"domain"`
+ PrivateNetworkSubdomain string `json:"privateNetworkSubdomain"`
+ AdminPublicKey string `json:"adminPublicKey"`
+ SecretToken string `json:"secretToken"`
+}
+
+func (s *EnvServer) readInvitations() ([]invitation, error) {
+ r, err := s.repo.Reader("invitations")
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return make([]invitation, 0), nil
+ }
+ return nil, err
+ }
+ defer r.Close()
+ dec := json.NewDecoder(r)
+ invitations := make([]invitation, 0)
+ for {
+ var i invitation
+ if err := dec.Decode(&i); err == io.EOF {
+ break
+ }
+ invitations = append(invitations, i)
+ }
+ return invitations, nil
+}
+
+func (s *EnvServer) writeInvitations(invitations []invitation) error {
+ w, err := s.repo.Writer("invitations")
+ if err != nil {
+ return err
+ }
+ defer w.Close()
+ enc := json.NewEncoder(w)
+ for _, i := range invitations {
+ if err := enc.Encode(i); err != nil {
+ return err
+ }
+ }
+ _, err = s.repo.CommitAndPush("Generated new invitation")
+ return err
+}
+
+func extractRequest(r *http.Request) (createEnvReq, error) {
+ var req createEnvReq
+ if err := func() error {
+ var err error
+ if err = r.ParseForm(); err != nil {
+ return err
+ }
+ if req.SecretToken, err = server.GetFormValue(r.PostForm, "secret-token"); err != nil {
+ return err
+ }
+ if req.Domain, err = server.GetFormValue(r.PostForm, "domain"); err != nil {
+ return err
+ }
+ if req.PrivateNetworkSubdomain, err = server.GetFormValue(r.PostForm, "private-network-subdomain"); err != nil {
+ return err
+ }
+ if req.ContactEmail, err = server.GetFormValue(r.PostForm, "contact-email"); err != nil {
+ return err
+ }
+ if req.AdminPublicKey, err = server.GetFormValue(r.PostForm, "admin-public-key"); err != nil {
+ return err
+ }
+ return nil
+ }(); err != nil {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ return createEnvReq{}, err
+ }
+ }
+ return req, nil
+}
+
+func (s *EnvServer) acceptInvitation(token string) error {
+ invitations, err := s.readInvitations()
+ if err != nil {
+ return err
+ }
+ found := false
+ for i := range invitations {
+ if invitations[i].Token == token && invitations[i].Status == StatusActive {
+ invitations[i].Status = StatusAccepted
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("Invitation not found")
+ }
+ return s.writeInvitations(invitations)
+}
+
+func (s *EnvServer) createEnv(w http.ResponseWriter, r *http.Request) {
+ if err := s.repo.Pull(); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ req, err := extractRequest(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ hf := installer.NewGitHelmFetcher()
+ lg := installer.NewInfraLocalChartGenerator()
+ mgr, err := installer.NewInfraAppManager(s.repo, s.nsCreator, hf, lg)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var infra installer.InfraConfig
+ if err := soft.ReadYaml(s.repo, "config.yaml", &infra); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // if err := s.acceptInvitation(req.SecretToken); err != nil {
+ // http.Error(w, err.Error(), http.StatusInternalServerError)
+ // return
+ // }
+ if name, err := s.nameGenerator.Generate(); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ } else {
+ req.Name = name
+ }
+ var cidrs installer.EnvCIDRs
+ if err := soft.ReadYaml(s.repo, "env-cidrs.yaml", &cidrs); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ startIP, err := findNextStartIP(cidrs)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ cidrs = append(cidrs, installer.EnvCIDR{req.Name, startIP})
+ if err := soft.WriteYaml(s.repo, "env-cidrs.yaml", cidrs); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, err := s.repo.CommitAndPush(fmt.Sprintf("Allocate CIDR for %s", req.Name)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ envNetwork, err := installer.NewEnvNetwork(startIP)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ privateDomain := ""
+ if req.PrivateNetworkSubdomain != "" {
+ privateDomain = fmt.Sprintf("%s.%s", req.PrivateNetworkSubdomain, req.Domain)
+ }
+ env := installer.EnvConfig{
+ Id: req.Name,
+ InfraName: infra.Name,
+ Domain: req.Domain,
+ PrivateDomain: privateDomain,
+ ContactEmail: req.ContactEmail,
+ AdminPublicKey: req.AdminPublicKey,
+ PublicIP: infra.PublicIP,
+ NameserverIP: infra.PublicIP,
+ NamespacePrefix: fmt.Sprintf("%s-", req.Name),
+ Network: envNetwork,
+ }
+ key := func() string {
+ for {
+ key, err := s.nameGenerator.Generate()
+ if err == nil {
+ return key
+ }
+ }
+ }()
+ infoUpdater := func(info string) {
+ s.envInfo[key] = template.HTML(markdown.ToHTML([]byte(info), nil, nil))
+ }
+ t, dns := tasks.NewCreateEnvTask(
+ env,
+ s.nsCreator,
+ s.jc,
+ s.hf,
+ s.dnsFetcher,
+ s.httpClient,
+ s.dnsClient,
+ s.repo,
+ s.repoClient,
+ mgr,
+ infoUpdater,
+ )
+ if err := s.Tasks.Add(key, t); err != nil {
+ panic(err)
+ }
+
+ s.dns[key] = dns
+ go t.Start()
+ http.Redirect(w, r, fmt.Sprintf("/env/%s", key), http.StatusSeeOther)
+}
+
+func findNextStartIP(cidrs installer.EnvCIDRs) (net.IP, error) {
+ m, err := netip.ParseAddr("10.0.0.0")
+ if err != nil {
+ return nil, err
+ }
+ for _, cidr := range cidrs {
+ i, err := netip.ParseAddr(cidr.IP.String())
+ if err != nil {
+ return nil, err
+ }
+ if i.Compare(m) > 0 {
+ m = i
+ }
+ }
+ sl := m.AsSlice()
+ sl[2]++
+ if sl[2] == 0b11111111 {
+ sl[2] = 0
+ sl[1]++
+ }
+ if sl[1] == 0b11111111 {
+ return nil, fmt.Errorf("Can not allocate")
+ }
+ ret, ok := netip.AddrFromSlice(sl)
+ if !ok {
+ return nil, fmt.Errorf("Must not reach")
+ }
+ return net.ParseIP(ret.String()), nil
+}