Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions rest-api/api/pkg/api/handler/siteexplorerendpointaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package handler

import (
"net/http"
"slices"

"github.com/labstack/echo/v4"
"google.golang.org/protobuf/proto"

"github.com/NVIDIA/infra-controller/rest-api/api/internal/config"
"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common"
"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model"
sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site"
cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util"
cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db"
corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1"
)

// SiteExplorerEndpointActionHandler triggers clear-error or re-explore actions for explored endpoints.
type SiteExplorerEndpointActionHandler struct {
dbSession *cdb.Session
scp *sc.ClientPool
cfg *config.Config
tracerSpan *cutil.TracerSpan
}

// NewSiteExplorerEndpointActionHandler returns a handler for site-explorer endpoint actions.
func NewSiteExplorerEndpointActionHandler(dbSession *cdb.Session, scp *sc.ClientPool, cfg *config.Config) SiteExplorerEndpointActionHandler {
return SiteExplorerEndpointActionHandler{
dbSession: dbSession,
scp: scp,
cfg: cfg,
tracerSpan: cutil.NewTracerSpan(),
}
}

// Handle godoc
// @Summary Trigger Site Explorer Endpoint Action
// @Description Trigger clear-error or re-explore for all or selected explored endpoints.
// @Tags site-explorer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param org path string true "Name of NGC organization"
// @Param request body model.APISiteExplorerEndpointActionRequest true "Site explorer endpoint action"
// @Success 200 {object} model.APISiteExplorerEndpointAction
// @Router /v2/org/{org}/nico/site-explorer/endpoint/action [post]
func (h SiteExplorerEndpointActionHandler) Handle(c echo.Context) error {
org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("SiteExplorerEndpointAction", "Create", c, h.tracerSpan)
if handlerSpan != nil {
defer handlerSpan.End()
}

var apiReq model.APISiteExplorerEndpointActionRequest
if err := c.Bind(&apiReq); err != nil {
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid request body", nil)
}
if err := apiReq.Validate(); err != nil {
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, err.Error(), nil)
}

stc, siteID, apiErr := common.AuthorizeProviderSiteForCore(common.AuthorizeProviderSiteForCoreInput{
Ctx: ctx,
Logger: logger,
DBSession: h.dbSession,
SCP: h.scp,
Org: org,
User: dbUser,
SiteID: apiReq.SiteID,
})
if apiErr != nil {
return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, apiErr.Data)
}

endpointIDs := slices.Clone(apiReq.EndpointIDs)
if apiReq.Target == model.SiteExplorerEndpointTargetAll {
var ids corev1.ExploredEndpointIdList
apiErr = common.ExecuteCoreGRPC(
ctx,
stc,
corev1.Forge_FindExploredEndpointIds_FullMethodName,
&corev1.ExploredEndpointSearchFilter{},
&ids,
siteID,
)
if apiErr != nil {
logAPIError(logger, apiErr, "failed to find explored endpoint IDs")
return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, nil)
}
endpointIDs = ids.GetEndpointIds()
}

logger.Info().
Str("action", apiReq.Action).
Str("target", apiReq.Target).
Str("siteID", apiReq.SiteID).
Int("endpointCount", len(endpointIDs)).
Msg("triggering site-explorer endpoint action via Core proxy")

for _, endpointID := range endpointIDs {
var fullMethod string
var coreReq proto.Message
switch apiReq.Action {
case model.SiteExplorerEndpointActionClearError:
fullMethod = corev1.Forge_ClearSiteExplorationError_FullMethodName
coreReq = &corev1.ClearSiteExplorationErrorRequest{IpAddress: endpointID}
case model.SiteExplorerEndpointActionReExplore:
fullMethod = corev1.Forge_ReExploreEndpoint_FullMethodName
coreReq = &corev1.ReExploreEndpointRequest{IpAddress: endpointID}
}

apiErr = common.ExecuteCoreGRPC(ctx, stc, fullMethod, coreReq, nil, siteID)
if apiErr != nil {
actionLogger := logger.With().Str("action", apiReq.Action).Str("endpointID", endpointID).Logger()
logAPIError(actionLogger, apiErr, "failed to trigger site-explorer endpoint action")
return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, nil)
}
}

