2026-05-15 19:26:25 +08:00
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/provision"
"sub2api-cn-relay-manager/internal/store/sqlite"
2026-05-18 22:22:22 +08:00
"sub2api-cn-relay-manager/internal/access"
2026-05-15 19:26:25 +08:00
)
type ActionSet struct {
2026-05-18 22:22:22 +08:00
InstallPack func ( context . Context , InstallPackRequest ) ( provision . PackInstallResult , error )
BatchDetail func ( context . Context , BatchDetailRequest ) ( provision . BatchDetailResult , error )
GetProviderStatus func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error )
GetProviderResources func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error )
GetProviderAccessStatus func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error )
ListProviderImportBatches func ( context . Context , ProviderQueryRequest ) ( [ ] ImportBatchInfo , error )
PreviewProvider func ( context . Context , PreviewProviderRequest ) ( provision . PreviewReport , error )
ImportProvider func ( context . Context , ImportProviderRequest ) ( provision . RuntimeImportResult , error )
RollbackProvider func ( context . Context , RollbackProviderRequest ) ( provision . RollbackReport , error )
RollbackBatch func ( context . Context , RollbackBatchRequest ) ( provision . RollbackReport , error )
ReconcileProvider func ( context . Context , ReconcileProviderRequest ) ( provision . ReconcileResult , error )
CreateHost func ( context . Context , CreateHostRequest ) ( HostInfo , error )
ProbeHost func ( context . Context , ProbeHostRequest ) ( HostInfo , error )
ListHosts func ( context . Context ) ( [ ] HostInfo , error )
GetHost func ( context . Context , string ) ( HostInfo , error )
DeleteHost func ( context . Context , string ) error
ListPacks func ( context . Context ) ( [ ] PackInfo , error )
GetPack func ( context . Context , string ) ( PackInfo , error )
ListPackProviders func ( context . Context , string ) ( [ ] PackProviderInfo , error )
AssignAccessSubscriptions func ( context . Context , AssignAccessSubscriptionsRequest ) ( AssignAccessSubscriptionsResult , error )
AccessPreview func ( context . Context , AccessPreviewRequest ) ( AccessPreviewResult , error )
}
type HostInfo struct {
HostID string ` json:"host_id" `
BaseURL string ` json:"base_url" `
HostVersion string ` json:"host_version" `
AuthType string ` json:"auth_type,omitempty" `
Status string ` json:"status,omitempty" `
Capabilities * sub2api . HostCapabilities ` json:"capabilities,omitempty" `
}
type CreateHostRequest struct {
Name string ` json:"name" `
BaseURL string ` json:"base_url" `
Auth CreateHostAuth ` json:"auth" `
}
type ProbeHostRequest struct {
HostID string ` json:"-" `
Auth CreateHostAuth ` json:"auth" `
}
type CreateHostAuth struct {
Type string ` json:"type" `
Token string ` json:"token" `
}
type ImportBatchInfo struct {
BatchID int64 ` json:"batch_id" `
BatchStatus string ` json:"batch_status" `
AccessStatus string ` json:"access_status" `
}
type PackInfo struct {
PackID string ` json:"pack_id" `
Version string ` json:"version" `
Vendor string ` json:"vendor,omitempty" `
TargetHost string ` json:"target_host,omitempty" `
MinHostVersion string ` json:"min_host_version,omitempty" `
MaxHostVersion string ` json:"max_host_version,omitempty" `
}
type PackProviderInfo struct {
ProviderID string ` json:"provider_id" `
DisplayName string ` json:"display_name" `
Platform string ` json:"platform,omitempty" `
}
type AssignAccessSubscriptionsRequest struct {
HostID string ` json:"host_id,omitempty" `
PackPath string ` json:"pack_path" `
ProviderID string ` json:"provider_id" `
HostBaseURL string ` json:"host_base_url,omitempty" `
HostAPIKey string ` json:"host_api_key,omitempty" `
HostBearerToken string ` json:"host_bearer_token,omitempty" `
AccessAPIKey string ` json:"access_api_key" `
SubscriptionUsers [ ] string ` json:"subscription_users" `
SubscriptionDays int ` json:"subscription_days" `
}
type AssignAccessSubscriptionsResult struct {
ProviderID string ` json:"provider_id" `
Assigned int ` json:"assigned" `
AccessStatus string ` json:"access_status" `
}
type AccessPreviewRequest struct {
ProviderID string ` json:"provider_id" `
PackID string ` json:"pack_id,omitempty" `
HostID string ` json:"host_id,omitempty" `
Mode string ` json:"mode" `
}
type AccessPreviewResult struct {
ProviderID string ` json:"provider_id" `
Mode string ` json:"mode" `
Available bool ` json:"available" `
Message string ` json:"message,omitempty" `
2026-05-15 19:26:25 +08:00
}
type InstallPackRequest struct {
HostBaseURL string ` json:"host_base_url" `
HostAPIKey string ` json:"host_api_key" `
HostBearerToken string ` json:"host_bearer_token" `
PackPath string ` json:"pack_path" `
}
type BatchDetailRequest struct {
BatchID int64
}
type ProviderQueryRequest struct {
ProviderID string
PackID string
2026-05-18 22:22:22 +08:00
HostID string
2026-05-15 19:26:25 +08:00
}
type RollbackProviderRequest struct {
2026-05-18 22:22:22 +08:00
HostID string ` json:"host_id,omitempty" `
HostBaseURL string ` json:"host_base_url,omitempty" `
HostAPIKey string ` json:"host_api_key,omitempty" `
HostBearerToken string ` json:"host_bearer_token,omitempty" `
2026-05-15 19:26:25 +08:00
PackPath string ` json:"pack_path" `
ProviderID string ` json:"provider_id" `
}
2026-05-18 22:22:22 +08:00
type RollbackBatchRequest struct {
BatchID int64 ` json:"-" `
Auth CreateHostAuth ` json:"auth" `
}
2026-05-15 19:26:25 +08:00
type ReconcileProviderRequest struct {
2026-05-18 22:22:22 +08:00
HostID string ` json:"host_id,omitempty" `
HostBaseURL string ` json:"host_base_url,omitempty" `
HostAPIKey string ` json:"host_api_key,omitempty" `
HostBearerToken string ` json:"host_bearer_token,omitempty" `
2026-05-15 19:26:25 +08:00
PackPath string ` json:"pack_path" `
ProviderID string ` json:"provider_id" `
AccessAPIKey string ` json:"access_api_key" `
}
type PreviewProviderRequest struct {
2026-05-18 22:22:22 +08:00
HostID string ` json:"host_id,omitempty" `
HostBaseURL string ` json:"host_base_url,omitempty" `
HostAPIKey string ` json:"host_api_key,omitempty" `
HostBearerToken string ` json:"host_bearer_token,omitempty" `
2026-05-15 19:26:25 +08:00
PackPath string ` json:"pack_path" `
ProviderID string ` json:"provider_id" `
Keys [ ] string ` json:"keys" `
Mode string ` json:"mode" `
}
type ImportProviderRequest struct {
2026-05-18 22:22:22 +08:00
HostID string ` json:"host_id,omitempty" `
HostBaseURL string ` json:"host_base_url,omitempty" `
HostAPIKey string ` json:"host_api_key,omitempty" `
HostBearerToken string ` json:"host_bearer_token,omitempty" `
2026-05-15 19:26:25 +08:00
PackPath string ` json:"pack_path" `
ProviderID string ` json:"provider_id" `
Keys [ ] string ` json:"keys" `
Mode string ` json:"mode" `
AccessMode string ` json:"access_mode" `
AccessAPIKey string ` json:"access_api_key" `
SubscriptionUsers [ ] string ` json:"subscription_users" `
SubscriptionDays int ` json:"subscription_days" `
}
type httpError struct {
StatusCode int ` json:"-" `
Code string ` json:"code" `
Message string ` json:"message" `
UpstreamStatus int ` json:"upstream_status,omitempty" `
}
func ( e * httpError ) Error ( ) string {
return e . Message
}
func NewAPIHandler ( adminToken string , actions ActionSet ) http . Handler {
mux := http . NewServeMux ( )
mux . HandleFunc ( "GET /healthz" , healthz )
mux . Handle ( "GET /api/import-batches/{batchID}" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleBatchDetail ( w , r , actions . BatchDetail )
} ) ) )
2026-05-18 22:22:22 +08:00
mux . Handle ( "POST /api/import-batches/{batchID}/rollback" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleRollbackBatch ( w , r , actions . RollbackBatch )
} ) ) )
2026-05-15 19:26:25 +08:00
mux . Handle ( "GET /api/providers/{providerID}/status" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleProviderStatus ( w , r , actions . GetProviderStatus )
} ) ) )
mux . Handle ( "GET /api/providers/{providerID}/resources" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleProviderResources ( w , r , actions . GetProviderResources )
} ) ) )
mux . Handle ( "GET /api/providers/{providerID}/access/status" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleProviderAccessStatus ( w , r , actions . GetProviderAccessStatus )
} ) ) )
2026-05-18 22:22:22 +08:00
mux . Handle ( "GET /api/providers/{providerID}/import-batches" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleListProviderImportBatches ( w , r , actions . ListProviderImportBatches )
} ) ) )
mux . Handle ( "POST /api/providers/{providerID}/access/assign-subscriptions" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleAssignAccessSubscriptions ( w , r , actions . AssignAccessSubscriptions )
} ) ) )
mux . Handle ( "POST /api/providers/{providerID}/access/preview" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleAccessPreview ( w , r , actions . AccessPreview )
} ) ) )
2026-05-15 19:26:25 +08:00
mux . Handle ( "POST /api/packs/install" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleInstallPack ( w , r , actions . InstallPack )
} ) ) )
2026-05-18 22:22:22 +08:00
mux . Handle ( "GET /api/packs" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleListPacks ( w , r , actions . ListPacks )
} ) ) )
mux . Handle ( "GET /api/packs/{packID}" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleGetPack ( w , r , actions . GetPack )
} ) ) )
mux . Handle ( "GET /api/packs/{packID}/providers" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleListPackProviders ( w , r , actions . ListPackProviders )
} ) ) )
2026-05-15 19:26:25 +08:00
mux . Handle ( "POST /api/providers/{providerID}/preview-import" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handlePreviewProvider ( w , r , actions . PreviewProvider )
} ) ) )
mux . Handle ( "POST /api/providers/{providerID}/import" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleImportProvider ( w , r , actions . ImportProvider )
} ) ) )
mux . Handle ( "POST /api/providers/{providerID}/rollback" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleRollbackProvider ( w , r , actions . RollbackProvider )
} ) ) )
mux . Handle ( "POST /api/providers/{providerID}/reconcile" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleReconcileProvider ( w , r , actions . ReconcileProvider )
} ) ) )
2026-05-18 22:22:22 +08:00
mux . Handle ( "GET /api/hosts" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleListHosts ( w , r , actions . ListHosts )
} ) ) )
mux . Handle ( "GET /api/hosts/{hostID}" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleGetHost ( w , r , actions . GetHost )
} ) ) )
mux . Handle ( "POST /api/hosts" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleCreateHost ( w , r , actions . CreateHost )
} ) ) )
mux . Handle ( "POST /api/hosts/{hostID}/probe" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleProbeHost ( w , r , actions . ProbeHost )
} ) ) )
mux . Handle ( "DELETE /api/hosts/{hostID}" , requireAdminToken ( adminToken , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
handleDeleteHost ( w , r , actions . DeleteHost )
} ) ) )
2026-05-15 19:26:25 +08:00
return mux
}
func healthz ( w http . ResponseWriter , _ * http . Request ) {
w . WriteHeader ( http . StatusOK )
_ , _ = w . Write ( [ ] byte ( "ok" ) )
}
func requireAdminToken ( token string , next http . Handler ) http . Handler {
if strings . TrimSpace ( token ) == "" {
return http . HandlerFunc ( func ( w http . ResponseWriter , _ * http . Request ) {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "admin token is not configured" } )
} )
}
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if bearerToken ( r ) != token {
writeHTTPError ( w , & httpError { StatusCode : http . StatusUnauthorized , Code : "unauthorized" , Message : "missing or invalid admin token" } )
return
}
next . ServeHTTP ( w , r )
} )
}
func bearerToken ( r * http . Request ) string {
header := strings . TrimSpace ( r . Header . Get ( "Authorization" ) )
if ! strings . HasPrefix ( strings . ToLower ( header ) , "bearer " ) {
return ""
}
return strings . TrimSpace ( header [ len ( "Bearer " ) : ] )
}
func handleInstallPack ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , InstallPackRequest ) ( provision . PackInstallResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "install-pack action is not configured" } )
return
}
var req InstallPackRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
providers := make ( [ ] map [ string ] string , 0 , len ( result . Providers ) )
for _ , provider := range result . Providers {
providers = append ( providers , map [ string ] string {
"provider_id" : provider . ProviderID ,
"display_name" : provider . DisplayName ,
} )
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"pack_id" : result . Pack . PackID ,
"version" : result . Pack . Version ,
"host_version" : result . HostVersion ,
"already_installed" : result . AlreadyInstalled ,
"providers" : providers ,
} )
}
2026-05-18 22:22:22 +08:00
func handleListPacks ( w http . ResponseWriter , r * http . Request , fn func ( context . Context ) ( [ ] PackInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "list-packs action is not configured" } )
return
}
packs , err := fn ( r . Context ( ) )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
if packs == nil {
packs = [ ] PackInfo { }
}
writeJSON ( w , http . StatusOK , map [ string ] any { "packs" : packs } )
}
func handleListProviderImportBatches ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ProviderQueryRequest ) ( [ ] ImportBatchInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "list-provider-import-batches action is not configured" } )
return
}
batches , err := fn ( r . Context ( ) , ProviderQueryRequest { ProviderID : r . PathValue ( "providerID" ) , PackID : r . URL . Query ( ) . Get ( "pack_id" ) , HostID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "host_id" ) ) } )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
if batches == nil {
batches = [ ] ImportBatchInfo { }
}
writeJSON ( w , http . StatusOK , map [ string ] any { "batches" : batches } )
}
func handleAssignAccessSubscriptions ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , AssignAccessSubscriptionsRequest ) ( AssignAccessSubscriptionsResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "assign-access-subscriptions action is not configured" } )
return
}
var req AssignAccessSubscriptionsRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , result )
}
func handleAccessPreview ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , AccessPreviewRequest ) ( AccessPreviewResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "access-preview action is not configured" } )
return
}
var req AccessPreviewRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
if req . PackID == "" {
req . PackID = strings . TrimSpace ( r . URL . Query ( ) . Get ( "pack_id" ) )
}
if req . HostID == "" {
req . HostID = strings . TrimSpace ( r . URL . Query ( ) . Get ( "host_id" ) )
}
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , result )
}
func handleGetPack ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , string ) ( PackInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "get-pack action is not configured" } )
return
}
packID := strings . TrimSpace ( r . PathValue ( "packID" ) )
if packID == "" {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "pack_id is required" } )
return
}
pack , err := fn ( r . Context ( ) , packID )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , pack )
}
func handleListPackProviders ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , string ) ( [ ] PackProviderInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "list-pack-providers action is not configured" } )
return
}
packID := strings . TrimSpace ( r . PathValue ( "packID" ) )
if packID == "" {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "pack_id is required" } )
return
}
providers , err := fn ( r . Context ( ) , packID )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
if providers == nil {
providers = [ ] PackProviderInfo { }
}
writeJSON ( w , http . StatusOK , map [ string ] any { "pack_id" : packID , "providers" : providers } )
}
2026-05-15 19:26:25 +08:00
func handleBatchDetail ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , BatchDetailRequest ) ( provision . BatchDetailResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "batch-detail action is not configured" } )
return
}
batchID , err := strconv . ParseInt ( r . PathValue ( "batchID" ) , 10 , 64 )
if err != nil || batchID <= 0 {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "batch_id must be a positive integer" } )
return
}
result , err := fn ( r . Context ( ) , BatchDetailRequest { BatchID : batchID } )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
items := make ( [ ] map [ string ] any , 0 , len ( result . Items ) )
for _ , item := range result . Items {
items = append ( items , map [ string ] any {
"id" : item . ID ,
"batch_id" : item . BatchID ,
"key_fingerprint" : item . KeyFingerprint ,
"account_status" : item . AccountStatus ,
"probe_summary_json" : item . ProbeSummaryJSON ,
} )
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"batch" : map [ string ] any {
"id" : result . Batch . ID ,
"host_id" : result . Batch . HostID ,
"pack_id" : result . Batch . PackID ,
"provider_id" : result . Batch . ProviderID ,
"mode" : result . Batch . Mode ,
"batch_status" : result . Batch . BatchStatus ,
"access_status" : result . Batch . AccessStatus ,
} ,
"items" : items ,
"managed_resources" : result . ManagedResources ,
"access_closures" : result . AccessClosures ,
"reconcile_runs" : result . ReconcileRuns ,
"items_count" : len ( result . Items ) ,
"managed_count" : len ( result . ManagedResources ) ,
"access_count" : len ( result . AccessClosures ) ,
"reconcile_count" : len ( result . ReconcileRuns ) ,
} )
}
func handleProviderStatus ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "provider-status action is not configured" } )
return
}
2026-05-18 22:22:22 +08:00
result , err := fn ( r . Context ( ) , ProviderQueryRequest { ProviderID : r . PathValue ( "providerID" ) , PackID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "pack_id" ) ) , HostID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "host_id" ) ) } )
2026-05-15 19:26:25 +08:00
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"host" : map [ string ] any { "host_id" : result . Host . HostID , "base_url" : result . Host . BaseURL , "host_version" : result . Host . HostVersion } ,
"pack" : map [ string ] any { "pack_id" : result . Pack . PackID , "version" : result . Pack . Version } ,
"provider" : map [ string ] any { "provider_id" : result . Provider . ProviderID , "display_name" : result . Provider . DisplayName , "platform" : result . Provider . Platform } ,
"batch" : map [ string ] any { "id" : result . Batch . ID , "batch_status" : result . Batch . BatchStatus , "access_status" : result . Batch . AccessStatus , "mode" : result . Batch . Mode } ,
"provider_status" : result . ProviderStatus ,
"latest_access_status" : result . LatestAccessStatus ,
"latest_reconcile_status" : result . LatestReconcileStatus ,
"latest_reconcile_summary" : result . LatestReconcileSummary ,
"managed_resources_count" : len ( result . ManagedResources ) ,
"access_closures_count" : len ( result . AccessClosures ) ,
"reconcile_runs_count" : len ( result . ReconcileRuns ) ,
} )
}
func handleProviderAccessStatus ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "provider-access-status action is not configured" } )
return
}
2026-05-18 22:22:22 +08:00
result , err := fn ( r . Context ( ) , ProviderQueryRequest { ProviderID : r . PathValue ( "providerID" ) , PackID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "pack_id" ) ) , HostID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "host_id" ) ) } )
2026-05-15 19:26:25 +08:00
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
latestClosure := map [ string ] any { }
if n := len ( result . AccessClosures ) ; n > 0 {
closure := result . AccessClosures [ n - 1 ]
latestClosure = map [ string ] any { "id" : closure . ID , "closure_type" : closure . ClosureType , "status" : closure . Status , "details_json" : closure . DetailsJSON }
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"provider_id" : result . Provider . ProviderID ,
"pack_id" : result . Pack . PackID ,
"batch_id" : result . Batch . ID ,
"batch_access_status" : result . Batch . AccessStatus ,
"latest_access_status" : result . LatestAccessStatus ,
"closures_count" : len ( result . AccessClosures ) ,
"latest_closure" : latestClosure ,
} )
}
func handleProviderResources ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "provider-resources action is not configured" } )
return
}
2026-05-18 22:22:22 +08:00
result , err := fn ( r . Context ( ) , ProviderQueryRequest { ProviderID : r . PathValue ( "providerID" ) , PackID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "pack_id" ) ) , HostID : strings . TrimSpace ( r . URL . Query ( ) . Get ( "host_id" ) ) } )
2026-05-15 19:26:25 +08:00
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
resources := make ( [ ] map [ string ] any , 0 , len ( result . ManagedResources ) )
for _ , resource := range result . ManagedResources {
resources = append ( resources , map [ string ] any { "id" : resource . ID , "resource_type" : resource . ResourceType , "host_resource_id" : resource . HostResourceID , "resource_name" : resource . ResourceName } )
}
accessClosures := make ( [ ] map [ string ] any , 0 , len ( result . AccessClosures ) )
for _ , closure := range result . AccessClosures {
accessClosures = append ( accessClosures , map [ string ] any { "id" : closure . ID , "closure_type" : closure . ClosureType , "status" : closure . Status , "details_json" : closure . DetailsJSON } )
}
reconcileRuns := make ( [ ] map [ string ] any , 0 , len ( result . ReconcileRuns ) )
for _ , run := range result . ReconcileRuns {
reconcileRuns = append ( reconcileRuns , map [ string ] any { "id" : run . ID , "status" : run . Status , "summary_json" : run . SummaryJSON } )
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"provider_id" : result . Provider . ProviderID ,
"pack_id" : result . Pack . PackID ,
"batch_id" : result . Batch . ID ,
"resources" : resources ,
"access_closures" : accessClosures ,
"reconcile_runs" : reconcileRuns ,
} )
}
func handlePreviewProvider ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , PreviewProviderRequest ) ( provision . PreviewReport , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "preview-provider action is not configured" } )
return
}
var req PreviewProviderRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"accepted_keys_count" : len ( result . AcceptedKeys ) ,
"names" : result . Names ,
"decisions" : result . Decisions ,
} )
}
func handleImportProvider ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ImportProviderRequest ) ( provision . RuntimeImportResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "import-provider action is not configured" } )
return
}
var req ImportProviderRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
result , err := fn ( r . Context ( ) , req )
if err != nil {
payload := map [ string ] any {
"batch_id" : result . BatchID ,
"batch_status" : result . Report . BatchStatus ,
"provider_status" : result . Report . ProviderStatus ,
"access_status" : result . Report . AccessStatus ,
"accepted_keys_count" : len ( result . Report . AcceptedKeys ) ,
"accounts_count" : len ( result . Report . Accounts ) ,
"gateway" : result . Report . Gateway ,
"error" : classifyError ( err ) ,
}
statusCode := http . StatusConflict
if result . BatchID == 0 {
statusCode = classifyError ( err ) . StatusCode
}
writeJSON ( w , statusCode , payload )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"batch_id" : result . BatchID ,
"batch_status" : result . Report . BatchStatus ,
"provider_status" : result . Report . ProviderStatus ,
"access_status" : result . Report . AccessStatus ,
"accepted_keys_count" : len ( result . Report . AcceptedKeys ) ,
"accounts_count" : len ( result . Report . Accounts ) ,
"group" : result . Report . Group ,
"channel" : result . Report . Channel ,
"plan" : result . Report . Plan ,
"gateway" : result . Report . Gateway ,
} )
}
func handleRollbackProvider ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , RollbackProviderRequest ) ( provision . RollbackReport , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "rollback-provider action is not configured" } )
return
}
var req RollbackProviderRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"provider_id" : req . ProviderID ,
"deleted_accounts" : result . AccountsDeleted ,
"deleted_plans" : result . PlansDeleted ,
"deleted_channels" : result . ChannelsDeleted ,
"deleted_groups" : result . GroupsDeleted ,
} )
}
2026-05-18 22:22:22 +08:00
func handleRollbackBatch ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , RollbackBatchRequest ) ( provision . RollbackReport , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "rollback-batch action is not configured" } )
return
}
var req RollbackBatchRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
batchID , err := strconv . ParseInt ( strings . TrimSpace ( r . PathValue ( "batchID" ) ) , 10 , 64 )
if err != nil || batchID <= 0 {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "batch_id must be a positive integer" } )
return
}
req . BatchID = batchID
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"batch_id" : req . BatchID ,
"deleted_accounts" : result . AccountsDeleted ,
"deleted_plans" : result . PlansDeleted ,
"deleted_channels" : result . ChannelsDeleted ,
"deleted_groups" : result . GroupsDeleted ,
} )
}
2026-05-15 19:26:25 +08:00
func handleReconcileProvider ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ReconcileProviderRequest ) ( provision . ReconcileResult , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "reconcile-provider action is not configured" } )
return
}
var req ReconcileProviderRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . ProviderID = r . PathValue ( "providerID" )
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"provider_id" : req . ProviderID ,
"batch_id" : result . BatchID ,
"status" : result . Status ,
"missing_count" : result . MissingCount ,
"extra_count" : result . ExtraCount ,
"summary" : result . Summary ,
} )
}
2026-05-18 22:22:22 +08:00
func handleListHosts ( w http . ResponseWriter , r * http . Request , fn func ( context . Context ) ( [ ] HostInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "list-hosts action is not configured" } )
return
}
hosts , err := fn ( r . Context ( ) )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
if hosts == nil {
hosts = [ ] HostInfo { }
}
writeJSON ( w , http . StatusOK , map [ string ] any { "hosts" : hosts } )
}
func handleGetHost ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , string ) ( HostInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "get-host action is not configured" } )
return
}
hostID := strings . TrimSpace ( r . PathValue ( "hostID" ) )
if hostID == "" {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "host_id is required" } )
return
}
host , err := fn ( r . Context ( ) , hostID )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , host )
}
func handleProbeHost ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , ProbeHostRequest ) ( HostInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "probe-host action is not configured" } )
return
}
var req ProbeHostRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
req . HostID = strings . TrimSpace ( r . PathValue ( "hostID" ) )
if req . HostID == "" {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "host_id is required" } )
return
}
host , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , host )
}
func handleCreateHost ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , CreateHostRequest ) ( HostInfo , error ) ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "create-host action is not configured" } )
return
}
var req CreateHostRequest
if err := decodeJSON ( r , & req ) ; err != nil {
writeHTTPError ( w , err )
return
}
result , err := fn ( r . Context ( ) , req )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
writeJSON ( w , http . StatusOK , result )
}
func handleDeleteHost ( w http . ResponseWriter , r * http . Request , fn func ( context . Context , string ) error ) {
if fn == nil {
writeHTTPError ( w , & httpError { StatusCode : http . StatusInternalServerError , Code : "server_misconfigured" , Message : "delete-host action is not configured" } )
return
}
hostID := strings . TrimSpace ( r . PathValue ( "hostID" ) )
if hostID == "" {
writeHTTPError ( w , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "host_id is required" } )
return
}
if err := fn ( r . Context ( ) , hostID ) ; err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
w . WriteHeader ( http . StatusNoContent )
}
2026-05-15 19:26:25 +08:00
func decodeJSON ( r * http . Request , dest any ) * httpError {
decoder := json . NewDecoder ( r . Body )
decoder . DisallowUnknownFields ( )
if err := decoder . Decode ( dest ) ; err != nil {
return & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : fmt . Sprintf ( "decode request body: %v" , err ) }
}
if err := decoder . Decode ( & struct { } { } ) ; err != nil && ! errors . Is ( err , io . EOF ) {
return & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "request body must contain a single JSON object" }
}
return nil
}
func writeHTTPError ( w http . ResponseWriter , err * httpError ) {
if err == nil {
err = & httpError { StatusCode : http . StatusInternalServerError , Code : "internal_error" , Message : "internal server error" }
}
writeJSON ( w , err . StatusCode , map [ string ] any { "error" : err } )
}
func writeJSON ( w http . ResponseWriter , statusCode int , body any ) {
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( statusCode )
_ = json . NewEncoder ( w ) . Encode ( body )
}
func classifyError ( err error ) * httpError {
if err == nil {
return nil
}
var requestErr * httpError
if errors . As ( err , & requestErr ) {
return requestErr
}
var upstreamErr * sub2api . HTTPError
if errors . As ( err , & upstreamErr ) {
return & httpError { StatusCode : http . StatusBadGateway , Code : "host_request_failed" , Message : err . Error ( ) , UpstreamStatus : upstreamErr . StatusCode }
}
message := err . Error ( )
switch {
case strings . Contains ( message , "already installed" ) || strings . Contains ( message , "checksum drift" ) :
return & httpError { StatusCode : http . StatusConflict , Code : "pack_conflict" , Message : message }
2026-05-18 22:22:22 +08:00
case strings . Contains ( message , "run import again before reconcile" ) :
return & httpError { StatusCode : http . StatusConflict , Code : "batch_not_reconcilable" , Message : message }
2026-05-15 19:26:25 +08:00
case strings . Contains ( message , "not found in pack" ) :
return & httpError { StatusCode : http . StatusBadRequest , Code : "provider_not_found" , Message : message }
2026-05-18 22:22:22 +08:00
case strings . Contains ( message , "not found" ) :
return & httpError { StatusCode : http . StatusNotFound , Code : "not_found" , Message : message }
2026-05-15 19:26:25 +08:00
case strings . Contains ( message , "pack path" ) || strings . Contains ( message , "pack dir" ) || strings . Contains ( message , "required" ) || strings . Contains ( message , "decode" ) :
return & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : message }
default :
return & httpError { StatusCode : http . StatusInternalServerError , Code : "internal_error" , Message : message }
}
}
func NewActionSet ( sqliteDSN string ) ActionSet {
return ActionSet {
InstallPack : func ( ctx context . Context , req InstallPackRequest ) ( 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
}
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . PackInstallResult { } , err
}
defer store . Close ( )
service := provision . NewPackInstallService ( store , client )
return service . Install ( ctx , provision . PackInstallRequest { Pack : loadedPack } )
} ,
BatchDetail : func ( ctx context . Context , req BatchDetailRequest ) ( provision . BatchDetailResult , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . BatchDetailResult { } , err
}
defer store . Close ( )
return provision . NewBatchDetailService ( store ) . Get ( ctx , req . BatchID )
} ,
GetProviderStatus : func ( ctx context . Context , req ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . ProviderSnapshot { } , err
}
defer store . Close ( )
2026-05-18 22:22:22 +08:00
return provision . NewProviderStatusService ( store ) . GetStatus ( ctx , provision . ProviderQuery { ProviderID : req . ProviderID , PackID : req . PackID , HostID : req . HostID } )
2026-05-15 19:26:25 +08:00
} ,
GetProviderResources : func ( ctx context . Context , req ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . ProviderSnapshot { } , err
}
defer store . Close ( )
2026-05-18 22:22:22 +08:00
return provision . NewProviderStatusService ( store ) . GetResources ( ctx , provision . ProviderQuery { ProviderID : req . ProviderID , PackID : req . PackID , HostID : req . HostID } )
2026-05-15 19:26:25 +08:00
} ,
GetProviderAccessStatus : func ( ctx context . Context , req ProviderQueryRequest ) ( provision . ProviderSnapshot , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . ProviderSnapshot { } , err
}
defer store . Close ( )
2026-05-18 22:22:22 +08:00
return provision . NewProviderStatusService ( store ) . GetStatus ( ctx , provision . ProviderQuery { ProviderID : req . ProviderID , PackID : req . PackID , HostID : req . HostID } )
} ,
ListProviderImportBatches : func ( ctx context . Context , req ProviderQueryRequest ) ( [ ] ImportBatchInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return nil , err
}
defer store . Close ( )
providers , err := resolveProvidersForQuery ( ctx , store , req )
if err != nil {
return nil , err
}
batches := make ( [ ] ImportBatchInfo , 0 )
for _ , providerRow := range providers {
var rows [ ] sqlite . ImportBatch
if strings . TrimSpace ( req . HostID ) != "" {
hostRow , err := store . Hosts ( ) . GetByHostID ( ctx , req . HostID )
if err != nil {
return nil , err
}
rows , err = store . ImportBatches ( ) . ListByProviderIDAndHostID ( ctx , providerRow . ID , hostRow . ID )
if err != nil {
return nil , err
}
} else {
rows , err = store . ImportBatches ( ) . ListByProviderID ( ctx , providerRow . ID )
if err != nil {
return nil , err
}
if len ( rows ) > 1 {
firstHostID := rows [ 0 ] . HostID
for _ , row := range rows [ 1 : ] {
if row . HostID != firstHostID {
return nil , fmt . Errorf ( "provider exists on multiple hosts; host_id is required" )
}
}
}
}
for _ , batch := range rows {
batches = append ( batches , ImportBatchInfo { BatchID : batch . ID , BatchStatus : batch . BatchStatus , AccessStatus : batch . AccessStatus } )
}
}
return batches , nil
2026-05-15 19:26:25 +08:00
} ,
PreviewProvider : func ( ctx context . Context , req PreviewProviderRequest ) ( provision . PreviewReport , error ) {
loadedPack , err := pack . LoadPath ( req . PackPath )
if err != nil {
return provision . PreviewReport { } , err
}
providerManifest , err := findProvider ( loadedPack , req . ProviderID )
if err != nil {
return provision . PreviewReport { } , err
}
2026-05-18 22:22:22 +08:00
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . PreviewReport { } , err
}
defer store . Close ( )
_ , client , err := resolveManagedHost ( ctx , store , req . HostID , req . HostBaseURL , createHostAuthFromLegacyFields ( req . HostAPIKey , req . HostBearerToken ) )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . PreviewReport { } , err
}
service := provision . NewPreviewService ( client )
return service . PreviewImport ( ctx , provision . PreviewRequest { Provider : providerManifest , Mode : req . Mode , Keys : req . Keys } )
} ,
ImportProvider : func ( ctx context . Context , req ImportProviderRequest ) ( provision . RuntimeImportResult , error ) {
loadedPack , err := pack . LoadPath ( req . PackPath )
if err != nil {
return provision . RuntimeImportResult { } , err
}
providerManifest , err := findProvider ( loadedPack , req . ProviderID )
if err != nil {
return provision . RuntimeImportResult { } , err
}
2026-05-18 22:22:22 +08:00
store , err := sqlite . Open ( ctx , sqliteDSN )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . RuntimeImportResult { } , err
}
2026-05-18 22:22:22 +08:00
defer store . Close ( )
hostRow , client , err := resolveManagedHost ( ctx , store , req . HostID , req . HostBaseURL , createHostAuthFromLegacyFields ( req . HostAPIKey , req . HostBearerToken ) )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . RuntimeImportResult { } , err
}
subscriptions := make ( [ ] provision . SubscriptionTarget , 0 , len ( req . SubscriptionUsers ) )
for _ , userID := range req . SubscriptionUsers {
subscriptions = append ( subscriptions , provision . SubscriptionTarget { UserID : userID , DurationDays : req . SubscriptionDays } )
}
service := provision . NewRuntimeImportService ( store , client )
return service . Import ( ctx , provision . RuntimeImportRequest {
2026-05-18 22:22:22 +08:00
HostID : hostRow . HostID ,
HostBaseURL : hostRow . BaseURL ,
2026-05-15 19:26:25 +08:00
Pack : loadedPack ,
Provider : providerManifest ,
Mode : req . Mode ,
Keys : req . Keys ,
Access : provision . AccessRequest {
Mode : req . AccessMode ,
ProbeAPIKey : req . AccessAPIKey ,
Subscriptions : subscriptions ,
} ,
} )
} ,
RollbackProvider : func ( ctx context . Context , req RollbackProviderRequest ) ( provision . RollbackReport , error ) {
loadedPack , err := pack . LoadPath ( req . PackPath )
if err != nil {
return provision . RollbackReport { } , err
}
providerManifest , err := findProvider ( loadedPack , req . ProviderID )
if err != nil {
return provision . RollbackReport { } , err
}
2026-05-18 22:22:22 +08:00
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . RollbackReport { } , err
}
defer store . Close ( )
hostRow , client , err := resolveManagedHost ( ctx , store , req . HostID , req . HostBaseURL , createHostAuthFromLegacyFields ( req . HostAPIKey , req . HostBearerToken ) )
if err != nil {
return provision . RollbackReport { } , err
}
packRow , err := store . Packs ( ) . GetByPackID ( ctx , loadedPack . Manifest . PackID )
if err != nil {
return provision . RollbackReport { } , err
}
providerRow , err := store . Providers ( ) . GetByPackIDAndProviderID ( ctx , packRow . ID , providerManifest . ProviderID )
if err != nil {
return provision . RollbackReport { } , err
}
batch , err := store . ImportBatches ( ) . GetLatestByProviderIDAndHostID ( ctx , providerRow . ID , hostRow . ID )
if err != nil {
return provision . RollbackReport { } , fmt . Errorf ( "find latest batch for provider %q on host %q: %w" , providerManifest . ProviderID , hostRow . HostID , err )
}
managedResources , err := store . ManagedResources ( ) . GetByBatchID ( ctx , batch . ID )
if err != nil {
return provision . RollbackReport { } , err
}
if len ( managedResources ) == 0 {
return provision . RollbackReport { } , fmt . Errorf ( "rollback requires stored managed resources for provider %q on host %q" , providerManifest . ProviderID , hostRow . HostID )
}
service := provision . NewRollbackService ( client )
report , rollbackErr := service . RollbackStoredResources ( ctx , managedResources )
if rollbackErr != nil {
_ = store . ImportBatches ( ) . UpdateStatus ( ctx , batch . ID , provision . BatchStatusFailed , batch . AccessStatus )
return report , rollbackErr
}
if err := store . ImportBatches ( ) . UpdateStatus ( ctx , batch . ID , provision . BatchStatusRolledBack , batch . AccessStatus ) ; err != nil {
return report , fmt . Errorf ( "rollback resources succeeded but update batch status: %w" , err )
}
return report , nil
} ,
RollbackBatch : func ( ctx context . Context , req RollbackBatchRequest ) ( provision . RollbackReport , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return provision . RollbackReport { } , err
}
defer store . Close ( )
batch , err := store . ImportBatches ( ) . GetByID ( ctx , req . BatchID )
if err != nil {
return provision . RollbackReport { } , err
}
hostRow , err := store . Hosts ( ) . GetByID ( ctx , batch . HostID )
if err != nil {
return provision . RollbackReport { } , err
}
client , err := newSub2APIClient ( hostRow . BaseURL , authFromStoredHost ( hostRow ) )
if err != nil {
return provision . RollbackReport { } , err
}
managedResources , err := store . ManagedResources ( ) . GetByBatchID ( ctx , batch . ID )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . RollbackReport { } , err
}
service := provision . NewRollbackService ( client )
2026-05-18 22:22:22 +08:00
report , rollbackErr := service . RollbackStoredResources ( ctx , managedResources )
if rollbackErr != nil {
_ = store . ImportBatches ( ) . UpdateStatus ( ctx , batch . ID , provision . BatchStatusFailed , batch . AccessStatus )
return report , rollbackErr
}
if err := store . ImportBatches ( ) . UpdateStatus ( ctx , batch . ID , provision . BatchStatusRolledBack , batch . AccessStatus ) ; err != nil {
return report , fmt . Errorf ( "rollback resources succeeded but update batch status: %w" , err )
}
return report , nil
2026-05-15 19:26:25 +08:00
} ,
ReconcileProvider : func ( ctx context . Context , req ReconcileProviderRequest ) ( provision . ReconcileResult , error ) {
loadedPack , err := pack . LoadPath ( req . PackPath )
if err != nil {
return provision . ReconcileResult { } , err
}
providerManifest , err := findProvider ( loadedPack , req . ProviderID )
if err != nil {
return provision . ReconcileResult { } , err
}
2026-05-18 22:22:22 +08:00
store , err := sqlite . Open ( ctx , sqliteDSN )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . ReconcileResult { } , err
}
2026-05-18 22:22:22 +08:00
defer store . Close ( )
hostRow , client , err := resolveManagedHost ( ctx , store , req . HostID , req . HostBaseURL , createHostAuthFromLegacyFields ( req . HostAPIKey , req . HostBearerToken ) )
2026-05-15 19:26:25 +08:00
if err != nil {
return provision . ReconcileResult { } , err
}
service := provision . NewReconcileService ( store , client )
2026-05-18 22:22:22 +08:00
return service . Reconcile ( ctx , provision . ReconcileRequest { HostID : hostRow . HostID , HostBaseURL : hostRow . BaseURL , AccessProbeAPIKey : req . AccessAPIKey , Pack : loadedPack , Provider : providerManifest } )
} ,
CreateHost : func ( ctx context . Context , req CreateHostRequest ) ( HostInfo , error ) {
if strings . TrimSpace ( req . BaseURL ) == "" {
return HostInfo { } , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "base_url is required" }
}
client , err := newSub2APIClient ( req . BaseURL , req . Auth )
if err != nil {
return HostInfo { } , err
}
hostVersion , capabilities , err := probeHostSnapshot ( ctx , client )
if err != nil {
return HostInfo { } , err
}
capabilityJSON , err := json . Marshal ( capabilities )
if err != nil {
return HostInfo { } , fmt . Errorf ( "marshal capabilities: %w" , err )
}
name := strings . TrimSpace ( req . Name )
if name == "" {
name = req . BaseURL
}
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return HostInfo { } , err
}
defer store . Close ( )
if existing , err := store . Hosts ( ) . GetByBaseURL ( ctx , req . BaseURL ) ; err == nil && existing . HostID != name {
return HostInfo { } , & httpError { StatusCode : http . StatusConflict , Code : "host_conflict" , Message : fmt . Sprintf ( "base_url %s already registered as host_id %s" , req . BaseURL , existing . HostID ) }
}
hostRecord := sqlite . Host { HostID : name , BaseURL : req . BaseURL , HostVersion : hostVersion , CapabilityProbeJSON : string ( capabilityJSON ) , AuthType : req . Auth . Type , AuthToken : req . Auth . Token }
if _ , err := store . Hosts ( ) . GetByHostID ( ctx , name ) ; err == nil {
if err := store . Hosts ( ) . UpdateConnectionByHostID ( ctx , name , req . BaseURL , hostVersion , string ( capabilityJSON ) , req . Auth . Type , req . Auth . Token ) ; err != nil {
return HostInfo { } , fmt . Errorf ( "update host: %w" , err )
}
} else {
if _ , err := store . Hosts ( ) . Create ( ctx , hostRecord ) ; err != nil {
return HostInfo { } , fmt . Errorf ( "save host: %w" , err )
}
}
stored , err := store . Hosts ( ) . GetByHostID ( ctx , name )
if err != nil {
return HostInfo { } , err
}
return hostRecordToInfo ( stored ) , nil
} ,
ProbeHost : func ( ctx context . Context , req ProbeHostRequest ) ( HostInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return HostInfo { } , err
}
defer store . Close ( )
hostRow , err := store . Hosts ( ) . GetByHostID ( ctx , req . HostID )
if err != nil {
return HostInfo { } , err
}
client , err := newSub2APIClient ( hostRow . BaseURL , authFromStoredHost ( hostRow ) )
if err != nil {
return HostInfo { } , err
}
hostVersion , capabilities , err := probeHostSnapshot ( ctx , client )
if err != nil {
return HostInfo { } , err
}
capabilityJSON , err := json . Marshal ( capabilities )
if err != nil {
return HostInfo { } , fmt . Errorf ( "marshal capabilities: %w" , err )
}
if err := store . Hosts ( ) . UpdateProbeByHostID ( ctx , req . HostID , hostVersion , string ( capabilityJSON ) ) ; err != nil {
return HostInfo { } , err
}
return HostInfo { HostID : hostRow . HostID , BaseURL : hostRow . BaseURL , HostVersion : hostVersion , Status : hostSupportStatus ( capabilities ) , Capabilities : & capabilities } , nil
} ,
ListHosts : func ( ctx context . Context ) ( [ ] HostInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return nil , err
}
defer store . Close ( )
hosts , err := store . Hosts ( ) . ListAll ( ctx )
if err != nil {
return nil , err
}
result := make ( [ ] HostInfo , 0 , len ( hosts ) )
for _ , host := range hosts {
result = append ( result , hostRecordToInfo ( host ) )
}
return result , nil
} ,
GetHost : func ( ctx context . Context , hostID string ) ( HostInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return HostInfo { } , err
}
defer store . Close ( )
host , err := store . Hosts ( ) . GetByHostID ( ctx , hostID )
if err != nil {
return HostInfo { } , err
}
return hostRecordToInfo ( host ) , nil
} ,
DeleteHost : func ( ctx context . Context , hostID string ) error {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return err
}
defer store . Close ( )
return store . Hosts ( ) . DeleteByHostID ( ctx , hostID )
} ,
ListPacks : func ( ctx context . Context ) ( [ ] PackInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return nil , err
}
defer store . Close ( )
packs , err := store . Packs ( ) . ListAll ( ctx )
if err != nil {
return nil , err
}
result := make ( [ ] PackInfo , 0 , len ( packs ) )
for _ , p := range packs {
result = append ( result , packRecordToInfo ( p ) )
}
return result , nil
} ,
GetPack : func ( ctx context . Context , packID string ) ( PackInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return PackInfo { } , err
}
defer store . Close ( )
pack , err := store . Packs ( ) . GetByPackID ( ctx , packID )
if err != nil {
return PackInfo { } , err
}
return packRecordToInfo ( pack ) , nil
} ,
ListPackProviders : func ( ctx context . Context , packID string ) ( [ ] PackProviderInfo , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return nil , err
}
defer store . Close ( )
pack , err := store . Packs ( ) . GetByPackID ( ctx , packID )
if err != nil {
return nil , err
}
providers , err := store . Providers ( ) . ListByPackID ( ctx , pack . ID )
if err != nil {
return nil , err
}
result := make ( [ ] PackProviderInfo , 0 , len ( providers ) )
for _ , p := range providers {
result = append ( result , PackProviderInfo {
ProviderID : p . ProviderID ,
DisplayName : p . DisplayName ,
Platform : p . Platform ,
} )
}
return result , nil
} ,
AssignAccessSubscriptions : func ( ctx context . Context , req AssignAccessSubscriptionsRequest ) ( AssignAccessSubscriptionsResult , error ) {
loadedPack , err := pack . LoadPath ( req . PackPath )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
providerManifest , err := findProvider ( loadedPack , req . ProviderID )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
defer store . Close ( )
hostRow , client , err := resolveManagedHost ( ctx , store , req . HostID , req . HostBaseURL , createHostAuthFromLegacyFields ( req . HostAPIKey , req . HostBearerToken ) )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
packRow , err := store . Packs ( ) . GetByPackID ( ctx , loadedPack . Manifest . PackID )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
providerRow , err := store . Providers ( ) . GetByPackIDAndProviderID ( ctx , packRow . ID , providerManifest . ProviderID )
if err != nil {
return AssignAccessSubscriptionsResult { } , fmt . Errorf ( "provider %q not found in pack %q" , req . ProviderID , loadedPack . Manifest . PackID )
}
batch , err := store . ImportBatches ( ) . GetLatestByProviderIDAndHostID ( ctx , providerRow . ID , hostRow . ID )
if err != nil {
return AssignAccessSubscriptionsResult { } , fmt . Errorf ( "find batch for provider on host: %w" , err )
}
resources , err := store . ManagedResources ( ) . GetByBatchID ( ctx , batch . ID )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
groupID := ""
for _ , r := range resources {
if r . ResourceType == "group" {
groupID = r . HostResourceID
break
}
}
if groupID == "" {
return AssignAccessSubscriptionsResult { } , fmt . Errorf ( "no group found for provider batch" )
}
subscriptions := make ( [ ] access . SubscriptionTarget , 0 , len ( req . SubscriptionUsers ) )
for _ , userID := range req . SubscriptionUsers {
subscriptions = append ( subscriptions , access . SubscriptionTarget { UserID : userID , DurationDays : req . SubscriptionDays } )
}
accessSvc := access . NewService ( client )
gwResult , err := accessSvc . Close ( ctx , access . ClosureRequest { Mode : access . ModeSubscription , ProbeAPIKey : req . AccessAPIKey , Subscriptions : subscriptions , GroupID : groupID , ExpectedModel : providerManifest . SmokeTestModel } )
if err != nil {
return AssignAccessSubscriptionsResult { } , err
}
accessStatus := deriveAccessStatus ( gwResult )
accessPayload , _ := json . Marshal ( map [ string ] any { "status_code" : gwResult . StatusCode , "ok" : gwResult . OK , "has_expected_model" : gwResult . HasExpectedModel , "models" : gwResult . Models } )
if _ , err := store . AccessClosures ( ) . Create ( ctx , sqlite . AccessClosureRecord { BatchID : batch . ID , ClosureType : access . ModeSubscription , Status : accessStatus , DetailsJSON : string ( accessPayload ) } ) ; err != nil {
return AssignAccessSubscriptionsResult { } , fmt . Errorf ( "record access closure: %w" , err )
}
if err := store . ImportBatches ( ) . UpdateStatus ( ctx , batch . ID , batch . BatchStatus , accessStatus ) ; err != nil {
return AssignAccessSubscriptionsResult { } , fmt . Errorf ( "update batch access status: %w" , err )
}
return AssignAccessSubscriptionsResult { ProviderID : req . ProviderID , Assigned : len ( req . SubscriptionUsers ) , AccessStatus : accessStatus } , nil
} ,
AccessPreview : func ( ctx context . Context , req AccessPreviewRequest ) ( AccessPreviewResult , error ) {
store , err := sqlite . Open ( ctx , sqliteDSN )
if err != nil {
return AccessPreviewResult { } , err
}
defer store . Close ( )
providers , err := resolveProvidersForQuery ( ctx , store , ProviderQueryRequest { ProviderID : req . ProviderID , PackID : req . PackID , HostID : req . HostID } )
if err != nil {
return AccessPreviewResult { } , err
}
if len ( providers ) == 0 {
return AccessPreviewResult { } , fmt . Errorf ( "provider %q not found" , req . ProviderID )
}
if len ( providers ) > 1 {
return AccessPreviewResult { } , fmt . Errorf ( "provider %q exists in multiple packs; pack_id is required" , req . ProviderID )
}
providerRow := providers [ 0 ]
2026-05-20 22:09:40 +08:00
latestStatus , err := resolveLatestAccessStatus ( ctx , store , providerRow , req . HostID )
2026-05-18 22:22:22 +08:00
if err != nil {
return AccessPreviewResult { } , fmt . Errorf ( "find batch for provider: %w" , err )
}
available := accessStatusSupportsMode ( latestStatus , req . Mode )
message := fmt . Sprintf ( "latest access status: %s" , latestStatus )
if ! available {
message = fmt . Sprintf ( "access status %s does not satisfy mode %s" , latestStatus , req . Mode )
}
return AccessPreviewResult { ProviderID : req . ProviderID , Mode : req . Mode , Available : available , Message : message } , nil
2026-05-15 19:26:25 +08:00
} ,
}
}
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 )
}
2026-05-18 22:22:22 +08:00
func resolveProvidersForQuery ( ctx context . Context , store * sqlite . DB , req ProviderQueryRequest ) ( [ ] sqlite . Provider , error ) {
if store == nil {
return nil , fmt . Errorf ( "store is required" )
}
providerID := strings . TrimSpace ( req . ProviderID )
if providerID == "" {
return nil , fmt . Errorf ( "provider_id is required" )
}
if packID := strings . TrimSpace ( req . PackID ) ; packID != "" {
packRow , err := store . Packs ( ) . GetByPackID ( ctx , packID )
if err != nil {
return nil , err
}
providerRow , err := store . Providers ( ) . GetByPackIDAndProviderID ( ctx , packRow . ID , providerID )
if err != nil {
return nil , err
}
return [ ] sqlite . Provider { providerRow } , nil
}
return store . Providers ( ) . ListByProviderID ( ctx , providerID )
}
2026-05-20 22:09:40 +08:00
func resolveLatestAccessStatus ( ctx context . Context , store * sqlite . DB , providerRow sqlite . Provider , hostID string ) ( string , error ) {
if store == nil {
return "" , fmt . Errorf ( "store is required" )
}
if strings . TrimSpace ( hostID ) != "" {
hostRow , err := store . Hosts ( ) . GetByHostID ( ctx , hostID )
if err != nil {
return "" , err
}
batches , err := store . ImportBatches ( ) . ListByProviderIDAndHostID ( ctx , providerRow . ID , hostRow . ID )
if err != nil {
return "" , err
}
modeStatuses , err := provision . LatestModeAccessStatuses ( ctx , store , batches )
if err != nil {
return "" , err
}
return provision . AggregateAccessStatus ( modeStatuses ) , nil
}
batches , err := store . ImportBatches ( ) . ListByProviderID ( ctx , providerRow . ID )
if err != nil {
return "" , err
}
if len ( batches ) == 0 {
return "" , fmt . Errorf ( "latest import batch not found for provider" )
}
hostIDValue := batches [ 0 ] . HostID
for _ , batch := range batches [ 1 : ] {
if batch . HostID != hostIDValue {
return "" , fmt . Errorf ( "provider exists on multiple hosts; host_id is required" )
}
}
modeStatuses , err := provision . LatestModeAccessStatuses ( ctx , store , batches )
if err != nil {
return "" , err
}
return provision . AggregateAccessStatus ( modeStatuses ) , nil
}
2026-05-18 22:22:22 +08:00
func resolveManagedHost ( ctx context . Context , store * sqlite . DB , hostID , baseURL string , auth CreateHostAuth ) ( sqlite . Host , * sub2api . Client , error ) {
if store == nil {
return sqlite . Host { } , nil , fmt . Errorf ( "store is required" )
}
hostID = strings . TrimSpace ( hostID )
baseURL = strings . TrimSpace ( baseURL )
if hostID != "" {
hostRow , err := store . Hosts ( ) . GetByHostID ( ctx , hostID )
if err != nil {
return sqlite . Host { } , nil , err
}
if baseURL != "" && baseURL != strings . TrimSpace ( hostRow . BaseURL ) {
return sqlite . Host { } , nil , fmt . Errorf ( "host %q base_url mismatch: registered=%s runtime=%s" , hostID , hostRow . BaseURL , baseURL )
}
client , err := newSub2APIClient ( hostRow . BaseURL , authFromStoredHost ( hostRow ) )
if err != nil {
return sqlite . Host { } , nil , err
}
return hostRow , client , nil
}
if baseURL == "" {
return sqlite . Host { } , nil , fmt . Errorf ( "host_id is required" )
}
hostRow , err := store . Hosts ( ) . GetByBaseURL ( ctx , baseURL )
if err != nil {
return sqlite . Host { } , nil , fmt . Errorf ( "host_id is required for unregistered host_base_url %q: %w" , baseURL , err )
}
client , err := newSub2APIClient ( hostRow . BaseURL , authFromStoredHost ( hostRow ) )
if err != nil {
return sqlite . Host { } , nil , err
}
return hostRow , client , nil
}
func authFromStoredHost ( host sqlite . Host ) CreateHostAuth {
authType := strings . TrimSpace ( host . AuthType )
if authType == "" {
authType = "apikey"
}
return CreateHostAuth { Type : authType , Token : strings . TrimSpace ( host . AuthToken ) }
}
func createHostAuthFromLegacyFields ( apiKey , bearerToken string ) CreateHostAuth {
if token := strings . TrimSpace ( bearerToken ) ; token != "" {
return CreateHostAuth { Type : "bearer" , Token : token }
}
return CreateHostAuth { Type : "apikey" , Token : strings . TrimSpace ( apiKey ) }
}
func newSub2APIClient ( baseURL string , auth CreateHostAuth ) ( * sub2api . Client , error ) {
authToken := strings . TrimSpace ( auth . Token )
if authToken == "" {
return nil , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : "auth.token is required" }
}
switch strings . ToLower ( strings . TrimSpace ( auth . Type ) ) {
case "bearer" :
return sub2api . NewClient ( baseURL , sub2api . WithBearerToken ( authToken ) )
case "apikey" , "api_key" , "" :
return sub2api . NewClient ( baseURL , sub2api . WithAPIKey ( authToken ) )
default :
return nil , & httpError { StatusCode : http . StatusBadRequest , Code : "bad_request" , Message : fmt . Sprintf ( "unsupported auth type %q (supported: bearer, apikey)" , auth . Type ) }
}
}
func probeHostSnapshot ( ctx context . Context , client * sub2api . Client ) ( string , sub2api . HostCapabilities , error ) {
hostVersion , err := client . GetHostVersion ( ctx )
if err != nil {
return "" , sub2api . HostCapabilities { } , fmt . Errorf ( "get host version: %w" , err )
}
capabilities , err := client . ProbeCapabilities ( ctx )
if err != nil {
return "" , sub2api . HostCapabilities { } , fmt . Errorf ( "probe host capabilities: %w" , err )
}
return hostVersion , capabilities , nil
}
func hostSupportStatus ( capabilities sub2api . HostCapabilities ) string {
if ! capabilities . Groups || ! capabilities . Channels || ! capabilities . Plans || ! capabilities . Accounts || ! capabilities . AccountTest || ! capabilities . AccountModels || ! capabilities . Subscriptions {
return "unsupported"
}
return "supported"
}
func accessStatusSupportsMode ( status , mode string ) bool {
status = strings . TrimSpace ( status )
mode = strings . TrimSpace ( mode )
switch mode {
case "" , "any" :
return status != provision . AccessStatusBroken && status != ""
case provision . AccessModeSubscription :
return status == provision . AccessStatusSubscriptionReady || status == provision . AccessStatusFullyReady
case provision . AccessModeSelfService :
return status == provision . AccessStatusSelfServiceReady || status == provision . AccessStatusFullyReady
default :
return status == provision . AccessStatusFullyReady
}
}
func hostRecordToInfo ( host sqlite . Host ) HostInfo {
info := HostInfo {
HostID : host . HostID ,
BaseURL : host . BaseURL ,
HostVersion : host . HostVersion ,
AuthType : strings . TrimSpace ( host . AuthType ) ,
}
if strings . TrimSpace ( host . CapabilityProbeJSON ) != "" && host . CapabilityProbeJSON != "{}" {
var caps sub2api . HostCapabilities
if err := json . Unmarshal ( [ ] byte ( host . CapabilityProbeJSON ) , & caps ) ; err == nil {
info . Capabilities = & caps
info . Status = hostSupportStatus ( caps )
}
}
return info
}
func packRecordToInfo ( pack sqlite . Pack ) PackInfo {
return PackInfo {
PackID : pack . PackID ,
Version : pack . Version ,
Vendor : pack . Vendor ,
TargetHost : pack . TargetHost ,
MinHostVersion : pack . MinHostVersion ,
MaxHostVersion : pack . MaxHostVersion ,
}
}
func deriveAccessStatus ( gw sub2api . GatewayAccessResult ) string {
if gw . OK && gw . HasExpectedModel {
return provision . AccessStatusSubscriptionReady
}
return provision . AccessStatusBroken
}