Files
sub2api-cn-relay-manager/cmd/cli/main.go

527 lines
17 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"strings"
"sub2api-cn-relay-manager/internal/config"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/provision"
"sub2api-cn-relay-manager/internal/reconcile"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type installPackFunc func(context.Context, installPackCLIRequest) (provision.PackInstallResult, error)
type importProviderFunc func(context.Context, importCLIRequest) (provision.ImportReport, error)
type previewProviderFunc func(context.Context, previewCLIRequest) (provision.PreviewReport, error)
type rollbackProviderFunc func(context.Context, rollbackCLIRequest) (rollbackSummary, error)
type reconcileProviderFunc func(context.Context, reconcileCLIRequest) (reconcile.Result, error)
type installPackCLIRequest struct {
HostBaseURL string
HostAPIKey string
HostBearerToken string
PackPath string
}
type importCLIRequest struct {
HostBaseURL string
HostAPIKey string
HostBearerToken string
PackDir string
ProviderID string
Keys []string
Mode string
AccessMode string
AccessAPIKey string
SubscriptionUsers []string
SubscriptionDays int
}
type previewCLIRequest struct {
HostBaseURL string
HostAPIKey string
HostBearerToken string
PackDir string
ProviderID string
Keys []string
Mode string
}
type rollbackCLIRequest struct {
HostBaseURL string
HostAPIKey string
HostBearerToken string
PackDir string
ProviderID string
}
type reconcileCLIRequest struct {
HostBaseURL string
HostAPIKey string
HostBearerToken string
PackDir string
ProviderID string
AccessAPIKey string
}
type rollbackSummary struct {
Accounts int
Plans int
Channels int
Groups int
}
func main() {
if err := execute(context.Background(), log.Writer(), os.Args[1:], func(context.Context) (config.StartupConfig, error) {
return config.LoadStartupFromEnv()
}, runInstallPack, runImportProvider, runPreviewProvider, runRollbackProvider, runReconcileProvider, runApplyHostOverlay, runBatchImport); err != nil {
log.Fatalf("run cli: %v", err)
}
}
func execute(
ctx context.Context,
output io.Writer,
args []string,
loadConfig func(context.Context) (config.StartupConfig, error),
installPack installPackFunc,
importProvider importProviderFunc,
previewProvider previewProviderFunc,
rollbackProvider rollbackProviderFunc,
reconcileProvider reconcileProviderFunc,
applyHostOverlay applyHostOverlayFunc,
batchImport batchImportFunc,
) error {
if len(args) > 0 && args[0] == "batch-import" {
req, err := parseBatchImportCLIArgs(args[1:])
if err != nil {
return err
}
result, err := batchImport(ctx, req)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "run_id=%s\nresult_page=%s\n", result.RunID, result.ResultPage)
return err
}
if len(args) > 0 && args[0] == "install-pack" {
req, err := parseInstallPackCLIArgs(args[1:])
if err != nil {
return err
}
result, err := installPack(ctx, req)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "pack_id=%s\nversion=%s\nhost_version=%s\nproviders=%d\nalready_installed=%t\n", result.Pack.PackID, result.Pack.Version, result.HostVersion, len(result.Providers), result.AlreadyInstalled)
return err
}
if len(args) > 0 && args[0] == "import-provider" {
req, err := parseImportCLIArgs(args[1:])
if err != nil {
return err
}
report, err := importProvider(ctx, req)
if err != nil {
_, _ = fmt.Fprintf(output, "batch_status=%s\nprovider_status=%s\naccess_status=%s\n", report.BatchStatus, report.ProviderStatus, report.AccessStatus)
return err
}
_, err = fmt.Fprintf(output, "batch_status=%s\nprovider_status=%s\naccess_status=%s\naccounts=%d\n", report.BatchStatus, report.ProviderStatus, report.AccessStatus, len(report.Accounts))
return err
}
if len(args) > 0 && args[0] == "preview-provider" {
req, err := parsePreviewCLIArgs(args[1:])
if err != nil {
return err
}
report, err := previewProvider(ctx, req)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "accepted_keys=%d\ngroup=%s\nchannel=%s\nplan=%s\n", len(report.AcceptedKeys), report.Decisions["group"].Action, report.Decisions["channel"].Action, report.Decisions["plan"].Action)
return err
}
if len(args) > 0 && args[0] == "rollback-provider" {
req, err := parseRollbackCLIArgs(args[1:])
if err != nil {
return err
}
summary, err := rollbackProvider(ctx, req)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "deleted_accounts=%d\ndeleted_plans=%d\ndeleted_channels=%d\ndeleted_groups=%d\n", summary.Accounts, summary.Plans, summary.Channels, summary.Groups)
return err
}
if len(args) > 0 && args[0] == "reconcile-provider" {
req, err := parseReconcileCLIArgs(args[1:])
if err != nil {
return err
}
result, err := reconcileProvider(ctx, req)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "status=%s\nmissing_count=%d\nextra_count=%d\nprobe_failures=%d\naccess_status=%s\n", result.Status, result.MissingCount, result.ExtraCount, result.ProbeFailureCount, result.AccessStatus)
return err
}
if len(args) > 0 && args[0] == "apply-host-overlay" {
req, err := parseApplyHostOverlayCLIArgs(args[1:])
if err != nil {
return err
}
result, err := applyHostOverlay(ctx, req)
if err != nil {
return err
}
overlayIDs := make([]string, 0, len(result.AppliedOverlays))
for _, hostOverlay := range result.AppliedOverlays {
overlayIDs = append(overlayIDs, hostOverlay.OverlayID)
}
_, err = fmt.Fprintf(output, "output_dir=%s\napplied_overlays=%d\noverlay_ids=%s\nmetadata_file=%s\n", result.OutputDir, len(result.AppliedOverlays), strings.Join(overlayIDs, ","), result.MetadataFilePath)
return err
}
cfg, err := loadConfig(ctx)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "sub2api-cn-relay-manager cli ready\nlisten_addr=%s\nsqlite_dsn=%s\n", cfg.Server.ListenAddr, cfg.Database.SQLiteDSN)
return err
}
func parseInstallPackCLIArgs(args []string) (installPackCLIRequest, error) {
fs := flag.NewFlagSet("install-pack", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var req installPackCLIRequest
fs.StringVar(&req.HostBaseURL, "host-base-url", "", "")
fs.StringVar(&req.HostAPIKey, "host-api-key", "", "")
fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "")
fs.StringVar(&req.PackPath, "pack-path", "", "")
if err := fs.Parse(args); err != nil {
return installPackCLIRequest{}, err
}
switch {
case strings.TrimSpace(req.HostBaseURL) == "":
return installPackCLIRequest{}, fmt.Errorf("--host-base-url is required")
case strings.TrimSpace(req.PackPath) == "":
return installPackCLIRequest{}, fmt.Errorf("--pack-path is required")
}
return req, nil
}
func parseImportCLIArgs(args []string) (importCLIRequest, error) {
fs := flag.NewFlagSet("import-provider", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var req importCLIRequest
var keysCSV string
var subscriptionUsersCSV string
fs.StringVar(&req.HostBaseURL, "host-base-url", "", "")
fs.StringVar(&req.HostAPIKey, "host-api-key", "", "")
fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "")
fs.StringVar(&req.PackDir, "pack-dir", "", "")
fs.StringVar(&req.ProviderID, "provider-id", "", "")
fs.StringVar(&keysCSV, "keys", "", "")
fs.StringVar(&req.Mode, "mode", provision.ImportModePartial, "")
fs.StringVar(&req.AccessMode, "access-mode", provision.AccessModeSelfService, "")
fs.StringVar(&req.AccessAPIKey, "access-api-key", "", "")
fs.StringVar(&subscriptionUsersCSV, "subscription-users", "", "")
fs.IntVar(&req.SubscriptionDays, "subscription-days", 30, "")
if err := fs.Parse(args); err != nil {
return importCLIRequest{}, err
}
req.Keys = splitCSV(keysCSV)
req.SubscriptionUsers = splitCSV(subscriptionUsersCSV)
switch {
case strings.TrimSpace(req.HostBaseURL) == "":
return importCLIRequest{}, fmt.Errorf("--host-base-url is required")
case strings.TrimSpace(req.PackDir) == "":
return importCLIRequest{}, fmt.Errorf("--pack-dir is required")
case strings.TrimSpace(req.ProviderID) == "":
return importCLIRequest{}, fmt.Errorf("--provider-id is required")
case len(req.Keys) == 0:
return importCLIRequest{}, fmt.Errorf("--keys is required")
case strings.TrimSpace(req.AccessAPIKey) == "":
return importCLIRequest{}, fmt.Errorf("--access-api-key is required")
}
return req, nil
}
func parsePreviewCLIArgs(args []string) (previewCLIRequest, error) {
fs := flag.NewFlagSet("preview-provider", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var req previewCLIRequest
var keysCSV string
fs.StringVar(&req.HostBaseURL, "host-base-url", "", "")
fs.StringVar(&req.HostAPIKey, "host-api-key", "", "")
fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "")
fs.StringVar(&req.PackDir, "pack-dir", "", "")
fs.StringVar(&req.ProviderID, "provider-id", "", "")
fs.StringVar(&keysCSV, "keys", "", "")
fs.StringVar(&req.Mode, "mode", provision.ImportModePartial, "")
if err := fs.Parse(args); err != nil {
return previewCLIRequest{}, err
}
req.Keys = splitCSV(keysCSV)
switch {
case strings.TrimSpace(req.HostBaseURL) == "":
return previewCLIRequest{}, fmt.Errorf("--host-base-url is required")
case strings.TrimSpace(req.PackDir) == "":
return previewCLIRequest{}, fmt.Errorf("--pack-dir is required")
case strings.TrimSpace(req.ProviderID) == "":
return previewCLIRequest{}, fmt.Errorf("--provider-id is required")
case len(req.Keys) == 0:
return previewCLIRequest{}, fmt.Errorf("--keys is required")
}
return req, nil
}
func parseRollbackCLIArgs(args []string) (rollbackCLIRequest, error) {
fs := flag.NewFlagSet("rollback-provider", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var req rollbackCLIRequest
fs.StringVar(&req.HostBaseURL, "host-base-url", "", "")
fs.StringVar(&req.HostAPIKey, "host-api-key", "", "")
fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "")
fs.StringVar(&req.PackDir, "pack-dir", "", "")
fs.StringVar(&req.ProviderID, "provider-id", "", "")
if err := fs.Parse(args); err != nil {
return rollbackCLIRequest{}, err
}
switch {
case strings.TrimSpace(req.HostBaseURL) == "":
return rollbackCLIRequest{}, fmt.Errorf("--host-base-url is required")
case strings.TrimSpace(req.PackDir) == "":
return rollbackCLIRequest{}, fmt.Errorf("--pack-dir is required")
case strings.TrimSpace(req.ProviderID) == "":
return rollbackCLIRequest{}, fmt.Errorf("--provider-id is required")
}
return req, nil
}
func parseReconcileCLIArgs(args []string) (reconcileCLIRequest, error) {
fs := flag.NewFlagSet("reconcile-provider", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var req reconcileCLIRequest
fs.StringVar(&req.HostBaseURL, "host-base-url", "", "")
fs.StringVar(&req.HostAPIKey, "host-api-key", "", "")
fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "")
fs.StringVar(&req.PackDir, "pack-dir", "", "")
fs.StringVar(&req.ProviderID, "provider-id", "", "")
fs.StringVar(&req.AccessAPIKey, "access-api-key", "", "")
if err := fs.Parse(args); err != nil {
return reconcileCLIRequest{}, err
}
switch {
case strings.TrimSpace(req.HostBaseURL) == "":
return reconcileCLIRequest{}, fmt.Errorf("--host-base-url is required")
case strings.TrimSpace(req.PackDir) == "":
return reconcileCLIRequest{}, fmt.Errorf("--pack-dir is required")
case strings.TrimSpace(req.ProviderID) == "":
return reconcileCLIRequest{}, fmt.Errorf("--provider-id is required")
}
return req, nil
}
func runInstallPack(ctx context.Context, req installPackCLIRequest) (provision.PackInstallResult, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.PackInstallResult{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.PackInstallResult{}, err
}
startupConfig, err := config.LoadStartupFromEnv()
if err != nil {
return provision.PackInstallResult{}, err
}
store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN)
if err != nil {
return provision.PackInstallResult{}, err
}
defer store.Close()
service := provision.NewPackInstallService(store, client)
return service.Install(ctx, provision.PackInstallRequest{Pack: loadedPack})
}
func runImportProvider(ctx context.Context, req importCLIRequest) (provision.ImportReport, error) {
loadedPack, err := pack.LoadDir(req.PackDir)
if err != nil {
return provision.ImportReport{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.ImportReport{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.ImportReport{}, err
}
startupConfig, err := config.LoadStartupFromEnv()
if err != nil {
return provision.ImportReport{}, err
}
store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN)
if err != nil {
return provision.ImportReport{}, err
}
defer store.Close()
subscriptions := make([]provision.SubscriptionTarget, 0, len(req.SubscriptionUsers))
for _, userID := range req.SubscriptionUsers {
subscriptions = append(subscriptions, provision.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays})
}
runtimeService := provision.NewRuntimeImportService(store, client)
result, err := runtimeService.Import(ctx, provision.RuntimeImportRequest{
HostBaseURL: req.HostBaseURL,
Pack: loadedPack,
Provider: providerManifest,
Mode: req.Mode,
Keys: req.Keys,
Access: provision.AccessRequest{
Mode: req.AccessMode,
ProbeAPIKey: req.AccessAPIKey,
Subscriptions: subscriptions,
},
})
return result.Report, err
}
func runPreviewProvider(ctx context.Context, req previewCLIRequest) (provision.PreviewReport, error) {
loadedPack, err := pack.LoadDir(req.PackDir)
if err != nil {
return provision.PreviewReport{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.PreviewReport{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.PreviewReport{}, err
}
service := provision.NewPreviewService(client)
hostVersion, err := client.GetHostVersion(ctx)
if err != nil {
return provision.PreviewReport{}, err
}
return service.PreviewImport(ctx, provision.PreviewRequest{
TargetHost: loadedPack.Manifest.TargetHost,
HostVersion: hostVersion,
Provider: providerManifest,
Mode: req.Mode,
Keys: req.Keys,
})
}
func runRollbackProvider(ctx context.Context, req rollbackCLIRequest) (rollbackSummary, error) {
loadedPack, err := pack.LoadDir(req.PackDir)
if err != nil {
return rollbackSummary{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return rollbackSummary{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return rollbackSummary{}, err
}
service := provision.NewRollbackService(client)
report, err := service.Rollback(ctx, provision.RollbackRequest{Provider: providerManifest})
if err != nil {
return rollbackSummary{}, err
}
return rollbackSummary{
Accounts: report.AccountsDeleted,
Plans: report.PlansDeleted,
Channels: report.ChannelsDeleted,
Groups: report.GroupsDeleted,
}, nil
}
func runReconcileProvider(ctx context.Context, req reconcileCLIRequest) (reconcile.Result, error) {
loadedPack, err := pack.LoadDir(req.PackDir)
if err != nil {
return reconcile.Result{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return reconcile.Result{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return reconcile.Result{}, err
}
startupConfig, err := config.LoadStartupFromEnv()
if err != nil {
return reconcile.Result{}, err
}
store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN)
if err != nil {
return reconcile.Result{}, err
}
defer store.Close()
hostRow, err := store.Hosts().GetByBaseURL(ctx, req.HostBaseURL)
if err != nil {
return reconcile.Result{}, err
}
service := reconcile.NewService(store, client)
return service.Reconcile(ctx, reconcile.Request{HostID: hostRow.HostID, HostBaseURL: req.HostBaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest})
}
func findProvider(loaded pack.LoadedPack, providerID string) (pack.ProviderManifest, error) {
for _, provider := range loaded.Providers {
if provider.ProviderID == strings.TrimSpace(providerID) {
return provider, nil
}
}
return pack.ProviderManifest{}, fmt.Errorf("provider %q not found in pack %q", providerID, loaded.Manifest.PackID)
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}