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
+}