Installer: Clean up RepoIO interface

Change-Id: If80d7be1460c725b7df9d1d27c9354cb9141acfe
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
index 1333f28..dffdfbe 100644
--- a/core/installer/tasks/activate.go
+++ b/core/installer/tasks/activate.go
@@ -8,6 +8,8 @@
 	"path"
 	"strings"
 	"text/template"
+
+	"github.com/giolekva/pcloud/core/installer"
 )
 
 //go:embed env-tmpl
@@ -29,45 +31,43 @@
 			return err
 		}
 		repoHost := strings.Split(st.ssClient.Addr, ":")[0]
-		kust, err := st.repo.ReadKustomization("environments/kustomization.yaml")
-		if err != nil {
-			return err
-		}
-		kust.AddResources(env.Name)
-		tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
-		if err != nil {
-			return err
-		}
-		var knownHosts bytes.Buffer
-		for _, key := range ssPublicKeys {
-			fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
-		}
-		for _, tmpl := range tmpls.Templates() { // TODO(gio): migrate to cue
-			dstPath := path.Join("environments", env.Name, tmpl.Name())
-			dst, err := st.repo.Writer(dstPath)
+		return st.repo.Atomic(func(r installer.RepoFS) (string, error) {
+			kust, err := installer.ReadKustomization(r, "environments/kustomization.yaml")
 			if err != nil {
-				return err
+				return "", err
 			}
-			defer dst.Close()
-
-			if err := tmpl.Execute(dst, map[string]string{
-				"Name":       env.Name,
-				"PrivateKey": base64.StdEncoding.EncodeToString(st.keys.RawPrivateKey()),
-				"PublicKey":  base64.StdEncoding.EncodeToString(st.keys.RawAuthorizedKey()),
-				"RepoHost":   repoHost,
-				"RepoName":   "config",
-				"KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
-			}); err != nil {
-				return err
+			kust.AddResources(env.Name)
+			tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
+			if err != nil {
+				return "", err
 			}
-		}
-		if err := st.repo.WriteKustomization("environments/kustomization.yaml", *kust); err != nil {
-			return err
-		}
-		if err := st.repo.CommitAndPush(fmt.Sprintf("%s: initialize environment", env.Name)); err != nil {
-			return err
-		}
-		return nil
+			var knownHosts bytes.Buffer
+			for _, key := range ssPublicKeys {
+				fmt.Fprintf(&knownHosts, "%s %s\n", repoHost, key)
+			}
+			for _, tmpl := range tmpls.Templates() { // TODO(gio): migrate to cue
+				dstPath := path.Join("environments", env.Name, tmpl.Name())
+				dst, err := r.Writer(dstPath)
+				if err != nil {
+					return "", err
+				}
+				defer dst.Close()
+				if err := tmpl.Execute(dst, map[string]string{
+					"Name":       env.Name,
+					"PrivateKey": base64.StdEncoding.EncodeToString(st.keys.RawPrivateKey()),
+					"PublicKey":  base64.StdEncoding.EncodeToString(st.keys.RawAuthorizedKey()),
+					"RepoHost":   repoHost,
+					"RepoName":   "config",
+					"KnownHosts": base64.StdEncoding.EncodeToString(knownHosts.Bytes()),
+				}); err != nil {
+					return "", err
+				}
+			}
+			if err := installer.WriteYaml(r, "environments/kustomization.yaml", kust); err != nil {
+				return "", err
+			}
+			return fmt.Sprintf("%s: initialize environment", env.Name), nil
+		})
 	})
 	return &t
 }
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 13c4b58..65ef787 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -38,22 +38,26 @@
 	st *state,
 ) Task {
 	t := newLeafTask("Generate and publish DNS records", func() error {
+		key, err := newDNSSecKey(env.Domain)
+		if err != nil {
+			return err
+		}
 		repo, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
 		}
-		r := installer.NewRepoIO(repo, st.ssClient.Signer)
-		{
-			key, err := newDNSSecKey(env.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(`
+		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err != nil {
+			return err
+		}
+		return r.Atomic(func(r installer.RepoFS) (string, error) {
+			{
+				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:
@@ -86,31 +90,29 @@
   private: {{ .dnssec.Private | toString | b64enc }}
   ds: {{ .dnssec.DS | toString | b64enc }}
 `)
-			if err != nil {
-				return err
+				if err != nil {
+					return "", err
+				}
+				if err := dnsZoneTmpl.Execute(out, map[string]any{
+					"namespace": env.Name,
+					"zone":      env.Domain,
+					"dnssec":    key,
+					"publicIPs": st.publicIPs,
+					"ingressIP": ingressIP.String(),
+				}); err != nil {
+					return "", err
+				}
+				rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
+				if err != nil {
+					return "", err
+				}
+				rootKust.AddResources("dns-zone.yaml")
+				if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
+					return "", err
+				}
+				return "configure dns zone", nil
 			}
-			if err := dnsZoneTmpl.Execute(out, map[string]any{
-				"namespace": env.Name,
-				"zone":      env.Domain,
-				"dnssec":    key,
-				"publicIPs": st.publicIPs,
-				"ingressIP": ingressIP.String(),
-			}); err != nil {
-				return err
-			}
-			rootKust, err := r.ReadKustomization("kustomization.yaml")
-			if err != nil {
-				return err
-			}
-			rootKust.AddResources("dns-zone.yaml")
-			if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
-				return err
-			}
-			if err := r.CommitAndPush("configure dns zone"); err != nil {
-				return err
-			}
-		}
-		return nil
+		})
 	})
 	return &t
 }
