installer: split up new env creation into chain of tasks
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
new file mode 100644
index 0000000..f8b8018
--- /dev/null
+++ b/core/installer/tasks/activate.go
@@ -0,0 +1,99 @@
+package tasks
+
+import (
+ "bytes"
+ "embed"
+ "encoding/base64"
+ "fmt"
+ "path"
+ "strings"
+ "text/template"
+
+ "github.com/charmbracelet/keygen"
+
+ "github.com/giolekva/pcloud/core/installer"
+)
+
+//go:embed env-tmpl
+var filesTmpls embed.FS
+
+type activateEnvTask struct {
+ basicTask
+ env Env
+ st *state
+}
+
+func NewActivateEnvTask(env Env, st *state) Task {
+ return &activateEnvTask{
+ basicTask: basicTask{
+ title: fmt.Sprintf("Activate %s environment", env.Name),
+ },
+ env: env,
+ st: st,
+ }
+}
+
+func (t *activateEnvTask) Start() {
+ ssPublicKeys, err := t.st.ssClient.GetPublicKeys()
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := t.addNewEnv(
+ t.st.repo,
+ strings.Split(t.st.ssClient.Addr, ":")[0],
+ t.st.keys,
+ ssPublicKeys,
+ ); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ t.callDoneListeners(nil)
+}
+
+func (t *activateEnvTask) addNewEnv(
+ repoIO installer.RepoIO,
+ repoHost string,
+ keys *keygen.KeyPair,
+ configRepoPublicKeys []string,
+) error {
+ kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
+ if err != nil {
+ return err
+ }
+ kust.AddResources(t.env.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", t.env.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": t.env.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", t.env.Name)); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
new file mode 100644
index 0000000..60a8a9d
--- /dev/null
+++ b/core/installer/tasks/dns.go
@@ -0,0 +1,149 @@
+package tasks
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "text/template"
+ "time"
+
+ "github.com/Masterminds/sprig/v3"
+
+ "github.com/giolekva/pcloud/core/installer"
+)
+
+type dnsResolver struct {
+ basicTask
+ name string
+ expected []net.IP
+ ctx context.Context
+ env Env
+ st *state
+}
+
+func NewDNSResolverTask(
+ name string,
+ expected []net.IP,
+ ctx context.Context,
+ env Env,
+ st *state,
+) Task {
+ return &dnsResolver{
+ basicTask: basicTask{
+ title: "Configure DNS",
+ },
+ name: name,
+ expected: expected,
+ ctx: ctx,
+ env: env,
+ st: st,
+ }
+}
+
+func (t *dnsResolver) Start() {
+ repo, err := t.st.ssClient.GetRepo("config")
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ r := installer.NewRepoIO(repo, t.st.ssClient.Signer)
+ {
+ key, err := newDNSSecKey(t.env.Domain)
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ out, err := r.Writer("dns-zone.yaml")
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ 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:
+{{ range .publicIPs }}
+ - {{ .String }}
+{{ end }}
+ nameservers:
+{{ range .publicIPs }}
+ - {{ .String }}
+{{ end }}
+ 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 {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := dnsZoneTmpl.Execute(out, map[string]any{
+ "namespace": t.env.Name,
+ "zone": t.env.Domain,
+ "dnssec": key,
+ "publicIPs": t.st.publicIPs,
+ }); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ rootKust := installer.NewKustomization()
+ rootKust.AddResources("dns-zone.yaml")
+ if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ r.CommitAndPush("configure dns zone")
+ }
+
+ gotExpectedIPs := func(actual []net.IP) bool {
+ for _, a := range actual {
+ found := false
+ for _, e := range t.expected {
+ if a.Equal(e) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+ }
+ check := func(check Check) {
+ addrs, err := net.LookupIP(t.name)
+ if err == nil && gotExpectedIPs(addrs) {
+ t.callDoneListeners(nil)
+ return
+ }
+ select {
+ case <-t.ctx.Done():
+ t.callDoneListeners(fmt.Errorf("deadline exceeded"))
+ return
+ case <-time.After(5 * time.Second):
+ check(check)
+ }
+ }
+ check(check)
+}
+
+type Check func(ch Check)
diff --git a/core/installer/tasks/dns_test.go b/core/installer/tasks/dns_test.go
new file mode 100644
index 0000000..270a495
--- /dev/null
+++ b/core/installer/tasks/dns_test.go
@@ -0,0 +1,33 @@
+package tasks
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestGoogle(t *testing.T) {
+ ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
+ d := NewDNSResolverTask(
+ "welcome.t5.lekva.me",
+ []net.IP{
+ net.ParseIP("135.181.48.180"),
+ net.ParseIP("65.108.39.172"),
+ },
+ ctx,
+ t.Logf,
+ )
+ d.FinalizeSubtasks()
+ ch := make(chan struct{})
+ d.OnDone(func(err error) {
+ if err != nil {
+ t.Logf("%s\n", err.Error())
+ } else {
+ t.Logf("Dooone")
+ }
+ ch <- struct{}{}
+ })
+ d.Start()
+ <-ch
+}
diff --git a/core/installer/tasks/env-tmpl/config-kustomization.yaml b/core/installer/tasks/env-tmpl/config-kustomization.yaml
new file mode 100644
index 0000000..2bf55eb
--- /dev/null
+++ b/core/installer/tasks/env-tmpl/config-kustomization.yaml
@@ -0,0 +1,13 @@
+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/tasks/env-tmpl/config-secret.yaml b/core/installer/tasks/env-tmpl/config-secret.yaml
new file mode 100644
index 0000000..bba3de0
--- /dev/null
+++ b/core/installer/tasks/env-tmpl/config-secret.yaml
@@ -0,0 +1,10 @@
+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/tasks/env-tmpl/config-source.yaml b/core/installer/tasks/env-tmpl/config-source.yaml
new file mode 100644
index 0000000..d22ab03
--- /dev/null
+++ b/core/installer/tasks/env-tmpl/config-source.yaml
@@ -0,0 +1,13 @@
+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/tasks/env-tmpl/kustomization.yaml b/core/installer/tasks/env-tmpl/kustomization.yaml
new file mode 100644
index 0000000..070ae80
--- /dev/null
+++ b/core/installer/tasks/env-tmpl/kustomization.yaml
@@ -0,0 +1,7 @@
+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/tasks/env.go b/core/installer/tasks/env.go
new file mode 100644
index 0000000..340648c
--- /dev/null
+++ b/core/installer/tasks/env.go
@@ -0,0 +1,102 @@
+package tasks
+
+import (
+ "context"
+ "fmt"
+ "net"
+
+ "github.com/charmbracelet/keygen"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type state struct {
+ publicIPs []net.IP
+ nsCreator installer.NamespaceCreator
+ repo installer.RepoIO
+ ssClient *soft.Client
+ fluxUserName string
+ keys *keygen.KeyPair
+}
+
+type createEnvTask struct {
+ basicTask
+ env Env
+ st state
+ createConfigRepo Task
+}
+
+type Env struct {
+ PCloudEnvName string
+ Name string
+ ContactEmail string
+ Domain string
+ AdminPublicKey string
+}
+
+func NewCreateEnvTask(
+ env Env,
+ publicIPs []net.IP,
+ nsCreator installer.NamespaceCreator,
+ repo installer.RepoIO,
+) Task {
+ ctx := context.Background()
+ e := &createEnvTask{
+ basicTask: basicTask{
+ title: fmt.Sprintf("Create %s environment", env.Domain),
+ },
+ env: env,
+ st: state{
+ publicIPs: publicIPs,
+ nsCreator: nsCreator,
+ repo: repo,
+ },
+ }
+ e.createConfigRepo = NewCreateConfigRepoTask(env, &e.st)
+ e.AddSubtask(e.createConfigRepo)
+ initRepo := NewInitConfigRepoTask(env, &e.st)
+ e.AddSubtask(initRepo)
+ e.createConfigRepo.OnDone(func(err error) {
+ if err == nil {
+ initRepo.Start()
+ } else {
+ e.callDoneListeners(err)
+ }
+ })
+ activate := NewActivateEnvTask(env, &e.st)
+ e.AddSubtask(activate)
+ initRepo.OnDone(func(err error) {
+ if err == nil {
+ activate.Start()
+ } else {
+ e.callDoneListeners(err)
+ }
+ })
+ dns := NewDNSResolverTask(env.Domain, publicIPs, ctx, env, &e.st)
+ e.AddSubtask(dns)
+ activate.OnDone(func(err error) {
+ if err == nil {
+ dns.Start()
+ } else {
+ e.callDoneListeners(err)
+ }
+ })
+ setupInfra := NewSetupInfraAppsTask(env, &e.st)
+ e.AddSubtask(setupInfra)
+ dns.OnDone(func(err error) {
+ if err == nil {
+ setupInfra.Start()
+ } else {
+ e.callDoneListeners(err)
+ }
+ })
+ setupInfra.OnDone(func(err error) {
+ e.callDoneListeners(err)
+ })
+ return e
+}
+
+func (t *createEnvTask) Start() {
+ go t.createConfigRepo.Start()
+}
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
new file mode 100644
index 0000000..e57c789
--- /dev/null
+++ b/core/installer/tasks/infra.go
@@ -0,0 +1,271 @@
+package tasks
+
+import (
+ "fmt"
+ "net/netip"
+
+ "github.com/miekg/dns"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type setupInfraAppsTask struct {
+ basicTask
+ env Env
+ st *state
+}
+
+func NewSetupInfraAppsTask(env Env, st *state) Task {
+ return &setupInfraAppsTask{
+ basicTask: basicTask{
+ title: "Configure environment infrastructure",
+ },
+ env: env,
+ st: st,
+ }
+}
+
+func (t *setupInfraAppsTask) Start() {
+ repo, err := t.st.ssClient.GetRepo("config")
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := t.initNewEnv(t.st.ssClient, installer.NewRepoIO(repo, t.st.ssClient.Signer), t.st.nsCreator, t.env.PCloudEnvName, t.st.publicIPs[0].String()); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ t.callDoneListeners(nil)
+}
+
+func (t *setupInfraAppsTask) initNewEnv(
+ ss *soft.Client,
+ r installer.RepoIO,
+ nsCreator installer.NamespaceCreator,
+ 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: t.env.Name,
+ ContactEmail: t.env.ContactEmail,
+ Domain: t.env.Domain,
+ PrivateDomain: fmt.Sprintf("p.%s", t.env.Domain),
+ PublicIP: pcloudPublicIP,
+ NamespacePrefix: fmt.Sprintf("%s-", t.env.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
+`, t.env.Name)
+ if err != nil {
+ return err
+ }
+ }
+ rootKust, err := r.ReadKustomization("kustomization.yaml")
+ if err != nil {
+ return err
+ }
+ rootKust.AddResources("pcloud-charts.yaml")
+ if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
+ return err
+ }
+ r.CommitAndPush("configure charts repo")
+ nsGen := installer.NewPrefixGenerator(t.env.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", t.env.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", t.env.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": t.env.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", t.env.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", t.env.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
+}
+
+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
+}
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
new file mode 100644
index 0000000..1e4ada4
--- /dev/null
+++ b/core/installer/tasks/init.go
@@ -0,0 +1,142 @@
+package tasks
+
+import (
+ "fmt"
+ "log"
+ "path/filepath"
+
+ "github.com/giolekva/pcloud/core/installer"
+ "github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type createConfigRepoTask struct {
+ basicTask
+ env Env
+ st *state
+}
+
+func NewCreateConfigRepoTask(env Env, st *state) Task {
+ return &createConfigRepoTask{
+ basicTask: basicTask{
+ title: "Install Git server",
+ },
+ env: env,
+ st: st,
+ }
+}
+
+func (t *createConfigRepoTask) Start() {
+ appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+ ssApp, err := appsRepo.Find("soft-serve")
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ ssAdminKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-admin-keys", t.env.Name))
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ ssKeys, err := installer.NewSSHKeyPair(fmt.Sprintf("%s-config-repo-keys", t.env.Name))
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ ssValues := map[string]any{
+ "ChartRepositoryNamespace": t.env.PCloudEnvName,
+ "ServiceType": "ClusterIP",
+ "PrivateKey": string(ssKeys.RawPrivateKey()),
+ "PublicKey": string(ssKeys.RawAuthorizedKey()),
+ "AdminKey": string(ssAdminKeys.RawAuthorizedKey()),
+ "Ingress": map[string]any{
+ "Enabled": false,
+ },
+ }
+ derived := installer.Derived{
+ Global: installer.Values{
+ Id: t.env.Name,
+ PCloudEnvName: t.env.PCloudEnvName,
+ },
+ Release: installer.Release{
+ Namespace: t.env.Name,
+ },
+ Values: ssValues,
+ }
+ if err := t.st.nsCreator.Create(t.env.Name); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := t.st.repo.InstallApp(*ssApp, filepath.Join("/environments", t.env.Name, "config-repo"), ssValues, derived); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ ssClient, err := soft.WaitForClient(
+ fmt.Sprintf("soft-serve.%s.svc.cluster.local:%d", t.env.Name, 22),
+ ssAdminKeys.RawPrivateKey(),
+ log.Default())
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := ssClient.AddPublicKey("admin", t.env.AdminPublicKey); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ // // TODO(gio): defer?
+ // // TODO(gio): remove at the end of final task cleanup
+ // if err := ssClient.RemovePublicKey("admin", string(ssAdminKeys.RawAuthorizedKey())); err != nil {
+ // t.callDoneListeners(err)
+ // return
+ // }
+ t.st.ssClient = ssClient
+ t.callDoneListeners(nil)
+}
+
+type initConfigRepoTask struct {
+ basicTask
+ env Env
+ st *state
+}
+
+func NewInitConfigRepoTask(env Env, st *state) Task {
+ return &initConfigRepoTask{
+ basicTask: basicTask{
+ title: "Create Git repository for environment configuration",
+ },
+ env: env,
+ st: st,
+ }
+}
+
+func (t *initConfigRepoTask) Start() {
+ t.st.fluxUserName = fmt.Sprintf("flux-%s", t.env.Name)
+ keys, err := installer.NewSSHKeyPair(t.st.fluxUserName)
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ t.st.keys = keys
+ if err := t.st.ssClient.AddRepository("config"); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ repo, err := t.st.ssClient.GetRepo("config")
+ if err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ repoIO := installer.NewRepoIO(repo, t.st.ssClient.Signer)
+ if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s PCloud environment", t.env.Name), "readme"); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := t.st.ssClient.AddUser(t.st.fluxUserName, keys.AuthorizedKey()); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ if err := t.st.ssClient.AddReadOnlyCollaborator("config", t.st.fluxUserName); err != nil {
+ t.callDoneListeners(err)
+ return
+ }
+ t.callDoneListeners(nil)
+}
diff --git a/core/installer/tasks/tasks.go b/core/installer/tasks/tasks.go
new file mode 100644
index 0000000..4eb834d
--- /dev/null
+++ b/core/installer/tasks/tasks.go
@@ -0,0 +1,106 @@
+package tasks
+
+import (
+ "fmt"
+)
+
+type Status int
+
+const (
+ StatusPending Status = 0
+ StatusRunning = 1
+ StatusFailed = 2
+ StatusDone = 3
+)
+
+type TaskDoneListener func(err error)
+
+type Task interface {
+ Title() string
+ Start()
+ Status() Status
+ Err() error
+ Subtasks() []Task
+ AddSubtask(t Task) error
+ FinalizeSubtasks()
+ OnDone(l TaskDoneListener)
+}
+
+type basicTask struct {
+ title string
+ status Status
+ err error
+ subtasks []Task
+ done []bool
+ finalized bool
+ listeners []TaskDoneListener
+}
+
+func (b *basicTask) Title() string {
+ return b.title
+}
+
+func (b *basicTask) Status() Status {
+ return b.status
+}
+
+func (b *basicTask) Err() error {
+ return b.err
+}
+
+func (b *basicTask) Subtasks() []Task {
+ return b.subtasks
+}
+
+func (b *basicTask) AddSubtask(t Task) error {
+ if b.finalized {
+ return fmt.Errorf("already finalized")
+ }
+ i := len(b.subtasks)
+ b.subtasks = append(b.subtasks, t)
+ b.done = append(b.done, false)
+ t.OnDone(func(err error) {
+ if b.done[i] {
+ panic(fmt.Sprintf("already done: %s", b.subtasks[i].Title()))
+ }
+ b.done[i] = true
+ if err != nil {
+ b.callDoneListeners(err)
+ }
+ if !b.finalized {
+ return
+ }
+ done := 0
+ for _, d := range b.done {
+ if d {
+ done++
+ } else {
+ break
+ }
+ }
+ if done == len(b.subtasks) {
+ b.callDoneListeners(nil)
+ }
+ })
+ return nil
+}
+
+func (b *basicTask) FinalizeSubtasks() {
+ b.finalized = true
+}
+
+func (b *basicTask) OnDone(l TaskDoneListener) {
+ b.listeners = append(b.listeners, l)
+}
+
+func (b *basicTask) callDoneListeners(err error) {
+ for _, l := range b.listeners {
+ go l(err)
+ }
+ if err == nil {
+ b.status = StatusDone
+ } else {
+ b.status = StatusFailed
+ b.err = err
+ }
+}