Files
Archivum/backend/internal/graph/gitsync.go
Bjorn Blomberg 19cf285d80 feat(gitsync): auto-bootstrap tom wiki från remote live-gren
Ett repo utan commits adopterar origin/<live_branch> automatiskt vid
första bakgrundssynken — färsk instans behöver ingen manuell reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:19:48 +02:00

619 lines
16 KiB
Go

package graph
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"github.com/brasse-b/archivum/internal/auth"
"github.com/brasse-b/archivum/internal/config"
"github.com/brasse-b/archivum/internal/git"
)
// The identity used for commits that the sync engine itself creates
// (merge commits, pre-reset snapshots).
const (
syncAuthor = "Archivum Sync"
syncEmail = "sync@archivum.local"
)
// ── SSH key management ────────────────────────────────────────────────────────
// sshDir returns the directory holding the sync keypair and known_hosts,
// next to config.json so it lives on the persistent /config volume.
func (s *Server) sshDir() string {
return filepath.Join(filepath.Dir(s.configPath), "ssh")
}
// ensureSSHKey generates an ed25519 keypair on first use and returns
// (keyPath, knownHostsPath, publicKey). The private key never leaves
// the server; the public key is what gets registered as a deploy key.
func (s *Server) ensureSSHKey() (string, string, string, error) {
dir := s.sshDir()
keyPath := filepath.Join(dir, "id_ed25519")
pubPath := keyPath + ".pub"
khPath := filepath.Join(dir, "known_hosts")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", "", err
}
if f, err := os.OpenFile(khPath, os.O_CREATE, 0644); err == nil {
f.Close()
}
if pub, err := os.ReadFile(pubPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
return keyPath, khPath, strings.TrimSpace(string(pub)), nil
}
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", "", err
}
block, err := ssh.MarshalPrivateKey(priv, "archivum-git-sync")
if err != nil {
return "", "", "", err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil {
return "", "", "", err
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
return "", "", "", err
}
pubLine := bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub))
pubLine = append(pubLine, []byte(" archivum-git-sync\n")...)
if err := os.WriteFile(pubPath, pubLine, 0644); err != nil {
return "", "", "", err
}
log.Printf("[gitsync] generated new ssh keypair at %s", keyPath)
return keyPath, khPath, strings.TrimSpace(string(pubLine)), nil
}
// ── Engine wiring ─────────────────────────────────────────────────────────────
// configureGitSync (re)applies git-sync settings: key, remote and the
// background ticker. Called from initRuntime and after updateGitSync.
func (s *Server) configureGitSync(cfg *config.Config) {
s.syncMu.Lock()
if s.syncStop != nil {
close(s.syncStop)
s.syncStop = nil
}
s.syncMu.Unlock()
if cfg == nil {
return
}
gs := cfg.GitSync
gs.Normalize()
s.mu.RLock()
repo := s.gitRepo
s.mu.RUnlock()
if repo == nil || !gs.Enabled || gs.RemoteURL == "" {
return
}
keyPath, khPath, _, err := s.ensureSSHKey()
if err != nil {
log.Printf("[gitsync] ssh key setup failed: %v", err)
return
}
repo.SetSSHKey(keyPath, khPath)
if err := repo.SetRemote(gs.RemoteURL); err != nil {
log.Printf("[gitsync] setting remote failed: %v", err)
return
}
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
stop := make(chan struct{})
s.syncMu.Lock()
s.syncStop = stop
s.syncMu.Unlock()
go func() {
// One eager sync shortly after (re)configuration, then on the
// configured interval. Manual-only mode still gets the eager
// run so status is populated after a restart.
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case <-stop:
return
case <-timer.C:
s.backgroundSync()
}
if gs.AutoSyncMinutes <= 0 {
return
}
ticker := time.NewTicker(time.Duration(gs.AutoSyncMinutes) * time.Minute)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
s.backgroundSync()
}
}
}()
}
// backgroundSync fetches and, when the live branch is checked out,
// fast-forwards it and pushes local commits. It never merges: anything
// that needs a real merge raises the attention flag for an admin.
func (s *Server) backgroundSync() {
s.mu.RLock()
repo := s.gitRepo
cfg := s.cfg
s.mu.RUnlock()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
return
}
gs := cfg.GitSync
record := func(err error, attention bool) {
s.syncMu.Lock()
s.lastSyncAt = time.Now().Format(time.RFC3339)
if err != nil {
s.lastSyncErr = err.Error()
} else {
s.lastSyncErr = ""
}
s.syncAttention = attention
s.syncMu.Unlock()
}
if repo.MergeInProgress() {
record(errors.New("merge väntar på att lösas i admin-panelen"), true)
return
}
if err := repo.Fetch(); err != nil {
record(fmt.Errorf("fetch: %w", err), false)
return
}
// Bootstrap: an empty wiki (no commits yet) adopts the remote live
// branch automatically, so a fresh instance needs no manual reset.
if !repo.HasCommits() {
if repo.RemoteBranchExists(gs.LiveBranch) {
_, err := repo.ResetToRemote(gs.LiveBranch, syncAuthor, syncEmail)
if err != nil {
record(fmt.Errorf("bootstrap från remote: %w", err), true)
} else {
log.Printf("[gitsync] empty wiki bootstrapped from origin/%s", gs.LiveBranch)
record(nil, false)
}
return
}
}
current, err := repo.CurrentBranch()
if err != nil || current != gs.LiveBranch {
// Someone is working on another branch; leave it alone.
record(nil, false)
return
}
ahead, behind, hasUpstream := repo.AheadBehind(gs.LiveBranch)
if !hasUpstream {
if gs.ReadOnly {
record(nil, false)
return
}
record(repo.Push(gs.LiveBranch), false)
return
}
if behind > 0 {
if err := repo.Pull(gs.LiveBranch, syncAuthor, syncEmail, true); err != nil {
// Not fast-forwardable (or unrelated histories): an admin
// must pull/merge or reset from the UI.
record(fmt.Errorf("kan inte snabbspola — manuell merge eller reset krävs (%w)", err), true)
return
}
}
if ahead > 0 && !gs.ReadOnly {
if err := repo.Push(gs.LiveBranch); err != nil {
record(fmt.Errorf("push: %w", err), false)
return
}
}
record(nil, false)
}
// gitSyncState returns the shared handles used by every handler below.
func (s *Server) gitSyncState() (*git.Repo, *config.Config) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.gitRepo, s.cfg
}
// ── GraphQL handlers (admin only) ─────────────────────────────────────────────
func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
_, cfg := s.gitSyncState()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
gs := cfg.GitSync
gs.Normalize()
publicKey := ""
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
publicKey = strings.TrimSpace(string(pub))
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitSyncSettings": map[string]interface{}{
"enabled": gs.Enabled,
"remoteUrl": gs.RemoteURL,
"liveBranch": gs.LiveBranch,
"readOnly": gs.ReadOnly,
"autoSyncMinutes": gs.AutoSyncMinutes,
"publicKey": publicKey,
"keySet": publicKey != "",
},
},
})
}
func (s *Server) handleUpdateGitSync(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
input, ok := req.Variables["input"].(map[string]interface{})
if !ok {
input, _ = req.Variables["i"].(map[string]interface{})
}
if input == nil {
writeGQLError(w, "missing input")
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg == nil {
writeGQLError(w, "server not initialised")
return
}
newCfg := *cfg
gs := config.GitSyncConfig{
Enabled: boolVal(input, "enabled"),
RemoteURL: strings.TrimSpace(strVal(input, "remoteUrl")),
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
ReadOnly: boolVal(input, "readOnly"),
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
}
gs.Normalize()
newCfg.GitSync = gs
if err := config.Save(s.configPath, &newCfg); err != nil {
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
return
}
s.mu.Lock()
s.cfg = &newCfg
s.mu.Unlock()
s.configureGitSync(&newCfg)
log.Printf("[admin] git sync updated by %s (enabled=%v remote=%s)",
sess.Username, gs.Enabled, gs.RemoteURL)
writeJSON(w, `{"data":{"updateGitSync":true}}`)
}
func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
dir := s.sshDir()
os.Remove(filepath.Join(dir, "id_ed25519"))
os.Remove(filepath.Join(dir, "id_ed25519.pub"))
_, _, publicKey, err := s.ensureSSHKey()
if err != nil {
writeGQLError(w, fmt.Sprintf("key generation failed: %v", err))
return
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
if cfg != nil {
s.configureGitSync(cfg)
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitRegenerateKey": map[string]interface{}{"publicKey": publicKey},
},
})
}
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil {
writeGQLError(w, "server not initialised")
return
}
gs := cfg.GitSync
gs.Normalize()
current, _ := repo.CurrentBranch()
ahead, behind, hasUpstream := 0, 0, false
if gs.Enabled {
ahead, behind, hasUpstream = repo.AheadBehind(current)
}
conflicts := repo.ConflictFiles()
s.syncMu.Lock()
lastAt, lastErr, attention := s.lastSyncAt, s.lastSyncErr, s.syncAttention
s.syncMu.Unlock()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitSyncStatus": map[string]interface{}{
"enabled": gs.Enabled,
"currentBranch": current,
"liveBranch": gs.LiveBranch,
"readOnly": gs.ReadOnly,
"ahead": ahead,
"behind": behind,
"hasUpstream": hasUpstream,
"dirty": repo.HasUncommitted(),
"mergeInProgress": repo.MergeInProgress(),
"conflicts": conflicts,
"lastSyncAt": lastAt,
"lastSyncError": lastErr,
"attention": attention || repo.MergeInProgress(),
},
},
})
}
func (s *Server) handleGitBranches(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
local, remote, err := repo.Branches()
if err != nil {
writeGQLError(w, err.Error())
return
}
current, _ := repo.CurrentBranch()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitBranches": map[string]interface{}{
"current": current,
"local": local,
"remote": remote,
},
},
})
}
func (s *Server) handleGitCreateBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
name := strings.TrimSpace(strVal(req.Variables, "name"))
if repo == nil || name == "" {
writeGQLError(w, "missing branch name")
return
}
if err := repo.CreateBranch(name); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s created branch %s", sess.Username, name)
writeJSON(w, `{"data":{"gitCreateBranch":true}}`)
}
func (s *Server) handleGitSwitchBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
name := strings.TrimSpace(strVal(req.Variables, "name"))
if repo == nil || name == "" {
writeGQLError(w, "missing branch name")
return
}
if repo.MergeInProgress() {
writeGQLError(w, "merge in progress — resolve or abort it first")
return
}
if err := repo.SwitchBranch(name); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s switched to branch %s", sess.Username, name)
writeJSON(w, `{"data":{"gitSwitchBranch":true}}`)
}
func (s *Server) handleGitPull(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if err := repo.Fetch(); err != nil {
writeGQLError(w, err.Error())
return
}
current, err := repo.CurrentBranch()
if err != nil {
writeGQLError(w, err.Error())
return
}
result := "OK"
err = repo.Pull(current, sess.Username, sess.Username+"@archivum.local", false)
switch {
case errors.Is(err, git.ErrMergeConflict):
result = "MERGE_CONFLICT"
case errors.Is(err, git.ErrUnrelatedHistories):
result = "UNRELATED_HISTORIES"
case err != nil:
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.lastSyncAt = time.Now().Format(time.RFC3339)
s.lastSyncErr = ""
s.syncAttention = result != "OK"
s.syncMu.Unlock()
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitPull": map[string]interface{}{
"result": result,
"conflicts": repo.ConflictFiles(),
},
},
})
}
func (s *Server) handleGitPush(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if cfg.GitSync.ReadOnly {
writeGQLError(w, "repository is configured read-only — pushing is disabled")
return
}
current, err := repo.CurrentBranch()
if err != nil {
writeGQLError(w, err.Error())
return
}
if err := repo.Push(current); err != nil {
writeGQLError(w, err.Error())
return
}
log.Printf("[gitsync] %s pushed %s", sess.Username, current)
writeJSON(w, `{"data":{"gitPush":true}}`)
}
func (s *Server) handleGitResolveMerge(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
strategy := strVal(req.Variables, "strategy")
paths := strSlice(req.Variables, "paths")
if err := repo.ResolveConflicts(strategy, paths, sess.Username, sess.Username+"@archivum.local"); err != nil {
writeGQLError(w, err.Error())
return
}
remaining := repo.ConflictFiles()
if len(remaining) == 0 {
s.syncMu.Lock()
s.syncAttention = false
s.syncMu.Unlock()
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitResolveMerge": map[string]interface{}{
"resolved": len(remaining) == 0,
"remaining": remaining,
},
},
})
}
func (s *Server) handleGitAbortMerge(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, _ := s.gitSyncState()
if repo == nil {
writeGQLError(w, "server not initialised")
return
}
if err := repo.AbortMerge(); err != nil {
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.syncAttention = false
s.syncMu.Unlock()
writeJSON(w, `{"data":{"gitAbortMerge":true}}`)
}
func (s *Server) handleGitResetFromRemote(w http.ResponseWriter, sess *auth.Session) {
if !s.requireAdmin(w, sess) {
return
}
repo, cfg := s.gitSyncState()
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
writeGQLError(w, "git sync is not enabled")
return
}
if err := repo.Fetch(); err != nil {
writeGQLError(w, err.Error())
return
}
// Reset the current branch when it exists on the remote; otherwise
// adopt the live branch (bootstrap case: local repo predates the
// remote and is on an unrelated branch like master).
current, err := repo.CurrentBranch()
if err != nil || current == "" {
current = cfg.GitSync.LiveBranch
} else if _, _, hasUpstream := repo.AheadBehind(current); !hasUpstream {
current = cfg.GitSync.LiveBranch
}
backup, err := repo.ResetToRemote(current, syncAuthor, syncEmail)
if err != nil {
writeGQLError(w, err.Error())
return
}
s.syncMu.Lock()
s.syncAttention = false
s.lastSyncErr = ""
s.syncMu.Unlock()
log.Printf("[gitsync] %s reset %s from remote (backup: %s)", sess.Username, current, backup)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"gitResetFromRemote": map[string]interface{}{"backupBranch": backup},
},
})
}
func intVal(m map[string]interface{}, key string) int {
if f, ok := m[key].(float64); ok {
return int(f)
}
return 0
}