feat(gitsync): synka wiki-repot mot remote över SSH
- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600), GIT_SSH_COMMAND med egen known_hosts (accept-new) - inställningar: remote-URL, live-gren, read-only, auto-synk-intervall - bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov - pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt), abort, reset från remote med automatisk backup-gren - headless-konfig via config.json eller updateGitSync-mutationen - openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
604
backend/internal/graph/gitsync.go
Normal file
604
backend/internal/graph/gitsync.go
Normal file
@@ -0,0 +1,604 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -68,6 +68,15 @@ type Query {
|
||||
# Effective permissions a subject has on each path (admin only, for the
|
||||
# access-control tree). For a user this includes their group memberships.
|
||||
subjectAccess(subjectType: String!, subjectId: Int!, paths: [String!]!): [PathAccess!]!
|
||||
|
||||
# Git sync settings incl. the public deploy key (admin only).
|
||||
gitSyncSettings: GitSyncSettings!
|
||||
|
||||
# Live git sync state: branch, ahead/behind, conflicts (admin only).
|
||||
gitSyncStatus: GitSyncStatus!
|
||||
|
||||
# Local and remote branches (admin only).
|
||||
gitBranches: GitBranches!
|
||||
}
|
||||
|
||||
type PathAccess {
|
||||
@@ -135,6 +144,38 @@ type Mutation {
|
||||
# Commit all uncommitted files in the storage path.
|
||||
initCommit(message: String!): Boolean!
|
||||
|
||||
# ── Git sync (admin only) ────────────────────────────────────────────────────
|
||||
# Update sync settings; the server (re)connects the remote and ticker.
|
||||
updateGitSync(input: GitSyncInput!): Boolean!
|
||||
|
||||
# Merge origin/<current> into the current branch. result is OK,
|
||||
# MERGE_CONFLICT (merge left open for gitResolveMerge/gitAbortMerge)
|
||||
# or UNRELATED_HISTORIES (use gitResetFromRemote).
|
||||
gitPull: GitPullResult!
|
||||
|
||||
# Push the current branch to origin. Fails when read-only.
|
||||
gitPush: Boolean!
|
||||
|
||||
# Create a branch at HEAD and switch to it.
|
||||
gitCreateBranch(name: String!): Boolean!
|
||||
|
||||
# Switch to a local branch, or track a remote-only one.
|
||||
gitSwitchBranch(name: String!): Boolean!
|
||||
|
||||
# Settle conflicted paths with "ours" or "theirs" (empty paths = all),
|
||||
# committing the merge when nothing remains.
|
||||
gitResolveMerge(strategy: String!, paths: [String!]): GitResolveResult!
|
||||
|
||||
# Throw away an in-progress merge.
|
||||
gitAbortMerge: Boolean!
|
||||
|
||||
# Force the current branch to match origin. Local-only state is kept
|
||||
# in the returned backup branch.
|
||||
gitResetFromRemote: GitResetResult!
|
||||
|
||||
# Replace the sync keypair (register the new public key as deploy key).
|
||||
gitRegenerateKey: GitKeyResult!
|
||||
|
||||
# Delete an image file.
|
||||
deleteImage(slug: String!, filename: String!): Boolean!
|
||||
|
||||
@@ -250,6 +291,64 @@ type RepoStatus {
|
||||
hasUncommitted: Boolean!
|
||||
}
|
||||
|
||||
type GitSyncSettings {
|
||||
enabled: Boolean!
|
||||
remoteUrl: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
autoSyncMinutes: Int!
|
||||
publicKey: String!
|
||||
keySet: Boolean!
|
||||
}
|
||||
|
||||
type GitSyncStatus {
|
||||
enabled: Boolean!
|
||||
currentBranch: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
ahead: Int!
|
||||
behind: Int!
|
||||
hasUpstream: Boolean!
|
||||
dirty: Boolean!
|
||||
mergeInProgress: Boolean!
|
||||
conflicts: [String!]!
|
||||
lastSyncAt: String!
|
||||
lastSyncError: String!
|
||||
attention: Boolean!
|
||||
}
|
||||
|
||||
type GitBranches {
|
||||
current: String!
|
||||
local: [String!]!
|
||||
remote: [String!]!
|
||||
}
|
||||
|
||||
type GitPullResult {
|
||||
result: String!
|
||||
conflicts: [String!]!
|
||||
}
|
||||
|
||||
type GitResolveResult {
|
||||
resolved: Boolean!
|
||||
remaining: [String!]!
|
||||
}
|
||||
|
||||
type GitResetResult {
|
||||
backupBranch: String!
|
||||
}
|
||||
|
||||
type GitKeyResult {
|
||||
publicKey: String!
|
||||
}
|
||||
|
||||
input GitSyncInput {
|
||||
enabled: Boolean!
|
||||
remoteUrl: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
autoSyncMinutes: Int!
|
||||
}
|
||||
|
||||
type ImageFile {
|
||||
name: String!
|
||||
url: String!
|
||||
|
||||
@@ -33,6 +33,13 @@ type Server struct {
|
||||
authMgr *auth.Manager
|
||||
gitRepo *git.Repo
|
||||
oidc *auth.OIDCProvider
|
||||
|
||||
// Git-sync engine state (see gitsync.go).
|
||||
syncMu sync.Mutex
|
||||
syncStop chan struct{}
|
||||
lastSyncAt string
|
||||
lastSyncErr string
|
||||
syncAttention bool
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
||||
@@ -112,6 +119,7 @@ func (s *Server) initRuntime(cfg *config.Config) {
|
||||
s.mu.Unlock()
|
||||
|
||||
s.configureOIDC(cfg)
|
||||
s.configureGitSync(cfg)
|
||||
}
|
||||
|
||||
// configureOIDC (re)initialises the OIDC provider from config. Discovery
|
||||
@@ -225,6 +233,44 @@ func (s *Server) dispatchAuthenticated(
|
||||
q := req.Query
|
||||
|
||||
switch {
|
||||
// ── Git sync (matched first; none of these names collide with the
|
||||
// generic "config"/"document"/"history" substrings further down) ────────
|
||||
case strings.Contains(q, "updateGitSync"):
|
||||
s.handleUpdateGitSync(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitSyncSettings"):
|
||||
s.handleGitSyncSettings(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitSyncStatus"):
|
||||
s.handleGitSyncStatus(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitBranches"):
|
||||
s.handleGitBranches(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitCreateBranch"):
|
||||
s.handleGitCreateBranch(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitSwitchBranch"):
|
||||
s.handleGitSwitchBranch(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitPull"):
|
||||
s.handleGitPull(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitPush"):
|
||||
s.handleGitPush(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitResolveMerge"):
|
||||
s.handleGitResolveMerge(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitAbortMerge"):
|
||||
s.handleGitAbortMerge(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitResetFromRemote"):
|
||||
s.handleGitResetFromRemote(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitRegenerateKey"):
|
||||
s.handleGitRegenerateKey(w, sess)
|
||||
|
||||
// ── Effective-access queries (matched before generic acl/subject cases) ────
|
||||
case strings.Contains(q, "myAccess"):
|
||||
s.handleMyAccess(w, req, sess)
|
||||
|
||||
Reference in New Issue
Block a user