diff --git a/core/installer/tasks/env.go b/core/installer/tasks/env.go
index 1d1eaba..7eaf0da 100644
--- a/core/installer/tasks/env.go
+++ b/core/installer/tasks/env.go
@@ -12,26 +12,25 @@
 )
 
 type state struct {
-	infoListener   EnvInfoListener
-	publicIPs      []net.IP
-	nsCreator      installer.NamespaceCreator
-	repo           installer.RepoIO
-	ssAdminKeys    *keygen.KeyPair
-	ssClient       *soft.Client
-	fluxUserName   string
-	keys           *keygen.KeyPair
-	appManager     *installer.AppManager
-	appsRepo       installer.AppRepository
-	nsGen          installer.NamespaceGenerator
-	emptySuffixGen installer.SuffixGenerator
+	infoListener EnvInfoListener
+	publicIPs    []net.IP
+	nsCreator    installer.NamespaceCreator
+	repo         installer.RepoIO
+	ssAdminKeys  *keygen.KeyPair
+	ssClient     *soft.Client
+	fluxUserName string
+	keys         *keygen.KeyPair
+	appManager   *installer.AppManager
+	appsRepo     installer.AppRepository
 }
 
 type Env struct {
-	PCloudEnvName  string
-	Name           string
-	ContactEmail   string
-	Domain         string
-	AdminPublicKey string
+	PCloudEnvName   string
+	Name            string
+	ContactEmail    string
+	Domain          string
+	AdminPublicKey  string
+	NamespacePrefix string
 }
 
 type EnvInfoListener func(string)
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 914307c..d718428 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -19,15 +19,16 @@
 		if err != nil {
 			return err
 		}
-		r := installer.NewRepoIO(repo, st.ssClient.Signer)
+		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err != nil {
+			return err
+		}
 		appManager, err := installer.NewAppManager(r, st.nsCreator)
 		if err != nil {
 			return err
 		}
 		st.appManager = appManager
 		st.appsRepo = installer.NewInMemoryAppRepository(installer.CreateAllApps())
