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>
This commit is contained in:
2026-02-25 20:06:20 +01:00
parent d3b53d9877
commit 0a5840a255
15 changed files with 992 additions and 1 deletions
@@ -0,0 +1,57 @@
package integrations
import (
"log"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// IntegrationKey builds the settings key: "provider:target".
func IntegrationKey(provider, target string) string {
return provider + ":" + target
}
// ParseIntegrationKey splits "provider:target" into its components.
func ParseIntegrationKey(key string) (provider, target string, ok bool) {
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
// ApplyContext holds all context needed by an integration handler.
type ApplyContext struct {
ProviderName string // e.g., "onlyoffice"
TargetName string // e.g., "filebrowser"
Domain string // e.g., "demo-felhom.eu"
ProviderEnv map[string]string // decrypted app.yaml env of provider
ProviderMeta *stacks.Metadata
StacksDir string
Logger *log.Logger
RestartStack func(name string) error // restart via stacks.Manager
}
// Handler defines the interface for a concrete integration handler.
// Each provider:target pair has one Handler implementation.
type Handler interface {
// Apply enables the integration (writes configs, runs commands, restarts containers).
Apply(ac *ApplyContext) error
// Revoke disables the integration (removes configs, runs commands, restarts containers).
Revoke(ac *ApplyContext) error
}
// StatusInfo is returned by the API for UI display.
type StatusInfo struct {
Key string `json:"key"` // "onlyoffice:filebrowser"
Provider string `json:"provider"`
Target string `json:"target"`
Label string `json:"label"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Status string `json:"status"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
LastError string `json:"last_error,omitempty"`
TargetDeployed bool `json:"target_deployed"`
TargetRunning bool `json:"target_running"`
}
@@ -0,0 +1,148 @@
package integrations
import (
"context"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// OnStackStop is called when a stack is stopped.
// Revokes active integrations where this stack is provider or target.
// Keeps enabled=true so OnStackStart can re-apply them later.
func (m *Manager) OnStackStop(_ context.Context, stackName string) {
m.mu.Lock()
defer m.mu.Unlock()
all := m.sett.GetIntegrationsForProvider(stackName)
targetAll := m.sett.GetIntegrationsForTarget(stackName)
for k, v := range targetAll {
all[k] = v
}
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 revoke: %v", key, err)
continue
}
if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke on stop failed for %s: %v", key, err)
}
if provider == stackName {
state.Status = "provider_stopped"
} else {
state.Status = "target_unavailable"
}
_ = m.sett.SetIntegrationState(key, state)
m.logger.Printf("[INFO] Integration %s suspended (stack %s stopped)", key, stackName)
}
}
// OnStackStart is called when a stack starts.
// Re-applies integrations that were previously enabled but are not currently active.
func (m *Manager) OnStackStart(_ context.Context, stackName string) {
m.mu.Lock()
defer m.mu.Unlock()
all := m.sett.GetIntegrationsForProvider(stackName)
targetAll := m.sett.GetIntegrationsForTarget(stackName)
for k, v := range targetAll {
all[k] = v
}
for key, state := range all {
if !state.Enabled || state.Status == "active" {
continue
}
provider, target, ok := ParseIntegrationKey(key)
if !ok {
continue
}
// Both provider and target must be running to re-apply
provStack, pOk := m.stacks.GetStack(provider)
if !pOk || !provStack.Deployed || provStack.State != stacks.StateRunning {
continue
}
if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target)
if !tOk || !tgtStack.Deployed || tgtStack.State != stacks.StateRunning {
continue
}
}
handler, hOk := m.handlers[key]
if !hOk {
continue
}
ac, err := m.buildApplyContext(provider, target)
if err != nil {
m.logger.Printf("[WARN] Cannot re-apply integration %s on start: %v", key, err)
continue
}
if err := handler.Apply(ac); err != nil {
m.logger.Printf("[WARN] Integration re-apply on start 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 re-activated (stack %s started)", key, stackName)
}
}
// OnStackRemove is called when a stack is removed.
// Permanently revokes and deletes integration state.
func (m *Manager) OnStackRemove(_ context.Context, stackName string) {
m.mu.Lock()
defer m.mu.Unlock()
all := m.sett.GetIntegrationsForProvider(stackName)
targetAll := m.sett.GetIntegrationsForTarget(stackName)
for k, v := range targetAll {
all[k] = v
}
for key, state := range all {
provider, target, ok := ParseIntegrationKey(key)
if !ok {
continue
}
if state.Enabled {
handler, hOk := m.handlers[key]
if hOk {
ac, _ := m.buildApplyContext(provider, target)
if ac != nil {
_ = handler.Revoke(ac)
}
}
}
_ = m.sett.RemoveIntegrationState(key)
m.logger.Printf("[INFO] Integration %s removed (stack %s removed)", key, stackName)
}
}
+198
View File
@@ -0,0 +1,198 @@
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
}
@@ -0,0 +1,113 @@
package integrations
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// OnlyOfficeFileBrowserHandler enables/disables OnlyOffice document editing in FileBrowser Quantum.
type OnlyOfficeFileBrowserHandler struct{}
func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error {
jwtSecret := ac.ProviderEnv["JWT_SECRET"]
if jwtSecret == "" {
return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva — telepítsd újra az alkalmazást")
}
subdomain := ac.ProviderEnv["SUBDOMAIN"]
if subdomain == "" && ac.ProviderMeta != nil {
subdomain = ac.ProviderMeta.Subdomain
}
if subdomain == "" {
return fmt.Errorf("OnlyOffice aldomain nem ismert")
}
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml")
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("FileBrowser config olvasási hiba: %w", err)
}
// Remove any existing integrations section, then append the new one
configStr := removeIntegrationsSection(string(configData))
officeURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
internalURL := "http://onlyoffice:80"
integrationsBlock := fmt.Sprintf("integrations:\n office:\n url: %q\n internalUrl: %q\n secret: %q\n viewOnly: false\n",
officeURL, internalURL, jwtSecret)
configStr = strings.TrimRight(configStr, "\n") + "\n" + integrationsBlock
// Atomic write
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(configStr), 0644); err != nil {
return fmt.Errorf("config írási hiba: %w", err)
}
if err := os.Rename(tmpPath, configPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("config átnevezési hiba: %w", err)
}
ac.Logger.Printf("[INFO] FileBrowser config updated with OnlyOffice integration")
return ac.RestartStack("filebrowser")
}
func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error {
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml")
configData, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("config olvasási hiba: %w", err)
}
cleaned := removeIntegrationsSection(string(configData))
if cleaned == string(configData) {
return nil // no integrations section, nothing to remove
}
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(cleaned), 0644); err != nil {
return fmt.Errorf("config írási hiba: %w", err)
}
if err := os.Rename(tmpPath, configPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("config átnevezési hiba: %w", err)
}
ac.Logger.Printf("[INFO] FileBrowser config cleaned — OnlyOffice integration removed")
return ac.RestartStack("filebrowser")
}
// removeIntegrationsSection strips the integrations: YAML block from a config string.
// It removes from the line starting with "integrations:" to the next unindented key or EOF.
func removeIntegrationsSection(config string) string {
lines := strings.Split(config, "\n")
var result []string
inBlock := false
for _, line := range lines {
if strings.HasPrefix(line, "integrations:") {
inBlock = true
continue
}
if inBlock {
trimmed := strings.TrimRight(line, " \t\r")
if trimmed == "" {
continue // skip blank lines within block
}
if line[0] != ' ' && line[0] != '\t' {
// New top-level key — end of integrations block
inBlock = false
result = append(result, line)
}
// else: still inside indented block, skip
continue
}
result = append(result, line)
}
return strings.Join(result, "\n")
}
@@ -0,0 +1,93 @@
package integrations
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
// OnlyOfficeNextcloudHandler enables/disables OnlyOffice document editing in Nextcloud via occ.
type OnlyOfficeNextcloudHandler struct{}
func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error {
jwtSecret := ac.ProviderEnv["JWT_SECRET"]
if jwtSecret == "" {
return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva")
}
subdomain := ac.ProviderEnv["SUBDOMAIN"]
if subdomain == "" && ac.ProviderMeta != nil {
subdomain = ac.ProviderMeta.Subdomain
}
if subdomain == "" {
return fmt.Errorf("OnlyOffice aldomain nem ismert")
}
publicURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
internalURL := "http://onlyoffice:80"
// Install and configure OnlyOffice app in Nextcloud
commands := []struct {
args []string
tolerate string // substring in output to tolerate as success
}{
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"},
tolerate: "already installed",
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + internalURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret},
},
}
for _, cmd := range commands {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
c := exec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...)
out, err := c.CombinedOutput()
cancel()
if err != nil {
if cmd.tolerate != "" && strings.Contains(string(out), cmd.tolerate) {
ac.Logger.Printf("[DEBUG] Nextcloud occ: tolerated — %s", strings.TrimSpace(string(out)))
continue
}
return fmt.Errorf("occ parancs sikertelen (%s): %v (kimenet: %s)", cmd.args[len(cmd.args)-1], err, strings.TrimSpace(string(out)))
}
ac.Logger.Printf("[DEBUG] Nextcloud occ %s: ok", strings.Join(cmd.args[7:], " "))
}
ac.Logger.Printf("[INFO] OnlyOffice integration applied to Nextcloud")
return nil
}
func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice")
out, err := cmd.CombinedOutput()
if err != nil {
outStr := string(out)
// Tolerate container not running or app not enabled
if strings.Contains(err.Error(), "No such container") ||
strings.Contains(outStr, "not enabled") ||
strings.Contains(outStr, "not installed") {
ac.Logger.Printf("[DEBUG] Nextcloud occ app:disable skipped — %s", strings.TrimSpace(outStr))
return nil
}
return fmt.Errorf("occ app:disable sikertelen: %v (kimenet: %s)", err, strings.TrimSpace(outStr))
}
ac.Logger.Printf("[INFO] OnlyOffice integration revoked from Nextcloud")
return nil
}