diff --git a/docs/server/docs.go b/docs/server/docs.go index 9ca280736f..9459ce1f7c 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1966,6 +1966,130 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult": { + "description": "Result is the upgrade-check outcome for the workload. It carries only\nmetadata (status, image references, drift) and never secret values.", + "properties": { + "candidate_image": { + "description": "CandidateImage is the image reference the registry currently reports.", + "type": "string" + }, + "config_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift" + }, + "current_image": { + "description": "CurrentImage is the image reference the workload is currently running.", + "type": "string" + }, + "env_var_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift" + }, + "reason": { + "description": "Reason provides additional context, primarily for StatusUnknown.", + "type": "string" + }, + "registry_server": { + "description": "RegistryServer is the registry entry name the workload was sourced from.\nEmpty when the workload is not registry-sourced.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus" + }, + "workload_name": { + "description": "WorkloadName is the name of the workload that was checked.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift": { + "description": "ConfigDrift describes posture differences (transport, permission profile)\nbetween the workload and the candidate registry entry.", + "properties": { + "permission_profile": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + }, + "transport": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift": { + "description": "EnvVarDrift describes environment variables the candidate registry entry\ndeclares that differ from the workload's current configuration.", + "properties": { + "added": { + "description": "Added lists environment variables the candidate declares that the\nworkload does not currently supply (via plain env vars or secrets).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + }, + "removed": { + "description": "Removed lists environment variables the workload supplies that the\ncandidate no longer declares. Populated on a best-effort basis; may be\nempty even when removals exist (forward-compatible field).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo": { + "properties": { + "default": { + "description": "Default is the candidate's default value. It is cleared (left empty)\nwhenever Secret is true: a secret env var's default could carry sensitive\ndata, and surfacing it in a drift report (which may be logged or returned\nover the API) would leak it. Non-secret defaults are safe to display.", + "type": "string" + }, + "description": { + "description": "Description is the human-readable purpose of the variable.", + "type": "string" + }, + "name": { + "description": "Name is the environment variable name.", + "type": "string" + }, + "required": { + "description": "Required indicates whether the candidate marks the variable as required.", + "type": "boolean" + }, + "secret": { + "description": "Secret indicates whether the variable holds sensitive data.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange": { + "description": "PermissionProfile is set when the candidate's permission profile differs\nfrom the workload's current profile.", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus": { + "description": "Status is the upgrade status for the workload.", + "enum": [ + "up-to-date", + "upgrade-available", + "not-registry-sourced", + "server-not-found", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "StatusUpToDate", + "StatusUpgradeAvailable", + "StatusNotRegistrySourced", + "StatusServerNotFound", + "StatusUnknown" + ] + }, "model.Argument": { "properties": { "choices": { @@ -3386,6 +3510,29 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.upgradeCheckBulkResponse": { + "description": "Results of checking multiple workloads for available upgrades", + "properties": { + "results": { + "description": "Results holds one upgrade-check outcome per scoped workload, in the order\nthe workloads were enumerated. Each entry carries only metadata and never\nsecret values.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "pkg_api_v1.upgradeCheckResponse": { + "description": "Result of checking a single workload for an available upgrade", + "properties": { + "result": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + } + }, + "type": "object" + }, "pkg_api_v1.validateSkillRequest": { "description": "Request to validate a skill definition", "properties": { @@ -6487,6 +6634,65 @@ const docTemplate = `{ ] } }, + "/api/v1beta/workloads/upgrade-check": { + "get": { + "description": "Check all workloads (optionally filtered by group) for newer\nimages available in their source registries. This is an offline\nmetadata comparison; it does not pull images. Secret values are\nnever returned.", + "parameters": [ + { + "description": "Include stopped workloads", + "in": "query", + "name": "all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Filter workloads by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Group not found" + } + }, + "summary": "Check workloads for available upgrades", + "tags": [ + "workloads" + ] + } + }, "/api/v1beta/workloads/{name}": { "delete": { "description": "Delete a workload asynchronously. Returns 202 Accepted immediately.\nThe deletion happens in the background. Poll the workload list to confirm deletion.", @@ -6943,6 +7149,58 @@ const docTemplate = `{ ] } }, + "/api/v1beta/workloads/{name}/upgrade-check": { + "get": { + "description": "Check whether a single workload has a newer image available in\nits source registry. This is an offline metadata comparison; it\ndoes not pull images. Secret values are never returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "summary": "Check a workload for an available upgrade", + "tags": [ + "workloads" + ] + } + }, "/health": { "get": { "description": "Check if the API is healthy", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 8127a52c45..ca7beee46b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1959,6 +1959,130 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult": { + "description": "Result is the upgrade-check outcome for the workload. It carries only\nmetadata (status, image references, drift) and never secret values.", + "properties": { + "candidate_image": { + "description": "CandidateImage is the image reference the registry currently reports.", + "type": "string" + }, + "config_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift" + }, + "current_image": { + "description": "CurrentImage is the image reference the workload is currently running.", + "type": "string" + }, + "env_var_drift": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift" + }, + "reason": { + "description": "Reason provides additional context, primarily for StatusUnknown.", + "type": "string" + }, + "registry_server": { + "description": "RegistryServer is the registry entry name the workload was sourced from.\nEmpty when the workload is not registry-sourced.", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus" + }, + "workload_name": { + "description": "WorkloadName is the name of the workload that was checked.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift": { + "description": "ConfigDrift describes posture differences (transport, permission profile)\nbetween the workload and the candidate registry entry.", + "properties": { + "permission_profile": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + }, + "transport": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift": { + "description": "EnvVarDrift describes environment variables the candidate registry entry\ndeclares that differ from the workload's current configuration.", + "properties": { + "added": { + "description": "Added lists environment variables the candidate declares that the\nworkload does not currently supply (via plain env vars or secrets).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + }, + "removed": { + "description": "Removed lists environment variables the workload supplies that the\ncandidate no longer declares. Populated on a best-effort basis; may be\nempty even when removals exist (forward-compatible field).", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo": { + "properties": { + "default": { + "description": "Default is the candidate's default value. It is cleared (left empty)\nwhenever Secret is true: a secret env var's default could carry sensitive\ndata, and surfacing it in a drift report (which may be logged or returned\nover the API) would leak it. Non-secret defaults are safe to display.", + "type": "string" + }, + "description": { + "description": "Description is the human-readable purpose of the variable.", + "type": "string" + }, + "name": { + "description": "Name is the environment variable name.", + "type": "string" + }, + "required": { + "description": "Required indicates whether the candidate marks the variable as required.", + "type": "boolean" + }, + "secret": { + "description": "Secret indicates whether the variable holds sensitive data.", + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange": { + "description": "PermissionProfile is set when the candidate's permission profile differs\nfrom the workload's current profile.", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus": { + "description": "Status is the upgrade status for the workload.", + "enum": [ + "up-to-date", + "upgrade-available", + "not-registry-sourced", + "server-not-found", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "StatusUpToDate", + "StatusUpgradeAvailable", + "StatusNotRegistrySourced", + "StatusServerNotFound", + "StatusUnknown" + ] + }, "model.Argument": { "properties": { "choices": { @@ -3379,6 +3503,29 @@ }, "type": "object" }, + "pkg_api_v1.upgradeCheckBulkResponse": { + "description": "Results of checking multiple workloads for available upgrades", + "properties": { + "results": { + "description": "Results holds one upgrade-check outcome per scoped workload, in the order\nthe workloads were enumerated. Each entry carries only metadata and never\nsecret values.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "pkg_api_v1.upgradeCheckResponse": { + "description": "Result of checking a single workload for an available upgrade", + "properties": { + "result": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult" + } + }, + "type": "object" + }, "pkg_api_v1.validateSkillRequest": { "description": "Request to validate a skill definition", "properties": { @@ -6480,6 +6627,65 @@ ] } }, + "/api/v1beta/workloads/upgrade-check": { + "get": { + "description": "Check all workloads (optionally filtered by group) for newer\nimages available in their source registries. This is an offline\nmetadata comparison; it does not pull images. Secret values are\nnever returned.", + "parameters": [ + { + "description": "Include stopped workloads", + "in": "query", + "name": "all", + "schema": { + "type": "boolean" + } + }, + { + "description": "Filter workloads by group name", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Group not found" + } + }, + "summary": "Check workloads for available upgrades", + "tags": [ + "workloads" + ] + } + }, "/api/v1beta/workloads/{name}": { "delete": { "description": "Delete a workload asynchronously. Returns 202 Accepted immediately.\nThe deletion happens in the background. Poll the workload list to confirm deletion.", @@ -6936,6 +7142,58 @@ ] } }, + "/api/v1beta/workloads/{name}/upgrade-check": { + "get": { + "description": "Check whether a single workload has a newer image available in\nits source registry. This is an offline metadata comparison; it\ndoes not pull images. Secret values are never returned.", + "parameters": [ + { + "description": "Workload name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.upgradeCheckResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Not Found" + } + }, + "summary": "Check a workload for an available upgrade", + "tags": [ + "workloads" + ] + } + }, "/health": { "get": { "description": "Check if the API is healthy", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 6bbded4bce..301dc33004 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1922,6 +1922,118 @@ components: WARNING: This should only be used for development/testing. type: boolean type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult: + description: |- + Result is the upgrade-check outcome for the workload. It carries only + metadata (status, image references, drift) and never secret values. + properties: + candidate_image: + description: CandidateImage is the image reference the registry currently + reports. + type: string + config_drift: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift' + current_image: + description: CurrentImage is the image reference the workload is currently + running. + type: string + env_var_drift: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift' + reason: + description: Reason provides additional context, primarily for StatusUnknown. + type: string + registry_server: + description: |- + RegistryServer is the registry entry name the workload was sourced from. + Empty when the workload is not registry-sourced. + type: string + status: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus' + workload_name: + description: WorkloadName is the name of the workload that was checked. + type: string + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.ConfigDrift: + description: |- + ConfigDrift describes posture differences (transport, permission profile) + between the workload and the candidate registry entry. + properties: + permission_profile: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange' + transport: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange' + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarDrift: + description: |- + EnvVarDrift describes environment variables the candidate registry entry + declares that differ from the workload's current configuration. + properties: + added: + description: |- + Added lists environment variables the candidate declares that the + workload does not currently supply (via plain env vars or secrets). + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo' + type: array + uniqueItems: false + removed: + description: |- + Removed lists environment variables the workload supplies that the + candidate no longer declares. Populated on a best-effort basis; may be + empty even when removals exist (forward-compatible field). + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo' + type: array + uniqueItems: false + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.EnvVarInfo: + properties: + default: + description: |- + Default is the candidate's default value. It is cleared (left empty) + whenever Secret is true: a secret env var's default could carry sensitive + data, and surfacing it in a drift report (which may be logged or returned + over the API) would leak it. Non-secret defaults are safe to display. + type: string + description: + description: Description is the human-readable purpose of the variable. + type: string + name: + description: Name is the environment variable name. + type: string + required: + description: Required indicates whether the candidate marks the variable + as required. + type: boolean + secret: + description: Secret indicates whether the variable holds sensitive data. + type: boolean + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.StringChange: + description: |- + PermissionProfile is set when the candidate's permission profile differs + from the workload's current profile. + properties: + from: + type: string + to: + type: string + type: object + github_com_stacklok_toolhive_pkg_workloads_upgrade.UpgradeStatus: + description: Status is the upgrade status for the workload. + enum: + - up-to-date + - upgrade-available + - not-registry-sourced + - server-not-found + - unknown + type: string + x-enum-varnames: + - StatusUpToDate + - StatusUpgradeAvailable + - StatusNotRegistrySourced + - StatusServerNotFound + - StatusUnknown model.Argument: properties: choices: @@ -2998,6 +3110,25 @@ components: description: Success message type: string type: object + pkg_api_v1.upgradeCheckBulkResponse: + description: Results of checking multiple workloads for available upgrades + properties: + results: + description: |- + Results holds one upgrade-check outcome per scoped workload, in the order + the workloads were enumerated. Each entry carries only metadata and never + secret values. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' + type: array + uniqueItems: false + type: object + pkg_api_v1.upgradeCheckResponse: + description: Result of checking a single workload for an available upgrade + properties: + result: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' + type: object pkg_api_v1.validateSkillRequest: description: Request to validate a skill definition properties: @@ -5283,6 +5414,41 @@ paths: summary: Stop a workload tags: - workloads + /api/v1beta/workloads/{name}/upgrade-check: + get: + description: |- + Check whether a single workload has a newer image available in + its source registry. This is an offline metadata comparison; it + does not pull images. Secret values are never returned. + parameters: + - description: Workload name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.upgradeCheckResponse' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Not Found + summary: Check a workload for an available upgrade + tags: + - workloads /api/v1beta/workloads/delete: post: description: |- @@ -5375,6 +5541,46 @@ paths: summary: Stop workloads in bulk tags: - workloads + /api/v1beta/workloads/upgrade-check: + get: + description: |- + Check all workloads (optionally filtered by group) for newer + images available in their source registries. This is an offline + metadata comparison; it does not pull images. Secret values are + never returned. + parameters: + - description: Include stopped workloads + in: query + name: all + schema: + type: boolean + - description: Filter workloads by group name + in: query + name: group + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.upgradeCheckBulkResponse' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "404": + content: + application/json: + schema: + type: string + description: Group not found + summary: Check workloads for available upgrades + tags: + - workloads /health: get: description: Check if the API is healthy diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index 180e4e5045..e2345a6625 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -16,6 +16,7 @@ import ( "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/transport/middleware" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" ) // workloadListResponse represents the response for listing workloads @@ -183,6 +184,25 @@ type oidcOptions struct { Scopes []string `json:"scopes,omitempty"` } +// upgradeCheckResponse is the response for a single-workload upgrade check. +// +// @Description Result of checking a single workload for an available upgrade +type upgradeCheckResponse struct { + // Result is the upgrade-check outcome for the workload. It carries only + // metadata (status, image references, drift) and never secret values. + Result *upgrade.CheckResult `json:"result"` +} + +// upgradeCheckBulkResponse is the response for a batch upgrade check. +// +// @Description Results of checking multiple workloads for available upgrades +type upgradeCheckBulkResponse struct { + // Results holds one upgrade-check outcome per scoped workload, in the order + // the workloads were enumerated. Each entry carries only metadata and never + // secret values. + Results []*upgrade.CheckResult `json:"results"` +} + // createWorkloadResponse represents the response for workload creation // // @Description Response after successfully creating a workload diff --git a/pkg/api/v1/workloads.go b/pkg/api/v1/workloads.go index f10c4eb9ec..3aac701e9d 100644 --- a/pkg/api/v1/workloads.go +++ b/pkg/api/v1/workloads.go @@ -18,6 +18,7 @@ import ( apierrors "github.com/stacklok/toolhive/pkg/api/errors" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/workloads" wt "github.com/stacklok/toolhive/pkg/workloads/types" @@ -41,6 +42,16 @@ type WorkloadRoutes struct { debugMode bool groupManager groups.Manager workloadService *WorkloadService + + // loadRunConfig loads a single workload's saved RunConfig. Injected so the + // upgrade-check handlers can be unit tested without the global state store. + loadRunConfig runConfigLoader + // listRunConfigNames lists all saved RunConfig names. Injected for the same + // testability reason as loadRunConfig. + listRunConfigNames runConfigLister + // registryProvider returns the registry provider used by upgrade checks. + // Injected so tests can supply a mock provider. + registryProvider registryProviderFunc } // @title ToolHive API @@ -64,11 +75,19 @@ func WorkloadRouter( ) routes := WorkloadRoutes{ - workloadManager: workloadManager, - containerRuntime: containerRuntime, - debugMode: debugMode, - groupManager: groupManager, - workloadService: workloadService, + workloadManager: workloadManager, + containerRuntime: containerRuntime, + debugMode: debugMode, + groupManager: groupManager, + workloadService: workloadService, + loadRunConfig: runner.LoadState, + listRunConfigNames: defaultRunConfigLister, + registryProvider: func() (registry.Provider, error) { + return registry.GetDefaultProviderWithConfig( + workloadService.configProvider, + registry.WithInteractive(false), + ) + }, } r := chi.NewRouter() @@ -80,6 +99,10 @@ func WorkloadRouter( r.With(stdTimeout).Post("/stop", apierrors.ErrorHandler(routes.stopWorkloadsBulk)) r.With(stdTimeout).Post("/restart", apierrors.ErrorHandler(routes.restartWorkloadsBulk)) r.With(stdTimeout).Post("/delete", apierrors.ErrorHandler(routes.deleteWorkloadsBulk)) + // Register the literal /upgrade-check before /{name} so chi routes it + // distinctly from the single-workload wildcard. + r.With(stdTimeout).Get("/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckBulk)) + r.With(stdTimeout).Get("/{name}/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckSingle)) r.With(stdTimeout).Get("/{name}", apierrors.ErrorHandler(routes.getWorkload)) r.With(longTimeout).Post("/{name}/edit", apierrors.ErrorHandler(routes.updateWorkload)) r.With(stdTimeout).Post("/{name}/stop", apierrors.ErrorHandler(routes.stopWorkload)) @@ -121,9 +144,18 @@ func (s *WorkloadRoutes) listWorkloads(w http.ResponseWriter, r *http.Request) e http.StatusBadRequest, ) } + // FilterByGroup silently returns an empty slice for an unknown group, + // so check existence explicitly to honor the documented 404. + exists, existsErr := s.groupManager.Exists(ctx, groupFilter) + if existsErr != nil { + return fmt.Errorf("failed to check group existence: %w", existsErr) + } + if !exists { + return fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupFilter) + } workloadList, err = workloads.FilterByGroup(workloadList, groupFilter) if err != nil { - return err // groups.ErrGroupNotFound already has 404 status code + return err } } diff --git a/pkg/api/v1/workloads_upgrade.go b/pkg/api/v1/workloads_upgrade.go new file mode 100644 index 0000000000..efa1022575 --- /dev/null +++ b/pkg/api/v1/workloads_upgrade.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/stacklok/toolhive-core/httperr" + groupval "github.com/stacklok/toolhive-core/validation/group" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/state" + "github.com/stacklok/toolhive/pkg/workloads" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +// runConfigLoader loads a single workload's saved RunConfig by name. +type runConfigLoader func(ctx context.Context, name string) (*runner.RunConfig, error) + +// runConfigLister lists the names of all saved RunConfigs. +type runConfigLister func(ctx context.Context) ([]string, error) + +// registryProviderFunc returns a registry provider for upgrade checks. +type registryProviderFunc func() (registry.Provider, error) + +// defaultRunConfigLister lists saved RunConfig names from the local state store. +// It mirrors the enumeration used by manager.ListWorkloadsUsingSecret. +func defaultRunConfigLister(ctx context.Context) ([]string, error) { + store, err := state.NewRunConfigStore(state.DefaultAppName) + if err != nil { + return nil, fmt.Errorf("failed to create state store: %w", err) + } + return store.List(ctx) +} + +// upgradeCheckSingle handles GET /workloads/{name}/upgrade-check. +// +// @Summary Check a workload for an available upgrade +// @Description Check whether a single workload has a newer image available in +// @Description its source registry. This is an offline metadata comparison; it +// @Description does not pull images. Secret values are never returned. +// @Tags workloads +// @Produce json +// @Param name path string true "Workload name" +// @Success 200 {object} upgradeCheckResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Router /api/v1beta/workloads/{name}/upgrade-check [get] +func (s *WorkloadRoutes) upgradeCheckSingle(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + name := chi.URLParam(r, "name") + + // Check if workload exists first (mirrors getWorkload's existence check). + if _, err := s.workloadManager.GetWorkload(ctx, name); err != nil { + return err // ErrWorkloadNotFound (404) or ErrInvalidWorkloadName (400) already have status codes + } + + runConfig, err := s.loadRunConfig(ctx, name) + if err != nil { + return httperr.WithCode( + fmt.Errorf("workload configuration not found: %w", err), + http.StatusNotFound, + ) + } + + checker, err := s.newUpgradeChecker() + if err != nil { + return err + } + + result, err := checker.Check(ctx, runConfig) + if err != nil { + return fmt.Errorf("failed to check workload for upgrade: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(upgradeCheckResponse{Result: result}); err != nil { + return fmt.Errorf("failed to marshal upgrade check result: %w", err) + } + return nil +} + +// upgradeCheckBulk handles GET /workloads/upgrade-check. +// +// @Summary Check workloads for available upgrades +// @Description Check all workloads (optionally filtered by group) for newer +// @Description images available in their source registries. This is an offline +// @Description metadata comparison; it does not pull images. Secret values are +// @Description never returned. +// @Tags workloads +// @Produce json +// @Param all query bool false "Include stopped workloads" +// @Param group query string false "Filter workloads by group name" +// @Success 200 {object} upgradeCheckBulkResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Group not found" +// @Router /api/v1beta/workloads/upgrade-check [get] +func (s *WorkloadRoutes) upgradeCheckBulk(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + listAll := r.URL.Query().Get("all") == "true" + groupFilter := r.URL.Query().Get("group") + + // Reuse the exact group/authorization scoping of listWorkloads so the batch + // endpoint can never report on workloads the caller cannot otherwise list. + workloadList, err := s.workloadManager.ListWorkloads(ctx, listAll) + if err != nil { + return fmt.Errorf("failed to list workloads: %w", err) + } + if groupFilter != "" { + if err := groupval.ValidateName(groupFilter); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid group name: %w", err), + http.StatusBadRequest, + ) + } + // FilterByGroup silently returns an empty slice for an unknown group, + // so check existence explicitly to honor the documented 404. + exists, existsErr := s.groupManager.Exists(ctx, groupFilter) + if existsErr != nil { + return fmt.Errorf("failed to check group existence: %w", existsErr) + } + if !exists { + return fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupFilter) + } + workloadList, err = workloads.FilterByGroup(workloadList, groupFilter) + if err != nil { + return err + } + } + + // Restrict the set of RunConfigs to those in the scoped workload list. + inScope := make(map[string]struct{}, len(workloadList)) + for _, wl := range workloadList { + inScope[wl.Name] = struct{}{} + } + + configNames, err := s.listRunConfigNames(ctx) + if err != nil { + return fmt.Errorf("failed to list workload configurations: %w", err) + } + + configs := make([]*runner.RunConfig, 0, len(inScope)) + for _, cfgName := range configNames { + if _, ok := inScope[cfgName]; !ok { + continue + } + runConfig, err := s.loadRunConfig(ctx, cfgName) + if err != nil { + // Skip configs we can't load; they may be corrupted or from an older + // version. The workload simply won't appear in the results. + slog.Debug("failed to load run config for upgrade check", "workload", cfgName, "error", err) + continue + } + configs = append(configs, runConfig) + } + + checker, err := s.newUpgradeChecker() + if err != nil { + return err + } + + results := checker.CheckAll(ctx, configs) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(upgradeCheckBulkResponse{Results: results}); err != nil { + return fmt.Errorf("failed to marshal upgrade check results: %w", err) + } + return nil +} + +// newUpgradeChecker builds an upgrade.Checker backed by the registry provider. +func (s *WorkloadRoutes) newUpgradeChecker() (*upgrade.Checker, error) { + provider, err := s.registryProvider() + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("failed to get registry provider: %w", err), + http.StatusServiceUnavailable, + ) + } + checker, err := upgrade.NewChecker(provider) + if err != nil { + return nil, fmt.Errorf("failed to create upgrade checker: %w", err) + } + return checker, nil +} diff --git a/pkg/api/v1/workloads_upgrade_test.go b/pkg/api/v1/workloads_upgrade_test.go new file mode 100644 index 0000000000..edb4c54001 --- /dev/null +++ b/pkg/api/v1/workloads_upgrade_test.go @@ -0,0 +1,471 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + regtypes "github.com/stacklok/toolhive-core/registry/types" + apierrors "github.com/stacklok/toolhive/pkg/api/errors" + "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/core" + groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/registry" + registrymocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/transport/types" + workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" + wt "github.com/stacklok/toolhive/pkg/workloads/types" + "github.com/stacklok/toolhive/pkg/workloads/upgrade" +) + +// upgradeTestRoutes builds a WorkloadRoutes wired with the supplied workload +// manager and registry provider plus in-memory loaders, so the upgrade-check +// handlers can run without touching the global state store or a real registry. +func upgradeTestRoutes( + wm *workloadsmocks.MockManager, + provider registry.Provider, + configs map[string]*runner.RunConfig, +) *WorkloadRoutes { + return &WorkloadRoutes{ + workloadManager: wm, + loadRunConfig: func(_ context.Context, name string) (*runner.RunConfig, error) { + cfg, ok := configs[name] + if !ok { + return nil, runtime.ErrWorkloadNotFound + } + return cfg, nil + }, + listRunConfigNames: func(_ context.Context) ([]string, error) { + names := make([]string, 0, len(configs)) + for n := range configs { + names = append(names, n) + } + return names, nil + }, + registryProvider: func() (registry.Provider, error) { + return provider, nil + }, + } +} + +// imageServer is a registry image entry fixture. +func imageServer(image string) *regtypes.ImageMetadata { + return ®types.ImageMetadata{Image: image} +} + +func TestUpgradeCheckSingle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workloadName string + setupMock func(*workloadsmocks.MockManager, *registrymocks.MockProvider) + configs map[string]*runner.RunConfig + expectedStatus int + expectedStatusBody upgrade.UpgradeStatus + expectBody string + }{ + { + name: "up-to-date", + workloadName: "fetch", + setupMock: func(wm *workloadsmocks.MockManager, p *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + p.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + }, + configs: map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + }, + expectedStatus: http.StatusOK, + expectedStatusBody: upgrade.StatusUpToDate, + }, + { + name: "upgrade-available", + workloadName: "fetch", + setupMock: func(wm *workloadsmocks.MockManager, p *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + p.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + }, + configs: map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + }, + expectedStatus: http.StatusOK, + expectedStatusBody: upgrade.StatusUpgradeAvailable, + }, + { + name: "workload not found", + workloadName: "missing", + setupMock: func(wm *workloadsmocks.MockManager, _ *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "missing"). + Return(core.Workload{}, runtime.ErrWorkloadNotFound) + }, + configs: map[string]*runner.RunConfig{}, + expectedStatus: http.StatusNotFound, + expectBody: "workload not found", + }, + { + name: "invalid workload name", + workloadName: "bad-name", + setupMock: func(wm *workloadsmocks.MockManager, _ *registrymocks.MockProvider) { + wm.EXPECT().GetWorkload(gomock.Any(), "bad-name"). + Return(core.Workload{}, wt.ErrInvalidWorkloadName) + }, + configs: map[string]*runner.RunConfig{}, + expectedStatus: http.StatusBadRequest, + expectBody: "invalid workload name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + tt.setupMock(wm, provider) + + routes := upgradeTestRoutes(wm, provider, tt.configs) + + req := httptest.NewRequest("GET", "/"+tt.workloadName+"/upgrade-check", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", tt.workloadName) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckSingle).ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + if tt.expectBody != "" { + assert.Contains(t, w.Body.String(), tt.expectBody) + return + } + + var resp upgradeCheckResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Result) + assert.Equal(t, tt.expectedStatusBody, resp.Result.Status) + assert.Equal(t, tt.workloadName, resp.Result.WorkloadName) + }) + } +} + +func TestUpgradeCheckBulk(t *testing.T) { + t.Parallel() + + t.Run("mixed results", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + {Name: "time"}, + {Name: "custom"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + provider.EXPECT().GetServer("io.github/time").Return(imageServer("ghcr.io/example/time:2.0.0"), nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "time": {Name: "time", Image: "ghcr.io/example/time:1.0.0", RegistryServerName: "io.github/time"}, + "custom": {Name: "custom", Image: "ghcr.io/example/custom:1.0.0"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 3) + + byName := map[string]*upgrade.CheckResult{} + for _, r := range resp.Results { + byName[r.WorkloadName] = r + } + assert.Equal(t, upgrade.StatusUpToDate, byName["fetch"].Status) + assert.Equal(t, upgrade.StatusUpgradeAvailable, byName["time"].Status) + assert.Equal(t, upgrade.StatusNotRegistrySourced, byName["custom"].Status) + }) + + t.Run("group filter scopes results", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // Listing returns two workloads in different groups; only the prod one + // survives FilterByGroup, so only its registry lookup occurs. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch", Group: "prod"}, + {Name: "time", Group: "dev"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + + gm := groupsmocks.NewMockManager(ctrl) + gm.EXPECT().Exists(gomock.Any(), "prod").Return(true, nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "time": {Name: "time", Image: "ghcr.io/example/time:1.0.0", RegistryServerName: "io.github/time"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + routes.groupManager = gm + + req := httptest.NewRequest("GET", "/upgrade-check?group=prod", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + assert.Equal(t, upgrade.StatusUpgradeAvailable, resp.Results[0].Status) + }) + + t.Run("unknown group returns 404", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // The group does not exist, so no per-workload registry lookup happens. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch", Group: "prod"}, + }, nil) + + gm := groupsmocks.NewMockManager(ctrl) + gm.EXPECT().Exists(gomock.Any(), "ghost").Return(false, nil) + + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + routes.groupManager = gm + + req := httptest.NewRequest("GET", "/upgrade-check?group=ghost", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("invalid group name", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{}, nil) + + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + + req := httptest.NewRequest("GET", "/upgrade-check?group=Invalid%20Group", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid group name") + }) + + t.Run("stale on-disk config absent from ListWorkloads is excluded", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // ListWorkloads reports only "fetch". "orphan" is a stale on-disk config + // that the manager no longer lists. The inScope intersection must drop it, + // and the provider must never be queried for the orphan's registry server + // (mock strictness fails the test if GetServer("io.github/orphan") fires). + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.0.0"), nil) + + configs := map[string]*runner.RunConfig{ + "fetch": {Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"}, + "orphan": {Name: "orphan", Image: "ghcr.io/example/orphan:1.0.0", RegistryServerName: "io.github/orphan"}, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + for _, r := range resp.Results { + assert.NotEqual(t, "orphan", r.WorkloadName, "stale on-disk config must not appear in results") + } + }) + + t.Run("unloadable in-scope config is skipped, not fatal", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + // Both workloads are in scope, but "broken" fails to load. The batch must + // still succeed and return a result for the loadable workload. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{ + {Name: "fetch"}, + {Name: "broken"}, + }, nil) + provider.EXPECT().GetServer("io.github/fetch").Return(imageServer("ghcr.io/example/fetch:1.1.0"), nil) + + fetchCfg := &runner.RunConfig{Name: "fetch", Image: "ghcr.io/example/fetch:1.0.0", RegistryServerName: "io.github/fetch"} + routes := &WorkloadRoutes{ + workloadManager: wm, + loadRunConfig: func(_ context.Context, name string) (*runner.RunConfig, error) { + if name == "broken" { + return nil, errors.New("corrupted run config") + } + return fetchCfg, nil + }, + listRunConfigNames: func(_ context.Context) ([]string, error) { + return []string{"fetch", "broken"}, nil + }, + registryProvider: func() (registry.Provider, error) { return provider, nil }, + } + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckBulk).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp upgradeCheckBulkResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Results, 1) + assert.Equal(t, "fetch", resp.Results[0].WorkloadName) + assert.Equal(t, upgrade.StatusUpgradeAvailable, resp.Results[0].Status) + }) +} + +// TestUpgradeCheckNoSecretLeak asserts the upgrade-check response carries only +// metadata and never any secret values from the workload's RunConfig. +func TestUpgradeCheckNoSecretLeak(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + provider := registrymocks.NewMockProvider(ctrl) + + wm.EXPECT().GetWorkload(gomock.Any(), "fetch").Return(core.Workload{Name: "fetch"}, nil) + // Candidate declares a secret env var with a default; the drift report must + // surface the name but never the secret default value. + provider.EXPECT().GetServer("io.github/fetch").Return(®types.ImageMetadata{ + Image: "ghcr.io/example/fetch:1.1.0", + EnvVars: []*regtypes.EnvVar{ + {Name: "API_TOKEN", Secret: true, Required: true, Default: "super-secret-default"}, + }, + }, nil) + + configs := map[string]*runner.RunConfig{ + "fetch": { + Name: "fetch", + Image: "ghcr.io/example/fetch:1.0.0", + RegistryServerName: "io.github/fetch", + Transport: types.TransportTypeStdio, + EnvVars: map[string]string{"PLAIN": "value"}, + Secrets: []string{"my-vault-secret,target=OTHER_TOKEN"}, + }, + } + routes := upgradeTestRoutes(wm, provider, configs) + + req := httptest.NewRequest("GET", "/fetch/upgrade-check", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "fetch") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.upgradeCheckSingle).ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + body := w.Body.String() + assert.NotContains(t, body, "super-secret-default", "secret env var default must not leak") + assert.NotContains(t, body, "my-vault-secret", "secret parameter name must not leak") + assert.NotContains(t, body, "OTHER_TOKEN", "secret target must not leak") + // The drift report should still name the missing candidate env var. + assert.Contains(t, body, "API_TOKEN") +} + +// TestUpgradeCheckRouting asserts /upgrade-check resolves to the batch handler +// and is not captured by the /{name} wildcard (chi static-before-wildcard +// ordering), while /{name}/upgrade-check resolves to the single handler. +func TestUpgradeCheckRouting(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + wm := workloadsmocks.NewMockManager(ctrl) + + // The batch handler lists workloads; getWorkload would instead call + // GetWorkload("upgrade-check"). Asserting ListWorkloads is hit proves the + // literal route won, not the wildcard. + wm.EXPECT().ListWorkloads(gomock.Any(), false).Return([]core.Workload{}, nil) + + provider := registrymocks.NewMockProvider(ctrl) + routes := upgradeTestRoutes(wm, provider, map[string]*runner.RunConfig{}) + + router := newUpgradeTestRouter(routes) + + req := httptest.NewRequest("GET", "/upgrade-check", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// newUpgradeTestRouter mounts only the routes relevant to ordering so the test +// exercises chi's real matching between /upgrade-check and /{name}. +func newUpgradeTestRouter(routes *WorkloadRoutes) chi.Router { + r := chi.NewRouter() + r.Get("/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckBulk)) + r.Get("/{name}/upgrade-check", apierrors.ErrorHandler(routes.upgradeCheckSingle)) + r.Get("/{name}", apierrors.ErrorHandler(routes.getWorkload)) + return r +}