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