Gitfed
bastien-mrq/gitfed / cmd / gitfed-install / actions.go
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"time"

	tea "github.com/charmbracelet/bubbletea"
)

// certManagerManifestURL always resolves to the current stable release —
// see INSTALL.md for why this is safer than pinning a version number that
// goes stale.
const certManagerManifestURL = "https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml"

// runInstallDeps installs whatever screen 1's scan found missing —
// nothing already present is touched again, so re-running the wizard
// after a partial failure never redoes finished work. Bails out after the
// first failing command rather than continuing into steps that can only
// fail more confusingly because an earlier one never actually finished.
func runInstallDeps(ctx context.Context, ch chan<- tea.Msg, scan scanResult, cfg instanceConfig) {
	if !scan.HasK3s {
		if err := runStreamed(ctx, ch, "k3s", "", "sh", "-c", "curl -sfL https://get.k3s.io | sh -"); err != nil {
			return
		}
		// A fresh k3s install only writes its kubeconfig at
		// /etc/rancher/k3s/k3s.yaml, root-owned — copy it into the current
		// user's own ~/.kube/config so the plain `kubectl` calls every
		// later step makes actually work without needing sudo each time
		// (see INSTALL.md step 1's second half, same fix, done by hand).
		if err := setupKubeconfig(ctx); err != nil {
			ch <- stepResultMsg{stream: "k3s", err: fmt.Errorf("kubeconfig setup: %w", err)}
			return
		}
	} else {
		ch <- stepResultMsg{stream: "k3s"}
	}

	if !scan.HasCertManager {
		if err := runStreamed(ctx, ch, "certmanager", "", "kubectl", "apply", "-f", certManagerManifestURL); err != nil {
			return
		}
		if !waitRollout(ctx, "cert-manager", "cert-manager-webhook") {
			ch <- stepResultMsg{stream: "certmanager", err: fmt.Errorf("cert-manager-webhook never became ready")}
			return
		}
	} else {
		ch <- stepResultMsg{stream: "certmanager"}
	}

	issuerYAML, err := renderClusterIssuer(cfg)
	if err != nil {
		ch <- stepResultMsg{stream: "issuer", err: err}
		return
	}
	err = applyYAML(ctx, issuerYAML)
	ch <- stepResultMsg{stream: "issuer", err: err}
}

func setupKubeconfig(ctx context.Context) error {
	home, err := os.UserHomeDir()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(home+"/.kube", 0700); err != nil {
		return err
	}
	uid, gid := os.Getuid(), os.Getgid()
	script := fmt.Sprintf(
		"cp /etc/rancher/k3s/k3s.yaml %s/.kube/config && chown %d:%d %s/.kube/config",
		home, uid, gid, home,
	)
	_, err = runQuiet(ctx, "", "sudo", "sh", "-c", script)
	if err == nil {
		os.Setenv("KUBECONFIG", home+"/.kube/config")
	}
	return err
}

func waitRollout(ctx context.Context, namespace, deployment string) bool {
	_, err := runQuiet(ctx, "", "kubectl", "-n", namespace, "rollout", "status",
		"deployment/"+deployment, "--timeout=120s")
	return err == nil
}

// runBuildImage clones sourceURL, builds the image tagged with the
// clone's own VERSION file, and imports it into containerd — the exact
// sequence INSTALL.md documents by hand, run from workDir so a re-run
// (e.g. after fixing the tag mismatch on screen 7b) starts from a clean
// clone rather than reusing a possibly half-built one.
func runBuildImage(ctx context.Context, ch chan<- tea.Msg, sourceURL, workDir string) {
	sourceDir := manifestPath(workDir, sourceDirName)
	_ = os.RemoveAll(sourceDir)

	if err := runStreamed(ctx, ch, "clone", "", "git", "clone", "--depth", "1", sourceURL, sourceDir); err != nil {
		return
	}

	version, err := readVersion(sourceDir)
	if err != nil {
		ch <- stepResultMsg{stream: "build", err: fmt.Errorf("read VERSION after clone: %w", err)}
		return
	}
	tag := "gitfed:" + version

	if err := runStreamed(ctx, ch, "build", sourceDir, "docker", "build",
		"-f", dockerfilePath(sourceDir), "-t", tag, "."); err != nil {
		return
	}

	err = runPiped(ctx, []string{"docker", "save", tag}, []string{"sudo", "k3s", "ctr", "images", "import", "-"})
	ch <- stepResultMsg{stream: "import", err: err}
}

