Skip to content

Commit abad95f

Browse files
authored
ROX-31479: Add fetching of images for deployments (#21)
Assisted-by: Claude Code
1 parent 8000112 commit abad95f

File tree

2 files changed

+361
-17
lines changed

2 files changed

+361
-17
lines changed

internal/toolsets/vulnerability/tools.go

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"strings"
7+
"sync"
78

89
"github.com/google/jsonschema-go/jsonschema"
910
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -30,11 +31,12 @@ const (
3031

3132
// getDeploymentsForCVEInput defines the input parameters for get_deployments_for_cve tool.
3233
type getDeploymentsForCVEInput struct {
33-
CVEName string `json:"cveName"`
34-
FilterClusterID string `json:"filterClusterId,omitempty"`
35-
FilterNamespace string `json:"filterNamespace,omitempty"`
36-
FilterPlatform filterPlatformType `json:"filterPlatform,omitempty"`
37-
Cursor string `json:"cursor,omitempty"`
34+
CVEName string `json:"cveName"`
35+
FilterClusterID string `json:"filterClusterId,omitempty"`
36+
FilterNamespace string `json:"filterNamespace,omitempty"`
37+
FilterPlatform filterPlatformType `json:"filterPlatform,omitempty"`
38+
IncludeAffectedImages bool `json:"includeAffectedImages,omitempty"`
39+
Cursor string `json:"cursor,omitempty"`
3840
}
3941

4042
func (input *getDeploymentsForCVEInput) validate() error {
@@ -45,12 +47,16 @@ func (input *getDeploymentsForCVEInput) validate() error {
4547
return nil
4648
}
4749

48-
// DeploymentResult contains deployment information.
50+
// DeploymentResult contains deployment information with optional image data.
4951
type DeploymentResult struct {
50-
Name string `json:"name"`
51-
Namespace string `json:"namespace"`
52-
ClusterID string `json:"clusterId"`
53-
ClusterName string `json:"clusterName"`
52+
id string
53+
54+
Name string `json:"name"`
55+
Namespace string `json:"namespace"`
56+
ClusterID string `json:"clusterId"`
57+
ClusterName string `json:"clusterName"`
58+
AffectedImages []string `json:"affectedImages,omitempty"`
59+
ImageFetchError string `json:"imageFetchError,omitempty"`
5460
}
5561

5662
// getDeploymentsForCVEOutput defines the output structure for get_deployments_for_cve tool.
@@ -118,6 +124,11 @@ func getDeploymentsForCVEInputSchema() *jsonschema.Schema {
118124
filterPlatformPlatform,
119125
}
120126

127+
schema.Properties["includeAffectedImages"].Description =
128+
"Whether to include affected image names for each deployment.\n" +
129+
"WARNING: This may significantly increase response time."
130+
schema.Properties["includeAffectedImages"].Default = toolsets.MustJSONMarshal(false)
131+
121132
schema.Properties["cursor"].Description = "Cursor for next page provided by server"
122133

123134
return schema
@@ -168,7 +179,84 @@ func getCursor(input *getDeploymentsForCVEInput) (*cursor.Cursor, error) {
168179
return currCursor, nil
169180
}
170181

182+
const defaultMaxFetchImageConcurrency = 10
183+
184+
// deploymentEnricher handles parallel enrichment of deployments with image data.
185+
type deploymentEnricher struct {
186+
imageClient v1.ImageServiceClient
187+
cveName string
188+
semaphore chan struct{}
189+
wg sync.WaitGroup
190+
}
191+
192+
// newDeploymentEnricher creates a new enricher with max concurrency limit.
193+
func newDeploymentEnricher(
194+
imageClient v1.ImageServiceClient,
195+
cveName string,
196+
maxConcurrency int,
197+
) *deploymentEnricher {
198+
return &deploymentEnricher{
199+
imageClient: imageClient,
200+
cveName: cveName,
201+
semaphore: make(chan struct{}, maxConcurrency),
202+
}
203+
}
204+
205+
// enrich enriches a single deployment result with image data in a goroutine.
206+
// Must be called before wait().
207+
func (e *deploymentEnricher) enrich(
208+
ctx context.Context,
209+
deployment *DeploymentResult,
210+
) {
211+
e.wg.Go(func() {
212+
e.semaphore <- struct{}{}
213+
214+
defer func() { <-e.semaphore }()
215+
216+
// Enrich the result in-place.
217+
images, err := fetchImagesForDeployment(ctx, e.imageClient, deployment, e.cveName)
218+
if err != nil {
219+
deployment.ImageFetchError = err.Error()
220+
221+
return
222+
}
223+
224+
deployment.AffectedImages = images
225+
})
226+
}
227+
228+
// wait waits for all enrichment workers to complete.
229+
func (e *deploymentEnricher) wait() {
230+
e.wg.Wait()
231+
}
232+
233+
// fetchImagesForDeployment fetches images for a single deployment.
234+
// It queries the images API filtered by CVE and Deployment ID.
235+
func fetchImagesForDeployment(
236+
ctx context.Context,
237+
imageClient v1.ImageServiceClient,
238+
deployment *DeploymentResult,
239+
cveName string,
240+
) ([]string, error) {
241+
query := fmt.Sprintf("CVE:%q+Deployment ID:%q", cveName, deployment.id)
242+
243+
resp, err := imageClient.ListImages(ctx, &v1.RawQuery{Query: query})
244+
if err != nil {
245+
return nil, errors.Wrapf(err, "failed to fetch images for deployment %q in namespace %q",
246+
deployment.Name, deployment.Namespace)
247+
}
248+
249+
images := make([]string, 0, len(resp.GetImages()))
250+
for _, img := range resp.GetImages() {
251+
images = append(images, img.GetName())
252+
}
253+
254+
return images, nil
255+
}
256+
171257
// handle is the handler for get_deployments_for_cve tool.
258+
//
259+
//nolint:funlen
172260
func (t *getDeploymentsForCVETool) handle(
173261
ctx context.Context,
174262
req *mcp.CallToolRequest,
@@ -205,14 +293,28 @@ func (t *getDeploymentsForCVETool) handle(
205293
return nil, nil, client.NewError(err, "ListDeployments")
206294
}
207295

208-
deployments := make([]DeploymentResult, 0, len(resp.GetDeployments()))
209-
for _, deployment := range resp.GetDeployments() {
210-
deployments = append(deployments, DeploymentResult{
296+
rawDeployments := resp.GetDeployments()
297+
298+
deployments := make([]DeploymentResult, len(rawDeployments))
299+
for i, deployment := range rawDeployments {
300+
deployments[i] = DeploymentResult{
301+
id: deployment.GetId(),
211302
Name: deployment.GetName(),
212303
Namespace: deployment.GetNamespace(),
213304
ClusterID: deployment.GetClusterId(),
214305
ClusterName: deployment.GetCluster(),
215-
})
306+
}
307+
}
308+
309+
if input.IncludeAffectedImages {
310+
imageClient := v1.NewImageServiceClient(conn)
311+
enricher := newDeploymentEnricher(imageClient, input.CVEName, defaultMaxFetchImageConcurrency)
312+
313+
for i := range deployments {
314+
enricher.enrich(callCtx, &deployments[i])
315+
}
316+
317+
enricher.wait()
216318
}
217319

218320
// We always fetch limit+1 - if we do not have one additional element we can end paging.

0 commit comments

Comments
 (0)