installer: split up new env creation into chain of tasks
diff --git a/core/installer/welcome/env-created.html b/core/installer/welcome/env-created.html
index 0a64116..fc8be86 100644
--- a/core/installer/welcome/env-created.html
+++ b/core/installer/welcome/env-created.html
@@ -1,15 +1,29 @@
+{{ define "task" }}
+<li aria-busy="{{ eq .Status 0 }}">
+ {{ .Title }}{{ if .Err }} - {{ .Err.Error }} {{ end }}
+ <ul>
+ {{ range .Subtasks }}
+ {{ template "task" . }}
+ {{ end }}
+ </ul>
+</li>
+{{ end }}
+
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<link rel="stylesheet" href="/static/pico.min.css">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+ {{ if not (or (eq .Root.Status 2) (eq .Root.Status 3))}}
+ <meta http-equiv="refresh" content="1">
+ {{ end }}
</head>
<body>
- <div style="display: contents">
- <main class="container">
- Environment for {{ .Domain }} is being configured. In few minutes go to <a href="https://welcome.{{ .Domain }}">https://welcome.<b>{{ .Domain }}</b></a> to set up administrative account.
- </main>
- </div>
+ <main class="container">
+ <ul>
+ {{ template "task" .Root }}
+ </ul>
+ </main>
</body>
</html>
diff --git a/core/installer/welcome/env-tmpl/config-kustomization.yaml b/core/installer/welcome/env-tmpl/config-kustomization.yaml
deleted file mode 100644
index 2bf55eb..0000000
--- a/core/installer/welcome/env-tmpl/config-kustomization.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-apiVersion: kustomize.toolkit.fluxcd.io/v1
-kind: Kustomization
-metadata:
- name: {{ .Name }}
- namespace: {{ .Name }}
-spec:
- interval: 1m
- path: "./"
- sourceRef:
- kind: GitRepository
- name: {{ .Name }}
- namespace: {{ .Name }}
- prune: true
diff --git a/core/installer/welcome/env-tmpl/config-secret.yaml b/core/installer/welcome/env-tmpl/config-secret.yaml
deleted file mode 100644
index bba3de0..0000000
--- a/core/installer/welcome/env-tmpl/config-secret.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-apiVersion: v1
-kind: Secret
-type: Opaque
-metadata:
- name: {{ .Name }}
- namespace: {{ .Name }}
-data:
- identity: {{ .PrivateKey }}
- identity.pub: {{ .PublicKey }}
- known_hosts: {{ .KnownHosts }}
diff --git a/core/installer/welcome/env-tmpl/config-source.yaml b/core/installer/welcome/env-tmpl/config-source.yaml
deleted file mode 100644
index d22ab03..0000000
--- a/core/installer/welcome/env-tmpl/config-source.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-apiVersion: source.toolkit.fluxcd.io/v1
-kind: GitRepository
-metadata:
- name: {{ .Name }}
- namespace: {{ .Name }}
-spec:
- interval: 1m0s
- ref:
- branch: master
- secretRef:
- name: {{ .Name }}
- timeout: 60s
- url: ssh://{{ .RepoHost }}/{{ .RepoName }}
diff --git a/core/installer/welcome/env-tmpl/kustomization.yaml b/core/installer/welcome/env-tmpl/kustomization.yaml
deleted file mode 100644
index 070ae80..0000000
--- a/core/installer/welcome/env-tmpl/kustomization.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-apiVersion: kustomize.config.k8s.io/v1beta1
-kind: Kustomization
-resources:
-- config-repo
-- config-secret.yaml
-- config-source.yaml
-- config-kustomization.yaml
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index c0f2f01..bb8b6ff 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -1,9 +1,7 @@
package welcome
import (
- "bytes"
- "embed"
- "encoding/base64"
+ _ "embed"
"encoding/json"
"errors"
"fmt"
@@ -11,27 +9,18 @@
"io"
"io/fs"
"log"
+ "net"
"net/http"
- "net/netip"
- "path"
- "path/filepath"
- "strings"
- "text/template"
- "github.com/Masterminds/sprig/v3"
- "github.com/charmbracelet/keygen"
"github.com/gorilla/mux"
- "github.com/miekg/dns"
"github.com/giolekva/pcloud/core/installer"
"github.com/giolekva/pcloud/core/installer/soft"
+ "github.com/giolekva/pcloud/core/installer/tasks"
)
-//go:embed env-tmpl
-var filesTmpls embed.FS
-
//go:embed create-env.html
-var createEnvFormHtml string
+var createEnvFormHtml []byte
//go:embed env-created.html
var envCreatedHtml string
@@ -55,6 +44,7 @@
repo installer.RepoIO
nsCreator installer.NamespaceCreator
nameGenerator installer.NameGenerator
+ tasks map[string]tasks.Task
}
func NewEnvServer(port int, ss *soft.Client, repo installer.RepoIO, nsCreator installer.NamespaceCreator, nameGenerator installer.NameGenerator) *EnvServer {
@@ -64,12 +54,14 @@
repo,
nsCreator,
nameGenerator,
+ make(map[string]tasks.Task),
}
}
func (s *EnvServer) Start() {
r := mux.NewRouter()
r.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets)))
+ r.Path("/env/{key}").Methods("GET").HandlerFunc(s.monitorTask)
r.Path("/").Methods("GET").HandlerFunc(s.createEnvForm)
r.Path("/").Methods("POST").HandlerFunc(s.createEnv)
r.Path("/create-invitation").Methods("GET").HandlerFunc(s.createInvitation)
@@ -77,8 +69,29 @@
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil))
}
+func (s *EnvServer) monitorTask(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ fmt.Printf("%+v %+v\n", s.tasks, vars)
+ t, ok := s.tasks[vars["key"]]
+ if !ok {
+ http.Error(w, "Task not found", http.StatusBadRequest)
+ return
+ }
+ tmpl, err := htemplate.New("response").Parse(envCreatedHtml)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := tmpl.Execute(w, map[string]any{
+ "Root": t,
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
func (s *EnvServer) createEnvForm(w http.ResponseWriter, r *http.Request) {
- if _, err := w.Write([]byte(createEnvFormHtml)); err != nil {
+ if _, err := w.Write(createEnvFormHtml); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
@@ -224,474 +237,22 @@
} else {
req.Name = name
}
- appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
- ssApp, err := appsRepo.Find("soft-serve")
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- ssAdminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", req.Name))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- ssKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", req.Name))
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- ssValues := map[string]any{
- "ChartRepositoryNamespace": env.Name,
- "ServiceType": "ClusterIP",
- "PrivateKey": string(ssKeys.RawPrivateKey()),
- "PublicKey": string(ssKeys.RawAuthorizedKey()),
- "AdminKey": string(ssAdminKeys.RawAuthorizedKey()),
- "Ingress": map[string]any{
- "Enabled": false,
+ t := tasks.NewCreateEnvTask(
+ tasks.Env{
+ PCloudEnvName: env.Name,
+ Name: req.Name,
+ ContactEmail: req.ContactEmail,
+ Domain: req.Domain,
+ AdminPublicKey: req.AdminPublicKey,
},
- }
- derived := installer.Derived{
- Global: installer.Values{
- Id: req.Name,
- PCloudEnvName: env.Name,
+ []net.IP{
+ net.ParseIP("135.181.48.180"),
+ net.ParseIP("65.108.39.172"),
},
- Release: installer.Release{
- Namespace: req.Name,
- },
- Values: ssValues,
- }
- if err := s.nsCreator.Create(req.Name); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := s.repo.InstallApp(*ssApp, filepath.Join("/environments", req.Name, "config-repo"), ssValues, derived); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- k := installer.NewKustomization()
- k.AddResources("config-repo")
- if err := s.repo.WriteKustomization(filepath.Join("/environments", req.Name, "kustomization.yaml"), k); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- ssClient, err := soft.WaitForClient(
- fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", req.Name, 22),
- ssAdminKeys.RawPrivateKey(),
- log.Default())
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := ssClient.AddPublicKey("admin", req.AdminPublicKey); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- defer func() {
- if err := ssClient.RemovePublicKey("admin", string(ssAdminKeys.RawAuthorizedKey())); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }()
- fluxUserName := fmt.Sprintf("flux-%s", req.Name)
- keys, err := installer.NewSSHKeyPair(fluxUserName)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- {
- if err := ssClient.AddRepository("config"); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- repo, err := ssClient.GetRepo("config")
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- repoIO := installer.NewRepoIO(repo, ssClient.Signer)
- if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s PCloud environment", req.Name), "readme"); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := ssClient.AddUser(fluxUserName, keys.AuthorizedKey()); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := ssClient.AddReadOnlyCollaborator("config", fluxUserName); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- {
- repo, err := ssClient.GetRepo("config")
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := initNewEnv(ssClient, installer.NewRepoIO(repo, ssClient.Signer), s.nsCreator, req, env.Name, env.PublicIP); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- {
- ssPublicKeys, err := ssClient.GetPublicKeys()
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := addNewEnv(
- s.repo,
- req,
- strings.Split(ssClient.Addr, ":")[0],
- keys,
- ssPublicKeys,
- ); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- tmpl, err := htemplate.New("response").Parse(envCreatedHtml)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if err := tmpl.Execute(w, map[string]any{
- "Domain": req.Domain,
- }); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-}
-
-type DNSSecKey struct {
- Basename string `json:"basename,omitempty"`
- Key []byte `json:"key,omitempty"`
- Private []byte `json:"private,omitempty"`
- DS []byte `json:"ds,omitempty"`
-}
-
-func newDNSSecKey(zone string) (DNSSecKey, error) {
- key := &dns.DNSKEY{
- Hdr: dns.RR_Header{Name: dns.Fqdn(zone), Class: dns.ClassINET, Ttl: 3600, Rrtype: dns.TypeDNSKEY},
- Algorithm: dns.ECDSAP256SHA256, Flags: 257, Protocol: 3,
- }
- priv, err := key.Generate(256)
- if err != nil {
- return DNSSecKey{}, err
- }
- return DNSSecKey{
- Basename: fmt.Sprintf("K%s+%03d+%05d", key.Header().Name, key.Algorithm, key.KeyTag()),
- Key: []byte(key.String()),
- Private: []byte(key.PrivateKeyString(priv)),
- DS: []byte(key.ToDS(dns.SHA256).String()),
- }, nil
-}
-
-func initNewEnv(
- ss *soft.Client,
- r installer.RepoIO,
- nsCreator installer.NamespaceCreator,
- req createEnvReq,
- pcloudEnvName string,
- pcloudPublicIP string,
-) error {
- appManager, err := installer.NewAppManager(r, nsCreator)
- if err != nil {
- return err
- }
- appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
- // TODO(giolekva): private domain can be configurable as well
- config := installer.Config{
- Values: installer.Values{
- PCloudEnvName: pcloudEnvName,
- Id: req.Name,
- ContactEmail: req.ContactEmail,
- Domain: req.Domain,
- PrivateDomain: fmt.Sprintf("p.%s", req.Domain),
- PublicIP: pcloudPublicIP,
- NamespacePrefix: fmt.Sprintf("%s-", req.Name),
- },
- }
- if err := r.WriteYaml("config.yaml", config); err != nil {
- return err
- }
- {
- out, err := r.Writer("pcloud-charts.yaml")
- if err != nil {
- return err
- }
- defer out.Close()
- _, err = fmt.Fprintf(out, `
-apiVersion: source.toolkit.fluxcd.io/v1
-kind: GitRepository
-metadata:
- name: pcloud
- namespace: %s
-spec:
- interval: 1m0s
- url: https://github.com/giolekva/pcloud
- ref:
- branch: main
-`, req.Name)
- if err != nil {
- return err
- }
- }
- {
- key, err := newDNSSecKey(req.Domain)
- if err != nil {
- return err
- }
- out, err := r.Writer("dns-zone.yaml")
- if err != nil {
- return err
- }
- defer out.Close()
- dnsZoneTmpl, err := template.New("config").Funcs(sprig.TxtFuncMap()).Parse(`
-apiVersion: dodo.cloud.dodo.cloud/v1
-kind: DNSZone
-metadata:
- name: dns-zone
- namespace: {{ .namespace }}
-spec:
- zone: {{ .zone }}
- privateIP: 10.1.0.1
- publicIPs:
- - 135.181.48.180
- - 65.108.39.172
- nameservers:
- - 135.181.48.180
- - 65.108.39.172
- dnssec:
- enabled: true
- secretName: dnssec-key
----
-apiVersion: v1
-kind: Secret
-metadata:
- name: dnssec-key
- namespace: {{ .namespace }}
-type: Opaque
-data:
- basename: {{ .dnssec.Basename | b64enc }}
- key: {{ .dnssec.Key | toString | b64enc }}
- private: {{ .dnssec.Private | toString | b64enc }}
- ds: {{ .dnssec.DS | toString | b64enc }}
-`)
- if err != nil {
- return err
- }
- if err := dnsZoneTmpl.Execute(out, map[string]any{
- "namespace": req.Name,
- "zone": req.Domain,
- "dnssec": key,
- }); err != nil {
- return err
- }
- }
- rootKust := installer.NewKustomization()
- rootKust.AddResources("pcloud-charts.yaml", "dns-zone.yaml", "apps")
- if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
- return err
- }
- appsKust := installer.NewKustomization()
- if err := r.WriteKustomization("apps/kustomization.yaml", appsKust); err != nil {
- return err
- }
- r.CommitAndPush("initialize config")
- nsGen := installer.NewPrefixGenerator(req.Name + "-")
- emptySuffixGen := installer.NewEmptySuffixGenerator()
- ingressPrivateIP, err := netip.ParseAddr("10.1.0.1")
- if err != nil {
- return err
- }
- {
- headscaleIP := ingressPrivateIP.Next()
- app, err := appsRepo.Find("metallb-ipaddresspool")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-ingress-private"), map[string]any{
- "Name": fmt.Sprintf("%s-ingress-private", req.Name),
- "From": ingressPrivateIP.String(),
- "To": ingressPrivateIP.String(),
- "AutoAssign": false,
- "Namespace": "metallb-system",
- }); err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-headscale"), map[string]any{
- "Name": fmt.Sprintf("%s-headscale", req.Name),
- "From": headscaleIP.String(),
- "To": headscaleIP.String(),
- "AutoAssign": false,
- "Namespace": "metallb-system",
- }); err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "Name": req.Name,
- "From": "10.1.0.100", // TODO(gio): auto-generate
- "To": "10.1.0.254",
- "AutoAssign": false,
- "Namespace": "metallb-system",
- }); err != nil {
- return err
- }
- }
- {
- app, err := appsRepo.Find("private-network")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "PrivateNetwork": map[string]any{
- "Hostname": "private-network-proxy",
- "Username": "private-network-proxy",
- "IPSubnet": "10.1.0.0/24",
- },
- }); err != nil {
- return err
- }
- }
- {
- app, err := appsRepo.Find("certificate-issuer-public")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{}); err != nil {
- return err
- }
- }
- {
- app, err := appsRepo.Find("certificate-issuer-private")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "APIConfigMap": map[string]any{
- "Name": "api-config", // TODO(gio): take from global pcloud config
- "Namespace": fmt.Sprintf("%s-dns-zone-manager", pcloudEnvName),
- },
- }); err != nil {
- return err
- }
- }
- {
- app, err := appsRepo.Find("core-auth")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "Subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
- }); err != nil {
- return err
- }
- }
- {
- app, err := appsRepo.Find("headscale")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "Subdomain": "headscale",
- }); err != nil {
- return err
- }
- }
- {
- keys, err := installer.NewSSHKeyPair("welcome")
- if err != nil {
- return err
- }
- user := fmt.Sprintf("%s-welcome", req.Name)
- if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
- return err
- }
- if err := ss.AddReadWriteCollaborator("config", user); err != nil {
- return err
- }
- app, err := appsRepo.Find("welcome")
- if err != nil {
- return err
- }
- if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
- "RepoAddr": ss.GetRepoAddress("config"),
- "SSHPrivateKey": string(keys.RawPrivateKey()),
- }); err != nil {
- return err
- }
- }
- {
- user := fmt.Sprintf("%s-appmanager", req.Name)
- keys, err := installer.NewSSHKeyPair(user)
- if err != nil {
- return err
- }
- if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
- return err
- }
- if err := ss.AddReadWriteCollaborator("config", 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, emptySuffixGen, map[string]any{
- "RepoAddr": ss.GetRepoAddress("config"),
- "SSHPrivateKey": string(keys.RawPrivateKey()),
- }); err != nil {
- return err
- }
- }
- return nil
-}
-
-func addNewEnv(
- repoIO installer.RepoIO,
- req createEnvReq,
- repoHost string,
- keys *keygen.KeyPair,
- configRepoPublicKeys []string,
-) error {
- kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
- if err != nil {
- return err
- }
- kust.AddResources(req.Name)
- tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
- if err != nil {
- return err
- }
- var knownHosts bytes.Buffer
- for _, key := range configRepoPublicKeys {
- fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
- }
- for _, tmpl := range tmpls.Templates() {
- dstPath := path.Join("environments", req.Name, tmpl.Name())
- dst, err := repoIO.Writer(dstPath)
- if err != nil {
- return err
- }
- defer dst.Close()
-
- if err := tmpl.Execute(dst, map[string]string{
- "Name": req.Name,
- "PrivateKey": base64.StdEncoding.EncodeToString(keys.RawPrivateKey()),
- "PublicKey": base64.StdEncoding.EncodeToString(keys.RawAuthorizedKey()),
- "RepoHost": repoHost,
- "RepoName": "config",
- "KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
- }); err != nil {
- return err
- }
- }
- if err := repoIO.WriteKustomization("environments/kustomization.yaml", *kust); err != nil {
- return err
- }
- if err := repoIO.CommitAndPush(fmt.Sprintf("%s: initialize environment", req.Name)); err != nil {
- return err
- }
- return nil
+ s.nsCreator,
+ s.repo,
+ )
+ s.tasks["foo"] = t
+ t.Start()
+ http.Redirect(w, r, "/env/foo", http.StatusSeeOther)
}