INSTALL.md
diff --git a/INSTALL.md b/INSTALL.md
index 4940f08..a86e4e6 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -8,6 +8,23 @@ tournent déjà chez toi (par exemple à côté d'ess-helm), regarde plutôt
Compte environ 15-20 minutes, DNS mis à part (la propagation peut prendre
un peu de temps).
+**Plus simple : `gitfed-install`.** Un assistant terminal qui fait
+exactement les étapes ci-dessous à ta place — détecte ce qui existe déjà,
+demande domaine/email, installe/construit/déploie, et diagnostique
+lui-même le blocage `ErrImageNeverPull` s'il survient. Sur le VPS :
+
+```sh
+git clone https://git.neuromancer.ovh/bastien-mrq/gitfed.git && cd gitfed
+go build -o gitfed-install ./cmd/gitfed-install
+./gitfed-install
+```
+
+(nécessite Go — `curl -sfL https://go.dev/dl/go1.25.4.linux-amd64.tar.gz \|
+sudo tar -C /usr/local -xz` puis `export PATH=$PATH:/usr/local/go/bin` si
+ce n'est pas déjà installé sur le VPS.) Le reste de ce document explique
+les mêmes étapes en détail, à la main — utile pour comprendre ce que
+l'assistant fait, ou si ta situation ne rentre pas dans son parcours.
+
## Ce qu'il te faut avant de commencer
- Un VPS avec une IP publique (ce guide suppose Ubuntu/Debian), accès root
README.fr.md
diff --git a/README.fr.md b/README.fr.md
index 2da3276..23038d9 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -101,7 +101,10 @@ git clone ssh://git@localhost:2222/<user>/<repo>
Tu pars d'un VPS tout neuf, sans rien d'installé (ni k3s, ni
cert-manager) ? [`INSTALL.md`](INSTALL.md) reprend tout ça depuis zéro,
-étape par étape.
+étape par étape — ou lance `gitfed-install`
+(`go build -o gitfed-install ./cmd/gitfed-install`), un assistant terminal
+qui fait les mêmes étapes à ta place, détecte ce qui est déjà installé, et
+diagnostique lui-même un pod bloqué en `ErrImageNeverPull`.
Si k3s/Traefik/cert-manager tournent déjà chez toi, l'explication
complète — manifestes, pourquoi le pod est formé ainsi, DNS, amorçage du
README.md
diff --git a/README.md b/README.md
index 0ed5150..72d39c7 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,10 @@ git clone ssh://git@localhost:2222/<user>/<repo>
Starting from a brand new VPS with nothing on it yet (no k3s, no
cert-manager)? [`INSTALL.md`](INSTALL.md) *(French)* walks through all of
-that from scratch, step by step.
+that from scratch, step by step — or run `gitfed-install`
+(`go build -o gitfed-install ./cmd/gitfed-install`), a terminal wizard that
+does the same steps for you, detects what's already installed, and
+diagnoses a stuck `ErrImageNeverPull` pod on its own.
Already have k3s/Traefik/cert-manager running? The full walkthrough —
manifests, why the pod is shaped the way it is, DNS, bootstrapping the
cmd/gitfed-install/actions.go
diff --git a/cmd/gitfed-install/actions.go b/cmd/gitfed-install/actions.go
new file mode 100644
index 0000000..df98a48
--- /dev/null
+++ b/cmd/gitfed-install/actions.go
@@ -0,0 +1,216 @@
+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
+ }
+
+ runStreamed(ctx, ch, "import", "", "sh", "-c",
+ "docker save "+shellQuote(tag)+" | sudo k3s ctr images import -")
+}
+
+func shellQuote(s string) string { return "'" + s + "'" }
+
+// 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)
+}
+
+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+"/"),
+ }
+}
cmd/gitfed-install/exec.go
diff --git a/cmd/gitfed-install/exec.go b/cmd/gitfed-install/exec.go
new file mode 100644
index 0000000..0b85933
--- /dev/null
+++ b/cmd/gitfed-install/exec.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "os/exec"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// logLineMsg is one line of output from a running step's subprocess,
+// tagged with which step it belongs to so the right log panel picks it up
+// (several screens have their own scrolling log — see screens_deploy.go).
+type logLineMsg struct {
+ stream string
+ line string
+}
+
+// stepResultMsg reports a step's subprocess finishing, successfully or
+// not — every long-running action (installing k3s, building the image,
+// applying manifests...) ends in exactly one of these.
+type stepResultMsg struct {
+ stream string
+ err error
+}
+
+// listen drains the shared message channel one value at a time — every
+// async operation in this program (subprocess output, scan results, DNS
+// checks...) funnels through msgCh, and Update re-issues listen(msgCh)
+// after handling each message to keep receiving. This is the standard
+// Bubbletea pattern for an external event source feeding the UI (see e.g.
+// the bubbletea "realtime" example) — one channel, one listener loop,
+// rather than a bespoke plumbing per screen.
+func listen(ch chan tea.Msg) tea.Cmd {
+ return func() tea.Msg { return <-ch }
+}
+
+// runStreamed runs name(args...) with dir as its working directory (empty
+// for the current one), sending each line of its combined output as a
+// logLineMsg tagged stream, then exactly one stepResultMsg when it exits.
+// Meant to be launched with `go runStreamed(...)` from a tea.Cmd — it
+// blocks, so it must never run on Bubbletea's own update goroutine.
+//
+// Also returns the same error it put in the stepResultMsg, so a multi-
+// command sequence (runInstallDeps, runBuildImage, runDeploy) can bail out
+// after the first failure instead of ploughing on into commands that can
+// only fail more confusingly because an earlier one never finished.
+func runStreamed(ctx context.Context, ch chan<- tea.Msg, stream, dir, name string, args ...string) error {
+ cmd := exec.CommandContext(ctx, name, args...)
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ ch <- stepResultMsg{stream: stream, err: err}
+ return err
+ }
+ cmd.Stderr = cmd.Stdout
+
+ if err := cmd.Start(); err != nil {
+ ch <- stepResultMsg{stream: stream, err: err}
+ return err
+ }
+
+ scanner := bufio.NewScanner(stdout)
+ // Docker build progress lines can be long (full build-context paths);
+ // the default 64KiB scanner buffer is already generous, but bump it
+ // so a single unusually long line never aborts the scan mid-stream.
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ ch <- logLineMsg{stream: stream, line: scanner.Text()}
+ }
+
+ err = cmd.Wait()
+ ch <- stepResultMsg{stream: stream, err: err}
+ return err
+}
+
+// runQuiet runs name(args...) and returns its combined output and error,
+// for the (frequent) case a step needs a command's result but not a live
+// log of it — e.g. a single `kubectl get pod` status poll.
+func runQuiet(ctx context.Context, dir, name string, args ...string) (string, error) {
+ cmd := exec.CommandContext(ctx, name, args...)
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
cmd/gitfed-install/keys.go
diff --git a/cmd/gitfed-install/keys.go b/cmd/gitfed-install/keys.go
new file mode 100644
index 0000000..fd517c8
--- /dev/null
+++ b/cmd/gitfed-install/keys.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if m.step == stepDomain {
+ return m.handleDomainKey(msg)
+ }
+
+ switch msg.String() {
+ case "ctrl+c":
+ m.cancel()
+ m.quitting = true
+ return m, tea.Quit
+ }
+
+ switch m.step {
+ case stepWelcome:
+ if msg.String() == "enter" {
+ m.step = stepScan
+ return m, tea.Batch(m.cmdScan(), listen(m.msgCh))
+ }
+ if msg.String() == "q" {
+ m.quitting = true
+ return m, tea.Quit
+ }
+
+ case stepScan:
+ if m.scanDone && msg.String() == "enter" {
+ m.step = stepDomain
+ return m, tea.Batch(m.cmdDetectIP(), listen(m.msgCh))
+ }
+
+ case stepDNS:
+ switch msg.String() {
+ case "enter":
+ if m.dns.Matches {
+ m.step = stepK3s
+ return m, tea.Batch(m.cmdInstallDeps(), listen(m.msgCh))
+ }
+ case "i":
+ // proceed anyway — a certificate request that fails because
+ // DNS genuinely isn't ready yet is recoverable (screen 6's
+ // ingress apply just won't get a cert until it is), not a
+ // reason to trap someone here if they know what they're doing.
+ m.step = stepK3s
+ return m, tea.Batch(m.cmdInstallDeps(), listen(m.msgCh))
+ case "r":
+ m.dnsChecking = true
+ return m, tea.Batch(m.cmdCheckDNS(), listen(m.msgCh))
+ }
+
+ case stepK3s:
+ if msg.String() == "enter" && m.k3sDone && m.certManagerDone && m.issuerDone && m.installErr == nil {
+ m.step = stepBuild
+ home, _ := os.UserHomeDir()
+ m.workDir = home + "/.gitfed-install"
+ return m, tea.Batch(m.cmdBuildImage(), listen(m.msgCh))
+ }
+
+ case stepBuild:
+ if msg.String() == "enter" && m.buildDone && m.importDone && m.buildErr == nil {
+ m.sourceDir = manifestPath(m.workDir, sourceDirName)
+ m.deployStatus = initialDeployStatus()
+ m.step = stepDeploy
+ return m, tea.Batch(m.cmdDeploy(), listen(m.msgCh))
+ }
+
+ case stepDeploy:
+ if msg.String() == "enter" && m.deployIdx == len(deployOrder) && m.deployErr == nil {
+ m.step = stepPodStatus
+ m.podPolling = true
+ return m, tea.Batch(m.cmdPollPod(), listen(m.msgCh))
+ }
+
+ case stepPodStuck:
+ switch msg.String() {
+ case "r":
+ m.step = stepBuild
+ m.buildLines = nil
+ m.buildDone, m.importDone, m.buildErr = false, false, nil
+ return m, tea.Batch(m.cmdBuildImage(), listen(m.msgCh))
+ case "q":
+ m.quitting = true
+ return m, tea.Quit
+ }
+
+ case stepVerify:
+ if msg.String() == "enter" && m.verifyDone {
+ m.step = stepAccount
+ return m, listen(m.msgCh)
+ }
+
+ case stepAccount:
+ switch msg.String() {
+ case "enter":
+ return m, launchAccountTUI()
+ case "s":
+ m.step = stepSuccess
+ return m, listen(m.msgCh)
+ }
+
+ case stepSuccess:
+ if msg.String() == "enter" || msg.String() == "q" {
+ m.quitting = true
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+func (m model) handleDomainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c":
+ m.cancel()
+ m.quitting = true
+ return m, tea.Quit
+ case "tab", "shift+tab", "down", "up":
+ m.focusIdx = 1 - m.focusIdx
+ if m.focusIdx == 0 {
+ m.domainInput.Focus()
+ m.contactInput.Blur()
+ } else {
+ m.contactInput.Focus()
+ m.domainInput.Blur()
+ }
+ return m, nil
+ case "enter":
+ if m.domainInput.Value() != "" && m.contactInput.Value() != "" {
+ m.step = stepDNS
+ m.dnsChecking = true
+ return m, tea.Batch(m.cmdCheckDNS(), listen(m.msgCh))
+ }
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ if m.focusIdx == 0 {
+ m.domainInput, cmd = m.domainInput.Update(msg)
+ } else {
+ m.contactInput, cmd = m.contactInput.Update(msg)
+ }
+ return m, cmd
+}
+
+func initialDeployStatus() []deployFileStatus {
+ labels := make([]deployFileStatus, len(deployOrder))
+ for i, f := range deployOrder {
+ labels[i] = deployFileStatus{Label: f}
+ }
+ return labels
+}
cmd/gitfed-install/main.go
diff --git a/cmd/gitfed-install/main.go b/cmd/gitfed-install/main.go
new file mode 100644
index 0000000..21ee3be
--- /dev/null
+++ b/cmd/gitfed-install/main.go
@@ -0,0 +1,38 @@
+// gitfed-install is an interactive terminal wizard that deploys gitfed on
+// a fresh VPS: installs k3s/cert-manager if missing, builds and imports
+// the image with the correct tag, applies the manifests, waits for the
+// pod to come up (diagnosing the ErrImageNeverPull tag-mismatch case
+// specifically if it does), verifies the live endpoints, then hands off
+// to gitfed-tui for the first account. See INSTALL.md for the same steps
+// done by hand — this automates that guide, it doesn't replace it.
+//
+// Deliberately a separate binary from the four shipped in the gitfed
+// container image (see deploy/docker/Dockerfile): it shells out to
+// docker/kubectl/git on the *host*, before the pod even exists, so it can
+// never run inside the container it's installing.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+ source := flag.String("source", defaultSourceURL, "gitfed source repository to clone and build")
+ flag.Parse()
+
+ m := initialModel(*source)
+ p := tea.NewProgram(m)
+ finalModel, err := p.Run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-install: %v\n", err)
+ os.Exit(1)
+ }
+ if fm, ok := finalModel.(model); ok && fm.fatalErr != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-install: %v\n", fm.fatalErr)
+ os.Exit(1)
+ }
+}
cmd/gitfed-install/manifests.go
diff --git a/cmd/gitfed-install/manifests.go b/cmd/gitfed-install/manifests.go
new file mode 100644
index 0000000..46d7032
--- /dev/null
+++ b/cmd/gitfed-install/manifests.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "bytes"
+ "embed"
+ "text/template"
+)
+
+//go:embed templates/*.tmpl
+var templateFS embed.FS
+
+var tmpl = template.Must(template.ParseFS(templateFS, "templates/*.tmpl"))
+
+type instanceConfig struct {
+ Domain string
+ Contact string
+}
+
+func render(name string, cfg instanceConfig) (string, error) {
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, name, cfg); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+func renderConfigMap(cfg instanceConfig) (string, error) { return render("configmap.yaml.tmpl", cfg) }
+func renderIngress(cfg instanceConfig) (string, error) { return render("ingress.yaml.tmpl", cfg) }
+func renderClusterIssuer(cfg instanceConfig) (string, error) {
+ return render("clusterissuer.yaml.tmpl", cfg)
+}
+
+// manifestFiles are applied straight from the cloned source checkout, in
+// this order — none of them need templating: deployment.yaml's image tag
+// already matches VERSION in that same checkout (see INSTALL.md's fix for
+// why that pairing matters), and the rest have no per-instance values.
+// configmap.yaml and ingress.yaml are NOT in this list — those two are
+// generated (see above) instead of applied from the checkout, since
+// they're the only files with a domain/contact to fill in.
+var manifestFiles = []string{
+ "deploy/k8s/namespace.yaml",
+ "deploy/k8s/pvc.yaml",
+ "deploy/k8s/deployment.yaml",
+ "deploy/k8s/service.yaml",
+}
cmd/gitfed-install/manifests_test.go
diff --git a/cmd/gitfed-install/manifests_test.go b/cmd/gitfed-install/manifests_test.go
new file mode 100644
index 0000000..485ce3c
--- /dev/null
+++ b/cmd/gitfed-install/manifests_test.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestRenderConfigMapEmbedsValidJSON(t *testing.T) {
+ out, err := renderConfigMap(instanceConfig{Domain: "git.example.com", Contact: "admin@example.com"})
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ if !strings.Contains(out, "git.example.com") {
+ t.Errorf("rendered configmap missing domain:\n%s", out)
+ }
+
+ // Extract the embedded JSON block (everything after "gitfed.json: |")
+ // and check it actually parses — a template typo here would otherwise
+ // only surface once it's already been applied to a live cluster.
+ _, block, ok := strings.Cut(out, "gitfed.json: |\n")
+ if !ok {
+ t.Fatalf("rendered configmap has no gitfed.json block:\n%s", out)
+ }
+ var lines []string
+ for _, l := range strings.Split(block, "\n") {
+ lines = append(lines, strings.TrimPrefix(l, " "))
+ }
+ jsonText := strings.Join(lines, "\n")
+
+ var cfg map[string]any
+ if err := json.Unmarshal([]byte(jsonText), &cfg); err != nil {
+ t.Fatalf("embedded gitfed.json doesn't parse: %v\n---\n%s", err, jsonText)
+ }
+ if cfg["domain"] != "git.example.com" {
+ t.Errorf("domain = %v, want git.example.com", cfg["domain"])
+ }
+ if cfg["contact"] != "admin@example.com" {
+ t.Errorf("contact = %v, want admin@example.com", cfg["contact"])
+ }
+}
+
+func TestRenderIngressUsesDomainTwice(t *testing.T) {
+ out, err := renderIngress(instanceConfig{Domain: "git.example.com"})
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ // hosts: [...] and host: both need the real domain, not the template
+ // placeholder — a missed substitution here would silently issue a
+ // certificate for the wrong name (or none at all).
+ if got := strings.Count(out, "git.example.com"); got != 2 {
+ t.Errorf("domain appears %d times in rendered ingress, want 2:\n%s", got, out)
+ }
+ if strings.Contains(out, "{{") {
+ t.Errorf("unrendered template placeholder left in output:\n%s", out)
+ }
+}
+
+func TestRenderClusterIssuerName(t *testing.T) {
+ out, err := renderClusterIssuer(instanceConfig{Contact: "admin@example.com"})
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ // ingress.yaml.tmpl's annotation hardcodes this exact name — if this
+ // ever drifted, cert-manager just wouldn't fire for the ingress, with
+ // no error message pointing at why.
+ if !strings.Contains(out, "name: letsencrypt-prod") {
+ t.Errorf("ClusterIssuer name isn't letsencrypt-prod:\n%s", out)
+ }
+ if !strings.Contains(out, "email: admin@example.com") {
+ t.Errorf("rendered issuer missing contact email:\n%s", out)
+ }
+}
cmd/gitfed-install/model.go
diff --git a/cmd/gitfed-install/model.go b/cmd/gitfed-install/model.go
new file mode 100644
index 0000000..491c14e
--- /dev/null
+++ b/cmd/gitfed-install/model.go
@@ -0,0 +1,419 @@
+package main
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "time"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type step int
+
+const (
+ stepWelcome step = iota
+ stepScan
+ stepDomain
+ stepDNS
+ stepK3s
+ stepBuild
+ stepDeploy
+ stepPodStatus
+ stepPodStuck
+ stepVerify
+ stepAccount
+ stepSuccess
+)
+
+// deployFileStatus tracks one manifest's apply progress in stepDeploy.
+type deployFileStatus struct {
+ Label string
+ Done bool
+ Err error
+}
+
+type verifyResult struct {
+ Label string
+ OK bool
+ Detail string
+}
+
+type model struct {
+ step step
+ width, height int
+ quitting bool
+ fatalErr error
+
+ msgCh chan tea.Msg
+ ctx context.Context
+ cancel context.CancelFunc
+
+ sourceURL string
+ workDir string
+ sourceDir string
+
+ scan scanResult
+ scanDone bool
+
+ domainInput, contactInput textinput.Model
+ focusIdx int
+ publicIP string
+ publicIPDone bool
+
+ dns dnsOutcome
+ dnsChecked bool
+ dnsChecking bool
+ dnsRetryIn int
+
+ k3sLines []string
+ k3sDone bool
+ certManagerDone bool
+ issuerDone bool
+ installErr error
+
+ buildLines []string
+ buildDone bool
+ importDone bool
+ buildErr error
+
+ deployStatus []deployFileStatus
+ deployIdx int
+ deployErr error
+
+ pod podStatus
+ podPolling bool
+ podErr error
+
+ stuckDiag tagDiagnosis
+ stuckDiagDone bool
+
+ verify []verifyResult
+ verifyDone bool
+
+ spin spinner.Model
+}
+
+func initialModel(sourceURL string) model {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ di := textinput.New()
+ di.Placeholder = "git.mondomaine.fr"
+ di.Focus()
+ di.CharLimit = 253
+ di.Width = 40
+
+ ci := textinput.New()
+ ci.Placeholder = "moi@example.com"
+ ci.CharLimit = 253
+ ci.Width = 40
+
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ sp.Style = styleAccent
+
+ return model{
+ step: stepWelcome,
+ msgCh: make(chan tea.Msg, 16),
+ ctx: ctx,
+ cancel: cancel,
+ sourceURL: sourceURL,
+ domainInput: di,
+ contactInput: ci,
+ spin: sp,
+ }
+}
+
+func (m model) Init() tea.Cmd {
+ return tea.Batch(listen(m.msgCh), m.spin.Tick)
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ return m.handleKey(msg)
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spin, cmd = m.spin.Update(msg)
+ return m, cmd
+
+ case scanResultMsg:
+ m.scan = msg.result
+ m.scanDone = true
+ return m, listen(m.msgCh)
+
+ case publicIPMsg:
+ // The domain field is never prefilled from this — the IP is shown
+ // alongside it purely as a reference for the DNS record to add.
+ m.publicIP = msg.ip
+ m.publicIPDone = true
+ return m, listen(m.msgCh)
+
+ case dnsResultMsg:
+ m.dns = msg.outcome
+ m.dnsChecked = true
+ m.dnsChecking = false
+ if !m.dns.Matches {
+ m.dnsRetryIn = 15
+ return m, tea.Batch(listen(m.msgCh), dnsTick())
+ }
+ return m, listen(m.msgCh)
+
+ case dnsTickMsg:
+ if m.step != stepDNS || m.dns.Matches {
+ return m, nil
+ }
+ m.dnsRetryIn--
+ if m.dnsRetryIn <= 0 {
+ m.dnsChecking = true
+ return m, tea.Batch(m.cmdCheckDNS(), listen(m.msgCh))
+ }
+ return m, dnsTick()
+
+ case logLineMsg:
+ m.appendLog(msg.stream, msg.line)
+ return m, listen(m.msgCh)
+
+ case stepResultMsg:
+ m.handleStepResult(msg)
+ return m, listen(m.msgCh)
+
+ case podPollTickMsg:
+ if m.step != stepPodStatus {
+ return m, nil
+ }
+ m.podPolling = true
+ return m, tea.Batch(m.cmdPollPod(), listen(m.msgCh))
+
+ case podPollMsg:
+ m.pod = msg.status
+ m.podErr = msg.err
+ m.podPolling = false
+ if msg.err == nil && msg.status.Phase == podReady {
+ m.step = stepVerify
+ return m, tea.Batch(m.cmdVerify(), listen(m.msgCh))
+ }
+ if msg.err == nil && msg.status.Phase == podErrImageNeverPull {
+ m.step = stepPodStuck
+ m.stuckDiagDone = false
+ return m, tea.Batch(m.cmdDiagnoseStuck(), listen(m.msgCh))
+ }
+ // still starting — poll again shortly
+ return m, tea.Batch(pollAgain(), listen(m.msgCh))
+
+ case stuckDiagMsg:
+ m.stuckDiag = msg.diag
+ m.stuckDiagDone = true
+ return m, listen(m.msgCh)
+
+ case verifyResultMsg:
+ m.verify = msg.results
+ m.verifyDone = true
+ return m, listen(m.msgCh)
+
+ case accountHandoffDoneMsg:
+ m.step = stepSuccess
+ return m, listen(m.msgCh)
+
+ case fatalErrMsg:
+ m.fatalErr = msg.err
+ return m, listen(m.msgCh)
+ }
+ return m, nil
+}
+
+func (m *model) appendLog(stream, line string) {
+ switch stream {
+ case "k3s", "certmanager", "issuer":
+ m.k3sLines = append(m.k3sLines, line)
+ case "clone", "build", "import":
+ m.buildLines = append(m.buildLines, line)
+ }
+}
+
+func (m *model) handleStepResult(msg stepResultMsg) {
+ switch msg.stream {
+ case "k3s":
+ m.installErr = msg.err
+ if msg.err == nil {
+ m.k3sDone = true
+ }
+ case "certmanager":
+ if msg.err == nil {
+ m.certManagerDone = true
+ } else {
+ m.installErr = msg.err
+ }
+ case "issuer":
+ if msg.err == nil {
+ m.issuerDone = true
+ } else {
+ m.installErr = msg.err
+ }
+ case "clone", "build":
+ if msg.err != nil {
+ m.buildErr = msg.err
+ } else if msg.stream == "build" {
+ m.buildDone = true
+ }
+ case "import":
+ if msg.err != nil {
+ m.buildErr = msg.err
+ } else {
+ m.importDone = true
+ }
+ default:
+ if len(msg.stream) > 7 && msg.stream[:7] == "deploy:" {
+ m.handleDeployResult(msg)
+ }
+ }
+}
+
+func (m *model) handleDeployResult(msg stepResultMsg) {
+ for i := range m.deployStatus {
+ if "deploy:"+m.deployStatus[i].Label == msg.stream {
+ m.deployStatus[i].Done = msg.err == nil
+ m.deployStatus[i].Err = msg.err
+ }
+ }
+ if msg.err != nil {
+ m.deployErr = msg.err
+ return
+ }
+ m.deployIdx++
+}
+
+// --- messages produced by background goroutines ---
+
+type scanResultMsg struct{ result scanResult }
+type publicIPMsg struct{ ip string }
+type dnsResultMsg struct{ outcome dnsOutcome }
+type dnsTickMsg struct{}
+type podPollMsg struct {
+ status podStatus
+ err error
+}
+type verifyResultMsg struct{ results []verifyResult }
+type stuckDiagMsg struct{ diag tagDiagnosis }
+type accountHandoffDoneMsg struct{}
+type fatalErrMsg struct{ err error }
+
+func dnsTick() tea.Cmd {
+ return tea.Tick(time.Second, func(time.Time) tea.Msg { return dnsTickMsg{} })
+}
+
+type podPollTickMsg struct{}
+
+func pollAgain() tea.Cmd {
+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return podPollTickMsg{} })
+}
+
+// --- commands: each launches a goroutine that eventually writes to m.msgCh ---
+
+func (m model) cmdScan() tea.Cmd {
+ ch := m.msgCh
+ return func() tea.Msg {
+ go func() { ch <- scanResultMsg{result: runScan()} }()
+ return nil
+ }
+}
+
+func (m model) cmdDetectIP() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ return func() tea.Msg {
+ go func() { ch <- publicIPMsg{ip: detectPublicIP(ctx)} }()
+ return nil
+ }
+}
+
+func (m model) cmdCheckDNS() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ domain, ip := m.domainInput.Value(), m.publicIP
+ return func() tea.Msg {
+ go func() { ch <- dnsResultMsg{outcome: checkDNS(ctx, domain, ip)} }()
+ return nil
+ }
+}
+
+func (m model) cmdInstallDeps() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ scan := m.scan
+ cfg := instanceConfig{Domain: m.domainInput.Value(), Contact: m.contactInput.Value()}
+ return func() tea.Msg {
+ go runInstallDeps(ctx, ch, scan, cfg)
+ return nil
+ }
+}
+
+func (m model) cmdBuildImage() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ return func() tea.Msg {
+ go runBuildImage(ctx, ch, m.sourceURL, m.workDir)
+ return nil
+ }
+}
+
+func (m model) cmdDeploy() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ cfg := instanceConfig{Domain: m.domainInput.Value(), Contact: m.contactInput.Value()}
+ sourceDir := m.sourceDir
+ return func() tea.Msg {
+ go runDeploy(ctx, ch, sourceDir, cfg)
+ return nil
+ }
+}
+
+func (m model) cmdPollPod() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ return func() tea.Msg {
+ go func() {
+ status, err := pollPodOnce(ctx)
+ ch <- podPollMsg{status: status, err: err}
+ }()
+ return nil
+ }
+}
+
+func (m model) cmdDiagnoseStuck() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ sourceDir := m.sourceDir
+ return func() tea.Msg {
+ go func() { ch <- stuckDiagMsg{diag: diagnoseStuckPod(ctx, sourceDir)} }()
+ return nil
+ }
+}
+
+func (m model) cmdVerify() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ domain := m.domainInput.Value()
+ return func() tea.Msg {
+ go func() { ch <- verifyResultMsg{results: runVerify(ctx, domain)} }()
+ return nil
+ }
+}
+
+// launchAccountTUI suspends gitfed-install and hands the terminal to a
+// real, unmodified, interactive `kubectl exec -it ... gitfed-tui` session
+// — see screens_finish.go for why this is a handoff rather than gitfed-
+// install driving account creation itself.
+func launchAccountTUI() tea.Cmd {
+ c := exec.Command("kubectl", "-n", "gitfed", "exec", "-it", "deployment/gitfed", "-c", "server",
+ "--", "gitfed-tui", "-config", "/etc/gitfed/gitfed.json")
+ c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
+ return tea.ExecProcess(c, func(error) tea.Msg { return accountHandoffDoneMsg{} })
+}
cmd/gitfed-install/netutil.go
diff --git a/cmd/gitfed-install/netutil.go b/cmd/gitfed-install/netutil.go
new file mode 100644
index 0000000..3577fc5
--- /dev/null
+++ b/cmd/gitfed-install/netutil.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// publicIPServices are tried in order — a single provider being down (or
+// blocked outbound from this VPS) shouldn't stall the wizard on something
+// that's just a convenience prefill, never a hard requirement (screen 2's
+// field stays editable either way).
+var publicIPServices = []string{
+ "https://api.ipify.org",
+ "https://ifconfig.me/ip",
+ "https://icanhazip.com",
+}
+
+// detectPublicIP best-effort discovers this machine's public IP by asking
+// an external echo service — there's no reliable way to learn it purely
+// from local interfaces on a machine that's behind NAT or has multiple
+// addresses. Returns "" (never an error the UI needs to render specially)
+// if every service fails; the domain field is always editable regardless.
+func detectPublicIP(ctx context.Context) string {
+ client := &http.Client{Timeout: 5 * time.Second}
+ for _, url := range publicIPServices {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ continue
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ continue
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
+ resp.Body.Close()
+ if err != nil || resp.StatusCode != http.StatusOK {
+ continue
+ }
+ ip := strings.TrimSpace(string(body))
+ if net.ParseIP(ip) != nil {
+ return ip
+ }
+ }
+ return ""
+}
+
+// dnsOutcome is screen 3's result: whether domain currently resolves, and
+// to what — compared against the publicIP gathered on screen 2.
+type dnsOutcome struct {
+ Resolved bool
+ Addresses []string
+ Matches bool // true if publicIP is among Addresses
+ Err error
+}
+
+func checkDNS(ctx context.Context, domain, publicIP string) dnsOutcome {
+ var resolver net.Resolver
+ addrs, err := resolver.LookupHost(ctx, domain)
+ if err != nil {
+ return dnsOutcome{Err: err}
+ }
+ out := dnsOutcome{Resolved: true, Addresses: addrs}
+ for _, a := range addrs {
+ if a == publicIP {
+ out.Matches = true
+ break
+ }
+ }
+ return out
+}
cmd/gitfed-install/netutil_test.go
diff --git a/cmd/gitfed-install/netutil_test.go b/cmd/gitfed-install/netutil_test.go
new file mode 100644
index 0000000..cfbc5bf
--- /dev/null
+++ b/cmd/gitfed-install/netutil_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "context"
+ "testing"
+)
+
+func TestCheckDNSResolvedAndMatches(t *testing.T) {
+ ctx := context.Background()
+ out := checkDNS(ctx, "localhost", "127.0.0.1")
+ if !out.Resolved {
+ t.Fatalf("expected localhost to resolve, err=%v", out.Err)
+ }
+ if !out.Matches {
+ t.Errorf("expected 127.0.0.1 to be among localhost's addresses: %v", out.Addresses)
+ }
+}
+
+func TestCheckDNSMismatch(t *testing.T) {
+ ctx := context.Background()
+ out := checkDNS(ctx, "localhost", "203.0.113.99") // TEST-NET-3, never a real answer
+ if !out.Resolved {
+ t.Fatalf("expected localhost to resolve, err=%v", out.Err)
+ }
+ if out.Matches {
+ t.Errorf("203.0.113.99 should never be among localhost's addresses: %v", out.Addresses)
+ }
+}
+
+func TestCheckDNSUnresolvable(t *testing.T) {
+ ctx := context.Background()
+ out := checkDNS(ctx, "this-domain-should-not-exist.invalid", "1.2.3.4")
+ if out.Resolved {
+ t.Errorf("expected an .invalid domain to fail resolution, got addresses: %v", out.Addresses)
+ }
+ if out.Err == nil {
+ t.Error("expected a non-nil Err for an unresolvable domain")
+ }
+}
cmd/gitfed-install/podstatus.go
diff --git a/cmd/gitfed-install/podstatus.go b/cmd/gitfed-install/podstatus.go
new file mode 100644
index 0000000..d9b14e5
--- /dev/null
+++ b/cmd/gitfed-install/podstatus.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+type podPhase int
+
+const (
+ podUnknown podPhase = iota
+ podStarting
+ podReady
+ podErrImageNeverPull
+ podOtherError
+)
+
+type podStatus struct {
+ Name string
+ ReadyContainers, TotalContainers int
+ RawStatus string
+ Phase podPhase
+}
+
+// parsePodStatus reads one line of `kubectl -n gitfed get pods --no-headers`
+// output: NAME READY STATUS RESTARTS AGE. Returns ok=false if out has
+// no pod line yet (nothing scheduled) rather than a zero-value guess.
+func parsePodStatus(out string) (podStatus, bool) {
+ var line string
+ for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
+ if strings.HasPrefix(strings.TrimSpace(l), "gitfed-") {
+ line = l
+ break
+ }
+ }
+ if line == "" {
+ return podStatus{}, false
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ return podStatus{}, false
+ }
+
+ ps := podStatus{Name: fields[0], RawStatus: fields[2]}
+ if ready, total, ok := parseReadyCount(fields[1]); ok {
+ ps.ReadyContainers, ps.TotalContainers = ready, total
+ }
+
+ switch {
+ case ps.ReadyContainers == ps.TotalContainers && ps.TotalContainers > 0 && ps.RawStatus == "Running":
+ ps.Phase = podReady
+ case ps.RawStatus == "ErrImageNeverPull":
+ ps.Phase = podErrImageNeverPull
+ case ps.RawStatus == "Pending" || ps.RawStatus == "ContainerCreating" || ps.RawStatus == "Running":
+ ps.Phase = podStarting
+ default:
+ ps.Phase = podOtherError
+ }
+ return ps, true
+}
+
+func parseReadyCount(s string) (ready, total int, ok bool) {
+ a, b, found := strings.Cut(s, "/")
+ if !found {
+ return 0, 0, false
+ }
+ ready, err1 := strconv.Atoi(a)
+ total, err2 := strconv.Atoi(b)
+ return ready, total, err1 == nil && err2 == nil
+}
+
+var deploymentImageRe = regexp.MustCompile(`image:\s*(gitfed:\S+)`)
+
+// expectedImageTag extracts the image reference deployment.yaml asks for
+// — both containers (server/web) always carry the same tag (see
+// deploy/k8s/deployment.yaml), so the first match is authoritative.
+func expectedImageTag(deploymentYAML string) (string, bool) {
+ m := deploymentImageRe.FindStringSubmatch(deploymentYAML)
+ if m == nil {
+ return "", false
+ }
+ return m[1], true
+}
+
+var importedImageRe = regexp.MustCompile(`(?:docker\.io/library/)?(gitfed:\S+)`)
+
+// importedImageTags extracts every "gitfed:X.Y.Z"-shaped reference out of
+// `k3s ctr images ls` output, however containerd chose to qualify it
+// (bare "gitfed:1.2.4" or "docker.io/library/gitfed:1.2.4" are both seen
+// in practice depending on how the image was built/imported).
+func importedImageTags(ctrImagesOutput string) []string {
+ var tags []string
+ seen := map[string]bool{}
+ for _, m := range importedImageRe.FindAllStringSubmatch(ctrImagesOutput, -1) {
+ if !seen[m[1]] {
+ seen[m[1]] = true
+ tags = append(tags, m[1])
+ }
+ }
+ return tags
+}
+
+type tagDiagnosis struct {
+ Expected string
+ Imported []string
+ Matches bool
+}
+
+// diagnoseImageTag is screen 7b's core logic: is the tag deployment.yaml
+// asks for actually among what's been imported into containerd? This is
+// the automated version of the manual `grep`/`k3s ctr images ls` compare
+// INSTALL.md's troubleshooting section walks through by hand.
+func diagnoseImageTag(deploymentYAML, ctrImagesOutput string) tagDiagnosis {
+ expected, _ := expectedImageTag(deploymentYAML)
+ imported := importedImageTags(ctrImagesOutput)
+ d := tagDiagnosis{Expected: expected, Imported: imported}
+ for _, tag := range imported {
+ if tag == expected {
+ d.Matches = true
+ break
+ }
+ }
+ return d
+}
cmd/gitfed-install/podstatus_test.go
diff --git a/cmd/gitfed-install/podstatus_test.go b/cmd/gitfed-install/podstatus_test.go
new file mode 100644
index 0000000..09ae3c6
--- /dev/null
+++ b/cmd/gitfed-install/podstatus_test.go
@@ -0,0 +1,67 @@
+package main
+
+import "testing"
+
+func TestParsePodStatusReady(t *testing.T) {
+ out := "NAME READY STATUS RESTARTS AGE\n" +
+ "gitfed-55b785dc9c-sljxk 2/2 Running 0 45s\n"
+ ps, ok := parsePodStatus(out)
+ if !ok {
+ t.Fatal("expected a pod line to be found")
+ }
+ if ps.Phase != podReady {
+ t.Errorf("phase = %v, want podReady", ps.Phase)
+ }
+ if ps.ReadyContainers != 2 || ps.TotalContainers != 2 {
+ t.Errorf("ready/total = %d/%d, want 2/2", ps.ReadyContainers, ps.TotalContainers)
+ }
+}
+
+func TestParsePodStatusErrImageNeverPull(t *testing.T) {
+ // The exact shape from the real screenshot this feature was built to
+ // diagnose (see the CHANGELOG/INSTALL.md fix this session).
+ out := "NAME READY STATUS RESTARTS AGE\n" +
+ "cm-acme-http-solver-wxldr 1/1 Running 0 25s\n" +
+ "gitfed-55b785dc9c-sljxk 0/2 ErrImageNeverPull 0 29s\n"
+ ps, ok := parsePodStatus(out)
+ if !ok {
+ t.Fatal("expected a pod line to be found")
+ }
+ if ps.Phase != podErrImageNeverPull {
+ t.Errorf("phase = %v, want podErrImageNeverPull", ps.Phase)
+ }
+}
+
+func TestParsePodStatusNoPodYet(t *testing.T) {
+ _, ok := parsePodStatus("NAME READY STATUS RESTARTS AGE\n")
+ if ok {
+ t.Error("expected ok=false when no gitfed- pod line is present")
+ }
+}
+
+func TestDiagnoseImageTagMismatch(t *testing.T) {
+ deployment := " image: gitfed:1.2.4\n imagePullPolicy: Never\n"
+ ctrImages := "REF TYPE DIGEST\n" +
+ "docker.io/library/gitfed:latest linux/amd64 sha256:abc\n"
+
+ d := diagnoseImageTag(deployment, ctrImages)
+ if d.Expected != "gitfed:1.2.4" {
+ t.Errorf("Expected = %q, want gitfed:1.2.4", d.Expected)
+ }
+ if d.Matches {
+ t.Error("expected Matches=false: deployment wants 1.2.4, only :latest was imported")
+ }
+ if len(d.Imported) != 1 || d.Imported[0] != "gitfed:latest" {
+ t.Errorf("Imported = %v, want [gitfed:latest]", d.Imported)
+ }
+}
+
+func TestDiagnoseImageTagMatch(t *testing.T) {
+ deployment := " image: gitfed:1.2.4\n"
+ ctrImages := "docker.io/library/gitfed:1.2.4 linux/amd64 sha256:abc\n"
+
+ d := diagnoseImageTag(deployment, ctrImages)
+ if !d.Matches {
+ t.Errorf("expected Matches=true, got Expected=%q Imported=%v", d.Expected, d.Imported)
+ }
+}
cmd/gitfed-install/scan.go
diff --git a/cmd/gitfed-install/scan.go b/cmd/gitfed-install/scan.go
new file mode 100644
index 0000000..b0e5b2e
--- /dev/null
+++ b/cmd/gitfed-install/scan.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "net"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "time"
+)
+
+// checkStatus is a scan item's outcome — deliberately three-valued rather
+// than a bool, since "not found" and "found but going to be skipped" both
+// render differently from a hard failure (see screens_intro.go).
+type checkStatus int
+
+const (
+ statusOK checkStatus = iota
+ statusMissing
+ statusWarn
+)
+
+type checkItem struct {
+ Label string
+ Detail string
+ Status checkStatus
+}
+
+// scanResult is everything screen 1 ("Analyse de la machine") reports —
+// gathered once, non-destructively: nothing here changes any state on the
+// machine, it only observes.
+type scanResult struct {
+ OS, Arch string
+
+ HasK3s bool
+ HasDocker bool
+ HasKubectl bool
+ HasCertManager bool // only meaningful if HasKubectl && a cluster answers
+
+ HasSudo bool
+
+ PortsBusy []int // any of 80/443/2222 already bound by something else
+}
+
+// requiredPorts are the ports gitfed's own deployment needs free: 80/443
+// for Traefik (HTTP-01 challenge + normal traffic), 2222 for gitfed's own
+// git+ssh (deliberately not 22, see INSTALL.md).
+var requiredPorts = []int{80, 443, 2222}
+
+func runScan() scanResult {
+ r := scanResult{
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ }
+ r.HasK3s = commandExists("k3s")
+ r.HasDocker = commandExists("docker")
+ r.HasKubectl = commandExists("kubectl") || r.HasK3s // k3s bundles its own "k3s kubectl"
+ r.HasSudo = hasPasswordlessSudo()
+
+ if r.HasKubectl {
+ r.HasCertManager = certManagerPresent()
+ }
+
+ for _, p := range requiredPorts {
+ if portBusy(p) {
+ r.PortsBusy = append(r.PortsBusy, p)
+ }
+ }
+ return r
+}
+
+func commandExists(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
+
+// hasPasswordlessSudo checks non-interactively (-n) so this scan never
+// blocks on a password prompt or leaves one dangling — a "no" here just
+// means the wizard will prompt for a password later, when it actually
+// needs to run something privileged.
+func hasPasswordlessSudo() bool {
+ cmd := exec.Command("sudo", "-n", "true")
+ return cmd.Run() == nil
+}
+
+// certManagerPresent asks the cluster (not just "is the binary installed",
+// there is no separate binary) whether the cert-manager namespace exists.
+func certManagerPresent() bool {
+ cmd := exec.Command("kubectl", "get", "namespace", "cert-manager", "--no-headers", "--ignore-not-found")
+ out, err := cmd.Output()
+ return err == nil && len(out) > 0
+}
+
+// portBusy reports whether something is already listening on port p, by
+// attempting (and immediately releasing) a bind — the same check the real
+// service will do implicitly when it starts, just surfaced early instead
+// of failing later with a less obvious error.
+func portBusy(port int) bool {
+ portStr := strconv.Itoa(port)
+ l, err := net.Listen("tcp", ":"+portStr)
+ if err != nil {
+ return true
+ }
+ _ = l.Close()
+
+ // hostPort-style services (like gitfed's own git+ssh) bind on all
+ // interfaces, not just the wildcard the tcp Listen above already
+ // covers — a quick dial confirms nothing answers there either.
+ conn, err := net.DialTimeout("tcp", "127.0.0.1:"+portStr, 200*time.Millisecond)
+ if err == nil {
+ _ = conn.Close()
+ return true
+ }
+ return false
+}
cmd/gitfed-install/scan_test.go
diff --git a/cmd/gitfed-install/scan_test.go b/cmd/gitfed-install/scan_test.go
new file mode 100644
index 0000000..62f171f
--- /dev/null
+++ b/cmd/gitfed-install/scan_test.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "net"
+ "testing"
+)
+
+func TestCommandExists(t *testing.T) {
+ if !commandExists("go") {
+ t.Error("expected to find \"go\" on PATH — this test itself runs via `go test`")
+ }
+ if commandExists("gitfed-install-definitely-not-a-real-binary") {
+ t.Error("expected a made-up binary name to not exist")
+ }
+}
+
+func TestPortBusyDetectsAnOpenListener(t *testing.T) {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ defer l.Close()
+ port := l.Addr().(*net.TCPAddr).Port
+
+ if !portBusy(port) {
+ t.Errorf("port %d has an active listener, portBusy should report true", port)
+ }
+}
+
+func TestPortBusyFalseForAFreePort(t *testing.T) {
+ // Grab an ephemeral port, then release it immediately — it's very
+ // likely still free a moment later, which is all this needs.
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ port := l.Addr().(*net.TCPAddr).Port
+ l.Close()
+
+ if portBusy(port) {
+ t.Errorf("port %d was just released, portBusy should report false", port)
+ }
+}
+
+func TestRunScanPopulatesOSAndArch(t *testing.T) {
+ r := runScan()
+ if r.OS == "" || r.Arch == "" {
+ t.Errorf("runScan() left OS/Arch empty: %+v", r)
+ }
+}
cmd/gitfed-install/screens_deploy.go
diff --git a/cmd/gitfed-install/screens_deploy.go b/cmd/gitfed-install/screens_deploy.go
new file mode 100644
index 0000000..f710646
--- /dev/null
+++ b/cmd/gitfed-install/screens_deploy.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "strconv"
+ "strings"
+)
+
+func (m model) viewK3s() string {
+ var b strings.Builder
+ b.WriteString(row(stepGlyph(m.k3sDone, m.installErr), "[1/3] k3s", stepDetail(m.k3sDone)) + "\n")
+ b.WriteString(row(stepGlyph(m.certManagerDone, m.installErr), "[2/3] cert-manager", stepDetail(m.certManagerDone)) + "\n")
+ b.WriteString(row(stepGlyph(m.issuerDone, m.installErr), "[3/3] Émetteur Let's Encrypt", stepDetail(m.issuerDone)) + "\n")
+
+ if m.installErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Erreur : "+m.installErr.Error()))
+ }
+
+ b.WriteString("\n\n" + styleLog.Render(tailLines(m.k3sLines, 8)))
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewBuild() string {
+ var b strings.Builder
+ b.WriteString(row(stepGlyph(m.buildDone, m.buildErr), "docker build", stepDetail(m.buildDone)) + "\n")
+ b.WriteString(row(stepGlyph(m.importDone, m.buildErr), "import dans containerd", stepDetail(m.importDone)) + "\n")
+
+ if m.buildErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Erreur : "+m.buildErr.Error()))
+ }
+
+ b.WriteString("\n\n" + styleLog.Render(tailLines(m.buildLines, 10)))
+ b.WriteString("\n" + styleMuted.Render("Construit ici, sur cette machine — jamais copié depuis un poste de travail d'une autre architecture."))
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewDeploy() string {
+ var b strings.Builder
+ for i, f := range m.deployStatus {
+ g := glyph(statusMissing)
+ if f.Err != nil {
+ g = glyph(statusWarn)
+ } else if f.Done {
+ g = glyph(statusOK)
+ } else if i == m.deployIdx {
+ g = m.spin.View()
+ }
+ b.WriteString(row(g, shortManifestName(f.Label), "") + "\n")
+ }
+ if m.deployErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Erreur : "+m.deployErr.Error()))
+ }
+ return stylePanel.Render(b.String())
+}
+
+func shortManifestName(path string) string {
+ if i := strings.LastIndex(path, "/"); i >= 0 {
+ return path[i+1:]
+ }
+ return path
+}
+
+func (m model) viewPodStatus() string {
+ var b strings.Builder
+ if m.pod.Name == "" {
+ b.WriteString(m.spin.View() + " " + styleBody.Render("En attente que le pod soit planifié…"))
+ if m.podErr != nil {
+ b.WriteString("\n\n" + styleWarn.Render("!") + " " + styleMuted.Render(m.podErr.Error()))
+ }
+ return stylePanel.Render(b.String())
+ }
+ ready := m.pod.Phase == podReady
+ g := m.spin.View()
+ if ready {
+ g = glyph(statusOK)
+ }
+ b.WriteString(row(g, m.pod.Name, itoaFrac(m.pod.ReadyContainers, m.pod.TotalContainers)+" "+m.pod.RawStatus) + "\n")
+ if ready {
+ b.WriteString(row(glyph(statusOK), "server — socket admin actif", "") + "\n")
+ b.WriteString(row(glyph(statusOK), "web — répond sur :8088", "") + "\n")
+ } else {
+ b.WriteString("\n" + styleMuted.Render("Un bref 0/2 juste après le déploiement est normal — web attend le socket admin de server."))
+ }
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewPodStuck() string {
+ var b strings.Builder
+ b.WriteString(row(glyph(statusWarn), m.pod.Name, itoaFrac(m.pod.ReadyContainers, m.pod.TotalContainers)+" "+m.pod.RawStatus) + "\n\n")
+
+ if !m.stuckDiagDone {
+ b.WriteString(m.spin.View() + " " + styleBody.Render("Comparaison des tags d'image…"))
+ return stylePanelDanger.Render(b.String())
+ }
+
+ diag := m.stuckDiag
+ b.WriteString(styleBold.Render("Ce que ça veut dire :") + " " +
+ styleBody.Render("l'image importée n'a pas le tag attendu par le déploiement.") + "\n\n")
+ b.WriteString(row("", "deployment.yaml attend", diag.Expected) + "\n")
+ if len(diag.Imported) == 0 {
+ b.WriteString(row(glyph(statusWarn), "images importées trouvées", "aucune") + "\n")
+ }
+ for _, tag := range diag.Imported {
+ g := glyph(statusMissing)
+ if tag == diag.Expected {
+ g = glyph(statusOK)
+ }
+ b.WriteString(row(g, "images importées trouvées", tag) + "\n")
+ }
+
+ b.WriteString("\n" + styleBody.Render("→ Reconstruire avec le bon tag corrige ça automatiquement (r)."))
+ return stylePanelDanger.Render(b.String())
+}
+
+func stepGlyph(done bool, err error) string {
+ if err != nil {
+ return glyph(statusWarn)
+ }
+ if done {
+ return glyph(statusOK)
+ }
+ return glyph(statusMissing)
+}
+
+func stepDetail(done bool) string {
+ if done {
+ return "prêt"
+ }
+ return ""
+}
+
+func tailLines(lines []string, n int) string {
+ if len(lines) == 0 {
+ return styleMuted.Render("(pas encore de sortie)")
+ }
+ if len(lines) > n {
+ lines = lines[len(lines)-n:]
+ }
+ return strings.Join(lines, "\n")
+}
+
+func itoaFrac(a, b int) string {
+ return strconv.Itoa(a) + "/" + strconv.Itoa(b)
+}
cmd/gitfed-install/screens_finish.go
diff --git a/cmd/gitfed-install/screens_finish.go b/cmd/gitfed-install/screens_finish.go
new file mode 100644
index 0000000..ca48131
--- /dev/null
+++ b/cmd/gitfed-install/screens_finish.go
@@ -0,0 +1,49 @@
+package main
+
+import "strings"
+
+func (m model) viewVerify() string {
+ if !m.verifyDone {
+ return stylePanel.Render(m.spin.View() + " " + styleBody.Render("Vérification finale…"))
+ }
+ var b strings.Builder
+ for _, v := range m.verify {
+ g := glyph(statusWarn)
+ if v.OK {
+ g = glyph(statusOK)
+ }
+ b.WriteString(row(g, v.Label, v.Detail) + "\n")
+ }
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewAccount() string {
+ var b strings.Builder
+ b.WriteString(styleBody.Render(
+ "Il n'y a aucun compte au démarrage, et pas d'auto-inscription — c'est volontaire.\n"+
+ "On va lancer l'outil d'administration existant, directement contre le pod, pour\n"+
+ "que tu crées ton premier compte en interactif (nom, clé SSH publique, mot de\n"+
+ "passe, puis répondre "+styleBold.Render("y")+" à la question admin).") + "\n\n")
+
+ b.WriteString(styleMuted.Render("La commande qui va s'exécuter :") + "\n")
+ b.WriteString(styleAccent.Render(
+ "kubectl -n gitfed exec -it deployment/gitfed -c server -- \\\n"+
+ " gitfed-tui -config /etc/gitfed/gitfed.json") + "\n\n")
+
+ b.WriteString(styleBody.Render("Cet assistant se met en pause pendant ce temps — tu reviens ici en quittant gitfed-tui."))
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewSuccess() string {
+ domain := m.domainInput.Value()
+ var b strings.Builder
+ b.WriteString(styleOK.Render("✓ gitfed est en ligne") + "\n\n")
+ b.WriteString(row("", "Interface web", "https://"+domain) + "\n")
+ b.WriteString(row("", "Connexion", "https://"+domain+"/login") + "\n")
+ b.WriteString(row("", "Clone SSH", "git clone ssh://git@"+domain+":2222/<utilisateur>/<dépôt>") + "\n\n")
+ b.WriteString(styleMuted.Render(
+ "Suggéré pour la suite : sauvegardes régulières (deploy/backup.sh), mises à jour\n" +
+ "(deploy/update.sh), et la page /security de ta propre instance pour le détail\n" +
+ "du modèle de confiance."))
+ return stylePanel.Render(b.String())
+}
cmd/gitfed-install/screens_intro.go
diff --git a/cmd/gitfed-install/screens_intro.go b/cmd/gitfed-install/screens_intro.go
new file mode 100644
index 0000000..1b698d8
--- /dev/null
+++ b/cmd/gitfed-install/screens_intro.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+func (m model) viewWelcome() string {
+ var b strings.Builder
+ b.WriteString(styleBody.Render("Un serveur git auto-hébergé et fédéré — tes dépôts, ton serveur, tes règles.") + "\n\n")
+
+ reassure := []string{
+ "Aucun tracker, aucune télémétrie envoyée nulle part — cet assistant ne parle qu'à cette machine, ton VPS et Let's Encrypt.",
+ "Tes données restent sur le serveur que tu contrôles.",
+ "Aucun compte tiers requis pour installer ou utiliser gitfed.",
+ "Tu choisis seul·e à quelles autres instances faire confiance — jamais automatique.",
+ "Code source ouvert (AGPLv3), y compris ce que fait cet assistant.",
+ }
+ for _, line := range reassure {
+ b.WriteString(styleOK.Render("✓") + " " + styleBody.Render(line) + "\n")
+ }
+
+ b.WriteString("\n" + styleBody.Render(
+ "Cet assistant détecte ce qui est déjà en place, te demande ton domaine, construit\n"+
+ "et déploie gitfed, puis crée ton premier compte. "+styleBold.Render("Rien n'est exécuté sans ta\nconfirmation à chaque étape.")))
+
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewScan() string {
+ if !m.scanDone {
+ return stylePanel.Render(m.spin.View() + " " + styleBody.Render("Analyse de la machine…"))
+ }
+ s := m.scan
+ var b strings.Builder
+ b.WriteString(row(glyph(statusOK), "Système", s.OS+" · "+s.Arch) + "\n")
+ b.WriteString(row(condGlyph(s.HasK3s), "k3s", condLabel(s.HasK3s, "détecté", "non détecté — sera installé")) + "\n")
+ b.WriteString(row(condGlyph(s.HasDocker), "Docker", condLabel(s.HasDocker, "détecté", "non détecté — sera installé")) + "\n")
+ b.WriteString(row(condGlyph(s.HasCertManager), "cert-manager", condLabel(s.HasCertManager, "détecté", "non détecté — sera installé")) + "\n")
+ b.WriteString(row(condGlyph(s.HasSudo), "Accès sudo sans mot de passe", condLabel(s.HasSudo, "oui", "non — un mot de passe pourra être demandé")) + "\n")
+ if len(s.PortsBusy) == 0 {
+ b.WriteString(row(glyph(statusOK), "Ports 80 / 443 / 2222", "libres") + "\n")
+ } else {
+ b.WriteString(row(glyph(statusWarn), "Ports occupés", fmt.Sprint(s.PortsBusy)) + "\n")
+ }
+
+ b.WriteString("\n" + styleMuted.Render("→ Rien n'est modifié pour l'instant, ceci est juste un état des lieux."))
+ return stylePanel.Render(b.String())
+}
+
+func condGlyph(ok bool) string {
+ if ok {
+ return glyph(statusOK)
+ }
+ return glyph(statusMissing)
+}
+
+func condLabel(ok bool, yes, no string) string {
+ if ok {
+ return yes
+ }
+ return no
+}
cmd/gitfed-install/screens_network.go
diff --git a/cmd/gitfed-install/screens_network.go b/cmd/gitfed-install/screens_network.go
new file mode 100644
index 0000000..1fd1948
--- /dev/null
+++ b/cmd/gitfed-install/screens_network.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "strconv"
+ "strings"
+)
+
+func (m model) viewDomain() string {
+ var b strings.Builder
+ b.WriteString(styleInputLabel.Render("DOMAINE DE CETTE INSTANCE") + "\n")
+ b.WriteString(m.domainInput.View() + "\n")
+ b.WriteString(styleHint.Render("C'est l'adresse que tes collaborateurs utiliseront pour cloner et se connecter.") + "\n\n")
+
+ b.WriteString(styleInputLabel.Render("EMAIL DE CONTACT") + "\n")
+ b.WriteString(m.contactInput.View() + "\n")
+ b.WriteString(styleHint.Render("Sert uniquement aux alertes d'expiration de certificat Let's Encrypt.") + "\n\n")
+
+ if !m.publicIPDone {
+ b.WriteString(m.spin.View() + " " + styleMuted.Render("détection de l'IP publique…"))
+ } else if m.publicIP != "" {
+ b.WriteString(row(glyph(statusOK), "IP publique détectée automatiquement", m.publicIP) + "\n\n")
+ b.WriteString(styleMuted.Render("→ Ajoute un enregistrement DNS A pour ton domaine vers cette IP,\n maintenant ou pendant qu'on continue — on vérifiera à l'étape suivante."))
+ } else {
+ b.WriteString(styleWarn.Render("!") + " " + styleMuted.Render("IP publique non détectée automatiquement (pas de sortie internet directe ?) — pas bloquant, ajoute simplement le bon enregistrement DNS toi-même."))
+ }
+
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewDNS() string {
+ var b strings.Builder
+ domain := m.domainInput.Value()
+
+ if m.dnsChecking {
+ b.WriteString(m.spin.View() + " " + styleBody.Render("Résolution de "+domain+"…"))
+ return stylePanel.Render(b.String())
+ }
+
+ if !m.dnsChecked {
+ return stylePanel.Render(styleMuted.Render("En attente…"))
+ }
+
+ if !m.dns.Resolved {
+ b.WriteString(row(glyph(statusWarn), domain, "ne résout pas encore") + "\n\n")
+ b.WriteString(styleMuted.Render(errString(m.dns.Err)) + "\n\n")
+ } else if m.dns.Matches {
+ b.WriteString(row(glyph(statusOK), domain+" → "+strings.Join(m.dns.Addresses, ", "), "résolu") + "\n\n")
+ b.WriteString(styleBody.Render("Correspond à l'IP de cette machine — le certificat pourra être délivré à l'étape 4."))
+ return stylePanel.Render(b.String())
+ } else {
+ b.WriteString(row(glyph(statusWarn), domain+" → "+strings.Join(m.dns.Addresses, ", "), "ne correspond pas à "+m.publicIP) + "\n\n")
+ }
+
+ if m.dnsRetryIn > 0 {
+ b.WriteString(styleMuted.Render(fmtRetry(m.dnsRetryIn)))
+ }
+ return stylePanel.Render(b.String())
+}
+
+func fmtRetry(seconds int) string {
+ return "La propagation DNS peut prendre quelques minutes. Nouvel essai dans " + strconv.Itoa(seconds) + "s… (r : revérifier maintenant, i : ignorer et continuer quand même)"
+}
+
+func errString(err error) string {
+ if err == nil {
+ return ""
+ }
+ return err.Error()
+}
cmd/gitfed-install/source.go
diff --git a/cmd/gitfed-install/source.go b/cmd/gitfed-install/source.go
new file mode 100644
index 0000000..caae9fa
--- /dev/null
+++ b/cmd/gitfed-install/source.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// defaultSourceURL is gitfed's own public repo, cloned over anonymous
+// HTTPS — dogfooding the exact feature described in docs/HOW_IT_WORKS.
+// Overridable (see main.go's -source flag) for anyone running a fork.
+const defaultSourceURL = "https://git.neuromancer.ovh/bastien-mrq/gitfed.git"
+
+// sourceDirName is where the clone lands inside the working directory
+// gitfed-install creates for itself.
+const sourceDirName = "gitfed-src"
+
+func readVersion(sourceDir string) (string, error) {
+ b, err := os.ReadFile(filepath.Join(sourceDir, "VERSION"))
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(b)), nil
+}
+
+func dockerfilePath(sourceDir string) string {
+ return filepath.Join(sourceDir, "deploy", "docker", "Dockerfile")
+}
+
+func manifestPath(sourceDir, relPath string) string {
+ return filepath.Join(sourceDir, relPath)
+}
cmd/gitfed-install/styles.go
diff --git a/cmd/gitfed-install/styles.go b/cmd/gitfed-install/styles.go
new file mode 100644
index 0000000..ae970f7
--- /dev/null
+++ b/cmd/gitfed-install/styles.go
@@ -0,0 +1,63 @@
+package main
+
+import "github.com/charmbracelet/lipgloss"
+
+// Palette mirrors gitfed-web's own CSS tokens (cmd/gitfed-web/render.go)
+// so the installer and the web UI it's about to stand up feel like the
+// same product, not two unrelated tools.
+var (
+ colCanvas = lipgloss.Color("#0d0f13")
+ colSurface = lipgloss.Color("#161a21")
+ colBorder = lipgloss.Color("#262c36")
+ colText = lipgloss.Color("#e8eaed")
+ colTextDim = lipgloss.Color("#9aa1ac")
+ colTextFaint = lipgloss.Color("#6b7280")
+ colAccent = lipgloss.Color("#6c9df5")
+ colOK = lipgloss.Color("#7bd6a8")
+ colWarn = lipgloss.Color("#e0b34e")
+ colDanger = lipgloss.Color("#f28b82")
+)
+
+var (
+ styleTitle = lipgloss.NewStyle().Bold(true).Foreground(colText)
+ styleTag = lipgloss.NewStyle().Foreground(colTextFaint).Border(lipgloss.RoundedBorder()).
+ BorderForeground(colBorder).Padding(0, 1)
+ styleBody = lipgloss.NewStyle().Foreground(colTextDim)
+ styleMuted = lipgloss.NewStyle().Foreground(colTextFaint)
+ styleAccent = lipgloss.NewStyle().Foreground(colAccent)
+ styleBold = lipgloss.NewStyle().Bold(true).Foreground(colText)
+
+ styleOK = lipgloss.NewStyle().Foreground(colOK).Bold(true)
+ styleWarn = lipgloss.NewStyle().Foreground(colWarn).Bold(true)
+ styleDanger = lipgloss.NewStyle().Foreground(colDanger).Bold(true)
+
+ stylePanel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
+ BorderForeground(colBorder).Padding(0, 2).Foreground(colText)
+ stylePanelDanger = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
+ BorderForeground(colDanger).Padding(0, 2).Foreground(colText)
+
+ styleLog = lipgloss.NewStyle().Foreground(colTextDim).Border(lipgloss.NormalBorder()).
+ BorderForeground(colBorder).Padding(0, 1)
+
+ styleBtn = lipgloss.NewStyle().Foreground(colText).Background(colSurface).
+ Padding(0, 2).MarginRight(1)
+ styleBtnActive = lipgloss.NewStyle().Foreground(colCanvas).Background(colAccent).
+ Bold(true).Padding(0, 2).MarginRight(1)
+ styleBtnGhost = lipgloss.NewStyle().Foreground(colTextFaint).Padding(0, 2).MarginRight(1)
+
+ styleInputLabel = lipgloss.NewStyle().Foreground(colTextFaint)
+ styleHint = lipgloss.NewStyle().Foreground(colTextFaint).Italic(true)
+
+ styleFooter = lipgloss.NewStyle().Foreground(colTextFaint)
+)
+
+func glyph(status checkStatus) string {
+ switch status {
+ case statusOK:
+ return styleOK.Render("✓")
+ case statusWarn:
+ return styleWarn.Render("!")
+ default:
+ return styleMuted.Render("○")
+ }
+}
cmd/gitfed-install/templates/clusterissuer.yaml.tmpl
diff --git a/cmd/gitfed-install/templates/clusterissuer.yaml.tmpl b/cmd/gitfed-install/templates/clusterissuer.yaml.tmpl
new file mode 100644
index 0000000..34f7bf7
--- /dev/null
+++ b/cmd/gitfed-install/templates/clusterissuer.yaml.tmpl
@@ -0,0 +1,17 @@
+# Generated by gitfed-install. Name must stay "letsencrypt-prod" — that's
+# the exact name deploy/k8s/ingress.yaml's annotation (and this program's
+# own ingress.yaml.tmpl) references.
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt-prod
+spec:
+ acme:
+ server: https://acme-v02.api.letsencrypt.org/directory
+ email: {{.Contact}}
+ privateKeySecretRef:
+ name: letsencrypt-prod-key
+ solvers:
+ - http01:
+ ingress:
+ ingressClassName: traefik
cmd/gitfed-install/templates/configmap.yaml.tmpl
diff --git a/cmd/gitfed-install/templates/configmap.yaml.tmpl b/cmd/gitfed-install/templates/configmap.yaml.tmpl
new file mode 100644
index 0000000..29a39e2
--- /dev/null
+++ b/cmd/gitfed-install/templates/configmap.yaml.tmpl
@@ -0,0 +1,22 @@
+# Generated by gitfed-install — mirrors deploy/k8s/configmap.yaml's shape
+# with the domain/contact this instance was configured with. If that
+# checked-in file's structure ever changes, this template needs the same
+# change (see manifests.go).
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: gitfed-config
+ namespace: gitfed
+data:
+ gitfed.json: |
+ {
+ "domain": "{{.Domain}}",
+ "data_dir": "/data",
+ "repos_dir": "/data/repos",
+ "listen_ssh": ":2222",
+ "listen_http": ":8443",
+ "contact": "{{.Contact}}",
+ "trust_policy": "whitelist",
+ "cert_ttl_hours": 24,
+ "insecure_federation": false
+ }
cmd/gitfed-install/templates/ingress.yaml.tmpl
diff --git a/cmd/gitfed-install/templates/ingress.yaml.tmpl b/cmd/gitfed-install/templates/ingress.yaml.tmpl
new file mode 100644
index 0000000..4d49dd9
--- /dev/null
+++ b/cmd/gitfed-install/templates/ingress.yaml.tmpl
@@ -0,0 +1,31 @@
+# Generated by gitfed-install — mirrors deploy/k8s/ingress.yaml's shape
+# with this instance's domain. If that checked-in file's structure ever
+# changes, this template needs the same change (see manifests.go).
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: gitfed
+ namespace: gitfed
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts: ["{{.Domain}}"]
+ secretName: gitfed-tls
+ rules:
+ - host: {{.Domain}}
+ http:
+ paths:
+ - path: /.well-known/
+ pathType: Prefix
+ backend:
+ service:
+ name: gitfed-wellknown
+ port: {number: 8443}
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: gitfed-web
+ port: {number: 8088}
cmd/gitfed-install/view.go
diff --git a/cmd/gitfed-install/view.go b/cmd/gitfed-install/view.go
new file mode 100644
index 0000000..d62299f
--- /dev/null
+++ b/cmd/gitfed-install/view.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m model) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ body := m.renderStep()
+ header := styleTitle.Render("gitfed-install") + " " + styleTag.Render(stepLabel(m.step))
+ footer := styleFooter.Render(footerHint(m.step))
+
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
+}
+
+func (m model) renderStep() string {
+ switch m.step {
+ case stepWelcome:
+ return m.viewWelcome()
+ case stepScan:
+ return m.viewScan()
+ case stepDomain:
+ return m.viewDomain()
+ case stepDNS:
+ return m.viewDNS()
+ case stepK3s:
+ return m.viewK3s()
+ case stepBuild:
+ return m.viewBuild()
+ case stepDeploy:
+ return m.viewDeploy()
+ case stepPodStatus:
+ return m.viewPodStatus()
+ case stepPodStuck:
+ return m.viewPodStuck()
+ case stepVerify:
+ return m.viewVerify()
+ case stepAccount:
+ return m.viewAccount()
+ case stepSuccess:
+ return m.viewSuccess()
+ }
+ return ""
+}
+
+func stepLabel(s step) string {
+ names := map[step]string{
+ stepWelcome: "bienvenue", stepScan: "1/9 scan", stepDomain: "2/9 domaine",
+ stepDNS: "3/9 DNS", stepK3s: "4/9 dépendances", stepBuild: "5/9 image",
+ stepDeploy: "6/9 déploiement", stepPodStatus: "7/9 état du pod",
+ stepPodStuck: "7/9 ça bloque", stepVerify: "8/9 vérification",
+ stepAccount: "9/9 compte admin", stepSuccess: "terminé",
+ }
+ return names[s]
+}
+
+func footerHint(s step) string {
+ switch s {
+ case stepWelcome:
+ return "entrée : commencer · q : quitter"
+ case stepDomain:
+ return "tab : changer de champ · entrée : continuer · ctrl+c : quitter"
+ case stepDNS:
+ return "entrée : continuer · r : revérifier · i : ignorer et continuer"
+ case stepPodStuck:
+ return "r : reconstruire avec le bon tag · q : quitter"
+ case stepAccount:
+ return "entrée : lancer gitfed-tui · s : ignorer pour l'instant"
+ case stepSuccess:
+ return "entrée : terminer"
+ default:
+ return "ctrl+c : quitter"
+ }
+}
+
+func row(gl string, label string, val string) string {
+ l := styleBody.Render(label)
+ if val != "" {
+ return fmt.Sprintf("%s %-46s %s", gl, l, styleBold.Render(val))
+ }
+ return fmt.Sprintf("%s %s", gl, l)
+}
deploy/docker/Dockerfile
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile
index 9332989..6643b78 100644
--- a/deploy/docker/Dockerfile
+++ b/deploy/docker/Dockerfile
@@ -3,8 +3,10 @@
# bootstrap the first account (username/password/admin flag) directly
# against the live admin socket — see deploy/k8s/README.md.
#
-# gitfed-renew-cert is NOT included — it's a client-side tool end users run
-# on their own machines, not part of the server deployment.
+# gitfed-renew-cert and gitfed-install are NOT included — both are
+# host-side tools (gitfed-install specifically shells out to docker/
+# kubectl on the machine it's installing gitfed onto, before the pod even
+# exists), never something that runs inside the container they help set up.
FROM golang:1.25-bookworm AS builder
WORKDIR /src