Files
deploy-felhom-compose/controller/internal/integrations/manager.go
T
admin 65c0da4a2b Fix FileBrowser integration config lost after SyncFileBrowserMounts
SyncFileBrowserMounts regenerates config.yaml from scratch, overwriting
any integration config. The old approach used an async OnStackStart hook
after container restart, which failed due to timing issues (stack state
not yet refreshed).

New approach: ReapplyConfigForTarget() writes integration config
synchronously after config generation but before container restart,
with a no-op RestartStack since the caller handles restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:54:03 +01:00

246 lines
7.2 KiB
Go

package integrations
import (
"context"
"fmt"
"log"
"path/filepath"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// StackProvider abstracts stacks.Manager to break circular imports.
type StackProvider interface {
GetStack(name string) (*stacks.Stack, bool)
GetStacks() []stacks.Stack
RestartStack(name string) error
}
// Manager coordinates integration apply/revoke and lifecycle hooks.
type Manager struct {
sett *settings.Settings
stacks StackProvider
domain string
stacksDir string
encKey []byte
logger *log.Logger
handlers map[string]Handler // key: "provider:target" -> Handler
mu sync.Mutex // serialize apply/revoke operations
}
// NewManager creates an integration manager and registers built-in handlers.
func NewManager(sett *settings.Settings, sp StackProvider, domain, stacksDir string, encKey []byte, logger *log.Logger) *Manager {
m := &Manager{
sett: sett,
stacks: sp,
domain: domain,
stacksDir: stacksDir,
encKey: encKey,
logger: logger,
handlers: make(map[string]Handler),
}
// Register built-in handlers
m.RegisterHandler("onlyoffice:filebrowser", &OnlyOfficeFileBrowserHandler{})
m.RegisterHandler("onlyoffice:nextcloud", &OnlyOfficeNextcloudHandler{})
return m
}
// RegisterHandler registers a handler for a provider:target integration key.
func (m *Manager) RegisterHandler(key string, h Handler) {
m.handlers[key] = h
}
// Toggle enables or disables an integration.
func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bool) (settings.IntegrationState, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := IntegrationKey(provider, target)
handler, ok := m.handlers[key]
if !ok {
return settings.IntegrationState{}, fmt.Errorf("nincs kezelő a(z) %s integrációhoz", key)
}
ac, err := m.buildApplyContext(provider, target)
if err != nil {
return settings.IntegrationState{}, err
}
var state settings.IntegrationState
if enable {
// Validate: provider must be deployed and running
provStack, pOk := m.stacks.GetStack(provider)
if !pOk || !provStack.Deployed {
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider)
}
if provStack.State != stacks.StateRunning {
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nem fut", provider)
}
// Validate: target must be deployed and running (filebrowser is infra, always present)
if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target)
if !tOk || !tgtStack.Deployed {
return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target)
}
if tgtStack.State != stacks.StateRunning {
return state, fmt.Errorf("a célalkalmazás (%s) nem fut", target)
}
}
if err := handler.Apply(ac); err != nil {
state.Enabled = true
state.Status = "error"
state.LastError = err.Error()
state.EnabledAt = time.Now().UTC().Format(time.RFC3339)
_ = m.sett.SetIntegrationState(key, state)
return state, fmt.Errorf("integráció alkalmazása sikertelen: %w", err)
}
state.Enabled = true
state.Status = "active"
state.EnabledAt = time.Now().UTC().Format(time.RFC3339)
m.logger.Printf("[INFO] Integration %s enabled", key)
} else {
if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke failed for %s: %v", key, err)
state.LastError = err.Error()
}
state.Enabled = false
state.Status = "disabled"
m.logger.Printf("[INFO] Integration %s disabled", key)
}
if err := m.sett.SetIntegrationState(key, state); err != nil {
return state, fmt.Errorf("integráció állapotának mentése sikertelen: %w", err)
}
return state, nil
}
// ListForProvider returns StatusInfo for all integrations defined in a provider's metadata.
func (m *Manager) ListForProvider(providerSlug string) []StatusInfo {
provStack, ok := m.stacks.GetStack(providerSlug)
if !ok {
return nil
}
var result []StatusInfo
for _, idef := range provStack.Meta.Integrations {
key := IntegrationKey(providerSlug, idef.Target)
state, _ := m.sett.GetIntegrationState(key)
targetDeployed := false
targetRunning := false
if tgt, tOk := m.stacks.GetStack(idef.Target); tOk {
// FileBrowser is infrastructure — if its stack exists, it's "deployed"
if idef.Target == "filebrowser" {
targetDeployed = true
} else {
targetDeployed = tgt.Deployed
}
targetRunning = tgt.State == stacks.StateRunning
}
result = append(result, StatusInfo{
Key: key,
Provider: providerSlug,
Target: idef.Target,
Label: idef.Label,
Description: idef.Description,
Enabled: state.Enabled,
Status: state.Status,
LastError: state.LastError,
TargetDeployed: targetDeployed,
TargetRunning: targetRunning,
})
}
return result
}
// buildApplyContext gathers all data needed by a handler.
func (m *Manager) buildApplyContext(provider, target string) (*ApplyContext, error) {
provStack, ok := m.stacks.GetStack(provider)
if !ok {
return nil, fmt.Errorf("szolgáltató stack %q nem található", provider)
}
// Load decrypted env from provider's app.yaml
provEnv := m.loadDecryptedEnv(provStack)
provMeta := provStack.Meta
return &ApplyContext{
ProviderName: provider,
TargetName: target,
Domain: m.domain,
ProviderEnv: provEnv,
ProviderMeta: &provMeta,
StacksDir: m.stacksDir,
Logger: m.logger,
RestartStack: m.stacks.RestartStack,
}, nil
}
// ReapplyConfigForTarget applies all active integrations targeting the given stack,
// writing config changes only (no container restart). Use this when the caller
// handles container restart separately (e.g. SyncFileBrowserMounts).
func (m *Manager) ReapplyConfigForTarget(targetName string) {
m.mu.Lock()
defer m.mu.Unlock()
all := m.sett.GetIntegrationsForTarget(targetName)
for key, state := range all {
if !state.Enabled || state.Status == "disabled" {
continue
}
provider, target, ok := ParseIntegrationKey(key)
if !ok {
continue
}
handler, hOk := m.handlers[key]
if !hOk {
continue
}
ac, err := m.buildApplyContext(provider, target)
if err != nil {
m.logger.Printf("[WARN] Cannot build context for integration %s reapply: %v", key, err)
continue
}
// Override RestartStack to no-op — caller handles restart
ac.RestartStack = func(string) error { return nil }
if err := handler.Apply(ac); err != nil {
m.logger.Printf("[WARN] Integration config reapply failed for %s: %v", key, err)
state.Status = "error"
state.LastError = err.Error()
_ = m.sett.SetIntegrationState(key, state)
continue
}
state.Status = "active"
state.LastError = ""
_ = m.sett.SetIntegrationState(key, state)
m.logger.Printf("[INFO] Integration %s config reapplied for %s", key, targetName)
}
}
// loadDecryptedEnv reads app.yaml for a stack and decrypts sensitive values.
func (m *Manager) loadDecryptedEnv(s *stacks.Stack) map[string]string {
stackDir := filepath.Dir(s.ComposePath)
cfg := stacks.LoadAppConfig(stackDir)
if cfg == nil {
return nil
}
if m.encKey != nil {
cfg.Env = crypto.DecryptMap(m.encKey, cfg.Env)
}
return cfg.Env
}