First go version of o-ru-closed-loop 17/6817/3
authorelinuxhenrik <henrik.b.andersson@est.tech>
Tue, 24 Aug 2021 15:01:24 +0000 (17:01 +0200)
committerelinuxhenrik <henrik.b.andersson@est.tech>
Tue, 5 Oct 2021 11:27:34 +0000 (13:27 +0200)
Issue-ID: NONRTRIC-588
Signed-off-by: elinuxhenrik <henrik.b.andersson@est.tech>
Change-Id: I41390ec58eb8281d87b92da8b0893666aa00ae3e

24 files changed:
test/usecases/oruclosedlooprecovery/goversion/.gitignore [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/Dockerfile [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/build-oruclosedloopconsumer-ubuntu.sh [new file with mode: 0755]
test/usecases/oruclosedlooprecovery/goversion/container-tag.yaml [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/go.mod [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/go.sum [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/config/config.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/config/config_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/restclient/client.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/ves/message.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/internal/ves/message_test.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/main.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/mocks/CsvFileHelper.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/mocks/HTTPClient.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/mocks/LookupService.go [new file with mode: 0644]
test/usecases/oruclosedlooprecovery/goversion/o-ru-to-o-du-map.csv [new file with mode: 0644]

diff --git a/test/usecases/oruclosedlooprecovery/goversion/.gitignore b/test/usecases/oruclosedlooprecovery/goversion/.gitignore
new file mode 100644 (file)
index 0000000..06758a7
--- /dev/null
@@ -0,0 +1,4 @@
+*.out
+.history
+
+oruclosedloop
diff --git a/test/usecases/oruclosedlooprecovery/goversion/Dockerfile b/test/usecases/oruclosedlooprecovery/goversion/Dockerfile
new file mode 100644 (file)
index 0000000..2462c44
--- /dev/null
@@ -0,0 +1,30 @@
+##
+## Build
+##
+FROM golang:1.17.1-bullseye AS build
+
+WORKDIR /app
+
+COPY go.mod ./
+COPY go.sum ./
+RUN go mod download
+
+COPY . ./
+
+RUN go build -o /docker-oruclosedloop
+
+##
+## Deploy
+##
+FROM gcr.io/distroless/base-debian10
+
+WORKDIR /
+
+## Copy from "build" stage
+COPY --from=build /docker-oruclosedloop .
+
+COPY --from=build /app/o-ru-to-o-du-map.csv .
+
+USER nonroot:nonroot
+
+ENTRYPOINT ["/docker-oruclosedloop"]
diff --git a/test/usecases/oruclosedlooprecovery/goversion/build-oruclosedloopconsumer-ubuntu.sh b/test/usecases/oruclosedlooprecovery/goversion/build-oruclosedloopconsumer-ubuntu.sh
new file mode 100755 (executable)
index 0000000..225fbb5
--- /dev/null
@@ -0,0 +1,40 @@
+#!/bin/bash
+##############################################################################
+#
+#   Copyright (C) 2021: Nordix Foundation
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+##############################################################################
+set -eux
+
+echo "--> build-oruclosedloopconsumer-ubuntu.sh"
+curdir=`pwd`
+# go installs tools like go-acc to $HOME/go/bin
+# ubuntu minion path lacks go
+export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
+go version
+cd test/usecases/oruclosedlooprecovery/goversion/
+
+# install the go coverage tool helper
+go get -v github.com/ory/go-acc
+
+export GO111MODULE=on
+go get github.com/stretchr/testify/mock@v1.7.0
+
+go-acc ./... --ignore mocks
+
+sed -i -e 's/oransc\.org\/usecase\/oruclosedloop\///g' coverage.txt
+
+cp coverage.txt $curdir
+echo "--> build-oruclosedloopconsumer-ubuntu.sh ends"
diff --git a/test/usecases/oruclosedlooprecovery/goversion/container-tag.yaml b/test/usecases/oruclosedlooprecovery/goversion/container-tag.yaml
new file mode 100644 (file)
index 0000000..6b1c9db
--- /dev/null
@@ -0,0 +1,5 @@
+# The Jenkins job requires a tag to build the Docker image.
+# By default this file is in the docker build directory,
+# but the location can configured in the JJB template.
+---
+tag: 1.0.0
diff --git a/test/usecases/oruclosedlooprecovery/goversion/go.mod b/test/usecases/oruclosedlooprecovery/goversion/go.mod
new file mode 100644 (file)
index 0000000..754bba1
--- /dev/null
@@ -0,0 +1,18 @@
+module oransc.org/usecase/oruclosedloop
+
+go 1.17
+
+require (
+       github.com/sirupsen/logrus v1.8.1
+       github.com/stretchr/testify v1.7.0
+)
+
+require (
+       github.com/davecgh/go-spew v1.1.1 // indirect
+       github.com/google/uuid v1.3.0 // indirect
+       github.com/gorilla/mux v1.8.0 // indirect
+       github.com/pmezard/go-difflib v1.0.0 // indirect
+       github.com/stretchr/objx v0.1.1 // indirect
+       golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect
+       gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
+)
diff --git a/test/usecases/oruclosedlooprecovery/goversion/go.sum b/test/usecases/oruclosedlooprecovery/goversion/go.sum
new file mode 100644 (file)
index 0000000..6ce7604
--- /dev/null
@@ -0,0 +1,23 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/config/config.go b/test/usecases/oruclosedlooprecovery/goversion/internal/config/config.go
new file mode 100644 (file)
index 0000000..43656b7
--- /dev/null
@@ -0,0 +1,84 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package config
+
+import (
+       "os"
+       "strconv"
+
+       log "github.com/sirupsen/logrus"
+)
+
+type Config struct {
+       LogLevel               log.Level
+       ConsumerHost           string
+       ConsumerPort           int
+       InfoCoordinatorAddress string
+       SDNRHost               string
+       SDNRPort               int
+       SDNRUser               string
+       SDNPassword            string
+       ORUToODUMapFile        string
+}
+
+func New() *Config {
+       return &Config{
+               LogLevel:               getLogLevel(),
+               ConsumerHost:           getEnv("CONSUMER_HOST", ""),
+               ConsumerPort:           getEnvAsInt("CONSUMER_PORT", 0),
+               InfoCoordinatorAddress: getEnv("INFO_COORD_ADDR", "http://enrichmentservice:8083"),
+               SDNRHost:               getEnv("SDNR_HOST", "http://localhost"),
+               SDNRPort:               getEnvAsInt("SDNR_PORT", 3904),
+               SDNRUser:               getEnv("SDNR_USER", "admin"),
+               SDNPassword:            getEnv("SDNR_PASSWORD", "Kp8bJ4SXszM0WXlhak3eHlcse2gAw84vaoGGmJvUy2U"),
+               ORUToODUMapFile:        getEnv("ORU_TO_ODU_MAP_FILE", "o-ru-to-o-du-map.csv"),
+       }
+}
+
+func getEnv(key string, defaultVal string) string {
+       if value, exists := os.LookupEnv(key); exists {
+               return value
+       }
+
+       return defaultVal
+}
+
+func getEnvAsInt(name string, defaultVal int) int {
+       valueStr := getEnv(name, "")
+       if value, err := strconv.Atoi(valueStr); err == nil {
+               return value
+       } else if valueStr != "" {
+               log.Warnf("Invalid int value: %v for variable: %v. Default value: %v will be used", valueStr, name, defaultVal)
+       }
+
+       return defaultVal
+}
+
+func getLogLevel() log.Level {
+       logLevelStr := getEnv("LOG_LEVEL", "Info")
+       if loglevel, err := log.ParseLevel(logLevelStr); err == nil {
+               return loglevel
+       } else {
+               log.Warnf("Invalid log level: %v. Log level will be Info!", logLevelStr)
+               return log.InfoLevel
+       }
+
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/config/config_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/config/config_test.go
new file mode 100644 (file)
index 0000000..e278e60
--- /dev/null
@@ -0,0 +1,119 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package config
+
+import (
+       "bytes"
+       "os"
+       "reflect"
+       "testing"
+
+       log "github.com/sirupsen/logrus"
+       "github.com/stretchr/testify/require"
+)
+
+func TestNew_envVarsSetConfigContainSetValues(t *testing.T) {
+       os.Setenv("LOG_LEVEL", "Debug")
+       os.Setenv("CONSUMER_HOST", "consumerHost")
+       os.Setenv("CONSUMER_PORT", "8095")
+       os.Setenv("INFO_COORD_ADDR", "infoCoordAddr")
+       os.Setenv("SDNR_HOST", "sdnrHost")
+       os.Setenv("SDNR_PORT", "3908")
+       os.Setenv("SDNR_USER", "admin")
+       os.Setenv("SDNR_PASSWORD", "pwd")
+       os.Setenv("ORU_TO_ODU_MAP_FILE", "file")
+       t.Cleanup(func() {
+               os.Clearenv()
+       })
+       wantConfig := Config{
+               LogLevel:               log.DebugLevel,
+               ConsumerHost:           "consumerHost",
+               ConsumerPort:           8095,
+               InfoCoordinatorAddress: "infoCoordAddr",
+               SDNRHost:               "sdnrHost",
+               SDNRPort:               3908,
+               SDNRUser:               "admin",
+               SDNPassword:            "pwd",
+               ORUToODUMapFile:        "file",
+       }
+       if got := New(); !reflect.DeepEqual(got, &wantConfig) {
+               t.Errorf("New() = %v, want %v", got, &wantConfig)
+       }
+}
+
+func TestNew_faultyIntValueSetConfigContainDefaultValueAndWarnInLog(t *testing.T) {
+       assertions := require.New(t)
+       var buf bytes.Buffer
+       log.SetOutput(&buf)
+
+       os.Setenv("CONSUMER_PORT", "wrong")
+       t.Cleanup(func() {
+               log.SetOutput(os.Stderr)
+               os.Clearenv()
+       })
+       wantConfig := Config{
+               LogLevel:               log.InfoLevel,
+               ConsumerHost:           "",
+               ConsumerPort:           0,
+               InfoCoordinatorAddress: "http://enrichmentservice:8083",
+               SDNRHost:               "http://localhost",
+               SDNRPort:               3904,
+               SDNRUser:               "admin",
+               SDNPassword:            "Kp8bJ4SXszM0WXlhak3eHlcse2gAw84vaoGGmJvUy2U",
+               ORUToODUMapFile:        "o-ru-to-o-du-map.csv",
+       }
+       if got := New(); !reflect.DeepEqual(got, &wantConfig) {
+               t.Errorf("New() = %v, want %v", got, &wantConfig)
+       }
+       logString := buf.String()
+       assertions.Contains(logString, "Invalid int value: wrong for variable: CONSUMER_PORT. Default value: 0 will be used")
+}
+
+func TestNew_envFaultyLogLevelConfigContainDefaultValues(t *testing.T) {
+       assertions := require.New(t)
+       var buf bytes.Buffer
+       log.SetOutput(&buf)
+
+       os.Setenv("LOG_LEVEL", "wrong")
+       t.Cleanup(func() {
+               log.SetOutput(os.Stderr)
+               os.Clearenv()
+       })
+       wantConfig := Config{
+               LogLevel:               log.InfoLevel,
+               ConsumerHost:           "",
+               ConsumerPort:           0,
+               InfoCoordinatorAddress: "http://enrichmentservice:8083",
+               SDNRHost:               "http://localhost",
+               SDNRPort:               3904,
+               SDNRUser:               "admin",
+               SDNPassword:            "Kp8bJ4SXszM0WXlhak3eHlcse2gAw84vaoGGmJvUy2U",
+               ORUToODUMapFile:        "o-ru-to-o-du-map.csv",
+       }
+       if got := New(); !reflect.DeepEqual(got, &wantConfig) {
+               t.Errorf("New() = %v, want %v", got, &wantConfig)
+       }
+       if got := New(); !reflect.DeepEqual(got, &wantConfig) {
+               t.Errorf("New() = %v, want %v", got, &wantConfig)
+       }
+       logString := buf.String()
+       assertions.Contains(logString, "Invalid log level: wrong. Log level will be Info!")
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler.go b/test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler.go
new file mode 100644 (file)
index 0000000..ebcf312
--- /dev/null
@@ -0,0 +1,113 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package linkfailure
+
+import (
+       "encoding/json"
+       "io/ioutil"
+       "net/http"
+       "strings"
+
+       log "github.com/sirupsen/logrus"
+
+       "oransc.org/usecase/oruclosedloop/internal/repository"
+       "oransc.org/usecase/oruclosedloop/internal/restclient"
+       "oransc.org/usecase/oruclosedloop/internal/ves"
+)
+
+type Configuration struct {
+       ConsumerAddress  string
+       InfoCoordAddress string
+       SDNRAddress      string
+       SDNRUser         string
+       SDNRPassword     string
+}
+
+const rawSdnrPath = "/rests/data/network-topology:network-topology/topology=topology-netconf/node=[O-DU-ID]/yang-ext:mount/o-ran-sc-du-hello-world:network-function/du-to-ru-connection=[O-RU-ID]"
+
+const unlockMessage = `{"o-ran-sc-du-hello-world:du-to-ru-connection": [{"name":"[O-RU-ID]","administrative-state":"UNLOCKED"}]}`
+
+type LinkFailureHandler struct {
+       lookupService repository.LookupService
+       config        Configuration
+}
+
+func NewLinkFailureHandler(ls repository.LookupService, conf Configuration) *LinkFailureHandler {
+       return &LinkFailureHandler{
+               lookupService: ls,
+               config:        conf,
+       }
+}
+
+func (lfh LinkFailureHandler) MessagesHandler(w http.ResponseWriter, r *http.Request) {
+       log.Debug("Handling messages")
+       if messages := lfh.getVesMessages(r); messages != nil {
+               faultMessages := ves.GetFaultMessages(messages)
+
+               for _, message := range faultMessages {
+                       if message.IsLinkFailure() {
+                               lfh.sendUnlockMessage(message.GetORuId())
+                       } else if message.IsClearLinkFailure() {
+                               log.Debugf("Cleared Link failure for O-RU ID: %v", message.GetORuId())
+                       }
+               }
+       }
+}
+
+func (lfh LinkFailureHandler) sendUnlockMessage(oRuId string) {
+       if oDuId, err := lfh.lookupService.GetODuID(oRuId); err == nil {
+               sdnrPath := getSdnrPath(oRuId, oDuId)
+               unlockMessage := lfh.getUnlockMessage(oRuId)
+               if error := restclient.Put(lfh.config.SDNRAddress+sdnrPath, unlockMessage, lfh.config.SDNRUser, lfh.config.SDNRPassword); error == nil {
+                       log.Debugf("Sent unlock message for O-RU: %v to O-DU: %v.", oRuId, oDuId)
+               } else {
+                       log.Warn(error)
+               }
+       } else {
+               log.Warn(err)
+       }
+
+}
+
+func getSdnrPath(oRuId string, oDuId string) string {
+       sdnrPath := strings.Replace(rawSdnrPath, "[O-DU-ID]", oDuId, 1)
+       sdnrPath = strings.Replace(sdnrPath, "[O-RU-ID]", oRuId, 1)
+       return sdnrPath
+}
+
+func (lfh LinkFailureHandler) getUnlockMessage(oRuId string) string {
+       return strings.Replace(unlockMessage, "[O-RU-ID]", oRuId, 1)
+}
+
+func (lfh LinkFailureHandler) getVesMessages(r *http.Request) *[]string {
+       var messages []string
+       body, err := ioutil.ReadAll(r.Body)
+       if err != nil {
+               log.Warn(err)
+               return nil
+       }
+       err = json.Unmarshal(body, &messages)
+       if err != nil {
+               log.Warn(err)
+               return nil
+       }
+       return &messages
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/linkfailure/linkfailurehandler_test.go
new file mode 100644 (file)
index 0000000..9653c99
--- /dev/null
@@ -0,0 +1,183 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package linkfailure
+
+import (
+       "bytes"
+       "encoding/json"
+       "io/ioutil"
+       "net/http"
+       "net/http/httptest"
+       "os"
+       "testing"
+
+       log "github.com/sirupsen/logrus"
+
+       "github.com/stretchr/testify/mock"
+       "github.com/stretchr/testify/require"
+       "oransc.org/usecase/oruclosedloop/internal/repository"
+       "oransc.org/usecase/oruclosedloop/internal/restclient"
+       "oransc.org/usecase/oruclosedloop/internal/ves"
+       "oransc.org/usecase/oruclosedloop/mocks"
+)
+
+func Test_MessagesHandlerWithLinkFailure(t *testing.T) {
+       log.SetLevel(log.DebugLevel)
+       assertions := require.New(t)
+
+       var buf bytes.Buffer
+       log.SetOutput(&buf)
+       defer func() {
+               log.SetOutput(os.Stderr)
+       }()
+
+       clientMock := mocks.HTTPClient{}
+
+       clientMock.On("Do", mock.Anything).Return(&http.Response{
+               StatusCode: http.StatusOK,
+       }, nil)
+
+       restclient.Client = &clientMock
+
+       lookupServiceMock := mocks.LookupService{}
+
+       lookupServiceMock.On("GetODuID", mock.Anything).Return("HCL-O-DU-1122", nil)
+
+       handlerUnderTest := NewLinkFailureHandler(&lookupServiceMock, Configuration{
+               SDNRAddress:  "http://localhost:9990",
+               SDNRUser:     "admin",
+               SDNRPassword: "pwd",
+       })
+
+       responseRecorder := httptest.NewRecorder()
+       r := newRequest(http.MethodPost, "/", getFaultMessage("ERICSSON-O-RU-11220", "CRITICAL"), t)
+       handler := http.HandlerFunc(handlerUnderTest.MessagesHandler)
+       handler.ServeHTTP(responseRecorder, r)
+       assertions.Equal(http.StatusOK, responseRecorder.Result().StatusCode)
+
+       var actualRequest *http.Request
+       clientMock.AssertCalled(t, "Do", mock.MatchedBy(func(req *http.Request) bool {
+               actualRequest = req
+               return true
+       }))
+       assertions.Equal(http.MethodPut, actualRequest.Method)
+       assertions.Equal("http", actualRequest.URL.Scheme)
+       assertions.Equal("localhost:9990", actualRequest.URL.Host)
+       expectedSdnrPath := "/rests/data/network-topology:network-topology/topology=topology-netconf/node=HCL-O-DU-1122/yang-ext:mount/o-ran-sc-du-hello-world:network-function/du-to-ru-connection=ERICSSON-O-RU-11220"
+       assertions.Equal(expectedSdnrPath, actualRequest.URL.Path)
+       assertions.Equal("application/json; charset=utf-8", actualRequest.Header.Get("Content-Type"))
+       tempRequest, _ := http.NewRequest("", "", nil)
+       tempRequest.SetBasicAuth("admin", "pwd")
+       assertions.Equal(tempRequest.Header.Get("Authorization"), actualRequest.Header.Get("Authorization"))
+       body, _ := ioutil.ReadAll(actualRequest.Body)
+       expectedBody := []byte(`{"o-ran-sc-du-hello-world:du-to-ru-connection": [{"name":"ERICSSON-O-RU-11220","administrative-state":"UNLOCKED"}]}`)
+       assertions.Equal(expectedBody, body)
+       clientMock.AssertNumberOfCalls(t, "Do", 1)
+
+       logString := buf.String()
+       assertions.Contains(logString, "Sent unlock message")
+       assertions.Contains(logString, "O-RU: ERICSSON-O-RU-11220")
+       assertions.Contains(logString, "O-DU: HCL-O-DU-1122")
+}
+
+func newRequest(method string, url string, bodyAsBytes []byte, t *testing.T) *http.Request {
+       body := ioutil.NopCloser(bytes.NewReader(bodyAsBytes))
+       if req, err := http.NewRequest(method, url, body); err == nil {
+               return req
+       } else {
+               t.Fatalf("Could not create request due to: %v", err)
+               return nil
+       }
+}
+
+func Test_MessagesHandlerWithClearLinkFailure(t *testing.T) {
+       log.SetLevel(log.DebugLevel)
+       assertions := require.New(t)
+
+       var buf bytes.Buffer
+       log.SetOutput(&buf)
+       defer func() {
+               log.SetOutput(os.Stderr)
+       }()
+
+       lookupServiceMock := mocks.LookupService{}
+
+       lookupServiceMock.On("GetODuID", mock.Anything).Return("HCL-O-DU-1122", nil)
+
+       handlerUnderTest := NewLinkFailureHandler(&lookupServiceMock, Configuration{})
+
+       responseRecorder := httptest.NewRecorder()
+       r := newRequest(http.MethodPost, "/", getFaultMessage("ERICSSON-O-RU-11220", "NORMAL"), t)
+       handler := http.HandlerFunc(handlerUnderTest.MessagesHandler)
+       handler.ServeHTTP(responseRecorder, r)
+       assertions.Equal(http.StatusOK, responseRecorder.Result().StatusCode)
+
+       logString := buf.String()
+       assertions.Contains(logString, "Cleared Link failure")
+       assertions.Contains(logString, "O-RU ID: ERICSSON-O-RU-11220")
+}
+
+func Test_MessagesHandlerWithLinkFailureUnmappedORU(t *testing.T) {
+       log.SetLevel(log.DebugLevel)
+       assertions := require.New(t)
+
+       var buf bytes.Buffer
+       log.SetOutput(&buf)
+       defer func() {
+               log.SetOutput(os.Stderr)
+       }()
+
+       lookupServiceMock := mocks.LookupService{}
+
+       lookupServiceMock.On("GetODuID", mock.Anything).Return("", repository.IdNotMappedError{
+               Id: "ERICSSON-O-RU-11220",
+       })
+
+       handlerUnderTest := NewLinkFailureHandler(&lookupServiceMock, Configuration{})
+
+       responseRecorder := httptest.NewRecorder()
+       r := newRequest(http.MethodPost, "/", getFaultMessage("ERICSSON-O-RU-11220", "CRITICAL"), t)
+       handler := http.HandlerFunc(handlerUnderTest.MessagesHandler)
+       handler.ServeHTTP(responseRecorder, r)
+       assertions.Equal(http.StatusOK, responseRecorder.Result().StatusCode)
+
+       logString := buf.String()
+       assertions.Contains(logString, "O-RU-ID: ERICSSON-O-RU-11220 not mapped.")
+}
+
+func getFaultMessage(sourceName string, eventSeverity string) []byte {
+       linkFailureMessage := ves.FaultMessage{
+               Event: ves.Event{
+                       CommonEventHeader: ves.CommonEventHeader{
+                               Domain:     "fault",
+                               SourceName: sourceName,
+                       },
+                       FaultFields: ves.FaultFields{
+                               AlarmCondition: "28",
+                               EventSeverity:  eventSeverity,
+                       },
+               },
+       }
+       messageAsByteArray, _ := json.Marshal(linkFailureMessage)
+       response := [1]string{string(messageAsByteArray)}
+       responseAsByteArray, _ := json.Marshal(response)
+       return responseAsByteArray
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp.go b/test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp.go
new file mode 100644 (file)
index 0000000..ccaff8b
--- /dev/null
@@ -0,0 +1,51 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package repository
+
+import (
+       "encoding/csv"
+       "os"
+)
+
+type CsvFileHelper interface {
+       GetCsvFromFile(name string) ([][]string, error)
+}
+
+type CsvFileHelperImpl struct{}
+
+func NewCsvFileHelper() CsvFileHelperImpl {
+       return CsvFileHelperImpl{}
+}
+
+func (h *CsvFileHelperImpl) GetCsvFromFile(name string) ([][]string, error) {
+       if csvFile, err := os.Open(name); err == nil {
+               defer csvFile.Close()
+               reader := csv.NewReader(csvFile)
+               reader.FieldsPerRecord = -1
+               if csvData, err := reader.ReadAll(); err == nil {
+                       return csvData, nil
+               } else {
+                       return nil, err
+               }
+       } else {
+               return nil, err
+       }
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/repository/csvhelp_test.go
new file mode 100644 (file)
index 0000000..dfd29cc
--- /dev/null
@@ -0,0 +1,82 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package repository
+
+import (
+       "os"
+       "reflect"
+       "testing"
+)
+
+func TestCsvFileHelperImpl_GetCsvFromFile(t *testing.T) {
+       filePath := createTempCsvFile()
+       defer os.Remove(filePath)
+       type args struct {
+               name string
+       }
+       tests := []struct {
+               name       string
+               fileHelper *CsvFileHelperImpl
+               args       args
+               want       [][]string
+               wantErr    bool
+       }{
+               {
+                       name:       "Read from file should return array of content",
+                       fileHelper: &CsvFileHelperImpl{},
+                       args: args{
+                               name: filePath,
+                       },
+                       want:    [][]string{{"O-RU-ID", "O-DU-ID"}},
+                       wantErr: false,
+               },
+               {
+                       name:       "File missing should return error",
+                       fileHelper: &CsvFileHelperImpl{},
+                       args: args{
+                               name: "nofile.csv",
+                       },
+                       want:    nil,
+                       wantErr: true,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       h := &CsvFileHelperImpl{}
+                       got, err := h.GetCsvFromFile(tt.args.name)
+                       if (err != nil) != tt.wantErr {
+                               t.Errorf("CsvFileHelperImpl.GetCsvFromFile() error = %v, wantErr %v", err, tt.wantErr)
+                               return
+                       }
+                       if !reflect.DeepEqual(got, tt.want) {
+                               t.Errorf("CsvFileHelperImpl.GetCsvFromFile() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func createTempCsvFile() string {
+       csvFile, _ := os.CreateTemp("", "test*.csv")
+       filePath := csvFile.Name()
+       csvFile.Write([]byte("O-RU-ID,O-DU-ID"))
+       csvFile.Close()
+       return filePath
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice.go b/test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice.go
new file mode 100644 (file)
index 0000000..1b6fa69
--- /dev/null
@@ -0,0 +1,73 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package repository
+
+import (
+       "fmt"
+)
+
+type IdNotMappedError struct {
+       Id string
+}
+
+func (inme IdNotMappedError) Error() string {
+       return fmt.Sprintf("O-RU-ID: %v not mapped.", inme.Id)
+}
+
+type LookupService interface {
+       Init() error
+       GetODuID(oRuId string) (string, error)
+}
+
+type LookupServiceImpl struct {
+       csvFileHelper CsvFileHelper
+       csvFileName   string
+
+       oRuIdToODuIdMap map[string]string
+}
+
+func NewLookupServiceImpl(fileHelper CsvFileHelper, fileName string) *LookupServiceImpl {
+       s := LookupServiceImpl{
+               csvFileHelper: fileHelper,
+               csvFileName:   fileName,
+       }
+       s.oRuIdToODuIdMap = make(map[string]string)
+       return &s
+}
+
+func (s LookupServiceImpl) Init() error {
+       if csvData, err := s.csvFileHelper.GetCsvFromFile(s.csvFileName); err == nil {
+               for _, each := range csvData {
+                       s.oRuIdToODuIdMap[each[0]] = each[1]
+               }
+               return nil
+       } else {
+               return err
+       }
+}
+
+func (s LookupServiceImpl) GetODuID(oRuId string) (string, error) {
+       if oDuId, ok := s.oRuIdToODuIdMap[oRuId]; ok {
+               return oDuId, nil
+       } else {
+               return "", IdNotMappedError{Id: oRuId}
+       }
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/repository/lookupservice_test.go
new file mode 100644 (file)
index 0000000..5819456
--- /dev/null
@@ -0,0 +1,176 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package repository
+
+import (
+       "errors"
+       "reflect"
+       "testing"
+
+       "oransc.org/usecase/oruclosedloop/mocks"
+)
+
+func TestNewLookupServiceImpl(t *testing.T) {
+       mockCsvFileHelper := &mocks.CsvFileHelper{}
+       type args struct {
+               fileHelper CsvFileHelper
+               fileName   string
+       }
+       tests := []struct {
+               name string
+               args args
+               want *LookupServiceImpl
+       }{
+               {
+                       name: "Should return populated service",
+                       args: args{
+                               fileHelper: mockCsvFileHelper,
+                               fileName:   "test.csv",
+                       },
+                       want: &LookupServiceImpl{
+                               csvFileHelper:   mockCsvFileHelper,
+                               csvFileName:     "test.csv",
+                               oRuIdToODuIdMap: map[string]string{},
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       if got := NewLookupServiceImpl(tt.args.fileHelper, tt.args.fileName); !reflect.DeepEqual(got, tt.want) {
+                               t.Errorf("NewLookupServiceImpl() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestLookupServiceImpl_Init(t *testing.T) {
+       mockCsvFileHelper := &mocks.CsvFileHelper{}
+       mockCsvFileHelper.On("GetCsvFromFile", "./map.csv").Return([][]string{{"O-RU-ID", "O-DU-ID"}}, nil).Once()
+       mockCsvFileHelper.On("GetCsvFromFile", "foo.csv").Return(nil, errors.New("Error")).Once()
+       type fields struct {
+               csvFileHelper   CsvFileHelper
+               csvFileName     string
+               oRuIdToODuIdMap map[string]string
+       }
+       tests := []struct {
+               name    string
+               fields  fields
+               wantErr bool
+       }{
+               {
+                       name: "Init with proper csv file should not return error",
+                       fields: fields{
+                               csvFileHelper:   mockCsvFileHelper,
+                               csvFileName:     "./map.csv",
+                               oRuIdToODuIdMap: map[string]string{}},
+                       wantErr: false,
+               },
+               {
+                       name: "Init with missing file should return error",
+                       fields: fields{
+                               csvFileHelper:   mockCsvFileHelper,
+                               csvFileName:     "foo.csv",
+                               oRuIdToODuIdMap: map[string]string{},
+                       },
+                       wantErr: true,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       s := LookupServiceImpl{
+                               csvFileHelper:   tt.fields.csvFileHelper,
+                               csvFileName:     tt.fields.csvFileName,
+                               oRuIdToODuIdMap: tt.fields.oRuIdToODuIdMap,
+                       }
+                       if err := s.Init(); (err != nil) != tt.wantErr {
+                               t.Errorf("LookupServiceImpl.Init() error = %v, wantErr %v", err, tt.wantErr)
+                       } else if !tt.wantErr {
+                               wantedMap := map[string]string{"O-RU-ID": "O-DU-ID"}
+                               if !reflect.DeepEqual(wantedMap, s.oRuIdToODuIdMap) {
+                                       t.Errorf("LookupServiceImpl.Init() map not initialized, wanted map: %v, got map: %v", wantedMap, s.oRuIdToODuIdMap)
+                               }
+                       }
+               })
+       }
+       mockCsvFileHelper.AssertNumberOfCalls(t, "GetCsvFromFile", 2)
+}
+
+func TestLookupServiceImpl_GetODuID(t *testing.T) {
+       type fields struct {
+               csvFileHelper   CsvFileHelper
+               csvFileName     string
+               oRuIdToODuIdMap map[string]string
+       }
+       type args struct {
+               oRuId string
+       }
+       tests := []struct {
+               name    string
+               fields  fields
+               args    args
+               want    string
+               wantErr error
+       }{
+               {
+                       name: "Id mapped should return mapped id",
+                       fields: fields{
+                               csvFileHelper:   nil,
+                               csvFileName:     "",
+                               oRuIdToODuIdMap: map[string]string{"O-RU-ID": "O-DU-ID"},
+                       },
+                       args: args{
+                               oRuId: "O-RU-ID",
+                       },
+                       want:    "O-DU-ID",
+                       wantErr: nil,
+               },
+               {
+                       name: "Id not mapped should return IdNotMappedError",
+                       fields: fields{
+                               csvFileHelper:   nil,
+                               csvFileName:     "",
+                               oRuIdToODuIdMap: map[string]string{},
+                       },
+                       args: args{
+                               oRuId: "O-RU-ID",
+                       },
+                       want:    "",
+                       wantErr: IdNotMappedError{Id: "O-RU-ID"},
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       s := LookupServiceImpl{
+                               csvFileHelper:   tt.fields.csvFileHelper,
+                               csvFileName:     tt.fields.csvFileName,
+                               oRuIdToODuIdMap: tt.fields.oRuIdToODuIdMap,
+                       }
+                       got, err := s.GetODuID(tt.args.oRuId)
+                       if err != tt.wantErr {
+                               t.Errorf("LookupServiceImpl.GetODuID() error = %v, wantErr %v", err, tt.wantErr)
+                               return
+                       }
+                       if got != tt.want {
+                               t.Errorf("LookupServiceImpl.GetODuID() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/restclient/client.go b/test/usecases/oruclosedlooprecovery/goversion/internal/restclient/client.go
new file mode 100644 (file)
index 0000000..f038b25
--- /dev/null
@@ -0,0 +1,116 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package restclient
+
+import (
+       "bytes"
+       "fmt"
+       "io"
+       "net/http"
+       "time"
+)
+
+type RequestError struct {
+       StatusCode int
+       Body       []byte
+}
+
+func (pe RequestError) Error() string {
+       return fmt.Sprintf("Request failed due to error response with status: %v and body: %v", pe.StatusCode, string(pe.Body))
+}
+
+// HTTPClient interface
+type HTTPClient interface {
+       Get(url string) (*http.Response, error)
+
+       Do(*http.Request) (*http.Response, error)
+}
+
+var (
+       Client HTTPClient
+)
+
+func init() {
+       Client = &http.Client{
+               Timeout: time.Second * 5,
+       }
+}
+
+func Get(url string) ([]byte, error) {
+       if response, err := Client.Get(url); err == nil {
+               defer response.Body.Close()
+               if responseData, err := io.ReadAll(response.Body); err == nil {
+                       return responseData, nil
+               } else {
+                       return nil, err
+               }
+       } else {
+               return nil, err
+       }
+}
+
+func PutWithoutAuth(url string, body []byte) error {
+       return do(http.MethodPut, url, body)
+}
+
+func Put(url string, body string, userName string, password string) error {
+       return do(http.MethodPut, url, []byte(body), userName, password)
+}
+
+func Delete(url string) error {
+       return do(http.MethodDelete, url, nil)
+}
+
+func do(method string, url string, body []byte, userInfo ...string) error {
+       if req, reqErr := http.NewRequest(method, url, bytes.NewBuffer(body)); reqErr == nil {
+               if body != nil {
+                       req.Header.Set("Content-Type", "application/json; charset=utf-8")
+               }
+               if len(userInfo) > 0 {
+                       req.SetBasicAuth(userInfo[0], userInfo[1])
+               }
+               if response, respErr := Client.Do(req); respErr == nil {
+                       if isResponseSuccess(response.StatusCode) {
+                               return nil
+                       } else {
+                               return getResponseError(response)
+                       }
+               } else {
+                       return respErr
+               }
+       } else {
+               return reqErr
+       }
+}
+
+func isResponseSuccess(statusCode int) bool {
+       return statusCode >= http.StatusOK && statusCode <= 299
+}
+
+func getResponseError(response *http.Response) RequestError {
+       defer response.Body.Close()
+       responseData, _ := io.ReadAll(response.Body)
+       putError := RequestError{
+               StatusCode: response.StatusCode,
+               Body:       responseData,
+       }
+       return putError
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder.go b/test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder.go
new file mode 100644 (file)
index 0000000..2293612
--- /dev/null
@@ -0,0 +1,42 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package ves
+
+import (
+       "encoding/json"
+
+       log "github.com/sirupsen/logrus"
+)
+
+func GetFaultMessages(messageStrings *[]string) []FaultMessage {
+       faultMessages := make([]FaultMessage, 0, len(*messageStrings))
+       for _, msgString := range *messageStrings {
+               var message FaultMessage
+               if err := json.Unmarshal([]byte(msgString), &message); err == nil {
+                       if message.isFault() {
+                               faultMessages = append(faultMessages, message)
+                       }
+               } else {
+                       log.Warn(err)
+               }
+       }
+       return faultMessages
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/ves/decoder_test.go
new file mode 100644 (file)
index 0000000..75bee1f
--- /dev/null
@@ -0,0 +1,64 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package ves
+
+import (
+       "reflect"
+       "testing"
+)
+
+func TestGetFaultMessages(t *testing.T) {
+       type args struct {
+               messageStrings *[]string
+       }
+       tests := []struct {
+               name string
+               args args
+               want []FaultMessage
+       }{
+               {
+                       name: "",
+                       args: args{
+                               messageStrings: &[]string{"{\"event\":{\"commonEventHeader\":{\"domain\":\"heartbeat\"}}}",
+                                       `{"event":{"commonEventHeader":{"domain":"fault","sourceName":"ERICSSON-O-RU-11220"},"faultFields":{"eventSeverity":"CRITICAL","alarmCondition":"28"}}}`},
+                       },
+                       want: []FaultMessage{{
+                               Event: Event{
+                                       CommonEventHeader: CommonEventHeader{
+                                               Domain:     "fault",
+                                               SourceName: "ERICSSON-O-RU-11220",
+                                       },
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "28",
+                                               EventSeverity:  "CRITICAL",
+                                       },
+                               },
+                       }},
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       if got := GetFaultMessages(tt.args.messageStrings); !reflect.DeepEqual(got, tt.want) {
+                               t.Errorf("GetFaultMessages() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/ves/message.go b/test/usecases/oruclosedlooprecovery/goversion/internal/ves/message.go
new file mode 100644 (file)
index 0000000..ca8a3ca
--- /dev/null
@@ -0,0 +1,64 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package ves
+
+type FaultMessage struct {
+       Event Event `json:"event"`
+}
+
+type Event struct {
+       CommonEventHeader CommonEventHeader `json:"commonEventHeader"`
+       FaultFields       FaultFields       `json:"faultFields"`
+}
+
+type CommonEventHeader struct {
+       Domain     string `json:"domain"`
+       SourceName string `json:"sourceName"`
+}
+
+type FaultFields struct {
+       AlarmCondition string `json:"alarmCondition"`
+       EventSeverity  string `json:"eventSeverity"`
+}
+
+func (message FaultMessage) isFault() bool {
+       return message.Event.CommonEventHeader.Domain == "fault"
+}
+
+func (message FaultMessage) isLinkAlarm() bool {
+       return message.Event.FaultFields.AlarmCondition == "28"
+}
+
+func (message FaultMessage) isSeverityNormal() bool {
+       return message.Event.FaultFields.EventSeverity == "NORMAL"
+}
+
+func (message FaultMessage) IsLinkFailure() bool {
+       return message.isFault() && message.isLinkAlarm() && !message.isSeverityNormal()
+}
+
+func (message FaultMessage) IsClearLinkFailure() bool {
+       return message.isFault() && message.isLinkAlarm() && message.isSeverityNormal()
+}
+
+func (message FaultMessage) GetORuId() string {
+       return message.Event.CommonEventHeader.SourceName
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/internal/ves/message_test.go b/test/usecases/oruclosedlooprecovery/goversion/internal/ves/message_test.go
new file mode 100644 (file)
index 0000000..30c7b72
--- /dev/null
@@ -0,0 +1,262 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package ves
+
+import "testing"
+
+func TestMessage_isFault(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   bool
+       }{
+               {
+                       name: "is Fault",
+                       fields: fields{
+                               Event: Event{
+                                       CommonEventHeader: CommonEventHeader{
+                                               Domain: "fault",
+                                       },
+                               },
+                       },
+                       want: true,
+               },
+               {
+                       name: "is not Fault",
+                       fields: fields{
+                               Event: Event{},
+                       },
+                       want: false,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.isFault(); got != tt.want {
+                               t.Errorf("Message.isFault() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestMessage_isLinkAlarm(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   bool
+       }{
+               {
+                       name: "is Link alarm",
+                       fields: fields{
+                               Event: Event{
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "28",
+                                       },
+                               },
+                       },
+                       want: true,
+               },
+               {
+                       name: "is not Link alarm",
+                       fields: fields{
+                               Event: Event{
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "2",
+                                       },
+                               },
+                       },
+                       want: false,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.isLinkAlarm(); got != tt.want {
+                               t.Errorf("Message.isLinkAlarm() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestMessage_isSeverityNormal(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   bool
+       }{
+               {
+                       name: "is severity NORMAL",
+                       fields: fields{
+                               Event: Event{
+                                       FaultFields: FaultFields{
+                                               EventSeverity: "NORMAL",
+                                       },
+                               },
+                       },
+                       want: true,
+               },
+               {
+                       name: "is not severity NORMAL",
+                       fields: fields{
+                               Event: Event{
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "ERROR",
+                                       },
+                               },
+                       },
+                       want: false,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.isSeverityNormal(); got != tt.want {
+                               t.Errorf("Message.isSeverityNormal() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestMessage_IsLinkFailure(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   bool
+       }{
+               {
+                       name: "is Link Failure",
+                       fields: fields{
+                               Event: Event{
+                                       CommonEventHeader: CommonEventHeader{
+                                               Domain: "fault",
+                                       },
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "28",
+                                               EventSeverity:  "ERROR",
+                                       },
+                               },
+                       },
+                       want: true,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.IsLinkFailure(); got != tt.want {
+                               t.Errorf("Message.IsLinkFailure() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestMessage_IsClearLinkFailure(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   bool
+       }{
+               {
+                       name: "is not Link Failure",
+                       fields: fields{
+                               Event: Event{
+                                       CommonEventHeader: CommonEventHeader{
+                                               Domain: "fault",
+                                       },
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "28",
+                                               EventSeverity:  "NORMAL",
+                                       },
+                               },
+                       },
+                       want: true,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.IsClearLinkFailure(); got != tt.want {
+                               t.Errorf("Message.IsClearLinkFailure() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestMessage_GetORuId(t *testing.T) {
+       type fields struct {
+               Event Event
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   string
+       }{
+               {
+                       name: "is not Link Failure",
+                       fields: fields{
+                               Event: Event{
+                                       CommonEventHeader: CommonEventHeader{
+                                               SourceName: "O-RU-ID",
+                                       },
+                                       FaultFields: FaultFields{
+                                               AlarmCondition: "28",
+                                               EventSeverity:  "NORMAL",
+                                       },
+                               },
+                       },
+                       want: "O-RU-ID",
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       message := FaultMessage{
+                               Event: tt.fields.Event,
+                       }
+                       if got := message.GetORuId(); got != tt.want {
+                               t.Errorf("Message.GetORuId() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/main.go b/test/usecases/oruclosedlooprecovery/goversion/main.go
new file mode 100644 (file)
index 0000000..5574f8c
--- /dev/null
@@ -0,0 +1,109 @@
+// -
+//   ========================LICENSE_START=================================
+//   O-RAN-SC
+//   %%
+//   Copyright (C) 2021: Nordix Foundation
+//   %%
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//        http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+//   ========================LICENSE_END===================================
+//
+
+package main
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/http"
+
+       "github.com/gorilla/mux"
+       log "github.com/sirupsen/logrus"
+       "oransc.org/usecase/oruclosedloop/internal/config"
+       "oransc.org/usecase/oruclosedloop/internal/linkfailure"
+       "oransc.org/usecase/oruclosedloop/internal/repository"
+       "oransc.org/usecase/oruclosedloop/internal/restclient"
+)
+
+var consumerConfig linkfailure.Configuration
+var lookupService repository.LookupService
+var host string
+var port string
+
+const jobId = "14e7bb84-a44d-44c1-90b7-6995a92ad43c"
+
+func init() {
+       configuration := config.New()
+
+       log.SetLevel(configuration.LogLevel)
+
+       if configuration.ConsumerHost == "" || configuration.ConsumerPort == 0 {
+               log.Fatal("Consumer host and port must be provided!")
+       }
+       host = configuration.ConsumerHost
+       port = fmt.Sprint(configuration.ConsumerPort)
+
+       csvFileHelper := repository.NewCsvFileHelper()
+       lookupService = repository.NewLookupServiceImpl(&csvFileHelper, configuration.ORUToODUMapFile)
+       if initErr := lookupService.Init(); initErr != nil {
+               log.Fatalf("Unable to create LookupService due to inability to get O-RU-ID to O-DU-ID map. Cause: %v", initErr)
+       }
+       consumerConfig = linkfailure.Configuration{
+               InfoCoordAddress: configuration.InfoCoordinatorAddress,
+               SDNRAddress:      configuration.SDNRHost + ":" + fmt.Sprint(configuration.SDNRPort),
+               SDNRUser:         configuration.SDNRUser,
+               SDNRPassword:     configuration.SDNPassword,
+       }
+}
+
+func main() {
+       defer deleteJob()
+       messageHandler := linkfailure.NewLinkFailureHandler(lookupService, consumerConfig)
+       r := mux.NewRouter()
+       r.HandleFunc("/", messageHandler.MessagesHandler).Methods(http.MethodPost)
+       r.HandleFunc("/admin/start", startHandler).Methods(http.MethodPost)
+       r.HandleFunc("/admin/stop", stopHandler).Methods(http.MethodPost)
+       log.Error(http.ListenAndServe(":"+port, r))
+}
+
+func startHandler(w http.ResponseWriter, r *http.Request) {
+       jobRegistrationInfo := struct {
+               InfoTypeId    string      `json:"info_type_id"`
+               JobResultUri  string      `json:"job_result_uri"`
+               JobOwner      string      `json:"job_owner"`
+               JobDefinition interface{} `json:"job_definition"`
+       }{
+               InfoTypeId:    "STD_Fault_Messages",
+               JobResultUri:  host + ":" + port,
+               JobOwner:      "O-RU Closed Loop Usecase",
+               JobDefinition: "{}",
+       }
+       body, _ := json.Marshal(jobRegistrationInfo)
+       putErr := restclient.PutWithoutAuth(consumerConfig.InfoCoordAddress+"/data-consumer/v1/info-jobs/"+jobId, body)
+       if putErr != nil {
+               http.Error(w, fmt.Sprintf("Unable to register consumer job: %v", putErr), http.StatusBadRequest)
+               return
+       }
+       log.Debug("Registered job.")
+}
+
+func stopHandler(w http.ResponseWriter, r *http.Request) {
+       deleteErr := deleteJob()
+       if deleteErr != nil {
+               http.Error(w, fmt.Sprintf("Unable to delete consumer job: %v", deleteErr), http.StatusBadRequest)
+               return
+       }
+       log.Debug("Deleted job.")
+}
+
+func deleteJob() error {
+       return restclient.Delete(consumerConfig.InfoCoordAddress + "/data-consumer/v1/info-jobs/" + jobId)
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/mocks/CsvFileHelper.go b/test/usecases/oruclosedlooprecovery/goversion/mocks/CsvFileHelper.go
new file mode 100644 (file)
index 0000000..b857de5
--- /dev/null
@@ -0,0 +1,33 @@
+// Code generated by mockery v1.0.0. DO NOT EDIT.
+
+package mocks
+
+import mock "github.com/stretchr/testify/mock"
+
+// CsvFileHelper is an autogenerated mock type for the CsvFileHelper type
+type CsvFileHelper struct {
+       mock.Mock
+}
+
+// GetCsvFromFile provides a mock function with given fields: name
+func (_m *CsvFileHelper) GetCsvFromFile(name string) ([][]string, error) {
+       ret := _m.Called(name)
+
+       var r0 [][]string
+       if rf, ok := ret.Get(0).(func(string) [][]string); ok {
+               r0 = rf(name)
+       } else {
+               if ret.Get(0) != nil {
+                       r0 = ret.Get(0).([][]string)
+               }
+       }
+
+       var r1 error
+       if rf, ok := ret.Get(1).(func(string) error); ok {
+               r1 = rf(name)
+       } else {
+               r1 = ret.Error(1)
+       }
+
+       return r0, r1
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/mocks/HTTPClient.go b/test/usecases/oruclosedlooprecovery/goversion/mocks/HTTPClient.go
new file mode 100644 (file)
index 0000000..3037798
--- /dev/null
@@ -0,0 +1,60 @@
+// Code generated by mockery v1.0.0. DO NOT EDIT.
+
+package mocks
+
+import (
+       http "net/http"
+
+       mock "github.com/stretchr/testify/mock"
+)
+
+// HTTPClient is an autogenerated mock type for the HTTPClient type
+type HTTPClient struct {
+       mock.Mock
+}
+
+// Do provides a mock function with given fields: _a0
+func (_m *HTTPClient) Do(_a0 *http.Request) (*http.Response, error) {
+       ret := _m.Called(_a0)
+
+       var r0 *http.Response
+       if rf, ok := ret.Get(0).(func(*http.Request) *http.Response); ok {
+               r0 = rf(_a0)
+       } else {
+               if ret.Get(0) != nil {
+                       r0 = ret.Get(0).(*http.Response)
+               }
+       }
+
+       var r1 error
+       if rf, ok := ret.Get(1).(func(*http.Request) error); ok {
+               r1 = rf(_a0)
+       } else {
+               r1 = ret.Error(1)
+       }
+
+       return r0, r1
+}
+
+// Get provides a mock function with given fields: url
+func (_m *HTTPClient) Get(url string) (*http.Response, error) {
+       ret := _m.Called(url)
+
+       var r0 *http.Response
+       if rf, ok := ret.Get(0).(func(string) *http.Response); ok {
+               r0 = rf(url)
+       } else {
+               if ret.Get(0) != nil {
+                       r0 = ret.Get(0).(*http.Response)
+               }
+       }
+
+       var r1 error
+       if rf, ok := ret.Get(1).(func(string) error); ok {
+               r1 = rf(url)
+       } else {
+               r1 = ret.Error(1)
+       }
+
+       return r0, r1
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/mocks/LookupService.go b/test/usecases/oruclosedlooprecovery/goversion/mocks/LookupService.go
new file mode 100644 (file)
index 0000000..2ba8369
--- /dev/null
@@ -0,0 +1,45 @@
+// Code generated by mockery v1.0.0. DO NOT EDIT.
+
+package mocks
+
+import mock "github.com/stretchr/testify/mock"
+
+// LookupService is an autogenerated mock type for the LookupService type
+type LookupService struct {
+       mock.Mock
+}
+
+// GetODuID provides a mock function with given fields: oRuId
+func (_m *LookupService) GetODuID(oRuId string) (string, error) {
+       ret := _m.Called(oRuId)
+
+       var r0 string
+       if rf, ok := ret.Get(0).(func(string) string); ok {
+               r0 = rf(oRuId)
+       } else {
+               r0 = ret.Get(0).(string)
+       }
+
+       var r1 error
+       if rf, ok := ret.Get(1).(func(string) error); ok {
+               r1 = rf(oRuId)
+       } else {
+               r1 = ret.Error(1)
+       }
+
+       return r0, r1
+}
+
+// Init provides a mock function with given fields:
+func (_m *LookupService) Init() error {
+       ret := _m.Called()
+
+       var r0 error
+       if rf, ok := ret.Get(0).(func() error); ok {
+               r0 = rf()
+       } else {
+               r0 = ret.Error(0)
+       }
+
+       return r0
+}
diff --git a/test/usecases/oruclosedlooprecovery/goversion/o-ru-to-o-du-map.csv b/test/usecases/oruclosedlooprecovery/goversion/o-ru-to-o-du-map.csv
new file mode 100644 (file)
index 0000000..951337a
--- /dev/null
@@ -0,0 +1,11 @@
+ERICSSON-O-RU-11220,HCL-O-DU-1122
+ERICSSON-O-RU-11221,HCL-O-DU-1122
+ERICSSON-O-RU-11222,HCL-O-DU-1122
+ERICSSON-O-RU-11223,HCL-O-DU-1122
+ERICSSON-O-RU-11223,HCL-O-DU-1122
+ERICSSON-O-RU-11224,HCL-O-DU-1123
+ERICSSON-O-RU-11225,HCL-O-DU-1123
+ERICSSON-O-RU-11226,HCL-O-DU-1123
+ERICSSON-O-RU-11227,HCL-O-DU-1124
+ERICSSON-O-RU-11228,HCL-O-DU-1125
+ERICSSON-O-RU-11229,HCL-O-DU-1125
\ No newline at end of file