321 lines
11 KiB
Go
321 lines
11 KiB
Go
package workflowsync
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/github"
|
|
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
|
)
|
|
|
|
// Applier applies parsed workflow definition files to a workspace and
|
|
// releases synced workflows back to manual ownership when syncing stops.
|
|
// Satisfied by the workflow service.
|
|
type Applier interface {
|
|
ApplySyncedWorkflows(ctx context.Context, workspaceID string, files []workflowservice.SyncFileExport) (*workflowservice.SyncApplyResult, error)
|
|
ReleaseSyncedWorkflows(ctx context.Context, workspaceID string) ([]string, error)
|
|
}
|
|
|
|
// ClientProvider exposes workspace-routed GitHub repository reads.
|
|
type ClientProvider interface {
|
|
ListRepoDirectoryForWorkspace(
|
|
ctx context.Context, workspaceID, owner, repo, path, ref string,
|
|
) ([]github.RepoContentEntry, error)
|
|
GetRepoFileContentForWorkspace(
|
|
ctx context.Context, workspaceID, owner, repo, path, ref string,
|
|
) ([]byte, error)
|
|
}
|
|
|
|
// Service owns workflow sync configuration and sync execution.
|
|
type Service struct {
|
|
store *Store
|
|
clients ClientProvider
|
|
applier Applier
|
|
logger *logger.Logger
|
|
// locks serializes syncs and config mutations per workspace so a force
|
|
// sync cannot interleave with a config delete/replace and apply stale
|
|
// definitions (or re-stamp workflows that were just released). The lock
|
|
// is deliberately held across the GitHub fetch too: a config change or
|
|
// delete for that workspace waits (bounded by the HTTP client timeout)
|
|
// rather than racing an in-flight apply.
|
|
locks sync.Map // workspaceID → *sync.Mutex
|
|
|
|
// workspaceAuthorizer enforces per-user workspace scoping. Nil (unit
|
|
// tests, or a caller with no identity in context — internal callers like
|
|
// the periodic poller) means unscoped, matching every other integration
|
|
// service's default before auth is wired up.
|
|
workspaceAuthorizer func(context.Context, string) error
|
|
}
|
|
|
|
// SetWorkspaceAuthorizer installs the per-user workspace access boundary
|
|
// applied before every user-facing config read/write and force sync.
|
|
func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) {
|
|
if s != nil {
|
|
s.workspaceAuthorizer = authorizer
|
|
}
|
|
}
|
|
|
|
func (s *Service) authorizeWorkspaceAccess(ctx context.Context, workspaceID string) error {
|
|
if s == nil || s.workspaceAuthorizer == nil {
|
|
return nil
|
|
}
|
|
return s.workspaceAuthorizer(ctx, workspaceID)
|
|
}
|
|
|
|
func (s *Service) workspaceLock(workspaceID string) *sync.Mutex {
|
|
lock, _ := s.locks.LoadOrStore(workspaceID, &sync.Mutex{})
|
|
return lock.(*sync.Mutex)
|
|
}
|
|
|
|
// NewService creates a workflow sync service.
|
|
func NewService(store *Store, clients ClientProvider, applier Applier, log *logger.Logger) *Service {
|
|
return &Service{
|
|
store: store,
|
|
clients: clients,
|
|
applier: applier,
|
|
logger: log.WithFields(zap.String("component", "workflowsync-service")),
|
|
}
|
|
}
|
|
|
|
// Store exposes the config store (e2e reset cascade).
|
|
func (s *Service) Store() *Store {
|
|
return s.store
|
|
}
|
|
|
|
// GetConfigForWorkspace returns the workspace's config, or nil when unset.
|
|
func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*Config, error) {
|
|
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.store.GetConfigForWorkspace(ctx, workspaceID)
|
|
}
|
|
|
|
// SetConfigForWorkspace validates and stores the workspace's config.
|
|
func (s *Service) SetConfigForWorkspace(ctx context.Context, workspaceID string, req *SetConfigRequest) (*Config, error) {
|
|
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := req.Normalize(); err != nil {
|
|
return nil, err
|
|
}
|
|
lock := s.workspaceLock(workspaceID)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
return s.store.UpsertConfigForWorkspace(ctx, workspaceID, req)
|
|
}
|
|
|
|
// DeleteConfigForWorkspace removes the workspace's config. Previously-synced
|
|
// workflows are released back to manual ownership first so they become
|
|
// editable again — a failed release keeps the config so the user can retry.
|
|
func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error {
|
|
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
|
return err
|
|
}
|
|
lock := s.workspaceLock(workspaceID)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
released, err := s.applier.ReleaseSyncedWorkflows(ctx, workspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to release synced workflows: %w", err)
|
|
}
|
|
if len(released) > 0 {
|
|
s.logger.Info("released synced workflows",
|
|
zap.String("workspace_id", workspaceID), zap.Int("count", len(released)))
|
|
}
|
|
return s.store.DeleteConfigForWorkspace(ctx, workspaceID)
|
|
}
|
|
|
|
// syncableExtensions are the file extensions read from the sync directory.
|
|
var syncableExtensions = []string{".yml", ".yaml", ".json"}
|
|
|
|
func isSyncableFile(name string) bool {
|
|
lower := strings.ToLower(name)
|
|
for _, ext := range syncableExtensions {
|
|
if strings.HasSuffix(lower, ext) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fetchedFile is one definition file read from the repo.
|
|
type fetchedFile struct {
|
|
path string
|
|
content []byte
|
|
}
|
|
|
|
// SyncWorkspace fetches the configured repo directory and reconciles the
|
|
// workspace's synced workflows with it. Every sync applies the definitions —
|
|
// including repairing local edits to synced workflows — but the applier only
|
|
// writes (and broadcasts) what actually differs, so a no-drift sync is
|
|
// silent. The outcome (including failures) is recorded on the config row so
|
|
// the UI can surface it.
|
|
func (s *Service) SyncWorkspace(ctx context.Context, workspaceID string) (*SyncResult, error) {
|
|
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
|
return nil, err
|
|
}
|
|
lock := s.workspaceLock(workspaceID)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg == nil {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
|
|
files, err := s.fetchFiles(ctx, cfg)
|
|
if err != nil {
|
|
s.recordFailure(ctx, workspaceID, err)
|
|
return nil, err
|
|
}
|
|
|
|
parsed, warnings := parseFiles(files)
|
|
applied, err := s.applier.ApplySyncedWorkflows(ctx, workspaceID, parsed)
|
|
if err != nil {
|
|
s.recordFailure(ctx, workspaceID, err)
|
|
return nil, err
|
|
}
|
|
warnings = append(warnings, applied.Warnings...)
|
|
if err := s.store.RecordSyncStatus(ctx, workspaceID, true, "", warnings, contentHash(files), time.Now().UTC()); err != nil {
|
|
return nil, err
|
|
}
|
|
return &SyncResult{
|
|
Created: applied.Created,
|
|
Updated: applied.Updated,
|
|
Deleted: applied.Deleted,
|
|
Warnings: warnings,
|
|
Unchanged: len(applied.Created)+len(applied.Updated)+len(applied.Deleted) == 0 && len(warnings) == 0,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) recordFailure(ctx context.Context, workspaceID string, syncErr error) {
|
|
// Clear the hash so the next successful fetch re-applies from scratch.
|
|
if err := s.store.RecordSyncStatus(ctx, workspaceID, false, syncErr.Error(), nil, "", time.Now().UTC()); err != nil {
|
|
s.logger.Warn("failed to record sync failure",
|
|
zap.String("workspace_id", workspaceID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// fetchFiles lists the configured directory and downloads every workflow
|
|
// definition file in it (non-recursive).
|
|
func (s *Service) fetchFiles(ctx context.Context, cfg *Config) ([]fetchedFile, error) {
|
|
if s.clients == nil {
|
|
return nil, fmt.Errorf("GitHub is not authenticated; configure a GitHub token to sync workflows")
|
|
}
|
|
entries, err := s.clients.ListRepoDirectoryForWorkspace(
|
|
ctx, cfg.WorkspaceID, cfg.RepoOwner, cfg.RepoName, cfg.Path, cfg.Branch,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list %s/%s@%s:%s: %w", cfg.RepoOwner, cfg.RepoName, cfg.Branch, cfg.Path, err)
|
|
}
|
|
var files []fetchedFile
|
|
for _, entry := range entries {
|
|
if entry.Type != "file" || !isSyncableFile(entry.Name) {
|
|
continue
|
|
}
|
|
content, err := s.clients.GetRepoFileContentForWorkspace(
|
|
ctx, cfg.WorkspaceID, cfg.RepoOwner, cfg.RepoName, entry.Path, cfg.Branch,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch %s: %w", entry.Path, err)
|
|
}
|
|
files = append(files, fetchedFile{path: entry.Path, content: content})
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
|
|
return files, nil
|
|
}
|
|
|
|
// contentHash is a stable digest of the fetched file set. It is recorded on
|
|
// the config row for observability only — every sync reconciles regardless
|
|
// (repairing local drift), with the applier writing only actual differences.
|
|
func contentHash(files []fetchedFile) string {
|
|
h := sha256.New()
|
|
for _, f := range files {
|
|
_, _ = fmt.Fprintf(h, "%s\x00%d\x00", f.path, len(f.content))
|
|
h.Write(f.content)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// parseFiles decodes and validates each fetched file. Files that fail to
|
|
// parse are reported as warnings and passed through with a nil export, which
|
|
// tells the applier to leave their previously-synced workflows untouched.
|
|
func parseFiles(files []fetchedFile) ([]workflowservice.SyncFileExport, []string) {
|
|
parsed := make([]workflowservice.SyncFileExport, 0, len(files))
|
|
var warnings []string
|
|
for _, f := range files {
|
|
export, err := parseExport(f.path, f.content)
|
|
if err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %v", f.path, err))
|
|
parsed = append(parsed, workflowservice.SyncFileExport{Path: f.path})
|
|
continue
|
|
}
|
|
parsed = append(parsed, workflowservice.SyncFileExport{Path: f.path, Export: export})
|
|
}
|
|
return parsed, warnings
|
|
}
|
|
|
|
func parseExport(path string, data []byte) (*workflowmodels.WorkflowExport, error) {
|
|
export := &workflowmodels.WorkflowExport{}
|
|
var err error
|
|
if strings.HasSuffix(strings.ToLower(path), ".json") {
|
|
err = json.Unmarshal(data, export)
|
|
} else {
|
|
err = yaml.Unmarshal(data, export)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("not a valid workflow export file: %w", err)
|
|
}
|
|
if err := export.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return export, nil
|
|
}
|
|
|
|
// SyncDueConfigs runs a periodic sync for every workspace whose interval has
|
|
// elapsed. Failures are recorded on the config row and logged, never fatal.
|
|
func (s *Service) SyncDueConfigs(ctx context.Context) {
|
|
configs, err := s.store.ListConfigs(ctx)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list workflow sync configs", zap.Error(err))
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
for _, cfg := range configs {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if !isSyncDue(cfg, now) {
|
|
continue
|
|
}
|
|
if _, err := s.SyncWorkspace(ctx, cfg.WorkspaceID); err != nil {
|
|
s.logger.Warn("periodic workflow sync failed",
|
|
zap.String("workspace_id", cfg.WorkspaceID), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func isSyncDue(cfg *Config, now time.Time) bool {
|
|
if !cfg.PollEnabled {
|
|
return false
|
|
}
|
|
if cfg.LastSyncedAt == nil {
|
|
return true
|
|
}
|
|
return now.Sub(*cfg.LastSyncedAt) >= time.Duration(cfg.IntervalSeconds)*time.Second
|
|
}
|