Gitfed
bastien-mrq/gitfed/ Commits/ deeebe2

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.

bastien-mrq 2026-07-29 14:52 commit deeebe21250215a02fdda329edf806420edc4c3c parent 2babb65c4d9be54b0e071377e0487ea2d72504be
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
diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8353f..1d485aa 100644 --- a/CHANGELOG.md +++ b/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
diff --git a/INSTALL.md b/INSTALL.md index 787bbe5..00520aa 100644 --- a/INSTALL.md +++ b/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
diff --git a/cmd/gitfed-ctl/backup.go b/cmd/gitfed-ctl/backup.go new file mode 100644 index 0000000..ffe8e87 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/backup_test.go b/cmd/gitfed-ctl/backup_test.go new file mode 100644 index 0000000..f0aa659 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/changelog.go b/cmd/gitfed-ctl/changelog.go new file mode 100644 index 0000000..7e87310 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/changelog_test.go b/cmd/gitfed-ctl/changelog_test.go new file mode 100644 index 0000000..7c1b418 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/exec.go b/cmd/gitfed-ctl/exec.go new file mode 100644 index 0000000..81f8eec --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/health.go b/cmd/gitfed-ctl/health.go new file mode 100644 index 0000000..6b8d30f --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/keys.go b/cmd/gitfed-ctl/keys.go new file mode 100644 index 0000000..b16b650 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/main.go b/cmd/gitfed-ctl/main.go new file mode 100644 index 0000000..8d49892 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/model.go b/cmd/gitfed-ctl/model.go new file mode 100644 index 0000000..82384c8 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/screens_dashboard.go b/cmd/gitfed-ctl/screens_dashboard.go new file mode 100644 index 0000000..6a1d4e9 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/screens_updates.go b/cmd/gitfed-ctl/screens_updates.go new file mode 100644 index 0000000..ee154ff --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/styles.go b/cmd/gitfed-ctl/styles.go new file mode 100644 index 0000000..005bb56 --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/update_pipeline.go b/cmd/gitfed-ctl/update_pipeline.go new file mode 100644 index 0000000..4318a3a --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/update_pipeline_test.go b/cmd/gitfed-ctl/update_pipeline_test.go new file mode 100644 index 0000000..8211c4a --- /dev/null +++ b/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
diff --git a/cmd/gitfed-ctl/view.go b/cmd/gitfed-ctl/view.go new file mode 100644 index 0000000..9c2f3a4 --- /dev/null +++ b/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
diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 0e6ac7f..4ad397b 100644 --- a/deploy/docker/Dockerfile +++ b/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