Files
member-console/cmd/start.go
2026-04-06 03:15:20 -05:00

329 lines
14 KiB
Go

package cmd
import (
"context"
"log/slog"
"os"
"strings"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/audit"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/cooperative"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki"
"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/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
stripemod "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
wfFedwiki "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/fedwiki"
wfStripe "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/stripe"
"github.com/spf13/cobra"
"github.com/spf13/viper"
enumspb "go.temporal.io/api/enums/v1"
"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)
migrationSources := append(db.BaseSources(),
identity.MigrationSource(),
organization.MigrationSource(),
billing.MigrationSource(),
entitlements.MigrationSource(),
cooperative.MigrationSource(),
audit.MigrationSource(),
integration.MigrationSource(),
fwmod.MigrationSource(),
stripemod.MigrationSource(),
)
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()
// 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"},
{viper.GetString("stripe-api-key"), viper.GetString("stripe-api-key-file"), "stripe-api-key"},
{viper.GetString("stripe-webhook-secret"), viper.GetString("stripe-webhook-secret-file"), "stripe-webhook-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(fwmod.New(database), entitlements.New(database), 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()
// Start Stripe integration polling workflows
// Start webhook processing workflow
_, err = temporalClient.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: "stripe-webhook-processor",
TaskQueue: workerCfg.TaskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, wfStripe.ProcessStripeWebhooks, wfStripe.ProcessStripeWebhooksInput{})
if err != nil {
logger.Error("failed to start stripe webhook processor workflow", slog.Any("error", err))
// Non-fatal: continue without webhook processing
} else {
logger.Info("started stripe webhook processor workflow")
}
// Start outbox polling workflow
_, err = temporalClient.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: "stripe-outbox-poller",
TaskQueue: workerCfg.TaskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, wfStripe.PollIntegrationOutbox, wfStripe.PollIntegrationOutboxInput{})
if err != nil {
logger.Error("failed to start stripe outbox poller workflow", slog.Any("error", err))
// Non-fatal: continue without outbox polling
} else {
logger.Info("started stripe outbox poller workflow")
}
// Set up FedWiki site sync schedule if enabled
if viper.GetBool("fedwiki-sync-enabled") {
syncInterval := viper.GetDuration("fedwiki-sync-interval")
if syncInterval == 0 {
syncInterval = wfFedwiki.DefaultSyncInterval
}
scheduleManager := wfFedwiki.NewScheduleManager(temporalClient, logger)
err := scheduleManager.EnsureSyncSchedule(ctx, wfFedwiki.ScheduleConfig{
Interval: syncInterval,
DefaultWorkspaceID: viper.GetString("fedwiki-sync-default-workspace-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
stripeMode := viper.GetString("stripe-mode")
stripeDashboardURL := "https://dashboard.stripe.com"
if stripeMode == "test" {
stripeDashboardURL = "https://dashboard.stripe.com/test"
}
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),
SiteQ: fwmod.New(database),
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,
StripeWebhookSecret: viper.GetString("stripe-webhook-secret"),
StripeAPIKey: viper.GetString("stripe-api-key"),
StripeDashboardURL: stripeDashboardURL,
BaseURL: viper.GetString("base-url"),
}
// 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", "", "PostgreSQL connection string (e.g., postgres://user:pass@localhost:5432/dbname?sslmode=disable)")
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().String("fedwiki-sync-default-workspace-id", "", "Default workspace 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)")
// Stripe configuration
startCmd.Flags().String("stripe-api-key", "", "Stripe API key")
startCmd.Flags().String("stripe-api-key-file", "", "Path to file containing Stripe API key")
startCmd.Flags().String("stripe-webhook-secret", "", "Stripe webhook signing secret")
startCmd.Flags().String("stripe-webhook-secret-file", "", "Path to file containing Stripe webhook signing secret")
startCmd.Flags().String("stripe-account-id", "", "Stripe account ID (acct_...)")
startCmd.Flags().String("stripe-mode", "", "Stripe mode (test or live)")
// 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("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-workspace-id", "")
viper.SetDefault("fedwiki-sync-trigger-immediately", true)
viper.SetDefault("stripe-mode", "test")
// 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
}