2217 lines
86 KiB
Go
2217 lines
86 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"sub2api-cn-relay-manager/internal/batch"
|
|
"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"
|
|
|
|
"sub2api-cn-relay-manager/internal/access"
|
|
)
|
|
|
|
type ActionSet struct {
|
|
CreateBatchImportRun func(context.Context, CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error)
|
|
ListBatchImportRuns func(context.Context, ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error)
|
|
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
|
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
|
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
|
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
|
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
|
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
|
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
|
DeleteProviderDraft func(context.Context, string) error
|
|
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)
|
|
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) (reconcile.Result, 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)
|
|
}
|
|
|
|
const maxJSONBodyBytes int64 = 1 << 20
|
|
|
|
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"`
|
|
HostOverlays int `json:"host_overlays,omitempty"`
|
|
}
|
|
|
|
type CreateProviderDraftRequest struct {
|
|
DraftID string `json:"draft_id,omitempty"`
|
|
PackID string `json:"pack_id"`
|
|
ProviderID string `json:"provider_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Platform string `json:"platform"`
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
|
SupportedModels []string `json:"supported_models,omitempty"`
|
|
Manifest json.RawMessage `json:"manifest,omitempty"`
|
|
SourceHostID string `json:"source_host_id,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type ListProviderDraftsRequest struct {
|
|
PackID string
|
|
ProviderID string
|
|
Query string
|
|
}
|
|
|
|
type UpdateProviderDraftRequest struct {
|
|
DraftID string `json:"-"`
|
|
CreateProviderDraftRequest
|
|
}
|
|
|
|
type PublishProviderDraftRequest struct {
|
|
DraftID string `json:"-"`
|
|
CommitMessage string `json:"commit_message,omitempty"`
|
|
}
|
|
|
|
type ProviderDraftInfo struct {
|
|
DraftID string `json:"draft_id"`
|
|
PackID string `json:"pack_id"`
|
|
ProviderID string `json:"provider_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Platform string `json:"platform"`
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
|
SupportedModels []string `json:"supported_models,omitempty"`
|
|
Manifest any `json:"manifest,omitempty"`
|
|
SourceHostID string `json:"source_host_id,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
type PublishProviderDraftResult struct {
|
|
DraftID string `json:"draft_id"`
|
|
PackID string `json:"pack_id"`
|
|
ProviderID string `json:"provider_id"`
|
|
ProviderPath string `json:"provider_path"`
|
|
PackVersionBefore string `json:"pack_version_before"`
|
|
PackVersionAfter string `json:"pack_version_after"`
|
|
PublishMode string `json:"publish_mode"`
|
|
CommitMessage string `json:"commit_message"`
|
|
CommitSHA string `json:"commit_sha"`
|
|
RepoRoot string `json:"repo_root"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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
|
|
HostID string
|
|
}
|
|
|
|
type RollbackProviderRequest struct {
|
|
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"`
|
|
PackPath string `json:"pack_path"`
|
|
ProviderID string `json:"provider_id"`
|
|
}
|
|
|
|
type RollbackBatchRequest struct {
|
|
BatchID int64 `json:"-"`
|
|
Auth CreateHostAuth `json:"auth"`
|
|
}
|
|
|
|
type ReconcileProviderRequest struct {
|
|
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"`
|
|
PackPath string `json:"pack_path"`
|
|
ProviderID string `json:"provider_id"`
|
|
AccessAPIKey string `json:"access_api_key"`
|
|
}
|
|
|
|
type PreviewProviderRequest struct {
|
|
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"`
|
|
PackPath string `json:"pack_path"`
|
|
ProviderID string `json:"provider_id"`
|
|
Keys []string `json:"keys"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
type ImportProviderRequest struct {
|
|
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"`
|
|
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("POST /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
|
|
})))
|
|
mux.Handle("GET /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleListBatchImportRuns(w, r, actions.ListBatchImportRuns)
|
|
})))
|
|
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleGetBatchImportRun(w, r, actions.GetBatchImportRun)
|
|
})))
|
|
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleListBatchImportRunItems(w, r, actions.ListBatchImportRunItems)
|
|
})))
|
|
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
|
|
})))
|
|
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
|
|
})))
|
|
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
|
|
})))
|
|
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleGetProviderDraft(w, r, actions.GetProviderDraft)
|
|
})))
|
|
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
|
|
})))
|
|
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
|
|
})))
|
|
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handlePublishProviderDraft(w, r, actions.PublishProviderDraft)
|
|
})))
|
|
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleBatchDetail(w, r, actions.BatchDetail)
|
|
})))
|
|
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleRollbackBatch(w, r, actions.RollbackBatch)
|
|
})))
|
|
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)
|
|
})))
|
|
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)
|
|
})))
|
|
mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handleInstallPack(w, r, actions.InstallPack)
|
|
})))
|
|
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)
|
|
})))
|
|
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)
|
|
})))
|
|
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)
|
|
})))
|
|
return mux
|
|
}
|
|
|
|
func healthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
func handleCreateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
|
if fn == nil {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "create-provider-draft action is not configured"})
|
|
return
|
|
}
|
|
var req CreateProviderDraftRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeHTTPError(w, err)
|
|
return
|
|
}
|
|
draft, err := fn(r.Context(), req)
|
|
if err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"draft": draft})
|
|
}
|
|
|
|
func handleListProviderDrafts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)) {
|
|
if fn == nil {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-drafts action is not configured"})
|
|
return
|
|
}
|
|
drafts, err := fn(r.Context(), ListProviderDraftsRequest{
|
|
PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")),
|
|
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
|
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
|
})
|
|
if err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
if drafts == nil {
|
|
drafts = []ProviderDraftInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"provider_drafts": drafts})
|
|
}
|
|
|
|
func handleGetProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (ProviderDraftInfo, error)) {
|
|
if fn == nil {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-draft action is not configured"})
|
|
return
|
|
}
|
|
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
|
if draftID == "" {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
|
return
|
|
}
|
|
draft, err := fn(r.Context(), draftID)
|
|
if err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
|
}
|
|
|
|
func handleUpdateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
|
if fn == nil {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-draft action is not configured"})
|
|
return
|
|
}
|
|
var req UpdateProviderDraftRequest
|
|
if err := decodeJSON(r, &req.CreateProviderDraftRequest); err != nil {
|
|
writeHTTPError(w, err)
|
|
return
|
|
}
|
|
req.DraftID = strings.TrimSpace(r.PathValue("draftID"))
|
|
if req.DraftID == "" {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
|
return
|
|
}
|
|
draft, err := fn(r.Context(), req)
|
|
if err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
|
}
|
|
|
|
func handleDeleteProviderDraft(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-provider-draft action is not configured"})
|
|
return
|
|
}
|
|
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
|
if draftID == "" {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
|
return
|
|
}
|
|
if err := fn(r.Context(), draftID); err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handlePublishProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)) {
|
|
if fn == nil {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "publish-provider-draft action is not configured"})
|
|
return
|
|
}
|
|
var req PublishProviderDraftRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeHTTPError(w, err)
|
|
return
|
|
}
|
|
req.DraftID = strings.TrimSpace(r.PathValue("draftID"))
|
|
if req.DraftID == "" {
|
|
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
|
return
|
|
}
|
|
result, err := fn(r.Context(), req)
|
|
if err != nil {
|
|
writeHTTPError(w, classifyError(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"publish": result})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
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
|
|
}
|
|
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"))})
|
|
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
|
|
}
|
|
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"))})
|
|
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
|
|
}
|
|
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"))})
|
|
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),
|
|
"host_overlays": result.HostOverlays,
|
|
"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),
|
|
"host_overlays": result.Report.HostOverlays,
|
|
"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),
|
|
"host_overlays": result.Report.HostOverlays,
|
|
"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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, ReconcileProviderRequest) (reconcile.Result, 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,
|
|
"stale_noise_count": result.StaleNoiseCount,
|
|
"summary": result.Summary,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func decodeJSON(r *http.Request, dest any) *httpError {
|
|
if r == nil {
|
|
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "request is required"}
|
|
}
|
|
r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes)
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(dest); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
return &httpError{StatusCode: http.StatusRequestEntityTooLarge, Code: "request_too_large", Message: fmt.Sprintf("request body exceeds %d bytes", maxJSONBodyBytes)}
|
|
}
|
|
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}
|
|
}
|
|
var hostDeleteBlocker *sqlite.HostDeleteBlocker
|
|
if errors.As(err, &hostDeleteBlocker) {
|
|
return &httpError{StatusCode: http.StatusConflict, Code: "host_in_use", Message: err.Error()}
|
|
}
|
|
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}
|
|
case strings.Contains(message, "repo root"):
|
|
return &httpError{StatusCode: http.StatusServiceUnavailable, Code: "publish_unavailable", Message: message}
|
|
case strings.Contains(message, "run import again before reconcile"):
|
|
return &httpError{StatusCode: http.StatusConflict, Code: "batch_not_reconcilable", Message: message}
|
|
case strings.Contains(message, "not found in pack"):
|
|
return &httpError{StatusCode: http.StatusBadRequest, Code: "provider_not_found", Message: message}
|
|
case strings.Contains(message, "not found"):
|
|
return &httpError{StatusCode: http.StatusNotFound, Code: "not_found", Message: message}
|
|
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{
|
|
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
|
|
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
|
|
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
|
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
|
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
|
CreateProviderDraft: func(ctx context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
defer store.Close()
|
|
|
|
draftID := strings.TrimSpace(req.DraftID)
|
|
if draftID == "" {
|
|
draftID = fmt.Sprintf("draft_%d", time.Now().UnixNano())
|
|
}
|
|
manifestJSON, manifestValue, supportedModels, err := normalizeProviderDraftPayload(req)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
|
|
draftRow := sqlite.ProviderDraft{
|
|
DraftID: draftID,
|
|
PackID: strings.TrimSpace(req.PackID),
|
|
ProviderID: strings.TrimSpace(req.ProviderID),
|
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
|
Platform: strings.TrimSpace(req.Platform),
|
|
BaseURL: strings.TrimSpace(req.BaseURL),
|
|
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
|
SupportedModelsJSON: encodeStringList(supportedModels),
|
|
ManifestJSON: manifestJSON,
|
|
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
}
|
|
if _, err := store.ProviderDrafts().Create(ctx, draftRow); err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
persisted, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
return providerDraftRecordToInfo(persisted, manifestValue, supportedModels)
|
|
},
|
|
ListProviderDrafts: func(ctx context.Context, req ListProviderDraftsRequest) ([]ProviderDraftInfo, error) {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer store.Close()
|
|
|
|
rows, err := store.ProviderDrafts().List(ctx, sqlite.ListProviderDraftsFilter{
|
|
PackID: req.PackID,
|
|
ProviderID: req.ProviderID,
|
|
Query: req.Query,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]ProviderDraftInfo, 0, len(rows))
|
|
for _, row := range rows {
|
|
info, err := providerDraftRecordToInfoFromStored(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, info)
|
|
}
|
|
return result, nil
|
|
},
|
|
GetProviderDraft: func(ctx context.Context, draftID string) (ProviderDraftInfo, error) {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
defer store.Close()
|
|
|
|
row, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
return providerDraftRecordToInfoFromStored(row)
|
|
},
|
|
UpdateProviderDraft: func(ctx context.Context, req UpdateProviderDraftRequest) (ProviderDraftInfo, error) {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
defer store.Close()
|
|
|
|
manifestJSON, _, supportedModels, err := normalizeProviderDraftPayload(req.CreateProviderDraftRequest)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
|
|
if err := store.ProviderDrafts().UpdateByDraftID(ctx, sqlite.ProviderDraft{
|
|
DraftID: strings.TrimSpace(req.DraftID),
|
|
PackID: strings.TrimSpace(req.PackID),
|
|
ProviderID: strings.TrimSpace(req.ProviderID),
|
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
|
Platform: strings.TrimSpace(req.Platform),
|
|
BaseURL: strings.TrimSpace(req.BaseURL),
|
|
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
|
SupportedModelsJSON: encodeStringList(supportedModels),
|
|
ManifestJSON: manifestJSON,
|
|
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
}); err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
row, err := store.ProviderDrafts().GetByDraftID(ctx, req.DraftID)
|
|
if err != nil {
|
|
return ProviderDraftInfo{}, err
|
|
}
|
|
return providerDraftRecordToInfoFromStored(row)
|
|
},
|
|
DeleteProviderDraft: func(ctx context.Context, draftID string) error {
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
return store.ProviderDrafts().DeleteByDraftID(ctx, draftID)
|
|
},
|
|
PublishProviderDraft: func(ctx context.Context, req PublishProviderDraftRequest) (PublishProviderDraftResult, error) {
|
|
startupCfg, err := config.LoadStartupFromEnv()
|
|
if err != nil {
|
|
return PublishProviderDraftResult{}, err
|
|
}
|
|
if strings.TrimSpace(startupCfg.Repository.RepoRoot) == "" {
|
|
return PublishProviderDraftResult{}, fmt.Errorf("pack repo root is not configured")
|
|
}
|
|
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return PublishProviderDraftResult{}, err
|
|
}
|
|
defer store.Close()
|
|
|
|
row, err := store.ProviderDrafts().GetByDraftID(ctx, req.DraftID)
|
|
if err != nil {
|
|
return PublishProviderDraftResult{}, err
|
|
}
|
|
manifest, err := buildPublishedProviderManifest(row)
|
|
if err != nil {
|
|
return PublishProviderDraftResult{}, err
|
|
}
|
|
publishResult, err := pack.PublishProviderManifest(ctx, pack.PublishProviderManifestRequest{
|
|
RepoRoot: startupCfg.Repository.RepoRoot,
|
|
PackID: row.PackID,
|
|
Manifest: manifest,
|
|
CommitMessage: strings.TrimSpace(req.CommitMessage),
|
|
})
|
|
if err != nil {
|
|
return PublishProviderDraftResult{}, err
|
|
}
|
|
return PublishProviderDraftResult{
|
|
DraftID: row.DraftID,
|
|
PackID: publishResult.PackID,
|
|
ProviderID: publishResult.ProviderID,
|
|
ProviderPath: publishResult.ProviderPath,
|
|
PackVersionBefore: publishResult.PackVersionBefore,
|
|
PackVersionAfter: publishResult.PackVersionAfter,
|
|
PublishMode: publishResult.PublishMode,
|
|
CommitMessage: publishResult.CommitMessage,
|
|
CommitSHA: publishResult.CommitSHA,
|
|
RepoRoot: publishResult.RepoRoot,
|
|
}, nil
|
|
},
|
|
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()
|
|
return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID})
|
|
},
|
|
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()
|
|
return provision.NewProviderStatusService(store).GetResources(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID, HostID: req.HostID})
|
|
},
|
|
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()
|
|
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
|
|
},
|
|
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
|
|
}
|
|
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))
|
|
if err != nil {
|
|
return provision.PreviewReport{}, err
|
|
}
|
|
hostVersion, err := client.GetHostVersion(ctx)
|
|
if err != nil {
|
|
return provision.PreviewReport{}, err
|
|
}
|
|
service := provision.NewPreviewService(client)
|
|
return service.PreviewImport(ctx, provision.PreviewRequest{
|
|
TargetHost: loadedPack.Manifest.TargetHost,
|
|
HostVersion: hostVersion,
|
|
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
|
|
}
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return provision.RuntimeImportResult{}, err
|
|
}
|
|
defer store.Close()
|
|
hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken))
|
|
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{
|
|
HostID: hostRow.HostID,
|
|
HostBaseURL: hostRow.BaseURL,
|
|
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
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return provision.RollbackReport{}, err
|
|
}
|
|
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
|
|
},
|
|
ReconcileProvider: func(ctx context.Context, req ReconcileProviderRequest) (reconcile.Result, error) {
|
|
loadedPack, err := pack.LoadPath(req.PackPath)
|
|
if err != nil {
|
|
return reconcile.Result{}, err
|
|
}
|
|
providerManifest, err := findProvider(loadedPack, req.ProviderID)
|
|
if err != nil {
|
|
return reconcile.Result{}, err
|
|
}
|
|
store, err := sqlite.Open(ctx, sqliteDSN)
|
|
if err != nil {
|
|
return reconcile.Result{}, err
|
|
}
|
|
defer store.Close()
|
|
hostRow, client, err := resolveManagedHost(ctx, store, req.HostID, req.HostBaseURL, createHostAuthFromLegacyFields(req.HostAPIKey, req.HostBearerToken))
|
|
if err != nil {
|
|
return reconcile.Result{}, err
|
|
}
|
|
service := reconcile.NewService(store, client)
|
|
return service.Reconcile(ctx, reconcile.Request{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()
|
|
if err := store.Hosts().DeleteByHostID(ctx, hostID); err != nil {
|
|
return classifyError(err)
|
|
}
|
|
return nil
|
|
},
|
|
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()
|
|
packRow, err := store.Packs().GetByPackID(ctx, packID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providers, err := store.Providers().ListByPackID(ctx, packRow.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]PackProviderInfo, 0, len(providers))
|
|
for _, p := range providers {
|
|
hostOverlays := 0
|
|
if strings.TrimSpace(p.ManifestJSON) != "" {
|
|
var providerManifest pack.ProviderManifest
|
|
if err := json.Unmarshal([]byte(p.ManifestJSON), &providerManifest); err != nil {
|
|
return nil, fmt.Errorf("decode stored provider manifest: %w", err)
|
|
}
|
|
hostOverlays = len(providerManifest.HostOverlays)
|
|
}
|
|
result = append(result, PackProviderInfo{
|
|
ProviderID: p.ProviderID,
|
|
DisplayName: p.DisplayName,
|
|
Platform: p.Platform,
|
|
HostOverlays: hostOverlays,
|
|
})
|
|
}
|
|
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, Prompt: "ping", MaxTokens: 8})
|
|
if err != nil {
|
|
return AssignAccessSubscriptionsResult{}, err
|
|
}
|
|
|
|
accessStatus := deriveAccessStatus(gwResult)
|
|
accessPayload, _ := json.Marshal(provision.BuildAccessClosureDetails(provision.AccessRequest{
|
|
Mode: provision.AccessModeSubscription,
|
|
ProbeAPIKey: req.AccessAPIKey,
|
|
Subscriptions: subscriptionTargets(req.SubscriptionUsers, req.SubscriptionDays),
|
|
}, gwResult))
|
|
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]
|
|
latestStatus, err := resolveLatestAccessStatus(ctx, store, providerRow, req.HostID)
|
|
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
|
|
},
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 normalizeProviderDraftPayload(req CreateProviderDraftRequest) (string, any, []string, error) {
|
|
supportedModels := normalizeStringList(req.SupportedModels)
|
|
if len(req.Manifest) > 0 {
|
|
var manifestValue any
|
|
if err := json.Unmarshal(req.Manifest, &manifestValue); err != nil {
|
|
return "", nil, nil, fmt.Errorf("decode manifest: %w", err)
|
|
}
|
|
manifestJSON := strings.TrimSpace(string(req.Manifest))
|
|
if manifestJSON == "" {
|
|
manifestJSON = "{}"
|
|
}
|
|
return manifestJSON, manifestValue, supportedModels, nil
|
|
}
|
|
|
|
manifestValue := map[string]any{
|
|
"provider_id": strings.TrimSpace(req.ProviderID),
|
|
"display_name": strings.TrimSpace(req.DisplayName),
|
|
"platform": strings.TrimSpace(req.Platform),
|
|
"base_url": strings.TrimSpace(req.BaseURL),
|
|
"smoke_test_model": strings.TrimSpace(req.SmokeTestModel),
|
|
"supported_models": supportedModels,
|
|
}
|
|
manifestJSONBytes, err := json.Marshal(manifestValue)
|
|
if err != nil {
|
|
return "", nil, nil, fmt.Errorf("marshal manifest: %w", err)
|
|
}
|
|
return string(manifestJSONBytes), manifestValue, supportedModels, nil
|
|
}
|
|
|
|
func providerDraftRecordToInfo(row sqlite.ProviderDraft, manifestValue any, supportedModels []string) (ProviderDraftInfo, error) {
|
|
if manifestValue == nil {
|
|
manifestValue = map[string]any{}
|
|
}
|
|
return ProviderDraftInfo{
|
|
DraftID: row.DraftID,
|
|
PackID: row.PackID,
|
|
ProviderID: row.ProviderID,
|
|
DisplayName: row.DisplayName,
|
|
Platform: row.Platform,
|
|
BaseURL: row.BaseURL,
|
|
SmokeTestModel: row.SmokeTestModel,
|
|
SupportedModels: append([]string(nil), supportedModels...),
|
|
Manifest: manifestValue,
|
|
SourceHostID: row.SourceHostID,
|
|
Notes: row.Notes,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func providerDraftRecordToInfoFromStored(row sqlite.ProviderDraft) (ProviderDraftInfo, error) {
|
|
var manifestValue any
|
|
if strings.TrimSpace(row.ManifestJSON) != "" {
|
|
if err := json.Unmarshal([]byte(row.ManifestJSON), &manifestValue); err != nil {
|
|
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft manifest: %w", err)
|
|
}
|
|
}
|
|
supportedModels := []string{}
|
|
if strings.TrimSpace(row.SupportedModelsJSON) != "" {
|
|
if err := json.Unmarshal([]byte(row.SupportedModelsJSON), &supportedModels); err != nil {
|
|
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft supported_models: %w", err)
|
|
}
|
|
}
|
|
return providerDraftRecordToInfo(row, manifestValue, supportedModels)
|
|
}
|
|
|
|
func buildPublishedProviderManifest(row sqlite.ProviderDraft) (pack.ProviderManifest, error) {
|
|
manifest := pack.ProviderManifest{
|
|
ProviderID: strings.TrimSpace(row.ProviderID),
|
|
DisplayName: strings.TrimSpace(row.DisplayName),
|
|
BaseURL: strings.TrimSpace(row.BaseURL),
|
|
Platform: strings.TrimSpace(row.Platform),
|
|
AccountType: "apikey",
|
|
SmokeTestModel: strings.TrimSpace(row.SmokeTestModel),
|
|
DefaultModels: normalizeStringList(decodeStringList(row.SupportedModelsJSON)),
|
|
GroupTemplate: pack.GroupTemplate{
|
|
Name: strings.TrimSpace(row.DisplayName) + " 默认分组",
|
|
RateMultiplier: 1.0,
|
|
},
|
|
ChannelTemplate: pack.ChannelTemplate{
|
|
Name: strings.TrimSpace(row.DisplayName) + " 默认渠道",
|
|
ModelMapping: map[string]string{},
|
|
},
|
|
PlanTemplate: pack.PlanTemplate{
|
|
Name: strings.TrimSpace(row.DisplayName) + " 默认套餐",
|
|
Price: 19.9,
|
|
ValidityDays: 30,
|
|
ValidityUnit: "day",
|
|
},
|
|
Import: pack.ImportOptions{
|
|
SupportsMultiKey: true,
|
|
SupportsStrict: true,
|
|
SupportsPartial: true,
|
|
},
|
|
}
|
|
if strings.TrimSpace(manifest.SmokeTestModel) == "" && len(manifest.DefaultModels) > 0 {
|
|
manifest.SmokeTestModel = manifest.DefaultModels[0]
|
|
}
|
|
if len(manifest.DefaultModels) == 0 && strings.TrimSpace(manifest.SmokeTestModel) != "" {
|
|
manifest.DefaultModels = []string{manifest.SmokeTestModel}
|
|
}
|
|
for _, model := range manifest.DefaultModels {
|
|
manifest.ChannelTemplate.ModelMapping[model] = model
|
|
}
|
|
|
|
if strings.TrimSpace(row.ManifestJSON) != "" {
|
|
if err := json.Unmarshal([]byte(row.ManifestJSON), &manifest); err != nil {
|
|
return pack.ProviderManifest{}, fmt.Errorf("decode publishable provider manifest: %w", err)
|
|
}
|
|
}
|
|
|
|
manifest.ProviderID = strings.TrimSpace(manifest.ProviderID)
|
|
manifest.DisplayName = strings.TrimSpace(manifest.DisplayName)
|
|
manifest.BaseURL = strings.TrimSpace(manifest.BaseURL)
|
|
manifest.Platform = strings.TrimSpace(manifest.Platform)
|
|
manifest.AccountType = strings.TrimSpace(manifest.AccountType)
|
|
manifest.SmokeTestModel = strings.TrimSpace(manifest.SmokeTestModel)
|
|
if manifest.AccountType == "" {
|
|
manifest.AccountType = "apikey"
|
|
}
|
|
manifest.DefaultModels = normalizeStringList(manifest.DefaultModels)
|
|
if len(manifest.DefaultModels) == 0 && manifest.SmokeTestModel != "" {
|
|
manifest.DefaultModels = []string{manifest.SmokeTestModel}
|
|
}
|
|
if manifest.SmokeTestModel == "" && len(manifest.DefaultModels) > 0 {
|
|
manifest.SmokeTestModel = manifest.DefaultModels[0]
|
|
}
|
|
if manifest.ChannelTemplate.ModelMapping == nil {
|
|
manifest.ChannelTemplate.ModelMapping = map[string]string{}
|
|
}
|
|
for _, model := range manifest.DefaultModels {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
continue
|
|
}
|
|
if _, ok := manifest.ChannelTemplate.ModelMapping[model]; !ok {
|
|
manifest.ChannelTemplate.ModelMapping[model] = model
|
|
}
|
|
}
|
|
if manifest.SmokeTestModel != "" {
|
|
if _, ok := manifest.ChannelTemplate.ModelMapping[manifest.SmokeTestModel]; !ok {
|
|
manifest.ChannelTemplate.ModelMapping[manifest.SmokeTestModel] = manifest.SmokeTestModel
|
|
}
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func encodeStringList(values []string) string {
|
|
encoded, err := json.Marshal(normalizeStringList(values))
|
|
if err != nil {
|
|
return "[]"
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func normalizeStringList(values []string) []string {
|
|
normalized := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
normalized = append(normalized, value)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func decodeStringList(raw string) []string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return []string{}
|
|
}
|
|
values := []string{}
|
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
|
return []string{}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
|
|
if provision.GatewayAccessReady(gw) {
|
|
return provision.AccessStatusSubscriptionReady
|
|
}
|
|
return provision.AccessStatusBroken
|
|
}
|
|
|
|
func subscriptionTargets(userIDs []string, durationDays int) []provision.SubscriptionTarget {
|
|
targets := make([]provision.SubscriptionTarget, 0, len(userIDs))
|
|
for _, userID := range userIDs {
|
|
targets = append(targets, provision.SubscriptionTarget{
|
|
UserID: strings.TrimSpace(userID),
|
|
DurationDays: durationDays,
|
|
})
|
|
}
|
|
return targets
|
|
}
|