381 lines
15 KiB
Go
381 lines
15 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"go.uber.org/zap"
|
|
|
|
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
|
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
|
|
"github.com/kandev/kandev/internal/auth/hostnames"
|
|
authstore "github.com/kandev/kandev/internal/auth/store"
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/persistence"
|
|
"github.com/kandev/kandev/internal/persistence/requiredstores"
|
|
quickterminalrepository "github.com/kandev/kandev/internal/quickterminal/repository"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
"github.com/kandev/kandev/internal/startup"
|
|
systemsettings "github.com/kandev/kandev/internal/system/settings"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
"github.com/kandev/kandev/internal/telemetrycontract"
|
|
terminalrepo "github.com/kandev/kandev/internal/terminal/repository"
|
|
utilitystore "github.com/kandev/kandev/internal/utility/store"
|
|
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
|
|
|
|
editorstore "github.com/kandev/kandev/internal/editors/store"
|
|
notificationstore "github.com/kandev/kandev/internal/notifications/store"
|
|
"github.com/kandev/kandev/internal/office"
|
|
promptstore "github.com/kandev/kandev/internal/prompts/store"
|
|
"github.com/kandev/kandev/internal/runtimeflags"
|
|
userstore "github.com/kandev/kandev/internal/user/store"
|
|
)
|
|
|
|
func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) {
|
|
// Each check is an admission barrier. Constructors that predate context
|
|
// support may finish one in-flight statement after cancellation, but the
|
|
// next store is never admitted and cleanup runs only after this synchronous
|
|
// initializer has returned.
|
|
cleanups := make([]func() error, 0, 12)
|
|
initialized := false
|
|
defer func() {
|
|
if !initialized {
|
|
for i := len(cleanups) - 1; i >= 0; i-- {
|
|
if cleanups[i] != nil {
|
|
_ = cleanups[i]()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
tracker, err := requiredstores.NewCatalogTracker()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
pool, cleanup, err := persistence.ProvideContext(ctx, cfg, log, version)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
cleanups = append(cleanups, cleanup)
|
|
startup.SetPhase(ctx, startup.ApplyingMigrations)
|
|
writer, reader := pool.Writer(), pool.Reader()
|
|
if err := checkStartupContext(ctx, "task repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
// repository.ProvideContext's own migrations and backfills open and
|
|
// close several of their own steps under this same ApplyingMigrations
|
|
// phase (journal turns/messages, prompt_seq, message_timestamps,
|
|
// subagent_context). It must finish before the sweep step below opens:
|
|
// steps must never overlap, so any of those steps beginning while
|
|
// stores.repositories was already active would silently end the sweep
|
|
// and turn every later recordRequiredStore call in this function into a
|
|
// no-op against a step that is no longer current.
|
|
taskRepoImpl, cleanup, taskRepoErr := repository.ProvideContext(ctx, writer, reader, log)
|
|
cleanups = append(cleanups, cleanup)
|
|
|
|
startup.BeginStep(ctx, startup.StepStoresRepositories)
|
|
startup.SetTotal(ctx, startup.StepStoresRepositories, int64(tracker.SweepTotal(startup.StepStoresRepositories)))
|
|
if err := checkStartupContext(ctx, "schema metadata"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if err := recordRequiredStore(ctx, tracker, "schema-meta", nil); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if taskRepoErr == nil {
|
|
taskRepoErr = taskRepoImpl.RecoverInterruptedKubernetesOperations(ctx)
|
|
}
|
|
if err := recordRequiredStore(ctx, tracker, "task", taskRepoErr); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("task store: %w", err)
|
|
}
|
|
// Workflow repo must be initialized before analytics repo because
|
|
// analytics creates indexes on the workflow_steps table.
|
|
if err := checkStartupContext(ctx, "workflow repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log)
|
|
if err := recordRequiredStore(ctx, tracker, "workflow", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("workflow store: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "analytics repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
analyticsRepo, cleanup, err := analyticsrepository.Provide(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "analytics", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("analytics store: %w", err)
|
|
}
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := checkStartupContext(ctx, "agent settings repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log)
|
|
if err := recordRequiredStore(ctx, tracker, "agent-settings", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("agent settings store: %w", err)
|
|
}
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := checkStartupContext(ctx, "support repositories"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
supportRepos, supportCleanups, err := provideSupportRepos(ctx, writer, reader, tracker)
|
|
if err != nil {
|
|
cleanups = append(cleanups, supportCleanups...)
|
|
return nil, nil, nil, err
|
|
}
|
|
cleanups = append(cleanups, supportCleanups...)
|
|
if err := checkStartupContext(ctx, "office repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
officeRepo, officeCleanup, err := office.Provide(writer, reader, log)
|
|
if err := recordRequiredStore(ctx, tracker, "office", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("office repo: %w", err)
|
|
}
|
|
cleanups = append(cleanups, officeCleanup)
|
|
if err := checkStartupContext(ctx, "terminal repositories"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log)
|
|
if err := recordRequiredStore(ctx, tracker, "terminal", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("terminal repo: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "quick terminal repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
quickTerminalRepoImpl, err := quickterminalrepository.NewWithDB(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "quick-terminal", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("quick terminal repo: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "runtime flags repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "runtime-flags", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err)
|
|
}
|
|
|
|
if err := checkStartupContext(ctx, "auth repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
authRepo, err := authstore.New(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "auth", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("auth store: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "secret repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir())
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("master key: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "secret store"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider)
|
|
if err := recordRequiredStore(ctx, tracker, "secrets", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("secret store: %w", err)
|
|
}
|
|
cleanups = append(cleanups, cleanup)
|
|
|
|
if err := checkStartupContext(ctx, "system settings and hostname repositories"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
systemSettings, err := systemsettings.NewStore(pool)
|
|
if err := recordRequiredStore(ctx, tracker, "system-settings", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("system settings store: %w", err)
|
|
}
|
|
if err := checkStartupContext(ctx, "hostname repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
hostnameCache, err := hostnames.NewStore(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "auth-hostnames", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("hostname cache store: %w", err)
|
|
}
|
|
|
|
if err := checkStartupContext(ctx, "telemetry contract repository"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
telemetryStore, err := telemetrycontract.NewWithDB(writer, reader)
|
|
if err := recordRequiredStore(ctx, tracker, "telemetry-contract", err); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("telemetry contract store: %w", err)
|
|
}
|
|
activateTelemetryContracts(ctx, telemetryStore, log)
|
|
|
|
repos := &Repositories{
|
|
RequiredStores: tracker,
|
|
Task: taskRepoImpl,
|
|
Analytics: analyticsRepo,
|
|
AgentSettings: agentSettingsRepo,
|
|
User: supportRepos.user,
|
|
UserAccounts: supportRepos.userAccounts,
|
|
Notification: supportRepos.notification,
|
|
Editor: supportRepos.editor,
|
|
Prompts: supportRepos.prompts,
|
|
Utility: supportRepos.utility,
|
|
Workflow: workflowRepo,
|
|
Secrets: secretStore,
|
|
Office: officeRepo,
|
|
Terminal: terminalRepoImpl,
|
|
QuickTerminal: quickTerminalRepoImpl,
|
|
RuntimeFlags: runtimeFlagsStore,
|
|
Auth: authRepo,
|
|
HostnameCache: hostnameCache,
|
|
SystemSettings: systemSettings,
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
initialized = true
|
|
startup.EndStep(ctx, startup.StepStoresRepositories)
|
|
startup.SetPhase(ctx, startup.InitializingServices)
|
|
startup.BeginStep(ctx, startup.StepStoresServices)
|
|
startup.SetTotal(ctx, startup.StepStoresServices, int64(tracker.SweepTotal(startup.StepStoresServices)))
|
|
return pool, repos, cleanups, nil
|
|
}
|
|
|
|
func checkStartupContext(ctx context.Context, stage string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return fmt.Errorf("startup canceled before %s: %w", stage, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// supportRepositorySet groups the lighter-weight support repositories
|
|
// that share a common (writer, reader) wire-up pattern.
|
|
type supportRepositorySet struct {
|
|
user userstore.Repository
|
|
userAccounts userstore.AccountRepository
|
|
notification notificationstore.Repository
|
|
editor editorstore.Repository
|
|
prompts promptstore.Repository
|
|
utility utilitystore.Repository
|
|
}
|
|
|
|
// provideSupportRepos wires up user, notification, editor, prompt, and utility
|
|
// repositories. Extracted from provideRepositories to keep its statement count
|
|
// within the funlen limit.
|
|
func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker *requiredstores.Tracker) (supportRepositorySet, []func() error, error) {
|
|
var cleanups []func() error
|
|
var repos supportRepositorySet
|
|
|
|
if err := checkStartupContext(ctx, "user repository"); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
userRepo, cleanup, err := userstore.Provide(writer, reader)
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := recordRequiredStore(ctx, tracker, "user", err); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
repos.user = userRepo
|
|
// Same concrete store, account-management view (used by internal/auth).
|
|
repos.userAccounts = userRepo
|
|
|
|
if err := checkStartupContext(ctx, "notification repository"); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader)
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := recordRequiredStore(ctx, tracker, "notification", err); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
repos.notification = notificationRepo
|
|
|
|
if err := checkStartupContext(ctx, "editor repository"); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
editorRepo, cleanup, err := editorstore.Provide(writer, reader)
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := recordRequiredStore(ctx, tracker, "editor", err); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
repos.editor = editorRepo
|
|
|
|
if err := checkStartupContext(ctx, "prompt repository"); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
promptRepo, cleanup, err := promptstore.Provide(writer, reader)
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := recordRequiredStore(ctx, tracker, "prompts", err); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
repos.prompts = promptRepo
|
|
|
|
if err := checkStartupContext(ctx, "utility repository"); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
|
|
cleanups = append(cleanups, cleanup)
|
|
if err := recordRequiredStore(ctx, tracker, "utility", err); err != nil {
|
|
return repos, cleanups, err
|
|
}
|
|
repos.utility = utilityRepo
|
|
|
|
return repos, cleanups, nil
|
|
}
|
|
|
|
// recordRequiredStore records a constructor result before the process can
|
|
// expose readiness. Initialization failures remain fatal to the caller. A
|
|
// successful admission advances the store-admission sweep step its catalog
|
|
// descriptor names, by one.
|
|
func recordRequiredStore(ctx context.Context, tracker *requiredstores.Tracker, id string, initErr error) error {
|
|
if trackerErr := tracker.Record(id, initErr); trackerErr != nil {
|
|
return trackerErr
|
|
}
|
|
if initErr != nil {
|
|
return fmt.Errorf("required store %q: %w", id, initErr)
|
|
}
|
|
if sweep, ok := tracker.DescriptorSweep(id); ok {
|
|
startup.Advance(ctx, sweep, 1)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// activateTelemetryContracts writes the boot activation row for any
|
|
// newly-active telemetry contract and logs one health line per registered
|
|
// contract. Neither step is fatal: a failure logs and boot continues,
|
|
// matching recordSchemaVersion's contract directly below.
|
|
func activateTelemetryContracts(ctx context.Context, store *telemetrycontract.Store, log *logger.Logger) {
|
|
if err := store.Activate(ctx); err != nil {
|
|
if log != nil {
|
|
log.Warn("failed to activate telemetry contracts", zap.Error(err))
|
|
}
|
|
}
|
|
store.LogHealth(ctx, log)
|
|
}
|
|
|
|
// recordSchemaVersion writes the current binary version into kandev_meta so the
|
|
// next boot can detect upgrades. A failure here is non-fatal: the stored
|
|
// version stays at the previous value and the next boot will take a fresh
|
|
// backup (idempotent).
|
|
func recordSchemaVersion(writer *sqlx.DB, _ string, version string, log *logger.Logger) {
|
|
if version == "" {
|
|
return
|
|
}
|
|
if err := persistence.WriteVersion(writer, version); err != nil {
|
|
if log != nil {
|
|
log.Warn("failed to record schema version", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
if log != nil {
|
|
log.Info("schema version recorded", zap.String(versionFieldKey, version))
|
|
}
|
|
}
|
|
|
|
// recordSchemaVersionAfterPersistence keeps the version marker behind the
|
|
// final required-store and health gates. Callers can therefore safely use it
|
|
// as the last startup action before readiness publication.
|
|
func recordSchemaVersionAfterPersistence(
|
|
validate func() error,
|
|
healthCheck func() error,
|
|
record func(),
|
|
) error {
|
|
if err := validate(); err != nil {
|
|
return fmt.Errorf("required-store validation before readiness: %w", err)
|
|
}
|
|
if err := healthCheck(); err != nil {
|
|
return fmt.Errorf("required-store health check before readiness: %w", err)
|
|
}
|
|
record()
|
|
return nil
|
|
}
|