-		st.nsGen = installer.NewPrefixGenerator(env.Name + "-")
-		st.emptySuffixGen = installer.NewEmptySuffixGenerator()
 		return nil
 	})
 	t.beforeStart = func() {
@@ -56,28 +57,31 @@
 		if err != nil {
 			return err
 		}
-		r := installer.NewRepoIO(repo, st.ssClient.Signer)
-		{
-			// TODO(giolekva): private domain can be configurable as well
-			config := installer.Config{
-				Values: installer.Values{
-					PCloudEnvName:   env.PCloudEnvName,
-					Id:              env.Name,
-					ContactEmail:    env.ContactEmail,
-					Domain:          env.Domain,
-					PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
-					PublicIP:        st.publicIPs[0].String(),
-					NamespacePrefix: fmt.Sprintf("%s-", env.Name),
-				},
-			}
-			if err := r.WriteYaml("config.yaml", config); err != nil {
-				return err
-			}
+		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err != nil {
+			return err
 		}
-		{
+		r.Atomic(func(r installer.RepoFS) (string, error) {
+			{
+				// TODO(giolekva): private domain can be configurable as well
+				config := installer.Config{
+					Values: installer.Values{
+						PCloudEnvName:   env.PCloudEnvName,
+						Id:              env.Name,
+						ContactEmail:    env.ContactEmail,
+						Domain:          env.Domain,
+						PrivateDomain:   fmt.Sprintf("p.%s", env.Domain),
+						PublicIP:        st.publicIPs[0].String(),
+						NamespacePrefix: fmt.Sprintf("%s-", env.Name),
+					},
+				}
+				if err := installer.WriteYaml(r, "config.yaml", config); err != nil {
+					return "", err
+				}
+			}
 			out, err := r.Writer("pcloud-charts.yaml")
 			if err != nil {
-				return err
+				return "", err
 			}
 			defer out.Close()
 			_, err = fmt.Fprintf(out, `
@@ -93,18 +97,18 @@
     branch: ingress-port-allocator
 `, env.Name)
 			if err != nil {
-				return err
+				return "", err
 			}
-			rootKust, err := r.ReadKustomization("kustomization.yaml")
+			rootKust, err := installer.ReadKustomization(r, "kustomization.yaml")
 			if err != nil {
-				return err
+				return "", err
 			}
 			rootKust.AddResources("pcloud-charts.yaml")
-			if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
-				return err
+			if err := installer.WriteYaml(r, "kustomization.yaml", rootKust); err != nil {
+				return "", err
 			}
-			r.CommitAndPush("configure charts repo")
-		}
+			return "configure charts repo", nil
+		})
 		return nil
 	})
 	return &t
@@ -121,12 +125,17 @@
 		if err != nil {
 			return err
 		}
-		r := installer.NewRepoIO(repo, st.ssClient.Signer)
-		fa := firstAccount{false, initGroups}
-		if err := r.WriteYaml("first-account.yaml", fa); err != nil {
+		r, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err != nil {
 			return err
 		}
-		return r.CommitAndPush("first account membership configuration")
+		return r.Atomic(func(r installer.RepoFS) (string, error) {
+			fa := firstAccount{false, initGroups}
+			if err := installer.WriteYaml(r, "first-account.yaml", fa); err != nil {
+				return "", err
+			}
+			return "first account membership configuration", nil
+		})
 	})
 	return &t
 }
@@ -161,32 +170,44 @@
 			if err != nil {
 				return err
 			}
-			if err := st.appManager.Install(app, st.nsGen, installer.NewSuffixGenerator("-ingress-private"), map[string]any{
-				"name":       fmt.Sprintf("%s-ingress-private", env.Name),
-				"from":       ingressPrivateIP.String(),
-				"to":         ingressPrivateIP.String(),
-				"autoAssign": false,
-				"namespace":  "metallb-system",
-			}); err != nil {
-				return err
+			{
+				appDir := fmt.Sprintf("/apps/%s-ingress-private", app.Name())
+				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
+				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+					"name":       fmt.Sprintf("%s-ingress-private", env.Name),
+					"from":       ingressPrivateIP.String(),
+					"to":         ingressPrivateIP.String(),
+					"autoAssign": false,
+					"namespace":  "metallb-system",
+				}); err != nil {
+					return err
+				}
 			}
-			if err := st.appManager.Install(app, st.nsGen, installer.NewSuffixGenerator("-headscale"), map[string]any{
-				"name":       fmt.Sprintf("%s-headscale", env.Name),
-				"from":       headscaleIP.String(),
-				"to":         headscaleIP.String(),
-				"autoAssign": false,
-				"namespace":  "metallb-system",
-			}); err != nil {
-				return err
+			{
+				appDir := fmt.Sprintf("/apps/%s-headscale", app.Name())
+				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
+				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+					"name":       fmt.Sprintf("%s-headscale", env.Name),
+					"from":       headscaleIP.String(),
+					"to":         headscaleIP.String(),
+					"autoAssign": false,
+					"namespace":  "metallb-system",
+				}); err != nil {
+					return err
+				}
 			}