// runDeploy applies the four unmodified manifests plus the two generated
// ones (configmap/ingress — see manifests.go), in the order INSTALL.md
// describes, one apply per message so the UI can show live per-file
// progress instead of one opaque "deploying…" spinner.
func runDeploy(ctx context.Context, ch chan<- tea.Msg, sourceDir string, cfg instanceConfig) {
	for _, rel := range manifestFiles {
		err := applyFile(ctx, manifestPath(sourceDir, rel))
		ch <- stepResultMsg{stream: "deploy:" + rel, err: err}
		if err != nil {
			return
		}
	}

	cmYAML, err := renderConfigMap(cfg)
	if err != nil {
		ch <- stepResultMsg{stream: "deploy:deploy/k8s/configmap.yaml", err: err}
		return
	}
	if err := applyYAML(ctx, cmYAML); err != nil {
		ch <- stepResultMsg{stream: "deploy:deploy/k8s/configmap.yaml", err: err}
		return
	}
	ch <- stepResultMsg{stream: "deploy:deploy/k8s/configmap.yaml"}

	ingYAML, err := renderIngress(cfg)
	if err != nil {
		ch <- stepResultMsg{stream: "deploy:deploy/k8s/ingress.yaml", err: err}
		return
	}
	err = applyYAML(ctx, ingYAML)
	ch <- stepResultMsg{stream: "deploy:deploy/k8s/ingress.yaml", err: err}
}

func applyFile(ctx context.Context, path string) error {
	_, err := runQuiet(ctx, "", "kubectl", "apply", "-f", path)
	return err
}

func applyYAML(ctx context.Context, yaml string) error {
	cmd := "cat <<'GITFED_INSTALL_EOF' | kubectl apply -f -\n" + yaml + "\nGITFED_INSTALL_EOF\n"
	_, err := runQuiet(ctx, "", "sh", "-c", cmd)
	return err
}

// deployOrder mirrors manifestFiles plus the two generated files, in the
// order runDeploy actually applies them — screens_deploy.go uses this to
// pre-populate the checklist before any result has come back yet.
var deployOrder = append(append([]string{}, manifestFiles...),
	"deploy/k8s/configmap.yaml", "deploy/k8s/ingress.yaml")

// pollPodOnce is one `kubectl get pod` snapshot — the caller (model.go's
// podPollTickMsg handler) re-issues this every couple seconds rather than
// this function looping itself, so it stays cancellable and testable in
// isolation.
func pollPodOnce(ctx context.Context) (podStatus, error) {
	out, err := runQuiet(ctx, "", "kubectl", "-n", "gitfed", "get", "pods", "--no-headers")
	if err != nil {
		return podStatus{}, err
	}
	ps, ok := parsePodStatus(out)
	if !ok {
		return podStatus{Phase: podStarting}, nil
	}
	return ps, nil
}

// diagnoseStuckPod is screen 7b's data: compare deployment.yaml's pinned
// tag against what's actually been imported into containerd.
func diagnoseStuckPod(ctx context.Context, sourceDir string) tagDiagnosis {
	deployYAML, _ := os.ReadFile(manifestPath(sourceDir, "deploy/k8s/deployment.yaml"))
	imagesOut, _ := runQuiet(ctx, "", "sudo", "k3s", "ctr", "images", "ls")
	return diagnoseImageTag(string(deployYAML), imagesOut)
}

// installGitfedCtl installs gitfed-ctl (see cmd/gitfed-ctl) straight into
// /usr/local/bin via a throwaway golang container — no system Go needed on
// the host (nothing else in this flow puts one there; the main image build
// compiles inside Docker too). The golang:1.25-bookworm base is already
// cached locally from runBuildImage a moment ago, so this is fast, not a
// second cold pull.
func installGitfedCtl(ctx context.Context) error {
	_, err := runQuiet(ctx, "", "docker", "run", "--rm",
		"-e", "GOPRIVATE=git.neuromancer.ovh/*",
		"-e", "GOBIN=/out",
		"-v", "/usr/local/bin:/out",
		"golang:1.25-bookworm",
		"go", "install", "git.neuromancer.ovh/bastien-mrq/gitfed/cmd/gitfed-ctl@latest")
	return err
}

func runVerify(ctx context.Context, domain string) []verifyResult {
	client := &http.Client{Timeout: 8 * time.Second}
	check := func(label, url string) verifyResult {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		if err != nil {
			return verifyResult{Label: label, Detail: err.Error()}
		}
		resp, err := client.Do(req)
		if err != nil {
			return verifyResult{Label: label, Detail: err.Error()}
		}
		defer resp.Body.Close()
		ok := resp.StatusCode == http.StatusOK
		detail := resp.Status
		if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
			detail += " · TLS " + resp.TLS.PeerCertificates[0].Issuer.CommonName
		}
		return verifyResult{Label: label, OK: ok, Detail: detail}
	}
	return []verifyResult{
		check("GET /.well-known/gitfed.json", "https://"+domain+"/.well-known/gitfed.json"),
		check("GET /", "https://"+domain+"/"),
	}
}