implemented git sync for app templates

This commit is contained in:
2026-02-14 13:40:00 +01:00
parent 44a7d0de2c
commit ee8650a41c
6 changed files with 499 additions and 13 deletions
+7 -1
View File
@@ -14,6 +14,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
@@ -55,8 +56,13 @@ func main() {
logger.Printf("[WARN] Initial stack scan failed: %v", err)
}
// --- Initialize catalog syncer ---
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks)
syncer.Start()
defer syncer.Stop()
// --- Initialize API router ---
apiRouter := api.NewRouter(cfg, stackMgr, logger)
apiRouter := api.NewRouter(cfg, stackMgr, syncer, logger)
// --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, logger, Version)
+25 -3
View File
@@ -10,6 +10,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -17,11 +18,12 @@ import (
type Router struct {
cfg *config.Config
stackMgr *stacks.Manager
syncer *catalogsync.Syncer
logger *log.Logger
}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, logger: logger}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, syncer: syncer, logger: logger}
}
type apiResponse struct {
@@ -77,6 +79,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
r.getStackLogs(w, req, extractName(path, "/logs"))
// POST /api/sync — trigger immediate catalog sync
case path == "/sync" && req.Method == http.MethodPost:
r.triggerSync(w, req)
// GET /api/system/info
case path == "/system/info" && req.Method == http.MethodGet:
r.systemInfo(w, req)
@@ -220,9 +226,25 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
r.logger.Println("[API] Manual catalog sync requested")
result := r.syncer.TriggerSync()
if !result.OK {
writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
return
}
r.logger.Printf("[API] Catalog sync completed: %s", result.Message)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
info := system.GetInfo(r.cfg.Paths.HDDPath)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
syncStatus := r.syncer.Status()
data := map[string]interface{}{
"system": info,
"sync_status": syncStatus,
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// --- Helpers ---
+355
View File
@@ -0,0 +1,355 @@
package sync
import (
"bytes"
"crypto/sha256"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// Syncer handles periodic git sync of the app catalog to the local stacks directory.
type Syncer struct {
cfg *config.Config
logger *log.Logger
cacheDir string // local git clone
rescanFn func() error
mu sync.Mutex
lastSync time.Time
lastErr error
syncing bool
stopCh chan struct{}
}
// SyncStatus holds information about the last sync operation.
type SyncStatus struct {
LastSync time.Time `json:"last_sync"`
LastStatus string `json:"last_status"` // "ok", "error", "disabled", "never"
LastError string `json:"last_error,omitempty"`
Syncing bool `json:"syncing"`
}
// SyncResult holds the result of a single sync operation.
type SyncResult struct {
OK bool `json:"ok"`
NewApps []string `json:"new_apps,omitempty"`
Updated []string `json:"updated,omitempty"`
Message string `json:"message"`
}
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
func New(cfg *config.Config, logger *log.Logger, rescanFn func() error) *Syncer {
cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache")
return &Syncer{
cfg: cfg,
logger: logger,
cacheDir: cacheDir,
rescanFn: rescanFn,
stopCh: make(chan struct{}),
}
}
// Start begins the periodic sync loop. Call Stop() to terminate.
func (s *Syncer) Start() {
if s.cfg.Git.RepoURL == "" {
s.logger.Println("[SYNC] Git repo URL is empty — sync disabled (manual mode)")
return
}
interval, err := time.ParseDuration(s.cfg.Git.SyncInterval)
if err != nil {
s.logger.Printf("[SYNC] Invalid sync_interval %q, defaulting to 15m", s.cfg.Git.SyncInterval)
interval = 15 * time.Minute
}
s.logger.Printf("[SYNC] Starting catalog sync (repo: %s, interval: %s)", s.cfg.Git.RepoURL, interval)
// Initial sync on startup
go func() {
result := s.doSync()
s.logger.Printf("[SYNC] Initial sync: %s", result.Message)
}()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
s.logger.Println("[SYNC] Sync loop stopped")
return
case <-ticker.C:
result := s.doSync()
s.logger.Printf("[SYNC] Periodic sync: %s", result.Message)
}
}
}()
}
// Stop terminates the periodic sync loop.
func (s *Syncer) Stop() {
close(s.stopCh)
}
// TriggerSync performs an immediate sync. Returns the result.
// Debounce: refuses if last sync was less than 30 seconds ago.
func (s *Syncer) TriggerSync() SyncResult {
if s.cfg.Git.RepoURL == "" {
return SyncResult{OK: false, Message: "Git sync is disabled (no repo_url configured)"}
}
s.mu.Lock()
if s.syncing {
s.mu.Unlock()
return SyncResult{OK: false, Message: "Szinkronizálás már folyamatban"}
}
if time.Since(s.lastSync) < 30*time.Second {
s.mu.Unlock()
return SyncResult{OK: false, Message: "Túl gyakori szinkronizálás — várj 30 másodpercet"}
}
s.mu.Unlock()
return s.doSync()
}
// Status returns the current sync status.
func (s *Syncer) Status() SyncStatus {
s.mu.Lock()
defer s.mu.Unlock()
status := SyncStatus{
LastSync: s.lastSync,
Syncing: s.syncing,
}
if s.cfg.Git.RepoURL == "" {
status.LastStatus = "disabled"
} else if s.lastSync.IsZero() {
status.LastStatus = "never"
} else if s.lastErr != nil {
status.LastStatus = "error"
status.LastError = s.lastErr.Error()
} else {
status.LastStatus = "ok"
}
return status
}
// doSync performs the actual git clone/pull + file copy.
func (s *Syncer) doSync() SyncResult {
s.mu.Lock()
s.syncing = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.syncing = false
s.mu.Unlock()
}()
result := SyncResult{OK: true}
// Step 1: Clone or pull
if err := s.gitCloneOrPull(); err != nil {
s.mu.Lock()
s.lastErr = err
s.lastSync = time.Now()
s.mu.Unlock()
return SyncResult{OK: false, Message: fmt.Sprintf("Git hiba: %v", err)}
}
// Step 2: Copy templates to stacks dir
newApps, updated, err := s.copyTemplates()
if err != nil {
s.mu.Lock()
s.lastErr = err
s.lastSync = time.Now()
s.mu.Unlock()
return SyncResult{OK: false, Message: fmt.Sprintf("Másolási hiba: %v", err)}
}
result.NewApps = newApps
result.Updated = updated
// Step 3: Trigger rescan if anything changed
if len(newApps) > 0 || len(updated) > 0 {
if err := s.rescanFn(); err != nil {
s.logger.Printf("[SYNC] Rescan after sync failed: %v", err)
}
}
// Build message
parts := []string{}
if len(newApps) > 0 {
parts = append(parts, fmt.Sprintf("új: %s", strings.Join(newApps, ", ")))
}
if len(updated) > 0 {
parts = append(parts, fmt.Sprintf("frissítve: %s", strings.Join(updated, ", ")))
}
if len(parts) == 0 {
result.Message = "Sablonok naprakészek — nincs változás"
} else {
result.Message = "Sablonok frissítve — " + strings.Join(parts, "; ")
}
s.mu.Lock()
s.lastErr = nil
s.lastSync = time.Now()
s.mu.Unlock()
return result
}
// gitCloneOrPull clones the repo if not yet cloned, or pulls latest changes.
func (s *Syncer) gitCloneOrPull() error {
if err := os.MkdirAll(filepath.Dir(s.cacheDir), 0755); err != nil {
return fmt.Errorf("creating cache parent dir: %w", err)
}
gitDir := filepath.Join(s.cacheDir, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Clone
s.logger.Printf("[SYNC] Cloning %s → %s", s.cfg.Git.RepoURL, s.cacheDir)
args := []string{"clone", "--depth", "1", "--branch", s.cfg.Git.Branch}
repoURL := s.buildRepoURL()
args = append(args, repoURL, s.cacheDir)
return s.runGit(args...)
}
// Pull
s.logger.Printf("[SYNC] Pulling latest from %s (branch: %s)", s.cfg.Git.RepoURL, s.cfg.Git.Branch)
if err := s.runGitInDir(s.cacheDir, "fetch", "--depth", "1", "origin", s.cfg.Git.Branch); err != nil {
return fmt.Errorf("git fetch: %w", err)
}
if err := s.runGitInDir(s.cacheDir, "reset", "--hard", "origin/"+s.cfg.Git.Branch); err != nil {
return fmt.Errorf("git reset: %w", err)
}
return nil
}
// buildRepoURL constructs the repo URL with optional auth credentials.
func (s *Syncer) buildRepoURL() string {
url := s.cfg.Git.RepoURL
if s.cfg.Git.Username != "" && s.cfg.Git.Token != "" {
// Inject credentials into HTTPS URL: https://user:token@host/path
url = strings.Replace(url, "https://", fmt.Sprintf("https://%s:%s@", s.cfg.Git.Username, s.cfg.Git.Token), 1)
}
return url
}
// copyTemplates copies docker-compose.yml and .felhom.yml from the catalog cache
// to the stacks directory. Never overwrites app.yaml or .env files.
func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error) {
templatesDir := filepath.Join(s.cacheDir, "templates")
entries, err := os.ReadDir(templatesDir)
if err != nil {
return nil, nil, fmt.Errorf("reading templates dir: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
appName := entry.Name()
srcDir := filepath.Join(templatesDir, appName)
dstDir := filepath.Join(s.cfg.Paths.StacksDir, appName)
isNew := false
if _, err := os.Stat(dstDir); os.IsNotExist(err) {
isNew = true
if err := os.MkdirAll(dstDir, 0755); err != nil {
s.logger.Printf("[SYNC] Failed to create stack dir %s: %v", dstDir, err)
continue
}
}
// Files to sync (only template files, never app.yaml or .env)
syncFiles := []string{"docker-compose.yml", ".felhom.yml"}
anyChanged := false
for _, filename := range syncFiles {
src := filepath.Join(srcDir, filename)
dst := filepath.Join(dstDir, filename)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue
}
changed, err := copyIfChanged(src, dst)
if err != nil {
s.logger.Printf("[SYNC] Failed to copy %s/%s: %v", appName, filename, err)
continue
}
if changed {
anyChanged = true
s.logger.Printf("[SYNC] Updated %s/%s", appName, filename)
}
}
if isNew {
newApps = append(newApps, appName)
} else if anyChanged {
updated = append(updated, appName)
}
}
return newApps, updated, nil
}
// copyIfChanged copies src to dst only if the content differs.
// Returns true if the file was actually written.
func copyIfChanged(src, dst string) (bool, error) {
srcData, err := os.ReadFile(src)
if err != nil {
return false, fmt.Errorf("reading %s: %w", src, err)
}
// Check if dst exists and has same content
dstData, err := os.ReadFile(dst)
if err == nil {
srcHash := sha256.Sum256(srcData)
dstHash := sha256.Sum256(dstData)
if srcHash == dstHash {
return false, nil // No change
}
}
if err := os.WriteFile(dst, srcData, 0644); err != nil {
return false, fmt.Errorf("writing %s: %w", dst, err)
}
return true, nil
}
// runGit executes a git command with the given args.
func (s *Syncer) runGit(args ...string) error {
return s.runGitInDir("", args...)
}
// runGitInDir executes a git command in the specified directory.
func (s *Syncer) runGitInDir(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
s.logger.Printf("[SYNC] Running: git %s", strings.Join(args, " "))
if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s: %w\nstderr: %s", strings.Join(args, " "), err, stderr.String())
}
return nil
}
+57
View File
@@ -37,6 +37,41 @@ const layoutTmpl = `
{{define "layout_end"}}
</main>
<script>
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
@@ -179,7 +214,9 @@ const stacksTmpl = `
<div class="page-header">
<h2>Alkalmazások</h2>
<span class="domain-badge">{{.Domain}}</span>
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
</div>
<div id="sync-toast" class="sync-toast" style="display:none"></div>
<div class="stack-grid">
{{range .Stacks}}
@@ -1160,6 +1197,26 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
}
.logs-actions { display: flex; gap: .5rem; }
/* Sync toast */
.sync-toast {
padding: .6rem 1rem;
border-radius: 8px;
font-size: .85rem;
margin-bottom: 1rem;
border: 1px solid;
transition: opacity 0.3s ease;
}
.sync-toast-ok {
background: var(--green-bg);
color: var(--green);
border-color: rgba(35, 134, 54, 0.3);
}
.sync-toast-err {
background: var(--red-bg);
color: var(--red);
border-color: rgba(218, 54, 51, 0.3);
}
.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); }
/* Login page */