diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index ecca8203b7..0890ac5bbc 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -1078,11 +1078,13 @@ type DeployRequest struct { // - `current` — render the artifact from the definition as it stands now. // - `build` — deploy a build prepared earlier, named by `buildId`. // - // REST API deployments accept only these two and always run a build: `current` - // stores what it renders as one, so a running deployment is always traceable to - // a stored snapshot. MCP proxy, LLM and event API deployments accept a - // `deploymentId` here as well, to promote that deployment by reusing its - // rendered artifact. + // These are the only two values, for REST APIs, MCP proxies, LLM providers and + // LLM proxies alike. Every deployment runs a build: `current` stores what it + // renders as one, so a running deployment is always traceable to a stored + // snapshot, and promoting carries that snapshot rather than re-rendering it. + // + // A `deploymentId` is no longer accepted here — see the note on this + // operation. Base string `binding:"required" json:"base" yaml:"base"` // BuildId The build to deploy, such as `2026-01-31-2`. Required when `base` is `build`, @@ -2968,6 +2970,12 @@ type ListLLMProviderAPIKeysParams struct { Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// GetLLMProviderBuildsParams defines parameters for GetLLMProviderBuilds. +type GetLLMProviderBuildsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` +} + // GetLLMProviderDeploymentsParams defines parameters for GetLLMProviderDeployments. type GetLLMProviderDeploymentsParams struct { // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. @@ -3028,6 +3036,12 @@ type ListLLMProxyAPIKeysParams struct { Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// GetLLMProxyBuildsParams defines parameters for GetLLMProxyBuilds. +type GetLLMProxyBuildsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` +} + // GetLLMProxyDeploymentsParams defines parameters for GetLLMProxyDeployments. type GetLLMProxyDeploymentsParams struct { // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. @@ -3070,6 +3084,12 @@ type ListMCPProxiesParams struct { Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// GetMCPProxyBuildsParams defines parameters for GetMCPProxyBuilds. +type GetMCPProxyBuildsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` +} + // GetMCPProxyDeploymentsParams defines parameters for GetMCPProxyDeployments. type GetMCPProxyDeploymentsParams struct { // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. @@ -3320,6 +3340,9 @@ type UpdateLLMProviderJSONRequestBody = LLMProvider // CreateLLMProviderAPIKeyJSONRequestBody defines body for CreateLLMProviderAPIKey for application/json ContentType. type CreateLLMProviderAPIKeyJSONRequestBody = CreateLLMProviderAPIKeyRequest +// CreateLLMProviderBuildJSONRequestBody defines body for CreateLLMProviderBuild for application/json ContentType. +type CreateLLMProviderBuildJSONRequestBody = BuildRequest + // DeployLLMProviderJSONRequestBody defines body for DeployLLMProvider for application/json ContentType. type DeployLLMProviderJSONRequestBody = DeployRequest @@ -3332,6 +3355,9 @@ type UpdateLLMProxyJSONRequestBody = LLMProxy // CreateLLMProxyAPIKeyJSONRequestBody defines body for CreateLLMProxyAPIKey for application/json ContentType. type CreateLLMProxyAPIKeyJSONRequestBody = CreateLLMProxyAPIKeyRequest +// CreateLLMProxyBuildJSONRequestBody defines body for CreateLLMProxyBuild for application/json ContentType. +type CreateLLMProxyBuildJSONRequestBody = BuildRequest + // DeployLLMProxyJSONRequestBody defines body for DeployLLMProxy for application/json ContentType. type DeployLLMProxyJSONRequestBody = DeployRequest @@ -3344,6 +3370,9 @@ type FetchMCPProxyServerInfoJSONRequestBody = MCPServerInfoFetchRequest // UpdateMCPProxyJSONRequestBody defines body for UpdateMCPProxy for application/json ContentType. type UpdateMCPProxyJSONRequestBody = MCPProxy +// CreateMCPProxyBuildJSONRequestBody defines body for CreateMCPProxyBuild for application/json ContentType. +type CreateMCPProxyBuildJSONRequestBody = BuildRequest + // DeployMCPProxyJSONRequestBody defines body for DeployMCPProxy for application/json ContentType. type DeployMCPProxyJSONRequestBody = DeployRequest diff --git a/platform-api/internal/handler/api_deployment.go b/platform-api/internal/handler/api_deployment.go index bd04548bb8..7722455469 100644 --- a/platform-api/internal/handler/api_deployment.go +++ b/platform-api/internal/handler/api_deployment.go @@ -19,9 +19,7 @@ package handler import ( "encoding/json" - "errors" "fmt" - "io" "log/slog" "net/http" "strings" @@ -277,133 +275,6 @@ func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Reques return nil } -// CreateBuild handles POST /api/v0.9/rest-apis/:apiId/builds -// Renders the API's current definition into an immutable snapshot, without deploying it -func (h *DeploymentHandler) CreateBuild(w http.ResponseWriter, r *http.Request) error { - orgId, exists := middleware.GetOrganizationFromRequest(r) - if !exists { - return apperror.Unauthorized.New(). - WithLogMessage("organization claim not found in token") - } - - apiId := r.PathValue("restApiId") - if apiId == "" { - return apperror.ValidationFailed.New("API ID is required") - } - - createdBy, err := resolveActorErr(r, h.identity, "prepare API build") - if err != nil { - return err - } - - // The body is optional: preparing a build needs nothing beyond the API, and - // metadata is there for callers that have an origin to record. - var req api.BuildRequest - if r.Body != nil && r.ContentLength != 0 { - // A chunked request carries no length, so an empty one only shows up here - // as EOF; that is still an absent body rather than a malformed one. - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - return apperror.ValidationFailed.New("Request body is not valid JSON") - } - } - var metadata map[string]interface{} - if req.Metadata != nil { - metadata = *req.Metadata - } - var description string - if req.Description != nil { - description = strings.TrimSpace(*req.Description) - } - - build, err := h.deploymentService.CreateBuildByHandle(apiId, orgId, createdBy, description, metadata) - if err != nil { - return serviceError(err, fmt.Sprintf("failed to prepare a build for API %s", apiId)) - } - - setLocation(w, "rest-apis", apiId, "builds", build.BuildId) - httputil.WriteJSON(w, http.StatusCreated, build) - return nil -} - -// GetBuilds handles GET /api/v0.9/rest-apis/:apiId/builds -// Lists the API's builds, newest first -func (h *DeploymentHandler) GetBuilds(w http.ResponseWriter, r *http.Request) error { - orgId, exists := middleware.GetOrganizationFromRequest(r) - if !exists { - return apperror.Unauthorized.New(). - WithLogMessage("organization claim not found in token") - } - - apiId := r.PathValue("restApiId") - if apiId == "" { - return apperror.ValidationFailed.New("API ID is required") - } - - limit, _ := parsePagination(r) - builds, err := h.deploymentService.GetBuildsByHandle(apiId, orgId, limit) - if err != nil { - return serviceError(err, fmt.Sprintf("failed to get builds for API %s", apiId)) - } - - httputil.WriteJSON(w, http.StatusOK, builds) - return nil -} - -// GetBuild handles GET /api/v0.9/rest-apis/:apiId/builds/:buildId -// Retrieves metadata for a single build -func (h *DeploymentHandler) GetBuild(w http.ResponseWriter, r *http.Request) error { - orgId, exists := middleware.GetOrganizationFromRequest(r) - if !exists { - return apperror.Unauthorized.New(). - WithLogMessage("organization claim not found in token") - } - - apiId := r.PathValue("restApiId") - buildId := r.PathValue("buildId") - - if apiId == "" { - return apperror.ValidationFailed.New("API ID is required") - } - if buildId == "" { - return apperror.ValidationFailed.New("Build ID is required") - } - - build, err := h.deploymentService.GetBuildByHandle(apiId, buildId, orgId) - if err != nil { - return serviceError(err, fmt.Sprintf("failed to get API %s build %s", apiId, buildId)) - } - - httputil.WriteJSON(w, http.StatusOK, build) - return nil -} - -// DeleteBuild handles DELETE /api/v0.9/rest-apis/:apiId/builds/:buildId -// Removes a build, unless a deployment still holds it -func (h *DeploymentHandler) DeleteBuild(w http.ResponseWriter, r *http.Request) error { - orgId, exists := middleware.GetOrganizationFromRequest(r) - if !exists { - return apperror.Unauthorized.New(). - WithLogMessage("organization claim not found in token") - } - - apiId := r.PathValue("restApiId") - buildId := r.PathValue("buildId") - - if apiId == "" { - return apperror.ValidationFailed.New("API ID is required") - } - if buildId == "" { - return apperror.ValidationFailed.New("Build ID is required") - } - - if err := h.deploymentService.DeleteBuildByHandle(apiId, buildId, orgId); err != nil { - return serviceError(err, fmt.Sprintf("failed to delete API %s build %s", apiId, buildId)) - } - - w.WriteHeader(http.StatusNoContent) - return nil -} - // RegisterRoutes registers all deployment-related routes func (h *DeploymentHandler) RegisterRoutes(mux router.Router) { h.slogger.Debug("Registering deployment routes") @@ -414,8 +285,14 @@ func (h *DeploymentHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetDeployments)) mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetDeployment)) mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteDeployment)) - mux.HandleFunc("POST "+base+"/builds", middleware.MapErrors(h.slogger, h.CreateBuild)) - mux.HandleFunc("GET "+base+"/builds", middleware.MapErrors(h.slogger, h.GetBuilds)) - mux.HandleFunc("GET "+base+"/builds/{buildId}", middleware.MapErrors(h.slogger, h.GetBuild)) - mux.HandleFunc("DELETE "+base+"/builds/{buildId}", middleware.MapErrors(h.slogger, h.DeleteBuild)) + // Builds are the same endpoints for every artifact kind, so REST APIs register + // the shared set rather than keeping their own copy of it. + BuildRoutes{ + Service: h.deploymentService, + Segment: "rest-apis", + PathParam: "restApiId", + Subject: "API", + Identity: h.identity, + Slogger: h.slogger, + }.Register(mux) } diff --git a/platform-api/internal/handler/build.go b/platform-api/internal/handler/build.go new file mode 100644 index 0000000000..0c0746eb4c --- /dev/null +++ b/platform-api/internal/handler/build.go @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// BuildEndpoints is the slice of a kind's deployment service that serves builds. +// Every kind's service satisfies it by delegating to the shared build store, so one +// set of handlers serves them all. +type BuildEndpoints interface { + CreateBuildByHandle(handle, orgID, actor, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) + GetBuildByHandle(handle, buildID, orgID string) (*api.BuildResponse, error) + GetBuildsByHandle(handle, orgID string, limit int) (*api.BuildListResponse, error) + DeleteBuildByHandle(handle, buildID, orgID string) error +} + +// BuildRoutes serves one artifact kind's /builds endpoints. +// +// The routes differ between kinds only in the path they hang off and the words used +// in messages, so they are registered from this one implementation rather than +// copied per kind. Each kind keeps its own URL — /rest-apis/…/builds, +// /mcp-proxies/…/builds — because that is the contract callers already know; only +// the code behind them is shared. +type BuildRoutes struct { + // Service serves the builds, already scoped to one artifact kind. + Service BuildEndpoints + // Segment is the kind's path segment, e.g. "mcp-proxies". + Segment string + // PathParam is the identifier's name in the route, e.g. "mcpProxyId". + PathParam string + // Subject names the kind in error messages, e.g. "MCP proxy". + Subject string + Identity *service.IdentityService + Slogger *slog.Logger +} + +// Register adds the kind's four build routes to the mux. +func (h BuildRoutes) Register(mux router.Router) { + base := constants.APIBasePath + "/" + h.Segment + "/{" + h.PathParam + "}" + mux.HandleFunc("POST "+base+"/builds", middleware.MapErrors(h.Slogger, h.create)) + mux.HandleFunc("GET "+base+"/builds", middleware.MapErrors(h.Slogger, h.list)) + mux.HandleFunc("GET "+base+"/builds/{buildId}", middleware.MapErrors(h.Slogger, h.get)) + mux.HandleFunc("DELETE "+base+"/builds/{buildId}", middleware.MapErrors(h.Slogger, h.delete)) +} + +// request pulls the organization and the artifact handle out of a request, which +// every one of these routes needs before it can do anything. +func (h BuildRoutes) request(r *http.Request) (orgID, handle string, err error) { + orgID, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return "", "", apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + handle = r.PathValue(h.PathParam) + if handle == "" { + return "", "", apperror.ValidationFailed.New(h.Subject + " ID is required") + } + return orgID, handle, nil +} + +// create handles POST //{id}/builds — renders the artifact's current +// definition into an immutable snapshot, without deploying it. +func (h BuildRoutes) create(w http.ResponseWriter, r *http.Request) error { + orgID, handle, err := h.request(r) + if err != nil { + return err + } + createdBy, err := resolveActorErr(r, h.Identity, "prepare "+h.Subject+" build") + if err != nil { + return err + } + + // The body is optional: preparing a build needs nothing beyond the artifact, + // and description and metadata are there for callers that have something to + // record. + var req api.BuildRequest + if r.Body != nil && r.ContentLength != 0 { + // A chunked request carries no length, so an empty one only shows up here + // as EOF; that is still an absent body rather than a malformed one. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + return apperror.ValidationFailed.New("Request body is not valid JSON") + } + } + var metadata map[string]interface{} + if req.Metadata != nil { + metadata = *req.Metadata + } + var description string + if req.Description != nil { + description = strings.TrimSpace(*req.Description) + } + + build, err := h.Service.CreateBuildByHandle(handle, orgID, createdBy, description, metadata) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to prepare a build for %s %s", h.Subject, handle)) + } + + setLocation(w, h.Segment, handle, "builds", build.BuildId) + httputil.WriteJSON(w, http.StatusCreated, build) + return nil +} + +// list handles GET //{id}/builds — the artifact's builds, newest first. +func (h BuildRoutes) list(w http.ResponseWriter, r *http.Request) error { + orgID, handle, err := h.request(r) + if err != nil { + return err + } + limit, _ := parsePagination(r) + builds, err := h.Service.GetBuildsByHandle(handle, orgID, limit) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get builds for %s %s", h.Subject, handle)) + } + httputil.WriteJSON(w, http.StatusOK, builds) + return nil +} + +// get handles GET //{id}/builds/{buildId}. +func (h BuildRoutes) get(w http.ResponseWriter, r *http.Request) error { + orgID, handle, err := h.request(r) + if err != nil { + return err + } + buildID := r.PathValue("buildId") + if buildID == "" { + return apperror.ValidationFailed.New("Build ID is required") + } + build, err := h.Service.GetBuildByHandle(handle, buildID, orgID) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get %s %s build %s", h.Subject, handle, buildID)) + } + httputil.WriteJSON(w, http.StatusOK, build) + return nil +} + +// delete handles DELETE //{id}/builds/{buildId} — how room is made once +// the artifact is at its build limit. +func (h BuildRoutes) delete(w http.ResponseWriter, r *http.Request) error { + orgID, handle, err := h.request(r) + if err != nil { + return err + } + buildID := r.PathValue("buildId") + if buildID == "" { + return apperror.ValidationFailed.New("Build ID is required") + } + if err := h.Service.DeleteBuildByHandle(handle, buildID, orgID); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete %s %s build %s", h.Subject, handle, buildID)) + } + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/platform-api/internal/handler/llm_deployment.go b/platform-api/internal/handler/llm_deployment.go index e22acab0f9..f537f3bf56 100644 --- a/platform-api/internal/handler/llm_deployment.go +++ b/platform-api/internal/handler/llm_deployment.go @@ -249,6 +249,16 @@ func (h *LLMProviderDeploymentHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetLLMProviderDeployments)) mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetLLMProviderDeployment)) mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteLLMProviderDeployment)) + + // The same build endpoints every artifact kind has, on this kind's own path. + BuildRoutes{ + Service: h.deploymentService, + Segment: "llm-providers", + PathParam: "llmProviderId", + Subject: "LLM provider", + Identity: h.identity, + Slogger: h.slogger, + }.Register(mux) } // DeployLLMProxy handles POST /api/v0.9/llm-proxies/{llmProxyId}/deployments @@ -442,4 +452,14 @@ func (h *LLMProxyDeploymentHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetLLMProxyDeployments)) mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetLLMProxyDeployment)) mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteLLMProxyDeployment)) + + // The same build endpoints every artifact kind has, on this kind's own path. + BuildRoutes{ + Service: h.deploymentService, + Segment: "llm-proxies", + PathParam: "llmProxyId", + Subject: "LLM proxy", + Identity: h.identity, + Slogger: h.slogger, + }.Register(mux) } diff --git a/platform-api/internal/handler/mcp_deployment.go b/platform-api/internal/handler/mcp_deployment.go index 8e8e8bbc8e..2640190895 100644 --- a/platform-api/internal/handler/mcp_deployment.go +++ b/platform-api/internal/handler/mcp_deployment.go @@ -60,6 +60,16 @@ func (h *MCPProxyDeploymentHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments", middleware.MapErrors(h.slogger, h.GetMCPProxyDeployments)) mux.HandleFunc("GET "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetMCPProxyDeployment)) mux.HandleFunc("DELETE "+constants.APIBasePath+"/mcp-proxies/{mcpProxyId}/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteMCPProxyDeployment)) + + // The same build endpoints every artifact kind has, on this kind's own path. + BuildRoutes{ + Service: h.deploymentService, + Segment: "mcp-proxies", + PathParam: "mcpProxyId", + Subject: "MCP proxy", + Identity: h.identity, + Slogger: h.slogger, + }.Register(mux) } // DeployMCPProxy handles POST /api/v0.9/mcp-proxies/:id/deployments diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index a118e26760..0792581081 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -255,7 +255,16 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.Security.APIKey.HashingAlgorithms, slogger) - deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, cfg, slogger) + // One definition per artifact kind, indexed by the kind the artifact row + // carries. Builds and deployments are shared across kinds; rendering is the + // one thing that is not, so this is where each kind supplies its own. + artifactDefinitions := service.NewArtifactDefinitions( + service.NewRestAPIDefinition(apiRepo, apiUtil), + service.NewMCPProxyDefinition(mcpProxyRepo, &utils.MCPUtils{}), + service.NewLLMProxyDefinition(llmProxyRepo), + service.NewLLMProviderDefinition(llmProviderRepo, llmTemplateRepo), + ) + deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, artifactDefinitions, cfg, slogger) llmTemplateService := service.NewLLMProviderTemplateService(llmTemplateRepo, auditRepo, identityService) llmProviderService := service.NewLLMProviderService(llmProviderRepo, llmTemplateRepo, orgRepo, llmTemplateSeeder, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) llmProviderService.SetCustomPolicyRepository(customPolicyRepo) @@ -273,6 +282,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, orgRepo, apiKeyRepo, gatewayEventsService, + artifactRepo, + artifactDefinitions, cfg, slogger, ) @@ -286,6 +297,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, orgRepo, apiKeyRepo, gatewayEventsService, + artifactRepo, + artifactDefinitions, cfg, slogger, ) @@ -297,9 +310,18 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, artifactRepo, apiKeyRepo, gatewayEventsService, + artifactDefinitions, cfg, slogger, ) + // One place that knows which service serves which artifact kind, so plugins and + // the per-kind paths reach the same code. + deploymentsByKind := service.NewDeploymentsByKind( + deploymentService, + mcpDeploymentService, + llmProxyDeploymentService, + llmProviderDeploymentService, + ) artifactImportService := service.NewArtifactImportService( apiRepo, llmProviderRepo, @@ -436,9 +458,11 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, // assignment itself is the compile-time contract check: if a service method // signature drifts from the pdk interface, this stops building. pdkDeps := &pdk.Deps{ - Gateways: gatewayService, - Projects: projectService, - Deployments: deploymentService, + Gateways: gatewayService, + Projects: projectService, + // Kind-routed, so a plugin names the artifact kind alongside the handle and + // reaches the same services the platform's own per-kind paths do. + Deployments: deploymentsByKind, Config: cfg, Logger: slogger, } diff --git a/platform-api/internal/service/artifact_definition.go b/platform-api/internal/service/artifact_definition.go new file mode 100644 index 0000000000..44a2834734 --- /dev/null +++ b/platform-api/internal/service/artifact_definition.go @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package service + +import ( + "fmt" + + "gopkg.in/yaml.v3" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/dto" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// ArtifactSnapshot is one artifact's definition as it currently stands, rendered +// but deliberately NOT translated for any gateway. +// +// A build stores this shape: the target gateway is not known when a build is +// prepared, and the same build is deployable to gateways on different data +// versions, so translation belongs to the deployment rather than the snapshot. +// DataVersion records the platform version the definition was written at, which +// is what a later deploy translates FROM. +type ArtifactSnapshot struct { + // Definition is the kind's own deployment struct, ready to be marshalled. + Definition any + // DataVersion is the artifact's platform data version. + DataVersion string + // Origin says where the artifact came from; a DP-originated artifact is + // read-only in the control plane and cannot be built or deployed from it. + Origin string +} + +// ArtifactDefinition renders one artifact kind's current definition into the +// snapshot a build stores, and reconstitutes a stored snapshot so a deploy can +// translate it for its target gateway. +// +// It exists because builds and deployments are shared across every artifact kind +// — they hang off artifact_uuid, not a kind-specific table — while rendering is +// not: each kind has its own definition, its own repository and its own +// deployment YAML. This is the one seam where that difference lives. +type ArtifactDefinition interface { + // Kind is the artifact kind, matching both the artifact registry's alias and + // the key gatewaytranslator dispatches on (they share one key space). + Kind() string + + // Current loads the artifact and renders its definition as it stands. It + // takes the resolved artifact row rather than an identifier because the + // kinds' repositories do not agree on one: REST APIs are fetched by UUID, + // LLM providers and proxies by handle. It returns the kind's own not-found + // error when the artifact has gone, so callers keep per-kind error + // semantics. + Current(artifact *model.Artifact) (*ArtifactSnapshot, error) + + // Decode unmarshals stored build content back into this kind's deployment + // struct, ready for gatewaytranslator.Translate. + Decode(content []byte) (any, error) +} + +// ArtifactDefinitions resolves the ArtifactDefinition for an artifact kind. It is +// assembled at wiring time, where every kind's repository is in scope. +type ArtifactDefinitions map[string]ArtifactDefinition + +// NewArtifactDefinitions indexes the given definitions by their kind. +func NewArtifactDefinitions(definitions ...ArtifactDefinition) ArtifactDefinitions { + indexed := make(ArtifactDefinitions, len(definitions)) + for _, definition := range definitions { + indexed[definition.Kind()] = definition + } + return indexed +} + +// For returns the definition for an artifact kind, or an error naming the kind +// when none is registered — a kind that reaches a build without a definition is +// a wiring mistake, not a user error. +func (d ArtifactDefinitions) For(kind string) (ArtifactDefinition, error) { + definition, ok := d[kind] + if !ok { + return nil, apperror.Internal.New(). + WithLogMessage(fmt.Sprintf("no artifact definition registered for kind %q", kind)) + } + return definition, nil +} + +// restAPIDefinition renders REST APIs. +type restAPIDefinition struct { + apiRepo repository.APIRepository + apiUtil *utils.APIUtil +} + +// NewRestAPIDefinition returns the ArtifactDefinition for REST APIs. +func NewRestAPIDefinition(apiRepo repository.APIRepository, apiUtil *utils.APIUtil) ArtifactDefinition { + return &restAPIDefinition{apiRepo: apiRepo, apiUtil: apiUtil} +} + +func (d *restAPIDefinition) Kind() string { return constants.RestApi } + +func (d *restAPIDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + apiModel, err := d.apiRepo.GetAPIByUUID(artifact.UUID, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.RESTAPINotFound.New() + } + definition, err := d.apiUtil.BuildAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to build API deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: definition, + DataVersion: apiModel.DataVersion, + Origin: apiModel.Origin, + }, nil +} + +func (d *restAPIDefinition) Decode(content []byte) (any, error) { + definition := &dto.APIDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored API deployment YAML: %w", err) + } + return definition, nil +} + +// llmProxyDefinition renders LLM proxies. +type llmProxyDefinition struct { + proxyRepo repository.LLMProxyRepository +} + +// NewLLMProxyDefinition returns the ArtifactDefinition for LLM proxies. +func NewLLMProxyDefinition(proxyRepo repository.LLMProxyRepository) ArtifactDefinition { + return &llmProxyDefinition{proxyRepo: proxyRepo} +} + +func (d *llmProxyDefinition) Kind() string { return constants.LLMProxy } + +func (d *llmProxyDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + proxy, err := d.proxyRepo.GetByID(artifact.Handle, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if proxy == nil { + return nil, apperror.LLMProxyNotFound.New() + } + definition, err := generateLLMProxyDeploymentYAML(proxy) + if err != nil { + return nil, fmt.Errorf("failed to generate LLM proxy deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: &definition, + DataVersion: proxy.DataVersion, + Origin: proxy.Origin, + }, nil +} + +func (d *llmProxyDefinition) Decode(content []byte) (any, error) { + definition := &dto.LLMProxyDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored LLM proxy deployment YAML: %w", err) + } + return definition, nil +} + +// llmProviderDefinition renders LLM providers. A provider's definition names the +// template it was created from, which is resolved here rather than supplied by a +// caller, so a provider build stays a pure function of the provider. +type llmProviderDefinition struct { + providerRepo repository.LLMProviderRepository + templateRepo repository.LLMProviderTemplateRepository +} + +// NewLLMProviderDefinition returns the ArtifactDefinition for LLM providers. +func NewLLMProviderDefinition( + providerRepo repository.LLMProviderRepository, + templateRepo repository.LLMProviderTemplateRepository, +) ArtifactDefinition { + return &llmProviderDefinition{providerRepo: providerRepo, templateRepo: templateRepo} +} + +func (d *llmProviderDefinition) Kind() string { return constants.LLMProvider } + +func (d *llmProviderDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + provider, err := d.providerRepo.GetByID(artifact.Handle, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if provider == nil { + return nil, apperror.LLMProviderNotFound.New() + } + templateHandle, err := d.templateHandle(provider.TemplateUUID, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + definition, err := generateLLMProviderDeploymentYAML(provider, templateHandle) + if err != nil { + return nil, fmt.Errorf("failed to generate LLM provider deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: &definition, + DataVersion: provider.DataVersion, + Origin: provider.Origin, + }, nil +} + +func (d *llmProviderDefinition) Decode(content []byte) (any, error) { + definition := &dto.LLMProviderDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored LLM provider deployment YAML: %w", err) + } + return definition, nil +} + +// templateHandle mirrors LLMProviderDeploymentService.getTemplateHandle: a +// provider whose template has gone is not renderable, and says so as a +// template-not-found rather than a bare nil dereference. +func (d *llmProviderDefinition) templateHandle(templateUUID, orgUUID string) (string, error) { + if templateUUID == "" { + return "", apperror.LLMProviderTemplateNotFound.New() + } + template, err := d.templateRepo.GetByUUID(templateUUID, orgUUID) + if err != nil { + return "", fmt.Errorf("failed to resolve template: %w", err) + } + if template == nil { + return "", apperror.LLMProviderTemplateNotFound.New() + } + return template.ID, nil +} + +// mcpProxyDefinition renders MCP proxies. +type mcpProxyDefinition struct { + proxyRepo repository.MCPProxyRepository + mcpUtils *utils.MCPUtils +} + +// NewMCPProxyDefinition returns the ArtifactDefinition for MCP proxies. +func NewMCPProxyDefinition(proxyRepo repository.MCPProxyRepository, mcpUtils *utils.MCPUtils) ArtifactDefinition { + return &mcpProxyDefinition{proxyRepo: proxyRepo, mcpUtils: mcpUtils} +} + +func (d *mcpProxyDefinition) Kind() string { return constants.MCPProxy } + +func (d *mcpProxyDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + proxy, err := d.proxyRepo.GetByUUID(artifact.UUID, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if proxy == nil { + return nil, apperror.MCPProxyNotFound.New() + } + definition, err := d.mcpUtils.BuildMCPDeploymentYAML(proxy) + if err != nil { + return nil, fmt.Errorf("failed to build MCP proxy deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: definition, + DataVersion: proxy.DataVersion, + Origin: proxy.Origin, + }, nil +} + +func (d *mcpProxyDefinition) Decode(content []byte) (any, error) { + definition := &model.MCPProxyDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored MCP proxy deployment YAML: %w", err) + } + return definition, nil +} diff --git a/platform-api/internal/service/build.go b/platform-api/internal/service/build.go new file mode 100644 index 0000000000..bc81d49295 --- /dev/null +++ b/platform-api/internal/service/build.go @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "errors" + "fmt" + "log/slog" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// BuildService stores and serves builds for EVERY artifact kind. +// +// Builds hang off artifact_uuid rather than any kind's own table, so everything +// about storing, listing, limiting and removing them is already common. The one +// part that is not — turning an artifact into the definition a build holds — is +// reached through ArtifactDefinition, so a new kind becomes a definition rather +// than another copy of this file. +// +// Each kind keeps its own REST path (/rest-apis/…/builds, /mcp-proxies/…/builds +// and so on). Only the implementation is shared. +type BuildService struct { + artifactRepo repository.ArtifactRepository + deploymentRepo repository.DeploymentRepository + definitions ArtifactDefinitions + cfg *config.Server + slogger *slog.Logger +} + +// NewBuildService creates the shared build service. +func NewBuildService( + artifactRepo repository.ArtifactRepository, + deploymentRepo repository.DeploymentRepository, + definitions ArtifactDefinitions, + cfg *config.Server, + slogger *slog.Logger, +) *BuildService { + return &BuildService{ + artifactRepo: artifactRepo, + deploymentRepo: deploymentRepo, + definitions: definitions, + cfg: cfg, + slogger: slogger, + } +} + +// resolve finds the artifact and the definition that can render its kind. +// +// The artifact row carries the kind (as the registry's alias, which is the key +// ArtifactDefinitions is indexed by), so one lookup answers both "does this exist" +// and "how is it rendered". +func (s *BuildService) resolve(artifactUUID, orgUUID, expectedKind string) (*model.Artifact, ArtifactDefinition, error) { + artifact, err := s.artifactRepo.GetByUUID(artifactUUID, orgUUID) + if err != nil { + return nil, nil, err + } + if artifact == nil { + return nil, nil, apperror.ArtifactNotFound.New() + } + // The caller says which kind its endpoint serves, and an artifact of another + // kind simply is not there as far as that endpoint is concerned. Handles are + // unique only WITHIN a kind, and the artifact lookup resolves a handle across + // every kind's table, so without this an /mcp-proxies/{handle} route could + // address a REST API that happens to share the handle. + if artifact.Type != expectedKind { + return nil, nil, apperror.ArtifactNotFound.New() + } + definition, err := s.definitions.For(artifact.Type) + if err != nil { + return nil, nil, err + } + return artifact, definition, nil +} + +// Render turns an artifact's current definition into a build that has not been +// stored yet, and hands back the struct it was rendered from alongside it. +// +// The struct is returned so a deploy can apply its own overrides and translate for +// the target gateway without re-parsing what it has just written — and so those +// overrides never reach the build, whose content is marshalled HERE, before any +// caller sees the struct. A build is the definition as it stood, not one +// deployment's customization of it. +// +// Storing is the caller's to do: preparing a build stores it alone, while a deploy +// from `current` stores it on the transaction that records the deployment, so the +// two commit together. +func (s *BuildService) Render(artifactUUID, orgUUID, kind, createdBy string, + metadata map[string]interface{}) (*model.Build, any, error) { + + artifact, definition, err := s.resolve(artifactUUID, orgUUID, kind) + if err != nil { + return nil, nil, err + } + snapshot, err := definition.Current(artifact) + if err != nil { + return nil, nil, err + } + // DP-originated artifacts are read-only in the control plane, so there is + // nothing here to snapshot and deploy. + if err := ensureOriginMutable(snapshot.Origin); err != nil { + return nil, nil, err + } + contentBytes, err := yaml.Marshal(snapshot.Definition) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal %s deployment YAML: %w", artifact.Type, err) + } + return &model.Build{ + ArtifactID: artifactUUID, + OrganizationID: orgUUID, + Content: contentBytes, + DataVersion: snapshot.DataVersion, + Metadata: metadata, + CreatedBy: createdBy, + }, snapshot.Definition, nil +} + +// Create renders the artifact's current definition into an immutable snapshot and +// stores it, without deploying it anywhere. +func (s *BuildService) Create(artifactUUID, orgUUID, kind, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + + build, _, err := s.Render(artifactUUID, orgUUID, kind, createdBy, metadata) + if err != nil { + return nil, err + } + build.Description = description + if err := s.deploymentRepo.CreateBuildWithLimitEnforcement(build, s.cfg.Deployments.MaxBuildsPerAPI); err != nil { + return nil, s.LimitError(err) + } + s.slogger.Debug("Build created", "buildID", build.BuildID, "artifactUUID", artifactUUID) + return toAPIBuildResponse(build), nil +} + +// Get returns one of an artifact's builds. +func (s *BuildService) Get(artifactUUID, buildID, orgUUID, kind string) (*api.BuildResponse, error) { + if _, _, err := s.resolve(artifactUUID, orgUUID, kind); err != nil { + return nil, err + } + build, err := s.deploymentRepo.GetBuild(buildID, artifactUUID, orgUUID) + if err != nil { + return nil, err + } + if build == nil { + return nil, apperror.BuildNotFound.New() + } + return toAPIBuildResponse(build), nil +} + +// List returns an artifact's builds, newest first. +func (s *BuildService) List(artifactUUID, orgUUID, kind string, limit int) (*api.BuildListResponse, error) { + if _, _, err := s.resolve(artifactUUID, orgUUID, kind); err != nil { + return nil, err + } + builds, err := s.deploymentRepo.GetBuilds(artifactUUID, orgUUID, limit) + if err != nil { + return nil, err + } + list := make([]api.BuildResponse, 0, len(builds)) + for _, build := range builds { + list = append(list, *toAPIBuildResponse(build)) + } + return &api.BuildListResponse{Count: len(list), List: list}, nil +} + +// Delete removes one of an artifact's builds. +// +// A build a gateway is serving is not deleted: taking the snapshot out from under +// it would leave the deployment with nothing to trace back to or promote onward, +// and the definition as it stood cannot be rendered again. Which deployment to give +// up is the caller's decision, so the conflict is reported rather than resolved. +func (s *BuildService) Delete(artifactUUID, buildID, orgUUID, kind string) error { + if _, _, err := s.resolve(artifactUUID, orgUUID, kind); err != nil { + return err + } + if err := s.deploymentRepo.DeleteBuild(buildID, artifactUUID, orgUUID); err != nil { + switch { + case errors.Is(err, repository.ErrBuildNotFound): + return apperror.BuildNotFound.New() + case errors.Is(err, repository.ErrBuildInUse): + return apperror.BuildInUse.New() + } + return err + } + s.slogger.Debug("Build deleted", "buildID", buildID, "artifactUUID", artifactUUID) + return nil +} + +// LimitError turns the repository's "nothing free to remove" signal into the +// conflict a caller can act on, naming the limit they are up against. Any other +// error is passed through untouched. Deploy paths that store a build of their own +// use it too, so being at the limit reads the same however it is reached. +func (s *BuildService) LimitError(err error) error { + if errors.Is(err, repository.ErrBuildLimitReached) { + return apperror.BuildLimitReached.New(s.cfg.Deployments.MaxBuildsPerAPI) + } + return err +} + +// DeploySource is what a deploy is about to put on a gateway: the definition to +// translate and override, the data version to translate FROM, and how the +// deployment records the build it runs. +// +// NewBuild is set only when the deploy rendered the artifact itself (base +// "current"). It is deliberately NOT stored here — the caller stores it on the +// transaction that records the deployment, so a deployment always has the build it +// runs and a failed deploy leaves no build behind. +type DeploySource struct { + Definition any + DataVersion string + NewBuild *model.Build + BuildUUID *string + BuildID *string +} + +// ValidateDeployBase checks the two fields that say WHAT a deploy ships. +// +// `base` is `current` (snapshot the artifact as it stands) or `build` (ship one +// prepared earlier, named by buildId). buildId is required with one and meaningless +// with the other; rejecting it where it cannot apply keeps a request from looking +// like it asked for something it did not get. +// +// The kind supplies its own validation error so the message names the right thing. +func ValidateDeployBase(base string, buildID *string, invalid apperror.Def) (string, string, error) { + base = strings.TrimSpace(base) + if base == "" { + return "", "", invalid.New("Base is required (use 'current' or 'build').") + } + if base != deployBaseCurrent && base != deployBaseBuild { + return "", "", invalid.New("Base must be 'current' or 'build'.") + } + requested := strings.TrimSpace(utils.ValueOrEmpty(buildID)) + if base == deployBaseBuild && requested == "" { + return "", "", invalid.New("A buildId is required when base is 'build'.") + } + if base == deployBaseCurrent && requested != "" { + return "", "", invalid.New("A buildId applies only when base is 'build'.") + } + return base, requested, nil +} + +// SourceForDeploy resolves what a deploy ships, for any artifact kind. +// +// `build` loads the named snapshot and decodes it through the kind's own +// definition; `current` renders the artifact now and hands back an unstored build +// for the caller to commit alongside the deployment. Either way the caller gets one +// shape back, so the deploy paths stop differing on this. +func (s *BuildService) SourceForDeploy(artifactUUID, orgUUID, kind, createdBy, base, requestedBuild string) (*DeploySource, error) { + if base == deployBaseBuild { + stored, err := s.deploymentRepo.GetBuild(requestedBuild, artifactUUID, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get build: %w", err) + } + if stored == nil { + return nil, apperror.BuildNotFound.New() + } + _, definition, err := s.resolve(artifactUUID, orgUUID, kind) + if err != nil { + return nil, err + } + decoded, err := definition.Decode(stored.Content) + if err != nil { + return nil, err + } + return &DeploySource{ + Definition: decoded, + DataVersion: stored.DataVersion, + // Record which build this deployment runs, so it can be traced back to + // the snapshot it came from. + BuildUUID: &stored.UUID, + BuildID: &stored.BuildID, + }, nil + } + + newBuild, definition, err := s.Render(artifactUUID, orgUUID, kind, createdBy, nil) + if err != nil { + return nil, err + } + return &DeploySource{ + Definition: definition, + DataVersion: newBuild.DataVersion, + NewBuild: newBuild, + }, nil +} diff --git a/platform-api/internal/service/build_kind_test.go b/platform-api/internal/service/build_kind_test.go new file mode 100644 index 0000000000..736d7b6b81 --- /dev/null +++ b/platform-api/internal/service/build_kind_test.go @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "log/slog" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +const ( + kindTestOrgUUID = "bbbbbbbb-0000-0000-0000-000000000001" + kindTestUUID = "bbbbbbbb-0000-0000-0000-000000000002" +) + +// kindTestArtifactRepo reports one artifact of whatever kind the test sets. +type kindTestArtifactRepo struct { + repository.ArtifactRepository + kind string + missing bool +} + +func (m *kindTestArtifactRepo) GetByUUID(uuid, orgUUID string) (*model.Artifact, error) { + if m.missing { + return nil, nil + } + return &model.Artifact{UUID: uuid, Type: m.kind, OrganizationUUID: orgUUID, Handle: "thing"}, nil +} + +// fakeDefinition stands in for one kind's renderer and records being used. +type fakeDefinition struct { + kind string + yaml string + origin string + called int + failErr error +} + +func (d *fakeDefinition) Kind() string { return d.kind } + +func (d *fakeDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + d.called++ + if d.failErr != nil { + return nil, d.failErr + } + return &ArtifactSnapshot{Definition: d.yaml, DataVersion: "1.0", Origin: d.origin}, nil +} + +func (d *fakeDefinition) Decode(content []byte) (any, error) { return string(content), nil } + +func newKindTestBuildService(t *testing.T, kind string, depRepo repository.DeploymentRepository, + definitions ...ArtifactDefinition) *BuildService { + t.Helper() + return NewBuildService( + &kindTestArtifactRepo{kind: kind}, + depRepo, + NewArtifactDefinitions(definitions...), + &testConfig, + slog.Default(), + ) +} + +// The point of the shared store: which renderer runs is decided by the kind on the +// artifact row, so an MCP proxy is snapshotted by MCP's renderer and a REST API by +// REST's, through one code path. +func TestBuildService_RendersWithTheDefinitionForTheArtifactsKind(t *testing.T) { + mcp := &fakeDefinition{kind: "Mcp", yaml: "kind: McpProxy"} + rest := &fakeDefinition{kind: "RestApi", yaml: "kind: RestApi"} + depRepo := &buildTestDeploymentRepo{} + service := newKindTestBuildService(t, "Mcp", depRepo, mcp, rest) + + if _, err := service.Create(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "", nil); err != nil { + t.Fatalf("Create: %v", err) + } + if mcp.called != 1 { + t.Errorf("the MCP renderer ran %d times, want 1", mcp.called) + } + if rest.called != 0 { + t.Errorf("the REST renderer ran for an MCP artifact") + } + if got := string(depRepo.createdBuild.Content); !strings.Contains(got, "McpProxy") { + t.Errorf("stored content = %q, want what the MCP renderer produced", got) + } +} + +// A kind with no renderer registered is a wiring mistake, not something a caller +// did, so it must not surface as a not-found or a bad request. +func TestBuildService_UnregisteredKindIsInternal(t *testing.T) { + service := newKindTestBuildService(t, "Mcp", &buildTestDeploymentRepo{}, + &fakeDefinition{kind: "RestApi"}) + + _, err := service.Create(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "", nil) + if err == nil { + t.Fatal("expected an error for a kind with no definition") + } + if apperror.ArtifactNotFound.Is(err) { + t.Error("an unregistered kind was reported as a missing artifact") + } +} + +// A missing artifact is a not-found, whatever the kind would have been. +func TestBuildService_MissingArtifactIsNotFound(t *testing.T) { + service := NewBuildService( + &kindTestArtifactRepo{kind: "Mcp", missing: true}, + &buildTestDeploymentRepo{}, + NewArtifactDefinitions(&fakeDefinition{kind: "Mcp"}), + &testConfig, + slog.Default(), + ) + + if _, err := service.Create(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "", nil); !apperror.ArtifactNotFound.Is(err) { + t.Fatalf("error = %v, want ArtifactNotFound", err) + } +} + +// A DP-originated artifact is read-only in the control plane, so there is nothing +// to snapshot — and that guard has to hold for every kind, not just REST. +func TestBuildService_RefusesADataPlaneArtifactForAnyKind(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newKindTestBuildService(t, "Mcp", depRepo, + &fakeDefinition{kind: "Mcp", yaml: "kind: McpProxy", origin: constants.OriginDP}) + + if _, err := service.Create(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "", nil); err == nil { + t.Fatal("expected a data-plane artifact to be refused") + } + if depRepo.createdBuild != nil { + t.Error("a build was stored for a data-plane artifact") + } +} + +// Every kind now deploys the same way: `current` snapshots the artifact and hands +// back an unstored build for the caller to commit with the deployment, and `build` +// ships a stored snapshot and names it. This is the contract the three non-REST +// kinds moved onto, replacing `base: `. +func TestSourceForDeploy_CurrentRendersAnUnstoredBuild(t *testing.T) { + definition := &fakeDefinition{kind: "Mcp", yaml: "kind: McpProxy"} + service := newKindTestBuildService(t, "Mcp", &buildTestDeploymentRepo{}, definition) + + source, err := service.SourceForDeploy(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "current", "") + if err != nil { + t.Fatalf("SourceForDeploy: %v", err) + } + if source.NewBuild == nil { + t.Fatal("base 'current' produced no build to store with the deployment") + } + if source.BuildUUID != nil || source.BuildID != nil { + t.Error("an unstored build must not be named yet; the id is assigned when it is stored") + } + if definition.called != 1 { + t.Errorf("the renderer ran %d times, want 1", definition.called) + } +} + +// Deploying a named build ships the STORED snapshot, decoded through the kind's own +// definition — nothing is re-rendered, which is what makes a promotion carry the +// artifact that was tested rather than one built again from a definition that may +// have moved on. +func TestSourceForDeploy_BuildShipsTheStoredSnapshot(t *testing.T) { + definition := &fakeDefinition{kind: "Mcp", yaml: "kind: McpProxy"} + depRepo := &buildTestDeploymentRepo{build: &model.Build{ + UUID: "build-uuid", + BuildID: buildTestBuildID, + Content: []byte("stored: snapshot"), + DataVersion: "1.0", + }} + service := newKindTestBuildService(t, "Mcp", depRepo, definition) + + source, err := service.SourceForDeploy(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "build", buildTestBuildID) + if err != nil { + t.Fatalf("SourceForDeploy: %v", err) + } + if source.NewBuild != nil { + t.Error("deploying a stored build must not render a new one") + } + if definition.called != 0 { + t.Error("the renderer ran while deploying an existing build") + } + if source.BuildUUID == nil || *source.BuildUUID != "build-uuid" { + t.Errorf("buildUUID = %v, want the stored build's", source.BuildUUID) + } + if got, ok := source.Definition.(string); !ok || got != "stored: snapshot" { + t.Errorf("definition = %v, want the stored content decoded", source.Definition) + } +} + +// A build id that is not one of this artifact's is a not-found rather than a +// silently different deployment. +func TestSourceForDeploy_UnknownBuildIsNotFound(t *testing.T) { + service := newKindTestBuildService(t, "Mcp", &buildTestDeploymentRepo{}, + &fakeDefinition{kind: "Mcp"}) + + _, err := service.SourceForDeploy(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "build", "2026-01-31-9") + if !apperror.BuildNotFound.Is(err) { + t.Fatalf("error = %v, want BuildNotFound", err) + } +} + +// The base contract, which is the same for every kind now. +func TestValidateDeployBase(t *testing.T) { + buildID := buildTestBuildID + empty := "" + cases := []struct { + name string + base string + buildID *string + wantErr bool + }{ + {"current alone", "current", nil, false}, + {"build with an id", "build", &buildID, false}, + {"no base", "", nil, true}, + {"a deploymentId is no longer a base", "some-deployment-uuid", nil, true}, + {"build without an id", "build", nil, true}, + {"build with a blank id", "build", &empty, true}, + {"current with an id", "current", &buildID, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := ValidateDeployBase(tc.base, tc.buildID, apperror.MCPProxyDeploymentValidationFailed) + if tc.wantErr && err == nil { + t.Error("expected the request to be refused") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// Handles are unique only WITHIN a kind, and the artifact lookup resolves one across +// every kind's table. So an endpoint has to say which kind it serves: reaching an +// artifact of another kind through it is a not-found, not a build of the wrong +// thing. +func TestBuildService_RefusesAnArtifactOfAnotherKind(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + // The artifact is a REST API; the caller is the MCP proxy endpoint. + service := newKindTestBuildService(t, "RestApi", depRepo, + &fakeDefinition{kind: "RestApi", yaml: "kind: RestApi"}, + &fakeDefinition{kind: "Mcp", yaml: "kind: McpProxy"}) + + _, err := service.Create(kindTestUUID, kindTestOrgUUID, "Mcp", "tester", "", nil) + if !apperror.ArtifactNotFound.Is(err) { + t.Fatalf("error = %v, want ArtifactNotFound for a REST API reached through the MCP path", err) + } + if depRepo.createdBuild != nil { + t.Error("a build was stored for an artifact of another kind") + } +} + +// The kind registry is what plugins reach the platform through, so it has to route +// to the right kind and refuse one it does not serve. +func TestDeploymentsByKind_RoutesAndRefuses(t *testing.T) { + registry := DeploymentsByKind{} + if _, err := registry.For("Mcp"); err == nil { + t.Error("an unregistered kind should be refused") + } + + // An unknown kind is the caller naming something the platform does not deploy, + // so it is a bad request rather than a missing artifact or an internal fault. + _, err := registry.GetBuildsByHandle("orders", "NotAKind", kindTestOrgUUID, 0) + if err == nil { + t.Fatal("expected an unknown kind to be refused") + } + if apperror.ArtifactNotFound.Is(err) { + t.Error("an unknown kind was reported as a missing artifact") + } + if !strings.Contains(err.Error(), "NotAKind") { + t.Errorf("error %q does not name the kind that was asked for", err.Error()) + } +} diff --git a/platform-api/internal/service/build_test.go b/platform-api/internal/service/build_test.go index 80f5567db1..8817918330 100644 --- a/platform-api/internal/service/build_test.go +++ b/platform-api/internal/service/build_test.go @@ -54,6 +54,24 @@ func (m *buildTestAPIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID return nil, nil } +// buildTestArtifactRepo answers the artifact lookup the shared build service does +// to find out which kind it is rendering. +// +// The real repository UNIONs the kind-specific tables, so an artifact row exists +// only while the API behind it does. This mirrors that by reporting nothing once +// the API is gone, rather than a row pointing at an API that is not there. +type buildTestArtifactRepo struct { + repository.ArtifactRepository + apiRepo *buildTestAPIRepo +} + +func (m *buildTestArtifactRepo) GetByUUID(uuid, orgUUID string) (*model.Artifact, error) { + if m.apiRepo == nil || m.apiRepo.apiModel == nil { + return nil, nil + } + return &model.Artifact{UUID: uuid, Type: constants.RestApi, OrganizationUUID: orgUUID}, nil +} + func (m *buildTestAPIRepo) CreateAPIAssociation(association *model.APIAssociation) error { return nil } @@ -161,15 +179,25 @@ func (m *buildTestGatewayRepo) GetByUUID(gatewayID string) (*model.Gateway, erro } func newBuildTestService(apiRepo *buildTestAPIRepo, depRepo *buildTestDeploymentRepo) *DeploymentService { + apiUtil := &utils.APIUtil{} + artifactRepo := &buildTestArtifactRepo{apiRepo: apiRepo} return &DeploymentService{ apiRepo: apiRepo, + artifactRepo: artifactRepo, deploymentRepo: depRepo, gatewayRepo: &buildTestGatewayRepo{gateway: &model.Gateway{ ID: buildTestGatewayUUID, Handle: "test-gateway", Version: "1.0.0", }}, - apiUtil: &utils.APIUtil{}, + apiUtil: apiUtil, + builds: NewBuildService( + artifactRepo, + depRepo, + NewArtifactDefinitions(NewRestAPIDefinition(apiRepo, apiUtil)), + &testConfig, + slog.Default(), + ), cfg: &testConfig, slogger: slog.Default(), } diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index 79c972dea8..95bbd700da 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -18,7 +18,6 @@ package service import ( - "errors" "fmt" "log/slog" "net/url" @@ -61,8 +60,11 @@ type DeploymentService struct { gatewayEventsService *GatewayEventsService auditRepo repository.AuditRepository apiUtil *utils.APIUtil - cfg *config.Server - slogger *slog.Logger + // builds is the shared build store, used by every artifact kind. REST API + // builds go through it rather than having their own copy. + builds *BuildService + cfg *config.Server + slogger *slog.Logger } // NewDeploymentService creates a new deployment service @@ -76,6 +78,7 @@ func NewDeploymentService( gatewayEventsService *GatewayEventsService, auditRepo repository.AuditRepository, apiUtil *utils.APIUtil, + definitions ArtifactDefinitions, cfg *config.Server, slogger *slog.Logger, ) *DeploymentService { @@ -89,6 +92,7 @@ func NewDeploymentService( gatewayEventsService: gatewayEventsService, auditRepo: auditRepo, apiUtil: apiUtil, + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), cfg: cfg, slogger: slogger, } @@ -105,127 +109,50 @@ func NewDeploymentService( // translation happens at deploy time. func (s *DeploymentService) CreateBuild(apiUUID, orgUUID, createdBy, description string, metadata map[string]interface{}) (*api.BuildResponse, error) { - apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) - if err != nil { - return nil, err - } - if apiModel == nil { - return nil, apperror.RESTAPINotFound.New() - } - // DP-originated artifacts are read-only in the control plane, so there is - // nothing here to snapshot and deploy. - if err := ensureOriginMutable(apiModel.Origin); err != nil { - return nil, err - } - - build, _, err := s.renderBuild(apiModel, apiUUID, orgUUID, createdBy, metadata) - if err != nil { - return nil, err - } - build.Description = description - if err := s.deploymentRepo.CreateBuildWithLimitEnforcement(build, s.cfg.Deployments.MaxBuildsPerAPI); err != nil { - return nil, s.buildLimitError(err) - } - s.slogger.Debug("Build created", "buildID", build.BuildID, "apiUUID", apiUUID) - return toAPIBuildResponse(build), nil + return s.builds.Create(apiUUID, orgUUID, constants.RestApi, createdBy, description, metadata) } -// renderBuild renders an API's current definition into a build that has not been -// stored yet, and hands back the struct it was rendered from alongside it. -// Preparing a build stores it on its own; deploying from `current` stores it on the -// transaction that records the deployment. The struct is returned so that path can -// apply its overrides and translate for the target gateway without re-parsing what -// it has just written — and those overrides never reach the build, whose content is -// marshalled here: a build is the definition as it stood, not one deployment's -// customization of it. -func (s *DeploymentService) renderBuild(apiModel *model.API, apiUUID, orgUUID, createdBy string, +// renderBuild renders the API's current definition into a build that has not been +// stored yet, narrowing the shared service's result to the REST deployment struct +// the deploy path then applies its overrides to. +// +// The artifact model is no longer read here: the shared service resolves the +// artifact and picks the renderer for its kind, so this stays a type assertion +// rather than a second copy of the rendering. +func (s *DeploymentService) renderBuild(apiUUID, orgUUID, createdBy string, metadata map[string]interface{}) (*model.Build, *dto.APIDeploymentYAML, error) { - apiDeployment, err := s.apiUtil.BuildAPIDeploymentYAML(apiModel) + build, definition, err := s.builds.Render(apiUUID, orgUUID, constants.RestApi, createdBy, metadata) if err != nil { - return nil, nil, fmt.Errorf("failed to build API deployment YAML: %w", err) + return nil, nil, err } - contentBytes, err := yaml.Marshal(apiDeployment) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal API deployment YAML: %w", err) + apiDeployment, ok := definition.(*dto.APIDeploymentYAML) + if !ok { + // Only reachable if a REST API's artifact row claims another kind, which + // would be a corrupted row rather than anything a caller did. + return nil, nil, fmt.Errorf("artifact %s did not render as a REST API definition", apiUUID) } - return &model.Build{ - ArtifactID: apiUUID, - OrganizationID: orgUUID, - Content: contentBytes, - DataVersion: apiModel.DataVersion, - Metadata: metadata, - CreatedBy: createdBy, - }, apiDeployment, nil + return build, apiDeployment, nil } // GetBuild returns one build of an API. func (s *DeploymentService) GetBuild(apiUUID, buildID, orgUUID string) (*api.BuildResponse, error) { - build, err := s.deploymentRepo.GetBuild(buildID, apiUUID, orgUUID) - if err != nil { - return nil, err - } - if build == nil { - return nil, apperror.BuildNotFound.New() - } - return toAPIBuildResponse(build), nil + return s.builds.Get(apiUUID, buildID, orgUUID, constants.RestApi) } // GetBuilds lists an API's builds, newest first. func (s *DeploymentService) GetBuilds(apiUUID, orgUUID string, limit int) (*api.BuildListResponse, error) { - apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) - if err != nil { - return nil, err - } - if apiModel == nil { - return nil, apperror.RESTAPINotFound.New() - } - builds, err := s.deploymentRepo.GetBuilds(apiUUID, orgUUID, limit) - if err != nil { - return nil, err - } - list := make([]api.BuildResponse, 0, len(builds)) - for _, build := range builds { - list = append(list, *toAPIBuildResponse(build)) - } - return &api.BuildListResponse{Count: len(list), List: list}, nil + return s.builds.List(apiUUID, orgUUID, constants.RestApi, limit) } // DeleteBuild removes one of an API's builds. -// -// A build a deployment holds is not deleted: the deployment — running, or suspended -// and still restorable — would be left with no snapshot to trace back to or promote -// onward, and the definition as it stood cannot be rendered again. So the conflict -// is reported and the caller chooses which deployment to give up, which is the same -// judgement that preparing a build at the limit asks of them. func (s *DeploymentService) DeleteBuild(apiUUID, buildID, orgUUID string) error { - apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) - if err != nil { - return err - } - if apiModel == nil { - return apperror.RESTAPINotFound.New() - } - if err := s.deploymentRepo.DeleteBuild(buildID, apiUUID, orgUUID); err != nil { - switch { - case errors.Is(err, repository.ErrBuildNotFound): - return apperror.BuildNotFound.New() - case errors.Is(err, repository.ErrBuildInUse): - return apperror.BuildInUse.New() - } - return err - } - s.slogger.Debug("Build deleted", "buildID", buildID, "apiUUID", apiUUID) - return nil + return s.builds.Delete(apiUUID, buildID, orgUUID, constants.RestApi) } -// buildLimitError turns the repository's "nothing free to remove" signal into the -// conflict a caller can act on, naming the limit they are up against. Any other -// error is passed through untouched. +// buildLimitError turns the repository's limit signal into the conflict a caller +// can act on. The deploy path stores a build of its own, so it maps the same way. func (s *DeploymentService) buildLimitError(err error) error { - if errors.Is(err, repository.ErrBuildLimitReached) { - return apperror.BuildLimitReached.New(s.cfg.Deployments.MaxBuildsPerAPI) - } - return err + return s.builds.LimitError(err) } // toAPIBuildResponse projects a stored build onto the API response. @@ -342,7 +269,7 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or buildReadableID = &baseBuild.BuildID case deployBaseCurrent: var err error - newBuild, apiDeployment, err = s.renderBuild(apiModel, apiUUID, orgUUID, createdBy, nil) + newBuild, apiDeployment, err = s.renderBuild(apiUUID, orgUUID, createdBy, nil) if err != nil { return nil, err } @@ -1120,8 +1047,11 @@ func (s *DeploymentService) getUUIDByHandle(handle, orgUUID string) (string, err if err != nil { return "", err } - if artifact == nil { - return "", apperror.ArtifactNotFound.New() + // Handles are unique only WITHIN a kind, and this lookup spans every kind's + // table, so an artifact of another kind that happens to share the handle must + // not be reachable through this endpoint. + if artifact == nil || artifact.Type != constants.RestApi { + return "", apperror.RESTAPINotFound.New() } return artifact.UUID, nil diff --git a/platform-api/internal/service/deployments_by_kind.go b/platform-api/internal/service/deployments_by_kind.go new file mode 100644 index 0000000000..16fb71249b --- /dev/null +++ b/platform-api/internal/service/deployments_by_kind.go @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "fmt" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" +) + +// ArtifactDeployments is the deployment and build lifecycle of ONE artifact kind, +// under names that do not vary between kinds. +// +// The build half needs no adapting: every kind's service already exposes those four +// identically, because they all delegate to the shared build store. The deployment +// half does — the kinds spell the same operations differently (DeployAPIByHandle, +// DeployMCPProxyByHandle, DeployLLMProxy) and only REST threads an actor through — +// so the adapters below normalise that and nothing else. +type ArtifactDeployments interface { + CreateBuildByHandle(handle, orgID, actor, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) + GetBuildByHandle(handle, buildID, orgID string) (*api.BuildResponse, error) + GetBuildsByHandle(handle, orgID string, limit int) (*api.BuildListResponse, error) + DeleteBuildByHandle(handle, buildID, orgID string) error + + Deploy(handle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) + Undeploy(handle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) + Restore(handle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) + GetDeployment(handle, deploymentID, orgID string) (*api.DeploymentResponse, error) + ListDeployments(handle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) +} + +// DeploymentsByKind routes an operation to the service for an artifact kind. +// +// The kind is always given by the caller rather than inferred from the handle: +// handles are unique only WITHIN a kind, so inferring one would let a request reach +// an artifact it did not name. Each kind's service validates that the artifact it +// resolves really is of its kind, so the check is made once, where the artifact is +// actually read. +type DeploymentsByKind map[string]ArtifactDeployments + +// For returns the services for an artifact kind, or a validation error naming it. +// An unknown kind is the caller's mistake — it named something the platform does +// not deploy — so it is a bad request rather than an internal error. +func (d DeploymentsByKind) For(kind string) (ArtifactDeployments, error) { + deployments, ok := d[kind] + if !ok { + return nil, apperror.ValidationFailed.New( + fmt.Sprintf("%q is not an artifact kind that can be deployed.", kind)) + } + return deployments, nil +} + +// NewDeploymentsByKind indexes each kind's services under the kind the artifact row +// carries, which is the same key ArtifactDefinitions uses. +func NewDeploymentsByKind( + rest *DeploymentService, + mcp *MCPDeploymentService, + llmProxy *LLMProxyDeploymentService, + llmProvider *LLMProviderDeploymentService, +) DeploymentsByKind { + return DeploymentsByKind{ + constants.RestApi: restDeployments{rest}, + constants.MCPProxy: mcpDeployments{mcp}, + constants.LLMProxy: llmProxyDeployments{llmProxy}, + constants.LLMProvider: llmProviderDeployments{llmProvider}, + } +} + +// restDeployments adapts the REST API service. Only Deploy is renamed; the rest +// already match, including the actor. +type restDeployments struct{ *DeploymentService } + +func (a restDeployments) Deploy(handle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) { + return a.DeployAPIByHandle(handle, req, orgID, actor) +} + +func (a restDeployments) Undeploy(handle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) { + return a.UndeployDeploymentByHandle(handle, deploymentID, gatewayHandle, orgID, actor) +} + +func (a restDeployments) Restore(handle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) { + return a.RestoreDeploymentByHandle(handle, deploymentID, gatewayHandle, orgID, actor) +} + +func (a restDeployments) GetDeployment(handle, deploymentID, orgID string) (*api.DeploymentResponse, error) { + return a.GetDeploymentByHandle(handle, deploymentID, orgID) +} + +func (a restDeployments) ListDeployments(handle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) { + return a.GetDeploymentsByHandle(handle, gatewayID, status, orgID) +} + +// mcpDeployments adapts the MCP proxy service, which records no actor of its own. +type mcpDeployments struct{ *MCPDeploymentService } + +func (a mcpDeployments) Deploy(handle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) { + return a.DeployMCPProxyByHandle(handle, req, orgID, actor) +} + +func (a mcpDeployments) Undeploy(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.UndeployDeploymentByHandle(handle, deploymentID, gatewayHandle, orgID) +} + +func (a mcpDeployments) Restore(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.RestoreMCPDeploymentByHandle(handle, deploymentID, gatewayHandle, orgID) +} + +func (a mcpDeployments) GetDeployment(handle, deploymentID, orgID string) (*api.DeploymentResponse, error) { + return a.MCPDeploymentService.GetDeploymentByHandle(handle, deploymentID, orgID) +} + +func (a mcpDeployments) ListDeployments(handle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) { + return a.MCPDeploymentService.GetDeploymentsByHandle(handle, gatewayID, status, orgID) +} + +// llmProxyDeployments adapts the LLM proxy service, whose identifier IS the handle +// and whose listing takes optional filters as pointers. +type llmProxyDeployments struct{ *LLMProxyDeploymentService } + +func (a llmProxyDeployments) Deploy(handle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) { + return a.DeployLLMProxy(handle, req, orgID, actor) +} + +func (a llmProxyDeployments) Undeploy(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.UndeployLLMProxyDeployment(handle, deploymentID, gatewayHandle, orgID) +} + +func (a llmProxyDeployments) Restore(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.RestoreLLMProxyDeployment(handle, deploymentID, gatewayHandle, orgID) +} + +func (a llmProxyDeployments) GetDeployment(handle, deploymentID, orgID string) (*api.DeploymentResponse, error) { + return a.GetLLMProxyDeployment(handle, deploymentID, orgID) +} + +func (a llmProxyDeployments) ListDeployments(handle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) { + return a.GetLLMProxyDeployments(handle, orgID, optionalFilter(gatewayID), optionalFilter(status)) +} + +// llmProviderDeployments adapts the LLM provider service. +type llmProviderDeployments struct{ *LLMProviderDeploymentService } + +func (a llmProviderDeployments) Deploy(handle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) { + return a.DeployLLMProvider(handle, req, orgID, actor) +} + +func (a llmProviderDeployments) Undeploy(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.UndeployLLMProviderDeployment(handle, deploymentID, gatewayHandle, orgID) +} + +func (a llmProviderDeployments) Restore(handle, deploymentID, gatewayHandle, orgID, _ string) (*api.DeploymentResponse, error) { + return a.RestoreLLMProviderDeployment(handle, deploymentID, gatewayHandle, orgID) +} + +func (a llmProviderDeployments) GetDeployment(handle, deploymentID, orgID string) (*api.DeploymentResponse, error) { + return a.GetLLMProviderDeployment(handle, deploymentID, orgID) +} + +func (a llmProviderDeployments) ListDeployments(handle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) { + return a.GetLLMProviderDeployments(handle, orgID, optionalFilter(gatewayID), optionalFilter(status)) +} + +// optionalFilter turns an empty filter into "not given", which is how the LLM +// services spell an absent gateway or status. +func optionalFilter(value string) *string { + if value == "" { + return nil + } + return &value +} + +// The methods below give DeploymentsByKind the shape plugins use: the same +// operations, each taking the artifact kind alongside the handle. They resolve the +// kind once and delegate, so the routing lives here rather than in every plugin. + +// CreateBuildByHandle prepares a build of an artifact of the named kind. +func (d DeploymentsByKind) CreateBuildByHandle(handle, kind, orgID, actor, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.CreateBuildByHandle(handle, orgID, actor, description, metadata) +} + +// GetBuildByHandle returns one of an artifact's builds. +func (d DeploymentsByKind) GetBuildByHandle(handle, kind, buildID, orgID string) (*api.BuildResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.GetBuildByHandle(handle, buildID, orgID) +} + +// GetBuildsByHandle lists an artifact's builds, newest first. +func (d DeploymentsByKind) GetBuildsByHandle(handle, kind, orgID string, limit int) (*api.BuildListResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.GetBuildsByHandle(handle, orgID, limit) +} + +// DeleteBuildByHandle removes one of an artifact's builds. +func (d DeploymentsByKind) DeleteBuildByHandle(handle, kind, buildID, orgID string) error { + deployments, err := d.For(kind) + if err != nil { + return err + } + return deployments.DeleteBuildByHandle(handle, buildID, orgID) +} + +// DeployByHandle deploys an artifact of the named kind onto one gateway. +func (d DeploymentsByKind) DeployByHandle(handle, kind string, req *api.DeployRequest, + orgID, actor string) (*api.DeploymentResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.Deploy(handle, req, orgID, actor) +} + +// GetDeploymentsByHandle lists an artifact's deployments. +func (d DeploymentsByKind) GetDeploymentsByHandle(handle, kind, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.ListDeployments(handle, gatewayID, status, orgID) +} + +// GetDeploymentByHandle returns a single deployment of an artifact. +func (d DeploymentsByKind) GetDeploymentByHandle(handle, kind, deploymentID, orgID string) (*api.DeploymentResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.GetDeployment(handle, deploymentID, orgID) +} + +// UndeployDeploymentByHandle takes a deployment off its gateway. +func (d DeploymentsByKind) UndeployDeploymentByHandle(handle, kind, deploymentID, gatewayHandle, + orgID, actor string) (*api.DeploymentResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.Undeploy(handle, deploymentID, gatewayHandle, orgID, actor) +} + +// RestoreDeploymentByHandle puts a suspended or superseded deployment back on its +// gateway, serving the artifact it already holds. +func (d DeploymentsByKind) RestoreDeploymentByHandle(handle, kind, deploymentID, gatewayHandle, + orgID, actor string) (*api.DeploymentResponse, error) { + deployments, err := d.For(kind) + if err != nil { + return nil, err + } + return deployments.Restore(handle, deploymentID, gatewayHandle, orgID, actor) +} diff --git a/platform-api/internal/service/llm_deployment.go b/platform-api/internal/service/llm_deployment.go index 084a059a4b..a2ccbf0d17 100644 --- a/platform-api/internal/service/llm_deployment.go +++ b/platform-api/internal/service/llm_deployment.go @@ -52,8 +52,10 @@ const ( // LLMProviderDeploymentService handles business logic for LLM provider deployment operations // using the shared deployments table and status model. type LLMProviderDeploymentService struct { - providerRepo repository.LLMProviderRepository - templateRepo repository.LLMProviderTemplateRepository + providerRepo repository.LLMProviderRepository + templateRepo repository.LLMProviderTemplateRepository + // builds is the shared build store every artifact kind uses. + builds *BuildService deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository @@ -66,7 +68,9 @@ type LLMProviderDeploymentService struct { // LLMProxyDeploymentService handles business logic for LLM proxy deployment operations // using the shared deployments table and status model. type LLMProxyDeploymentService struct { - proxyRepo repository.LLMProxyRepository + proxyRepo repository.LLMProxyRepository + // builds is the shared build store every artifact kind uses. + builds *BuildService deploymentRepo repository.DeploymentRepository gatewayRepo repository.GatewayRepository orgRepo repository.OrganizationRepository @@ -85,10 +89,13 @@ func NewLLMProviderDeploymentService( orgRepo repository.OrganizationRepository, apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, + artifactRepo repository.ArtifactRepository, + definitions ArtifactDefinitions, cfg *config.Server, slogger *slog.Logger, ) *LLMProviderDeploymentService { return &LLMProviderDeploymentService{ + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), providerRepo: providerRepo, templateRepo: templateRepo, deploymentRepo: deploymentRepo, @@ -109,10 +116,13 @@ func NewLLMProxyDeploymentService( orgRepo repository.OrganizationRepository, apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, + artifactRepo repository.ArtifactRepository, + definitions ArtifactDefinitions, cfg *config.Server, slogger *slog.Logger, ) *LLMProxyDeploymentService { return &LLMProxyDeploymentService{ + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), proxyRepo: proxyRepo, deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, @@ -124,14 +134,118 @@ func NewLLMProxyDeploymentService( } } +// providerUUID resolves an LLM provider's identifier to its artifact UUID, which is +// what builds are keyed by. Resolving here keeps this kind's own not-found. +func (s *LLMProviderDeploymentService) providerUUID(providerID, orgUUID string) (string, error) { + provider, err := s.providerRepo.GetByID(providerID, orgUUID) + if err != nil { + return "", err + } + if provider == nil { + return "", apperror.LLMProviderNotFound.New() + } + return provider.UUID, nil +} + +// CreateBuildByHandle prepares a build of an LLM provider without deploying it. +// +// Builds are the same thing for every artifact kind, so these four delegate to the +// shared store; only resolving the identifier is this kind's own. +func (s *LLMProviderDeploymentService) CreateBuildByHandle(providerID, orgUUID, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + providerUUID, err := s.providerUUID(providerID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Create(providerUUID, orgUUID, constants.LLMProvider, createdBy, description, metadata) +} + +// GetBuildByHandle returns one of an LLM provider's builds. +func (s *LLMProviderDeploymentService) GetBuildByHandle(providerID, buildID, orgUUID string) (*api.BuildResponse, error) { + providerUUID, err := s.providerUUID(providerID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Get(providerUUID, buildID, orgUUID, constants.LLMProvider) +} + +// GetBuildsByHandle lists an LLM provider's builds, newest first. +func (s *LLMProviderDeploymentService) GetBuildsByHandle(providerID, orgUUID string, limit int) (*api.BuildListResponse, error) { + providerUUID, err := s.providerUUID(providerID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.List(providerUUID, orgUUID, constants.LLMProvider, limit) +} + +// DeleteBuildByHandle removes one of an LLM provider's builds. +func (s *LLMProviderDeploymentService) DeleteBuildByHandle(providerID, buildID, orgUUID string) error { + providerUUID, err := s.providerUUID(providerID, orgUUID) + if err != nil { + return err + } + return s.builds.Delete(providerUUID, buildID, orgUUID, constants.LLMProvider) +} + +// proxyUUID resolves an LLM proxy's identifier to its artifact UUID. +func (s *LLMProxyDeploymentService) proxyUUID(proxyID, orgUUID string) (string, error) { + proxy, err := s.proxyRepo.GetByID(proxyID, orgUUID) + if err != nil { + return "", err + } + if proxy == nil { + return "", apperror.LLMProxyNotFound.New() + } + return proxy.UUID, nil +} + +// CreateBuildByHandle prepares a build of an LLM proxy without deploying it. +func (s *LLMProxyDeploymentService) CreateBuildByHandle(proxyID, orgUUID, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + proxyUUID, err := s.proxyUUID(proxyID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Create(proxyUUID, orgUUID, constants.LLMProxy, createdBy, description, metadata) +} + +// GetBuildByHandle returns one of an LLM proxy's builds. +func (s *LLMProxyDeploymentService) GetBuildByHandle(proxyID, buildID, orgUUID string) (*api.BuildResponse, error) { + proxyUUID, err := s.proxyUUID(proxyID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Get(proxyUUID, buildID, orgUUID, constants.LLMProxy) +} + +// GetBuildsByHandle lists an LLM proxy's builds, newest first. +func (s *LLMProxyDeploymentService) GetBuildsByHandle(proxyID, orgUUID string, limit int) (*api.BuildListResponse, error) { + proxyUUID, err := s.proxyUUID(proxyID, orgUUID) + if err != nil { + return nil, err + } + return s.builds.List(proxyUUID, orgUUID, constants.LLMProxy, limit) +} + +// DeleteBuildByHandle removes one of an LLM proxy's builds. +func (s *LLMProxyDeploymentService) DeleteBuildByHandle(proxyID, buildID, orgUUID string) error { + proxyUUID, err := s.proxyUUID(proxyID, orgUUID) + if err != nil { + return err + } + return s.builds.Delete(proxyUUID, buildID, orgUUID, constants.LLMProxy) +} + // DeployLLMProvider creates a new immutable deployment artifact and deploys it to a gateway func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Validate request if req == nil { return nil, apperror.LLMProviderDeploymentValidationFailed.New("A request body is required.") } - if req.Base == "" { - return nil, apperror.LLMProviderDeploymentValidationFailed.New("Base is required (use 'current' or a deploymentId).") + base, requestedBuild, err := ValidateDeployBase(req.Base, req.BuildId, + apperror.LLMProviderDeploymentValidationFailed) + if err != nil { + return nil, err } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -192,45 +306,30 @@ func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req return nil, err } - var baseDeploymentID *string - var contentBytes []byte - - // Determine the source: "current" or existing deployment - if req.Base == "current" { - tplHandle, err := s.getTemplateHandle(provider.TemplateUUID, orgUUID) - if err != nil { - return nil, err - } - providerDeployment, err := generateLLMProviderDeploymentYAML(provider, tplHandle) - if err != nil { - return nil, fmt.Errorf("failed to generate LLM provider deployment YAML: %w", err) - } - sourceDataVersion := gatewaytranslator.PlatformDataVersion(provider.DataVersion) - targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) - if err := gatewaytranslator.Translate( - constants.LLMProvider, - sourceDataVersion, - targetDataVersion, - &providerDeployment, - ); err != nil { - return nil, fmt.Errorf("failed to transform LLM provider deployment for gateway %s: %w", gateway.Version, err) - } - providerYamlBytes, marshalErr := yaml.Marshal(providerDeployment) - if marshalErr != nil { - return nil, fmt.Errorf("failed to marshal LLM provider deployment YAML: %w", marshalErr) - } - contentBytes = providerYamlBytes - } else { - // Use existing deployment as base - baseDeployment, err := s.deploymentRepo.GetWithContent(req.Base, provider.UUID, orgUUID) - if err != nil { - if apperror.DeploymentNotFound.Is(err) { - return nil, apperror.DeploymentBaseNotFound.Wrap(err) - } - return nil, fmt.Errorf("failed to get base deployment: %w", err) - } - contentBytes = baseDeployment.Content - baseDeploymentID = &req.Base + // What this deploy ships: a build prepared earlier, or a snapshot of the + // provider as it stands now. A snapshot comes back unstored so it commits with + // the deployment below. + source, err := s.builds.SourceForDeploy(provider.UUID, orgUUID, constants.LLMProvider, createdBy, base, requestedBuild) + if err != nil { + return nil, err + } + providerDeployment, ok := source.Definition.(*dto.LLMProviderDeploymentYAML) + if !ok { + return nil, fmt.Errorf("artifact %s did not render as an LLM provider definition", provider.UUID) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(source.DataVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate( + constants.LLMProvider, + sourceDataVersion, + targetDataVersion, + providerDeployment, + ); err != nil { + return nil, fmt.Errorf("failed to transform LLM provider deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(providerDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal LLM provider deployment YAML: %w", err) } // Generate deployment ID @@ -241,22 +340,34 @@ func (s *LLMProviderDeploymentService) DeployLLMProvider(providerID string, req deployed := model.DeploymentStatusDeployed deployment := &model.Deployment{ - DeploymentID: deploymentID, - Name: req.Name, - ArtifactID: provider.UUID, - OrganizationID: orgUUID, - GatewayID: gatewayID, - BaseDeploymentID: baseDeploymentID, - Content: contentBytes, - Metadata: metadata, - Status: &deployed, + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: provider.UUID, + OrganizationID: orgUUID, + GatewayID: gatewayID, + BuildUUID: source.BuildUUID, + BuildID: source.BuildID, + Content: contentBytes, + Metadata: metadata, + Status: &deployed, } if s.cfg.Deployments.MaxPerAPIGateway < 1 { return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) } hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer - if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + // A build rendered for this deploy is stored with the deployment, in one + // transaction, so a recorded deployment always has the build it runs. + if source.NewBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, source.NewBuild, + s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + if limitErr := s.builds.LimitError(err); limitErr != err { + return nil, limitErr + } return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -1282,8 +1393,10 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl if req == nil { return nil, apperror.LLMProxyDeploymentValidationFailed.New("A request body is required.") } - if req.Base == "" { - return nil, apperror.LLMProxyDeploymentValidationFailed.New("Base is required (use 'current' or a deploymentId).") + base, requestedBuild, err := ValidateDeployBase(req.Base, req.BuildId, + apperror.LLMProxyDeploymentValidationFailed) + if err != nil { + return nil, err } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -1340,41 +1453,30 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl return nil, err } - var baseDeploymentID *string - var contentBytes []byte - - // Determine the source: "current" or existing deployment - if req.Base == "current" { - proxyDeployment, err := generateLLMProxyDeploymentYAML(proxy) - if err != nil { - return nil, fmt.Errorf("failed to generate LLM proxy deployment YAML: %w", err) - } - sourceDataVersion := gatewaytranslator.PlatformDataVersion(proxy.DataVersion) - targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) - if err := gatewaytranslator.Translate( - constants.LLMProxy, - sourceDataVersion, - targetDataVersion, - &proxyDeployment, - ); err != nil { - return nil, fmt.Errorf("failed to transform LLM proxy deployment for gateway %s: %w", gateway.Version, err) - } - proxyYamlBytes, marshalErr := yaml.Marshal(proxyDeployment) - if marshalErr != nil { - return nil, fmt.Errorf("failed to marshal LLM proxy deployment YAML: %w", marshalErr) - } - contentBytes = proxyYamlBytes - } else { - // Use existing deployment as base - baseDeployment, err := s.deploymentRepo.GetWithContent(req.Base, proxy.UUID, orgUUID) - if err != nil { - if apperror.DeploymentNotFound.Is(err) { - return nil, apperror.DeploymentBaseNotFound.Wrap(err) - } - return nil, fmt.Errorf("failed to get base deployment: %w", err) - } - contentBytes = baseDeployment.Content - baseDeploymentID = &req.Base + // What this deploy ships: a build prepared earlier, or a snapshot of the proxy + // as it stands now. A snapshot comes back unstored so it commits with the + // deployment below. + source, err := s.builds.SourceForDeploy(proxy.UUID, orgUUID, constants.LLMProxy, createdBy, base, requestedBuild) + if err != nil { + return nil, err + } + proxyDeployment, ok := source.Definition.(*dto.LLMProxyDeploymentYAML) + if !ok { + return nil, fmt.Errorf("artifact %s did not render as an LLM proxy definition", proxy.UUID) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(source.DataVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate( + constants.LLMProxy, + sourceDataVersion, + targetDataVersion, + proxyDeployment, + ); err != nil { + return nil, fmt.Errorf("failed to transform LLM proxy deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(proxyDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal LLM proxy deployment YAML: %w", err) } // Generate deployment ID @@ -1385,22 +1487,34 @@ func (s *LLMProxyDeploymentService) DeployLLMProxy(proxyID string, req *api.Depl deployed := model.DeploymentStatusDeployed deployment := &model.Deployment{ - DeploymentID: deploymentID, - Name: req.Name, - ArtifactID: proxy.UUID, - OrganizationID: orgUUID, - GatewayID: gatewayID, - BaseDeploymentID: baseDeploymentID, - Content: contentBytes, - Metadata: metadata, - Status: &deployed, + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: proxy.UUID, + OrganizationID: orgUUID, + GatewayID: gatewayID, + BuildUUID: source.BuildUUID, + BuildID: source.BuildID, + Content: contentBytes, + Metadata: metadata, + Status: &deployed, } if s.cfg.Deployments.MaxPerAPIGateway < 1 { return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) } hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer - if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + // A build rendered for this deploy is stored with the deployment, in one + // transaction, so a recorded deployment always has the build it runs. + if source.NewBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, source.NewBuild, + s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + if limitErr := s.builds.LimitError(err); limitErr != err { + return nil, limitErr + } return nil, fmt.Errorf("failed to create deployment: %w", err) } diff --git a/platform-api/internal/service/mcp_deployment.go b/platform-api/internal/service/mcp_deployment.go index e0d38e4c4b..dc532b0803 100644 --- a/platform-api/internal/service/mcp_deployment.go +++ b/platform-api/internal/service/mcp_deployment.go @@ -47,12 +47,15 @@ type MCPDeploymentService struct { gatewayEventsService *GatewayEventsService cfg *config.Server utils *utils.MCPUtils - slogger *slog.Logger + // builds is the shared build store every artifact kind uses. + builds *BuildService + slogger *slog.Logger } func NewMCPDeploymentService(mcpRepo repository.MCPProxyRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, artifactRepo repository.ArtifactRepository, - apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, cfg *config.Server, slogger *slog.Logger) *MCPDeploymentService { + apiKeyRepo repository.APIKeyRepository, gatewayEventsService *GatewayEventsService, + definitions ArtifactDefinitions, cfg *config.Server, slogger *slog.Logger) *MCPDeploymentService { return &MCPDeploymentService{ mcpRepo: mcpRepo, deploymentRepo: deploymentRepo, @@ -63,10 +66,52 @@ func NewMCPDeploymentService(mcpRepo repository.MCPProxyRepository, deploymentRe gatewayEventsService: gatewayEventsService, cfg: cfg, utils: &utils.MCPUtils{}, + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), slogger: slogger, } } +// CreateBuildByHandle prepares a build of an MCP proxy without deploying it. +// +// Builds are the same thing for every artifact kind, so these four delegate to the +// shared store; only resolving the handle is MCP's own, which keeps the not-found +// this kind already reports. +func (s *MCPDeploymentService) CreateBuildByHandle(proxyHandle, orgUUID, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + proxyUUID, err := s.getMCPProxyUUIDByHandle(proxyHandle, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Create(proxyUUID, orgUUID, constants.MCPProxy, createdBy, description, metadata) +} + +// GetBuildByHandle returns one of an MCP proxy's builds. +func (s *MCPDeploymentService) GetBuildByHandle(proxyHandle, buildID, orgUUID string) (*api.BuildResponse, error) { + proxyUUID, err := s.getMCPProxyUUIDByHandle(proxyHandle, orgUUID) + if err != nil { + return nil, err + } + return s.builds.Get(proxyUUID, buildID, orgUUID, constants.MCPProxy) +} + +// GetBuildsByHandle lists an MCP proxy's builds, newest first. +func (s *MCPDeploymentService) GetBuildsByHandle(proxyHandle, orgUUID string, limit int) (*api.BuildListResponse, error) { + proxyUUID, err := s.getMCPProxyUUIDByHandle(proxyHandle, orgUUID) + if err != nil { + return nil, err + } + return s.builds.List(proxyUUID, orgUUID, constants.MCPProxy, limit) +} + +// DeleteBuildByHandle removes one of an MCP proxy's builds. +func (s *MCPDeploymentService) DeleteBuildByHandle(proxyHandle, buildID, orgUUID string) error { + proxyUUID, err := s.getMCPProxyUUIDByHandle(proxyHandle, orgUUID) + if err != nil { + return err + } + return s.builds.Delete(proxyUUID, buildID, orgUUID, constants.MCPProxy) +} + // DeployMCPProxyByHandle creates a new immutable deployment artifact using MCP proxy handle func (s *MCPDeploymentService) DeployMCPProxyByHandle(proxyHandle string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Convert MCP proxy handle to UUID @@ -183,8 +228,10 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR if req == nil { return nil, apperror.MCPProxyDeploymentValidationFailed.New("A request body is required.") } - if req.Base == "" { - return nil, apperror.MCPProxyDeploymentValidationFailed.New("Base is required.") + base, requestedBuild, err := ValidateDeployBase(req.Base, req.BuildId, + apperror.MCPProxyDeploymentValidationFailed) + if err != nil { + return nil, err } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -255,67 +302,49 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR return nil, err } - var baseDeploymentID *string - var contentBytes []byte + // What this deploy ships: a build prepared earlier, or a snapshot of the proxy + // as it stands now. Either way it comes back as one shape, and a snapshot comes + // back unstored so it commits with the deployment below. + source, err := s.builds.SourceForDeploy(proxyUUID, orgId, constants.MCPProxy, createdBy, base, requestedBuild) + if err != nil { + return nil, err + } + d, ok := source.Definition.(*model.MCPProxyDeploymentYAML) + if !ok { + // Only reachable if the artifact row claims another kind, which would be a + // corrupted row rather than anything a caller did. + return nil, fmt.Errorf("artifact %s did not render as an MCP proxy definition", proxyUUID) + } - if req.Base == "current" { - // Build struct directly, apply overrides on struct, marshal once - d, err := s.utils.BuildMCPDeploymentYAML(mcpProxy) - if err != nil { - return nil, fmt.Errorf("failed to build MCP deployment YAML: %w", err) - } - if endpointURL != nil { - d.Spec.Upstream.URL = *endpointURL - s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) - } - sourceDataVersion := gatewaytranslator.PlatformDataVersion(mcpProxy.DataVersion) - targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) - if err := gatewaytranslator.Translate(constants.MCPProxy, sourceDataVersion, targetDataVersion, d); err != nil { - return nil, fmt.Errorf("failed to transform MCP proxy deployment for gateway %s: %w", gateway.Version, err) - } - contentBytes, err = yaml.Marshal(d) - if err != nil { - return nil, fmt.Errorf("failed to marshal MCP deployment YAML: %w", err) - } - } else { - // Use existing deployment as base - baseDeployment, err := s.deploymentRepo.GetWithContent(req.Base, proxyUUID, orgId) - if err != nil { - if apperror.DeploymentNotFound.Is(err) { - return nil, apperror.DeploymentBaseNotFound.Wrap(err) - } - return nil, fmt.Errorf("failed to get base deployment: %w", err) - } - contentBytes = baseDeployment.Content - baseDeploymentID = &req.Base - - if endpointURL != nil { - // Unmarshal into the correct MCP type, apply override, marshal back - var mcpDeployment model.MCPProxyDeploymentYAML - if err := yaml.Unmarshal(contentBytes, &mcpDeployment); err != nil { - return nil, fmt.Errorf("failed to parse MCP deployment YAML: %w", err) - } - mcpDeployment.Spec.Upstream.URL = *endpointURL - contentBytes, err = yaml.Marshal(&mcpDeployment) - if err != nil { - return nil, fmt.Errorf("failed to marshal modified MCP deployment YAML: %w", err) - } - s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) - } + // The build holds the definition as it stood; this deployment's own endpoint is + // applied here, after the snapshot, so it never reaches the build. + if endpointURL != nil { + d.Spec.Upstream.URL = *endpointURL + s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(source.DataVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate(constants.MCPProxy, sourceDataVersion, targetDataVersion, d); err != nil { + return nil, fmt.Errorf("failed to transform MCP proxy deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(d) + if err != nil { + return nil, fmt.Errorf("failed to marshal MCP deployment YAML: %w", err) } // Create new deployment record with limit enforcement // Hard limit = soft limit (configured) + 5 buffer for concurrent deployments deployment := &model.Deployment{ - DeploymentID: deploymentID, - Name: req.Name, - ArtifactID: proxyUUID, - OrganizationID: orgId, - GatewayID: gatewayID, - BaseDeploymentID: baseDeploymentID, - Content: contentBytes, - Metadata: metadata, - CreatedBy: createdBy, + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: proxyUUID, + OrganizationID: orgId, + GatewayID: gatewayID, + BuildUUID: source.BuildUUID, + BuildID: source.BuildID, + Content: contentBytes, + Metadata: metadata, + CreatedBy: createdBy, } // Use CreateDeploymentWithLimitEnforcement - handles count, cleanup, insert, and status update atomically @@ -323,7 +352,19 @@ func (s *MCPDeploymentService) deployMCPProxy(proxyUUID string, req *api.DeployR return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) } hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer - if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + // A build rendered for this deploy is stored with the deployment, in one + // transaction: a recorded deployment always has the build it runs, and a deploy + // that fails leaves no build behind. + if source.NewBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, source.NewBuild, + s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + if limitErr := s.builds.LimitError(err); limitErr != err { + return nil, limitErr + } return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -713,8 +754,11 @@ func (s *MCPDeploymentService) getMCPProxyUUIDByHandle(handle, orgUUID string) ( if err != nil { return "", err } - if artifact == nil { - return "", apperror.ArtifactNotFound.New() + // Handles are unique only WITHIN a kind, and this lookup spans every kind's + // table, so an artifact of another kind that happens to share the handle must + // not be reachable through this endpoint. + if artifact == nil || artifact.Type != constants.MCPProxy { + return "", apperror.MCPProxyNotFound.New() } return artifact.UUID, nil diff --git a/platform-api/pdk/deps.go b/platform-api/pdk/deps.go index e7a14894ec..aea6e8d967 100644 --- a/platform-api/pdk/deps.go +++ b/platform-api/pdk/deps.go @@ -92,50 +92,68 @@ type Projects interface { // named by buildId. That lets a caller fix WHAT will be deployed at a known moment // — so a deploy cannot silently pick up edits made since — and deploy that same // snapshot to any number of gateways, or onward to the next environment. +// The artifact kinds Deployments accepts. They are the same values the platform's +// own per-kind paths are split by and the same the artifact row carries, so a +// plugin names a kind rather than guessing a string. +const ( + KindRestAPI = "RestApi" + KindMCPProxy = "Mcp" + KindLLMProxy = "LlmProxy" + KindLLMProvider = "LlmProvider" +) + type Deployments interface { - // CreateBuildByHandle renders the API's current definition into an immutable - // snapshot without deploying it, so a later deploy can name that snapshot - // instead of re-rendering whatever the definition has become (Prepare). + // Every operation names the artifact KIND alongside the handle — the same kinds + // the platform's own paths are split by ("RestApi", "Mcp", "LlmProxy", + // "LlmProvider"). Handles are unique only WITHIN a kind, so a handle alone could + // reach an artifact the caller did not name; the kind settles it, and the + // platform checks that the artifact it resolves really is of that kind. + + // CreateBuildByHandle renders the artifact's current definition into an + // immutable snapshot without deploying it, so a later deploy can name that + // snapshot instead of re-rendering whatever the definition has become (Prepare). // Description is an optional note recorded with the build; metadata is stored - // with it and returned uninterpreted. Refused when the API is at its build + // with it and returned uninterpreted. Refused when the artifact is at its build // limit and every stored build is in use by a current deployment; redeploying to // the same gateway does not run the limit down, since a superseded deployment // stops holding its build. - CreateBuildByHandle(apiHandle, orgID, actor, description string, metadata map[string]interface{}) (*api.BuildResponse, error) - - // GetBuildByHandle returns one of an API's builds — its id, metadata and when - // it was prepared, not the rendered artifact itself (Read). - GetBuildByHandle(apiHandle, buildID, orgID string) (*api.BuildResponse, error) - - // GetBuildsByHandle lists an API's builds, newest first (Read). - GetBuildsByHandle(apiHandle, orgID string, limit int) (*api.BuildListResponse, error) - - // DeleteBuildByHandle removes one of an API's builds, and is how room is made - // once the limit refuses another (Delete). Refused only while the build is on a - // gateway — DEPLOYED, DEPLOYING or UNDEPLOYING; undeployed, failed and archived - // deployments all release it, so this reaches the builds automatic cleanup will - // not take. Those deployments stay redeployable from their own artifact but stop - // naming a build, so they can no longer be promoted onward — which is why - // reclaiming them is a request rather than something cleanup decides. - DeleteBuildByHandle(apiHandle, buildID, orgID string) error - - // DeployAPIByHandle creates a new immutable deployment of an API onto one + CreateBuildByHandle(handle, kind, orgID, actor, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) + + // GetBuildByHandle returns one of an artifact's builds — its id, metadata and + // when it was prepared, not the rendered artifact itself (Read). + GetBuildByHandle(handle, kind, buildID, orgID string) (*api.BuildResponse, error) + + // GetBuildsByHandle lists an artifact's builds, newest first (Read). + GetBuildsByHandle(handle, kind, orgID string, limit int) (*api.BuildListResponse, error) + + // DeleteBuildByHandle removes one of an artifact's builds, and is how room is + // made once the limit refuses another (Delete). Refused only while the build is + // on a gateway — DEPLOYED, DEPLOYING or UNDEPLOYING; undeployed, failed and + // archived deployments all release it, so this reaches the builds automatic + // cleanup will not take. Those deployments stay redeployable from their own + // artifact but stop naming a build, so they can no longer be promoted onward — + // which is why reclaiming them is a request rather than something cleanup + // decides. + DeleteBuildByHandle(handle, kind, buildID, orgID string) error + + // DeployByHandle creates a new immutable deployment of an artifact onto one // gateway, from a build (Create). - DeployAPIByHandle(apiHandle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) + DeployByHandle(handle, kind string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) - // GetDeploymentsByHandle lists an API's deployments, optionally filtered by + // GetDeploymentsByHandle lists an artifact's deployments, optionally filtered by // gateway handle and status (Read). - GetDeploymentsByHandle(apiHandle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) + GetDeploymentsByHandle(handle, kind, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) - // GetDeploymentByHandle returns a single deployment of an API, including its - // persisted metadata (Read). - GetDeploymentByHandle(apiHandle, deploymentID, orgID string) (*api.DeploymentResponse, error) + // GetDeploymentByHandle returns a single deployment of an artifact, including + // its persisted metadata (Read). + GetDeploymentByHandle(handle, kind, deploymentID, orgID string) (*api.DeploymentResponse, error) // UndeployDeploymentByHandle undeploys a deployment from its gateway (Delete). - UndeployDeploymentByHandle(apiHandle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) + UndeployDeploymentByHandle(handle, kind, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) // RestoreDeploymentByHandle puts an UNDEPLOYED or ARCHIVED deployment back on // its gateway, serving the artifact it already holds rather than rendering or // building anything new (Update). The deployment must not already be DEPLOYED. - RestoreDeploymentByHandle(apiHandle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) + RestoreDeploymentByHandle(handle, kind, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) } diff --git a/platform-api/pdk/kinds_test.go b/platform-api/pdk/kinds_test.go new file mode 100644 index 0000000000..a7b1e5e6e9 --- /dev/null +++ b/platform-api/pdk/kinds_test.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package pdk + +import ( + "testing" + + "github.com/wso2/api-platform/platform-api/internal/constants" +) + +// The pdk re-declares the artifact kinds because plugins live outside this module +// and cannot import internal packages. That makes them two copies of one value, so +// this pins them together: a kind renamed internally must be renamed here too, or +// every plugin silently starts naming a kind the platform no longer knows. +func TestKindConstantsMatchThePlatform(t *testing.T) { + for _, tc := range []struct { + name string + exported string + internal string + }{ + {"REST API", KindRestAPI, constants.RestApi}, + {"MCP proxy", KindMCPProxy, constants.MCPProxy}, + {"LLM proxy", KindLLMProxy, constants.LLMProxy}, + {"LLM provider", KindLLMProvider, constants.LLMProvider}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.exported != tc.internal { + t.Errorf("pdk has %q but the platform uses %q", tc.exported, tc.internal) + } + }) + } +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index b991cb2414..261bf423bd 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1802,6 +1802,202 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /llm-providers/{llmProviderId}/builds: + post: + summary: Prepare a build of a LLM provider + description: | + Renders the LLM provider's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + A LLM provider keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + + Access is validated against the organization in the JWT token. + operationId: CreateLLMProviderBuild + security: + - OAuth2Security: + - ap:llm_provider:build:create + - ap:llm_provider:build:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + schema: + type: string + description: Identifier of the LLM provider + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BuildRequest' + responses: + '201': + description: Build prepared successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: Get builds for a LLM provider + description: | + Lists the LLM provider's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. + Access is validated against the organization in the JWT token. + operationId: GetLLMProviderBuilds + security: + - OAuth2Security: + - ap:llm_provider:build:read + - ap:llm_provider:build:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + schema: + type: string + description: Identifier of the LLM provider + - $ref: '#/components/parameters/limit-Q' + responses: + '200': + description: Builds retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-providers/{llmProviderId}/builds/{buildId}: + get: + summary: Get build by ID + description: | + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetLLMProviderBuild + security: + - OAuth2Security: + - ap:llm_provider:build:read + - ap:llm_provider:build:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + schema: + type: string + description: Identifier of the LLM provider + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '200': + description: Build metadata retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete a build + description: | + Deletes one of the LLM provider's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + + Access is validated against the organization in the JWT token. + operationId: DeleteLLMProviderBuild + security: + - OAuth2Security: + - ap:llm_provider:build:delete + - ap:llm_provider:build:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + schema: + type: string + description: Identifier of the LLM provider + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '204': + description: Build deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /llm-providers/{llmProviderId}/deployments: post: summary: Create and deploy a new LLM provider deployment @@ -2351,16 +2547,191 @@ paths: - name: llmProxyId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM proxy + schema: + type: string + responses: + '200': + description: LLM proxy details + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProxy' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + summary: Update an existing LLM proxy + description: Update the configuration of an existing LLM proxy. + operationId: updateLLMProxy + security: + - OAuth2Security: + - ap:llm_proxy:update + - ap:llm_proxy:manage + tags: + - LLM Proxies + parameters: + - name: llmProxyId + in: path + required: true + description: Unique identifier of the LLM proxy + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProxy' + responses: + '200': + description: LLM proxy updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProxy' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete an LLM proxy + description: Remove an LLM proxy. + operationId: deleteLLMProxy + security: + - OAuth2Security: + - ap:llm_proxy:delete + - ap:llm_proxy:manage + tags: + - LLM Proxies + parameters: + - name: llmProxyId + in: path + required: true + description: Unique identifier of the LLM proxy + schema: + type: string + responses: + '204': + description: LLM proxy deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-proxies/{llmProxyId}/builds: + post: + summary: Prepare a build of a LLM proxy + description: | + Renders the LLM proxy's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + A LLM proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + + Access is validated against the organization in the JWT token. + operationId: CreateLLMProxyBuild + security: + - OAuth2Security: + - ap:llm_proxy:build:create + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage + tags: + - LLM Proxy Deployments + - Deployments + parameters: + - name: llmProxyId + in: path + required: true + schema: + type: string + description: Identifier of the LLM proxy + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BuildRequest' + responses: + '201': + description: Build prepared successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: Get builds for a LLM proxy + description: | + Lists the LLM proxy's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. + Access is validated against the organization in the JWT token. + operationId: GetLLMProxyBuilds + security: + - OAuth2Security: + - ap:llm_proxy:build:read + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage + tags: + - LLM Proxy Deployments + - Deployments + parameters: + - name: llmProxyId + in: path + required: true schema: type: string + description: Identifier of the LLM proxy + - $ref: '#/components/parameters/limit-Q' responses: '200': - description: LLM proxy details + description: Builds retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + $ref: '#/components/schemas/BuildListResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -2368,75 +2739,96 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update an existing LLM proxy - description: Update the configuration of an existing LLM proxy. - operationId: updateLLMProxy + /llm-proxies/{llmProxyId}/builds/{buildId}: + get: + summary: Get build by ID + description: | + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetLLMProxyBuild security: - OAuth2Security: - - ap:llm_proxy:update + - ap:llm_proxy:build:read + - ap:llm_proxy:build:manage - ap:llm_proxy:manage tags: - - LLM Proxies + - LLM Proxy Deployments + - Deployments parameters: - name: llmProxyId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LLMProxy' + description: Identifier of the LLM proxy + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build responses: '200': - description: LLM proxy updated successfully + description: Build metadata retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/BuildResponse' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete an LLM proxy - description: Remove an LLM proxy. - operationId: deleteLLMProxy + summary: Delete a build + description: | + Deletes one of the LLM proxy's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + + Access is validated against the organization in the JWT token. + operationId: DeleteLLMProxyBuild security: - OAuth2Security: - - ap:llm_proxy:delete + - ap:llm_proxy:build:delete + - ap:llm_proxy:build:manage - ap:llm_proxy:manage tags: - - LLM Proxies + - LLM Proxy Deployments + - Deployments parameters: - name: llmProxyId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string + description: Identifier of the LLM proxy + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build responses: '204': - description: LLM proxy deleted successfully - '400': - $ref: '#/components/responses/BadRequest' + description: Build deleted successfully '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -3038,6 +3430,202 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /mcp-proxies/{mcpProxyId}/builds: + post: + summary: Prepare a build of a MCP proxy + description: | + Renders the MCP proxy's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + A MCP proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + + Access is validated against the organization in the JWT token. + operationId: CreateMCPProxyBuild + security: + - OAuth2Security: + - ap:mcp_proxy:build:create + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage + tags: + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + schema: + type: string + description: Identifier of the MCP proxy + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BuildRequest' + responses: + '201': + description: Build prepared successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: Get builds for a MCP proxy + description: | + Lists the MCP proxy's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. + Access is validated against the organization in the JWT token. + operationId: GetMCPProxyBuilds + security: + - OAuth2Security: + - ap:mcp_proxy:build:read + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage + tags: + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + schema: + type: string + description: Identifier of the MCP proxy + - $ref: '#/components/parameters/limit-Q' + responses: + '200': + description: Builds retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /mcp-proxies/{mcpProxyId}/builds/{buildId}: + get: + summary: Get build by ID + description: | + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetMCPProxyBuild + security: + - OAuth2Security: + - ap:mcp_proxy:build:read + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage + tags: + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + schema: + type: string + description: Identifier of the MCP proxy + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '200': + description: Build metadata retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete a build + description: | + Deletes one of the MCP proxy's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + + Access is validated against the organization in the JWT token. + operationId: DeleteMCPProxyBuild + security: + - OAuth2Security: + - ap:mcp_proxy:build:delete + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage + tags: + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + schema: + type: string + description: Identifier of the MCP proxy + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '204': + description: Build deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /mcp-proxies/{mcpProxyId}/deployments: post: summary: Create and deploy a new deployment for MCP proxy @@ -4961,19 +5549,23 @@ components: ap:gateway:manage: Full access to gateways ap:gateway:manifest:read: Read a gateway manifest ap:gateway:read: Read gateways - ap:gateway_custom_policy:create: Create a gateway custom policy - ap:gateway_custom_policy:delete: Delete a gateway custom policy - ap:gateway_custom_policy:manage: Full access to gateway custom policies - ap:gateway_custom_policy:read: Read gateway custom policies ap:gateway:token:create: Create a gateway token ap:gateway:token:delete: Delete a gateway token ap:gateway:token:manage: Full access to gateway tokens ap:gateway:token:read: Read gateway tokens ap:gateway:update: Update a gateway + ap:gateway_custom_policy:create: Create a gateway custom policy + ap:gateway_custom_policy:delete: Delete a gateway custom policy + ap:gateway_custom_policy:manage: Full access to gateway custom policies + ap:gateway_custom_policy:read: Read gateway custom policies ap:llm_provider:api_key:create: Create an LLM provider API key ap:llm_provider:api_key:delete: Delete an LLM provider API key ap:llm_provider:api_key:manage: Full access to LLM provider API keys ap:llm_provider:api_key:read: Read LLM provider API keys + ap:llm_provider:build:create: Prepare a build of an LLM provider + ap:llm_provider:build:delete: Delete a build of an LLM provider + ap:llm_provider:build:manage: Full access to an LLM provider's builds + ap:llm_provider:build:read: Read builds of an LLM provider ap:llm_provider:create: Create an LLM provider ap:llm_provider:delete: Delete an LLM provider ap:llm_provider:deployment:create: Deploy an LLM provider @@ -4989,6 +5581,10 @@ components: ap:llm_proxy:api_key:delete: Delete an LLM proxy API key ap:llm_proxy:api_key:manage: Full access to LLM proxy API keys ap:llm_proxy:api_key:read: Read LLM proxy API keys + ap:llm_proxy:build:create: Prepare a build of an LLM proxy + ap:llm_proxy:build:delete: Delete a build of an LLM proxy + ap:llm_proxy:build:manage: Full access to an LLM proxy's builds + ap:llm_proxy:build:read: Read builds of an LLM proxy ap:llm_proxy:create: Create an LLM proxy ap:llm_proxy:delete: Delete an LLM proxy ap:llm_proxy:deployment:create: Deploy an LLM proxy @@ -5005,6 +5601,10 @@ components: ap:llm_template:manage: Full access to LLM provider templates ap:llm_template:read: Read LLM provider templates ap:llm_template:update: Update an LLM provider template + ap:mcp_proxy:build:create: Prepare a build of an MCP proxy + ap:mcp_proxy:build:delete: Delete a build of an MCP proxy + ap:mcp_proxy:build:manage: Full access to an MCP proxy's builds + ap:mcp_proxy:build:read: Read builds of an MCP proxy ap:mcp_proxy:create: Create an MCP proxy ap:mcp_proxy:delete: Delete an MCP proxy ap:mcp_proxy:deployment:create: Deploy an MCP proxy @@ -6893,11 +7493,13 @@ components: - `current` — render the artifact from the definition as it stands now. - `build` — deploy a build prepared earlier, named by `buildId`. - REST API deployments accept only these two and always run a build: `current` - stores what it renders as one, so a running deployment is always traceable to - a stored snapshot. MCP proxy, LLM and event API deployments accept a - `deploymentId` here as well, to promote that deployment by reusing its - rendered artifact. + These are the only two values, for REST APIs, MCP proxies, LLM providers and + LLM proxies alike. Every deployment runs a build: `current` stores what it + renders as one, so a running deployment is always traceable to a stored + snapshot, and promoting carries that snapshot rather than re-rendering it. + + A `deploymentId` is no longer accepted here — see the note on this + operation. example: "current" buildId: type: string