Skip to content
Open
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
15 changes: 15 additions & 0 deletions backend/factory/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/asaskevich/govalidator"
"github.com/google/uuid"

"github.com/free5gc/util/nfheartbeat"
"github.com/free5gc/webconsole/backend/logger"
)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
72 changes: 72 additions & 0 deletions backend/factory/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
23 changes: 22 additions & 1 deletion backend/webui_context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 {
Expand All @@ -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

Expand All @@ -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() {
Expand Down
39 changes: 39 additions & 0 deletions backend/webui_context/nrf_heartbeat.go
Original file line number Diff line number Diff line change
@@ -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()
}
177 changes: 177 additions & 0 deletions backend/webui_context/nrf_heartbeat_internal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading