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+"/"),
}
}