package cmd import ( "context" "database/sql" "log/slog" "os" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations" stripeintegration "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe" stripemod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store" "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" "git.coopcloud.tech/wiki-cafe/member-console/internal/migrate" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/server" "git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant" "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows" wfBilling "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/billing" wfDomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains" wfMaintenance "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/maintenance" "github.com/spf13/cobra" "github.com/spf13/viper" "go.temporal.io/sdk/client" ) // domainsReconciler is the optional capability an installed integration // implements to reconcile the domains registry against its own state at boot // (design D8): ensuring the operator roots its configuration implies, and // backfilling registry rows for resources that predate the registry. // // Declared HERE, at its point of use, rather than in internal/integrations: // the composition root is the only place that holds both the integration // registry and the live *sql.DB, and an integration satisfies this // structurally without importing anything to say so — the same pattern // server.RouteProvider and workflows.WorkflowProvider follow, for the same // import-cycle reason. // // Implementations MUST be idempotent: the loop below runs on every boot, with // no gate of any kind, and doubles as the backstop for allocation orphans. type domainsReconciler interface { ReconcileDomains(ctx context.Context, database *sql.DB, logger *slog.Logger) error } var startCmd = &cobra.Command{ Use: "start", Short: "Start serving the member-console web application", Long: `The start command starts an HTTP server that serves the member-console web application from the components directory in the current directory. The server listens on port 8080 by default, unless a different port is specified using the --port flag.`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { // Create base context for the application ctx := context.Background() // Set up structured logging env := viper.GetString("env") logger := logging.SetupLogger(env) // Store logger in context ctx = logging.WithContext(ctx, logger) // Installed integrations (FedWiki, Stripe, ...) are read from the // registry once and reused for every registry-driven concern below: // secret/file resolution, config validation, and (further down, // after the database is up) route/workflow wiring. integs := integrations.All() var integrationConfigSpecs []config.ConfigKey for _, integ := range integs { if cp, ok := integ.(config.ConfigProvider); ok { integrationConfigSpecs = append(integrationConfigSpecs, cp.ConfigSpec()...) } } // Resolve *-secret / *-secret-file pairs into Viper before validating or // starting any service, so file-backed secrets (e.g. csrf-secret-file) // are present when ValidateStart reads them. A conflict or unreadable // file is a fatal misconfiguration — exit non-zero, not a soft return. // Core's own secrets are hand-listed (a small, stable set); every // installed integration's secret keys are derived from its declared // ConfigSpec via config.SecretPairsFrom instead of being hand- // enumerated here too. secretPairs := []config.SecretPair{ {Name: "oidc-sp-client-secret", FileName: "oidc-sp-client-secret-file"}, {Name: "csrf-secret", FileName: "csrf-secret-file"}, {Name: "temporal-oauth-client-secret", FileName: "temporal-oauth-client-secret-file"}, } secretPairs = append(secretPairs, config.SecretPairsFrom(integrationConfigSpecs)...) for _, pair := range secretPairs { value := viper.GetString(pair.Name) file := viper.GetString(pair.FileName) // Both a direct value and a file path is ambiguous. if value != "" && file != "" { logger.Error("configuration error", slog.String("config", pair.Name), slog.String("error", "both direct value and file path provided; use only one")) os.Exit(1) } // If a file path is provided, load the value from the file. if file != "" { loaded, err := loadFromFile(file) if err != nil { logger.Error("failed to load configuration from file", slog.String("config", pair.Name), slog.String("file", file), slog.Any("error", err)) os.Exit(1) } viper.Set(pair.Name, loaded) } } // Validate required configuration before initializing any service, so a // misconfiguration fails fast with an actionable, aggregated error rather // than a late, cryptic downstream failure. integrationConfigSpecs drives // the generic required-together check (e.g. Stripe's api-key/webhook- // secret pair) instead of a hardcoded per-integration conditional. if err := config.ValidateStart(integrationConfigSpecs); err != nil { logger.Error("invalid configuration", slog.Any("error", err)) os.Exit(1) } // Database Setup dbDSN := viper.GetString("db-dsn") dbConfig := db.DefaultDBConfig(dbDSN) migrationSources := migrate.Sources() database, err := db.ConnectAndMigrate(ctx, logger, dbConfig, migrationSources) if err != nil { logger.Error("failed to initialize database", slog.Any("error", err)) os.Exit(1) } defer database.Close() // Layer operator-set config overrides (core.integration_config_ // overrides) over the environment. Must run here — after migrations // (the table exists), before any route/workflow registration (that's // when integrations capture their config) and before goroutines // (viper.Set is not synchronized). Overrides apply on restart by // design; see internal/config.ApplyOverlay. overrideRows, err := integration.New(database).ListConfigOverrides(ctx) if err != nil { logger.Error("failed to load integration config overrides", slog.Any("error", err)) os.Exit(1) } overrides := make([]config.Override, 0, len(overrideRows)) for _, row := range overrideRows { overrides = append(overrides, config.Override{Key: row.Key, Value: row.Value}) } if err := config.ApplyOverlay(integrationConfigSpecs, overrides); err != nil { logger.Error("invalid integration config override", slog.Any("error", err)) os.Exit(1) } // Register providers from code into the registry (parallel to how // migration sources are enumerated). Runs after migrations, so the // resource keys each provider manifest names already exist for stamping. providerSources := make([]integration.ProviderSource, 0, len(integs)) for _, integ := range integs { ps := integ.Provider() // D7: each integration declares its slug twice (Slug() and // its manifest's Slug); assert they agree before this // integration's manifest is registered, so a drift fails // boot with a named error instead of silently splitting the // operator Integrations page later. if err := integration.AssertSlugMatch(integ.Slug(), ps.ProviderManifest()); err != nil { logger.Error("integration slug mismatch", slog.Any("error", err)) os.Exit(1) } providerSources = append(providerSources, ps) } if err := integration.RegisterProviders(ctx, database, providerSources); err != nil { logger.Error("failed to register providers", slog.Any("error", err)) os.Exit(1) } // Ensure the singleton System tenant exists (it owns ownerless provider // resources). Runs after migrations; fatal on failure. The resulting // workspace ID isn't needed here — the FedWiki integration re-derives it // itself (Ensure is idempotent) inside its WorkflowProvider Startup hook, // only when its sync schedule is actually enabled. if _, err := systemtenant.Ensure(ctx, database); err != nil { logger.Error("failed to ensure system tenant", slog.Any("error", err)) os.Exit(1) } // Reconcile the domains registry against each integration's own // state (design D8): operator roots ensured from deployment // configuration, and placements backfilled for resources that // predate the registry. UNCONDITIONAL and here, not in an // integration's Startup hook — that hook runs only when Temporal is // configured (and, for FedWiki, only when its sync is enabled, which // defaults to false), so a default deployment would boot with no // operator roots and /domains/ask would refuse every hosted name it // serves. Runs after migrations (the registry tables exist) and after // provider registration (placements FK core.providers(slug)), before // the server and worker start. // // Fatal on failure, matching RegisterProviders above: a registry that // does not fit the deployment's own namespace configuration is a // misconfiguration, and booting past it would serve a console that // silently cannot allocate or authorize names. // Expire pending claims whose verification window has elapsed // (claim-expiry-and-carve-guards D1/D3). Core registry work with no // integration in it, so it lives here rather than inside an // integration's ReconcileDomains: a deployment with no integrations // installed still has claims that expire. // // BEFORE the reconciler loop below, deliberately. A stranded pending // claim is exactly what stops boot reconciliation from adopting the // resource under it, and a stranded pending EXTERNAL claim at a // configured farm domain makes EnsureOperatorRoot fatal — sweeping // first can clear that on its own. // // Logged, not fatal, unlike the loop: an unswept claim holds one name, // where an unensurable root leaves every hosted name unallocatable. // The recurring Temporal schedule below re-runs this; the boot pass is // what makes it independent of Temporal being configured at all. if result, err := domains.NewRegistry(database).SweepExpiredClaims(ctx); err != nil { logger.Error("domain claim expiry sweep failed", slog.Int("candidates", result.Candidates), slog.Int("expired", result.Expired), slog.Int("failed", result.Failed), slog.Any("error", err)) } else if result.Candidates > 0 { logger.Info("domain claim expiry sweep completed", slog.Int("candidates", result.Candidates), slog.Int("expired", result.Expired)) } // Ensure core.webhook_events has monthly RANGE partitions for the // current month and the months ahead (webhook-partition-maintenance): // the baseline migration only creates the current + next two months // (internal/db/migrations/00001_init.sql:1048-1064), and there is no // DEFAULT partition, so webhook ingestion would otherwise fail once // the deployment runs past that window. // // Logged, not fatal, like the sweep above: the current-month // partition almost always already exists, and failing boot on a // transient DB error would turn a months-away risk into an // immediate outage. The recurring Temporal schedule below re-runs // this; the boot pass is what makes it independent of Temporal // being configured at all. if err := db.EnsureWebhookEventPartitions(ctx, database, time.Now(), db.DefaultWebhookPartitionMonthsAhead); err != nil { logger.Warn("webhook_events partition ensure failed", slog.Any("error", err)) } for _, integ := range integs { dr, ok := integ.(domainsReconciler) if !ok { continue } if err := dr.ReconcileDomains(ctx, database, logger); err != nil { logger.Error("failed to reconcile domains registry", slog.String("integration", integ.Slug()), slog.Any("error", err)) os.Exit(1) } } // Collect HTTP route mounts, Temporal workflow/activity // registrations, and UI (template/static) mounts from installed // integrations (integs, gathered at the top of this function). // server.RouteProvider and workflows.WorkflowProvider are declared // at their point of use (not in internal/integrations) to avoid // import cycles — see those interfaces' doc comments. // integrations.UIProvider has no such cycle (it references no // server/workflows types) and is declared alongside Integration // itself. cmd/start.go is the only place that imports // internal/integrations, internal/server, and internal/workflows // together, so it's the only place these type assertions can // happen. var routeMounts []server.RouteMount var workflowProviders []workflows.WorkflowProvider var uiMounts []server.UIMount var dashboardCards []server.DashboardCard var integrationConfigs []server.IntegrationConfigInfo for _, integ := range integs { if cp, ok := integ.(config.ConfigProvider); ok { manifest := integ.Provider().ProviderManifest() integrationConfigs = append(integrationConfigs, server.IntegrationConfigInfo{ Slug: integ.Slug(), DisplayName: manifest.DisplayName, SurfacePath: manifest.OperatorSurfacePath, Keys: cp.ConfigSpec(), }) } if rp, ok := integ.(server.RouteProvider); ok { routeMounts = append(routeMounts, server.RouteMount{ Register: rp.RegisterRoutes, CSRFExemptPaths: rp.CSRFExemptPaths(), }) } if wp, ok := integ.(workflows.WorkflowProvider); ok { workflowProviders = append(workflowProviders, wp) } if uip, ok := integ.(integrations.UIProvider); ok { uiMounts = append(uiMounts, server.UIMount{ Slug: integ.Slug(), Templates: uip.Templates(), Static: uip.Static(), }) } if dp, ok := integ.(server.DashboardCardProvider); ok { dashboardCards = append(dashboardCards, dp.DashboardCards()...) } } // Retrieve the configuration values from Viper port := viper.GetString("port") csrfSecret := viper.GetString("csrf-secret") // Claim lifecycle policy (design D6): read once here and carried on // server.Config → server.Deps, so core's Domains page and every // integration surface that claims names allocate under one policy. // The Temporal worker needs none of it — every policy decision is made // in the registry at allocation time. Unset keys leave the matching // field zero, which the registry resolves to its shipped default. domainsPolicy := domains.Policy{ ClaimWindow: viper.GetDuration("domains-claim-window"), PendingCap: viper.GetInt("domains-pending-cap"), AbandonBudget: configuredBudget(viper.GetInt("domains-abandon-budget")), AbandonWindow: viper.GetDuration("domains-abandon-window"), ScopeLabels: viper.GetInt("domains-scope-labels"), InitiationBudget: configuredBudget(viper.GetInt("domains-initiation-budget")), } // Resolved once and threaded as data (server config, worker config, // integration Deps): the DNS target members point external custom // domains at. Empty disables external domain claims deployment-wide. domainsConnectTarget := viper.GetString("domains-connect-target") // Create Temporal client if configured var temporalClient client.Client temporalHost := viper.GetString("temporal-host") if temporalHost != "" { temporalOAuthTokenURL := viper.GetString("temporal-oauth-token-url") temporalOAuthClientID := viper.GetString("temporal-oauth-client-id") temporalOAuthClientSecret := viper.GetString("temporal-oauth-client-secret") temporalOAuthScopes := viper.GetStringSlice("temporal-oauth-scopes") clientCfg := workflows.ClientConfig{ HostPort: temporalHost, Namespace: viper.GetString("temporal-namespace"), Logger: logger, ConnectTimeout: viper.GetDuration("temporal-connect-timeout"), } if temporalOAuthTokenURL != "" || temporalOAuthClientID != "" || temporalOAuthClientSecret != "" || len(temporalOAuthScopes) > 0 { clientCfg.OAuthTokenProvider = &workflows.OAuthTokenProviderConfig{ TokenURL: temporalOAuthTokenURL, ClientID: temporalOAuthClientID, ClientSecret: temporalOAuthClientSecret, Scopes: temporalOAuthScopes, } } var err error temporalClient, err = workflows.NewClient(ctx, clientCfg) if err != nil { logger.Error("failed to connect to Temporal", slog.Any("error", err)) os.Exit(1) } defer temporalClient.Close() // Start Temporal worker workerCfg := workflows.DefaultWorkerConfig(database, logger) workerCfg.WorkflowProviders = workflowProviders workerCfg.DomainsConnectTarget = domainsConnectTarget worker, err := workflows.NewWorker(temporalClient, workerCfg) if err != nil { logger.Error("failed to create Temporal worker", slog.Any("error", err)) os.Exit(1) } if err := worker.Start(); err != nil { logger.Error("failed to start Temporal worker", slog.Any("error", err)) os.Exit(1) } defer worker.Stop() // Run each installed integration's Startup hook (Stripe's hand-started // webhook/outbox-poller workflows, FedWiki's sync schedule, ...) // instead of naming them here. Each implementation logs and swallows // its own failures (see workflows.WorkflowProvider's doc comment); this // loop only logs defensively for a well-behaved implementation that // does return an error. for _, wp := range workflowProviders { if err := wp.Startup(ctx, temporalClient, workerCfg.TaskQueue, database, logger); err != nil { logger.Error("integration workflow startup failed", slog.Any("error", err)) } } // Set up the billing scheduled-change sweep — the backstop firing // path for due subscription scheduled changes. Period-end // cancellations also fire via the Stripe deletion webhook, so a // failure here is non-fatal. sweepInterval := viper.GetDuration("billing-sweep-interval") if sweepInterval == 0 { sweepInterval = wfBilling.DefaultSweepInterval } sweepSchedule := wfBilling.NewScheduleManager(temporalClient, logger) if err := sweepSchedule.EnsureSweepSchedule(ctx, wfBilling.SweepScheduleConfig{ Interval: sweepInterval, }); err != nil { logger.Error("failed to set up billing sweep schedule", slog.Any("error", err)) } // Set up the domain claim expiry sweep — the backstop firing path // for pending claims past their deadline. A claim's own // verification workflow normally expires it; this catches the ones // whose workflow is gone. The console also sweeps once per boot // (above), which is what covers deployments with no Temporal at // all, so a failure here is non-fatal. claimSweepInterval := viper.GetDuration("domains-expiry-sweep-interval") if claimSweepInterval == 0 { claimSweepInterval = wfDomains.DefaultExpirySweepInterval } claimSweepSchedule := wfDomains.NewScheduleManager(temporalClient, logger) if err := claimSweepSchedule.EnsureExpirySweepSchedule(ctx, wfDomains.ExpirySweepScheduleConfig{ Interval: claimSweepInterval, }); err != nil { logger.Error("failed to set up domain claim expiry sweep schedule", slog.Any("error", err)) } // Set up the webhook_events partition ensure schedule (design.md D3, // webhook-partition-maintenance): the backstop for deployments that // run longer than the partition lookahead window without // restarting. The console also ensures partitions once per boot // (above), which is what covers deployments with no Temporal at // all, so a failure here is non-fatal. Unlike the sweeps above, the // spec requires triggering an immediate run on registration. webhookPartitionInterval := viper.GetDuration("webhook-partition-ensure-interval") if webhookPartitionInterval == 0 { webhookPartitionInterval = wfMaintenance.DefaultWebhookPartitionEnsureInterval } webhookPartitionSchedule := wfMaintenance.NewScheduleManager(temporalClient, logger) if err := webhookPartitionSchedule.EnsureWebhookPartitionSchedule(ctx, wfMaintenance.WebhookPartitionScheduleConfig{ Interval: webhookPartitionInterval, TriggerImmediately: true, }); err != nil { logger.Error("failed to set up webhook partition ensure schedule", slog.Any("error", err)) } } else { logger.Warn("Temporal not configured - integration workflows and scheduled jobs will be unavailable") } // Create server config. stripeDashboardURL is Stripe-specific plumbing // (mapping stripe-mode to a dashboard base URL) owned by the Stripe // integration and only consumed downstream by the payments-seam // operator billing UI (internal/server/operator_billing.go, // operator_partials.go), which stays in core per design.md Decision 6. stripeDashboardURL := stripeintegration.DashboardURL(viper.GetString("stripe-mode")) serverConfig := server.Config{ Port: port, Env: env, CSRFSecret: csrfSecret, Logger: logger, Database: database, IdentityQ: identity.New(database), OrgQ: organization.New(database), EntitlementsQ: entitlements.New(database), BillingQ: billing.New(database), StripeQ: stripemod.New(database), DomainsQ: domains.New(database), TemporalClient: temporalClient, StripeWebhookSecret: viper.GetString("stripe-webhook-secret"), StripeAPIKey: viper.GetString("stripe-api-key"), StripeDashboardURL: stripeDashboardURL, BaseURL: viper.GetString("base-url"), AskFallbackURL: viper.GetString("domains-ask-fallback-url"), DomainsPolicy: domainsPolicy, DomainsConnectTarget: domainsConnectTarget, RouteMounts: routeMounts, UIMounts: uiMounts, DashboardCards: dashboardCards, IntegrationConfigs: integrationConfigs, } // Start the server if err := server.Start(ctx, serverConfig); err != nil { logger.Error("server failed to start", slog.Any("error", err)) } }, } func init() { // Register flags with Cobra // DO NOT SET DEFAULT VALUES HERE. Use viper.SetDefault() instead. https://github.com/spf13/viper/issues/671 // General configuration startCmd.Flags().StringP("port", "p", "", "Port to listen on") startCmd.Flags().String("base-url", "", "Address at which the server is exposed") startCmd.Flags().String("env", "", "Environment (development/production)") startCmd.Flags().String("deployment-name", "", "Name this deployment presents for itself on the member and operator mastheads, page titles, and OpenGraph tags (default \"Member Console\")") startCmd.Flags().String("db-dsn", "", "PostgreSQL connection string (e.g., postgres://user:pass@localhost:5432/dbname?sslmode=disable)") startCmd.Flags().String("valkey-addr", "", "Valkey/Redis address for session storage (host:port)") startCmd.Flags().String("csrf-secret", "", "Secret key for CSRF protection (must be exactly 32 bytes)") startCmd.Flags().String("csrf-secret-file", "", "Path to file containing CSRF secret key") // OIDC configuration startCmd.Flags().String("oidc-sp-client-id", "", "OIDC Client ID") startCmd.Flags().String("oidc-idp-issuer-url", "", "OIDC Identity Provider Issuer URL") startCmd.Flags().String("oidc-sp-client-secret", "", "OIDC Client Secret") startCmd.Flags().String("oidc-sp-client-secret-file", "", "Path to file containing OIDC Client Secret") startCmd.Flags().Duration("billing-sweep-interval", time.Hour, "Interval between billing scheduled-change sweeps (backstop firing path)") // Domains registry configuration. The fallback URL is a migration-window // seam: registry misses (and only misses) are forwarded to a legacy // on-demand-TLS answerer, so a deployment can strangle a filesystem-based // answerer incrementally. Empty — the default — refuses unknown names. startCmd.Flags().String("domains-ask-fallback-url", "", "Legacy on-demand-TLS answerer consulted for names unknown to the domains registry (empty disables)") startCmd.Flags().String("domains-connect-target", "", "DNS target members point external custom domains at (CNAME host or A-record IP); empty disables external domain claims deployment-wide") // Claim lifecycle policy (design D6). Flag-only core keys: they appear on // no operator settings page, so the operator domains surface prints the // effective set. Defaults are internal/domains' own constants, applied via // viper.SetDefault below — a single-tenant coop and a public multi-tenant // host want different numbers, and the defaults suit the latter. startCmd.Flags().Duration("domains-claim-window", 0, "How long a member has to publish the TXT challenge before a pending external domain claim expires (default 24h)") startCmd.Flags().Int("domains-pending-cap", 0, "Concurrent pending external domain verifications allowed per workspace (default 5)") startCmd.Flags().Int("domains-abandon-budget", 0, "Verifications a workspace may start and abandon under one domain scope within the abandonment window before further claims are refused (default 3; 0 disables the ledger)") startCmd.Flags().Duration("domains-abandon-window", 0, "Rolling window abandoned verifications are counted over (default 168h)") startCmd.Flags().Int("domains-scope-labels", 0, "Trailing DNS labels forming the scope abandonments are counted under, so sibling names share one scope (default 2)") startCmd.Flags().Int("domains-initiation-budget", 0, "New external domain claims allowed per workspace per rolling 24h (default 10; 0 disables the cap)") startCmd.Flags().Duration("domains-expiry-sweep-interval", wfDomains.DefaultExpirySweepInterval, "Interval between domain claim expiry sweeps (backstop for claims whose verification workflow is gone)") startCmd.Flags().Duration("webhook-partition-ensure-interval", wfMaintenance.DefaultWebhookPartitionEnsureInterval, "Interval between core.webhook_events partition ensure runs (backstop keeping monthly partitions provisioned ahead of need)") // Support configuration startCmd.Flags().String("support-url", "", "URL for users to get support (shown in error messages)") // Integration configuration: every installed integration (FedWiki, // Stripe, ...) declares its own flags/defaults via ConfigSpec instead // of each key being hand-declared here — see internal/config.ConfigKey // and internal/config.ConfigProvider. This replaces what used to be // separate "FedWiki configuration" (farm API URL, allowed domains, // site scheme, admin token) and "Stripe configuration" (api key, // webhook secret, mode) blocks. registerIntegrationConfigFlags(startCmd) // Temporal configuration startCmd.Flags().String("temporal-host", "", "Temporal server host:port (e.g., localhost:7233)") startCmd.Flags().String("temporal-namespace", "", "Temporal namespace") startCmd.Flags().String("temporal-oauth-token-url", "", "OAuth2 token endpoint for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-id", "", "OAuth2 client ID for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-secret", "", "OAuth2 client secret for Temporal authentication") startCmd.Flags().String("temporal-oauth-client-secret-file", "", "Path to file containing Temporal OAuth2 client secret") startCmd.Flags().StringSlice("temporal-oauth-scopes", nil, "OAuth2 scopes for Temporal authentication (comma-separated)") startCmd.Flags().Duration("temporal-connect-timeout", 0, "Max time to retry connecting to Temporal on startup before failing (0 = 90s)") // Bind all flags to Viper viper.BindPFlags(startCmd.Flags()) // Set default values viper.SetDefault("port", "8080") viper.SetDefault("valkey-addr", "localhost:6379") viper.SetDefault("env", "development") viper.SetDefault("deployment-name", config.DefaultDeploymentName) viper.SetDefault("temporal-namespace", "default") viper.SetDefault("temporal-connect-timeout", 90*time.Second) viper.SetDefault("billing-sweep-interval", time.Hour) viper.SetDefault("domains-claim-window", domains.ExternalClaimWindow) viper.SetDefault("domains-pending-cap", domains.DefaultPendingCap) viper.SetDefault("domains-abandon-budget", domains.DefaultAbandonBudget) viper.SetDefault("domains-abandon-window", domains.DefaultAbandonWindow) viper.SetDefault("domains-scope-labels", domains.DefaultScopeLabels) viper.SetDefault("domains-initiation-budget", domains.DefaultInitiationBudget) viper.SetDefault("domains-expiry-sweep-interval", wfDomains.DefaultExpirySweepInterval) viper.SetDefault("webhook-partition-ensure-interval", wfMaintenance.DefaultWebhookPartitionEnsureInterval) // fedwiki-site-scheme and stripe-mode defaults are set by // registerIntegrationConfigFlags above, from each integration's // declared ConfigSpec. // Add the command to the root command rootCmd.AddCommand(startCmd) } // registerIntegrationConfigFlags binds a cobra flag — and, for secrets, its // "-file" companion — for every ConfigKey each installed integration // declares via ConfigProvider, and sets the key's default via // viper.SetDefault. This replaces what used to be per-integration flag // literals and SetDefault calls hand-maintained in this file (see // internal/config.ConfigKey's doc comment for the declarations this reads). // Flags always register with a zero-value default (matching the // "DO NOT SET DEFAULT VALUES HERE" convention above): the real default // flows through viper.SetDefault instead. func registerIntegrationConfigFlags(cmd *cobra.Command) { for _, integ := range integrations.All() { cp, ok := integ.(config.ConfigProvider) if !ok { continue } for _, key := range cp.ConfigSpec() { // A key's Go type is carried by its declared Default value // (integration-config-parity D2): bool and duration keys travel // this seam like strings do, so no integration knob needs // hand-declaring in core. switch key.Default.(type) { case []string: cmd.Flags().StringSlice(key.Name, nil, key.Usage) case bool: cmd.Flags().Bool(key.Name, false, key.Usage) case time.Duration: cmd.Flags().Duration(key.Name, 0, key.Usage) default: cmd.Flags().String(key.Name, "", key.Usage) } if key.Secret { cmd.Flags().String(key.Name+"-file", "", "Path to file containing the "+key.Name+" value") } if key.Default != nil { viper.SetDefault(key.Name, key.Default) } } } } // configuredBudget maps a configured claim budget onto domains.Policy's // encoding. A configured 0 means "never refuse for this reason", which the // struct's zero value cannot carry — there it means "unconfigured, use the // default" — so it becomes domains.PolicyDisabled. func configuredBudget(configured int) int { if configured == 0 { return domains.PolicyDisabled } return configured } // loadFromFile reads a file and returns its contents as a trimmed string func loadFromFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", err } return strings.TrimSpace(string(data)), nil }