Gitfed
bastien-mrq/gitfed/ Commits/ 2091729

Add gitfed-install, a terminal wizard for first-time deployment

A Bubbletea TUI automating the exact steps INSTALL.md documents by hand: scan the machine for what's already installed (k3s, Docker, cert-manager, sudo, required ports), collect domain/contact (with public IP auto-detected as a DNS reference), verify DNS with automatic retry, install whatever's missing, clone+build+import the image with the correct version tag, apply the manifests (generating configmap/ ingress from the collected domain/contact, applying the rest unmodified), poll the pod to ready — diagnosing an ErrImageNeverPull tag mismatch automatically if it happens rather than leaving it to be debugged by hand — then verify the live endpoints. First-account creation deliberately isn't automated inline: rather than add a new gitfed-tui flag or script its interactive prompts, the wizard suspends itself (tea.ExecProcess) and hands the terminal to a real, unmodified `kubectl exec -it ... gitfed-tui` session, resuming once that exits. Keeps account creation exactly as documented — a human-driven step through the existing tool — without touching gitfed-tui itself. Separate binary from the four shipped in the container image: it shells out to docker/kubectl/git on the host, before the pod exists, so it can never run inside what it's installing (see the Dockerfile's updated comment). Tests cover the pure logic (scan detection, DNS matching, manifest templating, pod-status parsing, the ErrImageNeverPull tag-mismatch diagnosis) — not the actual system-mutating steps (installing k3s, docker build, kubectl apply), which need a disposable VPS to exercise for real rather than this development machine.

bastien-mrq 2026-07-29 10:55 commit 2091729085b86b621effec6c4a3c1883440c2754 parent e790d7bb6d6766f4799b29802859df2d8ea94775
27 files changed +2110 −4
M INSTALL.md +17 −0
M README.fr.md +4 −1
M README.md +4 −1
A cmd/gitfed-install/actions.go +216 −0
A cmd/gitfed-install/exec.go +89 −0
A cmd/gitfed-install/keys.go +156 −0
A cmd/gitfed-install/main.go +38 −0
A cmd/gitfed-install/manifests.go +45 −0
A cmd/gitfed-install/manifests_test.go +73 −0
A cmd/gitfed-install/model.go +419 −0
A cmd/gitfed-install/netutil.go +74 −0
A cmd/gitfed-install/netutil_test.go +39 −0
A cmd/gitfed-install/podstatus.go +125 −0
A cmd/gitfed-install/podstatus_test.go +67 −0
A cmd/gitfed-install/scan.go +114 −0
A cmd/gitfed-install/scan_test.go +50 −0
A cmd/gitfed-install/screens_deploy.go +143 −0
A cmd/gitfed-install/screens_finish.go +49 −0
A cmd/gitfed-install/screens_intro.go +63 −0
A cmd/gitfed-install/screens_network.go +69 −0
A cmd/gitfed-install/source.go +32 −0
A cmd/gitfed-install/styles.go +63 −0
A cmd/gitfed-install/templates/clusterissuer.yaml.tmpl +17 −0
A cmd/gitfed-install/templates/configmap.yaml.tmpl +22 −0
A cmd/gitfed-install/templates/ingress.yaml.tmpl +31 −0
A cmd/gitfed-install/view.go +87 −0
M deploy/docker/Dockerfile +4 −2
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