test(project): achieve ≥70% package coverage across all internal packages
- store/sqlite: 75.4% (repos + db coverage) - host/sub2api: 80.8% (httptest mock server, pure function tests) - app: 74.2% (handler error paths, NewActionSet closures) - pack: 72.4% - provision: 75.2% - access: 77.3% - config: 94.7% (lookup mock tests) All tests pass: build, vet, race, coverage gates.
This commit is contained in:
638
internal/app/http_api.go
Normal file
638
internal/app/http_api.go
Normal file
@@ -0,0 +1,638 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type ActionSet struct {
|
||||
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)
|
||||
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
|
||||
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
|
||||
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
|
||||
ReconcileProvider func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type RollbackProviderRequest struct {
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
HostAPIKey string `json:"host_api_key"`
|
||||
HostBearerToken string `json:"host_bearer_token"`
|
||||
PackPath string `json:"pack_path"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
type ReconcileProviderRequest struct {
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
HostAPIKey string `json:"host_api_key"`
|
||||
HostBearerToken string `json:"host_bearer_token"`
|
||||
PackPath string `json:"pack_path"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
AccessAPIKey string `json:"access_api_key"`
|
||||
}
|
||||
|
||||
type PreviewProviderRequest struct {
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
HostAPIKey string `json:"host_api_key"`
|
||||
HostBearerToken string `json:"host_bearer_token"`
|
||||
PackPath string `json:"pack_path"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
Keys []string `json:"keys"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type ImportProviderRequest struct {
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
HostAPIKey string `json:"host_api_key"`
|
||||
HostBearerToken string `json:"host_bearer_token"`
|
||||
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)
|
||||
})))
|
||||
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("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleInstallPack(w, r, actions.InstallPack)
|
||||
})))
|
||||
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)
|
||||
})))
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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"))})
|
||||
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"))})
|
||||
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"))})
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
case strings.Contains(message, "not found in pack"):
|
||||
return &httpError{StatusCode: http.StatusBadRequest, Code: "provider_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{
|
||||
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})
|
||||
},
|
||||
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})
|
||||
},
|
||||
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})
|
||||
},
|
||||
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
|
||||
}
|
||||
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
|
||||
if err != nil {
|
||||
return provision.PreviewReport{}, err
|
||||
}
|
||||
service := provision.NewPreviewService(client)
|
||||
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
|
||||
}
|
||||
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
|
||||
if err != nil {
|
||||
return provision.RuntimeImportResult{}, err
|
||||
}
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return provision.RuntimeImportResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
subscriptions := make([]provision.SubscriptionTarget, 0, len(req.SubscriptionUsers))
|
||||
for _, userID := range req.SubscriptionUsers {
|
||||
subscriptions = append(subscriptions, provision.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays})
|
||||
}
|
||||
service := provision.NewRuntimeImportService(store, client)
|
||||
return service.Import(ctx, provision.RuntimeImportRequest{
|
||||
HostBaseURL: req.HostBaseURL,
|
||||
Pack: loadedPack,
|
||||
Provider: providerManifest,
|
||||
Mode: req.Mode,
|
||||
Keys: req.Keys,
|
||||
Access: provision.AccessRequest{
|
||||
Mode: req.AccessMode,
|
||||
ProbeAPIKey: req.AccessAPIKey,
|
||||
Subscriptions: subscriptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
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
|
||||
}
|
||||
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
|
||||
if err != nil {
|
||||
return provision.RollbackReport{}, err
|
||||
}
|
||||
service := provision.NewRollbackService(client)
|
||||
return service.Rollback(ctx, provision.RollbackRequest{Provider: providerManifest})
|
||||
},
|
||||
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
|
||||
}
|
||||
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
|
||||
if err != nil {
|
||||
return provision.ReconcileResult{}, err
|
||||
}
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return provision.ReconcileResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
service := provision.NewReconcileService(store, client)
|
||||
return service.Reconcile(ctx, provision.ReconcileRequest{HostBaseURL: req.HostBaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findProvider(loaded pack.LoadedPack, providerID string) (pack.ProviderManifest, error) {
|
||||
for _, provider := range loaded.Providers {
|
||||
if provider.ProviderID == strings.TrimSpace(providerID) {
|
||||
return provider, nil
|
||||
}
|
||||
}
|
||||
return pack.ProviderManifest{}, fmt.Errorf("provider %q not found in pack %q", providerID, loaded.Manifest.PackID)
|
||||
}
|
||||
Reference in New Issue
Block a user