diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dec9be..4bc1069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: --health-timeout 3s --health-retries 20 env: - DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_e2e_test?sslmode=disable + ASTERROUTER_SERVER_STORAGE_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_e2e_test?sslmode=disable steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 @@ -207,7 +207,7 @@ jobs: - name: First-install setup browser journey env: ASTER_SETUP_JOURNEY_DIR: ${{ runner.temp }}/asterrouter-setup-journey - DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_e2e_setup_test?sslmode=disable + ASTER_SETUP_JOURNEY_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_e2e_setup_test?sslmode=disable run: bash scripts/test-setup-browser-journey.sh - name: Upload browser evidence if: always() diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index be43c3d..21db4eb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -76,7 +76,7 @@ jobs: --health-timeout 3s --health-retries 20 env: - DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_nightly_e2e?sslmode=disable + ASTERROUTER_SERVER_STORAGE_DATABASE_URL: postgres://asterrouter:asterrouter@127.0.0.1:5432/asterrouter_nightly_e2e?sslmode=disable steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v6 diff --git a/.gitignore b/.gitignore index e26c989..404c14c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ *.test coverage.out backend/data/ +backend/config/config.yaml # Frontend frontend/dist/ diff --git a/Dockerfile b/Dockerfile index da55329..46fe177 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN npm ci COPY frontend/ ./ RUN npm run build -FROM golang:1.25-alpine AS backend +FROM golang:1.26-alpine AS backend WORKDIR /src/backend COPY backend/go.mod backend/go.sum ./ RUN go mod download @@ -25,8 +25,9 @@ WORKDIR /app RUN adduser -D -H -u 10001 asterrouter COPY --from=backend /out/asterrouter /app/asterrouter COPY --from=frontend /src/frontend/dist /app/frontend/dist -ENV ASTER_ADDR=:8080 \ - ASTER_FRONTEND_DIR=/app/frontend/dist +ENV ASTERROUTER_SERVER_HTTP_LISTEN=:8080 \ + ASTERROUTER_SERVER_HTTP_FRONTEND_DIR=/app/frontend/dist EXPOSE 8080 USER asterrouter ENTRYPOINT ["/app/asterrouter"] +CMD ["server"] diff --git a/README.md b/README.md index 244850d..6918424 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Choose one business deployment role during installation. This controls the initi AI Platform is separate from relay operations. Both can issue or accept API credentials, but a relay operator owns customer balances, plans, and risk workflows. An AI platform owns the gateway boundary for developer API Keys or delegated product access; the connected product remains the source of truth for its users, sessions, subscriptions, and orders. Enterprise owns employee and department governance; Personal owns only its workspace. These are different business roots, not four pages of one customer model. -Linux Release installation requires `--deployment` (or `ASTERROUTER_DEPLOYMENT_ROLE`); Docker and source development use `/setup`, which requires an explicit choice and does not preselect a role. Production and Docker deployments persist the selection in PostgreSQL and fix it for that instance. Source development without `DATABASE_URL` uses temporary in-memory storage, so its setup is intentionally lost when the process restarts and must not be treated as a production installation. At later PostgreSQL-backed starts, a configured role must match the persisted role or startup fails; an environment variable can never switch Enterprise to Platform, or any other role. This protects Provider, credential, usage, and audit data from being mixed across business models. Run another instance when a second model is needed. Existing multi-profile installations remain compatible but their profile configuration is frozen. Multi-profile operation for new deployments is a future migration capability, gated on end-to-end `profile_scope`, explicit Provider Sharing Binding, and tenant isolation. +Linux Release installation requires `--deployment` (or installer input `ASTERROUTER_DEPLOYMENT_ROLE`); Docker and source development use `/setup`, which requires an explicit choice and does not preselect a role. Production and Docker deployments persist the selection in PostgreSQL and fix it for that instance. Source development without `ASTERROUTER_SERVER_STORAGE_DATABASE_URL` uses temporary in-memory storage, so its setup is intentionally lost when the process restarts and must not be treated as a production installation. At later PostgreSQL-backed starts, a configured role must match the persisted role or startup fails. Run another instance when a second model is needed. ### Linux release @@ -100,7 +100,7 @@ Replace `enterprise` with `personal`, `relay_operator`, or `platform` as appropr docker compose up --build ``` -Open `http://localhost:8080/setup` to choose one deployment role and review its business boundary before completing setup. After installation, AsterRouter clears any browser session left by another deployment role and asks you to sign in again before opening the selected console. Retrying the same installation choice is safe. A system administrator can switch the active role later from Settings; the previous role is hidden but its data is retained. For non-interactive deployment, set `ASTER_DEPLOYMENT_ROLE=platform`; the older matching `ASTER_PROFILES=platform` and `ASTER_DEFAULT_PROFILE=platform` pair remains compatible as bootstrap input. +Open `http://localhost:8080/setup` to choose one deployment role and review its business boundary before completing setup. After installation, AsterRouter clears any browser session left by another deployment role and asks you to sign in again before opening the selected console. Retrying the same installation choice is safe. A system administrator can switch the active role later from Settings; the previous role is hidden but its data is retained. For non-interactive runtime deployment, set `ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE=platform`. ## Private deployment and managed delivery @@ -141,7 +141,7 @@ To expose the one-click demo entry on the sign-in page, start the isolated demo bash scripts/dev.sh --demo ``` -Demo mode enables the built-in demo account and must only be used locally or on an isolated public demo instance. Docker users can enable it with `ASTER_DEMO_MODE=true docker compose up --build`; production instances should keep it disabled. +Demo mode enables the built-in demo account and must only be used locally or on an isolated public demo instance. Docker users can enable it with `ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true docker compose up --build`; production instances should keep it disabled. Run backend tests: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7f6654e..422f1e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ AI Platform 是独立部署角色,不是中转 Customer 的一种用法。它 AI 平台与中转运营是两个独立角色。两者都可能使用 API 凭据,但中转运营管理客户余额、套餐和风控;AI 平台管理开发者 API Key 或产品委托接入的网关边界,接入产品仍然拥有自己的用户、登录会话、订阅和订单事实。企业管理员工和部门治理,个人只管理自己的 Workspace;它们是不同的业务根对象,不是同一客户模型下的四个页面。 -Linux Release 安装必须传入 `--deployment`,也可预设 `ASTERROUTER_DEPLOYMENT_ROLE`;Docker 和源码开发通过 `/setup` 显式选择,且不会默认选中任何角色。生产与 Docker 部署会把选择写入 PostgreSQL,并固定为当前实例的业务模型。源码开发未配置 `DATABASE_URL` 时使用临时内存存储,进程重启后安装状态会丢失,不能把它当成生产安装。PostgreSQL 部署后续启动时,若环境中声明的角色与已持久化角色不一致,服务会拒绝启动;环境变量不能把企业实例切换成 AI 平台或其他角色。这样可避免不同业务模型的 Provider、凭据、用量和审计数据混在一起。需要第二种业务模型时部署独立实例。已有多 Profile 实例保持兼容运行,但其 Profile 配置被冻结。面向新部署的多 Profile 仅是未来迁移能力,前提是端到端 `profile_scope`、显式 Provider Sharing Binding 和租户隔离均已落地。 +Linux Release 安装必须传入 `--deployment`,也可预设安装器输入 `ASTERROUTER_DEPLOYMENT_ROLE`;Docker 和源码开发通过 `/setup` 显式选择,且不会默认选中任何角色。生产与 Docker 部署会把选择写入 PostgreSQL,并固定为当前实例的业务模型。源码开发未配置 `ASTERROUTER_SERVER_STORAGE_DATABASE_URL` 时使用临时内存存储,进程重启后安装状态会丢失,不能把它当成生产安装。PostgreSQL 部署后续启动时,若配置角色与已持久化角色不一致,服务会拒绝启动。需要第二种业务模型时部署独立实例。 ### Linux Release @@ -100,7 +100,7 @@ curl -sSL https://raw.githubusercontent.com/astercloud/asterrouter/main/deploy/i docker compose up --build ``` -访问 `http://localhost:8080/setup`,选择一个部署角色并确认其业务边界后完成初始配置。安装完成后,AsterRouter 会清理可能属于其他部署角色的浏览器旧会话,并要求重新登录后再进入所选后台;相同角色的安装请求可以安全重试。系统管理员可以在设置中切换活动形态,旧形态会隐藏但数据会保留。非交互部署设置 `ASTER_DEPLOYMENT_ROLE=platform`;旧的匹配 `ASTER_PROFILES=platform` 与 `ASTER_DEFAULT_PROFILE=platform` 仍作为首次启动配置兼容。 +访问 `http://localhost:8080/setup`,选择一个部署角色并确认其业务边界后完成初始配置。安装完成后,AsterRouter 会清理可能属于其他部署角色的浏览器旧会话,并要求重新登录后再进入所选后台;相同角色的安装请求可以安全重试。系统管理员可以在设置中切换活动形态,旧形态会隐藏但数据会保留。非交互运行时部署设置 `ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE=platform`。 ## 私有化与托管交付 @@ -141,7 +141,7 @@ bash scripts/dev.sh bash scripts/dev.sh --demo ``` -演示模式会开放内置演示账号并在登录页显示“一键体验 Demo”,只应在本地或隔离的公开演示实例使用。Docker 可通过 `ASTER_DEMO_MODE=true docker compose up --build` 启用;生产实例应保持关闭。 +演示模式会开放内置演示账号并在登录页显示“一键体验 Demo”,只应在本地或隔离的公开演示实例使用。Docker 可通过 `ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true docker compose up --build` 启用;生产实例应保持关闭。 运行后端测试: diff --git a/backend/cmd/asterrouter/main.go b/backend/cmd/asterrouter/main.go index 1f9b236..0cf4026 100644 --- a/backend/cmd/asterrouter/main.go +++ b/backend/cmd/asterrouter/main.go @@ -2,422 +2,42 @@ package main import ( "context" - "errors" "fmt" - "log" - "net/http" + "log/slog" "os" - "os/signal" - "strings" - "syscall" - "time" - "github.com/astercloud/asterrouter/backend/internal/auth" + appserver "github.com/astercloud/asterrouter/backend/internal/appcmd/server" "github.com/astercloud/asterrouter/backend/internal/buildinfo" "github.com/astercloud/asterrouter/backend/internal/config" - "github.com/astercloud/asterrouter/backend/internal/controlplane" - operatorcore "github.com/astercloud/asterrouter/backend/internal/operator" - "github.com/astercloud/asterrouter/backend/internal/plugins" - "github.com/astercloud/asterrouter/backend/internal/server" - "github.com/astercloud/asterrouter/backend/internal/settings" - "github.com/astercloud/asterrouter/backend/internal/system" - "github.com/redis/go-redis/v9" + "github.com/urfave/cli/v3" ) func main() { - if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "version") { - fmt.Printf("asterrouter %s\ncommit: %s\nbuilt: %s\nbuild_type: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.Date, buildinfo.BuildType) + if len(os.Args) > 1 && os.Args[1] == "--version" { + printVersion() return } - - cfg := config.Load() - if err := config.ValidateRuntime(cfg); err != nil { - log.Fatalf("invalid runtime configuration: %v", err) - } - repo, storageMode, err := settings.NewRepository(context.Background(), cfg.DatabaseURL) - if err != nil { - log.Fatalf("initialize settings repository: %v", err) - } - defer repo.Close() - - controlRepo, _, err := controlplane.NewRepository(context.Background(), cfg.DatabaseURL) - if err != nil { - log.Fatalf("initialize control plane repository: %v", err) - } - defer controlRepo.Close() - operatorRepo, err := operatorcore.NewRepository(context.Background(), cfg.DatabaseURL) - if err != nil { - log.Fatalf("initialize operator repository: %v", err) - } - defer operatorRepo.Close() - pluginRepo, _, err := plugins.NewRepository(context.Background(), cfg.DatabaseURL) - if err != nil { - log.Fatalf("initialize plugin repository: %v", err) - } - defer pluginRepo.Close() - exportJobStore, err := server.NewCSVExportJobStore(context.Background(), cfg.DatabaseURL) - if err != nil { - log.Fatalf("initialize export job store: %v", err) - } - defer exportJobStore.Close() - - settingsService := settings.NewService(repo, settings.ServiceOptions{ - Version: cfg.Version, - EnabledProfiles: cfg.Profiles, - DefaultProfile: cfg.DefaultProfile, - StorageMode: storageMode, - DemoMode: cfg.DemoMode, - }) - if err := settingsService.BootstrapProfile(context.Background()); err != nil { - log.Fatalf("bootstrap profile: %v", err) - } - adminSettings, err := settingsService.Admin(context.Background()) - if err != nil { - log.Fatalf("load settings: %v", err) - } - oidcService, err := auth.NewOIDCService(auth.OIDCConfig{ - Enabled: adminSettings.OIDCEnabled, - RequireVerifiedEmail: adminSettings.OIDCRequireVerifiedEmail, - IssuerURL: adminSettings.OIDCIssuerURL, - ClientID: adminSettings.OIDCClientID, - RedirectURL: strings.TrimRight(adminSettings.PublicBaseURL, "/") + "/api/v1/auth/oidc/callback", - }) - if err != nil { - log.Fatalf("initialize oidc: %v", err) - } - if adminSettings.OIDCEnabled { - if err := oidcService.Initialize(context.Background()); err != nil { - log.Fatalf("initialize oidc provider: %v", err) - } - } - feishuSecret, err := settingsService.FeishuSecret(context.Background()) - if err != nil { - log.Fatalf("load feishu secret: %v", err) - } - feishuService, err := auth.NewFeishuService(auth.FeishuConfig{Enabled: adminSettings.FeishuEnabled, Region: adminSettings.FeishuRegion, AppID: adminSettings.FeishuAppID, AppSecret: feishuSecret, RedirectURL: strings.TrimRight(adminSettings.PublicBaseURL, "/") + "/api/v1/auth/feishu/callback"}) - if err != nil { - log.Fatalf("initialize feishu login: %v", err) - } - githubSecret, googleSecret, err := settingsService.SocialOAuthSecrets(context.Background()) - if err != nil { - log.Fatalf("load social OAuth secrets: %v", err) - } - githubOAuthService, err := auth.NewSocialOAuthService(auth.SocialOAuthConfig{Provider: "github", Enabled: adminSettings.GitHubOAuthEnabled, ClientID: adminSettings.GitHubOAuthClientID, ClientSecret: githubSecret, RedirectURL: strings.TrimRight(adminSettings.PublicBaseURL, "/") + "/api/v1/auth/oauth/github/callback"}) - if err != nil { - log.Fatalf("initialize GitHub OAuth: %v", err) - } - googleOAuthService, err := auth.NewSocialOAuthService(auth.SocialOAuthConfig{Provider: "google", Enabled: adminSettings.GoogleOAuthEnabled, ClientID: adminSettings.GoogleOAuthClientID, ClientSecret: googleSecret, RedirectURL: strings.TrimRight(adminSettings.PublicBaseURL, "/") + "/api/v1/auth/oauth/google/callback"}) - if err != nil { - log.Fatalf("initialize Google OAuth: %v", err) - } - dingTalkSecret, err := settingsService.DingTalkSecret(context.Background()) - if err != nil { - log.Fatalf("load DingTalk secret: %v", err) - } - dingTalkService, err := auth.NewDingTalkService(auth.DingTalkConfig{Enabled: adminSettings.DingTalkEnabled, ClientID: adminSettings.DingTalkClientID, ClientSecret: dingTalkSecret, RedirectURL: strings.TrimRight(adminSettings.PublicBaseURL, "/") + "/api/v1/auth/dingtalk/callback"}) - if err != nil { - log.Fatalf("initialize DingTalk login: %v", err) - } - controlService := controlplane.NewService(controlRepo, "/v1", cfg.SecretKey) - switch strings.TrimSpace(cfg.ArtifactStoreDriver) { - case "local": - artifactStore, err := controlplane.NewLocalArtifactStore(cfg.ArtifactLocalRoot) - if err != nil { - log.Fatalf("initialize local artifact store: %v", err) - } - if err := controlService.SetArtifactStore(artifactStore); err != nil { - log.Fatalf("configure local artifact store: %v", err) - } - case "s3": - artifactStore, err := controlplane.NewS3ArtifactStore(context.Background(), controlplane.S3ArtifactStoreConfig{ - Endpoint: cfg.ArtifactS3Endpoint, Region: cfg.ArtifactS3Region, Bucket: cfg.ArtifactS3Bucket, - Prefix: cfg.ArtifactS3Prefix, AccessKey: cfg.ArtifactS3AccessKey, SecretKey: cfg.ArtifactS3SecretKey, - PathStyle: cfg.ArtifactS3PathStyle, - }) - if err != nil { - log.Fatalf("initialize S3 artifact store: %v", err) - } - if err := controlService.SetArtifactStore(artifactStore); err != nil { - log.Fatalf("configure S3 artifact store: %v", err) - } - } - if err := controlService.SetAIJobAdmissionLimits(controlplane.AIJobAdmissionLimits{ - Profile: cfg.AIJobQueueProfileLimit, Tenant: cfg.AIJobQueueTenantLimit, Principal: cfg.AIJobQueuePrincipalLimit, - }); err != nil { - log.Fatalf("configure durable ai job admission: %v", err) - } - deliveryQueue, closeAIJobInfrastructure, err := configureAIJobInfrastructure(context.Background(), cfg, controlService) - if err != nil { - log.Fatalf("initialize durable ai job infrastructure: %v", err) - } - defer closeAIJobInfrastructure() - if err := controlService.EnsureSeedData(context.Background()); err != nil { - log.Fatalf("seed control plane repository: %v", err) - } - if adminSettings.DefaultProfile == controlplane.ProfileScopePlatform { - if err := controlService.EnsurePlatformBootstrap(context.Background()); err != nil { - log.Fatalf("initialize platform domain: %v", err) - } - } - authService := auth.NewService(auth.Config{ - Username: cfg.AdminUsername, - Password: cfg.AdminPassword, - LegacyAdminToken: cfg.AdminToken, - SecretKey: cfg.SecretKey, - DemoMode: cfg.DemoMode, - }) - localAdminUsername, localAdminPassword := authService.BootstrapIdentity() - localAdminDefaults := controlplane.WorkspaceUserDefaults{BalanceCents: adminSettings.DefaultBalanceCents, ConcurrencyLimit: adminSettings.DefaultConcurrency, RPMLimit: adminSettings.DefaultRPM} - localAdmin, err := controlService.EnsureLocalAdmin(context.Background(), localAdminUsername, localAdminPassword, localAdminDefaults) - if err != nil { - log.Fatalf("initialize local administrator account: %v", err) - } - authService.SetPasswordHash(localAdmin.PasswordHash) - operatorService := operatorcore.NewService(operatorRepo, controlService) - operatorService.SetRiskConfigProvider(func(ctx context.Context) (operatorcore.RiskRuntimeConfig, error) { - current, err := settingsService.Admin(ctx) - if err != nil { - return operatorcore.RiskRuntimeConfig{}, err - } - return operatorcore.RiskRuntimeConfig{ - Enabled: current.RiskControlEnabled, - AutoBlock: current.CyberSessionBlockEnabled, - BlockTimeout: time.Duration(current.CyberSessionBlockTTLSeconds) * time.Second, - }, nil - }) - controlService.SetUsageObserver(operatorService) - monitorCtx, stopChannelMonitor := context.WithCancel(context.Background()) - defer stopChannelMonitor() - go controlService.RunChannelMonitor(monitorCtx, func(ctx context.Context) (controlplane.ChannelMonitorConfig, error) { - current, err := settingsService.Admin(ctx) - if err != nil { - return controlplane.ChannelMonitorConfig{}, err - } - return controlplane.ChannelMonitorConfig{ - Enabled: current.ChannelMonitorEnabled, - Interval: time.Duration(current.ChannelMonitorIntervalSeconds) * time.Second, - }, nil - }, func(operation string, err error) { - log.Printf("channel monitor: %s: %v", operation, err) - }) - go controlService.RunCustomerNotificationScheduler(monitorCtx, func(err error) { - log.Printf("customer notification scheduler: %v", err) - }) - go controlService.RunPlatformUsageDeliveryScheduler(monitorCtx, func(err error) { - log.Printf("platform usage delivery scheduler: %v", err) - }) - go controlService.RunArtifactLifecycleScheduler(monitorCtx, 30*time.Second, 100, func(err error) { - log.Printf("artifact lifecycle scheduler: %v", err) - }) - go controlService.RunEffectivePricingDecisionMonitor(monitorCtx, time.Minute, func(err error) { - log.Printf("effective pricing decision monitor: %v", err) - }) - go controlService.RunProviderBillingSyncScheduler(monitorCtx, time.Minute, func(err error) { - log.Printf("provider billing sync scheduler: %v", err) - }) - pluginService := plugins.NewServiceWithOptions(pluginRepo, plugins.ServiceOptions{ - SecretKey: cfg.SecretKey, - OfficialCatalog: plugins.OfficialCatalogConfig{ - Mode: cfg.CatalogMode, - BootstrapURL: cfg.CatalogBootstrapURL, - URL: cfg.CatalogURL, - ServicesURL: cfg.OfficialServicesURL, - LicenseURL: cfg.LicenseURL, - RedeemURL: cfg.RedeemURL, - PublicKeyID: cfg.CatalogKeyID, - PublicKeyBase64: cfg.CatalogPublicKey, - }, - OfficialLicense: plugins.OfficialLicenseConfig{ - URL: cfg.LicenseURL, - RedeemURL: cfg.RedeemURL, - PublicKeyID: cfg.LicenseKeyID, - PublicKeyBase64: cfg.LicensePublicKey, - InstanceID: cfg.InstanceID, - Fingerprint: cfg.InstanceFingerprint, - DisplayName: cfg.InstanceDisplayName, + versionCommand := &cli.Command{ + Name: "version", + Usage: "print build version information", + Action: func(context.Context, *cli.Command) error { printVersion(); return nil }, + } + command := &cli.Command{ + Name: "asterrouter", + Usage: "AI gateway control plane", + Commands: []*cli.Command{appserver.Command, versionCommand}, + HideHelpCommand: true, + Action: func(_ context.Context, command *cli.Command) error { + return cli.ShowSubcommandHelp(command) }, - PackageCacheDir: cfg.PluginCacheDir, - PluginActiveDir: cfg.PluginActiveDir, - PluginHostURL: cfg.PluginHostURL, - CoreVersion: cfg.Version, - ArtifactSinkRegistry: controlService, - }) - if err := pluginService.EnsureSeedData(context.Background()); err != nil { - log.Fatalf("seed plugin repository: %v", err) - } - if err := pluginService.StartEnabledSidecars(context.Background()); err != nil { - log.Fatalf("start enabled plugin sidecars: %v", err) - } - if err := pluginService.StartEnabledArtifactSinks(context.Background()); err != nil { - log.Fatalf("start enabled artifact sink plugins: %v", err) - } - durableJobRuntime, err := controlplane.NewDurableAIJobRuntime(controlService, deliveryQueue, pluginService, controlplane.DurableAIJobRuntimeConfig{}) - if err != nil { - log.Fatalf("initialize durable ai job runtime: %v", err) - } - durableJobRuntimeDone := make(chan error, 1) - go func() { - durableJobRuntimeDone <- durableJobRuntime.Run(monitorCtx, func(component string, err error) { - log.Printf("durable ai job runtime: %s: %v", component, err) - }) - }() - officialCatalogURL := "" - officialCatalogKeyID := "" - officialCatalogPublicKey := "" - if cfg.CatalogMode == "online" { - officialCatalogURL = cfg.CatalogURL - if officialCatalogURL == "" { - officialCatalogURL = cfg.CatalogBootstrapURL - } - officialCatalogKeyID = cfg.CatalogKeyID - officialCatalogPublicKey = cfg.CatalogPublicKey - } - systemService := system.NewService(system.Config{ - Version: cfg.Version, - BuildType: cfg.BuildType, - ManifestURL: cfg.UpdateManifestURL, - OfficialCatalogURL: officialCatalogURL, - OfficialKeyID: officialCatalogKeyID, - OfficialPublicKey: officialCatalogPublicKey, - AllowRestart: cfg.AllowRestart, - DatabaseURL: cfg.DatabaseURL, - PluginCacheDir: cfg.PluginCacheDir, - PluginActiveDir: cfg.PluginActiveDir, - BackupDir: cfg.BackupDir, - DiagnosticDir: cfg.DiagnosticDir, - MaxArchiveBytes: cfg.MaxArchiveBytes, - }) - go server.RunBackupScheduler(monitorCtx, systemService, settingsService, controlService, func(err error) { - log.Printf("backup scheduler: %v", err) - }) - - router := server.New(server.Options{ - Config: cfg, - AuthService: authService, - OIDCService: oidcService, - FeishuService: feishuService, - GitHubOAuthService: githubOAuthService, - GoogleOAuthService: googleOAuthService, - DingTalkService: dingTalkService, - SettingsService: settingsService, - ControlService: controlService, - OperatorService: operatorService, - PluginService: pluginService, - SystemService: systemService, - ExportJobStore: exportJobStore, - DurableAIJobs: durableJobRuntime, - AIJobRuntime: durableJobRuntime, - }) - - httpServer := &http.Server{ - Addr: cfg.Addr, - Handler: router, - ReadHeaderTimeout: 10 * time.Second, - } - - go func() { - log.Printf("AsterRouter listening on %s (storage=%s)", cfg.Addr, storageMode) - if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("server failed: %v", err) - } - }() - - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - stopChannelMonitor() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := httpServer.Shutdown(ctx); err != nil { - log.Printf("server shutdown failed: %v", err) - } - select { - case err := <-durableJobRuntimeDone: - if err != nil { - log.Printf("durable ai job runtime shutdown failed: %v", err) - } - case <-ctx.Done(): - log.Printf("durable ai job runtime shutdown timed out: %v", ctx.Err()) } - if err := pluginService.Shutdown(context.Background()); err != nil { - log.Printf("plugin shutdown failed: %v", err) + config.Manager.MustConfigure(command) + if err := command.Run(context.Background(), os.Args); err != nil { + slog.Error("command failed", "error", err) + os.Exit(1) } } -func configureAIJobInfrastructure(ctx context.Context, cfg config.Config, service *controlplane.Service) (controlplane.AIJobDeliveryQueue, func(), error) { - queueDriver := strings.TrimSpace(cfg.AIJobQueueDriver) - if queueDriver == "" { - queueDriver = "memory" - } - affinityDriver := strings.TrimSpace(cfg.RoutingAffinityDriver) - if affinityDriver == "" { - affinityDriver = "repository" - } - if queueDriver != "memory" && queueDriver != "redis" { - return nil, func() {}, fmt.Errorf("unsupported durable ai job queue driver %q", queueDriver) - } - if affinityDriver != "repository" && affinityDriver != "redis" { - return nil, func() {}, fmt.Errorf("unsupported routing affinity driver %q", affinityDriver) - } - if queueDriver != "redis" && affinityDriver != "redis" { - queue, err := controlplane.NewMemoryAIJobDeliveryQueue(30 * time.Second) - if err != nil { - return nil, func() {}, err - } - service.SetAIJobReadyIndex(controlplane.NewMemoryAIJobReadyIndex()) - return queue, func() {}, nil - } - options, err := redis.ParseURL(strings.TrimSpace(cfg.RedisURL)) - if err != nil { - return nil, func() {}, fmt.Errorf("parse Redis URL: %w", err) - } - client := redis.NewClient(options) - closeClient := func() { _ = client.Close() } - pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := client.Ping(pingCtx).Err(); err != nil { - closeClient() - return nil, func() {}, fmt.Errorf("connect to Redis: %w", err) - } - namespace := strings.TrimSpace(cfg.RedisNamespace) - if namespace == "" { - namespace = "asterrouter" - } - var queue controlplane.AIJobDeliveryQueue - if queueDriver == "redis" { - queue, err = controlplane.NewRedisAIJobDeliveryQueue(client, controlplane.RedisAIJobDeliveryQueueConfig{Namespace: namespace}) - if err != nil { - closeClient() - return nil, func() {}, err - } - readyIndex, readyErr := controlplane.NewRedisAIJobReadyIndex(client, controlplane.RedisAIJobReadyIndexConfig{Namespace: namespace}) - if readyErr != nil { - closeClient() - return nil, func() {}, readyErr - } - capacityStore, capacityErr := controlplane.NewRedisProviderCapacityStore(client, controlplane.RedisProviderCapacityStoreConfig{Namespace: namespace}) - if capacityErr != nil { - closeClient() - return nil, func() {}, capacityErr - } - service.SetAIJobReadyIndex(readyIndex) - service.SetProviderCapacityStore(capacityStore) - } else { - queue, err = controlplane.NewMemoryAIJobDeliveryQueue(30 * time.Second) - if err != nil { - closeClient() - return nil, func() {}, err - } - service.SetAIJobReadyIndex(controlplane.NewMemoryAIJobReadyIndex()) - } - if affinityDriver == "redis" { - coordinator, coordinatorErr := controlplane.NewRedisRoutingAffinityCoordinator(client, controlplane.RedisRoutingAffinityCoordinatorConfig{Namespace: namespace}) - if coordinatorErr != nil { - closeClient() - return nil, func() {}, coordinatorErr - } - service.SetRoutingAffinityCoordinator(coordinator) - } - return queue, closeClient, nil +func printVersion() { + fmt.Printf("asterrouter %s\ncommit: %s\nbuilt: %s\nbuild_type: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.Date, buildinfo.BuildType) } diff --git a/backend/config/config.example.yaml b/backend/config/config.example.yaml new file mode 100644 index 0000000..5eec62e --- /dev/null +++ b/backend/config/config.example.yaml @@ -0,0 +1,102 @@ +# 默认配置示例文件, 此文件由单元测试生成, 请勿直接修改 +# 复制此文件为 config.yaml 并根据需要修改 + +# AsterRouter service configuration +server: + + # HTTP service configuration + http: + listen: ":8080" # HTTP listen address + frontend-dir: "../frontend/dist" # Built frontend asset directory + + # First-run bootstrap configuration + bootstrap: + deployment-role: "" # Optional unattended deployment role: personal, relay_operator, enterprise, or platform + demo-mode: false # Enable the isolated demonstration login flow + + # Authentication and encryption configuration + security: + + # Bootstrap administrator credentials + admin: + username: "admin" # Bootstrap administrator username + password: "" # Bootstrap administrator password + token: "" # Legacy administrator bearer token + secret-key: "" # Stable application encryption and signing secret + + # Persistent storage and Redis configuration + storage: + database-url: "" # PostgreSQL connection URL; empty uses in-memory storage for source development + + # Shared Redis configuration + redis: + url: "" # Redis connection URL + namespace: "asterrouter" # Redis key namespace + + # Official catalog and license services + official: + update-manifest-url: "" # Release update manifest URL + + # Official plugin catalog configuration + catalog: + mode: "disabled" # Catalog mode: disabled, online, private_mirror, or offline + bootstrap-url: "" # Signed catalog bootstrap URL + url: "" # Catalog index URL + services-url: "" # Official services base URL + key-id: "" # Catalog signature key ID + public-key: "" # Catalog signature public key + + # Official license service configuration + license: + url: "" # License service URL + redeem-url: "" # License redemption URL + key-id: "" # License signature key ID + public-key: "" # License signature public key + + # Stable installation identity + instance: + id: "" # Stable installation ID + fingerprint: "" # Installation fingerprint + display-name: "" # Installation display name + + # Plugin runtime configuration + plugins: + cache-dir: "data/plugin-cache" # Downloaded plugin package cache + active-dir: "" # Activated plugin runtime directory; derived from cache-dir when empty + host-url: "" # Plugin host callback URL; derived from http.listen when empty + + # Durable AI job infrastructure + jobs: + + # Durable AI job delivery queue + queue: + driver: "memory" # Queue driver: memory or redis + + # Concurrent queued job admission limits; zero disables a limit + limits: + profile: 0 # Maximum queued jobs per profile + tenant: 0 # Maximum queued jobs per tenant + principal: 0 # Maximum queued jobs per principal + routing-affinity-driver: "repository" # Routing affinity driver: repository or redis + + # Artifact storage configuration + artifacts: + driver: "none" # Artifact store driver: none, local, or s3 + local-root: "data/artifacts" # Local artifact root directory + + # S3-compatible artifact store + s3: + endpoint: "" # S3-compatible endpoint URL + region: "auto" # S3 region + bucket: "" # S3 bucket + prefix: "" # S3 object key prefix + access-key: "" # S3 access key + secret-key: "" # S3 secret key + path-style: false # Use path-style S3 addressing + + # Backup, diagnostics, and process management + maintenance: + backup-dir: "data/backups" # Local backup directory + diagnostic-dir: "data/diagnostics" # Diagnostic bundle directory + max-archive-bytes: 2147483648 # Maximum backup or diagnostic archive size + allow-restart: false # Allow managed process restart operations diff --git a/backend/go.mod b/backend/go.mod index 126b924..146197c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/astercloud/asterrouter/backend -go 1.25.0 +go 1.26.1 require ( github.com/aws/aws-sdk-go-v2 v1.42.1 @@ -13,7 +13,9 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/gowebpki/jcs v1.0.1 github.com/lib/pq v1.12.3 + github.com/lwmacct/251207-go-pkg-cfgm v0.33.260716 github.com/redis/go-redis/v9 v9.21.0 + github.com/urfave/cli/v3 v3.10.1 golang.org/x/crypto v0.54.0 golang.org/x/oauth2 v0.36.0 ) @@ -43,6 +45,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -58,6 +61,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.29.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7d14ab6..44c7140 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -71,6 +71,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -84,10 +86,16 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lwmacct/251207-go-pkg-cfgm v0.33.260716 h1:9xir7y3iUCyzA1k6OFzkn3/Be/VRBYCG3KUcohzB7+k= +github.com/lwmacct/251207-go-pkg-cfgm v0.33.260716/go.mod h1:PnQgCni1FfdfF2kudO6BqHd990LYxmk5Sc32/n2mvVc= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -107,6 +115,8 @@ github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+ github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -123,6 +133,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= @@ -131,6 +143,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= @@ -146,6 +160,8 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/appcmd/server/app.go b/backend/internal/appcmd/server/app.go new file mode 100644 index 0000000..4777bdf --- /dev/null +++ b/backend/internal/appcmd/server/app.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/astercloud/asterrouter/backend/internal/buildinfo" + "github.com/astercloud/asterrouter/backend/internal/config" +) + +type App struct { + cfg *config.Server +} + +func NewApp(cfg *config.Server) *App { + return &App{cfg: cfg} +} + +func (app *App) Run(ctx context.Context) error { + if app == nil || app.cfg == nil { + return config.ErrInvalidHTTP + } + validated, err := config.Validate(*app.cfg, buildinfo.BuildType) + if err != nil { + return err + } + if validated.Security.SecretKey == "" { + validated.Security.SecretKey, err = ephemeralSecret() + if err != nil { + return fmt.Errorf("generate ephemeral development secret: %w", err) + } + slog.Warn("using an ephemeral development secret; sessions and encrypted data will not survive restart") + } + *app.cfg = validated + + rt, err := newRuntime(ctx, app.cfg) + if err != nil { + return err + } + defer func() { _ = rt.Close(context.Background()) }() + + srv := newHTTPServer(app.cfg, rt) + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", srv.Addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", srv.Addr, err) + } + defer func() { _ = listener.Close() }() + + rt.Start(ctx) + serveErr := make(chan error, 1) + go func() { + slog.Info("AsterRouter service starting", "listen", srv.Addr, "storage", rt.storageMode) + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + }() + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signalCh) + + select { + case <-ctx.Done(): + case received := <-signalCh: + slog.Info("received shutdown signal", "signal", received.String()) + case err := <-serveErr: + return errors.Join(fmt.Errorf("serve HTTP: %w", err), shutdown(ctx, srv, rt)) + case err := <-rt.Errors(): + return errors.Join(fmt.Errorf("runtime stopped: %w", err), shutdown(ctx, srv, rt)) + } + return shutdown(ctx, srv, rt) +} + +func shutdown(ctx context.Context, srv *http.Server, rt *runtime) error { + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + var errs []error + if srv != nil { + if err := srv.Shutdown(shutdownCtx); err != nil { + errs = append(errs, err) + } + } + if rt != nil { + if err := rt.Close(shutdownCtx); err != nil { + errs = append(errs, err) + } + } + slog.Info("AsterRouter service stopped") + return errors.Join(errs...) +} + +func ephemeralSecret() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} diff --git a/backend/internal/appcmd/server/command.go b/backend/internal/appcmd/server/command.go new file mode 100644 index 0000000..d76e087 --- /dev/null +++ b/backend/internal/appcmd/server/command.go @@ -0,0 +1,19 @@ +package server + +import ( + "context" + + "github.com/astercloud/asterrouter/backend/internal/config" + "github.com/urfave/cli/v3" +) + +var Command = &cli.Command{ + Name: "server", + Usage: "start AsterRouter", + Action: config.Manager.Action(action), + HideHelpCommand: true, +} + +func action(ctx context.Context, _ *cli.Command, cfg *config.Config) error { + return NewApp(&cfg.Server).Run(ctx) +} diff --git a/backend/internal/appcmd/server/command_test.go b/backend/internal/appcmd/server/command_test.go new file mode 100644 index 0000000..2463d3a --- /dev/null +++ b/backend/internal/appcmd/server/command_test.go @@ -0,0 +1,25 @@ +package server + +import ( + "testing" +) + +func TestNewAppRejectsNilConfiguration(t *testing.T) { + if err := NewApp(nil).Run(t.Context()); err == nil { + t.Fatal("NewApp(nil).Run() accepted a nil configuration") + } +} + +func TestEphemeralSecretIsRandomAndNonEmpty(t *testing.T) { + first, err := ephemeralSecret() + if err != nil { + t.Fatal(err) + } + second, err := ephemeralSecret() + if err != nil { + t.Fatal(err) + } + if len(first) < 40 || len(second) < 40 || first == second { + t.Fatalf("unexpected ephemeral secrets: lengths %d/%d equal=%t", len(first), len(second), first == second) + } +} diff --git a/backend/internal/appcmd/server/http.go b/backend/internal/appcmd/server/http.go new file mode 100644 index 0000000..1916897 --- /dev/null +++ b/backend/internal/appcmd/server/http.go @@ -0,0 +1,16 @@ +package server + +import ( + "net/http" + "time" + + "github.com/astercloud/asterrouter/backend/internal/config" +) + +func newHTTPServer(cfg *config.Server, rt *runtime) *http.Server { + return &http.Server{ + Addr: cfg.HTTP.Listen, + Handler: rt.handler, + ReadHeaderTimeout: 10 * time.Second, + } +} diff --git a/backend/internal/appcmd/server/identity.go b/backend/internal/appcmd/server/identity.go new file mode 100644 index 0000000..ef46cd6 --- /dev/null +++ b/backend/internal/appcmd/server/identity.go @@ -0,0 +1,68 @@ +package server + +import ( + "context" + "fmt" + "strings" + + "github.com/astercloud/asterrouter/backend/internal/auth" + "github.com/astercloud/asterrouter/backend/internal/settings" +) + +func newIdentityServices(ctx context.Context, service *settings.Service, current settings.AdminSettings) (*auth.OIDCService, *auth.FeishuService, *auth.SocialOAuthService, *auth.SocialOAuthService, *auth.DingTalkService, error) { + baseURL := strings.TrimRight(current.PublicBaseURL, "/") + oidcService, err := auth.NewOIDCService(auth.OIDCConfig{ + Enabled: current.OIDCEnabled, RequireVerifiedEmail: current.OIDCRequireVerifiedEmail, + IssuerURL: current.OIDCIssuerURL, ClientID: current.OIDCClientID, + RedirectURL: baseURL + "/api/v1/auth/oidc/callback", + }) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize OIDC: %w", err) + } + if current.OIDCEnabled { + if err = oidcService.Initialize(ctx); err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize OIDC provider: %w", err) + } + } + feishuSecret, err := service.FeishuSecret(ctx) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("load Feishu secret: %w", err) + } + feishuService, err := auth.NewFeishuService(auth.FeishuConfig{ + Enabled: current.FeishuEnabled, Region: current.FeishuRegion, AppID: current.FeishuAppID, + AppSecret: feishuSecret, RedirectURL: baseURL + "/api/v1/auth/feishu/callback", + }) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize Feishu login: %w", err) + } + githubSecret, googleSecret, err := service.SocialOAuthSecrets(ctx) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("load social OAuth secrets: %w", err) + } + githubService, err := auth.NewSocialOAuthService(auth.SocialOAuthConfig{ + Provider: "github", Enabled: current.GitHubOAuthEnabled, ClientID: current.GitHubOAuthClientID, + ClientSecret: githubSecret, RedirectURL: baseURL + "/api/v1/auth/oauth/github/callback", + }) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize GitHub OAuth: %w", err) + } + googleService, err := auth.NewSocialOAuthService(auth.SocialOAuthConfig{ + Provider: "google", Enabled: current.GoogleOAuthEnabled, ClientID: current.GoogleOAuthClientID, + ClientSecret: googleSecret, RedirectURL: baseURL + "/api/v1/auth/oauth/google/callback", + }) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize Google OAuth: %w", err) + } + dingTalkSecret, err := service.DingTalkSecret(ctx) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("load DingTalk secret: %w", err) + } + dingTalkService, err := auth.NewDingTalkService(auth.DingTalkConfig{ + Enabled: current.DingTalkEnabled, ClientID: current.DingTalkClientID, + ClientSecret: dingTalkSecret, RedirectURL: baseURL + "/api/v1/auth/dingtalk/callback", + }) + if err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("initialize DingTalk login: %w", err) + } + return oidcService, feishuService, githubService, googleService, dingTalkService, nil +} diff --git a/backend/internal/appcmd/server/infrastructure.go b/backend/internal/appcmd/server/infrastructure.go new file mode 100644 index 0000000..a4e0747 --- /dev/null +++ b/backend/internal/appcmd/server/infrastructure.go @@ -0,0 +1,104 @@ +package server + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/astercloud/asterrouter/backend/internal/config" + "github.com/astercloud/asterrouter/backend/internal/controlplane" + "github.com/redis/go-redis/v9" +) + +func configureArtifactStore(ctx context.Context, cfg config.Artifacts, service *controlplane.Service) error { + switch cfg.Driver { + case "none": + return nil + case "local": + store, err := controlplane.NewLocalArtifactStore(cfg.LocalRoot) + if err != nil { + return fmt.Errorf("initialize local artifact store: %w", err) + } + if err = service.SetArtifactStore(store); err != nil { + return fmt.Errorf("configure local artifact store: %w", err) + } + return nil + case "s3": + store, err := controlplane.NewS3ArtifactStore(ctx, controlplane.S3ArtifactStoreConfig{ + Endpoint: cfg.S3.Endpoint, Region: cfg.S3.Region, Bucket: cfg.S3.Bucket, + Prefix: cfg.S3.Prefix, AccessKey: cfg.S3.AccessKey, SecretKey: cfg.S3.SecretKey, PathStyle: cfg.S3.PathStyle, + }) + if err != nil { + return fmt.Errorf("initialize S3 artifact store: %w", err) + } + if err = service.SetArtifactStore(store); err != nil { + return fmt.Errorf("configure S3 artifact store: %w", err) + } + return nil + default: + return fmt.Errorf("unsupported artifact store driver %q", cfg.Driver) + } +} + +func configureAIJobInfrastructure(ctx context.Context, jobs config.Jobs, redisConfig config.Redis, service *controlplane.Service) (controlplane.AIJobDeliveryQueue, func(), error) { + queueDriver := strings.TrimSpace(jobs.Queue.Driver) + affinityDriver := strings.TrimSpace(jobs.RoutingAffinityDriver) + if queueDriver != "redis" && affinityDriver != "redis" { + queue, err := controlplane.NewMemoryAIJobDeliveryQueue(30 * time.Second) + if err != nil { + return nil, func() {}, err + } + service.SetAIJobReadyIndex(controlplane.NewMemoryAIJobReadyIndex()) + return queue, func() {}, nil + } + options, err := redis.ParseURL(strings.TrimSpace(redisConfig.URL)) + if err != nil { + return nil, func() {}, fmt.Errorf("parse Redis URL: %w", err) + } + client := redis.NewClient(options) + closeClient := func() { _ = client.Close() } + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err = client.Ping(pingCtx).Err(); err != nil { + closeClient() + return nil, func() {}, fmt.Errorf("connect to Redis: %w", err) + } + namespace := strings.TrimSpace(redisConfig.Namespace) + var queue controlplane.AIJobDeliveryQueue + if queueDriver == "redis" { + queue, err = controlplane.NewRedisAIJobDeliveryQueue(client, controlplane.RedisAIJobDeliveryQueueConfig{Namespace: namespace}) + if err != nil { + closeClient() + return nil, func() {}, err + } + readyIndex, readyErr := controlplane.NewRedisAIJobReadyIndex(client, controlplane.RedisAIJobReadyIndexConfig{Namespace: namespace}) + if readyErr != nil { + closeClient() + return nil, func() {}, readyErr + } + capacityStore, capacityErr := controlplane.NewRedisProviderCapacityStore(client, controlplane.RedisProviderCapacityStoreConfig{Namespace: namespace}) + if capacityErr != nil { + closeClient() + return nil, func() {}, capacityErr + } + service.SetAIJobReadyIndex(readyIndex) + service.SetProviderCapacityStore(capacityStore) + } else { + queue, err = controlplane.NewMemoryAIJobDeliveryQueue(30 * time.Second) + if err != nil { + closeClient() + return nil, func() {}, err + } + service.SetAIJobReadyIndex(controlplane.NewMemoryAIJobReadyIndex()) + } + if affinityDriver == "redis" { + coordinator, coordinatorErr := controlplane.NewRedisRoutingAffinityCoordinator(client, controlplane.RedisRoutingAffinityCoordinatorConfig{Namespace: namespace}) + if coordinatorErr != nil { + closeClient() + return nil, func() {}, coordinatorErr + } + service.SetRoutingAffinityCoordinator(coordinator) + } + return queue, closeClient, nil +} diff --git a/backend/cmd/asterrouter/main_test.go b/backend/internal/appcmd/server/infrastructure_test.go similarity index 63% rename from backend/cmd/asterrouter/main_test.go rename to backend/internal/appcmd/server/infrastructure_test.go index 8e06bf4..73dffed 100644 --- a/backend/cmd/asterrouter/main_test.go +++ b/backend/internal/appcmd/server/infrastructure_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" @@ -20,9 +20,9 @@ func TestConfigureAIJobInfrastructureSupportsRedisAffinityWithMemoryQueue(t *tes } namespace := fmt.Sprintf("affinity-wiring-%d", time.Now().UnixNano()) service := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1", "affinity-wiring-secret") - queue, closeInfrastructure, err := configureAIJobInfrastructure(context.Background(), config.Config{ - AIJobQueueDriver: "memory", RoutingAffinityDriver: "redis", RedisURL: rawURL, RedisNamespace: namespace, - }, service) + queue, closeInfrastructure, err := configureAIJobInfrastructure(t.Context(), config.Jobs{ + Queue: config.JobQueue{Driver: "memory"}, RoutingAffinityDriver: "redis", + }, config.Redis{URL: rawURL, Namespace: namespace}, service) if err != nil { t.Fatalf("configureAIJobInfrastructure(): %v", err) } @@ -34,7 +34,7 @@ func TestConfigureAIJobInfrastructureSupportsRedisAffinityWithMemoryQueue(t *tes TenantID: "tenant", PrincipalID: "principal", CredentialID: "credential", Model: "model", Protocol: "openai_chat_completions", RouteGroup: "default", PolicyVersion: 1, } - if err := service.BindGatewayCandidateAffinity(context.Background(), input, controlplane.GatewayProvider{ID: "provider-a"}); err != nil { + if err = service.BindGatewayCandidateAffinity(t.Context(), input, controlplane.GatewayProvider{ID: "provider-a"}); err != nil { t.Fatal(err) } options, err := redis.ParseURL(rawURL) @@ -44,7 +44,7 @@ func TestConfigureAIJobInfrastructureSupportsRedisAffinityWithMemoryQueue(t *tes client := redis.NewClient(options) t.Cleanup(func() { _ = client.Close() }) pattern := "asterrouter:{" + namespace + ":routing_affinity}:*" - keys, err := client.Keys(context.Background(), pattern).Result() + keys, err := client.Keys(t.Context(), pattern).Result() if err != nil || len(keys) != 1 { t.Fatalf("routing affinity keys=%v err=%v", keys, err) } @@ -54,13 +54,3 @@ func TestConfigureAIJobInfrastructureSupportsRedisAffinityWithMemoryQueue(t *tes _ = client.Del(cleanupCtx, keys...).Err() }) } - -func TestConfigureAIJobInfrastructureRejectsUnknownDrivers(t *testing.T) { - service := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") - if _, _, err := configureAIJobInfrastructure(context.Background(), config.Config{AIJobQueueDriver: "unknown"}, service); err == nil { - t.Fatal("unknown AI Job queue driver was accepted") - } - if _, _, err := configureAIJobInfrastructure(context.Background(), config.Config{RoutingAffinityDriver: "unknown"}, service); err == nil { - t.Fatal("unknown routing affinity driver was accepted") - } -} diff --git a/backend/internal/appcmd/server/runtime.go b/backend/internal/appcmd/server/runtime.go new file mode 100644 index 0000000..6de67ed --- /dev/null +++ b/backend/internal/appcmd/server/runtime.go @@ -0,0 +1,351 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/astercloud/asterrouter/backend/internal/auth" + "github.com/astercloud/asterrouter/backend/internal/buildinfo" + "github.com/astercloud/asterrouter/backend/internal/config" + "github.com/astercloud/asterrouter/backend/internal/controlplane" + operatorcore "github.com/astercloud/asterrouter/backend/internal/operator" + "github.com/astercloud/asterrouter/backend/internal/plugins" + httpserver "github.com/astercloud/asterrouter/backend/internal/server" + "github.com/astercloud/asterrouter/backend/internal/settings" + "github.com/astercloud/asterrouter/backend/internal/system" +) + +type runtime struct { + storageMode string + handler http.Handler + + settingsRepo settings.Repository + controlRepo controlplane.Repository + operatorRepo operatorcore.Repository + pluginRepo plugins.Repository + exportStore httpserver.CSVExportJobStore + + settingsService *settings.Service + controlService *controlplane.Service + pluginService *plugins.Service + systemService *system.Service + durableJobs *controlplane.DurableAIJobRuntime + + closeInfrastructure func() + cancel context.CancelFunc + backgroundErrors chan error + waitGroup sync.WaitGroup + closeOnce sync.Once + closeErr error +} + +func newRuntime(ctx context.Context, cfg *config.Server) (_ *runtime, err error) { + rt := &runtime{closeInfrastructure: func() {}, backgroundErrors: make(chan error, 1)} + defer func() { + if err != nil { + _ = rt.Close(context.Background()) + } + }() + + if rt.settingsRepo, rt.storageMode, err = settings.NewRepository(ctx, cfg.Storage.DatabaseURL); err != nil { + return nil, fmt.Errorf("initialize settings repository: %w", err) + } + if rt.controlRepo, _, err = controlplane.NewRepository(ctx, cfg.Storage.DatabaseURL); err != nil { + return nil, fmt.Errorf("initialize control plane repository: %w", err) + } + if rt.operatorRepo, err = operatorcore.NewRepository(ctx, cfg.Storage.DatabaseURL); err != nil { + return nil, fmt.Errorf("initialize operator repository: %w", err) + } + if rt.pluginRepo, _, err = plugins.NewRepository(ctx, cfg.Storage.DatabaseURL); err != nil { + return nil, fmt.Errorf("initialize plugin repository: %w", err) + } + if rt.exportStore, err = httpserver.NewCSVExportJobStore(ctx, cfg.Storage.DatabaseURL); err != nil { + return nil, fmt.Errorf("initialize export job store: %w", err) + } + + profiles := []string(nil) + defaultProfile := "" + if cfg.Bootstrap.DeploymentRole != "" { + profiles = []string{cfg.Bootstrap.DeploymentRole} + defaultProfile = cfg.Bootstrap.DeploymentRole + } + rt.settingsService = settings.NewService(rt.settingsRepo, settings.ServiceOptions{ + Version: buildinfo.Version, EnabledProfiles: profiles, DefaultProfile: defaultProfile, + StorageMode: rt.storageMode, DemoMode: cfg.Bootstrap.DemoMode, + }) + if err = rt.settingsService.BootstrapProfile(ctx); err != nil { + return nil, fmt.Errorf("bootstrap profile: %w", err) + } + adminSettings, err := rt.settingsService.Admin(ctx) + if err != nil { + return nil, fmt.Errorf("load settings: %w", err) + } + + oidcService, feishuService, githubOAuthService, googleOAuthService, dingTalkService, err := newIdentityServices(ctx, rt.settingsService, adminSettings) + if err != nil { + return nil, err + } + + rt.controlService = controlplane.NewService(rt.controlRepo, "/v1", cfg.Security.SecretKey) + if err = configureArtifactStore(ctx, cfg.Artifacts, rt.controlService); err != nil { + return nil, err + } + if err = rt.controlService.SetAIJobAdmissionLimits(controlplane.AIJobAdmissionLimits{ + Profile: cfg.Jobs.Queue.Limits.Profile, Tenant: cfg.Jobs.Queue.Limits.Tenant, Principal: cfg.Jobs.Queue.Limits.Principal, + }); err != nil { + return nil, fmt.Errorf("configure durable AI job admission: %w", err) + } + var deliveryQueue controlplane.AIJobDeliveryQueue + deliveryQueue, rt.closeInfrastructure, err = configureAIJobInfrastructure(ctx, cfg.Jobs, cfg.Storage.Redis, rt.controlService) + if err != nil { + return nil, fmt.Errorf("initialize durable AI job infrastructure: %w", err) + } + if err = rt.controlService.EnsureSeedData(ctx); err != nil { + return nil, fmt.Errorf("seed control plane repository: %w", err) + } + if adminSettings.DefaultProfile == controlplane.ProfileScopePlatform { + if err = rt.controlService.EnsurePlatformBootstrap(ctx); err != nil { + return nil, fmt.Errorf("initialize platform domain: %w", err) + } + } + + authService := auth.NewService(auth.Config{ + Username: cfg.Security.Admin.Username, Password: cfg.Security.Admin.Password, + LegacyAdminToken: cfg.Security.Admin.Token, SecretKey: cfg.Security.SecretKey, DemoMode: cfg.Bootstrap.DemoMode, + }) + localAdminUsername, localAdminPassword := authService.BootstrapIdentity() + localAdminDefaults := controlplane.WorkspaceUserDefaults{ + BalanceCents: adminSettings.DefaultBalanceCents, ConcurrencyLimit: adminSettings.DefaultConcurrency, RPMLimit: adminSettings.DefaultRPM, + } + localAdmin, err := rt.controlService.EnsureLocalAdmin(ctx, localAdminUsername, localAdminPassword, localAdminDefaults) + if err != nil { + return nil, fmt.Errorf("initialize local administrator account: %w", err) + } + authService.SetPasswordHash(localAdmin.PasswordHash) + + operatorService := operatorcore.NewService(rt.operatorRepo, rt.controlService) + operatorService.SetRiskConfigProvider(func(ctx context.Context) (operatorcore.RiskRuntimeConfig, error) { + current, err := rt.settingsService.Admin(ctx) + if err != nil { + return operatorcore.RiskRuntimeConfig{}, err + } + return operatorcore.RiskRuntimeConfig{ + Enabled: current.RiskControlEnabled, AutoBlock: current.CyberSessionBlockEnabled, + BlockTimeout: time.Duration(current.CyberSessionBlockTTLSeconds) * time.Second, + }, nil + }) + rt.controlService.SetUsageObserver(operatorService) + + rt.pluginService = plugins.NewServiceWithOptions(rt.pluginRepo, plugins.ServiceOptions{ + SecretKey: cfg.Security.SecretKey, + OfficialCatalog: plugins.OfficialCatalogConfig{ + Mode: cfg.Official.Catalog.Mode, BootstrapURL: cfg.Official.Catalog.BootstrapURL, + URL: cfg.Official.Catalog.URL, ServicesURL: cfg.Official.Catalog.ServicesURL, + LicenseURL: cfg.Official.License.URL, RedeemURL: cfg.Official.License.RedeemURL, + PublicKeyID: cfg.Official.Catalog.KeyID, PublicKeyBase64: cfg.Official.Catalog.PublicKey, + }, + OfficialLicense: plugins.OfficialLicenseConfig{ + URL: cfg.Official.License.URL, RedeemURL: cfg.Official.License.RedeemURL, + PublicKeyID: cfg.Official.License.KeyID, PublicKeyBase64: cfg.Official.License.PublicKey, + InstanceID: cfg.Official.Instance.ID, Fingerprint: cfg.Official.Instance.Fingerprint, + DisplayName: cfg.Official.Instance.DisplayName, + }, + PackageCacheDir: cfg.Plugins.CacheDir, PluginActiveDir: cfg.Plugins.ActiveDir, + PluginHostURL: cfg.Plugins.HostURL, CoreVersion: buildinfo.Version, ArtifactSinkRegistry: rt.controlService, + }) + if err = rt.pluginService.EnsureSeedData(ctx); err != nil { + return nil, fmt.Errorf("seed plugin repository: %w", err) + } + if err = rt.pluginService.StartEnabledSidecars(ctx); err != nil { + return nil, fmt.Errorf("start enabled plugin sidecars: %w", err) + } + if err = rt.pluginService.StartEnabledArtifactSinks(ctx); err != nil { + return nil, fmt.Errorf("start enabled artifact sinks: %w", err) + } + if rt.durableJobs, err = controlplane.NewDurableAIJobRuntime(rt.controlService, deliveryQueue, rt.pluginService, controlplane.DurableAIJobRuntimeConfig{}); err != nil { + return nil, fmt.Errorf("initialize durable AI job runtime: %w", err) + } + + officialCatalogURL, officialCatalogKeyID, officialCatalogPublicKey := systemCatalog(cfg.Official.Catalog) + rt.systemService = system.NewService(system.Config{ + Version: buildinfo.Version, BuildType: buildinfo.BuildType, ManifestURL: cfg.Official.UpdateManifestURL, + OfficialCatalogURL: officialCatalogURL, OfficialKeyID: officialCatalogKeyID, OfficialPublicKey: officialCatalogPublicKey, + AllowRestart: cfg.Maintenance.AllowRestart, DatabaseURL: cfg.Storage.DatabaseURL, + PluginCacheDir: cfg.Plugins.CacheDir, PluginActiveDir: cfg.Plugins.ActiveDir, + BackupDir: cfg.Maintenance.BackupDir, DiagnosticDir: cfg.Maintenance.DiagnosticDir, + MaxArchiveBytes: cfg.Maintenance.MaxArchiveBytes, + }) + + rt.handler = httpserver.New(httpserver.Options{ + Runtime: httpserver.RuntimeConfig{ + AdminToken: cfg.Security.Admin.Token, DemoMode: cfg.Bootstrap.DemoMode, FrontendDir: cfg.HTTP.FrontendDir, + }, + AuthService: authService, OIDCService: oidcService, FeishuService: feishuService, + GitHubOAuthService: githubOAuthService, GoogleOAuthService: googleOAuthService, DingTalkService: dingTalkService, + SettingsService: rt.settingsService, ControlService: rt.controlService, OperatorService: operatorService, + PluginService: rt.pluginService, SystemService: rt.systemService, ExportJobStore: rt.exportStore, + DurableAIJobs: rt.durableJobs, AIJobRuntime: rt.durableJobs, + }) + return rt, nil +} + +func (rt *runtime) Start(ctx context.Context) { + backgroundCtx, cancel := context.WithCancel(ctx) + rt.cancel = cancel + rt.startBackground(backgroundCtx) +} + +func (rt *runtime) Errors() <-chan error { + return rt.backgroundErrors +} + +func (rt *runtime) Close(ctx context.Context) error { + if rt == nil { + return nil + } + rt.closeOnce.Do(func() { + if rt.cancel != nil { + rt.cancel() + } + waitDone := make(chan struct{}) + go func() { + rt.waitGroup.Wait() + close(waitDone) + }() + select { + case <-waitDone: + case <-ctx.Done(): + rt.closeErr = errors.Join(rt.closeErr, ctx.Err()) + } + if rt.pluginService != nil { + rt.closeErr = errors.Join(rt.closeErr, rt.pluginService.Shutdown(context.WithoutCancel(ctx))) + } + if rt.closeInfrastructure != nil { + rt.closeInfrastructure() + } + for _, closeResource := range []func() error{ + closeCSVExportStore(rt.exportStore), closePluginRepository(rt.pluginRepo), closeOperatorRepository(rt.operatorRepo), + closeControlRepository(rt.controlRepo), closeSettingsRepository(rt.settingsRepo), + } { + rt.closeErr = errors.Join(rt.closeErr, closeResource()) + } + }) + return rt.closeErr +} + +func (rt *runtime) startBackground(ctx context.Context) { + rt.launch(func() { + rt.controlService.RunChannelMonitor(ctx, func(ctx context.Context) (controlplane.ChannelMonitorConfig, error) { + current, err := rt.settingsService.Admin(ctx) + if err != nil { + return controlplane.ChannelMonitorConfig{}, err + } + return controlplane.ChannelMonitorConfig{ + Enabled: current.ChannelMonitorEnabled, Interval: time.Duration(current.ChannelMonitorIntervalSeconds) * time.Second, + }, nil + }, func(operation string, err error) { + slog.Error("channel monitor failed", "operation", operation, "error", err) + }) + }) + rt.launch(func() { + rt.controlService.RunCustomerNotificationScheduler(ctx, logBackgroundError("customer notification scheduler")) + }) + rt.launch(func() { + rt.controlService.RunPlatformUsageDeliveryScheduler(ctx, logBackgroundError("platform usage delivery scheduler")) + }) + rt.launch(func() { + rt.controlService.RunArtifactLifecycleScheduler(ctx, 30*time.Second, 100, logBackgroundError("artifact lifecycle scheduler")) + }) + rt.launch(func() { + rt.controlService.RunEffectivePricingDecisionMonitor(ctx, time.Minute, logBackgroundError("effective pricing decision monitor")) + }) + rt.launch(func() { + rt.controlService.RunProviderBillingSyncScheduler(ctx, time.Minute, logBackgroundError("provider billing sync scheduler")) + }) + rt.launch(func() { + httpserver.RunBackupScheduler(ctx, rt.systemService, rt.settingsService, rt.controlService, logBackgroundError("backup scheduler")) + }) + rt.launch(func() { + if err := rt.durableJobs.Run(ctx, func(component string, err error) { + slog.Error("durable AI job runtime component failed", "component", component, "error", err) + }); err != nil && !errors.Is(err, context.Canceled) { + select { + case rt.backgroundErrors <- err: + default: + } + } + }) +} + +func (rt *runtime) launch(run func()) { + rt.waitGroup.Add(1) + go func() { + defer rt.waitGroup.Done() + run() + }() +} + +func logBackgroundError(component string) func(error) { + return func(err error) { slog.Error("background component failed", "component", component, "error", err) } +} + +func systemCatalog(cfg config.OfficialCatalog) (string, string, string) { + if cfg.Mode != "online" { + return "", "", "" + } + url := cfg.URL + if url == "" { + url = cfg.BootstrapURL + } + return url, cfg.KeyID, cfg.PublicKey +} + +func closeCSVExportStore(store httpserver.CSVExportJobStore) func() error { + return func() error { + if store == nil { + return nil + } + return store.Close() + } +} + +func closePluginRepository(repo plugins.Repository) func() error { + return func() error { + if repo == nil { + return nil + } + return repo.Close() + } +} + +func closeOperatorRepository(repo operatorcore.Repository) func() error { + return func() error { + if repo == nil { + return nil + } + return repo.Close() + } +} + +func closeControlRepository(repo controlplane.Repository) func() error { + return func() error { + if repo == nil { + return nil + } + return repo.Close() + } +} + +func closeSettingsRepository(repo settings.Repository) func() error { + return func() error { + if repo == nil { + return nil + } + return repo.Close() + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0513765..9320631 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,349 +1,153 @@ package config -import ( - "errors" - "net" - "os" - "path/filepath" - "strconv" - "strings" +import "github.com/lwmacct/251207-go-pkg-cfgm/pkg/cfgm" - "github.com/astercloud/asterrouter/backend/internal/buildinfo" -) +type Config struct { + Server Server `json:"server" desc:"AsterRouter service configuration"` +} -const localDevelopmentSecret = "asterrouter-local-development-secret" +type Server struct { + HTTP HTTP `json:"http" desc:"HTTP service configuration"` + Bootstrap Bootstrap `json:"bootstrap" desc:"First-run bootstrap configuration"` + Security Security `json:"security" desc:"Authentication and encryption configuration"` + Storage Storage `json:"storage" desc:"Persistent storage and Redis configuration"` + Official Official `json:"official" desc:"Official catalog and license services"` + Plugins Plugins `json:"plugins" desc:"Plugin runtime configuration"` + Jobs Jobs `json:"jobs" desc:"Durable AI job infrastructure"` + Artifacts Artifacts `json:"artifacts" desc:"Artifact storage configuration"` + Maintenance Maintenance `json:"maintenance" desc:"Backup, diagnostics, and process management"` +} -type Config struct { - Addr string - AdminToken string - AdminUsername string - AdminPassword string - DatabaseURL string - DeploymentRole string - FrontendDir string - DefaultProfile string - Profiles []string - PublicBase string - SecretKey string - Version string - BuildType string - UpdateManifestURL string - CatalogMode string - CatalogBootstrapURL string - CatalogURL string - OfficialServicesURL string - CatalogKeyID string - CatalogPublicKey string - LicenseURL string - RedeemURL string - LicenseKeyID string - LicensePublicKey string - InstanceID string - InstanceFingerprint string - InstanceDisplayName string - PluginCacheDir string - PluginActiveDir string - PluginHostURL string - BackupDir string - DiagnosticDir string - MaxArchiveBytes int64 - AIJobQueueProfileLimit int - AIJobQueueTenantLimit int - AIJobQueuePrincipalLimit int - AIJobQueueDriver string - RoutingAffinityDriver string - RedisURL string - RedisNamespace string - ArtifactStoreDriver string - ArtifactLocalRoot string - ArtifactS3Endpoint string - ArtifactS3Region string - ArtifactS3Bucket string - ArtifactS3Prefix string - ArtifactS3AccessKey string - ArtifactS3SecretKey string - ArtifactS3PathStyle bool - AllowRestart bool - DemoMode bool +type HTTP struct { + Listen string `json:"listen" desc:"HTTP listen address"` + FrontendDir string `json:"frontend-dir" desc:"Built frontend asset directory"` +} + +type Bootstrap struct { + DeploymentRole string `json:"deployment-role" desc:"Optional unattended deployment role: personal, relay_operator, enterprise, or platform"` + DemoMode bool `json:"demo-mode" desc:"Enable the isolated demonstration login flow"` +} + +type Security struct { + Admin Admin `json:"admin" desc:"Bootstrap administrator credentials"` + SecretKey string `json:"secret-key" desc:"Stable application encryption and signing secret"` } -func Load() Config { - addr := getEnv("ASTER_ADDR", ":8080") - profiles := normalizeProfiles(os.Getenv("ASTER_PROFILES")) - defaultProfile := normalizeProfile(os.Getenv("ASTER_DEFAULT_PROFILE")) - deploymentRole := strings.TrimSpace(os.Getenv("ASTER_DEPLOYMENT_ROLE")) - if normalizedRole := normalizeProfile(deploymentRole); normalizedRole != "" && len(profiles) == 0 { - profiles = []string{normalizedRole} - if defaultProfile == "" { - defaultProfile = normalizedRole - } - } - if defaultProfile == "" && len(profiles) > 0 { - defaultProfile = profiles[0] - } - pluginCacheDir := getEnv("ASTER_PLUGIN_CACHE_DIR", "data/plugin-cache") - pluginActiveDir := getEnv("ASTER_PLUGIN_ACTIVE_DIR", filepath.Join(filepath.Dir(pluginCacheDir), "plugin-active")) - return Config{ - Addr: addr, - AdminToken: strings.TrimSpace(os.Getenv("ASTER_ADMIN_TOKEN")), - AdminUsername: getEnv("ASTER_ADMIN_USERNAME", "admin"), - AdminPassword: strings.TrimSpace(os.Getenv("ASTER_ADMIN_PASSWORD")), - DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")), - DeploymentRole: deploymentRole, - FrontendDir: getEnv("ASTER_FRONTEND_DIR", "../frontend/dist"), - DefaultProfile: defaultProfile, - Profiles: profiles, - PublicBase: strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")), - SecretKey: getEnv("ASTER_SECRET_KEY", localDevelopmentSecret), - Version: getEnv("ASTER_VERSION", buildinfo.Version), - BuildType: getEnv("ASTER_BUILD_TYPE", buildinfo.BuildType), - UpdateManifestURL: strings.TrimSpace(os.Getenv("ASTER_UPDATE_MANIFEST_URL")), - CatalogMode: getEnv("ASTER_CATALOG_MODE", "disabled"), - CatalogBootstrapURL: strings.TrimSpace(os.Getenv("ASTER_CATALOG_BOOTSTRAP_URL")), - CatalogURL: strings.TrimSpace(os.Getenv("ASTER_CATALOG_URL")), - OfficialServicesURL: strings.TrimSpace(os.Getenv("ASTER_OFFICIAL_SERVICES_URL")), - CatalogKeyID: strings.TrimSpace(os.Getenv("ASTER_CATALOG_KEY_ID")), - CatalogPublicKey: strings.TrimSpace(os.Getenv("ASTER_CATALOG_PUBLIC_KEY")), - LicenseURL: strings.TrimSpace(os.Getenv("ASTER_LICENSE_URL")), - RedeemURL: strings.TrimSpace(os.Getenv("ASTER_REDEEM_URL")), - LicenseKeyID: strings.TrimSpace(os.Getenv("ASTER_LICENSE_KEY_ID")), - LicensePublicKey: strings.TrimSpace(os.Getenv("ASTER_LICENSE_PUBLIC_KEY")), - InstanceID: strings.TrimSpace(os.Getenv("ASTER_INSTANCE_ID")), - InstanceFingerprint: strings.TrimSpace(os.Getenv("ASTER_INSTANCE_FINGERPRINT")), - InstanceDisplayName: strings.TrimSpace(os.Getenv("ASTER_INSTANCE_DISPLAY_NAME")), - PluginCacheDir: pluginCacheDir, - PluginActiveDir: pluginActiveDir, - PluginHostURL: defaultString(strings.TrimSpace(os.Getenv("ASTER_PLUGIN_HOST_URL")), defaultPluginHostURL(addr)), - BackupDir: getEnv("ASTER_BACKUP_DIR", "data/backups"), - DiagnosticDir: getEnv("ASTER_DIAGNOSTIC_DIR", "data/diagnostics"), - MaxArchiveBytes: getInt64Env("ASTER_MAX_ARCHIVE_BYTES", 2<<30), - AIJobQueueProfileLimit: getNonNegativeIntEnv("ASTER_AI_JOB_QUEUE_PROFILE_LIMIT"), - AIJobQueueTenantLimit: getNonNegativeIntEnv("ASTER_AI_JOB_QUEUE_TENANT_LIMIT"), - AIJobQueuePrincipalLimit: getNonNegativeIntEnv("ASTER_AI_JOB_QUEUE_PRINCIPAL_LIMIT"), - AIJobQueueDriver: getEnv("ASTER_AI_JOB_QUEUE_DRIVER", "memory"), - RoutingAffinityDriver: getEnv("ASTER_ROUTING_AFFINITY_DRIVER", "repository"), - RedisURL: strings.TrimSpace(os.Getenv("ASTER_REDIS_URL")), - RedisNamespace: getEnv("ASTER_REDIS_NAMESPACE", "asterrouter"), - ArtifactStoreDriver: getEnv("ASTER_ARTIFACT_STORE_DRIVER", "none"), - ArtifactLocalRoot: getEnv("ASTER_ARTIFACT_LOCAL_ROOT", "data/artifacts"), - ArtifactS3Endpoint: strings.TrimSpace(os.Getenv("ASTER_ARTIFACT_S3_ENDPOINT")), - ArtifactS3Region: getEnv("ASTER_ARTIFACT_S3_REGION", "auto"), - ArtifactS3Bucket: strings.TrimSpace(os.Getenv("ASTER_ARTIFACT_S3_BUCKET")), - ArtifactS3Prefix: strings.Trim(strings.TrimSpace(os.Getenv("ASTER_ARTIFACT_S3_PREFIX")), "/"), - ArtifactS3AccessKey: strings.TrimSpace(os.Getenv("ASTER_ARTIFACT_S3_ACCESS_KEY")), - ArtifactS3SecretKey: strings.TrimSpace(os.Getenv("ASTER_ARTIFACT_S3_SECRET_KEY")), - ArtifactS3PathStyle: getBoolEnv("ASTER_ARTIFACT_S3_PATH_STYLE"), - AllowRestart: getBoolEnv("ASTER_ALLOW_RESTART"), - DemoMode: getBoolEnv("ASTER_DEMO_MODE"), - } +type Admin struct { + Username string `json:"username" desc:"Bootstrap administrator username"` + Password string `json:"password" desc:"Bootstrap administrator password"` + Token string `json:"token" desc:"Legacy administrator bearer token"` } -func defaultPluginHostURL(addr string) string { - addr = strings.TrimSpace(addr) - if addr == "" { - return "" - } - host, port, err := net.SplitHostPort(addr) - if err != nil { - if strings.HasPrefix(addr, ":") { - port = strings.TrimPrefix(addr, ":") - } else { - return "" - } - } - host = strings.Trim(host, "[]") - if host == "" || host == "0.0.0.0" || host == "::" { - host = "127.0.0.1" - } - if port == "" { - return "" - } - return "http://" + net.JoinHostPort(host, port) + "/api/v1/plugin-host" +type Storage struct { + DatabaseURL string `json:"database-url" desc:"PostgreSQL connection URL; empty uses in-memory storage for source development"` + Redis Redis `json:"redis" desc:"Shared Redis configuration"` } -func defaultString(value string, fallback string) string { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - return fallback +type Redis struct { + URL string `json:"url" desc:"Redis connection URL"` + Namespace string `json:"namespace" desc:"Redis key namespace"` } -func ValidateRuntime(cfg Config) error { - if cfg.AIJobQueueProfileLimit < 0 || cfg.AIJobQueueTenantLimit < 0 || cfg.AIJobQueuePrincipalLimit < 0 { - return errors.New("ASTER_AI_JOB_QUEUE_PROFILE_LIMIT, ASTER_AI_JOB_QUEUE_TENANT_LIMIT, and ASTER_AI_JOB_QUEUE_PRINCIPAL_LIMIT must be non-negative integers") - } - switch strings.TrimSpace(cfg.AIJobQueueDriver) { - case "", "memory": - case "redis": - if strings.TrimSpace(cfg.RedisURL) == "" { - return errors.New("ASTER_REDIS_URL is required when ASTER_AI_JOB_QUEUE_DRIVER=redis") - } - default: - return errors.New("ASTER_AI_JOB_QUEUE_DRIVER must be memory or redis") - } - switch strings.TrimSpace(cfg.RoutingAffinityDriver) { - case "", "repository": - case "redis": - if strings.TrimSpace(cfg.RedisURL) == "" { - return errors.New("ASTER_REDIS_URL is required when ASTER_ROUTING_AFFINITY_DRIVER=redis") - } - default: - return errors.New("ASTER_ROUTING_AFFINITY_DRIVER must be repository or redis") - } - if strings.TrimSpace(cfg.RedisNamespace) != "" && !validRuntimeNamespace(cfg.RedisNamespace) { - return errors.New("ASTER_REDIS_NAMESPACE must contain only letters, numbers, dots, underscores, or hyphens") - } - if strings.TrimSpace(cfg.DeploymentRole) != "" { - deploymentRole := normalizeProfile(cfg.DeploymentRole) - if !isDeploymentProfile(deploymentRole) { - return errors.New("ASTER_DEPLOYMENT_ROLE must be one of personal, relay_operator, enterprise, or platform") - } - if len(cfg.Profiles) != 1 || cfg.Profiles[0] != deploymentRole || cfg.DefaultProfile != deploymentRole { - return errors.New("ASTER_DEPLOYMENT_ROLE must match the legacy ASTER_PROFILES and ASTER_DEFAULT_PROFILE values when they are also set") - } - } - for _, profile := range cfg.Profiles { - if !isDeploymentProfile(profile) { - return errors.New("ASTER_PROFILES must contain only personal, relay_operator, enterprise, or platform") - } - } - if cfg.DefaultProfile != "" && !isDeploymentProfile(cfg.DefaultProfile) { - return errors.New("ASTER_DEFAULT_PROFILE must be one of personal, relay_operator, enterprise, or platform") - } - if len(cfg.Profiles) > 1 { - return errors.New("ASTER_PROFILES accepts one bootstrap profile; deploy a separate instance for a different business model") - } - if len(cfg.Profiles) == 1 && cfg.DefaultProfile != "" && cfg.DefaultProfile != cfg.Profiles[0] { - return errors.New("ASTER_DEFAULT_PROFILE must match the single ASTER_PROFILES bootstrap profile") - } - switch strings.TrimSpace(cfg.ArtifactStoreDriver) { - case "", "none": - case "local": - if strings.TrimSpace(cfg.ArtifactLocalRoot) == "" { - return errors.New("ASTER_ARTIFACT_LOCAL_ROOT is required when ASTER_ARTIFACT_STORE_DRIVER=local") - } - case "s3": - if strings.TrimSpace(cfg.ArtifactS3Bucket) == "" || strings.TrimSpace(cfg.ArtifactS3AccessKey) == "" || strings.TrimSpace(cfg.ArtifactS3SecretKey) == "" { - return errors.New("ASTER_ARTIFACT_S3_BUCKET, ASTER_ARTIFACT_S3_ACCESS_KEY, and ASTER_ARTIFACT_S3_SECRET_KEY are required when ASTER_ARTIFACT_STORE_DRIVER=s3") - } - default: - return errors.New("ASTER_ARTIFACT_STORE_DRIVER must be none, local, or s3") - } - if cfg.BuildType != "release" { - return nil - } - if strings.TrimSpace(cfg.DatabaseURL) == "" { - return errors.New("DATABASE_URL is required for release deployments") - } - if strings.TrimSpace(cfg.SecretKey) == localDevelopmentSecret { - return errors.New("ASTER_SECRET_KEY must be set to a stable production secret") - } - if strings.TrimSpace(cfg.AdminPassword) == "" && strings.TrimSpace(cfg.AdminToken) == "" && !cfg.DemoMode { - return errors.New("ASTER_ADMIN_PASSWORD or ASTER_ADMIN_TOKEN is required for release deployments") - } - switch strings.TrimSpace(cfg.CatalogMode) { - case "online", "private_mirror": - if strings.TrimSpace(cfg.CatalogBootstrapURL) == "" { - if strings.TrimSpace(cfg.CatalogURL) == "" { - return errors.New("ASTER_CATALOG_URL or ASTER_CATALOG_BOOTSTRAP_URL is required when ASTER_CATALOG_MODE=online or private_mirror") - } - if strings.TrimSpace(cfg.CatalogKeyID) == "" || strings.TrimSpace(cfg.CatalogPublicKey) == "" { - return errors.New("ASTER_CATALOG_KEY_ID and ASTER_CATALOG_PUBLIC_KEY are required when ASTER_CATALOG_BOOTSTRAP_URL is not set") - } - } - if (strings.TrimSpace(cfg.LicenseKeyID) == "") != (strings.TrimSpace(cfg.LicensePublicKey) == "") { - return errors.New("ASTER_LICENSE_KEY_ID and ASTER_LICENSE_PUBLIC_KEY must be set together") - } - case "offline": - if strings.TrimSpace(cfg.CatalogKeyID) == "" || strings.TrimSpace(cfg.CatalogPublicKey) == "" { - return errors.New("ASTER_CATALOG_KEY_ID and ASTER_CATALOG_PUBLIC_KEY are required when ASTER_CATALOG_MODE=offline") - } - } - return nil +type Official struct { + UpdateManifestURL string `json:"update-manifest-url" desc:"Release update manifest URL"` + Catalog OfficialCatalog `json:"catalog" desc:"Official plugin catalog configuration"` + License OfficialLicense `json:"license" desc:"Official license service configuration"` + Instance Instance `json:"instance" desc:"Stable installation identity"` } -func validRuntimeNamespace(value string) bool { - value = strings.TrimSpace(value) - if value == "" || len(value) > 96 { - return false - } - for _, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || strings.ContainsRune("._-", character) { - continue - } - return false - } - return true +type OfficialCatalog struct { + Mode string `json:"mode" desc:"Catalog mode: disabled, online, private_mirror, or offline"` + BootstrapURL string `json:"bootstrap-url" desc:"Signed catalog bootstrap URL"` + URL string `json:"url" desc:"Catalog index URL"` + ServicesURL string `json:"services-url" desc:"Official services base URL"` + KeyID string `json:"key-id" desc:"Catalog signature key ID"` + PublicKey string `json:"public-key" desc:"Catalog signature public key"` } -func getEnv(key, fallback string) string { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback - } - return value +type OfficialLicense struct { + URL string `json:"url" desc:"License service URL"` + RedeemURL string `json:"redeem-url" desc:"License redemption URL"` + KeyID string `json:"key-id" desc:"License signature key ID"` + PublicKey string `json:"public-key" desc:"License signature public key"` } -func normalizeProfile(value string) string { - return strings.TrimSpace(value) +type Instance struct { + ID string `json:"id" desc:"Stable installation ID"` + Fingerprint string `json:"fingerprint" desc:"Installation fingerprint"` + DisplayName string `json:"display-name" desc:"Installation display name"` } -func isDeploymentProfile(value string) bool { - switch strings.TrimSpace(value) { - case "personal", "relay_operator", "enterprise", "platform": - return true - default: - return false - } +type Plugins struct { + CacheDir string `json:"cache-dir" desc:"Downloaded plugin package cache"` + ActiveDir string `json:"active-dir" desc:"Activated plugin runtime directory; derived from cache-dir when empty"` + HostURL string `json:"host-url" desc:"Plugin host callback URL; derived from http.listen when empty"` } -func normalizeProfiles(value string) []string { - fields := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ';' || r == '|' || r == ' ' || r == '\n' || r == '\t' - }) - out := make([]string, 0, len(fields)) - seen := map[string]bool{} - for _, field := range fields { - profile := normalizeProfile(field) - if seen[profile] { - continue - } - seen[profile] = true - out = append(out, profile) - } - return out +type Jobs struct { + Queue JobQueue `json:"queue" desc:"Durable AI job delivery queue"` + RoutingAffinityDriver string `json:"routing-affinity-driver" desc:"Routing affinity driver: repository or redis"` } -func getBoolEnv(key string) bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { - case "1", "true", "yes", "on": - return true - default: - return false - } +type JobQueue struct { + Driver string `json:"driver" desc:"Queue driver: memory or redis"` + Limits JobLimits `json:"limits" desc:"Concurrent queued job admission limits; zero disables a limit"` } -func getInt64Env(key string, fallback int64) int64 { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback - } - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil || parsed <= 0 { - return fallback - } - return parsed +type JobLimits struct { + Profile int `json:"profile" desc:"Maximum queued jobs per profile"` + Tenant int `json:"tenant" desc:"Maximum queued jobs per tenant"` + Principal int `json:"principal" desc:"Maximum queued jobs per principal"` } -func getNonNegativeIntEnv(key string) int { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return 0 - } - parsed, err := strconv.Atoi(value) - if err != nil || parsed < 0 { - return -1 - } - return parsed +type Artifacts struct { + Driver string `json:"driver" desc:"Artifact store driver: none, local, or s3"` + LocalRoot string `json:"local-root" desc:"Local artifact root directory"` + S3 ArtifactS3 `json:"s3" desc:"S3-compatible artifact store"` } + +type ArtifactS3 struct { + Endpoint string `json:"endpoint" desc:"S3-compatible endpoint URL"` + Region string `json:"region" desc:"S3 region"` + Bucket string `json:"bucket" desc:"S3 bucket"` + Prefix string `json:"prefix" desc:"S3 object key prefix"` + AccessKey string `json:"access-key" desc:"S3 access key"` + SecretKey string `json:"secret-key" desc:"S3 secret key"` + PathStyle bool `json:"path-style" desc:"Use path-style S3 addressing"` +} + +type Maintenance struct { + BackupDir string `json:"backup-dir" desc:"Local backup directory"` + DiagnosticDir string `json:"diagnostic-dir" desc:"Diagnostic bundle directory"` + MaxArchiveBytes int64 `json:"max-archive-bytes" desc:"Maximum backup or diagnostic archive size"` + AllowRestart bool `json:"allow-restart" desc:"Allow managed process restart operations"` +} + +func DefaultConfig() Config { + return Config{Server: Server{ + HTTP: HTTP{Listen: ":8080", FrontendDir: "../frontend/dist"}, + Security: Security{Admin: Admin{Username: "admin"}}, + Storage: Storage{Redis: Redis{Namespace: "asterrouter"}}, + Official: Official{Catalog: OfficialCatalog{Mode: "disabled"}}, + Plugins: Plugins{CacheDir: "data/plugin-cache"}, + Jobs: Jobs{ + Queue: JobQueue{Driver: "memory"}, + RoutingAffinityDriver: "repository", + }, + Artifacts: Artifacts{ + Driver: "none", + LocalRoot: "data/artifacts", + S3: ArtifactS3{Region: "auto"}, + }, + Maintenance: Maintenance{ + BackupDir: "data/backups", + DiagnosticDir: "data/diagnostics", + MaxArchiveBytes: 2 << 30, + }, + }} +} + +var Manager = cfgm.New( + DefaultConfig(), + cfgm.AppName("asterrouter"), +) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f9d68f0..8f4eb16 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,271 +1,155 @@ package config -import "testing" +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" -func TestValidateRuntimeAllowsSourceBuildWithoutDatabase(t *testing.T) { - cfg := Config{BuildType: "source"} + "github.com/lwmacct/251207-go-pkg-cfgm/pkg/cfgm" +) - if err := ValidateRuntime(cfg); err != nil { - t.Fatalf("ValidateRuntime() = %v, want nil", err) - } +var files = cfgm.ConfigFiles[Config]{ + Manager: Manager, + ExampleFile: "config/config.example.yaml", + RuntimeFile: "config/config.yaml", } -func TestValidateRuntimeRequiresReleaseDatabase(t *testing.T) { - cfg := Config{ - BuildType: "release", - SecretKey: "stable-secret", - AdminPassword: "change-me", - } +func TestWriteConfigExample(t *testing.T) { files.WriteExample(t) } +func TestRuntimeConfigKeysValid(t *testing.T) { files.ValidateRuntimeConfig(t) } - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime() = nil, want error") +func TestManagerLoadsStrictServerHierarchy(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + content := "server:\n http:\n listen: 127.0.0.1:19090\n jobs:\n queue:\n limits:\n tenant: 25\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) } -} - -func TestValidateRuntimeAllowsDemoModeAsReleaseAuthMechanism(t *testing.T) { - cfg := Config{ - BuildType: "release", - DatabaseURL: "postgres://asterrouter:pass@localhost:5432/asterrouter?sslmode=disable", - SecretKey: "stable-secret", - DemoMode: true, + loaded, err := Manager.Load(t.Context(), cfgm.File(path)) + if err != nil { + t.Fatal(err) } - - if err := ValidateRuntime(cfg); err != nil { - t.Fatalf("ValidateRuntime() = %v, want nil", err) + if loaded.Server.HTTP.Listen != "127.0.0.1:19090" || loaded.Server.Jobs.Queue.Limits.Tenant != 25 { + t.Fatalf("unexpected loaded config: %#v", loaded.Server) } -} -func TestValidateRuntimeRequiresProductionSecret(t *testing.T) { - cfg := Config{ - BuildType: "release", - DatabaseURL: "postgres://asterrouter:pass@localhost:5432/asterrouter?sslmode=disable", - SecretKey: localDevelopmentSecret, - AdminPassword: "change-me", + if err := os.WriteFile(path, []byte("http:\n listen: :9999\n"), 0o600); err != nil { + t.Fatal(err) } - - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime() = nil, want error") + if _, err = Manager.Load(t.Context(), cfgm.File(path)); err == nil || !strings.Contains(err.Error(), "http") { + t.Fatalf("root-level legacy config must be rejected, got %v", err) } } -func TestValidateRuntimeRequiresCatalogTrustMaterialWhenOnline(t *testing.T) { - cfg := Config{ - BuildType: "release", - DatabaseURL: "postgres://asterrouter:pass@localhost:5432/asterrouter?sslmode=disable", - SecretKey: "stable-secret", - AdminPassword: "change-me", - CatalogMode: "online", - CatalogURL: "https://catalog.example.test/official/v1/catalog/index", +func TestManagerLoadsCanonicalEnvironment(t *testing.T) { + t.Setenv("ASTERROUTER_SERVER_HTTP_LISTEN", "127.0.0.1:18081") + t.Setenv("ASTERROUTER_SERVER_JOBS_QUEUE_LIMITS_PROFILE", "12") + t.Setenv("ASTERROUTER_SERVER_ARTIFACTS_S3_PATH_STYLE", "true") + loaded, err := Manager.Load(t.Context(), cfgm.Env("ASTERROUTER_")) + if err != nil { + t.Fatal(err) } - - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime() = nil, want error") + if loaded.Server.HTTP.Listen != "127.0.0.1:18081" || loaded.Server.Jobs.Queue.Limits.Profile != 12 || !loaded.Server.Artifacts.S3.PathStyle { + t.Fatalf("unexpected environment config: %#v", loaded.Server) } } -func TestValidateRuntimeRequiresCatalogTrustMaterialWhenPrivateMirror(t *testing.T) { - cfg := Config{ - BuildType: "release", - DatabaseURL: "postgres://asterrouter:pass@localhost:5432/asterrouter?sslmode=disable", - SecretKey: "stable-secret", - AdminPassword: "change-me", - CatalogMode: "private_mirror", - CatalogURL: "https://mirror.example.test/official/v1/catalog/index", +func TestManagerDoesNotReadLegacyEnvironment(t *testing.T) { + t.Setenv("ASTER_ADDR", "127.0.0.1:19999") + t.Setenv("DATABASE_URL", "postgres://legacy.example/ignored") + loaded, err := Manager.Load(t.Context(), cfgm.Env("ASTERROUTER_")) + if err != nil { + t.Fatal(err) } - - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime() = nil, want error") + if loaded.Server.HTTP.Listen != ":8080" || loaded.Server.Storage.DatabaseURL != "" { + t.Fatalf("legacy environment affected config: %#v", loaded.Server) } } -func TestValidateRuntimeRequiresCatalogTrustMaterialWhenOffline(t *testing.T) { - cfg := Config{ - BuildType: "release", - DatabaseURL: "postgres://asterrouter:pass@localhost:5432/asterrouter?sslmode=disable", - SecretKey: "stable-secret", - AdminPassword: "change-me", - CatalogMode: "offline", +func TestValidateNormalizesDerivedValues(t *testing.T) { + cfg := DefaultConfig().Server + cfg.HTTP.Listen = " 0.0.0.0:18090 " + cfg.Plugins.CacheDir = " /var/lib/asterrouter/plugins/cache " + cfg.Plugins.ActiveDir = "" + cfg.Plugins.HostURL = "" + validated, err := Validate(cfg, "source") + if err != nil { + t.Fatal(err) } - - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime() = nil, want error") + if validated.Plugins.ActiveDir != "/var/lib/asterrouter/plugins/plugin-active" { + t.Fatalf("active dir = %q", validated.Plugins.ActiveDir) } -} - -func TestDefaultPluginHostURLUsesLoopbackForWildcardListenAddress(t *testing.T) { - if got := defaultPluginHostURL(":8080"); got != "http://127.0.0.1:8080/api/v1/plugin-host" { - t.Fatalf("defaultPluginHostURL(:8080) = %q", got) - } - if got := defaultPluginHostURL("0.0.0.0:18090"); got != "http://127.0.0.1:18090/api/v1/plugin-host" { - t.Fatalf("defaultPluginHostURL(0.0.0.0:18090) = %q", got) + if validated.Plugins.HostURL != "http://127.0.0.1:18090/api/v1/plugin-host" { + t.Fatalf("host URL = %q", validated.Plugins.HostURL) } } -func TestNormalizeProfilesPreservesUnknownValuesForValidation(t *testing.T) { - profiles := normalizeProfiles("platform, enterprise, platform, unknown") - want := []string{"platform", "enterprise", "unknown"} - if len(profiles) != len(want) { - t.Fatalf("normalizeProfiles() = %v, want %v", profiles, want) +func TestValidateReleaseFailsClosed(t *testing.T) { + tests := []struct { + name string + edit func(*Server) + want error + }{ + {name: "database", edit: func(cfg *Server) { cfg.Storage.DatabaseURL = "" }, want: ErrInvalidStorage}, + {name: "secret", edit: func(cfg *Server) { cfg.Security.SecretKey = "" }, want: ErrInvalidSecurity}, + {name: "admin", edit: func(cfg *Server) { cfg.Security.Admin.Password = "" }, want: ErrInvalidSecurity}, } - for index, profile := range want { - if profiles[index] != profile { - t.Fatalf("normalizeProfiles() = %v, want %v", profiles, want) - } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validReleaseConfig() + test.edit(&cfg) + _, err := Validate(cfg, "release") + if !errors.Is(err, test.want) { + t.Fatalf("Validate() error = %v, want %v", err, test.want) + } + }) } } -func TestValidateRuntimeRejectsUnknownLegacyProfiles(t *testing.T) { - for _, cfg := range []Config{ - {BuildType: "source", Profiles: []string{"unknown"}, DefaultProfile: "unknown"}, - {BuildType: "source", DefaultProfile: "unknown"}, - } { - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime(%+v) accepted an unknown legacy profile", cfg) - } +func TestValidateRequiresSecretForPersistentSourceBuild(t *testing.T) { + cfg := DefaultConfig().Server + cfg.Storage.DatabaseURL = "postgres://asterrouter:test@localhost/asterrouter" + if _, err := Validate(cfg, "source"); !errors.Is(err, ErrInvalidSecurity) { + t.Fatalf("Validate() error = %v, want security error", err) } } -func TestLoadPreservesUnknownLegacyProfileForValidation(t *testing.T) { - t.Setenv("ASTER_DEPLOYMENT_ROLE", "") - t.Setenv("ASTER_PROFILES", "unknown") - t.Setenv("ASTER_DEFAULT_PROFILE", "") - - cfg := Load() - if len(cfg.Profiles) != 1 || cfg.Profiles[0] != "unknown" || cfg.DefaultProfile != "unknown" { - t.Fatalf("Load() silently discarded an unknown legacy profile: %+v", cfg) +func TestValidateJobsAndArtifacts(t *testing.T) { + cfg := DefaultConfig().Server + cfg.Jobs.Queue.Driver = "redis" + if _, err := Validate(cfg, "source"); !errors.Is(err, ErrInvalidJobs) { + t.Fatalf("missing Redis error = %v", err) } - if err := ValidateRuntime(cfg); err == nil { - t.Fatal("ValidateRuntime() accepted the unknown legacy profile loaded from the environment") + cfg.Storage.Redis.URL = "redis://127.0.0.1:6379/0" + cfg.Artifacts.Driver = "s3" + if _, err := Validate(cfg, "source"); !errors.Is(err, ErrInvalidArtifacts) { + t.Fatalf("missing S3 credentials error = %v", err) } -} - -func TestValidateRuntimeRejectsMultipleBootstrapProfiles(t *testing.T) { - cfg := Config{BuildType: "source", Profiles: []string{"enterprise", "platform"}} - if err := ValidateRuntime(cfg); err == nil { - t.Fatal("ValidateRuntime() accepted multiple bootstrap profiles") + cfg.Artifacts.S3.Bucket = "artifacts" + cfg.Artifacts.S3.AccessKey = "access" + cfg.Artifacts.S3.SecretKey = "secret" + if _, err := Validate(cfg, "source"); err != nil { + t.Fatalf("valid Redis/S3 config: %v", err) } } -func TestValidateRuntimeAcceptsDeploymentRoleAndRejectsLegacyMismatch(t *testing.T) { - valid := Config{ - BuildType: "source", - DeploymentRole: "platform", - Profiles: []string{"platform"}, - DefaultProfile: "platform", - } - if err := ValidateRuntime(valid); err != nil { - t.Fatalf("ValidateRuntime() deployment role: %v", err) +func TestValidateOfficialTrustModes(t *testing.T) { + cfg := DefaultConfig().Server + cfg.Official.Catalog.Mode = "offline" + if _, err := Validate(cfg, "source"); !errors.Is(err, ErrInvalidOfficial) { + t.Fatalf("missing offline trust material error = %v", err) } - - mismatched := valid - mismatched.Profiles = []string{"enterprise"} - mismatched.DefaultProfile = "enterprise" - if err := ValidateRuntime(mismatched); err == nil { - t.Fatal("ValidateRuntime() accepted mismatched deployment role and legacy profile") - } - - invalid := valid - invalid.DeploymentRole = "unknown" - if err := ValidateRuntime(invalid); err == nil { - t.Fatal("ValidateRuntime() accepted an unknown deployment role") + cfg.Official.Catalog.KeyID = "catalog-v1" + cfg.Official.Catalog.PublicKey = "public-key" + if _, err := Validate(cfg, "source"); err != nil { + t.Fatalf("valid offline config: %v", err) } } -func TestLoadDeploymentRoleBootstrapsCompatibleProfiles(t *testing.T) { - t.Setenv("ASTER_DEPLOYMENT_ROLE", "platform") - t.Setenv("ASTER_PROFILES", "") - t.Setenv("ASTER_DEFAULT_PROFILE", "") - - cfg := Load() - if cfg.DeploymentRole != "platform" || len(cfg.Profiles) != 1 || cfg.Profiles[0] != "platform" || cfg.DefaultProfile != "platform" { - t.Fatalf("Load() deployment role configuration = %+v", cfg) - } - if err := ValidateRuntime(cfg); err != nil { - t.Fatalf("ValidateRuntime() loaded deployment role: %v", err) - } -} - -func TestLoadDurableAIJobQueueLimits(t *testing.T) { - t.Setenv("ASTER_AI_JOB_QUEUE_PROFILE_LIMIT", "1000") - t.Setenv("ASTER_AI_JOB_QUEUE_TENANT_LIMIT", "100") - t.Setenv("ASTER_AI_JOB_QUEUE_PRINCIPAL_LIMIT", "10") - - cfg := Load() - if cfg.AIJobQueueProfileLimit != 1000 || cfg.AIJobQueueTenantLimit != 100 || cfg.AIJobQueuePrincipalLimit != 10 { - t.Fatalf("Load() durable job limits = %+v", cfg) - } -} - -func TestValidateRuntimeRejectsInvalidDurableAIJobQueueLimit(t *testing.T) { - t.Setenv("ASTER_AI_JOB_QUEUE_PRINCIPAL_LIMIT", "invalid") - cfg := Load() - if cfg.AIJobQueuePrincipalLimit != -1 { - t.Fatalf("invalid limit=%d", cfg.AIJobQueuePrincipalLimit) - } - if err := ValidateRuntime(cfg); err == nil { - t.Fatal("ValidateRuntime() accepted an invalid durable job queue limit") - } -} - -func TestDurableAIJobInfrastructureConfiguration(t *testing.T) { - t.Setenv("ASTER_AI_JOB_QUEUE_DRIVER", "redis") - t.Setenv("ASTER_ROUTING_AFFINITY_DRIVER", "redis") - t.Setenv("ASTER_REDIS_URL", "redis://127.0.0.1:6379/0") - t.Setenv("ASTER_REDIS_NAMESPACE", "tenant-a.runtime") - cfg := Load() - if cfg.AIJobQueueDriver != "redis" || cfg.RoutingAffinityDriver != "redis" || cfg.RedisURL == "" || cfg.RedisNamespace != "tenant-a.runtime" { - t.Fatalf("Load() durable runtime config=%+v", cfg) - } - if err := ValidateRuntime(cfg); err != nil { - t.Fatalf("ValidateRuntime()=%v", err) - } - for _, invalid := range []Config{ - {BuildType: "source", AIJobQueueDriver: "redis"}, - {BuildType: "source", AIJobQueueDriver: "unsupported"}, - {BuildType: "source", RoutingAffinityDriver: "redis"}, - {BuildType: "source", RoutingAffinityDriver: "unsupported"}, - {BuildType: "source", AIJobQueueDriver: "memory", RedisNamespace: "invalid namespace"}, - } { - if err := ValidateRuntime(invalid); err == nil { - t.Fatalf("ValidateRuntime(%+v) accepted invalid durable runtime configuration", invalid) - } - } -} - -func TestArtifactStoreConfigurationIsOptionalAndValidated(t *testing.T) { - if err := ValidateRuntime(Config{BuildType: "source", ArtifactStoreDriver: "none"}); err != nil { - t.Fatalf("optional artifact store: %v", err) - } - if err := ValidateRuntime(Config{BuildType: "source", ArtifactStoreDriver: "local", ArtifactLocalRoot: "data/artifacts"}); err != nil { - t.Fatalf("local artifact store: %v", err) - } - if err := ValidateRuntime(Config{BuildType: "source", ArtifactStoreDriver: "s3", ArtifactS3Bucket: "bucket", ArtifactS3AccessKey: "access", ArtifactS3SecretKey: "secret"}); err != nil { - t.Fatalf("S3 artifact store: %v", err) - } - for _, cfg := range []Config{ - {BuildType: "source", ArtifactStoreDriver: "local"}, - {BuildType: "source", ArtifactStoreDriver: "s3", ArtifactS3Bucket: "bucket"}, - {BuildType: "source", ArtifactStoreDriver: "unsupported"}, - } { - if err := ValidateRuntime(cfg); err == nil { - t.Fatalf("ValidateRuntime(%+v) accepted invalid artifact configuration", cfg) - } - } -} - -func TestLoadArtifactStoreConfiguration(t *testing.T) { - t.Setenv("ASTER_ARTIFACT_STORE_DRIVER", "s3") - t.Setenv("ASTER_ARTIFACT_S3_ENDPOINT", "https://objects.example.test") - t.Setenv("ASTER_ARTIFACT_S3_REGION", "auto") - t.Setenv("ASTER_ARTIFACT_S3_BUCKET", "media") - t.Setenv("ASTER_ARTIFACT_S3_PREFIX", "/aster/artifacts/") - t.Setenv("ASTER_ARTIFACT_S3_ACCESS_KEY", "access") - t.Setenv("ASTER_ARTIFACT_S3_SECRET_KEY", "secret") - t.Setenv("ASTER_ARTIFACT_S3_PATH_STYLE", "true") - cfg := Load() - if cfg.ArtifactStoreDriver != "s3" || cfg.ArtifactS3Endpoint != "https://objects.example.test" || cfg.ArtifactS3Region != "auto" || - cfg.ArtifactS3Bucket != "media" || cfg.ArtifactS3Prefix != "aster/artifacts" || cfg.ArtifactS3AccessKey != "access" || - cfg.ArtifactS3SecretKey != "secret" || !cfg.ArtifactS3PathStyle { - t.Fatalf("Load() artifact config=%+v", cfg) - } +func validReleaseConfig() Server { + cfg := DefaultConfig().Server + cfg.Storage.DatabaseURL = "postgres://asterrouter:test@localhost/asterrouter" + cfg.Security.SecretKey = "stable-secret" + cfg.Security.Admin.Password = "change-me" + return cfg } diff --git a/backend/internal/config/validate.go b/backend/internal/config/validate.go new file mode 100644 index 0000000..e9a46d3 --- /dev/null +++ b/backend/internal/config/validate.go @@ -0,0 +1,231 @@ +package config + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "strings" +) + +var ( + ErrInvalidHTTP = errors.New("invalid HTTP configuration") + ErrInvalidBootstrap = errors.New("invalid bootstrap configuration") + ErrInvalidSecurity = errors.New("invalid security configuration") + ErrInvalidStorage = errors.New("invalid storage configuration") + ErrInvalidOfficial = errors.New("invalid official service configuration") + ErrInvalidPlugins = errors.New("invalid plugin configuration") + ErrInvalidJobs = errors.New("invalid AI job configuration") + ErrInvalidArtifacts = errors.New("invalid artifact configuration") + ErrInvalidMaintenance = errors.New("invalid maintenance configuration") +) + +func Validate(cfg Server, buildType string) (Server, error) { + normalize(&cfg) + if cfg.HTTP.Listen == "" || cfg.HTTP.FrontendDir == "" { + return cfg, fmt.Errorf("%w: server.http.listen and server.http.frontend-dir are required", ErrInvalidHTTP) + } + if cfg.Bootstrap.DeploymentRole != "" && !isDeploymentRole(cfg.Bootstrap.DeploymentRole) { + return cfg, fmt.Errorf("%w: server.bootstrap.deployment-role must be personal, relay_operator, enterprise, or platform", ErrInvalidBootstrap) + } + if cfg.Security.Admin.Username == "" { + return cfg, fmt.Errorf("%w: server.security.admin.username is required", ErrInvalidSecurity) + } + if cfg.Storage.Redis.Namespace == "" || !validRuntimeNamespace(cfg.Storage.Redis.Namespace) { + return cfg, fmt.Errorf("%w: server.storage.redis.namespace must contain only letters, numbers, dots, underscores, or hyphens", ErrInvalidStorage) + } + if err := validateJobs(cfg); err != nil { + return cfg, err + } + if err := validateArtifacts(cfg.Artifacts); err != nil { + return cfg, err + } + if err := validateOfficial(cfg.Official); err != nil { + return cfg, err + } + if cfg.Plugins.CacheDir == "" || cfg.Plugins.ActiveDir == "" || cfg.Plugins.HostURL == "" { + return cfg, fmt.Errorf("%w: server.plugins cache-dir, active-dir, and host-url must resolve to non-empty values", ErrInvalidPlugins) + } + if cfg.Maintenance.BackupDir == "" || cfg.Maintenance.DiagnosticDir == "" || cfg.Maintenance.MaxArchiveBytes <= 0 { + return cfg, fmt.Errorf("%w: backup-dir, diagnostic-dir, and a positive max-archive-bytes are required", ErrInvalidMaintenance) + } + + isRelease := strings.TrimSpace(buildType) == "release" + if isRelease && cfg.Storage.DatabaseURL == "" { + return cfg, fmt.Errorf("%w: server.storage.database-url is required in release builds", ErrInvalidStorage) + } + if (isRelease || cfg.Storage.DatabaseURL != "") && cfg.Security.SecretKey == "" { + return cfg, fmt.Errorf("%w: server.security.secret-key is required for release or persistent storage", ErrInvalidSecurity) + } + if isRelease && cfg.Security.Admin.Password == "" && cfg.Security.Admin.Token == "" && !cfg.Bootstrap.DemoMode { + return cfg, fmt.Errorf("%w: an admin password or token is required in release builds", ErrInvalidSecurity) + } + return cfg, nil +} + +func normalize(cfg *Server) { + cfg.HTTP.Listen = strings.TrimSpace(cfg.HTTP.Listen) + cfg.HTTP.FrontendDir = strings.TrimSpace(cfg.HTTP.FrontendDir) + cfg.Bootstrap.DeploymentRole = strings.TrimSpace(cfg.Bootstrap.DeploymentRole) + cfg.Security.Admin.Username = strings.TrimSpace(cfg.Security.Admin.Username) + cfg.Security.Admin.Password = strings.TrimSpace(cfg.Security.Admin.Password) + cfg.Security.Admin.Token = strings.TrimSpace(cfg.Security.Admin.Token) + cfg.Security.SecretKey = strings.TrimSpace(cfg.Security.SecretKey) + cfg.Storage.DatabaseURL = strings.TrimSpace(cfg.Storage.DatabaseURL) + cfg.Storage.Redis.URL = strings.TrimSpace(cfg.Storage.Redis.URL) + cfg.Storage.Redis.Namespace = strings.TrimSpace(cfg.Storage.Redis.Namespace) + cfg.Official.UpdateManifestURL = strings.TrimSpace(cfg.Official.UpdateManifestURL) + normalizeOfficial(&cfg.Official) + cfg.Plugins.CacheDir = strings.TrimSpace(cfg.Plugins.CacheDir) + cfg.Plugins.ActiveDir = strings.TrimSpace(cfg.Plugins.ActiveDir) + if cfg.Plugins.ActiveDir == "" && cfg.Plugins.CacheDir != "" { + cfg.Plugins.ActiveDir = filepath.Join(filepath.Dir(cfg.Plugins.CacheDir), "plugin-active") + } + cfg.Plugins.HostURL = strings.TrimSpace(cfg.Plugins.HostURL) + if cfg.Plugins.HostURL == "" { + cfg.Plugins.HostURL = defaultPluginHostURL(cfg.HTTP.Listen) + } + cfg.Jobs.Queue.Driver = strings.TrimSpace(cfg.Jobs.Queue.Driver) + cfg.Jobs.RoutingAffinityDriver = strings.TrimSpace(cfg.Jobs.RoutingAffinityDriver) + cfg.Artifacts.Driver = strings.TrimSpace(cfg.Artifacts.Driver) + cfg.Artifacts.LocalRoot = strings.TrimSpace(cfg.Artifacts.LocalRoot) + cfg.Artifacts.S3.Endpoint = strings.TrimSpace(cfg.Artifacts.S3.Endpoint) + cfg.Artifacts.S3.Region = strings.TrimSpace(cfg.Artifacts.S3.Region) + cfg.Artifacts.S3.Bucket = strings.TrimSpace(cfg.Artifacts.S3.Bucket) + cfg.Artifacts.S3.Prefix = strings.Trim(strings.TrimSpace(cfg.Artifacts.S3.Prefix), "/") + cfg.Artifacts.S3.AccessKey = strings.TrimSpace(cfg.Artifacts.S3.AccessKey) + cfg.Artifacts.S3.SecretKey = strings.TrimSpace(cfg.Artifacts.S3.SecretKey) + cfg.Maintenance.BackupDir = strings.TrimSpace(cfg.Maintenance.BackupDir) + cfg.Maintenance.DiagnosticDir = strings.TrimSpace(cfg.Maintenance.DiagnosticDir) +} + +func normalizeOfficial(cfg *Official) { + cfg.Catalog.Mode = strings.TrimSpace(cfg.Catalog.Mode) + cfg.Catalog.BootstrapURL = strings.TrimSpace(cfg.Catalog.BootstrapURL) + cfg.Catalog.URL = strings.TrimSpace(cfg.Catalog.URL) + cfg.Catalog.ServicesURL = strings.TrimSpace(cfg.Catalog.ServicesURL) + cfg.Catalog.KeyID = strings.TrimSpace(cfg.Catalog.KeyID) + cfg.Catalog.PublicKey = strings.TrimSpace(cfg.Catalog.PublicKey) + cfg.License.URL = strings.TrimSpace(cfg.License.URL) + cfg.License.RedeemURL = strings.TrimSpace(cfg.License.RedeemURL) + cfg.License.KeyID = strings.TrimSpace(cfg.License.KeyID) + cfg.License.PublicKey = strings.TrimSpace(cfg.License.PublicKey) + cfg.Instance.ID = strings.TrimSpace(cfg.Instance.ID) + cfg.Instance.Fingerprint = strings.TrimSpace(cfg.Instance.Fingerprint) + cfg.Instance.DisplayName = strings.TrimSpace(cfg.Instance.DisplayName) +} + +func validateJobs(cfg Server) error { + limits := cfg.Jobs.Queue.Limits + if limits.Profile < 0 || limits.Tenant < 0 || limits.Principal < 0 { + return fmt.Errorf("%w: server.jobs.queue.limits values must be non-negative", ErrInvalidJobs) + } + switch cfg.Jobs.Queue.Driver { + case "memory": + case "redis": + if cfg.Storage.Redis.URL == "" { + return fmt.Errorf("%w: server.storage.redis.url is required when the queue driver is redis", ErrInvalidJobs) + } + default: + return fmt.Errorf("%w: server.jobs.queue.driver must be memory or redis", ErrInvalidJobs) + } + switch cfg.Jobs.RoutingAffinityDriver { + case "repository": + case "redis": + if cfg.Storage.Redis.URL == "" { + return fmt.Errorf("%w: server.storage.redis.url is required when routing affinity uses redis", ErrInvalidJobs) + } + default: + return fmt.Errorf("%w: server.jobs.routing-affinity-driver must be repository or redis", ErrInvalidJobs) + } + return nil +} + +func validateArtifacts(cfg Artifacts) error { + switch cfg.Driver { + case "none": + return nil + case "local": + if cfg.LocalRoot == "" { + return fmt.Errorf("%w: server.artifacts.local-root is required for the local driver", ErrInvalidArtifacts) + } + return nil + case "s3": + if cfg.S3.Bucket == "" || cfg.S3.AccessKey == "" || cfg.S3.SecretKey == "" { + return fmt.Errorf("%w: S3 bucket, access-key, and secret-key are required", ErrInvalidArtifacts) + } + return nil + default: + return fmt.Errorf("%w: server.artifacts.driver must be none, local, or s3", ErrInvalidArtifacts) + } +} + +func validateOfficial(cfg Official) error { + if (cfg.License.KeyID == "") != (cfg.License.PublicKey == "") { + return fmt.Errorf("%w: license key-id and public-key must be configured together", ErrInvalidOfficial) + } + switch cfg.Catalog.Mode { + case "disabled": + return nil + case "online", "private_mirror": + if cfg.Catalog.BootstrapURL == "" { + if cfg.Catalog.URL == "" || cfg.Catalog.KeyID == "" || cfg.Catalog.PublicKey == "" { + return fmt.Errorf("%w: catalog URL and trust material are required without a bootstrap URL", ErrInvalidOfficial) + } + } + return nil + case "offline": + if cfg.Catalog.KeyID == "" || cfg.Catalog.PublicKey == "" { + return fmt.Errorf("%w: offline catalog mode requires key-id and public-key", ErrInvalidOfficial) + } + return nil + default: + return fmt.Errorf("%w: catalog mode must be disabled, online, private_mirror, or offline", ErrInvalidOfficial) + } +} + +func isDeploymentRole(value string) bool { + switch value { + case "personal", "relay_operator", "enterprise", "platform": + return true + default: + return false + } +} + +func validRuntimeNamespace(value string) bool { + if value == "" || len(value) > 96 { + return false + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune("._-", character) { + continue + } + return false + } + return true +} + +func defaultPluginHostURL(listen string) string { + listen = strings.TrimSpace(listen) + if listen == "" { + return "" + } + host, port, err := net.SplitHostPort(listen) + if err != nil { + if strings.HasPrefix(listen, ":") { + port = strings.TrimPrefix(listen, ":") + } else { + return "" + } + } + host = strings.Trim(host, "[]") + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + if port == "" { + return "" + } + return "http://" + net.JoinHostPort(host, port) + "/api/v1/plugin-host" +} diff --git a/backend/internal/server/account_routes.go b/backend/internal/server/account_routes.go index 538da33..0d5a962 100644 --- a/backend/internal/server/account_routes.go +++ b/backend/internal/server/account_routes.go @@ -38,7 +38,7 @@ type accountSecurityResponse struct { func registerAccountRoutes(api *gin.RouterGroup, opts Options) { account := api.Group("/account") - account.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + account.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) account.GET("/profile", func(c *gin.Context) { data, err := currentAccountProfile(c, opts) diff --git a/backend/internal/server/admin_routes_test.go b/backend/internal/server/admin_routes_test.go index c5f6209..65a771b 100644 --- a/backend/internal/server/admin_routes_test.go +++ b/backend/internal/server/admin_routes_test.go @@ -12,13 +12,12 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/system" ) func TestAdminDashboardEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/dashboard", nil) rec := httptest.NewRecorder() @@ -40,7 +39,7 @@ func TestAdminDashboardEndpoint(t *testing.T) { } func TestAdminModelPricingEndpoints(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) createBody := bytes.NewBufferString(`{"model":"priced-model","currency":"USD","input_price_cents_per_1m_tokens":120,"output_price_cents_per_1m_tokens":480,"status":"active"}`) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/model-pricings", createBody) @@ -96,7 +95,7 @@ func TestAdminModelPricingEndpoints(t *testing.T) { } func TestAdminGatewayModelAndRouteEndpoints(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "route provider", Type: "openai_compatible", BaseURL: "https://provider.example/v1", Status: controlplane.ProviderStatusActive, Models: []string{"upstream-chat"}, APIKey: "provider-secret", @@ -184,7 +183,7 @@ func TestAdminProviderAccountModelEndpoints(t *testing.T) { _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"existing"},{"id":"new-model"}]}`)) })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{SecretKey: "admin-model-secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Inventory provider", Type: "openai_compatible", BaseURL: upstream.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"existing"}, APIKey: "provider-secret"}) if err != nil { t.Fatal(err) @@ -215,7 +214,7 @@ func TestAdminProviderAccountModelEndpoints(t *testing.T) { } func TestAdminGovernancePolicyEndpoints(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) createBody := bytes.NewBufferString(`{"name":"Platform policy","scope_type":"global","model_allowlist":["gpt-4o-mini"],"model_denylist":[],"qps_limit":10,"monthly_token_limit":1000000,"monthly_budget_cents":50000,"overage_action":"block","prompt_logging_mode":"metadata_only","retention_days":30,"tool_call_allowed":true,"image_input_allowed":true,"web_access_allowed":false,"status":"active"}`) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/policies", createBody) @@ -271,7 +270,7 @@ func TestAdminGovernancePolicyEndpoints(t *testing.T) { } func TestAdminRecordEndpointsSupportQueryParameters(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "query key", ModelAllowlist: []string{"model-a", "model-b"}, @@ -535,7 +534,7 @@ func TestAdminRecordEndpointsSupportQueryParameters(t *testing.T) { } func TestCreateAPIKeyEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) body := bytes.NewBufferString(`{"name":"demo","model_allowlist":["gpt-4o-mini"],"qps_limit":2,"monthly_token_limit":1000}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/api-keys", body) @@ -559,7 +558,7 @@ func TestCreateAPIKeyEndpoint(t *testing.T) { } func TestAPIKeyPolicyExplanationEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) policyBody := bytes.NewBufferString(`{"name":"Platform policy","scope_type":"global","model_allowlist":["gpt-4o-mini"],"qps_limit":5,"monthly_token_limit":1000,"overage_action":"block","prompt_logging_mode":"metadata_only","retention_days":30,"tool_call_allowed":true,"image_input_allowed":true,"web_access_allowed":false,"status":"active"}`) policyReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/policies", policyBody) @@ -612,7 +611,7 @@ func TestAPIKeyPolicyExplanationEndpoint(t *testing.T) { } func TestUpdateProviderEndpointKeepsExistingSecret(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) createBody := bytes.NewBufferString(`{"name":"Vendor A","type":"openai_compatible","base_url":"https://example.com/v1","status":"active","models":["gpt-4o-mini"],"priority":10,"api_key":"sk-test-123456"}`) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/providers", createBody) @@ -649,7 +648,7 @@ func TestUpdateProviderEndpointKeepsExistingSecret(t *testing.T) { } func TestCheckProviderEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/providers/prov_openai_compatible/check", nil) rec := httptest.NewRecorder() @@ -685,7 +684,7 @@ func TestCheckProviderEndpoint(t *testing.T) { } func TestAdminRoutingGroupsAndProviderAccountsEndpoints(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/models" { t.Fatalf("path = %s", r.URL.Path) @@ -802,7 +801,7 @@ func TestAdminRoutingGroupsAndProviderAccountsEndpoints(t *testing.T) { } func TestAdminProviderAccountClearCooldownEndpoint(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "Cooldown provider", Type: "openai_compatible", @@ -867,7 +866,7 @@ func TestAdminProviderAccountClearCooldownEndpoint(t *testing.T) { } func TestAdminSystemCheckUpdatesEndpoint(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/check-updates?force=true", nil) rec := httptest.NewRecorder() @@ -899,7 +898,7 @@ func TestAdminSystemCheckUpdatesEndpoint(t *testing.T) { } func TestAdminSystemUpdateWithoutManifestRequiresManualConfiguration(t *testing.T) { - handler, _ := newTestRuntime(t, config.Config{}) + handler, _ := newTestRuntime(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil) req.Header.Set("Idempotency-Key", "test-update") @@ -915,7 +914,7 @@ func TestAdminSystemUpdateWithoutManifestRequiresManualConfiguration(t *testing. } func TestSystemBackupEndpointsExposeEmptyListAndRejectMemoryBackup(t *testing.T) { - handler, _ := newTestRuntime(t, config.Config{}) + handler, _ := newTestRuntime(t, RuntimeConfig{}) for _, path := range []string{"/api/v1/admin/system/backups", "/api/v1/console/system/backups", "/api/v1/operator/system/backups"} { req := httptest.NewRequest(http.MethodGet, path, nil) diff --git a/backend/internal/server/ai_job_admin_routes_test.go b/backend/internal/server/ai_job_admin_routes_test.go index 4279517..2220ac7 100644 --- a/backend/internal/server/ai_job_admin_routes_test.go +++ b/backend/internal/server/ai_job_admin_routes_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" ) @@ -24,7 +23,7 @@ func (testAIJobRuntime) Status() controlplane.DurableAIJobRuntimeStatus { } func TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions(t *testing.T) { - handler, control := newTestRuntimeWithDurableAdmission(t, config.Config{AdminToken: "secret"}, testAIJobRuntime{}) + handler, control := newTestRuntimeWithDurableAdmission(t, RuntimeConfig{AdminToken: "secret"}, testAIJobRuntime{}) ctx := context.Background() model, err := control.CreateGatewayModel(ctx, "tester", controlplane.GatewayModelRequest{ ModelID: "admin-job-model", Name: "Admin job model", Modality: "image", Status: controlplane.GatewayModelStatusActive, @@ -93,7 +92,7 @@ func TestAdminAIJobEndpointsProvideRuntimeDetailAndSafeActions(t *testing.T) { } func TestAdminAIJobRBACRequiresGlobalScope(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) ctx := context.Background() department, err := control.CreateDepartment(ctx, "tester", controlplane.DepartmentRequest{Name: "Job team", Code: "job-team", Status: controlplane.DepartmentStatusActive}) if err != nil { diff --git a/backend/internal/server/alert_routes_test.go b/backend/internal/server/alert_routes_test.go index 39b4681..191e3d9 100644 --- a/backend/internal/server/alert_routes_test.go +++ b/backend/internal/server/alert_routes_test.go @@ -7,13 +7,12 @@ import ( "net/http/httptest" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestAdminAlertEndpoints(t *testing.T) { ctx := context.Background() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(ctx, "tester", controlplane.APIKeyCreateRequest{ Name: "HTTP alert key", ModelAllowlist: []string{"gpt-alert"}, diff --git a/backend/internal/server/api_key_rotation_routes_test.go b/backend/internal/server/api_key_rotation_routes_test.go index 2ae3f46..aaaa8c9 100644 --- a/backend/internal/server/api_key_rotation_routes_test.go +++ b/backend/internal/server/api_key_rotation_routes_test.go @@ -9,12 +9,11 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestAdminAPIKeyRotationAcceptsGracePeriod(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "grace route", ModelAllowlist: []string{"model-a"}, }) @@ -53,7 +52,7 @@ func TestAdminAPIKeyRotationAcceptsGracePeriod(t *testing.T) { } func TestAdminAPIKeyRotationRejectsInvalidGracePeriodWithoutMutation(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "invalid grace route", ModelAllowlist: []string{"model-a"}, }) diff --git a/backend/internal/server/artifact_admin_routes_test.go b/backend/internal/server/artifact_admin_routes_test.go index 571d417..e3b2e99 100644 --- a/backend/internal/server/artifact_admin_routes_test.go +++ b/backend/internal/server/artifact_admin_routes_test.go @@ -9,13 +9,12 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" ) func TestAdminArtifactEndpointsReturnFilteredRedactedRecords(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) artifact := createAdminRouteArtifact(t, control) if err := control.SetArtifactSink(serverArtifactSink{id: "customer-sink"}); err != nil { t.Fatal(err) @@ -81,7 +80,7 @@ func TestAdminArtifactEndpointsReturnFilteredRedactedRecords(t *testing.T) { } func TestAdminArtifactRBACSeparatesReadAndRetryAndRequiresGlobalScope(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) ctx := context.Background() auditor, err := control.CreateWorkspaceUser(ctx, "tester", controlplane.WorkspaceUserRequest{ Email: "artifact-auditor@example.test", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleReadOnlyAuditor, diff --git a/backend/internal/server/auth_rate_limit_test.go b/backend/internal/server/auth_rate_limit_test.go index 8584119..1e1e5b6 100644 --- a/backend/internal/server/auth_rate_limit_test.go +++ b/backend/internal/server/auth_rate_limit_test.go @@ -8,8 +8,14 @@ import ( func TestAuthAttemptLimiterBlocksAndResets(t *testing.T) { l := newAuthAttemptLimiter(2, time.Minute) now := time.Now() - if !l.Allow("ip", now) || !l.Allow("ip", now) || l.Allow("ip", now) { - t.Fatal("limiter did not enforce threshold") + if !l.Allow("ip", now) { + t.Fatal("limiter rejected the first attempt") + } + if !l.Allow("ip", now) { + t.Fatal("limiter rejected the second attempt") + } + if l.Allow("ip", now) { + t.Fatal("limiter allowed an attempt above the threshold") } l.Reset("ip") if !l.Allow("ip", now) { diff --git a/backend/internal/server/customer_routes_test.go b/backend/internal/server/customer_routes_test.go index 26f7e7e..5512bbd 100644 --- a/backend/internal/server/customer_routes_test.go +++ b/backend/internal/server/customer_routes_test.go @@ -8,14 +8,13 @@ import ( "net/http/httptest" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/settings" "github.com/astercloud/asterrouter/backend/internal/system" ) func TestCustomerNotificationRoutesPersistAndMarkRead(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, _, err := control.RegisterWorkspaceUser(context.Background(), "notify-routes@example.test", "long-password", "Notify", false) if err != nil { t.Fatal(err) @@ -87,7 +86,7 @@ func TestCustomerNotificationRoutesPersistAndMarkRead(t *testing.T) { } func TestCustomerBillingRoutesAreSeparateFromOperator(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "relay-customer@example.test", DisplayName: "Relay Customer", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper, @@ -135,7 +134,7 @@ func TestCustomerRoutesRequireRelayOperatorProfile(t *testing.T) { t.Fatal(err) } handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), }) req := httptest.NewRequest(http.MethodGet, "/api/v1/customer/billing", nil) diff --git a/backend/internal/server/department_routes_test.go b/backend/internal/server/department_routes_test.go index 0e96db9..9d02596 100644 --- a/backend/internal/server/department_routes_test.go +++ b/backend/internal/server/department_routes_test.go @@ -9,12 +9,11 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestAdminDepartmentEndpoints(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) parentBody := bytes.NewBufferString(`{"name":"Engineering","code":"eng","cost_center":"eng-core","monthly_budget_cents":250000,"status":"active"}`) parentReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/departments", parentBody) diff --git a/backend/internal/server/effective_pricing_routes_test.go b/backend/internal/server/effective_pricing_routes_test.go index 4b35af7..0841998 100644 --- a/backend/internal/server/effective_pricing_routes_test.go +++ b/backend/internal/server/effective_pricing_routes_test.go @@ -10,12 +10,11 @@ import ( "sync/atomic" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "pricing provider", Type: "openai_compatible", BaseURL: "https://provider.example/v1", Status: controlplane.ProviderStatusActive, Models: []string{"upstream-model"}, APIKey: "provider-secret", @@ -85,7 +84,7 @@ func TestEffectivePricingAdminEndpointsCreatePriceAndReconcileBilling(t *testing } func TestEffectivePricingPolicyEndpointRejectsUnsafeValues(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) request := httptest.NewRequest(http.MethodPut, "/api/v1/admin/effective-pricing/policy", bytes.NewBufferString(`{"mode":"canary","window_hours":24,"min_sample_count":0,"min_metrics_coverage":0.8,"min_billing_consistency":0.95,"min_cost_improvement":0.08,"max_error_rate_regression":0.005,"max_p95_latency_regression":0.2,"canary_percent":5,"supplier_affinity_ttl_seconds":86400,"account_affinity_ttl_seconds":1800}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() @@ -105,7 +104,7 @@ func TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLi })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "billing source", Type: "openai_compatible", BaseURL: upstream.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"model"}, APIKey: "provider-secret", @@ -188,7 +187,7 @@ func TestProviderBillingSourceInspectionEndpointDetectsSub2APIWithoutInventingLi } func TestEffectivePricingDecisionEvaluationsEndpointReturnsEmptyHistory(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/effective-pricing/decisions/decision-missing/evaluations?limit=20", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -210,7 +209,7 @@ func TestProviderCacheProbeEndpointRunsControlledSequenceAndRejectsMissingConfir })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "probe provider", Type: "openai_compatible", BaseURL: upstream.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"probe-model"}, APIKey: "provider-secret", diff --git a/backend/internal/server/export_routes_test.go b/backend/internal/server/export_routes_test.go index 3932190..9ec3642 100644 --- a/backend/internal/server/export_routes_test.go +++ b/backend/internal/server/export_routes_test.go @@ -10,12 +10,11 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestAdminRecordExportEndpointsSupportQueryParameters(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "export key", ModelAllowlist: []string{"model-a", "model-b"}, @@ -77,7 +76,7 @@ func TestAdminRecordExportEndpointsSupportQueryParameters(t *testing.T) { } func TestAdminAsyncExportJobLifecycle(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "async export key", ModelAllowlist: []string{"model-a"}, diff --git a/backend/internal/server/external_auth_routes_test.go b/backend/internal/server/external_auth_routes_test.go index 973ba81..7c5056b 100644 --- a/backend/internal/server/external_auth_routes_test.go +++ b/backend/internal/server/external_auth_routes_test.go @@ -12,7 +12,6 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/settings" ) @@ -126,7 +125,7 @@ func TestPlatformUsageSinkRoutesMaskSecretsAndRejectCrossSinkRequeue(t *testing. settingsService := settings.NewService(settings.NewMemoryRepository(), settings.ServiceOptions{ Version: "test", StorageMode: "memory", EnabledProfiles: []string{"platform"}, DefaultProfile: "platform", }) - handler := New(Options{Config: config.Config{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control}) + handler := New(Options{Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control}) ctx := context.Background() tenant, err := control.CreatePlatformTenant(ctx, "operator", controlplane.PlatformTenantRequest{Name: "Usage routes", Slug: "usage-routes"}) if err != nil { @@ -213,7 +212,7 @@ func newPlatformExternalAuthHandler(t *testing.T) (http.Handler, *controlplane.S Version: "test", StorageMode: "memory", EnabledProfiles: []string{"platform"}, DefaultProfile: "platform", }) control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") - return New(Options{Config: config.Config{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control}), control + return New(Options{Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control}), control } func signedGatewayContext(t testing.TB, claims controlplane.ExternalAuthContextClaims, secret string) string { diff --git a/backend/internal/server/gateway_artifacts_test.go b/backend/internal/server/gateway_artifacts_test.go index 2c51428..e39090f 100644 --- a/backend/internal/server/gateway_artifacts_test.go +++ b/backend/internal/server/gateway_artifacts_test.go @@ -10,12 +10,11 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestGatewayArtifactAuthorizationRangeAndDeletion(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "artifact-image-model", Name: "Artifact image model", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -138,7 +137,7 @@ func TestPublicArtifactResponseExposesOnlyDeliveredCustomerSinkReference(t *test } func TestGatewayProxyArtifactContentAndUnavailablePlugin(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "proxy-image-model", Name: "Proxy image model", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { diff --git a/backend/internal/server/gateway_audio_test.go b/backend/internal/server/gateway_audio_test.go index 4c82353..505eeef 100644 --- a/backend/internal/server/gateway_audio_test.go +++ b/backend/internal/server/gateway_audio_test.go @@ -14,7 +14,6 @@ import ( "sync" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) @@ -84,7 +83,7 @@ func newAudioProtocolFixture(t *testing.T, artifactPolicy string) *audioProtocol })) t.Cleanup(upstream.Close) - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) fixture.handler, fixture.control = handler, control if artifactPolicy == controlplane.GatewayArtifactPolicyTemporary || artifactPolicy == controlplane.GatewayArtifactPolicyManaged { if err := control.SetArtifactStore(controlplane.NewMemoryArtifactStore()); err != nil { diff --git a/backend/internal/server/gateway_billing_hold_test.go b/backend/internal/server/gateway_billing_hold_test.go index 6a81cca..066e13b 100644 --- a/backend/internal/server/gateway_billing_hold_test.go +++ b/backend/internal/server/gateway_billing_hold_test.go @@ -8,7 +8,6 @@ import ( "sync/atomic" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) @@ -95,7 +94,7 @@ func TestGatewayBillingHoldLifecycle(t *testing.T) { func setupGatewayBillingHoldRuntime(t *testing.T, upstreamURL string) (http.Handler, *controlplane.Service, string) { t.Helper() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "Billing provider", Type: "openai_compatible", BaseURL: upstreamURL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"billing-upstream"}, APIKey: "billing-provider-secret", diff --git a/backend/internal/server/gateway_contract_test.go b/backend/internal/server/gateway_contract_test.go index 36f347e..50320ff 100644 --- a/backend/internal/server/gateway_contract_test.go +++ b/backend/internal/server/gateway_contract_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/testutil" ) @@ -198,7 +197,7 @@ func TestGatewayProtocolEdgeRejectsQueryAndConflictingCredentials(t *testing.T) func TestGatewayCanonicalPlannerRejectsChatOnImageOnlyModel(t *testing.T) { upstream := testutil.NewFakeOpenAI(t) - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "image provider", Type: "openai_compatible", BaseURL: upstream.BaseURL(), Status: controlplane.ProviderStatusActive, Models: []string{"upstream-image"}, APIKey: "provider-secret", @@ -240,7 +239,7 @@ func TestGatewayCanonicalPlannerRejectsChatOnImageOnlyModel(t *testing.T) { func TestGatewayTraceIncludesPlannerExclusionEvidence(t *testing.T) { upstream := testutil.NewFakeOpenAI(t) - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "excluded provider", Type: "openai_compatible", BaseURL: upstream.BaseURL(), Status: controlplane.ProviderStatusActive, Models: []string{"upstream-model"}, APIKey: "provider-secret", @@ -359,7 +358,7 @@ func gatewayContractRuntime(t *testing.T, upstream *testutil.FakeOpenAI) (http.H func gatewayContractRuntimeWithKeyRequest(t *testing.T, upstream *testutil.FakeOpenAI, keyRequest controlplane.APIKeyCreateRequest) (http.Handler, *controlplane.Service, string) { t.Helper() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "contract provider", Type: "openai_compatible", BaseURL: upstream.BaseURL(), Status: controlplane.ProviderStatusActive, Models: []string{"upstream-model"}, APIKey: "provider-secret", diff --git a/backend/internal/server/gateway_job_events_test.go b/backend/internal/server/gateway_job_events_test.go index 845c2b3..fc5b58b 100644 --- a/backend/internal/server/gateway_job_events_test.go +++ b/backend/internal/server/gateway_job_events_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) @@ -313,7 +312,7 @@ func gatewayJobEventTestRuntime(t *testing.T) (http.Handler, *controlplane.Servi func gatewayJobEventTestRuntimeWithKey(t *testing.T, configure func(*controlplane.APIKeyCreateRequest)) (http.Handler, *controlplane.Service, controlplane.APIKeyCreateResponse, publicAIJobResponse) { t.Helper() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "event-image-job", Name: "Event image job", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { diff --git a/backend/internal/server/gateway_jobs_test.go b/backend/internal/server/gateway_jobs_test.go index e6f74a5..746f260 100644 --- a/backend/internal/server/gateway_jobs_test.go +++ b/backend/internal/server/gateway_jobs_test.go @@ -10,13 +10,12 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" ) func TestGatewayDurableJobLifecycleAndIdempotency(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "public-image-job", Name: "Public image job", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -82,7 +81,7 @@ func TestGatewayDurableJobLifecycleAndIdempotency(t *testing.T) { } func TestGatewayJobActionCreatesOwnedIdempotentChildJob(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "action-image", controlplane.GatewayModelRequest{ ModelID: "action-image", Name: "Action image", Modality: controlplane.GatewayModalityImage, Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -153,7 +152,7 @@ func TestGatewayJobActionCreatesOwnedIdempotentChildJob(t *testing.T) { } func TestGatewayMediaGenerationRoutesUseDurableJobContract(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) for _, test := range []struct { path string model string @@ -197,7 +196,7 @@ func TestGatewayMediaGenerationRoutesUseDurableJobContract(t *testing.T) { } func TestGatewayMediaDirectModesFailClosedWithoutCreatingJobs(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "direct-video", controlplane.GatewayModelRequest{ ModelID: "direct-video", Name: "Direct video", Modality: controlplane.GatewayModalityVideo, Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -233,7 +232,7 @@ func TestGatewayMediaDirectModesFailClosedWithoutCreatingJobs(t *testing.T) { } func TestGatewayMediaGenerationRouteFailsClosedWithoutAdapter(t *testing.T) { - handler, control := newTestRuntimeWithDurableAdmission(t, config.Config{}, nil) + handler, control := newTestRuntimeWithDurableAdmission(t, RuntimeConfig{}, nil) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "unavailable-video", Name: "Unavailable video", Modality: controlplane.GatewayModalityVideo, Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -262,7 +261,7 @@ func TestGatewayMediaGenerationRouteFailsClosedWithoutAdapter(t *testing.T) { } func TestGatewayDurableJobQueueBackpressure(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "limited-image-job", Name: "Limited image job", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -289,7 +288,7 @@ func TestGatewayDurableJobQueueBackpressure(t *testing.T) { } func TestGatewayDurableJobFailsClosedWithoutExecutableAdapter(t *testing.T) { - handler, control := newTestRuntimeWithDurableAdmission(t, config.Config{}, nil) + handler, control := newTestRuntimeWithDurableAdmission(t, RuntimeConfig{}, nil) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "unavailable-image-job", Name: "Unavailable image job", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -320,7 +319,7 @@ func TestGatewayDurableJobCapabilityRejectionPersistsInternalTraceEvidence(t *te UpstreamModel: "upstream-internal", Reason: controlplane.DurableAIJobCapabilityModalityUnsupported, }}, } - handler, control := newTestRuntimeWithDurableAdmission(t, config.Config{}, rejectingDurableAIJobAdmission{evaluation: evaluation}) + handler, control := newTestRuntimeWithDurableAdmission(t, RuntimeConfig{}, rejectingDurableAIJobAdmission{evaluation: evaluation}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "trace-video-job", Name: "Trace video job", Modality: controlplane.GatewayModalityVideo, Status: controlplane.GatewayModelStatusActive, }); err != nil { @@ -359,7 +358,7 @@ func TestGatewayDurableJobCapabilityRejectionPersistsInternalTraceEvidence(t *te } func TestGatewayDurableJobAuthorizationAndNonDisclosure(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "test", controlplane.GatewayModelRequest{ ModelID: "isolated-image-job", Name: "Isolated image job", Modality: "image", Status: controlplane.GatewayModelStatusActive, }); err != nil { diff --git a/backend/internal/server/gateway_protocols_test.go b/backend/internal/server/gateway_protocols_test.go index 67c8256..c56dca9 100644 --- a/backend/internal/server/gateway_protocols_test.go +++ b/backend/internal/server/gateway_protocols_test.go @@ -11,7 +11,6 @@ import ( "sync" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) @@ -62,7 +61,7 @@ func newNativeProtocolFixture(t *testing.T) *nativeProtocolFixture { } })) t.Cleanup(upstream.Close) - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) fixture.handler, fixture.control = handler, control provider, err := control.CreateProvider(context.Background(), "test", controlplane.ProviderRequest{ Name: "Native protocol provider", Type: "openai_compatible", BaseURL: upstream.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"native-upstream"}, APIKey: "provider-secret", diff --git a/backend/internal/server/gateway_realtime_test.go b/backend/internal/server/gateway_realtime_test.go index 9447b0e..846777c 100644 --- a/backend/internal/server/gateway_realtime_test.go +++ b/backend/internal/server/gateway_realtime_test.go @@ -14,7 +14,6 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" "github.com/coder/websocket" @@ -127,7 +126,7 @@ type realtimeGatewayFixture struct { func newRealtimeGatewayFixture(t *testing.T, upstreamURL string) realtimeGatewayFixture { t.Helper() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "test", controlplane.ProviderRequest{ Name: "Realtime provider", Type: "openai_compatible", BaseURL: upstreamURL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"upstream-realtime"}, APIKey: "provider-fallback-secret", diff --git a/backend/internal/server/gateway_routes_test.go b/backend/internal/server/gateway_routes_test.go index d6afc39..a329acb 100644 --- a/backend/internal/server/gateway_routes_test.go +++ b/backend/internal/server/gateway_routes_test.go @@ -13,13 +13,12 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/gin-gonic/gin" ) func TestGatewayModelsRequiresAPIKey(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) rec := httptest.NewRecorder() @@ -31,7 +30,7 @@ func TestGatewayModelsRequiresAPIKey(t *testing.T) { } func TestGatewayModelsUsesAPIKeyAllowlist(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if _, err := control.CreateGatewayModel(context.Background(), "tester", controlplane.GatewayModelRequest{ModelID: "gpt-4o-mini", Name: "GPT", Status: controlplane.GatewayModelStatusActive}); err != nil { t.Fatalf("CreateGatewayModel(): %v", err) } @@ -67,7 +66,7 @@ func TestGatewayModelsUsesAPIKeyAllowlist(t *testing.T) { } func TestGatewayChatCompletionAuthorizesModelAndAudits(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "gateway", ModelAllowlist: []string{"gpt-4o-mini"}, @@ -101,7 +100,7 @@ func TestGatewayChatCompletionAuthorizesModelAndAudits(t *testing.T) { } func TestGatewayChatCompletionEnforcesQPSLimitAndRecordsTrace(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "gateway limited", ModelAllowlist: []string{"gpt-4o-mini"}, @@ -159,7 +158,7 @@ func TestGatewayChatCompletionEnforcesQPSLimitAndRecordsTrace(t *testing.T) { } func TestGatewayChatCompletionEnforcesWorkspaceKeyBudgetAndRecordsTrace(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) policy, err := control.CreateGovernancePolicy(context.Background(), "tester", controlplane.GovernancePolicyRequest{ Name: "Workspace key budget", ScopeType: controlplane.GovernancePolicyScopeGlobal, @@ -232,7 +231,7 @@ func TestGatewayChatCompletionEnforcesWorkspaceKeyBudgetAndRecordsTrace(t *testi } func TestGatewayChatCompletionRejectsDisallowedModel(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "gateway", ModelAllowlist: []string{"gpt-4o-mini"}, @@ -280,7 +279,7 @@ func TestGatewayChatCompletionForwardsToConfiguredProvider(t *testing.T) { })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "test provider", Type: "openai_compatible", @@ -348,7 +347,7 @@ func TestGatewayChatCompletionRoutesThroughProviderAccountPool(t *testing.T) { })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "account route provider", Type: "openai_compatible", @@ -461,7 +460,7 @@ func TestGatewayChatCompletionFallsBackToNextAccountAfterUpstreamFailure(t *test })) defer healthy.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) failingProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "failing provider", Type: "openai_compatible", @@ -583,7 +582,7 @@ func TestGatewayChatCompletionFallsBackAfterRateLimitAndServerError(t *testing.T })) defer fallback.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) primaryProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Failing provider", Type: "openai_compatible", BaseURL: failing.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"model"}, APIKey: "primary-provider-secret"}) if err != nil { t.Fatal(err) @@ -650,7 +649,7 @@ func TestGatewayChatCompletionFallsBackAfterPrimaryTimeoutAndReleasesCapacity(t })) defer fallback.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) primaryProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Timeout primary", Type: "openai_compatible", BaseURL: primary.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"model"}, APIKey: "primary-secret"}) if err != nil { t.Fatal(err) @@ -719,7 +718,7 @@ func TestGatewayStreamingInterruptionRecordsErrorWithoutUnsafeFailover(t *testin })) defer fallback.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) primaryProvider, _ := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Interrupted stream", Type: "openai_compatible", BaseURL: interrupted.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"model"}, APIKey: "primary-secret"}) fallbackProvider, _ := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{Name: "Stream fallback", Type: "openai_compatible", BaseURL: fallback.URL + "/v1", Status: controlplane.ProviderStatusActive, Models: []string{"model"}, APIKey: "fallback-secret"}) primaryAccount := createGatewayTestAccount(t, control, primaryProvider, "model", "primary-secret", 10, 1) @@ -768,7 +767,7 @@ func TestGatewayChatCompletionSkipsAccountAtConcurrencyCapacity(t *testing.T) { })) defer free.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) busyProvider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "busy provider", Type: "openai_compatible", @@ -877,7 +876,7 @@ func TestGatewayChatCompletionSkipsAccountAtConcurrencyCapacity(t *testing.T) { } func TestGatewayChatCompletionRejectsOversizedRequestBody(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", gatewayRequestBodyLimit+1))) req.Header.Set("Content-Type", "application/json") @@ -905,7 +904,7 @@ func TestGatewayChatCompletionPassesThroughUpstreamError(t *testing.T) { })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "limited provider", Type: "openai_compatible", @@ -957,7 +956,7 @@ func TestGatewayChatCompletionStreamsConfiguredProvider(t *testing.T) { })) defer upstream.Close() - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) provider, err := control.CreateProvider(context.Background(), "tester", controlplane.ProviderRequest{ Name: "stream provider", Type: "openai_compatible", @@ -1003,7 +1002,7 @@ func TestGatewayChatCompletionStreamsConfiguredProvider(t *testing.T) { } func TestGatewayChatCompletionRejectsStreamingWithoutProvider(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) created, err := control.CreateAPIKey(context.Background(), "tester", controlplane.APIKeyCreateRequest{ Name: "gateway", ModelAllowlist: []string{"gpt-4o-mini"}, diff --git a/backend/internal/server/gateway_uploads_test.go b/backend/internal/server/gateway_uploads_test.go index df73e25..eb64709 100644 --- a/backend/internal/server/gateway_uploads_test.go +++ b/backend/internal/server/gateway_uploads_test.go @@ -12,12 +12,11 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestGatewayUploadCreatesOwnedInputArtifactAndReplaysIdempotently(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if err := control.SetArtifactStore(controlplane.NewMemoryArtifactStore()); err != nil { t.Fatal(err) } @@ -99,7 +98,7 @@ func TestGatewayUploadCreatesOwnedInputArtifactAndReplaysIdempotently(t *testing } func TestGatewayUploadFailsClosedWithoutStoreOrChecksum(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) key, err := control.CreateAPIKey(context.Background(), "test", controlplane.APIKeyCreateRequest{ Name: "upload caller", ModelAllowlist: []string{"upload-model"}, Scopes: []string{controlplane.GatewayScopeArtifactsWrite}, ArtifactPolicy: controlplane.GatewayArtifactPolicyTemporary, @@ -140,7 +139,7 @@ func TestGatewayUploadFailsClosedWithoutStoreOrChecksum(t *testing.T) { } func TestGatewayResumableUploadChunksAreOrderedIdempotentAndOwnerScoped(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if err := control.SetArtifactStore(controlplane.NewMemoryArtifactStore()); err != nil { t.Fatal(err) } @@ -248,7 +247,7 @@ func TestGatewayResumableUploadChunksAreOrderedIdempotentAndOwnerScoped(t *testi } func TestGatewayResumableUploadRejectsIncompleteAndTotalChecksumMismatch(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) if err := control.SetArtifactStore(controlplane.NewMemoryArtifactStore()); err != nil { t.Fatal(err) } diff --git a/backend/internal/server/identity_routes_test.go b/backend/internal/server/identity_routes_test.go index 3a1cb32..a91fe72 100644 --- a/backend/internal/server/identity_routes_test.go +++ b/backend/internal/server/identity_routes_test.go @@ -9,12 +9,11 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestAdminIdentityUserAndRoleBindingEndpoints(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) department, err := control.CreateDepartment(t.Context(), "tester", controlplane.DepartmentRequest{Name: "Engineering", Code: "eng", Status: controlplane.DepartmentStatusActive}) if err != nil { t.Fatal(err) @@ -128,7 +127,7 @@ func TestAdminIdentityUserAndRoleBindingEndpoints(t *testing.T) { } func TestAdminUserDepartmentAssignmentValidationAndSessionRevocation(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) engineering, err := control.CreateDepartment(t.Context(), "tester", controlplane.DepartmentRequest{Name: "Engineering", Code: "eng", Status: controlplane.DepartmentStatusActive}) if err != nil { t.Fatal(err) @@ -193,7 +192,7 @@ func TestAdminUserDepartmentAssignmentValidationAndSessionRevocation(t *testing. } func TestDepartmentAdministratorCanOnlyAssignAuthorizedDepartment(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) engineering, err := control.CreateDepartment(t.Context(), "tester", controlplane.DepartmentRequest{Name: "Engineering", Code: "eng", Status: controlplane.DepartmentStatusActive}) if err != nil { t.Fatal(err) @@ -236,7 +235,7 @@ func TestDepartmentAdministratorCanOnlyAssignAuthorizedDepartment(t *testing.T) } func TestAdminOrganizationGroupLifecycle(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) user, err := control.CreateWorkspaceUser(t.Context(), "tester", controlplane.WorkspaceUserRequest{Email: "group-member@example.test", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper}) if err != nil { t.Fatal(err) diff --git a/backend/internal/server/operator_routes_test.go b/backend/internal/server/operator_routes_test.go index 5e34b41..5e70441 100644 --- a/backend/internal/server/operator_routes_test.go +++ b/backend/internal/server/operator_routes_test.go @@ -7,13 +7,12 @@ import ( "net/http/httptest" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" operatorcore "github.com/astercloud/asterrouter/backend/internal/operator" ) func TestOperatorBusinessLifecycle(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) group := operatorPost[operatorcore.CustomerGroup](t, handler, "/api/v1/operator/customer-groups", `{"name":"Standard","status":"active"}`) plan := operatorPost[operatorcore.Plan](t, handler, "/api/v1/operator/plans", `{"name":"Starter","included_tokens":1000000,"monthly_limit_cents":1000,"rate_multiplier":1,"status":"active"}`) customerBody := `{"name":"Customer A","email":"a@example.com","group_id":"` + group.ID + `","plan_id":"` + plan.ID + `","credit_cents":500,"status":"active"}` diff --git a/backend/internal/server/platform_routes_test.go b/backend/internal/server/platform_routes_test.go index 87d4be3..238c8e5 100644 --- a/backend/internal/server/platform_routes_test.go +++ b/backend/internal/server/platform_routes_test.go @@ -9,7 +9,6 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" "github.com/astercloud/asterrouter/backend/internal/settings" @@ -18,7 +17,7 @@ import ( func TestPlatformAPIRequiresEnabledProfileAndExplicitSurfaceBinding(t *testing.T) { t.Run("disabled profile is not exposed", func(t *testing.T) { - handler := newTestHandler(t, config.Config{AdminToken: "secret"}) + handler := newTestHandler(t, RuntimeConfig{AdminToken: "secret"}) req := httptest.NewRequest(http.MethodGet, "/api/v1/platform/dashboard", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -42,7 +41,7 @@ func TestPlatformAPIRequiresEnabledProfileAndExplicitSurfaceBinding(t *testing.T t.Fatal(err) } handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), @@ -95,7 +94,7 @@ func TestSystemProfileChangesRequireSystemAdministrator(t *testing.T) { t.Fatal(err) } handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), @@ -153,7 +152,7 @@ func TestProfileBundleChangesAreDeniedAcrossSurfaceSettings(t *testing.T) { } } handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), @@ -191,7 +190,7 @@ func TestPlatformAPIKeysAllowOnlyWorkspaceOrServiceOwnership(t *testing.T) { }) control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), @@ -242,7 +241,7 @@ func TestPlatformOperationsAreProfileScopedAndDoNotDiscloseForeignResources(t *t platformJob, platformArtifact := createScopedOperationsFixture(t, control, model.ModelID, "platform", controlplane.ProfileScopePlatform) foreignJob, foreignArtifact := createScopedOperationsFixture(t, control, model.ModelID, "enterprise", "enterprise") handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), @@ -350,7 +349,7 @@ func TestPlatformControlPlaneScopesPlatformDomainAndObservability(t *testing.T) }) control := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") handler := New(Options{ - Config: config.Config{AdminToken: "secret"}, + Runtime: RuntimeConfig{AdminToken: "secret"}, SettingsService: settingsService, ControlService: control, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"}), diff --git a/backend/internal/server/plugin_routes_test.go b/backend/internal/server/plugin_routes_test.go index 1da9676..da071c7 100644 --- a/backend/internal/server/plugin_routes_test.go +++ b/backend/internal/server/plugin_routes_test.go @@ -16,7 +16,6 @@ import ( "testing" "time" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/plugins" "github.com/astercloud/asterrouter/backend/internal/settings" @@ -26,7 +25,7 @@ import ( ) func TestAdminPluginsCatalogEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins", nil) rec := httptest.NewRecorder() @@ -51,7 +50,7 @@ func TestAdminOfficialFeedSyncRecordsDisabledAttempt(t *testing.T) { settingsService := settings.NewService(settings.NewMemoryRepository(), settings.ServiceOptions{Version: "test", StorageMode: "memory", EnabledProfiles: []string{"enterprise"}}) controlService := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") pluginService := plugins.NewService(plugins.NewMemoryRepository()) - handler := New(Options{Config: config.Config{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) body := bytes.NewBufferString(`{"service_key":"provider-intelligence"}`) syncReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/feeds/sync", body) @@ -80,7 +79,7 @@ func TestAdminOfficialFeedSyncRecordsDisabledAttempt(t *testing.T) { } func TestPluginHostFeedEndpointRejectsExternalAndUnknownRuntime(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) externalReq := httptest.NewRequest(http.MethodGet, "/api/v1/plugin-host/com.asterrouter.test/feeds/provider-intelligence", nil) externalReq.RemoteAddr = "198.51.100.20:43100" @@ -102,7 +101,7 @@ func TestPluginHostFeedEndpointRejectsExternalAndUnknownRuntime(t *testing.T) { } func TestPluginHostProviderCallbackEndpointRejectsExternalAndUnknownRuntime(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) body := bytes.NewBufferString(`{"event_id":"event-1","adapter_id":"com.asterrouter.test","attempt_id":"attempt-1","provider_id":"provider-1","provider_account_id":"account-1","provider_task_id":"task-1","status":"running"}`) externalReq := httptest.NewRequest(http.MethodPost, "/api/v1/plugin-host/com.asterrouter.test/provider-callback", body) externalReq.RemoteAddr = "198.51.100.20:43100" @@ -138,7 +137,7 @@ func TestPluginOpenCatalogUsesScopedAPIToken(t *testing.T) { if err != nil { t.Fatalf("CreatePluginAPIToken(): %v", err) } - handler := New(Options{Config: config.Config{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) unauthorizedReq := httptest.NewRequest(http.MethodGet, "/api/v1/open/plugins/catalog?surface=personal", nil) unauthorizedRec := httptest.NewRecorder() @@ -212,7 +211,7 @@ func TestAdminPluginsCatalogSyncEndpoint(t *testing.T) { }, Now: func() time.Time { return now }, }) - handler := New(Options{Config: config.Config{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) syncReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/catalog-sync", nil) syncRec := httptest.NewRecorder() @@ -355,7 +354,7 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { TargetArch: "arm64", Now: func() time.Time { return now }, }) - handler := New(Options{Config: config.Config{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) syncReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/catalog-sync", nil) syncRec := httptest.NewRecorder() @@ -435,7 +434,7 @@ func TestAdminPluginPackageDownloadEndpoint(t *testing.T) { } func TestAdminPluginsEnableFreePluginAudits(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/com.asterrouter.notification.webhook/enable", nil) rec := httptest.NewRecorder() @@ -467,7 +466,7 @@ func TestAdminPluginsEnableFreePluginAudits(t *testing.T) { } func TestAdminPluginConfigEndpointsAuditAndMaskSecrets(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) body := bytes.NewBufferString(`{"settings":{"min_severity":"critical","alert_types":"api_key_quota"},"secrets":{"webhook_url":"https://example.com/hook","bearer_token":"secret-token"}}`) req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/plugins/com.asterrouter.notification.webhook/config", body) @@ -509,7 +508,7 @@ func TestAdminPluginConfigEndpointsAuditAndMaskSecrets(t *testing.T) { } func TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) const accessKey = "artifact-access-key-value" const secretKey = "artifact-secret-key-value" body := bytes.NewBufferString(`{ @@ -592,7 +591,7 @@ func TestAdminArtifactSinkDestinationEndpointsAuditAndMaskSecrets(t *testing.T) } func TestAdminArtifactSinkDestinationRBACAndInvalidPayloads(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "artifact-auditor@example.com", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleReadOnlyAuditor, }) @@ -649,7 +648,7 @@ func TestAdminArtifactSinkDestinationRBACAndInvalidPayloads(t *testing.T) { } func TestAdminPluginDeliveriesEndpoint(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{}) + handler, control := newTestRuntime(t, RuntimeConfig{}) webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) })) @@ -769,7 +768,7 @@ func TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus(t *testing.T) { if err := pluginService.EnsureSeedData(context.Background()); err != nil { t.Fatalf("Plugin EnsureSeedData(): %v", err) } - handler := New(Options{Config: config.Config{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: settingsService, ControlService: controlService, PluginService: pluginService, SystemService: system.NewService(system.Config{Version: "test", BuildType: "source"})}) statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins/license/status", nil) statusRec := httptest.NewRecorder() @@ -832,7 +831,7 @@ func TestAdminPluginLicenseImportEndpointAuditsAndUpdatesStatus(t *testing.T) { } func TestAdminPluginsRejectLockedPaidPlugin(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/com.asterrouter.notification.slack/enable", nil) rec := httptest.NewRecorder() diff --git a/backend/internal/server/portal_routes_test.go b/backend/internal/server/portal_routes_test.go index 905feee..d320680 100644 --- a/backend/internal/server/portal_routes_test.go +++ b/backend/internal/server/portal_routes_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/astercloud/asterrouter/backend/internal/auth" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) @@ -97,7 +96,7 @@ func TestPortalChannelVisibilityOnlyHidesModelCatalog(t *testing.T) { } func TestPortalForeignAPIKeyUsesNotFoundSemantics(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) owner, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{Email: "owner@example.test", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper}) if err != nil { t.Fatal(err) diff --git a/backend/internal/server/rbac_test.go b/backend/internal/server/rbac_test.go index 91917b9..aa62784 100644 --- a/backend/internal/server/rbac_test.go +++ b/backend/internal/server/rbac_test.go @@ -9,13 +9,12 @@ import ( "strings" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/gin-gonic/gin" ) func TestAdminRBACAllowsGlobalAuditorReadAndBlocksWrites(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "auditor@example.com", Status: controlplane.WorkspaceUserStatusActive, @@ -53,7 +52,7 @@ func TestAdminRBACAllowsGlobalAuditorReadAndBlocksWrites(t *testing.T) { } func TestAdminRBACBlocksDeveloperButPortalStillWorks(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "dev@example.com", Status: controlplane.WorkspaceUserStatusActive, @@ -101,7 +100,7 @@ func TestAdminRBACBlocksDeveloperButPortalStillWorks(t *testing.T) { } func TestAdminRBACResourceBindingOnlyGrantsMatchingResource(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "key-scope@example.com", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper, }) @@ -145,7 +144,7 @@ func TestAdminRBACResourceBindingOnlyGrantsMatchingResource(t *testing.T) { } func TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) ctx := context.Background() engineering, err := control.CreateDepartment(ctx, "tester", controlplane.DepartmentRequest{Name: "Engineering", Code: "eng", Status: controlplane.DepartmentStatusActive}) if err != nil { @@ -337,7 +336,7 @@ func TestDepartmentScopedAdministratorOnlySeesDepartmentUsersAndKeys(t *testing. } func TestSurfaceBindingExplicitlyGrantsOperatorAccess(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "operator@example.com", Status: controlplane.WorkspaceUserStatusActive, Role: controlplane.RoleDeveloper, }) @@ -387,7 +386,7 @@ func TestAdminRoutesResolveResourceDomains(t *testing.T) { } func TestAdminRBACProtectsPluginAndSystemWrites(t *testing.T) { - handler, control := newTestRuntime(t, config.Config{AdminToken: "secret"}) + handler, control := newTestRuntime(t, RuntimeConfig{AdminToken: "secret"}) user, err := control.CreateWorkspaceUser(context.Background(), "tester", controlplane.WorkspaceUserRequest{ Email: "auditor@example.com", Status: controlplane.WorkspaceUserStatusActive, diff --git a/backend/internal/server/retention_routes_test.go b/backend/internal/server/retention_routes_test.go index c5e599b..73821d2 100644 --- a/backend/internal/server/retention_routes_test.go +++ b/backend/internal/server/retention_routes_test.go @@ -6,12 +6,11 @@ import ( "net/http/httptest" "testing" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" ) func TestManualRetentionCleanupEndpointReturnsEvidence(t *testing.T) { - handler, _ := newTestRuntime(t, config.Config{}) + handler, _ := newTestRuntime(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/retention/cleanup", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 4525b3d..0830ec8 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -9,7 +9,6 @@ import ( "time" "github.com/astercloud/asterrouter/backend/internal/auth" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/httpx" operatorcore "github.com/astercloud/asterrouter/backend/internal/operator" @@ -20,7 +19,7 @@ import ( ) type Options struct { - Config config.Config + Runtime RuntimeConfig AuthService *auth.Service OIDCService *auth.OIDCService FeishuService *auth.FeishuService @@ -38,6 +37,12 @@ type Options struct { authBindingStore *authBindingStore } +type RuntimeConfig struct { + AdminToken string + DemoMode bool + FrontendDir string +} + type AIJobRuntimeStatusProvider interface { Status() controlplane.DurableAIJobRuntimeStatus } @@ -320,7 +325,7 @@ func New(opts Options) http.Handler { httpx.Error(c, http.StatusBadRequest, 1322, err.Error()) return } - if policy.EmailVerification && !opts.Config.DemoMode { + if policy.EmailVerification && !opts.Runtime.DemoMode { public, _ := opts.SettingsService.Public(c.Request.Context()) verifyURL := strings.TrimRight(public.PublicBaseURL, "/") + "/login?verify=" + url.QueryEscape(token) if mailErr := sendConfiguredEmail(c.Request.Context(), opts.SettingsService, "email_verification", user.Email, user.DisplayName, verifyURL); mailErr != nil { @@ -329,7 +334,7 @@ func New(opts Options) http.Handler { } } data := gin.H{"user_id": user.ID, "verification_required": policy.EmailVerification} - if policy.EmailVerification && opts.Config.DemoMode { + if policy.EmailVerification && opts.Runtime.DemoMode { data["verification_token"] = token } httpx.OK(c, data) @@ -703,11 +708,11 @@ func New(opts Options) http.Handler { "uuid": "", }) }) - api.GET("/auth/me", requireAdminAuth(opts.Config.AdminToken, opts.AuthService), func(c *gin.Context) { + api.GET("/auth/me", requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService), func(c *gin.Context) { httpx.OK(c, currentAuthUser(c, opts)) }) registerAccountRoutes(api, opts) - api.POST("/auth/totp/setup", requireAdminAuth(opts.Config.AdminToken, opts.AuthService), func(c *gin.Context) { + api.POST("/auth/totp/setup", requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService), func(c *gin.Context) { data, err := opts.ControlService.BeginTOTPSetup(c.Request.Context(), actor(c)) if err != nil { httpx.Error(c, http.StatusBadRequest, 1312, err.Error()) @@ -715,7 +720,7 @@ func New(opts Options) http.Handler { } httpx.OK(c, data) }) - api.POST("/auth/totp/confirm", requireAdminAuth(opts.Config.AdminToken, opts.AuthService), func(c *gin.Context) { + api.POST("/auth/totp/confirm", requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService), func(c *gin.Context) { var req struct { Code string `json:"code"` } @@ -738,7 +743,7 @@ func New(opts Options) http.Handler { response.Codes = codes httpx.OK(c, response) }) - api.POST("/auth/totp/disable", requireAdminAuth(opts.Config.AdminToken, opts.AuthService), func(c *gin.Context) { + api.POST("/auth/totp/disable", requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService), func(c *gin.Context) { var req struct { Code string `json:"code"` } @@ -759,7 +764,7 @@ func New(opts Options) http.Handler { response.Enabled = &enabled httpx.OK(c, response) }) - api.POST("/auth/totp/recovery-codes", requireAdminAuth(opts.Config.AdminToken, opts.AuthService), func(c *gin.Context) { + api.POST("/auth/totp/recovery-codes", requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService), func(c *gin.Context) { codes, err := opts.ControlService.GenerateTOTPRecoveryCodes(c.Request.Context(), actor(c)) if err != nil { httpx.Error(c, http.StatusBadRequest, 1318, err.Error()) @@ -776,7 +781,7 @@ func New(opts Options) http.Handler { registerPluginOpenRoutes(api.Group("/open/plugins"), opts.PluginService, opts.ControlService) registerPluginHostRoutes(api.Group("/plugin-host"), opts.PluginService, opts.ControlService) systemAPI := api.Group("/system") - systemAPI.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + systemAPI.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) systemAPI.Use(requireSystemAdministrator(opts.ControlService)) systemAPI.GET("/profiles", func(c *gin.Context) { current, err := opts.SettingsService.Admin(c.Request.Context()) @@ -807,7 +812,7 @@ func New(opts Options) http.Handler { }) admin := api.Group("/admin") - admin.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + admin.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) admin.Use(requireProfile(opts.SettingsService, "enterprise")) admin.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfaceEnterprise)) admin.Use(requireRBAC(opts.ControlService)) @@ -933,20 +938,20 @@ func New(opts Options) http.Handler { }) portal := api.Group("/portal") - portal.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + portal.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) portal.Use(requireProfile(opts.SettingsService, "enterprise")) portal.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfacePortal)) registerPortalRoutes(portal, opts.ControlService, opts.SettingsService) customer := api.Group("/customer") - customer.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + customer.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) customer.Use(requireProfile(opts.SettingsService, "relay_operator")) customer.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfaceCustomer)) registerPortalRoutes(customer, opts.ControlService, opts.SettingsService) registerCustomerRoutes(customer, opts.ControlService) operatorAPI := api.Group("/operator") - operatorAPI.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + operatorAPI.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) operatorAPI.Use(requireProfile(opts.SettingsService, "relay_operator")) operatorAPI.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfaceRelayOperator)) registerOperatorRoutes(operatorAPI, opts.OperatorService) @@ -956,7 +961,7 @@ func New(opts Options) http.Handler { registerPluginRoutes(operatorAPI.Group("/plugins"), opts.PluginService, opts.ControlService, "relay_operator") consoleAPI := api.Group("/console") - consoleAPI.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + consoleAPI.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) consoleAPI.Use(requireProfile(opts.SettingsService, "personal")) consoleAPI.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfacePersonal)) registerSharedCoreRoutes(consoleAPI, opts.ControlService, true) @@ -969,7 +974,7 @@ func New(opts Options) http.Handler { registerPluginRoutes(consoleAPI.Group("/plugins"), opts.PluginService, opts.ControlService, "personal") platformAPI := api.Group("/platform") - platformAPI.Use(requireAdminAuth(opts.Config.AdminToken, opts.AuthService)) + platformAPI.Use(requireAdminAuth(opts.Runtime.AdminToken, opts.AuthService)) platformAPI.Use(requireProfile(opts.SettingsService, "platform")) platformAPI.Use(requireSurfaceAccess(opts.ControlService, controlplane.SurfacePlatform)) platformAPI.Use(requireSurfaceRBAC(opts.ControlService, controlplane.SurfacePlatform)) @@ -978,7 +983,7 @@ func New(opts Options) http.Handler { registerSystemRoutes(platformAPI.Group("/system"), opts.SystemService, opts.SettingsService, opts.ControlService) registerGatewayRoutes(r, opts.ControlService, opts.DurableAIJobs, opts.PluginService) - serveSPA(r, opts.Config.FrontendDir) + serveSPA(r, opts.Runtime.FrontendDir) return r } diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 97bfe46..2abb358 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -11,7 +11,6 @@ import ( "testing" "github.com/astercloud/asterrouter/backend/internal/auth" - "github.com/astercloud/asterrouter/backend/internal/config" "github.com/astercloud/asterrouter/backend/internal/controlplane" "github.com/astercloud/asterrouter/backend/internal/gatewaycore" operatorcore "github.com/astercloud/asterrouter/backend/internal/operator" @@ -26,11 +25,11 @@ func (allowDurableAIJobs) SupportsDurableAIJob(context.Context, gatewaycore.Cano return true, nil } -func newTestRuntime(t *testing.T, cfg config.Config) (http.Handler, *controlplane.Service) { +func newTestRuntime(t *testing.T, cfg RuntimeConfig) (http.Handler, *controlplane.Service) { return newTestRuntimeWithDurableAdmission(t, cfg, allowDurableAIJobs{}) } -func newTestRuntimeWithDurableAdmission(t *testing.T, cfg config.Config, durableJobs DurableAIJobAdmission) (http.Handler, *controlplane.Service) { +func newTestRuntimeWithDurableAdmission(t *testing.T, cfg RuntimeConfig, durableJobs DurableAIJobAdmission) (http.Handler, *controlplane.Service) { t.Helper() settingsService := settings.NewService(settings.NewMemoryRepository(), settings.ServiceOptions{Version: "test", StorageMode: "memory", DemoMode: true, EnabledProfiles: []string{"personal", "relay_operator", "enterprise"}}) controlService := controlplane.NewService(controlplane.NewMemoryRepository(), "/v1") @@ -47,10 +46,10 @@ func newTestRuntimeWithDurableAdmission(t *testing.T, cfg config.Config, durable if value, ok := durableJobs.(AIJobRuntimeStatusProvider); ok { runtime = value } - return New(Options{Config: cfg, SettingsService: settingsService, ControlService: controlService, OperatorService: operatorService, PluginService: pluginService, SystemService: systemService, DurableAIJobs: durableJobs, AIJobRuntime: runtime}), controlService + return New(Options{Runtime: cfg, SettingsService: settingsService, ControlService: controlService, OperatorService: operatorService, PluginService: pluginService, SystemService: systemService, DurableAIJobs: durableJobs, AIJobRuntime: runtime}), controlService } -func newTestHandler(t *testing.T, cfg config.Config) http.Handler { +func newTestHandler(t *testing.T, cfg RuntimeConfig) http.Handler { t.Helper() handler, _ := newTestRuntime(t, cfg) return handler @@ -79,7 +78,7 @@ func newAuthTestRuntime(t *testing.T) (http.Handler, *controlplane.Service) { t.Fatalf("Plugin EnsureSeedData(): %v", err) } return New(Options{ - Config: config.Config{}, + Runtime: RuntimeConfig{}, AuthService: auth.NewService(auth.Config{Username: "admin", Password: "secret", PasswordHash: localAdmin.PasswordHash, SecretKey: "test-secret"}), SettingsService: settingsService, ControlService: controlService, @@ -90,7 +89,7 @@ func newAuthTestRuntime(t *testing.T) (http.Handler, *controlplane.Service) { } func TestPublicSettingsEndpoint(t *testing.T) { - handler := newTestHandler(t, config.Config{}) + handler := newTestHandler(t, RuntimeConfig{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) rec := httptest.NewRecorder() @@ -112,7 +111,7 @@ func TestPublicSettingsEndpoint(t *testing.T) { } func TestAdminSettingsRequiresToken(t *testing.T) { - handler := newTestHandler(t, config.Config{AdminToken: "secret"}) + handler := newTestHandler(t, RuntimeConfig{AdminToken: "secret"}) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) rec := httptest.NewRecorder() @@ -296,7 +295,7 @@ func TestSetupProfileEndpoint(t *testing.T) { t.Fatalf("Plugin EnsureSeedData(): %v", err) } systemService := system.NewService(system.Config{Version: "test", BuildType: "source"}) - handler := New(Options{Config: config.Config{}, SettingsService: svc, ControlService: controlService, PluginService: pluginService, SystemService: systemService}) + handler := New(Options{Runtime: RuntimeConfig{}, SettingsService: svc, ControlService: controlService, PluginService: pluginService, SystemService: systemService}) postProfile := func(profile string) *httptest.ResponseRecorder { t.Helper() body := bytes.NewBufferString(fmt.Sprintf(`{"profile":%q}`, profile)) diff --git a/backend/internal/system/service.go b/backend/internal/system/service.go index 7cdd2a6..45d8fe2 100644 --- a/backend/internal/system/service.go +++ b/backend/internal/system/service.go @@ -380,7 +380,7 @@ func (s *Service) Restart(operationID string, delay time.Duration) (ApplyResult, return ApplyResult{ Message: "Automatic restart is disabled.", OperationID: operationID, - ManualAction: "Restart the service manually, or set ASTER_ALLOW_RESTART=true for managed deployments.", + ManualAction: "Restart the service manually, or set ASTERROUTER_SERVER_MAINTENANCE_ALLOW_RESTART=true for managed deployments.", }, ErrRestartUnsupported } if delay <= 0 { diff --git a/deploy/asterrouter.env.example b/deploy/asterrouter.env.example index 522afd9..c4cb769 100644 --- a/deploy/asterrouter.env.example +++ b/deploy/asterrouter.env.example @@ -2,42 +2,43 @@ # Copy to /etc/asterrouter/asterrouter.env and set real values before starting. GIN_MODE=release -ASTER_ADDR=127.0.0.1:8082 -ASTER_FRONTEND_DIR=/opt/asterrouter/frontend/dist +ASTERROUTER_SERVER_HTTP_LISTEN=127.0.0.1:8082 +ASTERROUTER_SERVER_HTTP_FRONTEND_DIR=/opt/asterrouter/frontend/dist # Leave this value unset to choose interactively at /setup. # For unattended first installation, choose one deployment role: # personal, relay_operator, enterprise, or platform. -# ASTER_DEPLOYMENT_ROLE=enterprise -# The older ASTER_PROFILES and ASTER_DEFAULT_PROFILE pair remains compatible. +# ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE=enterprise # After setup, the selected deployment role is persisted in PostgreSQL and cannot be changed. # Deploy a separate instance for another business model. -ASTER_DEPLOYMENT_ROLE= -PUBLIC_BASE_URL= +ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE= -ASTER_ADMIN_USERNAME=admin -ASTER_ADMIN_PASSWORD=change-this-password -ASTER_ADMIN_TOKEN= +ASTERROUTER_SERVER_SECURITY_ADMIN_USERNAME=admin +ASTERROUTER_SERVER_SECURITY_ADMIN_PASSWORD=change-this-password +ASTERROUTER_SERVER_SECURITY_ADMIN_TOKEN= # Enables the public demo/demo account and one-click login entry. Keep false on production instances. -ASTER_DEMO_MODE=false +ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=false -DATABASE_URL=postgres://asterrouter:change-this-password@127.0.0.1:5432/asterrouter?sslmode=disable -ASTER_SECRET_KEY=replace-with-a-stable-random-secret +ASTERROUTER_SERVER_STORAGE_DATABASE_URL=postgres://asterrouter:change-this-password@127.0.0.1:5432/asterrouter?sslmode=disable +ASTERROUTER_SERVER_SECURITY_SECRET_KEY=replace-with-a-stable-random-secret -ASTER_BUILD_TYPE=release -ASTER_UPDATE_MANIFEST_URL=https://github.com/astercloud/asterrouter/releases/latest/download/asterrouter_update_manifest.json +ASTERROUTER_SERVER_OFFICIAL_UPDATE_MANIFEST_URL=https://github.com/astercloud/asterrouter/releases/latest/download/asterrouter_update_manifest.json # disabled, online, private_mirror, or offline. -ASTER_CATALOG_MODE=disabled +ASTERROUTER_SERVER_OFFICIAL_CATALOG_MODE=disabled # online/private_mirror can use bootstrap URL only; explicit URL/key values override discovered values. -ASTER_CATALOG_BOOTSTRAP_URL= +ASTERROUTER_SERVER_OFFICIAL_CATALOG_BOOTSTRAP_URL= # offline uses local metadata and package imports. -ASTER_CATALOG_URL= -ASTER_CATALOG_KEY_ID= -ASTER_CATALOG_PUBLIC_KEY= -ASTER_LICENSE_URL= -ASTER_LICENSE_KEY_ID= -ASTER_LICENSE_PUBLIC_KEY= -ASTER_INSTANCE_ID= -ASTER_INSTANCE_FINGERPRINT= -ASTER_INSTANCE_DISPLAY_NAME= -ASTER_PLUGIN_CACHE_DIR=/var/lib/asterrouter/plugins -ASTER_ALLOW_RESTART=true +ASTERROUTER_SERVER_OFFICIAL_CATALOG_URL= +ASTERROUTER_SERVER_OFFICIAL_CATALOG_KEY_ID= +ASTERROUTER_SERVER_OFFICIAL_CATALOG_PUBLIC_KEY= +ASTERROUTER_SERVER_OFFICIAL_LICENSE_URL= +ASTERROUTER_SERVER_OFFICIAL_LICENSE_REDEEM_URL= +ASTERROUTER_SERVER_OFFICIAL_LICENSE_KEY_ID= +ASTERROUTER_SERVER_OFFICIAL_LICENSE_PUBLIC_KEY= +ASTERROUTER_SERVER_OFFICIAL_INSTANCE_ID= +ASTERROUTER_SERVER_OFFICIAL_INSTANCE_FINGERPRINT= +ASTERROUTER_SERVER_OFFICIAL_INSTANCE_DISPLAY_NAME= +ASTERROUTER_SERVER_PLUGINS_CACHE_DIR=/var/lib/asterrouter/plugin-cache +ASTERROUTER_SERVER_PLUGINS_ACTIVE_DIR=/var/lib/asterrouter/plugin-active +ASTERROUTER_SERVER_MAINTENANCE_BACKUP_DIR=/var/lib/asterrouter/backups +ASTERROUTER_SERVER_MAINTENANCE_DIAGNOSTIC_DIR=/var/lib/asterrouter/diagnostics +ASTERROUTER_SERVER_MAINTENANCE_ALLOW_RESTART=true diff --git a/deploy/asterrouter.service b/deploy/asterrouter.service index b9e8853..56d4066 100644 --- a/deploy/asterrouter.service +++ b/deploy/asterrouter.service @@ -10,7 +10,7 @@ User=asterrouter Group=asterrouter WorkingDirectory=/opt/asterrouter EnvironmentFile=/etc/asterrouter/asterrouter.env -ExecStart=/opt/asterrouter/asterrouter +ExecStart=/opt/asterrouter/asterrouter server Restart=always RestartSec=5 StandardOutput=journal diff --git a/deploy/install.sh b/deploy/install.sh index c4dfab1..74e0abc 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -206,30 +206,27 @@ create_env_if_missing() { cat > "$ENV_FILE" < 最近核验:2026-07-14 CST +> 最近核验:2026-07-16 CST > -> 基准提交:`f8378c5c58a94ac56295872eb5919e4d506f939a`(dirty worktree) +> 基准提交:`aee45c9dec4f5a50d1bf515fd834fef6e4d07f4f`(dirty worktree) > > 结论:v1 的本地实现、测试入口和 CI/release/nightly 门禁已落地。当前不能宣称候选版本可发布,直到本轮变更在 GitHub Actions 的 PostgreSQL 16、Linux、Docker、arm64 和 nightly 环境产生真实成功证据。 -本轮本地复核已通过:`GOTOOLCHAIN=go1.25.1 bash scripts/test.sh all`、`go test -race -count=1 -timeout=15m ./...`、J04 六场景失败矩阵、20 秒 normal/SSE soak(1,615 请求,goroutine 增量 3)、独立空库 setup、以及四个独立单 Profile runtime 的 Chromium 验收。所有临时 runtime 使用独立端口和内存数据,不能替代 PostgreSQL 或 Linux candidate artifact 证据。 +配置与启动重构本地复核已通过:Go 1.26.1 全量与 race、34 个前端测试文件的 102 个用例、单源生产 smoke、installer 安装/升级/回滚,以及 Go 1.26 容器的 PostgreSQL ready、非 root、fail-closed 和 SIGTERM 验收。Linux candidate archive 和 GitHub Actions 证据仍由发布门禁负责。 ## 1. 当前自动化入口 | 能力 | 自动化入口 | 当前本地证据 | | --- | --- | --- | | 后端全量 | `cd backend && go test -count=1 ./...` | 通过;本机无 PostgreSQL 时环境依赖测试按显式条件跳过 | -| 后端 race | `cd backend && GOTOOLCHAIN=go1.25.1 go test -race -count=1 -timeout=15m ./...` | 已运行通过;长期 nightly 仍需 Ubuntu 证据 | -| 后端覆盖率 | `GOTOOLCHAIN=go1.25.1 bash scripts/test.sh backend-coverage` | 本地 Go 1.25.1 运行,coverage profile 已生成;当前关键包仍未达到 75% 渐进目标 | -| 前端单元/组件 | `cd frontend && npm run test:unit:coverage` | 11 文件、46 用例通过;覆盖率持续以渐进 ratchet 管理 | +| 后端 race | `cd backend && go test -race -count=1 -timeout=15m ./...` | Go 1.26.1 本地运行通过;长期 nightly 仍需 Ubuntu 证据 | +| 后端覆盖率 | `bash scripts/test.sh backend-coverage` | 项目基线已升级到 Go 1.26.1;coverage profile 已生成,关键包继续执行 75% 渐进目标 | +| 前端单元/组件 | `cd frontend && npm run test:unit` | 34 文件、102 用例通过;覆盖率持续以渐进 ratchet 管理 | | 开发浏览器 smoke | `cd frontend && npm run test:e2e:smoke` | demo 多 Surface Chromium smoke 通过;关键 API 旅程仅在桌面运行,其余视口按设计 skip | | 首装浏览器旅程 | `bash scripts/test-setup-browser-journey.sh` | 空 runtime、单源构建、`platform` 首装持久化;桌面 1 通过、其余视口按设计 skip | | 非 demo 候选路径模拟 | 每个 `enterprise`、`relay_operator`、`personal`、`platform` 启动独立 runtime/数据库;`configure-e2e-profiles.mjs` 验证其单 Profile 状态 | 本机已验证 `enterprise` J01-J05 与 `platform` Surface;候选 archive 的 Linux/PostgreSQL 实跑由 CI 负责 | @@ -24,7 +24,7 @@ | 短 soak 回归 | `ASTER_GATEWAY_SOAK=1 ASTER_GATEWAY_SOAK_DURATION=20s ...` | 1,615 普通/流式请求通过;goroutine 增量 3;30 分钟 nightly 仍待执行 | | 发布包静态验收 | `ASTER_DIST_DIR= bash scripts/build-release.sh 0.5.0 && ...test-release-artifacts.sh 0.5.0` | amd64/arm64 archive、checksum、manifest 与二进制元数据通过;本机不能执行 Linux binary | -本机为 macOS arm64、Go 1.24.3(可用 `GOTOOLCHAIN=go1.25.1`)、Node 23.4.0。项目 CI 的 Go 1.25、Node 24 与 Ubuntu Linux 结果才是版本和发布门禁的事实来源。 +本地环境版本仅用于快速反馈。项目 CI 的 Go 1.26、Node 24 与 Ubuntu Linux 结果才是版本和发布门禁的事实来源。 ## 2. Phase 状态 diff --git a/docs/test/v1/README.md b/docs/test/v1/README.md index 28d2e8f..2ecf6d9 100644 --- a/docs/test/v1/README.md +++ b/docs/test/v1/README.md @@ -33,7 +33,7 @@ | 项目 | 当前状态 | 基线结果 | | --- | --- | --- | -| 后端 | Go 1.25,56 个 `*_test.go`,232 个 `Test*` | `go test ./...` 通过 | +| 后端 | Go 1.26.1,使用 cfgm 统一文件、环境变量与 CLI 配置 | `go test ./...` 通过 | | 后端覆盖面 | auth/config/controlplane/operator/plugins/server/settings/system | server 83、controlplane 68、plugins 35 个测试函数,分布不均 | | PostgreSQL | 仅 1 个测试由 `ASTER_TEST_DATABASE_URL` 控制 | 默认本地/CI 会跳过该测试 | | 前端 | Vue 3 + TypeScript + Vite | 0 个单元、组件或 E2E 测试文件 | @@ -45,7 +45,7 @@ 需要在 Phase 0 先处理的结构性风险: -- `backend/go.mod` 声明 Go 1.25,而 `Dockerfile` 使用 `golang:1.24-alpine`;CI、容器和发布构建必须验证并统一工具链事实。 +- Go module、CI 和 Docker 构建统一以 Go 1.26 系列为基线;发布验收必须继续验证实际工具链版本。 - PostgreSQL Schema 由各 Repository 内联 SQL 在运行时创建,同时保留 `backend/migrations/*.sql`;两套事实源存在漂移风险。 - `backend/migrations` 的编号从 018 跳到 020,历史 `019` 已删除。必须明确迁移文件是部署事实源、审计快照还是历史参考,并为相应规则加自动校验。 - 当前大多数后端测试使用内存 Repository,不能证明 SQL 约束、事务原子性、重启持久化和升级兼容性。 diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 533933e..71b35ff 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -66,8 +66,8 @@ export default defineConfig({ `ASTER_DEV_FRONTEND_PORT=${frontendPort}`, `VITE_DEV_PROXY_TARGET=http://127.0.0.1:${backendPort}`, `ASTER_E2E_UPSTREAM_PORT=${upstreamPort}`, - 'ASTER_DEMO_MODE=true', - 'ASTER_SECRET_KEY=asterrouter-e2e-test-secret', + 'ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true', + 'ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-e2e-test-secret', 'bash ../scripts/e2e.sh' ].join(' '), url: `http://127.0.0.1:${backendPort}/ready`, diff --git a/scripts/dev.sh b/scripts/dev.sh index a27767b..a23844d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -11,7 +11,7 @@ if [ -f "${ROOT_DIR}/.env" ]; then fi KILL_OCCUPIED="${ASTER_DEV_KILL_OCCUPIED:-1}" -DEMO_MODE="${ASTER_DEMO_MODE:-false}" +DEMO_MODE="${ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE:-false}" usage() { cat <<'EOF' @@ -24,7 +24,7 @@ Options: -h, --help Show this help message. Environment: - ASTER_DEMO_MODE=true Enable the built-in one-click demo account. + ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true Enable the built-in one-click demo account. ASTER_DEV_KILL_OCCUPIED=1 Enable automatic port cleanup (default). ASTER_DEV_KILL_OCCUPIED=0 Disable automatic port cleanup. EOF @@ -154,10 +154,10 @@ fi ( cd "${ROOT_DIR}/backend" - ASTER_ADDR="${ASTER_ADDR:-${BACKEND_HOST}:${BACKEND_PORT}}" \ - ASTER_FRONTEND_DIR="${ASTER_FRONTEND_DIR:-../frontend/dist}" \ - ASTER_DEMO_MODE="${DEMO_MODE}" \ - go run ./cmd/asterrouter + ASTERROUTER_SERVER_HTTP_LISTEN="${ASTERROUTER_SERVER_HTTP_LISTEN:-${BACKEND_HOST}:${BACKEND_PORT}}" \ + ASTERROUTER_SERVER_HTTP_FRONTEND_DIR="${ASTERROUTER_SERVER_HTTP_FRONTEND_DIR:-../frontend/dist}" \ + ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE="${DEMO_MODE}" \ + go run ./cmd/asterrouter server ) & PIDS+=("$!") diff --git a/scripts/test-container.sh b/scripts/test-container.sh index fb88294..283ef34 100644 --- a/scripts/test-container.sh +++ b/scripts/test-container.sh @@ -46,12 +46,12 @@ if docker run --rm --network "${NETWORK}" "${IMAGE}" >"${ARTIFACT_DIR}/container echo "Release container unexpectedly started without required configuration." >&2 exit 1 fi -grep -Fq 'DATABASE_URL is required for release deployments' "${ARTIFACT_DIR}/container-fail-closed.log" +grep -Fq 'server.storage.database-url is required in release builds' "${ARTIFACT_DIR}/container-fail-closed.log" docker run -d --name "${APP_CONTAINER}" --network "${NETWORK}" -p "127.0.0.1:${PORT}:8080" \ - -e ASTER_DEMO_MODE=true \ - -e ASTER_SECRET_KEY=asterrouter-container-test-secret \ - -e DATABASE_URL='postgres://asterrouter:asterrouter@postgres:5432/asterrouter_test?sslmode=disable' \ + -e ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true \ + -e ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-container-test-secret \ + -e ASTERROUTER_SERVER_STORAGE_DATABASE_URL='postgres://asterrouter:asterrouter@postgres:5432/asterrouter_test?sslmode=disable' \ "${IMAGE}" >/dev/null for _ in $(seq 1 90); do diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 006bdb3..19a4a7e 100644 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -108,7 +108,7 @@ test "$("${INSTALL_DIR}/asterrouter" --version)" = "asterrouter 0.3.0" test -f "${INSTALL_DIR}/frontend/dist/index.html" test -x "${COMMAND_PATH}" test -f "${SERVICE_FILE}" -grep -q '^ASTER_DEPLOYMENT_ROLE=platform$' "${CONFIG_DIR}/asterrouter.env" +grep -q '^ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE=platform$' "${CONFIG_DIR}/asterrouter.env" bash "${ROOT_DIR}/deploy/install.sh" upgrade -v 0.4.0 test "$("${INSTALL_DIR}/asterrouter" --version)" = "asterrouter 0.4.0" @@ -132,7 +132,7 @@ grep -Eq 'FAILED|checksum' "${WORK_DIR}/checksum-rejection.log" grep -q '^daemon-reload$' "${SYSTEMCTL_LOG}" grep -q '^stop asterrouter-test$' "${SYSTEMCTL_LOG}" if grep -q '^enable --now asterrouter-test$' "${SYSTEMCTL_LOG}"; then - echo "Installer started the service even though DATABASE_URL was empty." >&2 + echo "Installer started the service even though ASTERROUTER_SERVER_STORAGE_DATABASE_URL was empty." >&2 exit 1 fi diff --git a/scripts/test-release-browser-journeys.sh b/scripts/test-release-browser-journeys.sh index 4837233..d208068 100644 --- a/scripts/test-release-browser-journeys.sh +++ b/scripts/test-release-browser-journeys.sh @@ -82,18 +82,17 @@ start_runtime() { ( cd "${PACKAGE_DIR}" exec env \ - "ASTER_ADDR=127.0.0.1:${port}" \ - "ASTER_FRONTEND_DIR=${PACKAGE_DIR}/frontend/dist" \ - "ASTER_ADMIN_PASSWORD=release-browser-test-password" \ - "ASTER_SECRET_KEY=asterrouter-release-journey-test-secret" \ - "ASTER_PROFILES=${profile}" \ - "ASTER_DEFAULT_PROFILE=${profile}" \ - "ASTER_PLUGIN_CACHE_DIR=${profile_dir}/data/plugin-cache" \ - "ASTER_PLUGIN_ACTIVE_DIR=${profile_dir}/data/plugin-active" \ - "ASTER_BACKUP_DIR=${profile_dir}/data/backups" \ - "ASTER_DIAGNOSTIC_DIR=${profile_dir}/data/diagnostics" \ - "DATABASE_URL=${database_url}" \ - ./asterrouter + "ASTERROUTER_SERVER_HTTP_LISTEN=127.0.0.1:${port}" \ + "ASTERROUTER_SERVER_HTTP_FRONTEND_DIR=${PACKAGE_DIR}/frontend/dist" \ + "ASTERROUTER_SERVER_SECURITY_ADMIN_PASSWORD=release-browser-test-password" \ + "ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-release-journey-test-secret" \ + "ASTERROUTER_SERVER_BOOTSTRAP_DEPLOYMENT_ROLE=${profile}" \ + "ASTERROUTER_SERVER_PLUGINS_CACHE_DIR=${profile_dir}/data/plugin-cache" \ + "ASTERROUTER_SERVER_PLUGINS_ACTIVE_DIR=${profile_dir}/data/plugin-active" \ + "ASTERROUTER_SERVER_MAINTENANCE_BACKUP_DIR=${profile_dir}/data/backups" \ + "ASTERROUTER_SERVER_MAINTENANCE_DIAGNOSTIC_DIR=${profile_dir}/data/diagnostics" \ + "ASTERROUTER_SERVER_STORAGE_DATABASE_URL=${database_url}" \ + ./asterrouter server ) >"${profile_dir}/runtime.log" 2>&1 & RUNTIME_PID="$!" PIDS+=("${RUNTIME_PID}") @@ -176,7 +175,7 @@ tar -C "${RUN_DIR}" -xzf "${ARCHIVE}" ) >"${RUN_DIR}/fake-upstream.log" 2>&1 & PIDS+=("$!") -DATABASE_URL="$(database_url_for platform_setup)" \ +ASTER_SETUP_JOURNEY_DATABASE_URL="$(database_url_for platform_setup)" \ ASTER_SETUP_JOURNEY_DIR="${RUN_DIR}/setup" \ ASTER_SETUP_JOURNEY_PORT="${BACKEND_PORT}" \ ASTER_SETUP_JOURNEY_BINARY="${PACKAGE_DIR}/asterrouter" \ diff --git a/scripts/test-release-runtime.sh b/scripts/test-release-runtime.sh index d46fcde..15dcf0f 100644 --- a/scripts/test-release-runtime.sh +++ b/scripts/test-release-runtime.sh @@ -44,11 +44,11 @@ fi mkdir -p "${RUN_DIR}" tar -C "${RUN_DIR}" -xzf "${ARCHIVE}" -if "${PACKAGE_DIR}/asterrouter" >"${RUN_DIR}/fail-closed.log" 2>&1; then +if "${PACKAGE_DIR}/asterrouter" server >"${RUN_DIR}/fail-closed.log" 2>&1; then echo "Release binary unexpectedly started without required configuration." >&2 exit 1 fi -grep -q 'DATABASE_URL is required for release deployments' "${RUN_DIR}/fail-closed.log" +grep -q 'server.storage.database-url is required in release builds' "${RUN_DIR}/fail-closed.log" cleanup() { if [ -n "${PID}" ] && kill -0 "${PID}" >/dev/null 2>&1; then @@ -61,16 +61,16 @@ trap cleanup EXIT INT TERM ( cd "${PACKAGE_DIR}" exec env \ - ASTER_ADDR="127.0.0.1:${PORT}" \ - ASTER_FRONTEND_DIR="${PACKAGE_DIR}/frontend/dist" \ - ASTER_DEMO_MODE=true \ - ASTER_SECRET_KEY=asterrouter-release-runtime-test-secret \ - ASTER_PLUGIN_CACHE_DIR="${RUN_DIR}/data/plugin-cache" \ - ASTER_PLUGIN_ACTIVE_DIR="${RUN_DIR}/data/plugin-active" \ - ASTER_BACKUP_DIR="${RUN_DIR}/data/backups" \ - ASTER_DIAGNOSTIC_DIR="${RUN_DIR}/data/diagnostics" \ - DATABASE_URL="${DATABASE_URL}" \ - ./asterrouter >"${RUN_DIR}/runtime.log" 2>&1 + ASTERROUTER_SERVER_HTTP_LISTEN="127.0.0.1:${PORT}" \ + ASTERROUTER_SERVER_HTTP_FRONTEND_DIR="${PACKAGE_DIR}/frontend/dist" \ + ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true \ + ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-release-runtime-test-secret \ + ASTERROUTER_SERVER_PLUGINS_CACHE_DIR="${RUN_DIR}/data/plugin-cache" \ + ASTERROUTER_SERVER_PLUGINS_ACTIVE_DIR="${RUN_DIR}/data/plugin-active" \ + ASTERROUTER_SERVER_MAINTENANCE_BACKUP_DIR="${RUN_DIR}/data/backups" \ + ASTERROUTER_SERVER_MAINTENANCE_DIAGNOSTIC_DIR="${RUN_DIR}/data/diagnostics" \ + ASTERROUTER_SERVER_STORAGE_DATABASE_URL="${DATABASE_URL}" \ + ./asterrouter server >"${RUN_DIR}/runtime.log" 2>&1 ) & PID="$!" diff --git a/scripts/test-setup-browser-journey.sh b/scripts/test-setup-browser-journey.sh index ad83764..355d981 100644 --- a/scripts/test-setup-browser-journey.sh +++ b/scripts/test-setup-browser-journey.sh @@ -1,99 +1,117 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -RUN_DIR="${ASTER_SETUP_JOURNEY_DIR:-${TMPDIR:-/tmp}/asterrouter-setup-journey-$$}" -PORT="${ASTER_SETUP_JOURNEY_PORT:-18088}" -BINARY="${ASTER_SETUP_JOURNEY_BINARY:-}" -DATABASE_URL="${DATABASE_URL:-}" -PID="" - -if [ -d "${RUN_DIR}" ] && find "${RUN_DIR}" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then - echo "Refusing to overwrite non-empty setup journey directory: ${RUN_DIR}" >&2 - exit 1 -fi -if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then - echo "Setup journey port ${PORT} is already in use." >&2 - exit 1 -fi - -cleanup() { - if [ -n "${PID}" ]; then - kill -TERM "${PID}" >/dev/null 2>&1 || true - wait "${PID}" >/dev/null 2>&1 || true +_pid="" + +__cleanup() { + if [ -n "${_pid}" ]; then + kill -TERM "${_pid}" >/dev/null 2>&1 || true + wait "${_pid}" >/dev/null 2>&1 || true fi } -trap cleanup EXIT INT TERM -mkdir -p "${RUN_DIR}" -if [ -z "${BINARY}" ]; then - ( - cd "${ROOT_DIR}/frontend" - npm run build - ) -else - if [ ! -x "${BINARY}" ]; then - echo "ASTER_SETUP_JOURNEY_BINARY must point to an executable: ${BINARY}" >&2 - exit 1 - fi -fi -( - if [ -n "${BINARY}" ]; then - cd "$(dirname "${BINARY}")" - APP_COMMAND=("${BINARY}") - FRONTEND_DIR="$(dirname "${BINARY}")/frontend/dist" +__run_runtime() { + local -a _app_command + local -a _runtime_env + local _frontend_dir + + if [ -n "${_binary}" ]; then + cd "$(dirname "${_binary}")" + _app_command=("${_binary}" server) + _frontend_dir="$(dirname "${_binary}")/frontend/dist" else - cd "${ROOT_DIR}/backend" - APP_COMMAND=(go run ./cmd/asterrouter) - FRONTEND_DIR="${ROOT_DIR}/frontend/dist" + cd "${_root_dir}/backend" + _app_command=(go run ./cmd/asterrouter server) + _frontend_dir="${_root_dir}/frontend/dist" fi - runtime_env=( - "ASTER_ADDR=127.0.0.1:${PORT}" - "ASTER_FRONTEND_DIR=${FRONTEND_DIR}" - 'ASTER_ADMIN_PASSWORD=setup-browser-test-password' - 'ASTER_SECRET_KEY=asterrouter-setup-browser-test-secret' - "ASTER_PLUGIN_CACHE_DIR=${RUN_DIR}/data/plugin-cache" - "ASTER_PLUGIN_ACTIVE_DIR=${RUN_DIR}/data/plugin-active" - "ASTER_BACKUP_DIR=${RUN_DIR}/data/backups" - "ASTER_DIAGNOSTIC_DIR=${RUN_DIR}/data/diagnostics" + _runtime_env=( + "ASTERROUTER_SERVER_HTTP_LISTEN=127.0.0.1:${_port}" + "ASTERROUTER_SERVER_HTTP_FRONTEND_DIR=${_frontend_dir}" + 'ASTERROUTER_SERVER_SECURITY_ADMIN_PASSWORD=setup-browser-test-password' + 'ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-setup-browser-test-secret' + "ASTERROUTER_SERVER_PLUGINS_CACHE_DIR=${_run_dir}/data/plugin-cache" + "ASTERROUTER_SERVER_PLUGINS_ACTIVE_DIR=${_run_dir}/data/plugin-active" + "ASTERROUTER_SERVER_MAINTENANCE_BACKUP_DIR=${_run_dir}/data/backups" + "ASTERROUTER_SERVER_MAINTENANCE_DIAGNOSTIC_DIR=${_run_dir}/data/diagnostics" ) - if [ -n "${DATABASE_URL}" ]; then - runtime_env+=("DATABASE_URL=${DATABASE_URL}") + if [ -n "${ASTER_SETUP_JOURNEY_DATABASE_URL:-}" ]; then + _runtime_env+=("ASTERROUTER_SERVER_STORAGE_DATABASE_URL=${ASTER_SETUP_JOURNEY_DATABASE_URL}") fi - exec env "${runtime_env[@]}" "${APP_COMMAND[@]}" -) >"${RUN_DIR}/runtime.log" 2>&1 & -PID="$!" + exec env "${_runtime_env[@]}" "${_app_command[@]}" +} -for _ in $(seq 1 120); do - if curl -fsS "http://127.0.0.1:${PORT}/ready" 2>/dev/null | grep -q '"status":"ready"'; then - break +__main() { + local _attempt + local _execution + + _root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + _run_dir="${ASTER_SETUP_JOURNEY_DIR:-${TMPDIR:-/tmp}/asterrouter-setup-journey-$$}" + _port="${ASTER_SETUP_JOURNEY_PORT:-18088}" + _binary="${ASTER_SETUP_JOURNEY_BINARY:-}" + + if [ -d "${_run_dir}" ] && find "${_run_dir}" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then + echo "Refusing to overwrite non-empty setup journey directory: ${_run_dir}" >&2 + exit 1 fi - if ! kill -0 "${PID}" >/dev/null 2>&1; then - wait "${PID}" || true - echo "Setup journey runtime exited before becoming ready." >&2 + if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${_port}" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Setup journey port ${_port} is already in use." >&2 + exit 1 + fi + + trap __cleanup EXIT INT TERM + + mkdir -p "${_run_dir}" + if [ -z "${_binary}" ]; then + ( + cd "${_root_dir}/frontend" + npm run build + ) + elif [ ! -x "${_binary}" ]; then + echo "ASTER_SETUP_JOURNEY_BINARY must point to an executable: ${_binary}" >&2 exit 1 fi - sleep 0.25 -done -curl -fsS "http://127.0.0.1:${PORT}/api/v1/setup/status" | grep -q '"setup_completed":false' - -( - cd "${ROOT_DIR}/frontend" - CI=true \ - ASTER_E2E_INCLUDE_SETUP=1 \ - ASTER_E2E_EXTERNAL_URL="http://127.0.0.1:${PORT}" \ - ASTER_E2E_ARTIFACT_DIR="${RUN_DIR}/playwright" \ - npx playwright test --grep '@setup' -) - -curl -fsS "http://127.0.0.1:${PORT}/api/v1/setup/status" | grep -q '"default_profile":"platform"' -curl -fsS "http://127.0.0.1:${PORT}/api/v1/setup/status" | grep -q '"setup_completed":true' - -{ - echo 'setup_browser_journey=passed' - echo "execution=$([ -n "${BINARY}" ] && echo candidate_binary || echo source_runtime)" - echo 'profile=platform' - echo 'browser=chromium' -} >"${RUN_DIR}/report.txt" - -echo "Setup browser journey passed. Evidence: ${RUN_DIR}/report.txt" + + __run_runtime >"${_run_dir}/runtime.log" 2>&1 & + _pid="$!" + + for _attempt in $(seq 1 120); do + if curl -fsS "http://127.0.0.1:${_port}/ready" 2>/dev/null | grep -q '"status":"ready"'; then + break + fi + if ! kill -0 "${_pid}" >/dev/null 2>&1; then + wait "${_pid}" || true + echo "Setup journey runtime exited before becoming ready." >&2 + exit 1 + fi + sleep 0.25 + done + curl -fsS "http://127.0.0.1:${_port}/api/v1/setup/status" | grep -q '"setup_completed":false' + + ( + cd "${_root_dir}/frontend" + CI=true \ + ASTER_E2E_INCLUDE_SETUP=1 \ + ASTER_E2E_EXTERNAL_URL="http://127.0.0.1:${_port}" \ + ASTER_E2E_ARTIFACT_DIR="${_run_dir}/playwright" \ + npx playwright test --grep '@setup' + ) + + curl -fsS "http://127.0.0.1:${_port}/api/v1/setup/status" | grep -q '"default_profile":"platform"' + curl -fsS "http://127.0.0.1:${_port}/api/v1/setup/status" | grep -q '"setup_completed":true' + + if [ -n "${_binary}" ]; then + _execution="candidate_binary" + else + _execution="source_runtime" + fi + { + echo 'setup_browser_journey=passed' + echo "execution=${_execution}" + echo 'profile=platform' + echo 'browser=chromium' + } >"${_run_dir}/report.txt" + + echo "Setup browser journey passed. Evidence: ${_run_dir}/report.txt" +} + +__main "$@" diff --git a/scripts/test-single-origin.sh b/scripts/test-single-origin.sh index 83be39c..ba5ee2f 100755 --- a/scripts/test-single-origin.sh +++ b/scripts/test-single-origin.sh @@ -26,11 +26,11 @@ fi ( cd "${ROOT_DIR}/backend" - ASTER_ADDR="127.0.0.1:${PORT}" \ - ASTER_FRONTEND_DIR="../frontend/dist" \ - ASTER_DEMO_MODE=true \ - ASTER_SECRET_KEY=asterrouter-single-origin-test-secret \ - go run ./cmd/asterrouter + ASTERROUTER_SERVER_HTTP_LISTEN="127.0.0.1:${PORT}" \ + ASTERROUTER_SERVER_HTTP_FRONTEND_DIR="../frontend/dist" \ + ASTERROUTER_SERVER_BOOTSTRAP_DEMO_MODE=true \ + ASTERROUTER_SERVER_SECURITY_SECRET_KEY=asterrouter-single-origin-test-secret \ + go run ./cmd/asterrouter server ) & PID="$!"