installer-env: wait for services to be reachable
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 06de0dc..995744c 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -7,27 +7,10 @@
 	"github.com/miekg/dns"
 
 	"github.com/giolekva/pcloud/core/installer"
-	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-type setupInfraAppsTask struct {
-	basicTask
-	env Env
-	st  *state
-}
-
-func (t *setupInfraAppsTask) initNewEnv(
-	ss *soft.Client,
-	r installer.RepoIO,
-	nsCreator installer.NamespaceCreator,
-	pcloudEnvName string,
-	pcloudPublicIP string,
-) error {
-	return nil
-}
-
-func NewSetupInfraAppsTask(env Env, st *state) Task {
-	t := newLeafTask("Configure environment infrastructure", func() error {
+func SetupInfra(env Env, st *state) []Task {
+	t := newLeafTask("Create client", func() error {
 		repo, err := st.ssClient.GetRepo("config")
 		if err != nil {
 			return err
@@ -37,22 +20,51 @@
 		if err != nil {
 			return err
 		}
-		appsRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-		// TODO(giolekva): private domain can be configurable as well
-		config := installer.Config{
-			Values: installer.Values{
-				PCloudEnvName:   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 {
+		st.appManager = appManager
+		st.appsRepo = installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		st.nsGen = installer.NewPrefixGenerator(env.Name + "-")
+		st.emptySuffixGen = installer.NewEmptySuffixGenerator()
+		return nil
+	})
+	return []Task{
+		CommitEnvironmentConfiguration(env, st),
+		&t,
+		newConcurrentParentTask(
+			"Core services",
+			SetupNetwork(env, st),
+			SetupCertificateIssuers(env, st),
+			SetupAuth(env, st),
+			SetupHeadscale(env, st),
+			SetupWelcome(env, st),
+			SetupAppStore(env, st),
+		),
+	}
+}
+
+func CommitEnvironmentConfiguration(env Env, st *state) Task {
+	t := newLeafTask("Configure environment infrastructure", func() error {
+		repo, err := st.ssClient.GetRepo("config")
+		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
+			}
+		}
 		{
 			out, err := r.Writer("pcloud-charts.yaml")
 			if err != nil {
@@ -74,29 +86,34 @@
 			if err != nil {
 				return err
 			}
+			rootKust, err := r.ReadKustomization("kustomization.yaml")
+			if err != nil {
+				return err
+			}
+			rootKust.AddResources("pcloud-charts.yaml")
+			if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
+				return err
+			}
+			r.CommitAndPush("configure charts repo")
 		}
-		rootKust, err := r.ReadKustomization("kustomization.yaml")
-		if err != nil {
-			return err
-		}
-		rootKust.AddResources("pcloud-charts.yaml")
-		if err := r.WriteKustomization("kustomization.yaml", *rootKust); err != nil {
-			return err
-		}
-		r.CommitAndPush("configure charts repo")
-		nsGen := installer.NewPrefixGenerator(env.Name + "-")
-		emptySuffixGen := installer.NewEmptySuffixGenerator()
+		return nil
+	})
+	return &t
+}
+
+func SetupNetwork(env Env, st *state) Task {
+	t := newLeafTask("Setup network", func() error {
 		ingressPrivateIP, err := netip.ParseAddr("10.1.0.1")
 		if err != nil {
 			return err
 		}
 		{
 			headscaleIP := ingressPrivateIP.Next()
-			app, err := appsRepo.Find("metallb-ipaddresspool")
+			app, err := st.appsRepo.Find("metallb-ipaddresspool")
 			if err != nil {
 				return err
 			}
-			if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-ingress-private"), map[string]any{
+			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(),
@@ -105,7 +122,7 @@
 			}); err != nil {
 				return err
 			}
-			if err := appManager.Install(*app, nsGen, installer.NewSuffixGenerator("-headscale"), map[string]any{
+			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(),
@@ -114,7 +131,7 @@
 			}); err != nil {
 				return err
 			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+			if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
 				"Name":       env.Name,
 				"From":       "10.1.0.100", // TODO(gio): auto-generate
 				"To":         "10.1.0.254",
@@ -125,11 +142,11 @@
 			}
 		}
 		{
-			app, err := appsRepo.Find("private-network")
+			app, err := st.appsRepo.Find("private-network")
 			if err != nil {
 				return err
 			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
+			if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
 				"PrivateNetwork": map[string]any{
 					"Hostname": "private-network-proxy",
 					"Username": "private-network-proxy",
@@ -139,96 +156,134 @@
 				return err
 			}
 		}
-		{
-			app, err := appsRepo.Find("certificate-issuer-public")
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{}); err != nil {
-				return err
-			}
+		return nil
+	})
+	return &t
+}
+
+func SetupCertificateIssuers(env Env, st *state) Task {
+	pub := newLeafTask(fmt.Sprintf("Public %s", env.Domain), func() error {
+		app, err := st.appsRepo.Find("certificate-issuer-public")
+		if err != nil {
+			return err
 		}
-		{
-			app, err := appsRepo.Find("certificate-issuer-private")
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-				"APIConfigMap": map[string]any{
-					"Name":      "api-config", // TODO(gio): take from global pcloud config
-					"Namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
-				},
-			}); err != nil {
-				return err
-			}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{}); err != nil {
+			return err
 		}
-		{
-			app, err := appsRepo.Find("core-auth")
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-				"Subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
-			}); err != nil {
-				return err
-			}
+		return nil
+	})
+	priv := newLeafTask(fmt.Sprintf("Private p.%s", env.Domain), func() error {
+		app, err := st.appsRepo.Find("certificate-issuer-private")
+		if err != nil {
+			return err
 		}
-		{
-			app, err := appsRepo.Find("headscale")
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-				"Subdomain": "headscale",
-			}); err != nil {
-				return err
-			}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"APIConfigMap": map[string]any{
+				"Name":      "api-config", // TODO(gio): take from global pcloud config
+				"Namespace": fmt.Sprintf("%s-dns-zone-manager", env.PCloudEnvName),
+			},
+		}); err != nil {
+			return err
 		}
-		{
-			keys, err := installer.NewSSHKeyPair("welcome")
-			if err != nil {
-				return err
-			}
-			user := fmt.Sprintf("%s-welcome", env.Name)
-			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
-				return err
-			}
-			if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
-				return err
-			}
-			app, err := appsRepo.Find("welcome")
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-				"RepoAddr":      st.ssClient.GetRepoAddress("config"),
-				"SSHPrivateKey": string(keys.RawPrivateKey()),
-			}); err != nil {
-				return err
-			}
+		return nil
+	})
+	return newSequentialParentTask("Configure TLS certificate issuers", &pub, &priv)
+}
+
+func SetupAuth(env Env, st *state) Task {
+	t := newLeafTask("Setup", func() error {
+		app, err := st.appsRepo.Find("core-auth")
+		if err != nil {
+			return err
 		}
-		{
-			user := fmt.Sprintf("%s-appmanager", env.Name)
-			keys, err := installer.NewSSHKeyPair(user)
-			if err != nil {
-				return err
-			}
-			if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
-				return err
-			}
-			if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
-				return err
-			}
-			app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
-			if err != nil {
-				return err
-			}
-			if err := appManager.Install(*app, nsGen, emptySuffixGen, map[string]any{
-				"RepoAddr":      st.ssClient.GetRepoAddress("config"),
-				"SSHPrivateKey": string(keys.RawPrivateKey()),
-			}); err != nil {
-				return err
-			}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"Subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
+		}); err != nil {
+			return err
+		}
+		return nil
+	})
+	return newSequentialParentTask(
+		"Authentication services",
+		&t,
+		waitForAddr(fmt.Sprintf("https://accounts-ui.%s", env.Domain)),
+	)
+}
+
+func SetupHeadscale(env Env, st *state) Task {
+	t := newLeafTask("Setup", func() error {
+		app, err := st.appsRepo.Find("headscale")
+		if err != nil {
+			return err
+		}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"Subdomain": "headscale",
+		}); err != nil {
+			return err
+		}
+		return nil
+	})
+	return newSequentialParentTask(
+		"Headscale service",
+		&t,
+		waitForAddr(fmt.Sprintf("https://headscale.%s/apple", env.Domain)),
+	)
+}
+
+func SetupWelcome(env Env, st *state) Task {
+	t := newLeafTask("Setup", func() error {
+		keys, err := installer.NewSSHKeyPair("welcome")
+		if err != nil {
+			return err
+		}
+		user := fmt.Sprintf("%s-welcome", env.Name)
+		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
+			return err
+		}
+		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
+			return err
+		}
+		app, err := st.appsRepo.Find("welcome")
+		if err != nil {
+			return err
+		}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"RepoAddr":      st.ssClient.GetRepoAddress("config"),
+			"SSHPrivateKey": string(keys.RawPrivateKey()),
+		}); err != nil {
+			return err
+		}
+		return nil
+	})
+	return newSequentialParentTask(
+		"Welcome service",
+		&t,
+		waitForAddr(fmt.Sprintf("https://welcome.%s", env.Domain)),
+	)
+}
+
+func SetupAppStore(env Env, st *state) Task {
+	t := newLeafTask("Application marketplace", func() error {
+		user := fmt.Sprintf("%s-appmanager", env.Name)
+		keys, err := installer.NewSSHKeyPair(user)
+		if err != nil {
+			return err
+		}
+		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
+			return err
+		}
+		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
+			return err
+		}
+		app, err := st.appsRepo.Find("app-manager") // TODO(giolekva): configure
+		if err != nil {
+			return err
+		}
+		if err := st.appManager.Install(*app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"RepoAddr":      st.ssClient.GetRepoAddress("config"),
+			"SSHPrivateKey": string(keys.RawPrivateKey()),
+		}); err != nil {
+			return err
 		}
 		return nil
 	})