diff --git a/backend/factory/config.go b/backend/factory/config.go index 61ac913..779b034 100644 --- a/backend/factory/config.go +++ b/backend/factory/config.go @@ -14,6 +14,7 @@ import ( "github.com/asaskevich/govalidator" "github.com/google/uuid" + "github.com/free5gc/util/nfheartbeat" "github.com/free5gc/webconsole/backend/logger" ) @@ -54,6 +55,9 @@ type Configuration struct { Mongodb *Mongodb `yaml:"mongodb" valid:"required"` NrfUri string `yaml:"nrfUri" valid:"required"` BillingServer *BillingServer `yaml:"billingServer,omitempty" valid:"required"` + // NfHeartBeatTimer is the fallback heartbeat interval in seconds, from 1 to + // 3600 as the NRF accepts. The interval the NRF assigns always wins. + NfHeartBeatTimer int32 `yaml:"nfHeartBeatTimer,omitempty" valid:"optional,range(1|3600)"` } func (c *Configuration) validate() (bool, error) { @@ -191,6 +195,17 @@ func (c *Config) GetLogReportCaller() bool { return c.Logger.ReportCaller } +// GetNfHeartBeatTimer returns the fallback heartbeat interval in seconds. +func (c *Config) GetNfHeartBeatTimer() int32 { + c.RLock() + defer c.RUnlock() + + if c.Configuration != nil && c.Configuration.NfHeartBeatTimer > 0 { + return c.Configuration.NfHeartBeatTimer + } + return nfheartbeat.DefaultTimer +} + func (c *Config) GetNfInstanceId() string { c.RLock() defer c.RUnlock() diff --git a/backend/factory/config_test.go b/backend/factory/config_test.go new file mode 100644 index 0000000..c61e6df --- /dev/null +++ b/backend/factory/config_test.go @@ -0,0 +1,72 @@ +package factory_test + +import ( + "testing" + + "github.com/asaskevich/govalidator" + + "github.com/free5gc/util/nfheartbeat" + "github.com/free5gc/webconsole/backend/factory" +) + +func TestGetNfHeartBeatTimer(t *testing.T) { + tests := []struct { + name string + cfg *factory.Config + want int32 + }{ + { + name: "no configuration section", + cfg: &factory.Config{}, + want: nfheartbeat.DefaultTimer, + }, + { + name: "option absent", + cfg: &factory.Config{Configuration: &factory.Configuration{}}, + want: nfheartbeat.DefaultTimer, + }, + { + name: "option set", + cfg: &factory.Config{Configuration: &factory.Configuration{NfHeartBeatTimer: 45}}, + want: 45, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.GetNfHeartBeatTimer(); got != tt.want { + t.Errorf("GetNfHeartBeatTimer() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestNfHeartBeatTimerRange(t *testing.T) { + // The range(1|3600) struct tag cannot reference constants; keep it aligned + // with the bounds the NRF profile validator enforces. + if nfheartbeat.MinTimer != 1 || nfheartbeat.MaxTimer != 3600 { + t.Fatalf("range(1|3600) tag out of sync with nfheartbeat bounds [%d, %d]", + nfheartbeat.MinTimer, nfheartbeat.MaxTimer) + } + + tests := []struct { + name string + timer int32 + wantErr bool + }{ + {name: "absent is optional", timer: 0}, + {name: "lower bound", timer: nfheartbeat.MinTimer}, + {name: "upper bound of 1 hour", timer: nfheartbeat.MaxTimer}, + {name: "above the upper bound", timer: nfheartbeat.MaxTimer + 1, wantErr: true}, + {name: "negative", timer: -1, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := govalidator.ValidateStruct(&factory.Configuration{NfHeartBeatTimer: tt.timer}) + + fieldErr := govalidator.ErrorByField(err, "NfHeartBeatTimer") + if gotErr := fieldErr != ""; gotErr != tt.wantErr { + t.Errorf("nfHeartBeatTimer %d: field error = %q, want error %v", tt.timer, fieldErr, tt.wantErr) + } + }) + } +} diff --git a/backend/webui_context/context.go b/backend/webui_context/context.go index c3ba4b5..9aaa675 100644 --- a/backend/webui_context/context.go +++ b/backend/webui_context/context.go @@ -12,6 +12,7 @@ import ( Nnrf_NFDiscovery "github.com/free5gc/openapi/nrf/NFDisc" Nnrf_NFManagement "github.com/free5gc/openapi/nrf/NFMgmt" "github.com/free5gc/openapi/oauth" + "github.com/free5gc/util/nfheartbeat" "github.com/free5gc/webconsole/backend/factory" "github.com/free5gc/webconsole/backend/logger" ) @@ -31,6 +32,14 @@ type WEBUIContext struct { NFManagementClient *Nnrf_NFManagement.APIClient NFDiscoveryClient *Nnrf_NFDiscovery.APIClient + + heartbeat *nfheartbeat.Runner + + // heartbeatTimer is the interval in seconds last assigned in a registration + // response; PATCH-adopted values live in the Runner. Set by the startup + // registration before the heartbeat goroutine starts, then only rewritten + // from re-registrations on that same goroutine. + heartbeatTimer int32 } type NfOamInstance struct { @@ -39,7 +48,7 @@ type NfOamInstance struct { Uri string } -func Init() { +func Init() error { webuiContext.NfInstanceID = factory.WebuiConfig.GetNfInstanceId() webuiContext.NrfUri = factory.WebuiConfig.Configuration.NrfUri @@ -52,6 +61,18 @@ func Init() { NFDiscovryConfig := Nnrf_NFDiscovery.NewConfiguration() NFDiscovryConfig.SetBasePath(GetSelf().NrfUri) webuiContext.NFDiscoveryClient = Nnrf_NFDiscovery.NewAPIClient(NFDiscovryConfig) + + heartbeat, err := nfheartbeat.NewRunner( + nrfRegistrar{}, + func() int32 { return factory.WebuiConfig.GetNfHeartBeatTimer() }, + logger.ConsumerLog, + ) + if err != nil { + return err + } + webuiContext.heartbeat = heartbeat + + return nil } func (context *WEBUIContext) UpdateNfProfiles() { diff --git a/backend/webui_context/nrf_heartbeat.go b/backend/webui_context/nrf_heartbeat.go new file mode 100644 index 0000000..9fcf274 --- /dev/null +++ b/backend/webui_context/nrf_heartbeat.go @@ -0,0 +1,39 @@ +package webui_context + +import ( + "context" + "sync" + + "github.com/free5gc/openapi/models" +) + +// nrfRegistrar adapts the webui context to nfheartbeat.Registrar. The NRF calls +// of this package are package level, so it carries no state of its own. +type nrfRegistrar struct{} + +func (r nrfRegistrar) UpdateNFInstance(ctx context.Context, patchItems []models.PatchItem) ( + models.Nrf_NFMgmt_NFProfile, *models.ProblemDetails, error, +) { + return SendUpdateNFInstance(ctx, patchItems) +} + +func (r nrfRegistrar) RegisterNFInstance(ctx context.Context) (int32, error) { + if err := SendNFRegistration(ctx, false); err != nil { + return 0, err + } + // Written by processRegisterResponse on this goroutine. + return GetSelf().heartbeatTimer, nil +} + +// StartHeartbeat launches the periodic NF heartbeat toward the NRF. +// It must be called after a successful NF registration. +func StartHeartbeat(ctx context.Context, wg *sync.WaitGroup) { + GetSelf().heartbeat.Start(ctx, wg, GetSelf().heartbeatTimer) +} + +// WaitHeartbeatStopped blocks until the heartbeat goroutine has exited, so that +// no heartbeat PATCH or re-registration PUT can reach the NRF after +// deregistration. It returns immediately when the heartbeat was never started. +func WaitHeartbeatStopped() { + GetSelf().heartbeat.Wait() +} diff --git a/backend/webui_context/nrf_heartbeat_internal_test.go b/backend/webui_context/nrf_heartbeat_internal_test.go new file mode 100644 index 0000000..d5eb5c7 --- /dev/null +++ b/backend/webui_context/nrf_heartbeat_internal_test.go @@ -0,0 +1,177 @@ +package webui_context + +import ( + "context" + "net/http" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/h2non/gock" + + "github.com/free5gc/webconsole/backend/factory" +) + +// TestHeartbeatWiring drives the runner through the real transport: the +// registration seeds the timer, the loop sends the PATCH, and shutdown waits +// for the heartbeat goroutine. The runner's own behavior is covered in the util +// nfheartbeat package. +func TestHeartbeatWiring(t *testing.T) { + // The NRF assigns registerTimer at registration time and answers the first + // heartbeat with adoptedTimer. + const ( + registerTimer = 1 + adoptedTimer = 2 + ) + + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(http.StatusCreated). + JSON(nfProfileJSON(registerTimer, nil)) + gock.New(testNrfUri). + Patch(testNfIdPath). + Reply(http.StatusOK). + JSON(nfProfileJSON(adoptedTimer, nil)) + + ctx, cancel := context.WithCancel(t.Context()) + if err := SendNFRegistration(ctx, true); err != nil { + t.Fatalf("SendNFRegistration: %v", err) + } + + var wg sync.WaitGroup + StartHeartbeat(ctx, &wg) + + // Fake clock: this returns as soon as the loop has served the tick. + time.Sleep(registerTimer * time.Second) + synctest.Wait() + + if !gock.IsDone() { + t.Fatal("the heartbeat loop sent no PATCH") + } + + cancel() + waitHeartbeatStopped(t) + wg.Wait() + }) +} + +// TestHeartbeatReregistersOnNotFound drives the 404 handshake through the real +// transport: the PATCH answers 404, the adapter re-registers with a PUT, and +// the next heartbeat fires on the interval the re-registration returned. +func TestHeartbeatReregistersOnNotFound(t *testing.T) { + const ( + initialTimer = 1 + reregisterTimer = 2 + ) + + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + GetSelf().heartbeatTimer = initialTimer + + gock.New(testNrfUri). + Patch(testNfIdPath). + Reply(http.StatusNotFound). + JSON(problemJSON(http.StatusNotFound, causeNotFound)) + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(http.StatusOK). + JSON(nfProfileJSON(reregisterTimer, nil)) + gock.New(testNrfUri). + Patch(testNfIdPath). + Reply(http.StatusNoContent) + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(t.Context()) + StartHeartbeat(ctx, &wg) + + time.Sleep(initialTimer * time.Second) + synctest.Wait() + + // One old interval later nothing may fire: the adopted interval is + // longer. The follow-up PATCH lands only on the new one. + time.Sleep(initialTimer * time.Second) + synctest.Wait() + if gock.IsDone() { + t.Fatal("the loop kept the old interval after re-registration") + } + + time.Sleep((reregisterTimer - initialTimer) * time.Second) + synctest.Wait() + if !gock.IsDone() { + t.Fatal("expected 404 PATCH, re-registration PUT and follow-up PATCH") + } + + cancel() + waitHeartbeatStopped(t) + wg.Wait() + }) +} + +// TestHeartbeatFallbackInterval proves the wiring of the config fallback: the +// NRF assigns no timer, so the loop must tick at the configured interval. +func TestHeartbeatFallbackInterval(t *testing.T) { + const configTimer = 45 + + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + factory.WebuiConfig.Configuration.NfHeartBeatTimer = configTimer + + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(http.StatusOK). + JSON(nfProfileJSON(0, nil)) + gock.New(testNrfUri). + Patch(testNfIdPath). + Reply(http.StatusNoContent) + + ctx, cancel := context.WithCancel(t.Context()) + if err := SendNFRegistration(ctx, true); err != nil { + t.Fatalf("SendNFRegistration: %v", err) + } + + var wg sync.WaitGroup + StartHeartbeat(ctx, &wg) + + // One second short of the configured interval nothing may fire; a + // loop running on the default interval would already have PATCHed. + time.Sleep((configTimer - 1) * time.Second) + synctest.Wait() + if gock.IsDone() { + t.Fatal("the heartbeat fired before the configured fallback interval") + } + + time.Sleep(1 * time.Second) + synctest.Wait() + if !gock.IsDone() { + t.Fatal("the heartbeat loop did not tick at the configured fallback interval") + } + + cancel() + waitHeartbeatStopped(t) + wg.Wait() + }) +} + +// waitHeartbeatStopped fails the test when the heartbeat goroutine outlives the +// wait that deregistration relies on. It must run inside a synctest bubble: the +// deadlock it guards against shows up as a blocked bubble, not as a timeout. +func waitHeartbeatStopped(t *testing.T) { + t.Helper() + + stopped := make(chan struct{}) + go func() { + WaitHeartbeatStopped() + close(stopped) + }() + + synctest.Wait() + select { + case <-stopped: + default: + t.Fatal("WaitHeartbeatStopped did not return") + } +} diff --git a/backend/webui_context/nrf_management.go b/backend/webui_context/nrf_management.go index d1c1ada..a7e57e9 100644 --- a/backend/webui_context/nrf_management.go +++ b/backend/webui_context/nrf_management.go @@ -2,8 +2,8 @@ package webui_context import ( "context" + "errors" "fmt" - "strings" "time" "github.com/free5gc/openapi" @@ -18,7 +18,14 @@ const ( MaxRetryAttempts = 10 ) -func SendNFRegistration() error { +// SendNFRegistration registers the AF profile with the NRF, retrying up to +// MaxRetryAttempts or until ctx is canceled. applyOAuth2 must be true only for +// the startup registration: it writes OAuth2Required, which the request handlers +// read concurrently once the web server is running. +// +// The profile keeps the instance ID the webconsole chose: NFRegister is a PUT on +// that ID, per 3GPP TS 29.510 clause 6.1.3.2.2. +func SendNFRegistration(ctx context.Context, applyOAuth2 bool) error { profile := &models.Nrf_NFMgmt_NFProfile{ NfInstanceId: GetSelf().NfInstanceID, NfType: models.Nrf_NFMgmt_NFType_AF, @@ -33,58 +40,56 @@ func SendNFRegistration() error { RequestBody: profile, } - var nf models.Nrf_NFMgmt_NFProfile var res *Nnrf_NFManagement.RegisterNFInstanceResponse var err error - retryTime := 0 - for { + for retryTime := 0; ctx.Err() == nil; retryTime++ { + if retryTime == MaxRetryAttempts { + return fmt.Errorf("NF Register retry failed %+v times", retryTime) + } res, err = GetSelf(). NFManagementClient. - NFInstanceIDDocumentApi.RegisterNFInstance(context.Background(), registrationRequest) - // RegisterNFInstance(context.TODO(), GetSelf().NfInstanceID, profile) - if err != nil || res == nil || res.Nrf_NFMgmt_NFProfile == nil { - if err == nil { - err = fmt.Errorf("RegisterNFInstance returned an empty NF profile") - } - logger.ConsumerLog.Warnf("Webconsole-AF register to NRF Error[%s]", err.Error()) - time.Sleep(RetryInterval) - retryTime += 1 - if retryTime == MaxRetryAttempts { - return fmt.Errorf("NF Register retry failed %+v times", retryTime) - } - continue + NFInstanceIDDocumentApi.RegisterNFInstance(ctx, registrationRequest) + if err == nil && res != nil && res.Nrf_NFMgmt_NFProfile != nil { + processRegisterResponse(res.Nrf_NFMgmt_NFProfile, applyOAuth2) + logger.InitLog.Infof("Webconsole-AF Registration to NRF success") + return nil } - nf = *res.Nrf_NFMgmt_NFProfile - - if res.Location == "" { - // NFUpdate - break - } else { - // NFRegister - resourceUri := res.Location - GetSelf().NfInstanceID = resourceUri[strings.LastIndex(resourceUri, "/")+1:] - - oauth2 := false - if customInfo, isMap := nf.CustomInfo.(map[string]interface{}); isMap { - v, ok := customInfo["oauth2"].(bool) - if ok { - oauth2 = v - logger.MainLog.Infoln("OAuth2 setting receive from NRF:", oauth2) - } - } - GetSelf().OAuth2Required = oauth2 + if err == nil { + err = fmt.Errorf("RegisterNFInstance returned an empty NF profile") + } + logger.ConsumerLog.Warnf("Webconsole-AF register to NRF Error[%s]", err.Error()) + select { + case <-ctx.Done(): + case <-time.After(RetryInterval): } } + return fmt.Errorf("context canceled before NF Register") +} - logger.InitLog.Infof("Webconsole-AF Registration to NRF success") - return nil +// processRegisterResponse adopts what the NRF answered to the NFRegister PUT: the +// heartbeat interval and the oauth2 custom info. +func processRegisterResponse(nf *models.Nrf_NFMgmt_NFProfile, applyOAuth2 bool) { + GetSelf().heartbeatTimer = nf.HeartBeatTimer + + oauth2 := false + if customInfo, isMap := nf.CustomInfo.(map[string]interface{}); isMap { + if v, ok := customInfo["oauth2"].(bool); ok { + oauth2 = v + logger.MainLog.Infoln("OAuth2 setting receive from NRF:", oauth2) + } + } + if applyOAuth2 { + GetSelf().OAuth2Required = oauth2 + } else if oauth2 != GetSelf().OAuth2Required { + logger.ConsumerLog.Warnf("NRF OAuth2 setting changed to %v, restart the webconsole to apply it", oauth2) + } } -func RetrySendNFRegistration(maxRetry int) error { +func RetrySendNFRegistration(ctx context.Context, maxRetry int, applyOAuth2 bool) error { retryCount := 0 for retryCount < maxRetry { - err := SendNFRegistration() + err := SendNFRegistration(ctx, applyOAuth2) if err == nil { return nil } @@ -94,6 +99,47 @@ func RetrySendNFRegistration(maxRetry int) error { return fmt.Errorf("[AF] Retry NF Registration has meet maximum") } +// SendUpdateNFInstance sends an NFUpdate PATCH to the NRF, honoring ctx. The +// raw err comes back alongside any ProblemDetails so callers can read its +// GenericOpenAPIError status. +func SendUpdateNFInstance(ctx context.Context, patchItem []models.PatchItem) ( + nf models.Nrf_NFMgmt_NFProfile, problemDetails *models.ProblemDetails, err error, +) { + afSelf := GetSelf() + tokCtx, pd, err := afSelf.GetTokenCtx(models.Nrf_NFMgmt_ServiceName_NNRF_NFM, models.Nrf_NFMgmt_NFType_NRF) + if err != nil { + return nf, pd, err + } + // GetTokenCtx takes no parent, so the token request stays uncancelable; + // transplanting the token lets at least the PATCH honor ctx. + if tok := tokCtx.Value(openapi.ContextOAuth2); tok != nil { + ctx = context.WithValue(ctx, openapi.ContextOAuth2, tok) + } + + req := &Nnrf_NFManagement.UpdateNFInstanceRequest{ + NfInstanceID: &afSelf.NfInstanceID, + RequestBody: patchItem, + } + + res, err := afSelf.NFManagementClient.NFInstanceIDDocumentApi.UpdateNFInstance(ctx, req) + if err != nil { + var apiErr openapi.GenericOpenAPIError + if errors.As(err, &apiErr) { + if updateErr, okModel := apiErr.Model().(Nnrf_NFManagement.UpdateNFInstanceError); okModel { + return nf, updateErr.ProblemDetails, err + } + } + return nf, nil, err + } + if res == nil { + return nf, nil, fmt.Errorf("empty NFUpdate response") + } + if res.Nrf_NFMgmt_NFProfile != nil { + nf = *res.Nrf_NFMgmt_NFProfile + } + return nf, nil, nil +} + func SendDeregisterNFInstance() (*models.ProblemDetails, error) { logger.ConsumerLog.Infof("Send Deregister NFInstance") diff --git a/backend/webui_context/nrf_management_internal_test.go b/backend/webui_context/nrf_management_internal_test.go new file mode 100644 index 0000000..7b72279 --- /dev/null +++ b/backend/webui_context/nrf_management_internal_test.go @@ -0,0 +1,345 @@ +package webui_context + +import ( + "context" + "errors" + "net/http" + "testing" + "testing/synctest" + "time" + + "github.com/h2non/gock" + + "github.com/free5gc/openapi" + "github.com/free5gc/util/nfheartbeat" + "github.com/free5gc/webconsole/backend/factory" +) + +const ( + testNrfUri = "http://127.0.0.10:8000" + testNfId = "6ba7b810-9dad-41d1-80b4-00c04fd430c8" + testNfIdPath = "/nnrf-nfm/v1/nf-instances/" + testNfId + + causeNotFound = "RESOURCE_URI_STRUCTURE_NOT_FOUND" + causeSystemFailure = "SYSTEM_FAILURE" +) + +// newTestContext points the package globals at a test configuration and restores +// them afterwards. The NRF calls of this package read those globals directly, so +// the test has to own them rather than pass a context around. +func newTestContext(t *testing.T, nrfUri string) { + t.Helper() + + savedConfig, savedContext := factory.WebuiConfig, webuiContext + t.Cleanup(func() { + factory.WebuiConfig, webuiContext = savedConfig, savedContext + }) + + factory.WebuiConfig = &factory.Config{ + Configuration: &factory.Configuration{ + NfInstanceId: testNfId, + NrfUri: nrfUri, + }, + } + if err := Init(); err != nil { + t.Fatalf("Init: %v", err) + } +} + +// newNrfTestContext is newTestContext with the openapi cleartext HTTP/2 client +// intercepted by gock. +func newNrfTestContext(t *testing.T) { + t.Helper() + + newTestContext(t, testNrfUri) + + openapi.InterceptInnerHttp2Client(t, false) + t.Cleanup(func() { + // OffAll, not Off: only OffAll clears the unmatched request registry the + // shutdown tests assert on. + gock.OffAll() + }) +} + +// nfProfileJSON is an NRF NF profile reply body. It omits heartBeatTimer when +// timer is 0 and customInfo when it is nil. +func nfProfileJSON(timer int32, customInfo map[string]any) map[string]any { + body := map[string]any{ + "nfInstanceId": testNfId, + "nfType": "AF", + "nfStatus": "REGISTERED", + } + if timer > 0 { + body["heartBeatTimer"] = timer + } + if customInfo != nil { + body["customInfo"] = customInfo + } + return body +} + +func problemJSON(status int, cause string) map[string]any { + return map[string]any{"status": status, "cause": cause} +} + +func TestSendUpdateNFInstance(t *testing.T) { + tests := []struct { + name string + status int + body map[string]any + wantTimer int32 + wantErr bool + }{ + { + name: "200 returns the updated profile", + status: http.StatusOK, + body: nfProfileJSON(20, nil), + wantTimer: 20, + }, + { + name: "204 returns an empty profile", + status: http.StatusNoContent, + }, + { + name: "404 reports the unknown profile", + status: http.StatusNotFound, + body: problemJSON(http.StatusNotFound, causeNotFound), + wantErr: true, + }, + { + name: "500 reports the NRF failure", + status: http.StatusInternalServerError, + body: problemJSON(http.StatusInternalServerError, causeSystemFailure), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newNrfTestContext(t) + + reply := gock.New(testNrfUri). + Patch(testNfIdPath). + MatchHeader("Content-Type", "application/json-patch+json"). + JSON([]map[string]any{ + {"op": "replace", "path": "/nfStatus", "value": "REGISTERED"}, + }). + Reply(tt.status) + if tt.body != nil { + reply.JSON(tt.body) + } + + nf, pd, err := SendUpdateNFInstance(t.Context(), nfheartbeat.PatchItems()) + + if !gock.IsDone() { + t.Fatal("the heartbeat PATCH was not sent as expected") + } + if !tt.wantErr { + if err != nil { + t.Fatalf("SendUpdateNFInstance: pd=%+v err=%v", pd, err) + } + if nf.HeartBeatTimer != tt.wantTimer { + t.Errorf("HeartBeatTimer = %d, want %d", nf.HeartBeatTimer, tt.wantTimer) + } + return + } + + var apiErr openapi.GenericOpenAPIError + if !errors.As(err, &apiErr) { + t.Fatalf("err = %T (%v), want openapi.GenericOpenAPIError", err, err) + } + if apiErr.ErrorStatus != tt.status { + t.Errorf("ErrorStatus = %d, want %d", apiErr.ErrorStatus, tt.status) + } + if pd == nil || pd.Status != int32(tt.status) { + t.Errorf("ProblemDetails = %+v, want status %d", pd, tt.status) + } + }) + } +} + +func TestSendUpdateNFInstanceWithoutNrfUri(t *testing.T) { + newTestContext(t, "") + + if _, _, err := SendUpdateNFInstance(t.Context(), nfheartbeat.PatchItems()); err == nil { + t.Error("SendUpdateNFInstance must report the missing NRF instead of panicking") + } +} + +func TestSendDeregisterNFInstance(t *testing.T) { + tests := []struct { + name string + status int + body map[string]any + wantErr bool + wantDetail bool + }{ + { + name: "204 deregisters the profile", + status: http.StatusNoContent, + }, + { + name: "404 reports the unknown profile", + status: http.StatusNotFound, + body: problemJSON(http.StatusNotFound, causeNotFound), + wantErr: true, + wantDetail: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newNrfTestContext(t) + + reply := gock.New(testNrfUri). + Delete(testNfIdPath). + Reply(tt.status) + if tt.body != nil { + reply.JSON(tt.body) + } + + pd, err := SendDeregisterNFInstance() + + if !gock.IsDone() { + t.Fatal("the deregistration DELETE was not sent as expected") + } + if gotErr := err != nil; gotErr != tt.wantErr { + t.Errorf("SendDeregisterNFInstance err = %v, want error %v", err, tt.wantErr) + } + if gotDetail := pd != nil; gotDetail != tt.wantDetail { + t.Errorf("ProblemDetails = %+v, want detail %v", pd, tt.wantDetail) + } + if tt.wantDetail && pd.Status != int32(tt.status) { + t.Errorf("ProblemDetails.Status = %d, want %d", pd.Status, tt.status) + } + }) + } +} + +// TestSendNFRegistrationRetriesUntilSuccess drives the retry loop: the first +// PUT fails on the NRF, the retry one interval later succeeds. +func TestSendNFRegistrationRetriesUntilSuccess(t *testing.T) { + const assignedTimer = 15 + + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(http.StatusInternalServerError). + JSON(problemJSON(http.StatusInternalServerError, causeSystemFailure)) + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(http.StatusCreated). + JSON(nfProfileJSON(assignedTimer, nil)) + + if err := SendNFRegistration(t.Context(), false); err != nil { + t.Fatalf("SendNFRegistration: %v", err) + } + if !gock.IsDone() { + t.Fatal("expected a failed PUT followed by a successful retry") + } + if GetSelf().heartbeatTimer != assignedTimer { + t.Errorf("heartbeatTimer = %d, want %d from the retry", GetSelf().heartbeatTimer, assignedTimer) + } + }) +} + +// TestSendNFRegistrationStopsOnCancel proves the retry loop gives up once the +// context is canceled instead of running to the attempt limit. +func TestSendNFRegistrationStopsOnCancel(t *testing.T) { + const deadline = 3 * time.Second + + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + + gock.New(testNrfUri). + Put(testNfIdPath). + Persist(). + Reply(http.StatusInternalServerError). + JSON(problemJSON(http.StatusInternalServerError, causeSystemFailure)) + + ctx, cancel := context.WithTimeout(t.Context(), deadline) + defer cancel() + + if err := SendNFRegistration(ctx, false); err == nil { + t.Fatal("SendNFRegistration must fail once the context is canceled") + } + }) +} + +// TestSendNFRegistrationGivesUp proves the attempt limit still bounds the loop +// when the context stays alive. +func TestSendNFRegistrationGivesUp(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + newNrfTestContext(t) + + gock.New(testNrfUri). + Put(testNfIdPath). + Persist(). + Reply(http.StatusInternalServerError). + JSON(problemJSON(http.StatusInternalServerError, causeSystemFailure)) + + if err := SendNFRegistration(t.Context(), false); err == nil { + t.Fatal("SendNFRegistration must report the exhausted attempt limit") + } + }) +} + +func TestSendNFRegistration(t *testing.T) { + tests := []struct { + name string + status int + body map[string]any + applyOAuth2 bool + wantTimer int32 + wantOAuth2 bool + }{ + { + name: "201 adopts the returned timer", + status: http.StatusCreated, + body: nfProfileJSON(15, map[string]any{"oauth2": true}), + applyOAuth2: true, + wantTimer: 15, + wantOAuth2: true, + }, + { + name: "200 on a profile the NRF already holds", + status: http.StatusOK, + body: nfProfileJSON(25, nil), + wantTimer: 25, + }, + { + name: "re-registration leaves the oauth2 setting untouched", + status: http.StatusCreated, + body: nfProfileJSON(0, map[string]any{"oauth2": true}), + applyOAuth2: false, + wantOAuth2: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newNrfTestContext(t) + + gock.New(testNrfUri). + Put(testNfIdPath). + Reply(tt.status). + JSON(tt.body) + + if err := SendNFRegistration(t.Context(), tt.applyOAuth2); err != nil { + t.Fatalf("SendNFRegistration: %v", err) + } + if !gock.IsDone() { + t.Fatal("the registration PUT was not sent as expected") + } + if GetSelf().heartbeatTimer != tt.wantTimer { + t.Errorf("heartbeatTimer = %d, want %d", GetSelf().heartbeatTimer, tt.wantTimer) + } + if got := GetSelf().OAuth2Required; got != tt.wantOAuth2 { + t.Errorf("OAuth2Required = %v, want %v", got, tt.wantOAuth2) + } + // The webconsole keeps the instance ID it chose, whatever the NRF echoes back. + if got := GetSelf().NfInstanceID; got != testNfId { + t.Errorf("NfInstanceID = %q, want %q", got, testNfId) + } + }) + } +} diff --git a/backend/webui_service/webui_init.go b/backend/webui_service/webui_init.go index 9cf1191..3fd558d 100644 --- a/backend/webui_service/webui_init.go +++ b/backend/webui_service/webui_init.go @@ -32,6 +32,10 @@ type WebuiApp struct { cfg *factory.Config webuiCtx *webui_context.WEBUIContext + // ctx is canceled by Terminate, which is what stops the NF heartbeat. + ctx context.Context + cancel context.CancelFunc + wg *sync.WaitGroup server *http.Server billingServer *billing.BillingDomain @@ -46,8 +50,11 @@ func NewApp(cfg *factory.Config) (*WebuiApp, error) { webui.SetLogLevel(cfg.GetLogLevel()) webui.SetReportCaller(cfg.GetLogReportCaller()) - webui_context.Init() + if err := webui_context.Init(); err != nil { + return nil, err + } webui.webuiCtx = webui_context.GetSelf() + webui.ctx, webui.cancel = context.WithCancel(context.Background()) return webui, nil } @@ -120,16 +127,19 @@ func (a *WebuiApp) Start(tlsKeyLogPath string) { }() go func() { - err := webui_context.SendNFRegistration() + err := webui_context.SendNFRegistration(a.ctx, true) if err != nil { - retry_err := webui_context.RetrySendNFRegistration(1) + retry_err := webui_context.RetrySendNFRegistration(a.ctx, 1, true) if retry_err != nil { logger.InitLog.Errorln(retry_err) logger.InitLog.Warningln("The registration to NRF failed, resulting in limited functionalities.") + return } - } else { - a.webuiCtx.IsRegistered = true } + a.webuiCtx.IsRegistered = true + // Only a registered profile has something to keep alive. Start is a no-op + // once the context is done, so racing Terminate here is safe. + webui_context.StartHeartbeat(a.ctx, a.wg) }() router := WebUI.NewRouter() @@ -189,6 +199,9 @@ func (a *WebuiApp) Start(tlsKeyLogPath string) { func (a *WebuiApp) Terminate() { logger.MainLog.Infoln("Terminating WebUI-AF...") + // Nothing else cancels the context, and the wait below would never return. + a.cancel() + if a.billingServer != nil { a.billingServer.Stop() } @@ -205,6 +218,9 @@ func (a *WebuiApp) Terminate() { // Deregister with NRF if a.webuiCtx.IsRegistered { + // no heartbeat PATCH or re-registration PUT may land after the deregistration + webui_context.WaitHeartbeatStopped() + problemDetails, err := webui_context.SendDeregisterNFInstance() if problemDetails != nil { logger.InitLog.Errorf("Deregister NF instance Failed Problem[%+v]", problemDetails) diff --git a/go.mod b/go.mod index 5fbed58..29e8d07 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/free5gc/chf v1.2.6-0.20260819160712-4e06244b5612 github.com/free5gc/openapi v1.3.0 github.com/free5gc/smf v1.4.6-0.20260819160737-dd94a6c43508 - github.com/free5gc/util v1.4.0 + github.com/free5gc/util v1.4.1-0.20260918051623-86c859a404ad github.com/gin-contrib/cors v1.6.0 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.2 @@ -51,7 +51,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect - github.com/h2non/gock v1.2.0 // indirect + github.com/h2non/gock v1.2.0 github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect diff --git a/go.sum b/go.sum index 1081d84..bec9adf 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,8 @@ github.com/free5gc/openapi v1.3.0 h1:dVJ9FXM2qr8jesiHeTR9ZGZwYRmr7qcHx5Xgm/ip6s0 github.com/free5gc/openapi v1.3.0/go.mod h1:ggzAcwqg4JotSSdUvFFFruK6M0Yyr6mhBOCFaDHZmdE= github.com/free5gc/smf v1.4.6-0.20260819160737-dd94a6c43508 h1:ST4LgftrkU6+kRvqLS5CP1wjDw+Yzs/85Ab3nu8hrH8= github.com/free5gc/smf v1.4.6-0.20260819160737-dd94a6c43508/go.mod h1:73OY0o2Hy3M+R4utx7JF1RzhMI9CU8QF6QxX5i+kKIE= -github.com/free5gc/util v1.4.0 h1:vdjzRUgxEYtjIy6AYkO4JuIQHV8DqBb1paNqJiajeus= -github.com/free5gc/util v1.4.0/go.mod h1:ihddsLLECOOIkYzAlgkK3CVXmQh9HTGEagaJT9Rv/+I= +github.com/free5gc/util v1.4.1-0.20260918051623-86c859a404ad h1:UPVIHmaWYap6oNbpOSnqO80dh10XyuVPlODp4fxo7cA= +github.com/free5gc/util v1.4.1-0.20260918051623-86c859a404ad/go.mod h1:/KTrB9YEXvGFBoOuX18kdPlwVuOd4MIbdHC/Qhq2wTU= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=