From b635476c20c0050a7a71f41fc5c59e7c4c2e3f05 Mon Sep 17 00:00:00 2001 From: Niahh Date: Mon, 21 Sep 2026 15:49:18 +0000 Subject: [PATCH] feat(webui): send periodic NF heartbeat to NRF Send an NFUpdate PATCH with nfStatus REGISTERED at the interval the NRF returns, per 3GPP TS 29.510 clause 5.2.2.3.2. Re-adopt heartBeatTimer from every answer. Re-register on 404 or after three consecutive failures. The webconsole registers as an AF, and the NRF sweep that suspends silent instances filters on nfStatus and lastHeartBeat alone, so the AF profile goes stale like any other without this. The heartbeat loop lives in the util nfheartbeat package; the webui context only supplies the PATCH and re-registration transport. The nfHeartBeatTimer config option only sets the fallback interval. The NRF value always wins. SendNFRegistration takes a context and an applyOAuth2 flag. The retry wait is interruptible, so a shutdown during an NRF outage no longer sleeps through it, and OAuth2Required is written only by the startup registration: the request handlers read it concurrently, so the re-registration from the heartbeat goroutine must not race them. Also drop the instance ID parsed from the register response Location: the generated client only fills it on 201, so a re-registration against an NRF that still holds the profile was overwriting NfInstanceID with an empty string. That branch also never broke out of the retry loop, so a successful registration always sent a second PUT. Init returns an error now, since it builds the heartbeat runner, and the app carries a context that Terminate cancels before waiting for the heartbeat goroutine and deregistering. --- backend/factory/config.go | 15 + backend/factory/config_test.go | 72 ++++ backend/webui_context/context.go | 23 +- backend/webui_context/nrf_heartbeat.go | 39 ++ .../nrf_heartbeat_internal_test.go | 177 +++++++++ backend/webui_context/nrf_management.go | 128 ++++--- .../nrf_management_internal_test.go | 345 ++++++++++++++++++ backend/webui_service/webui_init.go | 26 +- go.mod | 4 +- go.sum | 4 +- 10 files changed, 782 insertions(+), 51 deletions(-) create mode 100644 backend/factory/config_test.go create mode 100644 backend/webui_context/nrf_heartbeat.go create mode 100644 backend/webui_context/nrf_heartbeat_internal_test.go create mode 100644 backend/webui_context/nrf_management_internal_test.go 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=