-			if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
-				"name":       env.Name,
-				"from":       fromIP.String(),
-				"to":         toIP.String(),
-				"autoAssign": false,
-				"namespace":  "metallb-system",
-			}); err != nil {
-				return err
+			{
+				appDir := fmt.Sprintf("/apps/%s", app.Name())
+				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+				if err := st.appManager.Install(app, appDir, namespace, map[string]any{
+					"name":       env.Name,
+					"from":       fromIP.String(),
+					"to":         toIP.String(),
+					"autoAssign": false,
+					"namespace":  "metallb-system",
+				}); err != nil {
+					return err
+				}
 			}
 		}
 		{
@@ -205,7 +226,9 @@
 			if err != nil {
 				return err
 			}
-			if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+			appDir := fmt.Sprintf("/apps/%s", app.Name())
+			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+			if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 				"privateNetwork": map[string]any{
 					"hostname": "private-network-proxy",
 					"username": "private-network-proxy",
@@ -227,7 +250,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{}); err != nil {
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
@@ -237,7 +262,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"apiConfigMap": map[string]any{
 				"name":      "api-config", // TODO(gio): take from global pcloud config
 				"namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
@@ -256,7 +283,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
 		}); err != nil {
 			return err
@@ -277,7 +306,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"authGroups": strings.Join(initGroups, ","),
 		}); err != nil {
 			return err
@@ -298,7 +329,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"subdomain": "headscale",
 			"ipSubnet":  fmt.Sprintf("%s/24", startIP),
 		}); err != nil {
@@ -331,7 +364,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 		}); err != nil {
@@ -364,7 +399,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+		appDir := fmt.Sprintf("/apps/%s", app.Name())
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if err := st.appManager.Install(app, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 			"authGroups":    strings.Join(initGroups, ","),
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index cb546c1..4ed7176 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -51,28 +51,18 @@
 		if err != nil {
 			return err
 		}
-		ssValues := map[string]any{
-			"privateKey": string(ssKeys.RawPrivateKey()),
-			"publicKey":  string(ssKeys.RawAuthorizedKey()),
-			"adminKey":   string(ssAdminKeys.RawAuthorizedKey()),
-		}
 		derived := installer.Derived{
 			Global: installer.Values{
 				Id:            env.Name,
 				PCloudEnvName: env.PCloudEnvName,
 			},
-			Release: installer.Release{
-				Namespace: env.Name,
+			Values: map[string]any{
+				"privateKey": string(ssKeys.RawPrivateKey()),
+				"publicKey":  string(ssKeys.RawAuthorizedKey()),
+				"adminKey":   string(ssAdminKeys.RawAuthorizedKey()),
 			},
-			Values: ssValues,
 		}
-		if err := st.nsCreator.Create(env.Name); err != nil {
-			return err
-		}
-		if err := st.repo.InstallApp(ssApp, filepath.Join("/environments", env.Name, "config-repo"), ssValues, derived); err != nil {
-			return err
-		}
-		return nil
+		return installer.InstallApp(st.repo, st.nsCreator, ssApp, filepath.Join("/environments", env.Name, "config-repo"), env.Name, derived.Values, derived)
 	})
 	return &t
 }
@@ -116,24 +106,24 @@
 		if err != nil {
 			return err
 		}
-		repoIO := installer.NewRepoIO(repo, st.ssClient.Signer)
-		if err := func() error {
-			w, err := repoIO.Writer("README.md")
+		repoIO, err := installer.NewRepoIO(repo, st.ssClient.Signer)
+		if err != nil {
+			return err
+		}
+		if err := repoIO.Atomic(func(r installer.RepoFS) (string, error) {
+			w, err := r.Writer("README.md")
 			if err != nil {
-				return err
+				return "", err
 			}
 			defer w.Close()
 			if _, err := fmt.Fprintf(w, "# %s PCloud environment", env.Name); err != nil {
-				return err
+				return "", err
 			}
-			return nil
-		}(); err != nil {
-			return err
-		}
-		if err := repoIO.WriteKustomization("kustomization.yaml", installer.NewKustomization()); err != nil {
-			return err
-		}
-		if err := repoIO.CommitAndPush("init"); err != nil {
+			if err := installer.WriteYaml(r, "kustomization.yaml", installer.NewKustomization()); err != nil {
+				return "", err
+			}
+			return "init", nil
+		}); err != nil {
 			return err
 		}
 		if err := st.ssClient.AddUser(st.fluxUserName, keys.AuthorizedKey()); err != nil {