package cmd import ( "context" "log/slog" "os" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/db" "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" "git.coopcloud.tech/wiki-cafe/member-console/internal/server" "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows" "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/fedwiki" "github.com/spf13/cobra" "github.com/spf13/viper" "go.temporal.io/sdk/client" ) 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) // Database Setup dbDSN := viper.GetString("db-dsn") dbConfig := db.DefaultDBConfig(dbDSN) database, err := db.ConnectAndMigrate(ctx, logger, dbConfig) if err != nil { logger.Error("failed to initialize database", slog.Any("error", err)) os.Exit(1) } defer database.Close() // You'll pass 'database' (or your sqlc Querier) to your server/handlers. // For example, by adding it to the server.Config // Validate and load configurations from files if specified configPairs := []struct { value string file string configKey string }{ {viper.GetString("oidc-sp-client-secret"), viper.GetString("oidc-sp-client-secret-file"), "oidc-sp-client-secret"}, {viper.GetString("session-secret"), viper.GetString("session-secret-file"), "session-secret"}, {viper.GetString("csrf-secret"), viper.GetString("csrf-secret-file"), "csrf-secret"}, {viper.GetString("fedwiki-admin-token"), viper.GetString("fedwiki-admin-token-file"), "fedwiki-admin-token"}, {viper.GetString("temporal-oauth-client-secret"), viper.GetString("temporal-oauth-client-secret-file"), "temporal-oauth-client-secret"}, } // Check for conflicts between direct values and file paths for _, pair := range configPairs { // Check if both a direct value and a file path are provided if pair.value != "" && pair.file != "" { logger.Error("configuration error", slog.String("config", pair.configKey), slog.String("error", "both direct value and file path provided; use only one")) return } // If a file path is provided, load the value from the file if pair.file != "" { value, err := loadFromFile(pair.file) if err != nil { logger.Error("failed to load configuration from file", slog.String("config", pair.configKey), slog.String("file", pair.file), slog.Any("error", err)) return } viper.Set(pair.configKey, value) } } // Retrieve the configuration values from Viper port := viper.GetString("port") csrfSecret := viper.GetString("csrf-secret") // 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, } 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(db.New(database), logger) workerCfg.FedWikiFarmAPIURL = viper.GetString("fedwiki-farm-api-url") workerCfg.FedWikiAllowedDomains = viper.GetStringSlice("fedwiki-allowed-domains") workerCfg.FedWikiAdminToken = viper.GetString("fedwiki-admin-token") workerCfg.SupportURL = viper.GetString("support-url") 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() // Set up FedWiki site sync schedule if enabled if viper.GetBool("fedwiki-sync-enabled") { syncInterval := viper.GetDuration("fedwiki-sync-interval") if syncInterval == 0 { syncInterval = fedwiki.DefaultSyncInterval } scheduleManager := fedwiki.NewScheduleManager(temporalClient, logger) err := scheduleManager.EnsureSyncSchedule(ctx, fedwiki.ScheduleConfig{ Interval: syncInterval, DefaultUserID: viper.GetInt64("fedwiki-sync-default-user-id"), TriggerImmediately: viper.GetBool("fedwiki-sync-trigger-immediately"), }) if err != nil { logger.Error("failed to set up FedWiki sync schedule", slog.Any("error", err)) // Non-fatal: continue without sync schedule } } } else { logger.Warn("Temporal not configured - FedWiki site provisioning will be unavailable") } // Create server config serverConfig := server.Config{ Port: port, Env: env, CSRFSecret: csrfSecret, Logger: logger, DB: db.New(database), // Pass the sqlc Querier FedWikiFarmAPIURL: viper.GetString("fedwiki-farm-api-url"), FedWikiAllowedDomains: viper.GetStringSlice("fedwiki-allowed-domains"), FedWikiSiteScheme: viper.GetString("fedwiki-site-scheme"), FedWikiAdminToken: viper.GetString("fedwiki-admin-token"), SupportURL: viper.GetString("support-url"), TemporalClient: temporalClient, } // 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("db-dsn", "", "Database DSN (e.g., ./member_console.db or file:/path/to/data.db?_foreign_keys=on)") startCmd.Flags().String("session-secret", "", "Secret key for session management (must be exactly 32 bytes)") startCmd.Flags().String("session-secret-file", "", "Path to file containing session secret key") 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") // FedWiki configuration startCmd.Flags().String("fedwiki-farm-api-url", "", "URL of the FedWiki farm API endpoint (e.g., https://admin.wiki.example.com)") startCmd.Flags().StringSlice("fedwiki-allowed-domains", nil, "Domains where users can create FedWiki sites (e.g., wiki.example.com,wiki2.example.com)") startCmd.Flags().String("fedwiki-site-scheme", "", "URL scheme for FedWiki sites (http or https)") startCmd.Flags().String("fedwiki-admin-token", "", "FedWiki admin access token for farmmanager API") startCmd.Flags().String("fedwiki-admin-token-file", "", "Path to file containing FedWiki admin access token") // FedWiki sync configuration startCmd.Flags().Bool("fedwiki-sync-enabled", false, "Enable periodic sync of sites from FedWiki farm") startCmd.Flags().Duration("fedwiki-sync-interval", time.Hour, "Interval between FedWiki site syncs") startCmd.Flags().Int64("fedwiki-sync-default-user-id", 1, "Default user ID for orphaned sites during sync") startCmd.Flags().Bool("fedwiki-sync-trigger-immediately", true, "Run sync immediately on startup") // Support configuration startCmd.Flags().String("support-url", "", "URL for users to get support (shown in error messages)") // 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)") // Bind all flags to Viper viper.BindPFlags(startCmd.Flags()) // Set default values viper.SetDefault("port", "8080") viper.SetDefault("env", "development") viper.SetDefault("db-dsn", "./member_console.db") viper.SetDefault("temporal-namespace", "default") viper.SetDefault("fedwiki-site-scheme", "https") viper.SetDefault("fedwiki-sync-enabled", false) viper.SetDefault("fedwiki-sync-interval", time.Hour) viper.SetDefault("fedwiki-sync-default-user-id", 1) viper.SetDefault("fedwiki-sync-trigger-immediately", true) // Add the command to the root command rootCmd.AddCommand(startCmd) } // 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 }