installer: fully automate initial bootstrap and env creation
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index b1e8135..9fd2045 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -63,7 +63,7 @@
 	if err != nil {
 		return err
 	}
-	m, err := installer.NewAppManager(repo, signer)
+	m, err := installer.NewAppManager(installer.NewRepoIO(repo, signer))
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/apps.go b/core/installer/cmd/apps.go
index baab5bf..9cfe955 100644
--- a/core/installer/cmd/apps.go
+++ b/core/installer/cmd/apps.go
@@ -60,10 +60,10 @@
 	if err != nil {
 		return err
 	}
-	m, err := installer.NewAppManager(
+	m, err := installer.NewAppManager(installer.NewRepoIO(
 		repo,
 		signer,
-	)
+	))
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/bootstrap.go b/core/installer/cmd/bootstrap.go
index e5f3f2b..135b6b4 100644
--- a/core/installer/cmd/bootstrap.go
+++ b/core/installer/cmd/bootstrap.go
@@ -1,6 +1,3 @@
-// TODO
-// * ns pcloud not found
-
 package main
 
 import (
@@ -22,12 +19,12 @@
 )
 
 var bootstrapFlags struct {
+	pcloudEnvName             string
 	chartsDir                 string
 	adminPubKey               string
-	adminPrivKey              string
 	storageDir                string
 	volumeDefaultReplicaCount int
-	softServeIP               string
+	softServeIP               string // TODO(giolekva): reserve using metallb IPAddressPool
 }
 
 func bootstrapCmd() *cobra.Command {
@@ -36,6 +33,12 @@
 		RunE: bootstrapCmdRun,
 	}
 	cmd.Flags().StringVar(
+		&bootstrapFlags.pcloudEnvName,
+		"pcloud-env-name",
+		"pcloud",
+		"",
+	)
+	cmd.Flags().StringVar(
 		&bootstrapFlags.chartsDir,
 		"charts-dir",
 		"",
@@ -48,12 +51,6 @@
 		"",
 	)
 	cmd.Flags().StringVar(
-		&bootstrapFlags.adminPrivKey,
-		"admin-priv-key",
-		"",
-		"",
-	)
-	cmd.Flags().StringVar(
 		&bootstrapFlags.storageDir,
 		"storage-dir",
 		"",
@@ -75,73 +72,68 @@
 }
 
 func bootstrapCmdRun(cmd *cobra.Command, args []string) error {
-	adminPubKey, adminPrivKey, err := readAdminKeys()
+	adminPubKey, err := os.ReadFile(bootstrapFlags.adminPubKey)
 	if err != nil {
 		return err
 	}
-	softServePub, softServePriv, err := installer.GenerateSSHKeys()
+	bootstrapJobKeys, err := installer.NewSSHKeyPair()
 	if err != nil {
 		return err
 	}
-	if err := installMetallbNamespace(); err != nil {
-		return err
-	}
 	if err := installMetallb(); err != nil {
 		return err
 	}
-	time.Sleep(1 * time.Minute)
-	if err := installMetallbConfig(); err != nil {
-		return err
-	}
 	if err := installLonghorn(); err != nil {
 		return err
 	}
-	time.Sleep(2 * time.Minute)
-	if err := installSoftServe(softServePub, softServePriv, string(adminPubKey)); err != nil {
+	time.Sleep(5 * time.Minute) // TODO(giolekva): implement proper wait
+	if err := installSoftServe(bootstrapJobKeys.Public); err != nil {
 		return err
 	}
-	time.Sleep(2 * time.Minute)
-	ss, err := soft.NewClient(bootstrapFlags.softServeIP, 22, adminPrivKey, log.Default())
+	time.Sleep(2 * time.Minute) // TODO(giolekva): implement proper wait
+	ss, err := soft.NewClient(bootstrapFlags.softServeIP, 22, []byte(bootstrapJobKeys.Private), log.Default())
 	if err != nil {
 		return err
 	}
-	fluxPub, fluxPriv, err := installer.GenerateSSHKeys()
+	if ss.AddPublicKey("admin", string(adminPubKey)); err != nil {
+		return err
+	}
+	if err := installFluxcd(ss, bootstrapFlags.pcloudEnvName); err != nil {
+		return err
+	}
+	repo, err := ss.GetRepo(bootstrapFlags.pcloudEnvName)
 	if err != nil {
 		return err
 	}
-	if err := ss.AddUser("flux", fluxPub); err != nil {
+	repoIO := installer.NewRepoIO(repo, ss.Signer)
+	if err := configurePCloudRepo(repoIO); err != nil {
 		return err
 	}
-	if err := ss.MakeUserAdmin("flux"); err != nil {
+	// TODO(giolekva): commit this to the repo above
+	global := map[string]any{
+		"PCloudEnvName": bootstrapFlags.pcloudEnvName,
+	}
+	if err := installInfrastructureServices(repoIO, global); err != nil {
 		return err
 	}
-	fmt.Println("Creating /pcloud repo")
-	if err := ss.AddRepository("pcloud", "# PCloud Systems"); err != nil {
+	if err := installEnvManager(ss, repoIO, global); err != nil {
 		return err
 	}
-	fmt.Println("Installing Flux")
-	if err := installFlux("ssh://soft-serve.pcloud.svc.cluster.local:22/pcloud", "soft-serve.pcloud.svc.cluster.local", softServePub, fluxPriv); err != nil {
+	if ss.RemovePublicKey("admin", bootstrapJobKeys.Public); err != nil {
 		return err
 	}
-	pcloudRepo, err := ss.GetRepo("pcloud") // TODO(giolekva): configurable
-	if err != nil {
+
+	return nil
+}
+
+func installMetallb() error {
+	if err := installMetallbNamespace(); err != nil {
 		return err
 	}
-	if err := configurePCloudRepo(installer.NewRepoIO(pcloudRepo, ss.Signer)); err != nil {
+	if err := installMetallbService(); err != nil {
 		return err
 	}
-	// TODO(giolekva): everything below must be installed using Flux
-	if err := installIngressPublic(); err != nil {
-		return err
-	}
-	if err := installCertManager(); err != nil {
-		return err
-	}
-	if err := installCertManagerWebhookGandi(); err != nil {
-		return err
-	}
-	// TODO(giolekva): ideally should be installed automatically if any of the user installed apps requires it
-	if err := installSmbDriver(); err != nil {
+	if err := installMetallbConfig(); err != nil {
 		return err
 	}
 	return nil
@@ -150,7 +142,7 @@
 func installMetallbNamespace() error {
 	fmt.Println("Installing metallb namespace")
 	// config, err := createActionConfig("default")
-	config, err := createActionConfig("pcloud")
+	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
 	if err != nil {
 		return err
 	}
@@ -168,7 +160,7 @@
 		},
 	}
 	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud"
+	installer.Namespace = bootstrapFlags.pcloudEnvName
 	installer.ReleaseName = "metallb-ns"
 	installer.Wait = true
 	installer.WaitForJobs = true
@@ -178,7 +170,7 @@
 	return nil
 }
 
-func installMetallb() error {
+func installMetallbService() error {
 	fmt.Println("Installing metallb")
 	// config, err := createActionConfig("default")
 	config, err := createActionConfig("metallb-system")
@@ -251,7 +243,7 @@
 
 func installLonghorn() error {
 	fmt.Println("Installing Longhorn")
-	config, err := createActionConfig("pcloud")
+	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
 	if err != nil {
 		return err
 	}
@@ -288,9 +280,13 @@
 	return nil
 }
 
-func installSoftServe(pubKey, privKey, adminKey string) error {
+func installSoftServe(adminPublicKey string) error {
 	fmt.Println("Installing SoftServe")
-	config, err := createActionConfig("pcloud")
+	keys, err := installer.NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
 	if err != nil {
 		return err
 	}
@@ -299,13 +295,13 @@
 		return err
 	}
 	values := map[string]interface{}{
-		"privateKey": privKey,
-		"publicKey":  pubKey,
-		"adminKey":   adminKey,
+		"privateKey": keys.Private,
+		"publicKey":  keys.Public,
+		"adminKey":   adminPublicKey,
 		"reservedIP": bootstrapFlags.softServeIP,
 	}
 	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud"
+	installer.Namespace = bootstrapFlags.pcloudEnvName
 	installer.CreateNamespace = true
 	installer.ReleaseName = "soft-serve"
 	installer.Wait = true
@@ -317,8 +313,39 @@
 	return nil
 }
 
-func installFlux(repoAddr, repoHost, repoHostPubKey, privateKey string) error {
-	config, err := createActionConfig("pcloud")
+func installFluxcd(ss *soft.Client, pcloudEnvName string) error {
+	keys, err := installer.NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	if err := ss.AddUser("flux", keys.Public); err != nil {
+		return err
+	}
+	if err := ss.MakeUserAdmin("flux"); err != nil {
+		return err
+	}
+	fmt.Printf("Creating /%s repo", pcloudEnvName)
+	if err := ss.AddRepository(pcloudEnvName, "# PCloud Systems"); err != nil {
+		return err
+	}
+	fmt.Println("Installing Flux")
+	ssPublic, err := ss.GetPublicKey()
+	if err != nil {
+		return err
+	}
+	if err := installFluxBootstrap(
+		ss.GetRepoAddress(pcloudEnvName),
+		ss.IP,
+		string(ssPublic),
+		keys.Private,
+	); err != nil {
+		return err
+	}
+	return nil
+}
+
+func installFluxBootstrap(repoAddr, repoHost, repoHostPubKey, privateKey string) error {
+	config, err := createActionConfig(bootstrapFlags.pcloudEnvName)
 	if err != nil {
 		return err
 	}
@@ -333,7 +360,7 @@
 		"privateKey":              privateKey,
 	}
 	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud"
+	installer.Namespace = bootstrapFlags.pcloudEnvName
 	installer.CreateNamespace = true
 	installer.ReleaseName = "flux"
 	installer.Wait = true
@@ -345,150 +372,103 @@
 	return nil
 }
 
-func installIngressPublic() error {
-	config, err := createActionConfig("pcloud")
-	if err != nil {
-		return err
+func installInfrastructureServices(repo installer.RepoIO, global map[string]any) error {
+	values := map[string]any{
+		"Global": global,
 	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "ingress-nginx"))
-	if err != nil {
-		return err
+	appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	install := func(name string) error {
+		app, err := appRepo.Find(name)
+		if err != nil {
+			return err
+		}
+		return repo.InstallApp(*app, "infrastructure", values)
 	}
-	values := map[string]interface{}{
-		"fullnameOverride": "pcloud-ingress-public",
-		"controller": map[string]interface{}{
-			"service": map[string]interface{}{
-				"type": "LoadBalancer",
-			},
-			"ingressClassByName": true,
-			"ingressClassResource": map[string]interface{}{
-				"name":            "pcloud-ingress-public",
-				"enabled":         true,
-				"default":         false,
-				"controllerValue": "k8s.io/pcloud-ingress-public",
-			},
-			"config": map[string]interface{}{
-				"proxy-body-size": "100M",
-			},
-		},
-		"udp": map[string]interface{}{
-			"6881": "lekva-app-qbittorrent/torrent:6881",
-		},
-		"tcp": map[string]interface{}{
-			"6881": "lekva-app-qbittorrent/torrent:6881",
-		},
+	appsToInstall := []string{
+		"resource-renderer-controller",
+		"headscale-controller",
+		"csi-driver-smb",
+		"ingress-public",
+		"cert-manager",
+		"cert-manager-webhook-gandi",
+		"cert-manager-webhook-gandi-role",
 	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud-ingress-public"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "ingress-public"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installCertManager() error {
-	config, err := createActionConfig("pcloud-cert-manager")
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "cert-manager"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{
-		"fullnameOverride": "pcloud-cert-manager",
-		"installCRDs":      true,
-		"image": map[string]interface{}{
-			"tag":        "v1.11.1",
-			"pullPolicy": "IfNotPresent",
-		},
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud-cert-manager"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "cert-manager"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installCertManagerWebhookGandi() error {
-	config, err := createActionConfig("pcloud-cert-manager")
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "cert-manager-webhook-gandi"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{
-		"fullnameOverride": "pcloud-cert-manager-webhook-gandi",
-		"certManager": map[string]interface{}{
-			"namespace":          "pcloud-cert-manager",
-			"serviceAccountName": "pcloud-cert-manager",
-		},
-		"image": map[string]interface{}{
-			"repository": "giolekva/cert-manager-webhook-gandi",
-			"tag":        "v0.2.0",
-			"pullPolicy": "IfNotPresent",
-		},
-		"logLevel": 2,
-	}
-	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud-cert-manager"
-	installer.CreateNamespace = false
-	installer.ReleaseName = "cert-manager-webhook-gandi"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
-	}
-	return nil
-}
-
-func installSmbDriver() error {
-	config, err := createActionConfig("pcloud-csi-driver-smb")
-	if err != nil {
-		return err
-	}
-	chart, err := loader.Load(filepath.Join(bootstrapFlags.chartsDir, "csi-driver-smb"))
-	if err != nil {
-		return err
-	}
-	values := map[string]interface{}{}
-	installer := action.NewInstall(config)
-	installer.Namespace = "pcloud-csi-driver-smb"
-	installer.CreateNamespace = true
-	installer.ReleaseName = "csi-driver-smb"
-	installer.Wait = true
-	installer.WaitForJobs = true
-	installer.Timeout = 20 * time.Minute
-	if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
-		return err
+	for _, name := range appsToInstall {
+		if err := install(name); err != nil {
+			return err
+		}
 	}
 	return nil
 }
 
 func configurePCloudRepo(repo installer.RepoIO) error {
-	kust := installer.NewKustomization()
-	kust.AddResources("pcloud-flux", "environments")
-	if err := repo.WriteKustomization("kustomization.yaml", kust); err != nil {
+	{
+		kust := installer.NewKustomization()
+		kust.AddResources("pcloud-flux", "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
+			}
+			defer out.Close()
+			_, err = out.Write([]byte(`
+apiVersion: source.toolkit.fluxcd.io/v1beta2
+kind: GitRepository
+metadata:
+  name: pcloud # TODO(giolekva): use more generic name
+  namespace: pcloud # TODO(giolekva): configurable
+spec:
+  interval: 1m0s
+  url: https://github.com/giolekva/pcloud
+  ref:
+    branch: main
+`))
+			if err != nil {
+				return err
+			}
+		}
+		infraKust := installer.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", installer.NewKustomization()); err != nil {
+			return err
+		}
+		if err := repo.CommitAndPush("initialize pcloud directory structure"); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func installEnvManager(ss *soft.Client, repo installer.RepoIO, global map[string]any) error {
+	keys, err := installer.NewSSHKeyPair()
+	if err != nil {
 		return err
 	}
-	if err := repo.WriteKustomization("environments/kustomization.yaml", installer.NewKustomization()); err != nil {
+	user := fmt.Sprintf("%s-env-manager", bootstrapFlags.pcloudEnvName)
+	if err := ss.AddUser(user, keys.Public); err != nil {
 		return err
 	}
-	return repo.CommitAndPush("initialize pcloud directory structure, environments with kustomization.yaml-s")
+	if err := ss.MakeUserAdmin(user); err != nil {
+		return err
+	}
+	appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	envManager, err := appRepo.Find("env-manager")
+	if err != nil {
+		return err
+	}
+	return repo.InstallApp(*envManager, "infrastructure", map[string]any{
+		"Global": global,
+		"Values": map[string]any{
+			"RepoIP":        bootstrapFlags.softServeIP,
+			"SSHPrivateKey": keys.Private,
+		},
+	})
 }
 
 func createActionConfig(namespace string) (*action.Configuration, error) {
@@ -506,15 +486,3 @@
 	}
 	return config, nil
 }
-
-func readAdminKeys() ([]byte, []byte, error) {
-	pubKey, err := os.ReadFile(bootstrapFlags.adminPubKey)
-	if err != nil {
-		return nil, nil, err
-	}
-	privKey, err := os.ReadFile(bootstrapFlags.adminPrivKey)
-	if err != nil {
-		return nil, nil, err
-	}
-	return pubKey, privKey, nil
-}
diff --git a/core/installer/cmd/env.go b/core/installer/cmd/env.go
index 8b7dc1e..045b258 100644
--- a/core/installer/cmd/env.go
+++ b/core/installer/cmd/env.go
@@ -7,6 +7,7 @@
 	"embed"
 	"encoding/base64"
 	"fmt"
+	"github.com/spf13/cobra"
 	"log"
 	"os"
 	"path"
@@ -14,17 +15,17 @@
 
 	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
-	"github.com/spf13/cobra"
 )
 
 //go:embed env-tmpl
 var filesTmpls embed.FS
 
 var createEnvFlags struct {
-	name         string
-	ip           string
-	port         int
-	adminPrivKey string
+	name          string
+	ip            string
+	port          int
+	adminPrivKey  string
+	adminUsername string
 }
 
 func createEnvCmd() *cobra.Command {
@@ -56,6 +57,12 @@
 		"",
 		"",
 	)
+	cmd.Flags().StringVar(
+		&createEnvFlags.adminUsername,
+		"admin-username",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -72,61 +79,190 @@
 	if err != nil {
 		return err
 	}
-	fmt.Println(string(ssPubKey))
-	pub, priv, err := installer.GenerateSSHKeys()
-	{
-		_ = priv
-	}
+	keys, err := installer.NewSSHKeyPair()
 	if err != nil {
 		return err
 	}
-	readme := fmt.Sprintf("# %s PCloud environment", createEnvFlags.name)
-	if err := ss.AddRepository(createEnvFlags.name, readme); err != nil {
+	if 1 == 2 {
+		readme := fmt.Sprintf("# %s PCloud environment", createEnvFlags.name)
+		if err := ss.AddRepository(createEnvFlags.name, readme); err != nil {
+			return err
+		}
+		fluxUserName := fmt.Sprintf("flux-%s", createEnvFlags.name)
+		if err := ss.AddUser(fluxUserName, keys.Public); err != nil {
+			return err
+		}
+		if err := ss.AddCollaborator(createEnvFlags.name, fluxUserName); err != nil {
+			return err
+		}
+	}
+	envRepo, err := ss.GetRepo(createEnvFlags.name)
+	if envRepo == nil {
 		return err
 	}
-	fluxUserName := fmt.Sprintf("flux-%s", createEnvFlags.name)
-	if err := ss.AddUser(fluxUserName, pub); err != nil {
+	if err := initEnvRepo(installer.NewRepoIO(envRepo, ss.Signer)); err != nil {
 		return err
 	}
-	if err := ss.AddCollaborator(createEnvFlags.name, fluxUserName); err != nil {
-		return err
-	}
-	repo, err := ss.GetRepo("pcloud")
-	if err != nil {
-		return err
-	}
-	repoIO := installer.NewRepoIO(repo, ss.Signer)
-	kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
-	if err != nil {
-		return err
-	}
-	kust.AddResources(createEnvFlags.name)
-	tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
-	if err != nil {
-		return err
-	}
-	for _, tmpl := range tmpls.Templates() {
-		dstPath := path.Join("environments", createEnvFlags.name, tmpl.Name())
-		dst, err := repoIO.Writer(dstPath)
+	if 1 == 2 {
+		repo, err := ss.GetRepo("pcloud")
 		if err != nil {
 			return err
 		}
-		defer dst.Close()
-		if err := tmpl.Execute(dst, map[string]string{
-			"Name":       createEnvFlags.name,
-			"PrivateKey": base64.StdEncoding.EncodeToString([]byte(priv)),
-			"PublicKey":  base64.StdEncoding.EncodeToString([]byte(pub)),
-			"GitHost":    createEnvFlags.ip,
-			"KnownHosts": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s %s", createEnvFlags.ip, ssPubKey))),
+		repoIO := installer.NewRepoIO(repo, ss.Signer)
+		kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
+		if err != nil {
+			return err
+		}
+		kust.AddResources(createEnvFlags.name)
+		tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
+		if err != nil {
+			return err
+		}
+		for _, tmpl := range tmpls.Templates() {
+			dstPath := path.Join("environments", createEnvFlags.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":       createEnvFlags.name,
+				"PrivateKey": base64.StdEncoding.EncodeToString([]byte(keys.Private)),
+				"PublicKey":  base64.StdEncoding.EncodeToString([]byte(keys.Public)),
+				"GitHost":    createEnvFlags.ip,
+				"KnownHosts": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s %s", createEnvFlags.ip, ssPubKey))),
+			}); 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", createEnvFlags.name)); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func initEnvRepo(r installer.RepoIO) error {
+	appManager, err := installer.NewAppManager(r)
+	if err != nil {
+		return err
+	}
+	appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	if 1 == 2 {
+		config := installer.Config{ // TODO(gioleka): configurable
+			Values: installer.Values{
+				PCloudEnvName:   "pcloud",
+				Id:              "lekva",
+				ContactEmail:    "giolekva@gmail.com",
+				Domain:          "lekva.me",
+				PrivateDomain:   "p.lekva.me",
+				PublicIP:        "46.49.35.44",
+				NamespacePrefix: "lekva-",
+			},
+		}
+		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 = out.Write([]byte(`
+apiVersion: source.toolkit.fluxcd.io/v1beta2
+kind: GitRepository
+metadata:
+  name: pcloud
+  namespace: lekva
+spec:
+  interval: 1m0s
+  url: https://github.com/giolekva/pcloud
+  ref:
+    branch: main
+`))
+			if err != nil {
+				return err
+			}
+		}
+		rootKust := installer.NewKustomization()
+		rootKust.AddResources("pcloud-charts.yaml", "apps")
+		if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
+			return err
+		}
+		appsKust := installer.NewKustomization()
+		if err := r.WriteKustomization("apps/kustomization.yaml", appsKust); err != nil {
+			return err
+		}
+		r.CommitAndPush("initialize config")
+		{
+			app, err := appsRepo.Find("metallb-config-env")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, map[string]any{
+				"IngressPrivate": "10.1.0.1",
+				"Headscale":      "10.1.0.2",
+				"SoftServe":      "10.1.0.3",
+				"Rest": map[string]any{
+					"From": "10.1.0.100",
+					"To":   "10.1.0.255",
+				},
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			app, err := appsRepo.Find("ingress-private")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, map[string]any{
+				"GandiAPIToken": "", // TODO(gioleka): configurable
+			}); err != nil {
+				return err
+			}
+		}
+		{
+			app, err := appsRepo.Find("core-auth")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, 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, map[string]any{
+			"Subdomain": "headscale",
 		}); 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", createEnvFlags.name)); err != nil {
-		return err
+	if 1 == 2 {
+		{
+			app, err := appsRepo.Find("tailscale-proxy")
+			if err != nil {
+				return err
+			}
+			if err := appManager.Install(*app, map[string]any{
+				"Username": createEnvFlags.adminUsername,
+				"IPSubnet": "10.1.0.0/24",
+			}); err != nil {
+				return err
+			}
+			// TODO(giolekva): headscale accept routes
+		}
 	}
 	return nil
 }
diff --git a/core/installer/cmd/env_manager.go b/core/installer/cmd/env_manager.go
new file mode 100644
index 0000000..4279445
--- /dev/null
+++ b/core/installer/cmd/env_manager.go
@@ -0,0 +1,323 @@
+package main
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"path"
+	"text/template"
+
+	"github.com/labstack/echo/v4"
+	"github.com/spf13/cobra"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+var envManagerFlags struct {
+	repoIP   string
+	repoPort int
+	sshKey   string
+	port     int
+}
+
+func envManagerCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:  "envmanager",
+		RunE: envManagerCmdRun,
+	}
+	cmd.Flags().StringVar(
+		&envManagerFlags.repoIP,
+		"repo-ip",
+		"",
+		"",
+	)
+	cmd.Flags().IntVar(
+		&envManagerFlags.repoPort,
+		"repo-port",
+		22,
+		"",
+	)
+	cmd.Flags().StringVar(
+		&envManagerFlags.sshKey,
+		"ssh-key",
+		"",
+		"",
+	)
+	cmd.Flags().IntVar(
+		&envManagerFlags.port,
+		"port",
+		8080,
+		"",
+	)
+	return cmd
+}
+
+func envManagerCmdRun(cmd *cobra.Command, args []string) error {
+	sshKey, err := os.ReadFile(envManagerFlags.sshKey)
+	if err != nil {
+		return err
+	}
+	fmt.Println(string(sshKey))
+	ss, err := soft.NewClient(envManagerFlags.repoIP, envManagerFlags.repoPort, sshKey, log.Default())
+	if err != nil {
+		return err
+	}
+	b, err := ss.GetPublicKey()
+	if err != nil {
+		return err
+	}
+	fmt.Println(string(b))
+	fmt.Println(111)
+	repo, err := ss.GetRepo("pcloud")
+	fmt.Println(222)
+	if err != nil {
+		return err
+	}
+	fmt.Println(333)
+	repoIO := installer.NewRepoIO(repo, ss.Signer)
+	s := &envServer{
+		port: envManagerFlags.port,
+		ss:   ss,
+		repo: repoIO,
+	}
+	s.start()
+	return nil
+}
+
+type envServer struct {
+	port int
+	ss   *soft.Client
+	repo installer.RepoIO
+}
+
+func (s *envServer) start() {
+	e := echo.New()
+	e.POST("/env", s.createEnv)
+	log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
+}
+
+type createEnvReq struct {
+	Name          string `json:"name"`
+	ContactEmail  string `json:"contactEmail"`
+	Domain        string `json:"domain"`
+	GandiAPIToken string `json:"gandiAPIToken"`
+	AdminUsername string `json:"adminUsername"`
+	// TODO(giolekva): take admin password as well
+}
+
+func (s *envServer) createEnv(c echo.Context) error {
+	var req createEnvReq
+	if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil {
+		return err
+	}
+	keys, err := installer.NewSSHKeyPair()
+	if err != nil {
+		return err
+	}
+	{
+		readme := fmt.Sprintf("# %s PCloud environment", req.Name)
+		if err := s.ss.AddRepository(req.Name, readme); err != nil {
+			return err
+		}
+		fluxUserName := fmt.Sprintf("flux-%s", req.Name)
+		if err := s.ss.AddUser(fluxUserName, keys.Public); err != nil {
+			return err
+		}
+		if err := s.ss.AddCollaborator(req.Name, fluxUserName); err != nil {
+			return err
+		}
+	}
+	{
+		repo, err := s.ss.GetRepo(req.Name)
+		if repo == nil {
+			return err
+		}
+		if err := initNewEnv(installer.NewRepoIO(repo, s.ss.Signer), req); err != nil {
+			return err
+		}
+	}
+	{
+		repo, err := s.ss.GetRepo("pcloud")
+		if err != nil {
+			return err
+		}
+		ssPubKey, err := s.ss.GetPublicKey()
+		if err != nil {
+			return err
+		}
+		if err := addNewEnv(
+			installer.NewRepoIO(repo, s.ss.Signer),
+			req,
+			keys,
+			ssPubKey,
+		); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func initNewEnv(r installer.RepoIO, req createEnvReq) error {
+	appManager, err := installer.NewAppManager(r)
+	if err != nil {
+		return err
+	}
+	appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	// TODO(giolekva): env name and ip should come from pcloud repo config.yaml
+	// TODO(giolekva): private domain can be configurable as well
+	config := installer.Config{
+		Values: installer.Values{
+			PCloudEnvName:   "pcloud",
+			Id:              req.Name,
+			ContactEmail:    req.ContactEmail,
+			Domain:          req.Domain,
+			PrivateDomain:   fmt.Sprintf("p.%s", req.Domain),
+			PublicIP:        "46.49.35.44",
+			NamespacePrefix: fmt.Sprintf("%s-", req.Name),
+		},
+	}
+	if err := r.WriteYaml("config.yaml", config); err != nil {
+		return err
+	}
+	{
+		out, err := r.Writer("pcloud-charts.yaml")
+		if err != nil {
+			return err
+		}
+		defer out.Close()
+		_, err = out.Write([]byte(`
+apiVersion: source.toolkit.fluxcd.io/v1beta2
+kind: GitRepository
+metadata:
+  name: pcloud
+  namespace: lekva
+spec:
+  interval: 1m0s
+  url: https://github.com/giolekva/pcloud
+  ref:
+    branch: main
+`))
+		if err != nil {
+			return err
+		}
+	}
+	rootKust := installer.NewKustomization()
+	rootKust.AddResources("pcloud-charts.yaml", "apps")
+	if err := r.WriteKustomization("kustomization.yaml", rootKust); err != nil {
+		return err
+	}
+	appsKust := installer.NewKustomization()
+	if err := r.WriteKustomization("apps/kustomization.yaml", appsKust); err != nil {
+		return err
+	}
+	r.CommitAndPush("initialize config")
+	{
+		app, err := appsRepo.Find("metallb-config-env")
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, map[string]any{
+			"IngressPrivate": "10.1.0.1",
+			"Headscale":      "10.1.0.2",
+			"SoftServe":      "10.1.0.3",
+			"Rest": map[string]any{
+				"From": "10.1.0.100",
+				"To":   "10.1.0.255",
+			},
+		}); err != nil {
+			return err
+		}
+	}
+	{
+		app, err := appsRepo.Find("ingress-private")
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, map[string]any{
+			"GandiAPIToken": req.GandiAPIToken,
+		}); err != nil {
+			return err
+		}
+	}
+	{
+		app, err := appsRepo.Find("core-auth")
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, 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, map[string]any{
+			"Subdomain": "headscale",
+		}); err != nil {
+			return err
+		}
+	}
+	{
+		app, err := appsRepo.Find("tailscale-proxy")
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, map[string]any{
+			"Username": req.AdminUsername,
+			"IPSubnet": "10.1.0.0/24",
+		}); err != nil {
+			return err
+		}
+		// TODO(giolekva): headscale accept routes
+	}
+
+	return nil
+}
+
+func addNewEnv(
+	repoIO installer.RepoIO,
+	req createEnvReq,
+	keys installer.KeyPair,
+	pcloudRepoPublicKey []byte,
+) error {
+	kust, err := repoIO.ReadKustomization("environments/kustomization.yaml")
+	if err != nil {
+		return err
+	}
+	kust.AddResources(req.Name)
+	tmpls, err := template.ParseFS(filesTmpls, "env-tmpl/*.yaml")
+	if err != nil {
+		return err
+	}
+	for _, tmpl := range tmpls.Templates() {
+		dstPath := path.Join("environments", req.Name, tmpl.Name())
+		dst, err := repoIO.Writer(dstPath)
+		if err != nil {
+			return err
+		}
+		defer dst.Close()
+		if err := tmpl.Execute(dst, map[string]string{
+			"Name":       req.Name,
+			"PrivateKey": base64.StdEncoding.EncodeToString([]byte(keys.Private)),
+			"PublicKey":  base64.StdEncoding.EncodeToString([]byte(keys.Public)),
+			"GitHost":    envManagerFlags.repoIP,
+			"KnownHosts": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s %s", envManagerFlags.repoIP, pcloudRepoPublicKey))),
+		}); err != nil {
+			return err
+		}
+	}
+	if err := repoIO.WriteKustomization("environments/kustomization.yaml", *kust); err != nil {
+		return err
+	}
+	if err := repoIO.CommitAndPush(fmt.Sprintf("%s: initialize environment", req.Name)); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/core/installer/cmd/main.go b/core/installer/cmd/main.go
index 7a71b90..e82b338 100644
--- a/core/installer/cmd/main.go
+++ b/core/installer/cmd/main.go
@@ -26,6 +26,7 @@
 	rootCmd.AddCommand(createEnvCmd())
 	rootCmd.AddCommand(installCmd())
 	rootCmd.AddCommand(appManagerCmd())
+	rootCmd.AddCommand(envManagerCmd())
 }
 
 func main() {