Installer: Clean up RepoIO interface

Change-Id: If80d7be1460c725b7df9d1d27c9354cb9141acfe
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 295c8ff..e438a26 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -1,8 +1,14 @@
 package installer
 
 import (
+	"bytes"
+	"encoding/json"
+	"errors"
 	"fmt"
+	"io/fs"
 	"io/ioutil"
+	"net/http"
+	"path"
 	"path/filepath"
 
 	"sigs.k8s.io/yaml"
@@ -25,15 +31,58 @@
 }
 
 func (m *AppManager) Config() (Config, error) {
-	return m.repoIO.ReadConfig()
+	var cfg Config
+	if err := ReadYaml(m.repoIO, configFileName, &cfg); err != nil {
+		return Config{}, err
+	} else {
+		return cfg, nil
+	}
+}
+
+func (m *AppManager) appConfig(path string) (AppConfig, error) {
+	var cfg AppConfig
+	if err := ReadYaml(m.repoIO, path, &cfg); err != nil {
+		return AppConfig{}, err
+	} else {
+		return cfg, nil
+	}
 }
 
 func (m *AppManager) FindAllInstances(name string) ([]AppConfig, error) {
-	return m.repoIO.FindAllInstances(appDir, name)
+	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDir, "kustomization.yaml"))
+	if err != nil {
+		return nil, err
+	}
+	ret := make([]AppConfig, 0)
+	for _, app := range kust.Resources {
+		cfg, err := m.appConfig(filepath.Join(appDir, app, "config.yaml"))
+		if err != nil {
+			return nil, err
+		}
+		cfg.Id = app
+		if cfg.AppId == name {
+			ret = append(ret, cfg)
+		}
+	}
+	return ret, nil
 }
 
 func (m *AppManager) FindInstance(id string) (AppConfig, error) {
-	return m.repoIO.FindInstance(appDir, id)
+	kust, err := ReadKustomization(m.repoIO, filepath.Join(appDir, "kustomization.yaml"))
+	if err != nil {
+		return AppConfig{}, err
+	}
+	for _, app := range kust.Resources {
+		if app == id {
+			cfg, err := m.appConfig(filepath.Join(appDir, app, "config.yaml"))
+			if err != nil {
+				return AppConfig{}, err
+			}
+			cfg.Id = id
+			return cfg, nil
+		}
+	}
+	return AppConfig{}, nil
 }
 
 func (m *AppManager) AppConfig(name string) (AppConfig, error) {
@@ -51,58 +100,146 @@
 	return cfg, err
 }
 
-func (m *AppManager) Install(app App, ns NamespaceGenerator, suffixGen SuffixGenerator, config map[string]any) error {
+type allocatePortReq struct {
+	Protocol      string `json:"protocol"`
+	SourcePort    int    `json:"sourcePort"`
+	TargetService string `json:"targetService"`
+	TargetPort    int    `json:"targetPort"`
+}
+
+func openPorts(ports []PortForward) error {
+	for _, p := range ports {
+		var buf bytes.Buffer
+		req := allocatePortReq{
+			Protocol:      p.Protocol,
+			SourcePort:    p.SourcePort,
+			TargetService: p.TargetService,
+			TargetPort:    p.TargetPort,
+		}
+		if err := json.NewEncoder(&buf).Encode(req); err != nil {
+			return err
+		}
+		resp, err := http.Post(p.Allocator, "application/json", &buf)
+		if err != nil {
+			return err
+		}
+		if resp.StatusCode != http.StatusOK {
+			return fmt.Errorf("Could not allocate port %d, status code: %d", p.SourcePort, resp.StatusCode)
+		}
+	}
+	return nil
+}
+
+func createKustomizationChain(r RepoFS, path string) error {
+	for p := filepath.Clean(path); p != "/"; {
+		parent, child := filepath.Split(p)
+		kustPath := filepath.Join(parent, "kustomization.yaml")
+		kust, err := ReadKustomization(r, kustPath)
+		if err != nil {
+			if errors.Is(err, fs.ErrNotExist) {
+				k := NewKustomization()
+				kust = &k
+			} else {
+				return err
+			}
+		}
+		kust.AddResources(child)
+		if err := WriteYaml(r, kustPath, kust); err != nil {
+			return err
+		}
+		p = filepath.Clean(parent)
+	}
+	return nil
+}
+
+func InstallApp(repo RepoIO, nsc NamespaceCreator, app App, appDir string, namespace string, initValues map[string]any, derived Derived) error {
+	if err := nsc.Create(namespace); err != nil {
+		return err
+	}
+	derived.Release = Release{
+		Namespace: namespace,
+		RepoAddr:  repo.FullAddress(),
+		AppDir:    appDir,
+	}
+	rendered, err := app.Render(derived)
+	if err != nil {
+		return err
+	}
+	if err := openPorts(rendered.Ports); err != nil {
+		return err
+	}
+	return repo.Atomic(func(r RepoFS) (string, error) {
+		if err := createKustomizationChain(r, appDir); err != nil {
+			return "", err
+		}
+		{
+			if err := r.RemoveDir(appDir); err != nil {
+				return "", err
+			}
+			if err := r.CreateDir(appDir); err != nil {
+				return "", err
+			}
+			cfg := AppConfig{
+				AppId:   app.Name(),
+				Config:  initValues,
+				Derived: derived,
+			}
+			if err := WriteYaml(r, path.Join(appDir, configFileName), cfg); err != nil {
+				return "", err
+			}
+		}
+		{
+			appKust := NewKustomization()
+			for name, contents := range rendered.Resources {
+				appKust.AddResources(name)
+				out, err := r.Writer(path.Join(appDir, name))
+				if err != nil {
+					return "", err
+				}
+				defer out.Close()
+				if _, err := out.Write(contents); err != nil {
+					return "", err
+				}
+			}
+			if err := WriteYaml(r, path.Join(appDir, "kustomization.yaml"), appKust); err != nil {
+				return "", err
+			}
+		}
+		return fmt.Sprintf("install: %s", app.Name()), nil
+	})
+}
+
+func (m *AppManager) Install(app App, appDir string, namespace string, values map[string]any) error {
+	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	suffix, err := suffixGen.Generate()
+	globalConfig, err := m.Config()
 	if err != nil {
 		return err
 	}
-	nms, err := ns.Generate(app.Namespace())
+	derivedValues, err := deriveValues(values, app.Schema(), CreateNetworks(globalConfig))
 	if err != nil {
 		return err
 	}
-	nms = nms + suffix
-	if err := m.nsCreator.Create(nms); err != nil {
-		return err
-	}
-	globalConfig, err := m.repoIO.ReadConfig()
-	if err != nil {
-		return err
-	}
-	derivedValues, err := deriveValues(config, app.Schema(), CreateNetworks(globalConfig))
-	if err != nil {
-		fmt.Println(err)
-		return err
-	}
 	derived := Derived{
 		Global: globalConfig.Values,
 		Values: derivedValues,
 	}
-	derived.Release.Namespace = nms
-	fmt.Printf("%+v\n", derived)
-	err = m.repoIO.InstallApp(
-		app,
-		filepath.Join(appDir, app.Name()+suffix),
-		config,
-		derived,
-	)
-	fmt.Println(err)
-	return err
+	return InstallApp(m.repoIO, m.nsCreator, app, appDir, namespace, values, derived)
 }
 
 func (m *AppManager) Update(app App, instanceId string, config map[string]any) error {
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	globalConfig, err := m.repoIO.ReadConfig()
+	globalConfig, err := m.Config()
 	if err != nil {
 		return err
 	}
 	instanceDir := filepath.Join(appDir, instanceId)
 	instanceConfigPath := filepath.Join(instanceDir, configFileName)
-	appConfig, err := m.repoIO.ReadAppConfig(instanceConfigPath)
+	appConfig, err := m.appConfig(instanceConfigPath)
 	if err != nil {
 		return err
 	}
@@ -115,14 +252,24 @@
 		Release: appConfig.Derived.Release,
 		Values:  derivedValues,
 	}
-	return m.repoIO.InstallApp(app, instanceDir, config, derived)
+	return InstallApp(m.repoIO, m.nsCreator, app, instanceDir, appConfig.Derived.Release.Namespace, config, derived)
 }
 
 func (m *AppManager) Remove(instanceId string) error {
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	return m.repoIO.RemoveApp(filepath.Join(appDir, instanceId))
+	return m.repoIO.Atomic(func(r RepoFS) (string, error) {
+		r.RemoveDir(filepath.Join(appDir, instanceId))
+		kustPath := filepath.Join(appDir, "kustomization.yaml")
+		kust, err := ReadKustomization(r, kustPath)
+		if err != nil {
+			return "", err
+		}
+		kust.RemoveResources(instanceId)
+		WriteYaml(r, kustPath, kust)
+		return fmt.Sprintf("uninstall: %s", instanceId), nil
+	})
 }
 
 // TODO(gio): deduplicate with cue definition in app.go, this one should be removed.
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index b981573..ad01140 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -24,13 +24,14 @@
 const dnsAPIConfigMapName = "api-config"
 
 type Bootstrapper struct {
-	cl ChartLoader
-	ns NamespaceCreator
-	ha HelmActionConfigFactory
+	cl      ChartLoader
+	ns      NamespaceCreator
+	ha      HelmActionConfigFactory
+	appRepo AppRepository
 }
 
