Skip to content

Commit 890ab5e

Browse files
lujunsandmjb
andauthored
Add a new POST edit Workload endpoint and update the GET (#1392)
Signed-off-by: lujunsan <[email protected]> Co-authored-by: Don Browne <[email protected]>
1 parent 6a6e17c commit 890ab5e

File tree

1 file changed

+211
-63
lines changed

1 file changed

+211
-63
lines changed

pkg/api/v1/workloads.go

Lines changed: 211 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func WorkloadRouter(
6060
r.Post("/restart", routes.restartWorkloadsBulk)
6161
r.Post("/delete", routes.deleteWorkloadsBulk)
6262
r.Get("/{name}", routes.getWorkload)
63+
r.Post("/{name}/edit", routes.updateWorkload)
6364
r.Post("/{name}/stop", routes.stopWorkload)
6465
r.Post("/{name}/restart", routes.restartWorkload)
6566
r.Get("/{name}/logs", routes.getLogsForWorkload)
@@ -124,14 +125,15 @@ func (s *WorkloadRoutes) listWorkloads(w http.ResponseWriter, r *http.Request) {
124125
// @Tags workloads
125126
// @Produce json
126127
// @Param name path string true "Workload name"
127-
// @Success 200 {object} core.Workload
128+
// @Success 200 {object} createRequest
128129
// @Failure 404 {string} string "Not Found"
129130
// @Router /api/v1beta/workloads/{name} [get]
130131
func (s *WorkloadRoutes) getWorkload(w http.ResponseWriter, r *http.Request) {
131132
ctx := r.Context()
132133
name := chi.URLParam(r, "name")
133134

134-
workload, err := s.workloadManager.GetWorkload(ctx, name)
135+
// Check if workload exists first
136+
_, err := s.workloadManager.GetWorkload(ctx, name)
135137
if err != nil {
136138
if errors.Is(err, runtime.ErrWorkloadNotFound) {
137139
http.Error(w, "Workload not found", http.StatusNotFound)
@@ -145,10 +147,19 @@ func (s *WorkloadRoutes) getWorkload(w http.ResponseWriter, r *http.Request) {
145147
return
146148
}
147149

148-
w.Header().Set("Content-Type", "application/json")
149-
err = json.NewEncoder(w).Encode(workload)
150+
// Load the workload configuration
151+
runConfig, err := runner.LoadState(ctx, name)
150152
if err != nil {
151-
http.Error(w, "Failed to marshal workload details", http.StatusInternalServerError)
153+
logger.Errorf("Failed to load workload configuration for %s: %v", name, err)
154+
http.Error(w, "Workload configuration not found", http.StatusNotFound)
155+
return
156+
}
157+
158+
config := runConfigToCreateRequest(runConfig)
159+
160+
w.Header().Set("Content-Type", "application/json")
161+
if err := json.NewEncoder(w).Encode(config); err != nil {
162+
http.Error(w, "Failed to marshal workload configuration", http.StatusInternalServerError)
152163
return
153164
}
154165
}
@@ -258,23 +269,6 @@ func (s *WorkloadRoutes) createWorkload(w http.ResponseWriter, r *http.Request)
258269
return
259270
}
260271

261-
// Fetch or build the requested image
262-
// TODO: Make verification configurable and return errors over the API.
263-
imageURL, imageMetadata, err := retriever.GetMCPServer(
264-
ctx,
265-
req.Image,
266-
"", // We do not let the user specify a CA cert path here.
267-
retriever.VerifyImageWarn,
268-
)
269-
if err != nil {
270-
if errors.Is(err, retriever.ErrImageNotFound) {
271-
http.Error(w, "MCP server image not found", http.StatusNotFound)
272-
} else {
273-
http.Error(w, fmt.Sprintf("Failed to retrieve MCP server image: %v", err), http.StatusInternalServerError)
274-
}
275-
return
276-
}
277-
278272
// check if the workload already exists
279273
if req.Name != "" {
280274
exists, err := s.workloadManager.DoesWorkloadExist(ctx, req.Name)
@@ -288,54 +282,92 @@ func (s *WorkloadRoutes) createWorkload(w http.ResponseWriter, r *http.Request)
288282
}
289283
}
290284

291-
// NOTE: None of the k8s-related config logic is included here.
292-
runSecrets := secrets.SecretParametersToCLI(req.Secrets)
293-
runConfig, err := runner.NewRunConfigBuilder().
294-
WithRuntime(s.containerRuntime).
295-
WithCmdArgs(req.CmdArguments).
296-
WithName(req.Name).
297-
WithImage(imageURL).
298-
WithHost(req.Host).
299-
WithTargetHost(transport.LocalhostIPv4).
300-
WithDebug(s.debugMode).
301-
WithVolumes(req.Volumes).
302-
WithSecrets(runSecrets).
303-
WithAuthzConfigPath(req.AuthzConfig).
304-
WithAuditConfigPath("").
305-
WithPermissionProfile(req.PermissionProfile).
306-
WithNetworkIsolation(req.NetworkIsolation).
307-
WithK8sPodPatch("").
308-
WithProxyMode(types.ProxyMode(req.ProxyMode)).
309-
WithTransportAndPorts(req.Transport, 0, req.TargetPort).
310-
WithAuditEnabled(false, "").
311-
WithOIDCConfig(req.OIDC.Issuer, req.OIDC.Audience, req.OIDC.JwksURL, req.OIDC.ClientID,
312-
"", "", "", "", "", false). // JWKS auth parameters not exposed through API yet
313-
WithTelemetryConfig("", false, "", 0.0, nil, false, nil). // Not exposed through API yet.
314-
WithToolsFilter(req.ToolsFilter).
315-
Build(ctx, imageMetadata, req.EnvVars, &runner.DetachedEnvVarValidator{})
285+
// Create the workload using shared logic
286+
runConfig, err := s.createWorkloadFromRequest(ctx, &req)
316287
if err != nil {
317-
logger.Errorf("Failed to create run config: %v", err)
318-
http.Error(w, "Failed to create run config", http.StatusBadRequest)
288+
// Error messages already logged in createWorkloadFromRequest
289+
if errors.Is(err, retriever.ErrImageNotFound) || err.Error() == "MCP server image not found" {
290+
http.Error(w, err.Error(), http.StatusNotFound)
291+
} else {
292+
http.Error(w, err.Error(), http.StatusInternalServerError)
293+
}
319294
return
320295
}
321296

322-
if err := runConfig.SaveState(ctx); err != nil {
323-
logger.Errorf("Failed to save workload config: %v", err)
324-
http.Error(w, "Failed to save workload config", http.StatusInternalServerError)
297+
// Return name so that the client will get the auto-generated name.
298+
w.Header().Set("Content-Type", "application/json")
299+
w.WriteHeader(http.StatusCreated)
300+
resp := createWorkloadResponse{
301+
Name: runConfig.ContainerName,
302+
Port: runConfig.Port,
303+
}
304+
if err = json.NewEncoder(w).Encode(resp); err != nil {
305+
http.Error(w, "Failed to marshal workload details", http.StatusInternalServerError)
325306
return
326307
}
308+
}
309+
310+
// updateWorkload
311+
//
312+
// @Summary Update workload
313+
// @Description Update an existing workload configuration
314+
// @Tags workloads
315+
// @Accept json
316+
// @Produce json
317+
// @Param name path string true "Workload name"
318+
// @Param request body updateRequest true "Update workload request"
319+
// @Success 200 {object} createWorkloadResponse
320+
// @Failure 400 {string} string "Bad Request"
321+
// @Failure 404 {string} string "Not Found"
322+
// @Router /api/v1beta/workloads/{name}/edit [post]
323+
func (s *WorkloadRoutes) updateWorkload(w http.ResponseWriter, r *http.Request) {
324+
ctx := r.Context()
325+
name := chi.URLParam(r, "name")
327326

328-
// Start workload with specified RunConfig.
329-
err = s.workloadManager.RunWorkloadDetached(ctx, runConfig)
327+
// Parse request body
328+
var updateReq updateRequest
329+
if err := json.NewDecoder(r.Body).Decode(&updateReq); err != nil {
330+
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
331+
return
332+
}
333+
334+
// Check if workload exists
335+
_, err := s.workloadManager.GetWorkload(ctx, name)
330336
if err != nil {
331-
logger.Errorf("Failed to start workload: %v", err)
332-
http.Error(w, "Failed to start workload", http.StatusInternalServerError)
337+
logger.Errorf("Failed to get workload: %v", err)
338+
http.Error(w, "Workload not found", http.StatusNotFound)
333339
return
334340
}
335341

336-
// Return name so that the client will get the auto-generated name.
342+
// Convert updateRequest to createRequest with the existing workload name
343+
createReq := createRequest{
344+
updateRequest: updateReq,
345+
Name: name, // Use the name from URL path, not from request body
346+
}
347+
348+
// Stop the existing workload
349+
if _, err = s.workloadManager.StopWorkloads(ctx, []string{name}); err != nil {
350+
logger.Errorf("Failed to stop workload %s: %v", name, err)
351+
http.Error(w, "Failed to stop workload", http.StatusInternalServerError)
352+
return
353+
}
354+
355+
// Delete the existing workload
356+
if _, err = s.workloadManager.DeleteWorkloads(ctx, []string{name}); err != nil {
357+
logger.Errorf("Failed to delete workload %s: %v", name, err)
358+
http.Error(w, "Failed to delete workload", http.StatusInternalServerError)
359+
return
360+
}
361+
362+
// Create the new workload using shared logic
363+
runConfig, err := s.createWorkloadFromRequest(ctx, &createReq)
364+
if err != nil {
365+
http.Error(w, err.Error(), http.StatusInternalServerError)
366+
return
367+
}
368+
369+
// Return the same response format as create
337370
w.Header().Set("Content-Type", "application/json")
338-
w.WriteHeader(http.StatusCreated)
339371
resp := createWorkloadResponse{
340372
Name: runConfig.ContainerName,
341373
Port: runConfig.Port,
@@ -560,12 +592,10 @@ type workloadListResponse struct {
560592
Workloads []core.Workload `json:"workloads"`
561593
}
562594

563-
// createRequest represents the request to create a new workload
595+
// updateRequest represents the request to update an existing workload
564596
//
565-
// @Description Request to create a new workload
566-
type createRequest struct {
567-
// Name of the workload
568-
Name string `json:"name"`
597+
// @Description Request to update an existing workload (name cannot be changed)
598+
type updateRequest struct {
569599
// Docker image to use
570600
Image string `json:"image"`
571601
// Host to bind to
@@ -596,6 +626,15 @@ type createRequest struct {
596626
ToolsFilter []string `json:"tools"`
597627
}
598628

629+
// createRequest represents the request to create a new workload
630+
//
631+
// @Description Request to create a new workload
632+
type createRequest struct {
633+
updateRequest
634+
// Name of the workload
635+
Name string `json:"name"`
636+
}
637+
599638
// oidcOptions represents OIDC configuration options
600639
//
601640
// @Description OIDC configuration for workload authentication
@@ -670,3 +709,112 @@ func (s *WorkloadRoutes) getWorkloadNamesFromRequest(ctx context.Context, req bu
670709

671710
return workloadNames, nil
672711
}
712+
713+
// createWorkloadFromRequest creates a workload from a request
714+
func (s *WorkloadRoutes) createWorkloadFromRequest(ctx context.Context, req *createRequest) (*runner.RunConfig, error) {
715+
// Fetch or build the requested image
716+
imageURL, imageMetadata, err := retriever.GetMCPServer(
717+
ctx,
718+
req.Image,
719+
"", // We do not let the user specify a CA cert path here.
720+
retriever.VerifyImageWarn,
721+
)
722+
if err != nil {
723+
if errors.Is(err, retriever.ErrImageNotFound) {
724+
return nil, fmt.Errorf("MCP server image not found")
725+
}
726+
return nil, fmt.Errorf("failed to retrieve MCP server image: %v", err)
727+
}
728+
729+
// Build RunConfig
730+
runSecrets := secrets.SecretParametersToCLI(req.Secrets)
731+
runConfig, err := runner.NewRunConfigBuilder().
732+
WithRuntime(s.containerRuntime).
733+
WithCmdArgs(req.CmdArguments).
734+
WithName(req.Name).
735+
WithImage(imageURL).
736+
WithHost(req.Host).
737+
WithTargetHost(transport.LocalhostIPv4).
738+
WithDebug(s.debugMode).
739+
WithVolumes(req.Volumes).
740+
WithSecrets(runSecrets).
741+
WithAuthzConfigPath(req.AuthzConfig).
742+
WithAuditConfigPath("").
743+
WithPermissionProfile(req.PermissionProfile).
744+
WithNetworkIsolation(req.NetworkIsolation).
745+
WithK8sPodPatch("").
746+
WithProxyMode(types.ProxyMode(req.ProxyMode)).
747+
WithTransportAndPorts(req.Transport, 0, req.TargetPort).
748+
WithAuditEnabled(false, "").
749+
WithOIDCConfig(req.OIDC.Issuer, req.OIDC.Audience, req.OIDC.JwksURL, req.OIDC.ClientID,
750+
"", "", "", "", "", false).
751+
WithTelemetryConfig("", false, "", 0.0, nil, false, nil).
752+
WithToolsFilter(req.ToolsFilter).
753+
Build(ctx, imageMetadata, req.EnvVars, &runner.DetachedEnvVarValidator{})
754+
if err != nil {
755+
logger.Errorf("Failed to build run config: %v", err)
756+
return nil, fmt.Errorf("invalid configuration: %v", err)
757+
}
758+
759+
// Save the workload state
760+
if err := runConfig.SaveState(ctx); err != nil {
761+
logger.Errorf("Failed to save workload config: %v", err)
762+
return nil, fmt.Errorf("failed to save workload config")
763+
}
764+
765+
// Start workload
766+
if err := s.workloadManager.RunWorkloadDetached(ctx, runConfig); err != nil {
767+
logger.Errorf("Failed to start workload: %v", err)
768+
return nil, fmt.Errorf("failed to start workload")
769+
}
770+
771+
return runConfig, nil
772+
}
773+
774+
// runConfigToCreateRequest converts a RunConfig to createRequest for API responses
775+
func runConfigToCreateRequest(runConfig *runner.RunConfig) *createRequest {
776+
// Convert CLI secrets ([]string) back to SecretParameters
777+
secretParams := make([]secrets.SecretParameter, 0, len(runConfig.Secrets))
778+
for _, secretStr := range runConfig.Secrets {
779+
// Parse the CLI format: "<name>,target=<target>"
780+
if secretParam, err := secrets.ParseSecretParameter(secretStr); err == nil {
781+
secretParams = append(secretParams, secretParam)
782+
}
783+
// Ignore invalid secrets rather than failing the entire conversion
784+
}
785+
786+
// Get OIDC fields from RunConfig
787+
var oidcConfig oidcOptions
788+
if runConfig.OIDCConfig != nil {
789+
oidcConfig = oidcOptions{
790+
Issuer: runConfig.OIDCConfig.Issuer,
791+
Audience: runConfig.OIDCConfig.Audience,
792+
JwksURL: runConfig.OIDCConfig.JWKSURL,
793+
IntrospectionURL: runConfig.OIDCConfig.IntrospectionURL,
794+
ClientID: runConfig.OIDCConfig.ClientID,
795+
ClientSecret: runConfig.OIDCConfig.ClientSecret,
796+
}
797+
}
798+
799+
authzConfigPath := ""
800+
801+
return &createRequest{
802+
updateRequest: updateRequest{
803+
Image: runConfig.Image,
804+
Host: runConfig.Host,
805+
CmdArguments: runConfig.CmdArgs,
806+
TargetPort: runConfig.TargetPort,
807+
EnvVars: runConfig.EnvVars,
808+
Secrets: secretParams,
809+
Volumes: runConfig.Volumes,
810+
Transport: string(runConfig.Transport),
811+
AuthzConfig: authzConfigPath,
812+
OIDC: oidcConfig,
813+
PermissionProfile: runConfig.PermissionProfile,
814+
ProxyMode: string(runConfig.ProxyMode),
815+
NetworkIsolation: runConfig.IsolateNetwork,
816+
ToolsFilter: runConfig.ToolsFilter,
817+
},
818+
Name: runConfig.Name,
819+
}
820+
}

0 commit comments

Comments
 (0)