Add gitfed-ctl: local update utility for follower instances
Replaces deploy/update.sh current's SSH-to-self round trip (repeated password prompts, missed kubeconfig setup — both observed live) with a tool that runs directly on the VPS: checks for new versions, forces acknowledgment of any CHANGELOG.md **BREAKING:** change before continuing, optionally backs up the data volume, then rebuilds and redeploys locally. Account and trust management stay on the web admin UI, which already covers both.
18 files changed
+1719 −8
M
CHANGELOG.md
+2 −0
M
INSTALL.md
+17 −4
A
cmd/gitfed-ctl/backup.go
+94 −0
A
cmd/gitfed-ctl/backup_test.go
+77 −0
A
cmd/gitfed-ctl/changelog.go
+214 −0
A
cmd/gitfed-ctl/changelog_test.go
+100 −0
A
cmd/gitfed-ctl/exec.go
+90 −0
A
cmd/gitfed-ctl/health.go
+79 −0
A
cmd/gitfed-ctl/keys.go
+154 −0
A
cmd/gitfed-ctl/main.go
+71 −0
A
cmd/gitfed-ctl/model.go
+316 −0
A
cmd/gitfed-ctl/screens_dashboard.go
+52 −0
A
cmd/gitfed-ctl/screens_updates.go
+153 −0
A
cmd/gitfed-ctl/styles.go
+61 −0
A
cmd/gitfed-ctl/update_pipeline.go
+87 −0
A
cmd/gitfed-ctl/update_pipeline_test.go
+56 −0
A
cmd/gitfed-ctl/view.go
+91 −0
M
deploy/docker/Dockerfile
+5 −4
CHANGELOG.md
@@ -1,5 +1,7 @@
# Changelog
+A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version.
+
## 1.2.7
- Fixed the `go install` instructions from 1.2.6: `@main` doesn't reliably resolve (Go's module resolution wants a real semver tag, not a branch pseudo-version, for this kind of self-hosted lookup), and the plain command errors out by default anyway since `go` tries the public proxy first. The real, tested command is `GOPRIVATE=git.neuromancer.ovh/* go install .../gitfed/cmd/gitfed-renew-cert@latest`. `deploy/update.sh` now also tags every release (`vX.Y.Z`) so `@latest` keeps resolving to something going forward — this release is the first one tagged.
INSTALL.md
@@ -277,10 +277,23 @@ Si ça fonctionne, l'installation est terminée.
X.Y.Z` — ajoute d'abord une entrée `## X.Y.Z` à `CHANGELOG.md`
décrivant le changement, le script refuse de continuer sans ça.
- **Tu suis juste les mises à jour de quelqu'un d'autre** (tu n'as rien
- modifié, tu veux juste la dernière version) : `git pull` pour
- récupérer le code, puis `deploy/update.sh current` — construit et
- déploie exactement ce que tu viens de récupérer, sans toucher à
- `VERSION` ni à git.
+ modifié, tu veux juste la dernière version) : `deploy/update.sh
+ current` — récupère le code (`git pull --ff-only`, refuse si le
+ répertoire de travail n'est pas propre) puis construit et déploie,
+ sans toucher à `VERSION` ni committer quoi que ce soit. Se lance
+ toujours depuis ta machine de travail, comme les autres modes.
+
+ Plus simple : **`gitfed-ctl`**, un utilitaire pensé pour ce cas
+ précis — il tourne directement sur le VPS (pas de `rsync`/SSH vers
+ soi-même, donc pas de mot de passe redemandé à chaque étape), affiche
+ les nouvelles versions disponibles avec leur changelog, et fait
+ acquitter individuellement tout changement cassant avant de continuer :
+
+ ```sh
+ GOPRIVATE=git.neuromancer.ovh/* go install git.neuromancer.ovh/bastien-mrq/gitfed/cmd/gitfed-ctl@latest
+ cd ~/gitfed-src # ou ton propre checkout sur le VPS
+ sudo gitfed-ctl
+ ```
- **Sauvegardes** : tout ce qui compte vit sur le volume `gitfed-data`
(base, clé de CA, clé d'hôte SSH, dépôts). Voir la section « Backups »
de [`deploy/k8s/README.md`](deploy/k8s/README.md#backups) pour
cmd/gitfed-ctl/backup.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+// defaultBackupKeep matches deploy/backup.sh's own default — kept the same
+// so the two tools' pruning behavior doesn't quietly diverge.
+const defaultBackupKeep = 14
+
+// runBackupNow is the local (no ssh) equivalent of deploy/backup.sh's core
+// snapshot: stream a tar of the whole /data volume out of the running
+// server container, write it atomically (a .part file renamed on success,
+// so a crash mid-stream never leaves a truncated backup mistaken for a
+// good one), then prune down to defaultBackupKeep. It's a live, crash-
+// consistent copy (bbolt tolerates this), not a maintenance-window
+// snapshot — same caveat deploy/backup.sh's own header documents.
+func runBackupNow(ctx context.Context, sourceDir string) (path string, err error) {
+ backupDir := filepath.Join(sourceDir, "backups")
+ if err := os.MkdirAll(backupDir, 0755); err != nil {
+ return "", err
+ }
+
+ timestamp := time.Now().UTC().Format("20060102T150405Z")
+ out := filepath.Join(backupDir, fmt.Sprintf("gitfed-%s.tar.gz", timestamp))
+ tmp := out + ".part"
+
+ f, err := os.Create(tmp)
+ if err != nil {
+ return "", err
+ }
+
+ cmdErr := runToFile(ctx, f, "kubectl", "-n", "gitfed", "exec", "deployment/gitfed", "-c", "server",
+ "--", "tar", "czf", "-", "-C", "/data", ".")
+ closeErr := f.Close()
+ if cmdErr != nil {
+ os.Remove(tmp)
+ return "", cmdErr
+ }
+ if closeErr != nil {
+ os.Remove(tmp)
+ return "", closeErr
+ }
+
+ if err := os.Rename(tmp, out); err != nil {
+ return "", err
+ }
+
+ if err := pruneBackups(sourceDir, defaultBackupKeep); err != nil {
+ // A pruning failure shouldn't fail the backup that already
+ // succeeded — the disk just has more history than intended.
+ return out, nil
+ }
+ return out, nil
+}
+
+// pruneBackups keeps the keep most recent backups/gitfed-*.tar.gz files
+// (by modification time) and removes the rest — same "keep N most recent"
+// behavior as deploy/backup.sh's `ls -1t | tail -n +N+1 | xargs rm`.
+func pruneBackups(sourceDir string, keep int) error {
+ matches, err := filepath.Glob(filepath.Join(sourceDir, "backups", "gitfed-*.tar.gz"))
+ if err != nil {
+ return err
+ }
+ if len(matches) <= keep {
+ return nil
+ }
+
+ type fileTime struct {
+ path string
+ mod time.Time
+ }
+ files := make([]fileTime, 0, len(matches))
+ for _, m := range matches {
+ info, err := os.Stat(m)
+ if err != nil {
+ continue
+ }
+ files = append(files, fileTime{path: m, mod: info.ModTime()})
+ }
+ sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
+
+ for _, f := range files[keep:] {
+ if err := os.Remove(f.path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
cmd/gitfed-ctl/backup_test.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestPruneBackupsKeepsNewest(t *testing.T) {
+ dir := t.TempDir()
+ backupDir := filepath.Join(dir, "backups")
+ if err := os.MkdirAll(backupDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ names := []string{
+ "gitfed-20260101T000000Z.tar.gz",
+ "gitfed-20260102T000000Z.tar.gz",
+ "gitfed-20260103T000000Z.tar.gz",
+ "gitfed-20260104T000000Z.tar.gz",
+ "gitfed-20260105T000000Z.tar.gz",
+ }
+ base := time.Now().Add(-time.Hour)
+ for i, n := range names {
+ p := filepath.Join(backupDir, n)
+ if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ mtime := base.Add(time.Duration(i) * time.Minute)
+ if err := os.Chtimes(p, mtime, mtime); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := pruneBackups(dir, 3); err != nil {
+ t.Fatalf("pruneBackups: %v", err)
+ }
+
+ remaining, err := filepath.Glob(filepath.Join(backupDir, "gitfed-*.tar.gz"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(remaining) != 3 {
+ t.Fatalf("expected 3 files remaining, got %d: %v", len(remaining), remaining)
+ }
+ for _, want := range names[2:] {
+ found := false
+ for _, r := range remaining {
+ if filepath.Base(r) == want {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("expected newest file %s to survive pruning, remaining=%v", want, remaining)
+ }
+ }
+}
+
+func TestPruneBackupsNoopWhenUnderLimit(t *testing.T) {
+ dir := t.TempDir()
+ backupDir := filepath.Join(dir, "backups")
+ if err := os.MkdirAll(backupDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(backupDir, "gitfed-20260101T000000Z.tar.gz")
+ if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := pruneBackups(dir, defaultBackupKeep); err != nil {
+ t.Fatalf("pruneBackups: %v", err)
+ }
+ if _, err := os.Stat(p); err != nil {
+ t.Errorf("expected file to survive when under the keep limit: %v", err)
+ }
+}
cmd/gitfed-ctl/changelog.go
@@ -0,0 +1,214 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// bullet is one CHANGELOG.md entry under a version heading.
+type bullet struct {
+ Text string
+ Breaking bool
+}
+
+// versionChangelog is everything gitfed-ctl found for one version in an
+// update range. TagMissing means the release commit is on the upstream
+// branch but no "vX.Y.Z" tag exists yet for it (a real gap: update.sh only
+// tags after a manual `git push --tags`) — rather than silently skipping
+// what might be a breaking change we can't actually read yet, this
+// produces one synthetic, non-dismissible bullet forcing acknowledgment.
+type versionChangelog struct {
+ Version string
+ Bullets []bullet
+ TagMissing bool
+}
+
+// checkForUpdates fetches tags and compares the local VERSION file against
+// whatever VERSION says at the tip of the configured upstream branch,
+// without touching the working tree (no pull yet — that only happens once
+// the wizard is confirmed).
+func checkForUpdates(ctx context.Context, sourceDir string) (current, target string, upToDate bool, err error) {
+ if _, err := runQuiet(ctx, sourceDir, "git", "fetch", "--tags"); err != nil {
+ return "", "", false, fmt.Errorf("git fetch: %w", err)
+ }
+
+ ref, err := upstreamRef(ctx, sourceDir)
+ if err != nil {
+ return "", "", false, err
+ }
+
+ targetRaw, err := runQuiet(ctx, sourceDir, "git", "show", ref+":VERSION")
+ if err != nil {
+ return "", "", false, fmt.Errorf("read VERSION at %s: %w", ref, err)
+ }
+
+ currentBytes, err := os.ReadFile(sourceDir + "/VERSION")
+ if err != nil {
+ return "", "", false, fmt.Errorf("read local VERSION: %w", err)
+ }
+
+ current = strings.TrimSpace(string(currentBytes))
+ target = strings.TrimSpace(targetRaw)
+ return current, target, current == target, nil
+}
+
+// upstreamRef resolves the branch's configured tracking remote, falling
+// back to origin/main if none is set (e.g. a checkout cloned in a way that
+// never set one up).
+func upstreamRef(ctx context.Context, sourceDir string) (string, error) {
+ out, err := runQuiet(ctx, sourceDir, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
+ if err != nil {
+ return "origin/main", nil
+ }
+ ref := strings.TrimSpace(out)
+ if ref == "" {
+ return "origin/main", nil
+ }
+ return ref, nil
+}
+
+// versionsInRange returns every version strictly after currentVersion and
+// up to and including targetVersion, in ascending order, each with its
+// CHANGELOG.md bullets read straight from its tag (git show, never
+// checked out) so nothing in the working tree changes before the wizard is
+// confirmed.
+func versionsInRange(ctx context.Context, sourceDir, currentVersion, targetVersion string) ([]versionChangelog, error) {
+ tagsOut, err := runQuiet(ctx, sourceDir, "git", "tag", "--list", "v*", "--sort=v:refname")
+ if err != nil {
+ return nil, fmt.Errorf("git tag --list: %w", err)
+ }
+
+ var versions []versionChangelog
+ seen := map[string]bool{}
+ for _, line := range strings.Split(tagsOut, "\n") {
+ tag := strings.TrimSpace(line)
+ if tag == "" || !strings.HasPrefix(tag, "v") {
+ continue
+ }
+ ver := strings.TrimPrefix(tag, "v")
+ if compareVersions(ver, currentVersion) <= 0 || compareVersions(ver, targetVersion) > 0 {
+ continue
+ }
+ seen[ver] = true
+
+ out, err := runQuiet(ctx, sourceDir, "git", "show", tag+":CHANGELOG.md")
+ if err != nil {
+ versions = append(versions, versionChangelog{
+ Version: ver,
+ TagMissing: true,
+ Bullets: []bullet{{
+ Text: "impossible de lire CHANGELOG.md pour " + tag + " — le tag existe mais son contenu n'a pas pu être lu",
+ Breaking: true,
+ }},
+ })
+ continue
+ }
+ versions = append(versions, versionChangelog{Version: ver, Bullets: parseSection(out, ver)})
+ }
+
+ // The release commit for targetVersion may be on the upstream branch
+ // without a tag yet (update.sh only tags after a manual git push) —
+ // fail safe rather than silently skip it.
+ if !seen[targetVersion] {
+ versions = append(versions, versionChangelog{
+ Version: targetVersion,
+ TagMissing: true,
+ Bullets: []bullet{{
+ Text: "aucun tag trouvé pour " + targetVersion + " — impossible de vérifier s'il contient un changement cassant",
+ Breaking: true,
+ }},
+ })
+ }
+
+ sortVersions(versions)
+ return versions, nil
+}
+
+func sortVersions(versions []versionChangelog) {
+ for i := 1; i < len(versions); i++ {
+ for j := i; j > 0 && compareVersions(versions[j].Version, versions[j-1].Version) < 0; j-- {
+ versions[j], versions[j-1] = versions[j-1], versions[j]
+ }
+ }
+}
+
+// parseSection extracts the bullets under "## version" in changelogMD, up
+// to the next "## " heading or EOF, and flags any bullet whose text starts
+// with "**BREAKING:**" (see the convention note at the top of
+// CHANGELOG.md).
+func parseSection(changelogMD, version string) []bullet {
+ heading := "## " + version
+ lines := strings.Split(changelogMD, "\n")
+
+ start := -1
+ for i, l := range lines {
+ if strings.TrimSpace(l) == heading {
+ start = i + 1
+ break
+ }
+ }
+ if start == -1 {
+ return nil
+ }
+
+ var bullets []bullet
+ for _, l := range lines[start:] {
+ trimmed := strings.TrimSpace(l)
+ if strings.HasPrefix(trimmed, "## ") {
+ break
+ }
+ if !strings.HasPrefix(trimmed, "- ") {
+ continue
+ }
+ text := strings.TrimPrefix(trimmed, "- ")
+ bullets = append(bullets, bullet{
+ Text: text,
+ Breaking: strings.HasPrefix(text, "**BREAKING:**"),
+ })
+ }
+ return bullets
+}
+
+// compareVersions compares two bare "X.Y.Z" semver strings, returning -1,
+// 0 or 1. Malformed components parse as 0, matching update.sh's own
+// unchecked assumption that VERSION/tags are always well-formed.
+func compareVersions(a, b string) int {
+ pa, pb := versionParts(a), versionParts(b)
+ for i := 0; i < 3; i++ {
+ if pa[i] != pb[i] {
+ if pa[i] < pb[i] {
+ return -1
+ }
+ return 1
+ }
+ }
+ return 0
+}
+
+func versionParts(v string) [3]int {
+ var parts [3]int
+ fields := strings.SplitN(v, ".", 3)
+ for i := 0; i < len(fields) && i < 3; i++ {
+ n, _ := strconv.Atoi(fields[i])
+ parts[i] = n
+ }
+ return parts
+}
+
+// flattenBreaking collects every breaking (or tag-missing) bullet across a
+// version range, in order — the update wizard's acknowledgment screen
+// works off this flat list.
+func flattenBreaking(versions []versionChangelog) []bullet {
+ var out []bullet
+ for _, v := range versions {
+ for _, b := range v.Bullets {
+ if b.Breaking {
+ out = append(out, b)
+ }
+ }
+ }
+ return out
+}
cmd/gitfed-ctl/changelog_test.go
@@ -0,0 +1,100 @@
+package main
+
+import "testing"
+
+func TestParseSection(t *testing.T) {
+ md := `# Changelog
+
+## 1.2.9
+
+- **BREAKING:** trust_policy déménage sous federation.trust_policy.
+- Vue Fédération dans gitfed-web.
+
+## 1.2.8
+
+- Badge de confiance en attente dans la nav admin.
+`
+ bullets := parseSection(md, "1.2.9")
+ if len(bullets) != 2 {
+ t.Fatalf("expected 2 bullets, got %d", len(bullets))
+ }
+ if !bullets[0].Breaking {
+ t.Errorf("expected first bullet to be flagged breaking, got %+v", bullets[0])
+ }
+ if bullets[1].Breaking {
+ t.Errorf("expected second bullet not to be flagged breaking, got %+v", bullets[1])
+ }
+
+ older := parseSection(md, "1.2.8")
+ if len(older) != 1 || older[0].Breaking {
+ t.Errorf("expected 1.2.8 to have 1 non-breaking bullet, got %+v", older)
+ }
+
+ if got := parseSection(md, "9.9.9"); got != nil {
+ t.Errorf("expected nil for a version not present, got %+v", got)
+ }
+}
+
+func TestParseSectionDoesNotBleedIntoNextVersion(t *testing.T) {
+ md := `# Changelog
+
+## 2.0.0
+
+- **BREAKING:** something big.
+
+## 1.9.0
+
+- **BREAKING:** this must not be counted for 2.0.0.
+`
+ bullets := parseSection(md, "2.0.0")
+ if len(bullets) != 1 {
+ t.Fatalf("expected exactly 1 bullet for 2.0.0, got %+v", bullets)
+ }
+}
+
+func TestCompareVersions(t *testing.T) {
+ cases := []struct {
+ a, b string
+ want int
+ }{
+ {"1.2.9", "1.2.9", 0},
+ {"1.2.8", "1.2.9", -1},
+ {"1.2.10", "1.2.9", 1}, // numeric, not lexicographic
+ {"2.0.0", "1.9.9", 1},
+ {"1.2.0", "1.10.0", -1},
+ }
+ for _, c := range cases {
+ if got := compareVersions(c.a, c.b); got != c.want {
+ t.Errorf("compareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
+ }
+ }
+}
+
+func TestFlattenBreaking(t *testing.T) {
+ versions := []versionChangelog{
+ {Version: "1.2.8", Bullets: []bullet{{Text: "a"}, {Text: "b", Breaking: true}}},
+ {Version: "1.2.9", Bullets: []bullet{{Text: "c", Breaking: true}}},
+ }
+ flat := flattenBreaking(versions)
+ if len(flat) != 2 {
+ t.Fatalf("expected 2 breaking bullets, got %d: %+v", len(flat), flat)
+ }
+ if flat[0].Text != "b" || flat[1].Text != "c" {
+ t.Errorf("unexpected order/content: %+v", flat)
+ }
+}
+
+func TestSortVersions(t *testing.T) {
+ versions := []versionChangelog{
+ {Version: "1.2.10"},
+ {Version: "1.2.9"},
+ {Version: "1.3.0"},
+ }
+ sortVersions(versions)
+ want := []string{"1.2.9", "1.2.10", "1.3.0"}
+ for i, w := range want {
+ if versions[i].Version != w {
+ t.Errorf("index %d: got %s, want %s", i, versions[i].Version, w)
+ }
+ }
+}
cmd/gitfed-ctl/exec.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "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 — same pattern as
+// cmd/gitfed-install/exec.go.
+type logLineMsg struct {
+ stream string
+ line string
+}
+
+// stepResultMsg reports a step's subprocess finishing, successfully or not.
+type stepResultMsg struct {
+ stream string
+ err error
+}
+
+// listen drains the shared message channel one value at a time — see
+// cmd/gitfed-install/exec.go for the full rationale (same idiom, reused
+// verbatim: one channel, one listener loop, re-issued after every message).
+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.
+//
+// Also returns the same error it put in the stepResultMsg, so a multi-
+// command sequence can bail out after the first failure.
+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; bump the buffer 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 a step that needs a command's result but not a live log of it.
+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
+}
+
+// runToFile runs name(args...) and streams its raw stdout straight into w
+// — used for the one step whose output is binary (backup.go's `tar` pull),
+// where treating it as line-oriented text like runStreamed would corrupt
+// it. stderr is discarded rather than mixed into w, since w is expected to
+// be exactly the command's stdout bytes.
+func runToFile(ctx context.Context, w io.Writer, name string, args ...string) error {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Stdout = w
+ return cmd.Run()
+}
cmd/gitfed-ctl/health.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// checkKubectl reports whether kubectl can currently reach the cluster —
+// this is the exact failure observed live: a freshly root-owned
+// /etc/rancher/k3s/k3s.yaml with no ~/.kube/config for root to fall back
+// to, so every kubectl call prompts nothing and just fails.
+func checkKubectl(ctx context.Context) bool {
+ _, err := runQuiet(ctx, "", "kubectl", "get", "ns", "gitfed", "--no-headers")
+ return err == nil
+}
+
+// k3sYAMLPath is where a fresh k3s install always writes its kubeconfig,
+// root-owned — same path cmd/gitfed-install/actions.go's setupKubeconfig
+// reads from for the non-root case.
+const k3sYAMLPath = "/etc/rancher/k3s/k3s.yaml"
+
+func k3sYAMLExists() bool {
+ _, err := os.Stat(k3sYAMLPath)
+ return err == nil
+}
+
+// fixKubeconfig is the root-side equivalent of cmd/gitfed-install's
+// setupKubeconfig: gitfed-ctl already runs as root (see main.go), so there
+// is no sudo subshell to invoke — just copy the file directly into root's
+// own kube config location and point KUBECONFIG at it for the rest of this
+// process's lifetime.
+func fixKubeconfig() error {
+ data, err := os.ReadFile(k3sYAMLPath)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll("/root/.kube", 0700); err != nil {
+ return err
+ }
+ if err := os.WriteFile("/root/.kube/config", data, 0600); err != nil {
+ return err
+ }
+ os.Setenv("KUBECONFIG", "/root/.kube/config")
+ return nil
+}
+
+// podSummary is a one-line human-readable status of the gitfed pod for the
+// dashboard — not a full stuck-pod diagnosis (that's cmd/gitfed-install's
+// job during first install); here it's only "is it healthy right now".
+func podSummary(ctx context.Context) (summary string, healthy bool, err error) {
+ out, err := runQuiet(ctx, "", "kubectl", "-n", "gitfed", "get", "pods", "--no-headers")
+ if err != nil {
+ return "", false, err
+ }
+ for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 3 || !strings.HasPrefix(fields[0], "gitfed-") {
+ continue
+ }
+ status := fields[2]
+ ready, total, ok := readyFraction(fields[1])
+ healthy = status == "Running" && ok && ready == total && total > 0
+ return fmt.Sprintf("%s — %s (%s)", fields[0], status, fields[1]), healthy, nil
+ }
+ return "aucun pod trouvé dans le namespace gitfed", false, nil
+}
+
+func readyFraction(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
+}
cmd/gitfed-ctl/keys.go
@@ -0,0 +1,154 @@
+package main
+
+import tea "github.com/charmbracelet/bubbletea"
+
+func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "ctrl+c":
+ m.cancel()
+ m.quitting = true
+ return m, tea.Quit
+ }
+
+ switch m.screen {
+ case screenDashboard:
+ return m.handleDashboardKey(msg)
+ case screenUpdatesCheck:
+ return m.handleUpdatesCheckKey(msg)
+ case screenUpdatesAckBreaking:
+ return m.handleAckBreakingKey(msg)
+ case screenUpdatesConfirm:
+ return m.handleConfirmKey(msg)
+ case screenUpdatesRunning:
+ return m.handleRunningKey(msg)
+ case screenUpdatesSuccess:
+ return m.handleSuccessKey(msg)
+ }
+ return m, nil
+}
+
+func (m model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "q":
+ m.quitting = true
+ return m, tea.Quit
+ case "r":
+ if !m.healthChecking {
+ m.healthChecking = true
+ return m, tea.Batch(m.cmdCheckHealth(), listen(m.msgCh))
+ }
+ case "f":
+ if !m.kubectlOK && m.k3sYAMLPresent && !m.fixing {
+ m.fixing = true
+ m.fixErr = nil
+ return m, tea.Batch(m.cmdFixKubeconfig(), listen(m.msgCh))
+ }
+ case "enter":
+ m.screen = screenUpdatesCheck
+ m.checking = true
+ m.checked = false
+ m.checkErr = nil
+ return m, tea.Batch(m.cmdCheckUpdates(), listen(m.msgCh))
+ }
+ return m, nil
+}
+
+func (m model) handleUpdatesCheckKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "q", "esc":
+ m.screen = screenDashboard
+ return m, nil
+ case "r":
+ if m.checked && m.checkErr != nil {
+ m.checking = true
+ m.checked = false
+ return m, tea.Batch(m.cmdCheckUpdates(), listen(m.msgCh))
+ }
+ case "enter":
+ if m.checked && m.checkErr == nil && m.upToDate {
+ m.screen = screenDashboard
+ }
+ }
+ return m, nil
+}
+
+func (m model) handleAckBreakingKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "q", "esc":
+ m.screen = screenDashboard
+ return m, nil
+ case "up", "k":
+ if m.ackCursor > 0 {
+ m.ackCursor--
+ }
+ case "down", "j":
+ if m.ackCursor < len(m.ackChecklist)-1 {
+ m.ackCursor++
+ }
+ case " ", "x":
+ if m.ackCursor < len(m.ackChecklist) {
+ m.ackChecklist[m.ackCursor] = !m.ackChecklist[m.ackCursor]
+ }
+ case "enter":
+ if allAcked(m.ackChecklist) {
+ m.screen = screenUpdatesConfirm
+ }
+ }
+ return m, nil
+}
+
+func allAcked(checklist []bool) bool {
+ if len(checklist) == 0 {
+ return false
+ }
+ for _, ok := range checklist {
+ if !ok {
+ return false
+ }
+ }
+ return true
+}
+
+func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "q", "esc":
+ m.screen = screenDashboard
+ return m, nil
+ case " ", "b":
+ m.backupToggle = !m.backupToggle
+ case "enter":
+ m.screen = screenUpdatesRunning
+ m.backupStep = pipelineStepStatus{}
+ m.pullStep = pipelineStepStatus{}
+ m.buildStep = pipelineStepStatus{}
+ m.importStep = pipelineStepStatus{}
+ m.applyStep = pipelineStepStatus{}
+ m.rolloutStep = pipelineStepStatus{}
+ m.buildLines = nil
+ m.pipelineErr = nil
+ return m, tea.Batch(m.cmdRunPipeline(), listen(m.msgCh))
+ }
+ return m, nil
+}
+
+func (m model) handleRunningKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if msg.String() == "q" && m.pipelineErr != nil {
+ m.quitting = true
+ return m, tea.Quit
+ }
+ return m, nil
+}
+
+func (m model) handleSuccessKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "enter":
+ m.screen = screenDashboard
+ m.healthChecking = true
+ m.checked = false
+ return m, tea.Batch(m.cmdCheckHealth(), listen(m.msgCh))
+ case "q":
+ m.quitting = true
+ return m, tea.Quit
+ }
+ return m, nil
+}
cmd/gitfed-ctl/main.go
@@ -0,0 +1,71 @@
+// gitfed-ctl is an update utility for an already-running gitfed instance:
+// it checks for new released versions, makes any breaking change flagged in
+// CHANGELOG.md something you have to individually acknowledge before
+// continuing, optionally backs up the data volume, then rebuilds and
+// redeploys — all running locally on the VPS itself, so there's no SSH hop
+// back to the same machine and no repeated password prompt (the problem
+// with `deploy/update.sh current` this replaces for anyone who isn't
+// cutting their own releases; see FEDERATION.md).
+//
+// Deliberately a separate binary from the four shipped in the gitfed
+// container image (see deploy/docker/Dockerfile): it shells out to
+// docker/k3s/kubectl/git on the host, none of which exist in the minimal
+// runtime image. It also never touches gitfed's own internal packages —
+// no admin socket, no store — so it has the same "zero internal gitfed
+// dependency" profile as cmd/gitfed-renew-cert and installs the same way:
+//
+// GOPRIVATE=git.neuromancer.ovh/* go install git.neuromancer.ovh/bastien-mrq/gitfed/cmd/gitfed-ctl@latest
+// sudo gitfed-ctl
+//
+// Account and federation-trust management stay on the web UI (Admin →
+// Utilisateurs / Admin → Trust store) — this tool is purely about updates.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+ if os.Geteuid() != 0 {
+ fmt.Fprintln(os.Stderr, "gitfed-ctl: must be run as root (sudo gitfed-ctl) — it needs direct access to docker/k3s/kubectl")
+ os.Exit(1)
+ }
+
+ sourceDir, err := os.Getwd()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-ctl: %v\n", err)
+ os.Exit(1)
+ }
+ if err := validateSourceDir(sourceDir); err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-ctl: %v\n", err)
+ os.Exit(1)
+ }
+
+ m := initialModel(sourceDir)
+ p := tea.NewProgram(m)
+ finalModel, err := p.Run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-ctl: %v\n", err)
+ os.Exit(1)
+ }
+ if fm, ok := finalModel.(model); ok && fm.fatalErr != nil {
+ fmt.Fprintf(os.Stderr, "gitfed-ctl: %v\n", fm.fatalErr)
+ os.Exit(1)
+ }
+}
+
+// validateSourceDir fails fast with a clear message if the current
+// directory isn't a gitfed checkout, rather than letting the first git/file
+// operation deep in the update wizard produce a confusing error.
+func validateSourceDir(dir string) error {
+ if _, err := os.Stat(dir + "/VERSION"); err != nil {
+ return fmt.Errorf("no VERSION file in %s — run gitfed-ctl from inside a gitfed git checkout", dir)
+ }
+ if _, err := os.Stat(dir + "/.git"); err != nil {
+ return fmt.Errorf("%s isn't a git checkout — run gitfed-ctl from inside a gitfed git checkout", dir)
+ }
+ return nil
+}
cmd/gitfed-ctl/model.go
@@ -0,0 +1,316 @@
+package main
+
+import (
+ "context"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type screen int
+
+const (
+ screenDashboard screen = iota
+ screenUpdatesCheck
+ screenUpdatesAckBreaking
+ screenUpdatesConfirm
+ screenUpdatesRunning
+ screenUpdatesSuccess
+)
+
+// pipelineStepStatus tracks one named step of the update pipeline for the
+// running screen's live checklist.
+type pipelineStepStatus struct {
+ Label string
+ Done bool
+ Err error
+}
+
+type model struct {
+ screen screen
+ width, height int
+ quitting bool
+ fatalErr error
+
+ msgCh chan tea.Msg
+ ctx context.Context
+ cancel context.CancelFunc
+
+ sourceDir string
+
+ // dashboard / health
+ healthChecked bool
+ healthChecking bool
+ kubectlOK bool
+ k3sYAMLPresent bool
+ podLine string
+ podHealthy bool
+ podErr error
+ fixing bool
+ fixErr error
+
+ // updates: check
+ checking bool
+ checked bool
+ checkErr error
+ currentVersion string
+ targetVersion string
+ upToDate bool
+
+ // updates: changelog range + breaking-change acknowledgment
+ fetchingVersions bool
+ versionsErr error
+ versions []versionChangelog
+ breaking []bullet
+ ackChecklist []bool
+ ackCursor int
+
+ // updates: confirm
+ backupToggle bool
+
+ // updates: running
+ backupStep, pullStep, buildStep, importStep, applyStep, rolloutStep pipelineStepStatus
+ buildLines []string
+ resolvedVersion string
+ pipelineErr error
+
+ spin spinner.Model
+}
+
+func initialModel(sourceDir string) model {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ sp.Style = styleAccent
+
+ return model{
+ screen: screenDashboard,
+ msgCh: make(chan tea.Msg, 16),
+ ctx: ctx,
+ cancel: cancel,
+ sourceDir: sourceDir,
+ backupToggle: true,
+ spin: sp,
+ }
+}
+
+func (m model) Init() tea.Cmd {
+ return tea.Batch(listen(m.msgCh), m.spin.Tick, m.cmdCheckHealth())
+}
+
+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 healthCheckMsg:
+ m.healthChecked = true
+ m.healthChecking = false
+ m.kubectlOK = msg.kubectlOK
+ m.k3sYAMLPresent = msg.k3sYAMLPresent
+ m.podLine = msg.podLine
+ m.podHealthy = msg.podHealthy
+ m.podErr = msg.podErr
+ return m, listen(m.msgCh)
+
+ case fixKubeconfigDoneMsg:
+ m.fixing = false
+ m.fixErr = msg.err
+ if msg.err == nil {
+ return m, tea.Batch(m.cmdCheckHealth(), listen(m.msgCh))
+ }
+ return m, listen(m.msgCh)
+
+ case updateCheckMsg:
+ m.checking = false
+ m.checked = true
+ m.checkErr = msg.err
+ m.currentVersion = msg.current
+ m.targetVersion = msg.target
+ m.upToDate = msg.upToDate
+ if msg.err != nil || msg.upToDate {
+ return m, listen(m.msgCh)
+ }
+ m.fetchingVersions = true
+ return m, tea.Batch(m.cmdFetchVersionsRange(), listen(m.msgCh))
+
+ case versionsRangeMsg:
+ m.fetchingVersions = false
+ m.versionsErr = msg.err
+ m.versions = msg.versions
+ if msg.err != nil {
+ return m, listen(m.msgCh)
+ }
+ m.breaking = flattenBreaking(msg.versions)
+ if len(m.breaking) == 0 {
+ m.screen = screenUpdatesConfirm
+ return m, listen(m.msgCh)
+ }
+ m.ackChecklist = make([]bool, len(m.breaking))
+ m.ackCursor = 0
+ m.screen = screenUpdatesAckBreaking
+ return m, listen(m.msgCh)
+
+ case logLineMsg:
+ if msg.stream == "build" {
+ m.buildLines = append(m.buildLines, msg.line)
+ }
+ return m, listen(m.msgCh)
+
+ case versionResolvedMsg:
+ m.resolvedVersion = msg.version
+ return m, listen(m.msgCh)
+
+ case stepResultMsg:
+ m.handlePipelineStep(msg)
+ return m, listen(m.msgCh)
+
+ case fatalErrMsg:
+ m.fatalErr = msg.err
+ return m, listen(m.msgCh)
+ }
+ return m, nil
+}
+
+func (m *model) handlePipelineStep(msg stepResultMsg) {
+ step := func(s *pipelineStepStatus, label string) {
+ s.Label = label
+ s.Err = msg.err
+ s.Done = msg.err == nil
+ }
+ switch msg.stream {
+ case "backup":
+ step(&m.backupStep, "Sauvegarde")
+ case "pull":
+ step(&m.pullStep, "Récupération du code")
+ case "version":
+ // no dedicated checklist row — this just re-writes deployment.yaml
+ // locally between "pull" and "build"; an error here surfaces on
+ // the pull row since nothing else is showing yet.
+ if msg.err != nil {
+ m.pullStep.Err = msg.err
+ }
+ case "build":
+ step(&m.buildStep, "Build de l'image")
+ case "import":
+ step(&m.importStep, "Import dans containerd")
+ case "apply":
+ step(&m.applyStep, "Application du déploiement")
+ case "rollout":
+ step(&m.rolloutStep, "Déploiement du pod")
+ if msg.err == nil {
+ m.screen = screenUpdatesSuccess
+ }
+ }
+ if msg.err != nil {
+ m.pipelineErr = msg.err
+ }
+}
+
+// --- messages produced by background goroutines ---
+
+type healthCheckMsg struct {
+ kubectlOK bool
+ k3sYAMLPresent bool
+ podLine string
+ podHealthy bool
+ podErr error
+}
+
+type fixKubeconfigDoneMsg struct{ err error }
+
+type updateCheckMsg struct {
+ current, target string
+ upToDate bool
+ err error
+}
+
+type versionsRangeMsg struct {
+ versions []versionChangelog
+ err error
+}
+
+type fatalErrMsg struct{ err error }
+
+// --- commands: each launches a goroutine that eventually writes to m.msgCh ---
+
+func (m model) cmdCheckHealth() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ return func() tea.Msg {
+ go func() {
+ kubectlOK := checkKubectl(ctx)
+ k3sPresent := k3sYAMLExists()
+ var podLine string
+ var podHealthy bool
+ var podErr error
+ if kubectlOK {
+ podLine, podHealthy, podErr = podSummary(ctx)
+ }
+ ch <- healthCheckMsg{
+ kubectlOK: kubectlOK,
+ k3sYAMLPresent: k3sPresent,
+ podLine: podLine,
+ podHealthy: podHealthy,
+ podErr: podErr,
+ }
+ }()
+ return nil
+ }
+}
+
+func (m model) cmdFixKubeconfig() tea.Cmd {
+ ch := m.msgCh
+ return func() tea.Msg {
+ go func() { ch <- fixKubeconfigDoneMsg{err: fixKubeconfig()} }()
+ return nil
+ }
+}
+
+func (m model) cmdCheckUpdates() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ sourceDir := m.sourceDir
+ return func() tea.Msg {
+ go func() {
+ current, target, upToDate, err := checkForUpdates(ctx, sourceDir)
+ ch <- updateCheckMsg{current: current, target: target, upToDate: upToDate, err: err}
+ }()
+ return nil
+ }
+}
+
+func (m model) cmdFetchVersionsRange() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ sourceDir := m.sourceDir
+ current, target := m.currentVersion, m.targetVersion
+ return func() tea.Msg {
+ go func() {
+ versions, err := versionsInRange(ctx, sourceDir, current, target)
+ ch <- versionsRangeMsg{versions: versions, err: err}
+ }()
+ return nil
+ }
+}
+
+func (m model) cmdRunPipeline() tea.Cmd {
+ ch := m.msgCh
+ ctx := m.ctx
+ sourceDir := m.sourceDir
+ backup := m.backupToggle
+ return func() tea.Msg {
+ go runUpdatePipeline(ctx, ch, sourceDir, backup)
+ return nil
+ }
+}
cmd/gitfed-ctl/screens_dashboard.go
@@ -0,0 +1,52 @@
+package main
+
+import "strings"
+
+func (m model) viewDashboard() string {
+ var b strings.Builder
+
+ if !m.healthChecked {
+ b.WriteString(m.spin.View() + " " + styleBody.Render("Vérification de l'état…") + "\n")
+ } else {
+ kubectlGlyph := glyph(statusOK)
+ kubectlVal := "joignable"
+ if !m.kubectlOK {
+ kubectlGlyph = glyph(statusWarn)
+ kubectlVal = "injoignable"
+ }
+ b.WriteString(row(kubectlGlyph, "kubectl", kubectlVal) + "\n")
+
+ if !m.kubectlOK && m.k3sYAMLPresent {
+ b.WriteString("\n" + stylePanelDanger.Render(
+ styleWarn.Render("!")+" kubeconfig introuvable pour cet utilisateur\n"+
+ styleMuted.Render(k3sYAMLPath+" existe mais n'est pas repris dans ~/.kube/config.")+"\n"+
+ styleBody.Render("→ ")+styleAccent.Render("f")+styleBody.Render(" pour réparer automatiquement"),
+ ) + "\n")
+ } else if !m.kubectlOK {
+ b.WriteString("\n" + styleWarn.Render("!") + " " + styleMuted.Render("kubectl injoignable et "+k3sYAMLPath+" introuvable — vérifie que k3s tourne sur cette machine.") + "\n")
+ }
+
+ if m.fixing {
+ b.WriteString("\n" + m.spin.View() + " " + styleBody.Render("Réparation du kubeconfig…") + "\n")
+ } else if m.fixErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Échec de la réparation : "+m.fixErr.Error()) + "\n")
+ }
+
+ if m.kubectlOK {
+ podGlyph := glyph(statusOK)
+ if !m.podHealthy {
+ podGlyph = glyph(statusWarn)
+ }
+ if m.podErr != nil {
+ b.WriteString(row(glyph(statusWarn), "pod", "erreur : "+m.podErr.Error()) + "\n")
+ } else {
+ b.WriteString(row(podGlyph, "pod", m.podLine) + "\n")
+ }
+ }
+ }
+
+ b.WriteString("\n")
+ b.WriteString(stylePanel.Render(styleBold.Render("→ Vérifier les mises à jour") + " " + styleMuted.Render("(entrée)")))
+
+ return b.String()
+}
cmd/gitfed-ctl/screens_updates.go
@@ -0,0 +1,153 @@
+package main
+
+import "strings"
+
+func (m model) viewUpdatesCheck() string {
+ var b strings.Builder
+
+ if !m.checked {
+ b.WriteString(m.spin.View() + " " + styleBody.Render("Vérification des nouvelles versions…"))
+ return stylePanel.Render(b.String())
+ }
+
+ if m.checkErr != nil {
+ b.WriteString(styleDanger.Render("Erreur : " + m.checkErr.Error()))
+ return stylePanelDanger.Render(b.String())
+ }
+
+ b.WriteString(row("", "Version actuelle", m.currentVersion) + "\n")
+
+ if m.upToDate {
+ b.WriteString("\n" + styleOK.Render("✓ instance à jour"))
+ return stylePanel.Render(b.String())
+ }
+
+ b.WriteString(row(glyph(statusWarn), "Version disponible", m.targetVersion) + "\n")
+
+ if m.fetchingVersions {
+ b.WriteString("\n" + m.spin.View() + " " + styleBody.Render("Lecture du changelog…"))
+ } else if m.versionsErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Erreur en lisant le changelog : "+m.versionsErr.Error()))
+ }
+
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewAckBreaking() string {
+ var b strings.Builder
+ b.WriteString(styleBody.Render("Cette mise à jour contient des changements cassants — coche chacun pour continuer.") + "\n\n")
+
+ for i, bl := range m.breaking {
+ box := "[ ]"
+ if m.ackChecklist[i] {
+ box = styleOK.Render("[x]")
+ }
+ cursor := " "
+ if i == m.ackCursor {
+ cursor = styleAccent.Render("> ")
+ }
+ b.WriteString(cursor + box + " " + styleBody.Render(bl.Text) + "\n")
+ }
+
+ return stylePanelDanger.Render(b.String())
+}
+
+func (m model) viewConfirm() string {
+ var b strings.Builder
+ b.WriteString(row("", "Mise à jour", m.currentVersion+" → "+m.targetVersion) + "\n\n")
+
+ for _, v := range m.versions {
+ heading := styleBold.Render(v.Version)
+ if v.TagMissing {
+ heading += " " + styleMuted.Render("(tag introuvable)")
+ }
+ b.WriteString(heading + "\n")
+ for _, bl := range v.Bullets {
+ if bl.Breaking {
+ continue
+ }
+ b.WriteString(" · " + styleBody.Render(bl.Text) + "\n")
+ }
+ }
+
+ backupBox := "[ ]"
+ if m.backupToggle {
+ backupBox = styleOK.Render("[x]")
+ }
+ b.WriteString("\n" + backupBox + " " + styleBody.Render("Sauvegarder la base avant de déployer") + "\n")
+
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewRunning() string {
+ var b strings.Builder
+
+ type namedStep struct {
+ label string
+ s pipelineStepStatus
+ }
+ var steps []namedStep
+ if m.backupToggle {
+ steps = append(steps, namedStep{"Sauvegarde", m.backupStep})
+ }
+ steps = append(steps,
+ namedStep{"Récupération du code", m.pullStep},
+ namedStep{"Build de l'image", m.buildStep},
+ namedStep{"Import dans containerd", m.importStep},
+ namedStep{"Application du déploiement", m.applyStep},
+ namedStep{"Déploiement du pod", m.rolloutStep},
+ )
+
+ current := -1
+ if m.pipelineErr == nil {
+ for i, st := range steps {
+ if !st.s.Done && st.s.Err == nil {
+ current = i
+ break
+ }
+ }
+ }
+
+ for i, st := range steps {
+ switch {
+ case st.s.Err != nil:
+ b.WriteString(row(glyph(statusWarn), st.label, "") + "\n")
+ case st.s.Done:
+ b.WriteString(row(glyph(statusOK), st.label, "prêt") + "\n")
+ case i == current:
+ b.WriteString(row(m.spin.View(), st.label, "") + "\n")
+ default:
+ b.WriteString(row(glyph(statusMissing), st.label, "") + "\n")
+ }
+ }
+
+ if m.pipelineErr != nil {
+ b.WriteString("\n" + styleDanger.Render("Erreur : "+m.pipelineErr.Error()))
+ }
+
+ if len(m.buildLines) > 0 && !m.buildStep.Done && m.buildStep.Err == nil {
+ b.WriteString("\n\n" + styleLog.Render(tailLines(m.buildLines, 10)))
+ }
+
+ return stylePanel.Render(b.String())
+}
+
+func (m model) viewSuccess() string {
+ var b strings.Builder
+ version := m.resolvedVersion
+ if version == "" {
+ version = m.targetVersion
+ }
+ b.WriteString(styleOK.Render("✓ v" + version + " est en ligne"))
+ return stylePanel.Render(b.String())
+}
+
+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")
+}
cmd/gitfed-ctl/styles.go
@@ -0,0 +1,61 @@
+package main
+
+import "github.com/charmbracelet/lipgloss"
+
+// Palette mirrors gitfed-web's own CSS tokens (cmd/gitfed-web/render.go),
+// same as cmd/gitfed-install/styles.go — duplicated here rather than shared
+// since these are separate `package main` binaries.
+var (
+ colCanvas = lipgloss.Color("#0d0f13")
+ 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)
+
+ styleFooter = lipgloss.NewStyle().Foreground(colTextFaint)
+)
+
+type checkStatus int
+
+const (
+ statusOK checkStatus = iota
+ statusMissing
+ statusWarn
+)
+
+func glyph(status checkStatus) string {
+ switch status {
+ case statusOK:
+ return styleOK.Render("✓")
+ case statusWarn:
+ return styleWarn.Render("!")
+ default:
+ return styleMuted.Render("○")
+ }
+}
cmd/gitfed-ctl/update_pipeline.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "context"
+ "os"
+ "regexp"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+var imageLineRe = regexp.MustCompile(`image:\s*gitfed:\S+`)
+
+// bumpDeploymentImageTag rewrites every "image: gitfed:..." line in
+// deploy/k8s/deployment.yaml (both containers carry the same tag — see
+// deploy/k8s/deployment.yaml) to point at newVersion. Never committed —
+// matches deploy/update.sh's "current" mode, which leaves this as a
+// local-only edit too.
+func bumpDeploymentImageTag(sourceDir, newVersion string) error {
+ path := sourceDir + "/deploy/k8s/deployment.yaml"
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ updated := imageLineRe.ReplaceAllString(string(data), "image: gitfed:"+newVersion)
+ return os.WriteFile(path, []byte(updated), 0644)
+}
+
+func shellQuote(s string) string { return "'" + s + "'" }
+
+// versionResolvedMsg reports the version actually deployed, read from
+// VERSION right after the pull — this is what the running/success screens
+// display, since the target shown at the "check" screen was read from the
+// remote and the pull is the point it becomes locally real.
+type versionResolvedMsg struct{ version string }
+
+// runUpdatePipeline is the local (no ssh/rsync) re-implementation of
+// deploy/update.sh's shared build/deploy block, run directly on the VPS
+// gitfed-ctl already lives on. Each step reports exactly one
+// stepResultMsg tagged with its own stream name, and the sequence bails
+// out after the first failure — same "don't plough on into commands that
+// can only fail more confusingly" rule as cmd/gitfed-install/actions.go.
+func runUpdatePipeline(ctx context.Context, ch chan<- tea.Msg, sourceDir string, backupFirst bool) {
+ if backupFirst {
+ _, err := runBackupNow(ctx, sourceDir)
+ ch <- stepResultMsg{stream: "backup", err: err}
+ if err != nil {
+ return
+ }
+ }
+
+ if err := runStreamed(ctx, ch, "pull", sourceDir, "git", "pull", "--ff-only"); err != nil {
+ return
+ }
+
+ versionBytes, err := os.ReadFile(sourceDir + "/VERSION")
+ if err != nil {
+ ch <- stepResultMsg{stream: "version", err: err}
+ return
+ }
+ newVersion := strings.TrimSpace(string(versionBytes))
+ ch <- versionResolvedMsg{version: newVersion}
+
+ if err := bumpDeploymentImageTag(sourceDir, newVersion); err != nil {
+ ch <- stepResultMsg{stream: "version", err: err}
+ return
+ }
+ ch <- stepResultMsg{stream: "version"}
+
+ if err := runStreamed(ctx, ch, "build", sourceDir, "docker", "build",
+ "-f", "deploy/docker/Dockerfile", "-t", "gitfed:"+newVersion, "-t", "gitfed:latest", "."); err != nil {
+ return
+ }
+
+ if err := runStreamed(ctx, ch, "import", "", "sh", "-c",
+ "docker save "+shellQuote("gitfed:"+newVersion)+" | k3s ctr images import -"); err != nil {
+ return
+ }
+
+ if err := runStreamed(ctx, ch, "apply", "", "kubectl", "-n", "gitfed", "apply",
+ "-f", sourceDir+"/deploy/k8s/deployment.yaml"); err != nil {
+ return
+ }
+
+ runStreamed(ctx, ch, "rollout", "", "kubectl", "-n", "gitfed", "rollout", "status",
+ "deployment/gitfed", "--timeout=90s")
+}
cmd/gitfed-ctl/update_pipeline_test.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestBumpDeploymentImageTag(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "deploy", "k8s"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ original := `apiVersion: apps/v1
+kind: Deployment
+spec:
+ template:
+ spec:
+ containers:
+ - name: server
+ image: gitfed:1.2.7
+ - name: web
+ image: gitfed:1.2.7
+`
+ path := filepath.Join(dir, "deploy", "k8s", "deployment.yaml")
+ if err := os.WriteFile(path, []byte(original), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := bumpDeploymentImageTag(dir, "1.2.10"); err != nil {
+ t.Fatalf("bumpDeploymentImageTag: %v", err)
+ }
+
+ updated, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(updated)
+ if strings.Count(got, "image: gitfed:1.2.10") != 2 {
+ t.Errorf("expected both image lines rewritten to 1.2.10, got:\n%s", got)
+ }
+ if strings.Contains(got, "1.2.7") {
+ t.Errorf("expected no trace of the old tag left, got:\n%s", got)
+ }
+ // non-image lines must be untouched
+ if !strings.Contains(got, "name: server") || !strings.Contains(got, "name: web") {
+ t.Errorf("unrelated lines were modified:\n%s", got)
+ }
+}
+
+func TestShellQuote(t *testing.T) {
+ if got := shellQuote("gitfed:1.2.10"); got != "'gitfed:1.2.10'" {
+ t.Errorf("shellQuote(%q) = %q", "gitfed:1.2.10", got)
+ }
+}
cmd/gitfed-ctl/view.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m model) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ body := m.renderScreen()
+ header := styleTitle.Render("gitfed-ctl") + " " + styleTag.Render(screenLabel(m.screen))
+ footer := styleFooter.Render(m.footerHint())
+
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "", footer)
+}
+
+func (m model) renderScreen() string {
+ switch m.screen {
+ case screenDashboard:
+ return m.viewDashboard()
+ case screenUpdatesCheck:
+ return m.viewUpdatesCheck()
+ case screenUpdatesAckBreaking:
+ return m.viewAckBreaking()
+ case screenUpdatesConfirm:
+ return m.viewConfirm()
+ case screenUpdatesRunning:
+ return m.viewRunning()
+ case screenUpdatesSuccess:
+ return m.viewSuccess()
+ }
+ return ""
+}
+
+func screenLabel(s screen) string {
+ names := map[screen]string{
+ screenDashboard: "accueil",
+ screenUpdatesCheck: "mises à jour · vérification",
+ screenUpdatesAckBreaking: "mises à jour · changements cassants",
+ screenUpdatesConfirm: "mises à jour · confirmation",
+ screenUpdatesRunning: "mises à jour · en cours",
+ screenUpdatesSuccess: "mises à jour · terminé",
+ }
+ return names[s]
+}
+
+func (m model) footerHint() string {
+ switch m.screen {
+ case screenDashboard:
+ hint := "entrée : vérifier les mises à jour · r : rafraîchir · q : quitter"
+ if !m.kubectlOK && m.k3sYAMLPresent {
+ hint = "f : réparer kubectl · " + hint
+ }
+ return hint
+ case screenUpdatesCheck:
+ if m.checked && m.checkErr != nil {
+ return "r : réessayer · esc : retour"
+ }
+ if m.checked && m.upToDate {
+ return "entrée / esc : retour à l'accueil"
+ }
+ return "esc : retour"
+ case screenUpdatesAckBreaking:
+ if allAcked(m.ackChecklist) {
+ return "↑↓ naviguer · espace : cocher · entrée : continuer · esc : annuler"
+ }
+ return "↑↓ naviguer · espace : cocher chaque changement cassant pour continuer · esc : annuler"
+ case screenUpdatesConfirm:
+ return "espace : bascule sauvegarde · entrée : déployer · esc : annuler"
+ case screenUpdatesRunning:
+ if m.pipelineErr != nil {
+ return "q : quitter"
+ }
+ return "ctrl+c : annuler"
+ case screenUpdatesSuccess:
+ return "entrée : retour à l'accueil · q : quitter"
+ }
+ 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
@@ -3,10 +3,11 @@
# bootstrap the first account (username/password/admin flag) directly
# against the live admin socket — see deploy/k8s/README.md.
#
-# 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.
+# gitfed-renew-cert, gitfed-install and gitfed-ctl are NOT included — all
+# three are host-side tools that shell out to docker/k3s/kubectl on the
+# machine they manage (gitfed-install before the pod even exists, gitfed-ctl
+# to rebuild and redeploy it), none of which exist in this minimal runtime
+# image, so they could never run inside the container they help set up.
FROM golang:1.25-bookworm AS builder
WORKDIR /src