-func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory) Bootstrapper {
-	return Bootstrapper{cl, ns, ha}
+func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory, appRepo AppRepository) Bootstrapper {
+	return Bootstrapper{cl, ns, ha, appRepo}
 }
 
 func (b Bootstrapper) Run(env EnvConfig) error {
@@ -75,30 +76,32 @@
 		fmt.Println("Failed to get config repo")
 		return err
 	}
-	repoIO := NewRepoIO(repo, ss.Signer)
+	repoIO, err := NewRepoIO(repo, ss.Signer)
+	if err != nil {
+		return err
+	}
 	fmt.Println("Configuring main repo")
 	if err := configureMainRepo(repoIO, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing infrastructure services")
-	nsGen := NewPrefixGenerator(env.NamespacePrefix)
-	if err := b.installInfrastructureServices(repoIO, nsGen, b.ns, env); err != nil {
+	if err := b.installInfrastructureServices(repoIO, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing DNS Zone Manager")
-	if err := b.installDNSZoneManager(ss, repoIO, nsGen, b.ns, env); err != nil {
+	if err := b.installDNSZoneManager(repoIO, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing Fluxcd Reconciler")
-	if err := b.installFluxcdReconciler(ss, repoIO, nsGen, b.ns, env); err != nil {
+	if err := b.installFluxcdReconciler(repoIO, ss, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing env manager")
-	if err := b.installEnvManager(ss, repoIO, nsGen, b.ns, env); err != nil {
+	if err := b.installEnvManager(repoIO, ss, env); err != nil {
 		return err
 	}
 	fmt.Println("Installing Ory Hydra Maester")
-	if err := b.installOryHydraMaester(ss, repoIO, nsGen, b.ns, env); err != nil {
+	if err := b.installOryHydraMaester(repoIO, env); err != nil {
 		return err
 	}
 	fmt.Println("Environment ready to use")
@@ -320,8 +323,20 @@
 	if err != nil {
 		return err
 	}
-	repoIO := NewRepoIO(repo, ss.Signer)
-	if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s systems", envName), "readme"); err != nil {
+	repoIO, err := NewRepoIO(repo, ss.Signer)
+	if err != nil {
+		return err
+	}
+	if err := repoIO.Atomic(func(r RepoFS) (string, error) {
+		w, err := r.Writer("README.md")
+		if err != nil {
+			return "", err
+		}
+		if _, err := fmt.Fprintf(w, "# %s systems", envName); err != nil {
+			return "", err
+		}
+		return "readme", nil
+	}); err != nil {
 		return err
 	}
 	fmt.Println("Installing Flux")
@@ -380,31 +395,20 @@
 	return nil
 }
 
-func (b Bootstrapper) installInfrastructureServices(repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
-	appRepo := NewInMemoryAppRepository(CreateAllApps())
+func (b Bootstrapper) installInfrastructureServices(repo RepoIO, env EnvConfig) error {
 	install := func(name string) error {
 		fmt.Printf("Installing infrastructure service %s\n", name)
-		app, err := appRepo.Find(name)
+		app, err := b.appRepo.Find(name)
 		if err != nil {
 			return err
 		}
-		nms, err := nsGen.Generate(app.Namespace())
-		if err != nil {
-			return err
-		}
-		if err := nsCreator.Create(nms); err != nil {
-			return err
-		}
+		namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
 		derived := Derived{
 			Global: Values{
 				PCloudEnvName: env.Name,
 			},
-			Release: Release{},
-			Values:  make(map[string]any),
 		}
-		derived.Release.Namespace = nms
-		values := map[string]any{}
-		return repo.InstallApp(app, filepath.Join("/infrastructure", app.Name()), values, derived)
+		return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
 	}
 	appsToInstall := []string{
 		"resource-renderer-controller",
@@ -422,28 +426,29 @@
 }
 
 func configureMainRepo(repo RepoIO, env EnvConfig) error {
-	if err := repo.WriteYaml("config.yaml", env); err != nil {
-		return err
-	}
-	if err := repo.WriteYaml("env-cidrs.yaml", EnvCIDRs{}); err != nil {
-		return err
-	}
-	kust := NewKustomization()
-	kust.AddResources(
-		fmt.Sprintf("%s-flux", env.Name),
-		"infrastructure",
-		"environments",
-	)
-	if err := repo.WriteKustomization("kustomization.yaml", kust); err != nil {
-		return err
-	}
-	{
-		out, err := repo.Writer("infrastructure/pcloud-charts.yaml")
-		if err != nil {
-			return err
+	return repo.Atomic(func(r RepoFS) (string, error) {
+		if err := WriteYaml(r, "config.yaml", env); err != nil {
+			return "", err
 		}
-		defer out.Close()
-		_, err = out.Write([]byte(fmt.Sprintf(`
+		if err := WriteYaml(r, "env-cidrs.yaml", EnvCIDRs{}); err != nil {
+			return "", err
+		}
+		kust := NewKustomization()
+		kust.AddResources(
+			fmt.Sprintf("%s-flux", env.Name),
+			"infrastructure",
+			"environments",
+		)
+		if err := WriteYaml(r, "kustomization.yaml", kust); err != nil {
+			return "", err
+		}
+		{
+			out, err := r.Writer("infrastructure/pcloud-charts.yaml")
+			if err != nil {
+				return "", err
+			}
+			defer out.Close()
+			_, err = out.Write([]byte(fmt.Sprintf(`
 apiVersion: source.toolkit.fluxcd.io/v1
 kind: GitRepository
 metadata:
@@ -455,25 +460,23 @@
   ref:
     branch: main
 `, env.Name)))
-		if err != nil {
-			return err
+			if err != nil {
+				return "", err
+			}
 		}
-	}
-	infraKust := NewKustomization()
-	infraKust.AddResources("pcloud-charts.yaml")
-	if err := repo.WriteKustomization("infrastructure/kustomization.yaml", infraKust); err != nil {
-		return err
-	}
-	if err := repo.WriteKustomization("environments/kustomization.yaml", NewKustomization()); err != nil {
-		return err
-	}
-	if err := repo.CommitAndPush("initialize pcloud directory structure"); err != nil {
-		return err
-	}
-	return nil
+		infraKust := NewKustomization()
+		infraKust.AddResources("pcloud-charts.yaml")
+		if err := WriteYaml(r, "infrastructure/kustomization.yaml", infraKust); err != nil {
+			return "", err
+		}
+		if err := WriteYaml(r, "environments/kustomization.yaml", NewKustomization()); err != nil {
+			return "", err
+		}
+		return "initialize pcloud directory structure", nil
+	})
 }
 
-func (b Bootstrapper) installEnvManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
+func (b Bootstrapper) installEnvManager(repo RepoIO, ss *soft.Client, env EnvConfig) error {
 	keys, err := NewSSHKeyPair("env-manager")
 	if err != nil {
 		return err
@@ -485,18 +488,11 @@
 	if err := ss.MakeUserAdmin(user); err != nil {
 		return err
 	}
-	appRepo := NewInMemoryAppRepository(CreateAllApps())
-	app, err := appRepo.Find("env-manager")
+	app, err := b.appRepo.Find("env-manager")
 	if err != nil {
 		return err
 	}
-	nms, err := nsGen.Generate(app.Namespace())
-	if err != nil {
-		return err
-	}
-	if err := nsCreator.Create(nms); err != nil {
-		return err
-	}
+	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
 	derived := Derived{
 		Global: Values{
 			PCloudEnvName: env.Name,
@@ -508,100 +504,61 @@
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 		},
 	}
-	derived.Release.Namespace = nms
-	return repo.InstallApp(app, filepath.Join("/infrastructure", app.Name()), derived.Values, derived)
+	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, derived.Values, derived)
 }
 
-func (b Bootstrapper) installOryHydraMaester(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
-	appRepo := NewInMemoryAppRepository(CreateAllApps())
-	app, err := appRepo.Find("hydra-maester")
+func (b Bootstrapper) installOryHydraMaester(repo RepoIO, env EnvConfig) error {
+	app, err := b.appRepo.Find("hydra-maester")
 	if err != nil {
 		return err
 	}
-	nms, err := nsGen.Generate(app.Namespace())
-	if err != nil {
-		return err
-	}
-	if err := nsCreator.Create(nms); err != nil {
-		return err
-	}
+	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
 	derived := Derived{
 		Global: Values{
 			PCloudEnvName: env.Name,
 		},
-		Values: map[string]any{},
 	}
-	derived.Release.Namespace = nms
-	return repo.InstallApp(app, filepath.Join("/infrastructure", app.Name()), derived.Values, derived)
+	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
 }
 
-func (b Bootstrapper) installDNSZoneManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
+func (b Bootstrapper) installDNSZoneManager(repo RepoIO, env EnvConfig) error {
 	const (
 		volumeClaimName = "dns-zone-configs"
 		volumeMountPath = "/etc/pcloud/dns-zone-configs"
 	)
-	ns, err := nsGen.Generate("dns-zone-manager")
+	app, err := b.appRepo.Find("dns-zone-manager")
 	if err != nil {
 		return err
 	}
-	if err := nsCreator.Create(ns); err != nil {
-		return err
-	}
-	appRepo := NewInMemoryAppRepository(CreateAllApps())
-	{
-		app, err := appRepo.Find("dns-zone-manager")
-		if err != nil {
-			return err
-		}
-		derived := Derived{
-			Global: Values{
-				PCloudEnvName: env.Name,
-			},
-			Values: map[string]any{
-				"volume": map[string]any{
-					"claimName": volumeClaimName,
-					"mountPath": volumeMountPath,
-					"size":      "1Gi",
-				},
-				"apiConfigMapName": dnsAPIConfigMapName,
-			},
-			Release: Release{
-				Namespace: ns,
-			},
-		}
-		if err := repo.InstallApp(app, filepath.Join("/infrastructure", app.Name()), derived.Values, derived); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func (b Bootstrapper) installFluxcdReconciler(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
-	appRepo := NewInMemoryAppRepository(CreateAllApps())
-	app, err := appRepo.Find("fluxcd-reconciler")
-	if err != nil {
-		return err
-	}
-	ns, err := nsGen.Generate(app.Namespace())
-	if err != nil {
-		return err
-	}
-	if err := nsCreator.Create(ns); err != nil {
-		return err
-	}
+	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
 	derived := Derived{
 		Global: Values{
 			PCloudEnvName: env.Name,
 		},
-		Values: map[string]any{},
-		Release: Release{
-			Namespace: ns,
+		Values: map[string]any{
+			"volume": map[string]any{
+				"claimName": volumeClaimName,
+				"mountPath": volumeMountPath,
+				"size":      "1Gi",
+			},
+			"apiConfigMapName": dnsAPIConfigMapName,
 		},
 	}
-	if err := repo.InstallApp(app, filepath.Join("/infrastructure", app.Name()), derived.Values, derived); err != nil {
+	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, derived.Values, derived)
+}
+
+func (b Bootstrapper) installFluxcdReconciler(repo RepoIO, ss *soft.Client, env EnvConfig) error {
+	app, err := b.appRepo.Find("fluxcd-reconciler")
+	if err != nil {
 		return err
 	}
-	return nil
+	namespace := fmt.Sprintf("%s-%s", env.Name, app.Namespace())
+	derived := Derived{
+		Global: Values{
+			PCloudEnvName: env.Name,
+		},
+	}
+	return InstallApp(repo, b.ns, app, filepath.Join("/infrastructure", app.Name()), namespace, nil, derived)
 }
 
 type HelmActionConfigFactory interface {
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 47210fc..621ad87 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -72,12 +72,10 @@
 		return err
 	}
 	log.Println("Cloned repository")
-	repoIO := installer.NewRepoIO(repo, signer)
-	config, err := repoIO.ReadConfig()
+	repoIO, err := installer.NewRepoIO(repo, signer)
 	if err != nil {
 		return err
 	}
-	log.Println("Read config")
 	kube, err := newNSCreator()
 	if err != nil {
 		return err
@@ -86,6 +84,11 @@
 	if err != nil {
 		return err
 	}
+	config, err := m.Config()
+	if err != nil {
+		return err
+	}
+	log.Println("Read config")
 	log.Println("Creating repository")
 	var r installer.AppRepository
 	if appManagerFlags.appRepoAddr != "" {
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index 9b68529..e6385b5 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -106,6 +106,7 @@
 		installer.NewFSChartLoader(bootstrapFlags.chartsDir),
 		nsCreator,
 		installer.NewActionConfigFactory(rootFlags.kubeConfig),
+		installer.NewInMemoryAppRepository(installer.CreateAllApps()),
 	)
 	return b.Run(envConfig)
 }
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
index 196110f..0142bee 100644
--- a/core/installer/cmd/env_manager.go
+++ b/core/installer/cmd/env_manager.go
@@ -64,7 +64,10 @@
 		return err
 	}
 	log.Printf("Cloned repo: %s\n", envManagerFlags.repoName)
-	repoIO := installer.NewRepoIO(repo, sshKey.Signer())
+	repoIO, err := installer.NewRepoIO(repo, sshKey.Signer())
+	if err != nil {
+		return err
+	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
 		return err
diff --git a/core/installer/cmd/welcome.go b/core/installer/cmd/welcome.go
index 43578b2..4390fcf 100644
--- a/core/installer/cmd/welcome.go
+++ b/core/installer/cmd/welcome.go
@@ -80,13 +80,17 @@
 	if err != nil {
 		return err
 	}
+	repoIO, err := installer.NewRepoIO(repo, signer)
+	if err != nil {
+		return err
+	}
 	nsCreator, err := newNSCreator()
 	if err != nil {
 		return err
 	}
 	s := welcome.NewServer(
 		welcomeFlags.port,
-		installer.NewRepoIO(repo, signer),
+		repoIO,
 		nsCreator,
 		welcomeFlags.createAccountAddr,
 		welcomeFlags.loginAddr,
diff --git a/core/installer/derived.go b/core/installer/derived.go
new file mode 100644
index 0000000..813939e
--- /dev/null
+++ b/core/installer/derived.go
@@ -0,0 +1,168 @@
+package installer
+
+import (
+	"fmt"
+)
+
+type Release struct {
+	Namespace string `json:"namespace"`
+	RepoAddr  string `json:"repoAddr"`
+	AppDir    string `json:"appDir"`
+}
+
+type Derived struct {
+	Release Release        `json:"release"`
+	Global  Values         `json:"global"`
+	Values  map[string]any `json:"input"` // TODO(gio): rename to input
+}
+
+type Network struct {
+	Name              string `json:"name,omitempty"`
+	IngressClass      string `json:"ingressClass,omitempty"`
+	CertificateIssuer string `json:"certificateIssuer,omitempty"`
+	Domain            string `json:"domain,omitempty"`
+	AllocatePortAddr  string `json:"allocatePortAddr,omitempty"`
+}
+
+type AppConfig struct {
+	Id      string         `json:"id"`
+	AppId   string         `json:"appId"`
+	Config  map[string]any `json:"config"`
+	Derived Derived        `json:"derived"`
+}
+
+func (a AppConfig) Input(schema Schema) map[string]any {
+	ret, err := derivedToConfig(a.Derived.Values, schema)
+	if err != nil {
+		panic(err) // TODO(gio): handle
+	}
+	return ret
+}
+
+func deriveValues(values any, schema Schema, networks []Network) (map[string]any, error) {
+	ret := make(map[string]any)
+	for k, def := range schema.Fields() {
+		// TODO(gio): validate that it is map
+		v, ok := values.(map[string]any)[k]
+		// TODO(gio): if missing use default value
+		if !ok {
+			if def.Kind() == KindSSHKey {
+				key, err := NewECDSASSHKeyPair("tmp")
+				if err != nil {
+					return nil, err
+				}
+				ret[k] = map[string]string{
+					"public":  string(key.RawAuthorizedKey()),
+					"private": string(key.RawPrivateKey()),
+				}
+			}
+			continue
+		}
+		switch def.Kind() {
+		case KindBoolean:
+			ret[k] = v
+		case KindString:
+			ret[k] = v
+		case KindInt:
+			ret[k] = v
+		case KindNetwork:
+			n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = n
+		case KindAuth:
+			r, err := deriveValues(v, AuthSchema, networks)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		case KindSSHKey:
+			r, err := deriveValues(v, SSHKeySchema, networks)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		case KindStruct:
+			r, err := deriveValues(v, def, networks)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		default:
+			return nil, fmt.Errorf("Should not reach!")
+		}
+	}
+	return ret, nil
+}
+
+func derivedToConfig(derived map[string]any, schema Schema) (map[string]any, error) {
+	ret := make(map[string]any)
+	for k, def := range schema.Fields() {
+		v, ok := derived[k]
+		// TODO(gio): if missing use default value
+		if !ok {
+			continue
+		}
+		switch def.Kind() {
+		case KindBoolean:
+			ret[k] = v
+		case KindString:
+			ret[k] = v
+		case KindInt:
+			ret[k] = v
+		case KindNetwork:
+			vm, ok := v.(map[string]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			name, ok := vm["name"]
+			if !ok {
+				return nil, fmt.Errorf("expected network name")
+			}
+			ret[k] = name
+		case KindAuth:
+			vm, ok := v.(map[string]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			r, err := derivedToConfig(vm, AuthSchema)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		case KindSSHKey:
+			vm, ok := v.(map[string]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			r, err := derivedToConfig(vm, SSHKeySchema)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		case KindStruct:
+			vm, ok := v.(map[string]any)
+			if !ok {
+				return nil, fmt.Errorf("expected map")
+			}
+			r, err := derivedToConfig(vm, def)
+			if err != nil {
+				return nil, err
+			}
+			ret[k] = r
+		default:
+			return nil, fmt.Errorf("Should not reach!")
+		}
+	}
+	return ret, nil
+}
+
+func findNetwork(networks []Network, name string) (Network, error) {
+	for _, n := range networks {
+		if n.Name == name {
+			return n, nil
+		}
+	}
+	return Network{}, fmt.Errorf("Network not found: %s", name)
+}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index 6371550..877a084 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -1,8 +1,10 @@
 module github.com/giolekva/pcloud/core/installer
 
+replace github.com/giolekva/pcloud/installer => /Users/lekva/dev/src/pcloud/core/installer
+
 go 1.21
 
-toolchain go1.21.5
+// toolchain go1.21.5
 
 require (
 	cuelang.org/go v0.8.1
diff --git a/core/installer/kustomization.go b/core/installer/kustomization.go
index 1db9614..466c140 100644
--- a/core/installer/kustomization.go
+++ b/core/installer/kustomization.go
@@ -4,7 +4,6 @@
 	"bytes"
 	"golang.org/x/exp/slices"
 	"io"
-	"io/ioutil"
 
 	"sigs.k8s.io/yaml"
 )
@@ -23,18 +22,6 @@
 	}
 }
 
-func ReadKustomization(r io.Reader) (*Kustomization, error) {
-	contents, err := ioutil.ReadAll(r)
-	if err != nil {
-		return nil, err
-	}
-	var ret Kustomization
-	if err = yaml.UnmarshalStrict(contents, &ret); err != nil {
-		return nil, err
-	}
-	return &ret, nil
-}
-
 func (k Kustomization) Write(w io.Writer) error {
 	contents, err := yaml.Marshal(k)
 	if err != nil {
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index 1d0bf7f..ffb4078 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -1,21 +1,16 @@
 package installer
 
 import (
-	"bytes"
-	"encoding/json"
 	"errors"
-	"fmt"
 	"io"
 	"io/fs"
 	"io/ioutil"
 	"net"
-	"net/http"
-	"os"
-	"path"
 	"path/filepath"
 	"sync"
 	"time"
 
+	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/util"
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing/object"
@@ -26,43 +21,74 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-type RepoIO interface {
-	Addr() string
-	Pull() error
-	ReadConfig() (Config, error)
-	ReadAppConfig(path string) (AppConfig, error)
-	ReadKustomization(path string) (*Kustomization, error)
-	WriteKustomization(path string, kust Kustomization) error
-	ReadYaml(path string) (map[string]any, error)
-	WriteYaml(path string, data any) error
-	CommitAndPush(message string) error
-	WriteCommitAndPush(path, contents, message string) error
+type RepoFS interface {
 	Reader(path string) (io.ReadCloser, error)
 	Writer(path string) (io.WriteCloser, error)
 	CreateDir(path string) error
 	RemoveDir(path string) error
-	InstallApp(app App, path string, values map[string]any, derived Derived) error
-	RemoveApp(path string) error
-	FindAllInstances(root string, appId string) ([]AppConfig, error)
-	FindInstance(root string, id string) (AppConfig, error)
+}
+
+type AtomicOp func(r RepoFS) (string, error)
+
+type RepoIO interface {
+	RepoFS
+	FullAddress() string
+	Pull() error
+	CommitAndPush(message string) error
+	Atomic(op AtomicOp) error
+}
+
+type repoFS struct {
+	fs billy.Filesystem
+}
+
+func (r *repoFS) Reader(path string) (io.ReadCloser, error) {
+	return r.fs.Open(path)
+}
+
+func (r *repoFS) Writer(path string) (io.WriteCloser, error) {
+	if err := r.fs.MkdirAll(filepath.Dir(path), fs.ModePerm); err != nil {
+		return nil, err
+	}
+	return r.fs.Create(path)
+}
+
+func (r *repoFS) CreateDir(path string) error {
+	return r.fs.MkdirAll(path, fs.ModePerm)
+}
+
+func (r *repoFS) RemoveDir(path string) error {
+	if err := util.RemoveAll(r.fs, path); err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return nil
+		}
+		return err
+	}
+	return nil
 }
 
 type repoIO struct {
+	*repoFS
 	repo   *soft.Repository
 	signer ssh.Signer
 	l      sync.Locker
 }
 
-func NewRepoIO(repo *soft.Repository, signer ssh.Signer) RepoIO {
+func NewRepoIO(repo *soft.Repository, signer ssh.Signer) (RepoIO, error) {
+	wt, err := repo.Worktree()
+	if err != nil {
+		return nil, err
+	}
 	return &repoIO{
+		&repoFS{wt.Filesystem},
 		repo,
 		signer,
 		&sync.Mutex{},
-	}
+	}, nil
 }
 
-func (r *repoIO) Addr() string {
-	return r.repo.Addr.Addr
+func (r *repoIO) FullAddress() string {
+	return r.repo.Addr.FullAddress()
 }
 
 func (r *repoIO) Pull() error {
@@ -74,121 +100,12 @@
 func (r *repoIO) pullWithoutLock() error {
 	wt, err := r.repo.Worktree()
 	if err != nil {
-		fmt.Printf("EEEER wt: %s\b", err)
 		return nil
 	}
-	err = wt.Pull(&git.PullOptions{
+	return wt.Pull(&git.PullOptions{
 		Auth:  auth(r.signer),
 		Force: true,
 	})
-	// TODO(gio): propagate error
-	if err != nil {
-		fmt.Printf("EEEER: %s\b", err)
-	}
-	return nil
-}
-
-func (r *repoIO) ReadConfig() (Config, error) {
-	configF, err := r.Reader(configFileName)
-	if err != nil {
-		return Config{}, err
-	}
-	defer configF.Close()
-	var cfg Config
-	if err := ReadYaml(configF, &cfg); err != nil {
-		return Config{}, err
-	} else {
-		return cfg, nil
-	}
-}
-
-func (r *repoIO) ReadAppConfig(path string) (AppConfig, error) {
-	configF, err := r.Reader(path)
-	if err != nil {
-		return AppConfig{}, err
-	}
-	defer configF.Close()
-	var cfg AppConfig
-	if err := ReadYaml(configF, &cfg); err != nil {
-		return AppConfig{}, err
-	} else {
-		return cfg, nil
-	}
-}
-
-func (r *repoIO) ReadKustomization(path string) (*Kustomization, error) {
-	inp, err := r.Reader(path)
-	if err != nil {
-		return nil, err
-	}
-	defer inp.Close()
-	return ReadKustomization(inp)
-}
-
-func (r *repoIO) Reader(path string) (io.ReadCloser, error) {
-	wt, err := r.repo.Worktree()
-	if err != nil {
-		return nil, err
-	}
-	return wt.Filesystem.Open(path)
-}
-
-func (r *repoIO) Writer(path string) (io.WriteCloser, error) {
-	wt, err := r.repo.Worktree()
-	if err != nil {
-		return nil, err
-	}
-	if err := wt.Filesystem.MkdirAll(filepath.Dir(path), fs.ModePerm); err != nil {
-		return nil, err
-	}
-	return wt.Filesystem.Create(path)
-}
-
-func (r *repoIO) WriteKustomization(path string, kust Kustomization) error {
-	out, err := r.Writer(path)
-	if err != nil {
-		return err
-	}
-	return kust.Write(out)
-}
-
-func (r *repoIO) WriteYaml(path string, data any) error {
-	out, err := r.Writer(path)
-	if err != nil {
-		return err
-	}
-	serialized, err := yaml.Marshal(data)
-	if err != nil {
-		return err
-	}
-	if _, err := out.Write(serialized); err != nil {
-		return err
-	}
-	return nil
-}
-
-func (r *repoIO) ReadYaml(path string) (map[string]any, error) {
-	inp, err := r.Reader(path)
-	if err != nil {
-		return nil, err
-	}
-	data := make(map[string]any)
-	if err := ReadYaml(inp, &data); err != nil {
-		return nil, err
-	}
-	return data, err
-}
-
-func (r *repoIO) WriteCommitAndPush(path, contents, message string) error {
-	w, err := r.Writer(path)
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-	if _, err := io.WriteString(w, contents); err != nil {
-		return err
-	}
-	return r.CommitAndPush(message)
 }
 
 func (r *repoIO) CommitAndPush(message string) error {
@@ -213,212 +130,17 @@
 	})
 }
 
-func (r *repoIO) CreateDir(path string) error {
-	wt, err := r.repo.Worktree()
-	if err != nil {
-		return err
-	}
-	return wt.Filesystem.MkdirAll(path, fs.ModePerm)
-}
-
-func (r *repoIO) RemoveDir(path string) error {
-	wt, err := r.repo.Worktree()
-	if err != nil {
-		return err
-	}
-	err = util.RemoveAll(wt.Filesystem, path)
-	if err == nil || errors.Is(err, fs.ErrNotExist) {
-		return nil
-	}
-	return err
-}
-
-type Release struct {
-	Namespace string `json:"namespace"`
-	RepoAddr  string `json:"repoAddr"`
-	AppDir    string `json:"appDir"`
-}
-
-type Derived struct {
-	Release Release        `json:"release"`
-	Global  Values         `json:"global"`
-	Values  map[string]any `json:"input"` // TODO(gio): rename to input
-}
-
-type AppConfig struct {
-	Id      string         `json:"id"`
-	AppId   string         `json:"appId"`
-	Config  map[string]any `json:"config"`
-	Derived Derived        `json:"derived"`
-}
-
-func (a AppConfig) Input(schema Schema) map[string]any {
-	ret, err := derivedToConfig(a.Derived.Values, schema)
-	if err != nil {
-		panic(err) // TODO(gio): handle
-	}
-	return ret
-}
-
-type allocatePortReq struct {
-	Protocol      string `json:"protocol"`
-	SourcePort    int    `json:"sourcePort"`
-	TargetService string `json:"targetService"`
-	TargetPort    int    `json:"targetPort"`
-}
-
-// TODO(gio): most of this logic should move to AppManager
-func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any, derived Derived) error {
+func (r *repoIO) Atomic(op AtomicOp) error {
 	r.l.Lock()
 	defer r.l.Unlock()
 	if err := r.pullWithoutLock(); err != nil {
 		return err
 	}
-	if !filepath.IsAbs(appRootDir) {
-		return fmt.Errorf("Expected absolute path: %s", appRootDir)
-	}
-	derived.Release.RepoAddr = r.repo.Addr.FullAddress()
-	// TODO(gio): maybe client should populate this?
-	derived.Release.AppDir = appRootDir
-	rendered, err := app.Render(derived)
-	if err != nil {
+	if msg, err := op(r); err != nil {
 		return err
+	} else {
+		return r.CommitAndPush(msg)
 	}
-	for _, p := range rendered.Ports {
-		var buf bytes.Buffer
-		req := allocatePortReq{
-			Protocol:      p.Protocol,
-			SourcePort:    p.SourcePort,
-			TargetService: p.TargetService,
-			TargetPort:    p.TargetPort,
-		}
-		fmt.Printf("%+v\n", req)
-		if err := json.NewEncoder(&buf).Encode(req); err != nil {
-			return err
-		}
-		resp, err := http.Post(p.Allocator, "application/json", &buf)
-		if err != nil {
-			return err
-		}
-		if resp.StatusCode != http.StatusOK {
-			io.Copy(os.Stdout, resp.Body)
-			return fmt.Errorf("Could not allocate port %d, status code: %d", p.SourcePort, resp.StatusCode)
-		}
-	}
-	if err := r.pullWithoutLock(); err != nil {
-		return err
-	}
-	appRootDir = filepath.Clean(appRootDir)
-	for p := appRootDir; p != "/"; {
-		parent, child := filepath.Split(p)
-		kustPath := filepath.Join(parent, "kustomization.yaml")
-		kust, err := r.ReadKustomization(kustPath)
-		if err != nil {
-			if errors.Is(err, fs.ErrNotExist) {
-				k := NewKustomization()
-				kust = &k
-			} else {
-				return err
-			}
-		}
-		kust.AddResources(child)
-		if err := r.WriteKustomization(kustPath, *kust); err != nil {
-			return err
-		}
-		p = filepath.Clean(parent)
-	}
-	{
-		if err := r.RemoveDir(appRootDir); err != nil {
-			return err
-		}
-		if err := r.CreateDir(appRootDir); err != nil {
-			return err
-		}
-		cfg := AppConfig{
-			AppId:   app.Name(),
-			Config:  values,
-			Derived: derived,
-		}
-		if err := r.WriteYaml(path.Join(appRootDir, configFileName), cfg); err != nil {
-			return err
-		}
-	}
-	{
-		appKust := NewKustomization()
-		for name, contents := range rendered.Resources {
-			appKust.AddResources(name)
-			out, err := r.Writer(path.Join(appRootDir, name))
-			if err != nil {
-				return err
-			}
-			defer out.Close()
-			if _, err := out.Write(contents); err != nil {
-				return err
-			}
-		}
-		if err := r.WriteKustomization(path.Join(appRootDir, "kustomization.yaml"), appKust); err != nil {
-			return err
-		}
-	}
-	return r.CommitAndPush(fmt.Sprintf("install: %s", app.Name()))
-}
-
-func (r *repoIO) RemoveApp(appRootDir string) error {
-	r.l.Lock()
-	defer r.l.Unlock()
-	r.RemoveDir(appRootDir)
-	parent, child := filepath.Split(appRootDir)
-	kustPath := filepath.Join(parent, "kustomization.yaml")
-	kust, err := r.ReadKustomization(kustPath)
-	if err != nil {
-		return err
-	}
-	kust.RemoveResources(child)
-	r.WriteKustomization(kustPath, *kust)
-	return r.CommitAndPush(fmt.Sprintf("uninstall: %s", child))
-}
-
-func (r *repoIO) FindAllInstances(root string, name string) ([]AppConfig, error) {
-	if !filepath.IsAbs(root) {
-		return nil, fmt.Errorf("Expected absolute path: %s", root)
-	}
-	kust, err := r.ReadKustomization(filepath.Join(root, "kustomization.yaml"))
-	if err != nil {
-		return nil, err
-	}
-	ret := make([]AppConfig, 0)
-	for _, app := range kust.Resources {
-		cfg, err := r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
-		if err != nil {
-			return nil, err
-		}
-		cfg.Id = app
-		if cfg.AppId == name {
-			ret = append(ret, cfg)
-		}
-	}
-	return ret, nil
-}
-
-func (r *repoIO) FindInstance(root string, id string) (AppConfig, error) {
-	if !filepath.IsAbs(root) {
-		return AppConfig{}, fmt.Errorf("Expected absolute path: %s", root)
-	}
-	kust, err := r.ReadKustomization(filepath.Join(root, "kustomization.yaml"))
-	if err != nil {
-		return AppConfig{}, err
-	}
-	for _, app := range kust.Resources {
-		if app == id {
-			cfg, err := r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
-			if err != nil {
-				return AppConfig{}, err
-			}
-			cfg.Id = id
-			return cfg, nil
-		}
-	}
-	return AppConfig{}, nil
 }
 
 func auth(signer ssh.Signer) *gitssh.PublicKeys {
@@ -434,7 +156,12 @@
 	}
 }
 
-func ReadYaml[T any](r io.Reader, o *T) error {
+func ReadYaml[T any](repo RepoFS, path string, o *T) error {
+	r, err := repo.Reader(path)
+	if err != nil {
+		return err
+	}
+	defer r.Close()
 	if contents, err := ioutil.ReadAll(r); err != nil {
 		return err
 	} else {
@@ -442,138 +169,28 @@
 	}
 }
 
-func deriveValues(values any, schema Schema, networks []Network) (map[string]any, error) {
-	ret := make(map[string]any)
-	for k, def := range schema.Fields() {
-		// TODO(gio): validate that it is map
-		v, ok := values.(map[string]any)[k]
-		// TODO(gio): if missing use default value
-		if !ok {
-			if def.Kind() == KindSSHKey {
-				key, err := NewECDSASSHKeyPair("tmp")
-				if err != nil {
-					return nil, err
-				}
-				ret[k] = map[string]string{
-					"public":  string(key.RawAuthorizedKey()),
-					"private": string(key.RawPrivateKey()),
-				}
-			}
-			continue
-		}
-		switch def.Kind() {
-		case KindBoolean:
-			ret[k] = v
-		case KindString:
-			ret[k] = v
-		case KindInt:
-			ret[k] = v
-		case KindNetwork:
-			n, err := findNetwork(networks, v.(string)) // TODO(giolekva): validate
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = n
-		case KindAuth:
-			r, err := deriveValues(v, AuthSchema, networks)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		case KindSSHKey:
-			r, err := deriveValues(v, SSHKeySchema, networks)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		case KindStruct:
-			r, err := deriveValues(v, def, networks)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		default:
-			return nil, fmt.Errorf("Should not reach!")
-		}
+func WriteYaml(repo RepoFS, path string, data any) error {
+	if d, ok := data.(*Kustomization); ok {
+		data = d
+	}
+	out, err := repo.Writer(path)
+	if err != nil {
+		return err
+	}
+	serialized, err := yaml.Marshal(data)
+	if err != nil {
+		return err
+	}
+	if _, err := out.Write(serialized); err != nil {
+		return err
+	}
+	return nil
+}
+
+func ReadKustomization(repo RepoFS, path string) (*Kustomization, error) {
+	ret := &Kustomization{}
+	if err := ReadYaml(repo, path, &ret); err != nil {
+		return nil, err
 	}
 	return ret, nil
 }
-
-func derivedToConfig(derived map[string]any, schema Schema) (map[string]any, error) {
-	ret := make(map[string]any)
-	for k, def := range schema.Fields() {
-		v, ok := derived[k]
-		// TODO(gio): if missing use default value
-		if !ok {
-			continue
-		}
-		switch def.Kind() {
-		case KindBoolean:
-			ret[k] = v
-		case KindString:
-			ret[k] = v
-		case KindInt:
-			ret[k] = v
-		case KindNetwork:
-			vm, ok := v.(map[string]any)
-			if !ok {
-				return nil, fmt.Errorf("expected map")
-			}
-			name, ok := vm["name"]
-			if !ok {
-				return nil, fmt.Errorf("expected network name")
-			}
-			ret[k] = name
-		case KindAuth:
-			vm, ok := v.(map[string]any)
-			if !ok {
-				return nil, fmt.Errorf("expected map")
-			}
-			r, err := derivedToConfig(vm, AuthSchema)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		case KindSSHKey:
-			vm, ok := v.(map[string]any)
-			if !ok {
-				return nil, fmt.Errorf("expected map")
-			}
-			r, err := derivedToConfig(vm, SSHKeySchema)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		case KindStruct:
-			vm, ok := v.(map[string]any)
-			if !ok {
-				return nil, fmt.Errorf("expected map")
-			}
-			r, err := derivedToConfig(vm, def)
-			if err != nil {
-				return nil, err
-			}
-			ret[k] = r
-		default:
-			return nil, fmt.Errorf("Should not reach!")
-		}
-	}
-	return ret, nil
-}
-
-func findNetwork(networks []Network, name string) (Network, error) {
-	for _, n := range networks {
-		if n.Name == name {
-			return n, nil
-		}
-	}
-	return Network{}, fmt.Errorf("Network not found: %s", name)
-}
-
-type Network struct {
-	Name              string `json:"name,omitempty"`
-	IngressClass      string `json:"ingressClass,omitempty"`
-	CertificateIssuer string `json:"certificateIssuer,omitempty"`
-	Domain            string `json:"domain,omitempty"`
-	AllocatePortAddr  string `json:"allocatePortAddr,omitempty"`
-}
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 {
diff --git a/core/installer/values-tmpl/headscale-user.cue b/core/installer/values-tmpl/headscale-user.cue
index fb38f8b..893ca28 100644
--- a/core/installer/values-tmpl/headscale-user.cue
+++ b/core/installer/values-tmpl/headscale-user.cue
@@ -22,7 +22,7 @@
 }
 
 helm: {
-	"headscale-user": {
+	"headscale-user-\(input.username)": {
 		chart: charts.headscaleUser
 		values: {
 			username: input.username
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 1206a73..b26a001 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -222,9 +222,14 @@
 		return err
 	}
 	log.Printf("Configuration: %+v\n", config)
-	nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
-	if err := s.m.Install(a, nsGen, suffixGen, values); err != nil {
+	suffix, err := suffixGen.Generate()
+	if err != nil {
+		return err
+	}
+	appDir := fmt.Sprintf("/apps/%s%s", a.Name(), suffix)
+	namespace := fmt.Sprintf("%s%s%s", config.Values.NamespacePrefix, a.Namespace(), suffix)
+	if err := s.m.Install(a, appDir, namespace, values); err != nil {
 		log.Printf("%s\n", err.Error())
 		return err
 	}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index e0d5a3f..3efe330 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -327,13 +327,7 @@
 		return
 	}
 	var env installer.EnvConfig
-	cr, err := s.repo.Reader("config.yaml")
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	defer cr.Close()
-	if err := installer.ReadYaml(cr, &env); err != nil {
+	if err := installer.ReadYaml(s.repo, "config.yaml", &env); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -348,13 +342,7 @@
 		req.Name = name
 	}
 	var cidrs installer.EnvCIDRs
-	cidrsR, err := s.repo.Reader("env-cidrs.yaml")
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	defer cidrsR.Close()
-	if err := installer.ReadYaml(cidrsR, &cidrs); err != nil {
+	if err := installer.ReadYaml(s.repo, "env-cidrs.yaml", &cidrs); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -364,7 +352,7 @@
 		return
 	}
 	cidrs = append(cidrs, installer.EnvCIDR{req.Name, startIP})
-	if err := s.repo.WriteYaml("env-cidrs.yaml", cidrs); err != nil {
+	if err := installer.WriteYaml(s.repo, "env-cidrs.yaml", cidrs); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -385,11 +373,12 @@
 	}
 	t, dns := tasks.NewCreateEnvTask(
 		tasks.Env{
-			PCloudEnvName:  env.Name,
-			Name:           req.Name,
-			ContactEmail:   req.ContactEmail,
-			Domain:         req.Domain,
-			AdminPublicKey: req.AdminPublicKey,
+			PCloudEnvName:   env.Name,
+			Name:            req.Name,
+			ContactEmail:    req.ContactEmail,
+			Domain:          req.Domain,
+			AdminPublicKey:  req.AdminPublicKey,
+			NamespacePrefix: fmt.Sprintf("%s-", req.Name),
 		},
 		[]net.IP{
 			net.ParseIP("135.181.48.180"),
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 9411747..697fd07 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -205,22 +205,16 @@
 		return
 	}
 	{
-		config, err := s.repo.ReadConfig()
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
-		nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
-		suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 		appManager, err := installer.NewAppManager(s.repo, s.nsCreator)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
+		config, err := appManager.Config()
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
 		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 		{
 			app, err := appsRepo.Find("headscale-user")
@@ -228,7 +222,9 @@
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
-			if err := appManager.Install(app, nsGen, suffixGen, map[string]any{
+			appDir := fmt.Sprintf("/apps/%s-%s", app.Name(), req.Username)
+			namespace := fmt.Sprintf("%s%s", config.Values.NamespacePrefix, app.Namespace())
+			if err := appManager.Install(app, appDir, namespace, map[string]any{
 				"username": req.Username,
 				"preAuthKey": map[string]any{
 					"enabled": false,
@@ -253,28 +249,26 @@
 }
 
 func (s *Server) initMemberships(username string) error {
-	inp, err := s.repo.Reader("first-account.yaml")
-	if err != nil {
-		return err
-	}
-	var fa firstaccount
-	if err := installer.ReadYaml(inp, &fa); err != nil {
-		return err
-	}
-	if fa.Created {
-		return nil
-	}
-	req := initRequest{username, fa.Groups}
-	var buf bytes.Buffer
-	if err := json.NewEncoder(&buf).Encode(req); err != nil {
-		return err
-	}
-	if _, err = http.Post(s.membershipsInitAddr, "applications/json", &buf); err != nil {
-		return err
-	}
-	fa.Created = true
-	if err := s.repo.WriteYaml("first-account.yaml", fa); err != nil {
-		return err
-	}
-	return s.repo.CommitAndPush("initialized groups for first account")
+	return s.repo.Atomic(func(r installer.RepoFS) (string, error) {
+		var fa firstaccount
+		if err := installer.ReadYaml(r, "first-account.yaml", &fa); err != nil {
+			return "", err
+		}
+		if fa.Created {
+			return "", nil
+		}
+		req := initRequest{username, fa.Groups}
+		var buf bytes.Buffer
+		if err := json.NewEncoder(&buf).Encode(req); err != nil {
+			return "", err
+		}
+		if _, err := http.Post(s.membershipsInitAddr, "applications/json", &buf); err != nil {
+			return "", err
+		}
+		fa.Created = true
+		if err := installer.WriteYaml(r, "first-account.yaml", fa); err != nil {
+			return "", err
+		}
+		return "initialized groups for first account", nil
+	})
 }