Skip to content

Commit 79aeb54

Browse files
CopilotwilltsaiCopilot
authored
Register getGraph custom action for Radius.Core/applications (#11796)
# Description `POST .../providers/Radius.Core/applications/{app}/getGraph` returned 404 because the namespace builder for `Radius.Core` never wired the `getGraph` custom action — only `Applications.Core/applications` did. The OpenAPI route exists but had no controller, blocking app graph rendering in the dashboard. - **New controller**: `GetGraphv20250801preview` in `pkg/corerp/frontend/controller/applications/v20250801preview/getgraph.go` (package `v20250801preview`). Placed in a `v20250801preview/` subpackage to match the convention established by `pkg/corerp/frontend/controller/environments/v20250801preview/`. Typed against `datamodel.Application_v20250801preview` and uses the `Application20250801*` converters. - **Shared helper**: Extracted a `ComputeGraphResponse` exported helper in the parent `pkg/corerp/frontend/controller/applications` package so both the `Applications.Core` and `Radius.Core` getGraph controllers reuse the same graph-listing logic (resource type discovery, application-/environment-scoped listing, and graph computation) without duplication. - **Stricter validation**: `ComputeGraphResponse` uses `resources.ParseResource` (instead of `resources.Parse`) to validate the environment ID is a named resource, rejecting collection or custom-action IDs. The error is wrapped with `fmt.Errorf` for context. - **Wiring**: `SetupRadiusCoreNamespace` in `pkg/corerp/setup/setup.go` now registers `Custom["getGraph"]` on the `applications` resource, analogous to the `Applications.Core/applications` registration. - **Tests**: Unit test for the not-found path on the new controller, plus a router assertion in `setup_test.go` that `POST .../radius.core/applications/app0/getgraph` resolves to `ACTIONGETGRAPH`. Additional tests targeting the lines flagged by Codecov: - `ComputeGraphResponse` invalid-environment-ID error path. - `ComputeGraphResponse` UCP error-propagation path, driven by an `httptest` UCP server returning 500 on `/planes/radius/local/providers`. - `ComputeGraphResponse` `listAllResourcesByApplication` error path (lines 62-63), driven by an `httptest` server returning a resource type then failing on `GetProviderSummary`. - `ComputeGraphResponse` `listAllResourcesByEnvironment` error path (lines 67-68), driven by an `httptest` server where application-scoped listing succeeds but environment-scoped listing returns 500. - Database error path through `GetGraph.Run` and `GetGraphv20250801preview.Run` (non-`NotFound` `Get` error is propagated). - End-to-end success path through both `Run` methods into `ComputeGraphResponse`, driven by an `httptest` UCP server returning an empty providers list to yield an empty graph. Coverage on the new shared helper rose from 0% to ~87.5%, both `Run` methods are at 100%, and the `v20250801preview` getGraph package is at 100%. ```go Custom: map[string]builder.Operation[datamodel.Application_v20250801preview]{ "getGraph": { APIController: func(opt apictrl.Options) (apictrl.Controller, error) { return app_v20250801_ctrl.NewGetGraphv20250801preview(opt, *recipeControllerConfig.UCPConnection) }, }, }, ``` ## Type of change - This pull request fixes a bug in Radius and has an approved issue (issue link required). ## Contributor checklist Please verify that the PR meets the following requirements, where applicable: - An overview of proposed schema changes is included in a linked GitHub issue. - [ ] Yes - [x] Not applicable - A design document is added or updated under `eng/design-notes/` in this repository, if new APIs are being introduced. - [ ] Yes - [x] Not applicable - The design document has been reviewed and approved by Radius maintainers/approvers. - [ ] Yes - [x] Not applicable - A PR for [resource-types-contrib](https://github.com/radius-project/resource-types-contrib/) is created, if resource types or recipes are affected by the changes in this PR. - [ ] Yes - [x] Not applicable - A PR for [dashboard](https://github.com/radius-project/dashboard/) is created, if the Radius Dashboard is affected by the changes in this PR. - [ ] Yes - [x] Not applicable - A PR for the [documentation repository](https://github.com/radius-project/docs) is created, if the changes in this PR affect the documentation or any user facing updates are made. - [ ] Yes - [x] Not applicable --------- Signed-off-by: Will <28876888+willtsai@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: willtsai <28876888+willtsai@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8ad0d19 commit 79aeb54

6 files changed

Lines changed: 485 additions & 29 deletions

File tree

‎pkg/corerp/frontend/controller/applications/getgraph.go‎

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package applications
1818

1919
import (
2020
"context"
21+
"fmt"
2122
"net/http"
2223

2324
v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
@@ -36,6 +37,42 @@ const (
3637
planeName = "local"
3738
)
3839

40+
// ComputeGraphResponse computes the application graph for the given application and environment IDs and
41+
// returns it wrapped in an OK rest.Response. It is shared by the Applications.Core and Radius.Core
42+
// implementations of the getGraph custom action.
43+
func ComputeGraphResponse(ctx context.Context, applicationID resources.ID, environmentID string, connection sdk.Connection) (rest.Response, error) {
44+
// An application **MUST** have an environment id
45+
parsedEnvironmentID, err := resources.ParseResource(environmentID)
46+
if err != nil {
47+
return nil, fmt.Errorf("failed to parse environment ID %q: %w", environmentID, err)
48+
}
49+
50+
clientOptions := sdk.NewClientOptions(connection)
51+
52+
ucpApplicationsManagementClient := &clients.UCPApplicationsManagementClient{
53+
RootScope: radiusPlane + planeName,
54+
ClientOptions: clientOptions,
55+
}
56+
57+
resourceTypes, err := ucpApplicationsManagementClient.ListAllResourceTypesNames(ctx, "local")
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
applicationResources, err := listAllResourcesByApplication(ctx, applicationID, resourceTypes, clientOptions)
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
environmentResources, err := listAllResourcesByEnvironment(ctx, parsedEnvironmentID, resourceTypes, clientOptions)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
graph := computeGraph(applicationResources, environmentResources)
73+
return rest.NewOKResponse(graph), nil
74+
}
75+
3976
var _ ctrl.Controller = (*GetGraph)(nil)
4077

4178
// GetGraph is the controller implementation to get application graph.
@@ -71,33 +108,5 @@ func (ctrl *GetGraph) Run(ctx context.Context, w http.ResponseWriter, req *http.
71108
return rest.NewNotFoundResponse(sCtx.ResourceID), nil
72109
}
73110
// An application **MUST** have an environment id
74-
environmentID, err := resources.Parse(applicationResource.Properties.Environment)
75-
if err != nil {
76-
return nil, err
77-
}
78-
79-
clientOptions := sdk.NewClientOptions(ctrl.connection)
80-
81-
ucpApplicationsManagementClient := &clients.UCPApplicationsManagementClient{
82-
RootScope: radiusPlane + planeName,
83-
ClientOptions: clientOptions,
84-
}
85-
86-
resourceTypes, err := ucpApplicationsManagementClient.ListAllResourceTypesNames(ctx, "local")
87-
if err != nil {
88-
return nil, err
89-
}
90-
91-
applicationResources, err := listAllResourcesByApplication(ctx, applicationID, resourceTypes, clientOptions)
92-
if err != nil {
93-
return nil, err
94-
}
95-
96-
environmentResources, err := listAllResourcesByEnvironment(ctx, environmentID, resourceTypes, clientOptions)
97-
if err != nil {
98-
return nil, err
99-
}
100-
101-
graph := computeGraph(applicationResources, environmentResources)
102-
return rest.NewOKResponse(graph), nil
111+
return ComputeGraphResponse(ctx, applicationID, applicationResource.Properties.Environment, ctrl.connection)
103112
}

‎pkg/corerp/frontend/controller/applications/getgraph_test.go‎

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@ package applications
1818

1919
import (
2020
"context"
21+
"errors"
22+
"net/http"
2123
"net/http/httptest"
2224
"testing"
2325

2426
v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
2527
ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller"
28+
"github.com/radius-project/radius/pkg/armrpc/rest"
2629
"github.com/radius-project/radius/pkg/armrpc/rpctest"
2730
"github.com/radius-project/radius/pkg/components/database"
31+
"github.com/radius-project/radius/pkg/corerp/datamodel"
32+
rpv1 "github.com/radius-project/radius/pkg/rp/v1"
2833
"github.com/radius-project/radius/pkg/sdk"
34+
"github.com/radius-project/radius/pkg/ucp/resources"
2935
"github.com/stretchr/testify/require"
3036
"go.uber.org/mock/gomock"
3137
)
@@ -67,3 +73,204 @@ func TestGetGraphRun_20231001Preview(t *testing.T) {
6773
require.NoError(t, err)
6874
})
6975
}
76+
77+
func TestComputeGraphResponse_InvalidEnvironmentID(t *testing.T) {
78+
conn, err := sdk.NewDirectConnection("http://localhost:9000/apis/api.ucp.dev/v1alpha3")
79+
require.NoError(t, err)
80+
81+
applicationID, err := resources.Parse("/planes/radius/local/resourceGroups/default/providers/Applications.Core/applications/myapp")
82+
require.NoError(t, err)
83+
84+
// An empty/invalid environment ID string must surface as a parse error from
85+
// resources.Parse and not a panic or a successful response.
86+
resp, err := ComputeGraphResponse(context.Background(), applicationID, "not-a-valid-resource-id", conn)
87+
require.Error(t, err)
88+
require.Nil(t, resp)
89+
}
90+
91+
// TestComputeGraphResponse_UCPProvidersError covers the error-propagation path from
92+
// ListAllResourceTypesNames (~lines 57-59) by returning a 500 from the UCP providers endpoint.
93+
func TestComputeGraphResponse_UCPProvidersError(t *testing.T) {
94+
mux := http.NewServeMux()
95+
mux.HandleFunc("/planes/radius/local/providers", func(w http.ResponseWriter, r *http.Request) {
96+
http.Error(w, "boom", http.StatusInternalServerError)
97+
})
98+
server := httptest.NewServer(mux)
99+
defer server.Close()
100+
101+
conn, err := sdk.NewDirectConnection(server.URL)
102+
require.NoError(t, err)
103+
104+
applicationID, err := resources.Parse("/planes/radius/local/resourceGroups/default/providers/Applications.Core/applications/myapp")
105+
require.NoError(t, err)
106+
107+
resp, err := ComputeGraphResponse(
108+
context.Background(),
109+
applicationID,
110+
"/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/myenv",
111+
conn,
112+
)
113+
require.Error(t, err)
114+
require.Nil(t, resp)
115+
}
116+
117+
// TestComputeGraphResponse_ListByApplicationError covers lines 62-63: the error-propagation path
118+
// from listAllResourcesByApplication when getAPIVersionForResourceType fails.
119+
func TestComputeGraphResponse_ListByApplicationError(t *testing.T) {
120+
mux := http.NewServeMux()
121+
// Return a provider with one resource type so the list is non-empty.
122+
mux.HandleFunc("/planes/radius/local/providers", func(w http.ResponseWriter, r *http.Request) {
123+
w.Header().Set("Content-Type", "application/json")
124+
w.WriteHeader(http.StatusOK)
125+
_, _ = w.Write([]byte(`{"value":[{"name":"Applications.Core","resourceTypes":{"containers":{"apiVersions":{},"defaultApiVersion":"2023-10-01-preview"}}}]}`))
126+
})
127+
// GetProviderSummary for the resource type returns a 500 so listAllResourcesByApplication fails.
128+
mux.HandleFunc("/planes/radius/local/providers/Applications.Core", func(w http.ResponseWriter, r *http.Request) {
129+
http.Error(w, "provider error", http.StatusInternalServerError)
130+
})
131+
server := httptest.NewServer(mux)
132+
defer server.Close()
133+
134+
conn, err := sdk.NewDirectConnection(server.URL)
135+
require.NoError(t, err)
136+
137+
applicationID, err := resources.Parse("/planes/radius/local/resourceGroups/app-rg/providers/Applications.Core/applications/myapp")
138+
require.NoError(t, err)
139+
140+
resp, err := ComputeGraphResponse(
141+
context.Background(),
142+
applicationID,
143+
"/planes/radius/local/resourceGroups/env-rg/providers/Applications.Core/environments/myenv",
144+
conn,
145+
)
146+
require.Error(t, err)
147+
require.Nil(t, resp)
148+
}
149+
150+
// TestComputeGraphResponse_ListByEnvironmentError covers lines 67-68: the error-propagation path
151+
// from listAllResourcesByEnvironment when the environment-scoped resource listing fails.
152+
func TestComputeGraphResponse_ListByEnvironmentError(t *testing.T) {
153+
mux := http.NewServeMux()
154+
// Return a provider with one resource type.
155+
mux.HandleFunc("/planes/radius/local/providers", func(w http.ResponseWriter, r *http.Request) {
156+
w.Header().Set("Content-Type", "application/json")
157+
w.WriteHeader(http.StatusOK)
158+
_, _ = w.Write([]byte(`{"value":[{"name":"Applications.Core","resourceTypes":{"containers":{"apiVersions":{"2023-10-01-preview":{}},"defaultApiVersion":"2023-10-01-preview"}}}]}`))
159+
})
160+
// GetProviderSummary succeeds with a valid API version.
161+
mux.HandleFunc("/planes/radius/local/providers/Applications.Core", func(w http.ResponseWriter, r *http.Request) {
162+
w.Header().Set("Content-Type", "application/json")
163+
w.WriteHeader(http.StatusOK)
164+
_, _ = w.Write([]byte(`{"name":"Applications.Core","resourceTypes":{"containers":{"apiVersions":{"2023-10-01-preview":{}},"defaultApiVersion":"2023-10-01-preview"}}}`))
165+
})
166+
// Application-scoped listing succeeds (empty list).
167+
mux.HandleFunc("/planes/radius/local/resourceGroups/app-rg/providers/Applications.Core/containers", func(w http.ResponseWriter, r *http.Request) {
168+
w.Header().Set("Content-Type", "application/json")
169+
w.WriteHeader(http.StatusOK)
170+
_, _ = w.Write([]byte(`{"value":[]}`))
171+
})
172+
// Environment-scoped listing fails.
173+
mux.HandleFunc("/planes/radius/local/resourceGroups/env-rg/providers/Applications.Core/containers", func(w http.ResponseWriter, r *http.Request) {
174+
http.Error(w, "env listing error", http.StatusInternalServerError)
175+
})
176+
server := httptest.NewServer(mux)
177+
defer server.Close()
178+
179+
conn, err := sdk.NewDirectConnection(server.URL)
180+
require.NoError(t, err)
181+
182+
applicationID, err := resources.Parse("/planes/radius/local/resourceGroups/app-rg/providers/Applications.Core/applications/myapp")
183+
require.NoError(t, err)
184+
185+
resp, err := ComputeGraphResponse(
186+
context.Background(),
187+
applicationID,
188+
"/planes/radius/local/resourceGroups/env-rg/providers/Applications.Core/environments/myenv",
189+
conn,
190+
)
191+
require.Error(t, err)
192+
require.Nil(t, resp)
193+
}
194+
195+
// TestGetGraphRun_DatabaseError covers the error path from GetResource (line ~104) where the
196+
// database client returns a non-NotFound error and the controller propagates it.
197+
func TestGetGraphRun_DatabaseError(t *testing.T) {
198+
mctrl := gomock.NewController(t)
199+
defer mctrl.Finish()
200+
201+
databaseClient := database.NewMockClient(mctrl)
202+
req, err := rpctest.NewHTTPRequestWithContent(
203+
context.Background(),
204+
v1.OperationPost.HTTPMethod(),
205+
"http://localhost:8080/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/radius-test-rg/providers/Applications.Core/Applications/myapp/getGraph?api-version=2023-10-01-preview", nil)
206+
require.NoError(t, err)
207+
208+
databaseClient.
209+
EXPECT().
210+
Get(gomock.Any(), gomock.Any()).
211+
Return(nil, errors.New("boom"))
212+
213+
ctx := rpctest.NewARMRequestContext(req)
214+
conn, err := sdk.NewDirectConnection("http://localhost:9000/apis/api.ucp.dev/v1alpha3")
215+
require.NoError(t, err)
216+
217+
ctl, err := NewGetGraph(ctrl.Options{DatabaseClient: databaseClient}, conn)
218+
require.NoError(t, err)
219+
220+
w := httptest.NewRecorder()
221+
resp, err := ctl.Run(ctx, w, req)
222+
require.Error(t, err)
223+
require.Nil(t, resp)
224+
}
225+
226+
// TestGetGraphRun_ComputeGraphSuccess covers the success path through the controller's Run into
227+
// ComputeGraphResponse (line ~110 and the body of ComputeGraphResponse) by serving an empty UCP
228+
// resource-providers list from an httptest server, which yields an empty graph.
229+
func TestGetGraphRun_ComputeGraphSuccess(t *testing.T) {
230+
mctrl := gomock.NewController(t)
231+
defer mctrl.Finish()
232+
233+
mux := http.NewServeMux()
234+
mux.HandleFunc("/planes/radius/local/providers", func(w http.ResponseWriter, r *http.Request) {
235+
w.Header().Set("Content-Type", "application/json")
236+
w.WriteHeader(http.StatusOK)
237+
_, _ = w.Write([]byte(`{"value":[]}`))
238+
})
239+
server := httptest.NewServer(mux)
240+
defer server.Close()
241+
242+
const appIDStr = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/radius-test-rg/providers/Applications.Core/applications/myapp"
243+
const envIDStr = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/radius-test-rg/providers/Applications.Core/environments/myenv"
244+
245+
databaseClient := database.NewMockClient(mctrl)
246+
databaseClient.
247+
EXPECT().
248+
Get(gomock.Any(), gomock.Any()).
249+
Return(rpctest.FakeStoreObject(&datamodel.Application{
250+
BaseResource: v1.BaseResource{
251+
TrackedResource: v1.TrackedResource{ID: appIDStr},
252+
},
253+
Properties: datamodel.ApplicationProperties{
254+
BasicResourceProperties: rpv1.BasicResourceProperties{Environment: envIDStr},
255+
},
256+
}), nil)
257+
258+
req, err := rpctest.NewHTTPRequestWithContent(
259+
context.Background(),
260+
v1.OperationPost.HTTPMethod(),
261+
"http://localhost:8080"+appIDStr+"/getGraph?api-version=2023-10-01-preview", nil)
262+
require.NoError(t, err)
263+
264+
ctx := rpctest.NewARMRequestContext(req)
265+
conn, err := sdk.NewDirectConnection(server.URL)
266+
require.NoError(t, err)
267+
268+
ctl, err := NewGetGraph(ctrl.Options{DatabaseClient: databaseClient}, conn)
269+
require.NoError(t, err)
270+
271+
w := httptest.NewRecorder()
272+
resp, err := ctl.Run(ctx, w, req)
273+
require.NoError(t, err)
274+
_, ok := resp.(*rest.OKResponse)
275+
require.True(t, ok, "expected an OK response, got %T", resp)
276+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
Copyright 2023 The Radius Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v20250801preview
18+
19+
import (
20+
"context"
21+
"net/http"
22+
23+
v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
24+
ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller"
25+
"github.com/radius-project/radius/pkg/armrpc/rest"
26+
"github.com/radius-project/radius/pkg/corerp/datamodel"
27+
"github.com/radius-project/radius/pkg/corerp/datamodel/converter"
28+
app_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/applications"
29+
"github.com/radius-project/radius/pkg/sdk"
30+
)
31+
32+
var _ ctrl.Controller = (*GetGraphv20250801preview)(nil)
33+
34+
// GetGraphv20250801preview is the controller implementation to get the application graph for
35+
// Radius.Core/applications resources.
36+
type GetGraphv20250801preview struct {
37+
ctrl.Operation[*datamodel.Application_v20250801preview, datamodel.Application_v20250801preview]
38+
connection sdk.Connection
39+
}
40+
41+
// NewGetGraphv20250801preview creates a new instance of the GetGraphv20250801preview controller.
42+
func NewGetGraphv20250801preview(opts ctrl.Options, connection sdk.Connection) (ctrl.Controller, error) {
43+
return &GetGraphv20250801preview{
44+
ctrl.NewOperation(opts,
45+
ctrl.ResourceOptions[datamodel.Application_v20250801preview]{
46+
RequestConverter: converter.Application20250801DataModelFromVersioned,
47+
ResponseConverter: converter.Application20250801DataModelToVersioned,
48+
},
49+
),
50+
connection,
51+
}, nil
52+
}
53+
54+
// Run handles the getGraph custom action for Radius.Core/applications. It looks up the application,
55+
// resolves its environment, lists application- and environment-scoped resources, and returns the
56+
// computed application graph.
57+
func (ctrl *GetGraphv20250801preview) Run(ctx context.Context, w http.ResponseWriter, req *http.Request) (rest.Response, error) {
58+
sCtx := v1.ARMRequestContextFromContext(ctx)
59+
60+
// Request route for getGraph has the operation name as suffix which must be removed to get the resource id.
61+
// route id format: /planes/radius/local/resourcegroups/default/providers/Radius.Core/applications/<app>/getGraph
62+
applicationID := sCtx.ResourceID.Truncate()
63+
applicationResource, _, err := ctrl.GetResource(ctx, applicationID)
64+
if err != nil {
65+
return nil, err
66+
}
67+
if applicationResource == nil {
68+
return rest.NewNotFoundResponse(sCtx.ResourceID), nil
69+
}
70+
71+
return app_ctrl.ComputeGraphResponse(ctx, applicationID, applicationResource.Properties.Environment, ctrl.connection)
72+
}

0 commit comments

Comments
 (0)