return c.JSON(http.StatusOK, apiReq.ToResponse(endpointIDs))
}
191 changes: 191 additions & 0 deletions rest-api/api/pkg/api/handler/siteexplorerendpointaction_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package handler

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
tmocks "go.temporal.io/sdk/mocks"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"

"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common"
"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model"
sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site"
authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization"
"github.com/NVIDIA/infra-controller/rest-api/common/pkg/coreproxy"
cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util"
cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model"
corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1"
)

func TestSiteExplorerEndpointActionHandlerClearErrorForSelectedEndpoints(t *testing.T) {
fixture := newSiteExplorerEndpointActionHandlerFixture(t, []string{authz.ProviderAdminRole})
fixture.expectProxyResponse(t, nil)

rec := fixture.request(t, model.APISiteExplorerEndpointActionRequest{
SiteID: fixture.siteID,
Action: model.SiteExplorerEndpointActionClearError,
Target: model.SiteExplorerEndpointTargetEndpointIDs,
EndpointIDs: []string{"10.0.0.1"},
})
assert.Equal(t, http.StatusOK, rec.Code)
require.Len(t, fixture.proxiedReqs, 1)
assert.Equal(t, corev1.Forge_ClearSiteExplorationError_FullMethodName, fixture.proxiedReqs[0].FullMethod)
assert.Empty(t, fixture.proxiedReqs[0].EncryptedSecrets)

var coreReq corev1.ClearSiteExplorationErrorRequest
require.NoError(t, protojson.Unmarshal(fixture.proxiedReqs[0].RequestJSON, &coreReq))
assert.Equal(t, "10.0.0.1", coreReq.GetIpAddress())

var resp model.APISiteExplorerEndpointAction
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, fixture.siteID, resp.SiteID)
assert.Equal(t, []string{"10.0.0.1"}, resp.EndpointIDs)
}

func TestSiteExplorerEndpointActionHandlerReExploreForAllEndpoints(t *testing.T) {
fixture := newSiteExplorerEndpointActionHandlerFixture(t, []string{authz.ProviderAdminRole})
fixture.expectProxyResponse(t, &corev1.ExploredEndpointIdList{EndpointIds: []string{"10.0.0.1", "10.0.0.2"}})
fixture.expectProxyResponse(t, nil)
fixture.expectProxyResponse(t, nil)

rec := fixture.request(t, model.APISiteExplorerEndpointActionRequest{
SiteID: fixture.siteID,
Action: model.SiteExplorerEndpointActionReExplore,
Target: model.SiteExplorerEndpointTargetAll,
})
assert.Equal(t, http.StatusOK, rec.Code)
require.Len(t, fixture.proxiedReqs, 3)
assert.Equal(t, corev1.Forge_FindExploredEndpointIds_FullMethodName, fixture.proxiedReqs[0].FullMethod)
assert.Equal(t, corev1.Forge_ReExploreEndpoint_FullMethodName, fixture.proxiedReqs[1].FullMethod)
assert.Equal(t, corev1.Forge_ReExploreEndpoint_FullMethodName, fixture.proxiedReqs[2].FullMethod)

var firstAction corev1.ReExploreEndpointRequest
require.NoError(t, protojson.Unmarshal(fixture.proxiedReqs[1].RequestJSON, &firstAction))
assert.Equal(t, "10.0.0.1", firstAction.GetIpAddress())
var secondAction corev1.ReExploreEndpointRequest
require.NoError(t, protojson.Unmarshal(fixture.proxiedReqs[2].RequestJSON, &secondAction))
assert.Equal(t, "10.0.0.2", secondAction.GetIpAddress())
}

