Files
deploy-felhom-compose/controller/internal/integrations/manager.go
T
admin 0a5840a255 feat: app-to-app integration framework + OnlyOffice handlers
Generic integration system for connecting deployed apps via toggle UI.
First handlers: OnlyOffice→FileBrowser (config.yaml patch) and
OnlyOffice→Nextcloud (occ CLI). Lifecycle hooks auto-suspend on
stop and re-apply on start.

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

199 lines
5.9 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
}
// 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
}