func TestSiteExplorerEndpointActionHandlerRejectsInvalidRequest(t *testing.T) {
fixture := newSiteExplorerEndpointActionHandlerFixture(t, []string{authz.ProviderAdminRole})

rec := fixture.request(t, model.APISiteExplorerEndpointActionRequest{
SiteID: fixture.siteID,
Action: model.SiteExplorerEndpointActionClearError,
Target: model.SiteExplorerEndpointTargetEndpointIDs,
EndpointIDs: []string{"not-an-ip"},
})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Empty(t, fixture.proxiedReqs)
}

func TestSiteExplorerEndpointActionHandlerRejectsNonProviderAdmin(t *testing.T) {
fixture := newSiteExplorerEndpointActionHandlerFixture(t, nil)

rec := fixture.request(t, model.APISiteExplorerEndpointActionRequest{
SiteID: fixture.siteID,
Action: model.SiteExplorerEndpointActionClearError,
Target: model.SiteExplorerEndpointTargetEndpointIDs,
EndpointIDs: []string{"10.0.0.1"},
})
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Empty(t, fixture.proxiedReqs)
}

type siteExplorerEndpointActionHandlerFixture struct {
org string
siteID string
user interface{}
handler SiteExplorerEndpointActionHandler
tsc *tmocks.Client
proxiedReqs []coreproxy.Request
}

func newSiteExplorerEndpointActionHandlerFixture(t *testing.T, roles []string) *siteExplorerEndpointActionHandlerFixture {
t.Helper()

dbSession := common.TestInitDB(t)
t.Cleanup(dbSession.Close)
common.TestSetupSchema(t, dbSession)

org := "test-org-" + uuid.NewString()
user := common.TestBuildUser(t, dbSession, "test-starfleet-id-"+uuid.NewString(), org, roles)
ip := common.TestBuildInfrastructureProvider(t, dbSession, "Test Infrastructure Provider", org, user)
site := common.TestBuildSite(t, dbSession, ip, "Test Site", user)
sDAO := cdbm.NewSiteDAO(dbSession)
_, err := sDAO.Update(context.Background(), nil, cdbm.SiteUpdateInput{
SiteID: site.ID,
Status: cutil.GetPtr(cdbm.SiteStatusRegistered),
})
require.NoError(t, err)

tsc := &tmocks.Client{}
scp := sc.NewClientPool(nil)
scp.IDClientMap[site.ID.String()] = tsc

return &siteExplorerEndpointActionHandlerFixture{
org: org,
siteID: site.ID.String(),
user: user,
handler: NewSiteExplorerEndpointActionHandler(dbSession, scp, common.GetTestConfig()),
tsc: tsc,
}
}

func (f *siteExplorerEndpointActionHandlerFixture) expectProxyResponse(t *testing.T, resp proto.Message) {
t.Helper()

wrun := &tmocks.WorkflowRun{}
wrun.On("Get", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
if resp == nil {
return
}
out := args.Get(1).(*coreproxy.Response)
responseJSON, err := protojson.Marshal(resp)
require.NoError(t, err)
out.ResponseJSON = responseJSON
}).Return(nil).Once()

f.tsc.On(
"ExecuteWorkflow",
mock.Anything,
mock.Anything,
coreproxy.WorkflowName,
mock.Anything,
).Run(func(args mock.Arguments) {
f.proxiedReqs = append(f.proxiedReqs, args.Get(3).(coreproxy.Request))
}).Return(wrun, nil).Once()
}

func (f *siteExplorerEndpointActionHandlerFixture) request(t *testing.T, apiReq model.APISiteExplorerEndpointActionRequest) *httptest.ResponseRecorder {
t.Helper()

body, err := json.Marshal(apiReq)
require.NoError(t, err)

e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
ec := e.NewContext(req, rec)
ec.SetParamNames("orgName")
ec.SetParamValues(f.org)
ec.Set("user", f.user)

require.NoError(t, f.handler.Handle(ec))
return rec
}
Loading