Skip to content

Commit

Permalink
Merge pull request #35 from safesoftware/healthcheck-no-auth
Browse files Browse the repository at this point in the history
Allow healthcheck to be run without logging in first.
  • Loading branch information
garnold54 authored Feb 17, 2023
2 parents baedf48 + 3f9e936 commit 9fc2deb
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 54 deletions.
14 changes: 13 additions & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,19 @@
"fmeserver"
]
},
"problemMatcher": []
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Build Docker Image",
"type": "shell",
"command": "docker build -t safesoftware/fmeserver-cli .",
"group": {
"kind": "build"
}
}
]
}
38 changes: 37 additions & 1 deletion cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"reflect"
"strings"
"unicode"
Expand Down Expand Up @@ -38,7 +41,9 @@ func buildFmeServerRequest(endpoint string, method string, body io.Reader) (http
fmeserverToken := viper.GetString("token")

req, err := http.NewRequest(method, fmeserverUrl+endpoint, body)
req.Header.Set("Authorization", "fmetoken token="+fmeserverToken)
if fmeserverToken != "" {
req.Header.Set("Authorization", "fmetoken token="+fmeserverToken)
}
return *req, err
}

Expand Down Expand Up @@ -163,3 +168,34 @@ func isEmpty(object interface{}) bool {
}
return false
}

func checkConfigFile(requireToken bool) error {
// make sure the config file is set up correctly
_, err := os.Stat(viper.ConfigFileUsed())
if err != nil {
return fmt.Errorf("could not open the config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}
fmeserverUrl := viper.GetString("url")

// check the fme server URL is valid
_, err = url.ParseRequestURI(fmeserverUrl)
if err != nil {
return fmt.Errorf("invalid FME Server url in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}

if requireToken {
// check there is a token to use for auth
fmeserverToken := viper.GetString("token")
if fmeserverToken == "" {
return fmt.Errorf("no token found in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}
}

// check there is a build set in the config file
fmeserverBuild := viper.GetString("build")
if fmeserverBuild == "" {
return fmt.Errorf("no build found in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}
return nil

}
43 changes: 41 additions & 2 deletions cmd/healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"

Expand All @@ -15,6 +16,7 @@ import (

type healthcheckFlags struct {
ready bool
url string
outputType string
noHeaders bool
apiVersion apiVersionFlag
Expand All @@ -37,7 +39,7 @@ func newHealthcheckCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "healthcheck",
Short: "Retrieves the health status of FME Server",
Long: "Retrieves the health status of FME Server. The health status is normal if the FME Server REST API is responsive. Note that this endpoint does not require authentication. Load balancer or other systems can monitor FME Server using this endpoint without supplying token or password credentials.",
Long: "Retrieves the health status of FME Server. The health status is normal if the FME Server REST API is responsive. Note that this endpoint does not require authentication. This command can be used without calling the login command first. The FME Server url can be passed in using the --url flag without needing a config file. A config file without a token can also be used.",
Example: `
# Check if the FME Server is healthy and accepting requests
fmeserver healthcheck
Expand All @@ -49,11 +51,38 @@ func newHealthcheckCmd() *cobra.Command {
fmeserver healthcheck --json
# Check that the FME Server is healthy and output just the status
fmeserver healthcheck --output=custom-columns=STATUS:.status`,
fmeserver healthcheck --output=custom-columns=STATUS:.status
# Check the FME Server is healthy without needing a config file
fmeserver healthcheck --url https://my-fmeserver.internal
# Check the FME Server is healthy with a manually created config file
cat << EOF >fmeserver-cli.yaml
build: 23235
url: https://my-fmeserver.internal
EOF
fmeserver healthcheck --config fmeserver-cli.yaml`,
Args: NoArgs,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// only check config if we didn't specify a url
if f.url == "" {
return checkConfigFile(false)
} else {
var err error
url, err := url.ParseRequestURI(f.url)
if err != nil {
return fmt.Errorf(urlErrorMsg)
}
if url.Path != "" {
return fmt.Errorf(urlErrorMsg)
}
}
return nil
},
RunE: healthcheckRun(&f),
}
cmd.Flags().BoolVar(&f.ready, "ready", false, "The health check will report the status of FME Server if it is ready to process jobs.")
cmd.Flags().StringVar(&f.url, "url", "", "The base URL of the FME Server to check the health of. Pass this in if checking the health of an FME Server that you haven't called the login command for.")
cmd.Flags().Var(&f.apiVersion, "api-version", "The api version to use when contacting FME Server. Must be one of v3 or v4")
cmd.Flags().StringVarP(&f.outputType, "output", "o", "table", "Specify the output type. Should be one of table, json, or custom-columns")
cmd.Flags().BoolVar(&f.noHeaders, "no-headers", false, "Don't print column headers")
Expand All @@ -74,6 +103,7 @@ func healthcheckRun(f *healthcheckFlags) func(cmd *cobra.Command, args []string)

// get build to decide if we should use v3 or v4
// FME Server 2023.0 and later can use v4. Otherwise fall back to v3
// If called without a config file and thus no build number, default to v3
if f.apiVersion == "" {
fmeserverBuild := viper.GetInt("build")
if fmeserverBuild < healthcheckV4BuildThreshold {
Expand All @@ -92,6 +122,10 @@ func healthcheckRun(f *healthcheckFlags) func(cmd *cobra.Command, args []string)
endpoint += "/liveness"
}

if f.url != "" {
viper.Set("url", f.url)
}

request, err := buildFmeServerRequest(endpoint, "GET", nil)
if err != nil {
return err
Expand Down Expand Up @@ -169,7 +203,12 @@ func healthcheckRun(f *healthcheckFlags) func(cmd *cobra.Command, args []string)
endpoint += "?ready=true"
}

if f.url != "" {
viper.Set("url", f.url)
}

request, err := buildFmeServerRequest(endpoint, "GET", nil)

if err != nil {
return err
}
Expand Down
16 changes: 16 additions & 0 deletions cmd/healthcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ func TestHealthcheck(t *testing.T) {
wantOutputJson: okResponseV3,
args: []string{"healthcheck", "--json", "--api-version", "v3"},
},
{
name: "v4 health check with url flag",
statusCode: http.StatusOK,
body: okResponseV4,
wantOutputRegex: "STATUS[\\s]*MESSAGE[\\s]*[\\s]*ok[\\s]*FME Server is healthy",
args: []string{"healthcheck", "--url", urlPlaceholder},
omitConfig: true,
},
{
name: "v4 health check with no token in config file",
statusCode: http.StatusOK,
body: okResponseV4,
wantOutputRegex: "STATUS[\\s]*MESSAGE[\\s]*[\\s]*ok[\\s]*FME Server is healthy",
args: []string{"healthcheck"},
omitConfigToken: true,
},
}
runTests(cases, t)
}
4 changes: 3 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type loginFlags struct {
expiration int
}

var urlErrorMsg = "invalid FME Server URL specified. URL should be of the form https://myfmeserverhostname.com"

func newLoginCmd() *cobra.Command {
f := loginFlags{}
cmd := &cobra.Command{
Expand Down Expand Up @@ -80,7 +82,7 @@ func newLoginCmd() *cobra.Command {
cmd.Usage()
return fmt.Errorf("accepts at most 1 argument, received %d", len(args))
}
urlErrorMsg := "invalid FME Server URL specified. URL should be of the form https://myfmeserverhostname.com"

url, err := url.ParseRequestURI(args[0])
if err != nil {
return fmt.Errorf(urlErrorMsg)
Expand Down
14 changes: 6 additions & 8 deletions cmd/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import (
"github.com/stretchr/testify/require"
)

var testURL = "https://myfmeserver.example.com"

func TestLogin(t *testing.T) {
tokenResponse := `{
"lastSaveDate": "2022-11-17T19:30:44Z",
Expand Down Expand Up @@ -71,20 +69,20 @@ func TestLogin(t *testing.T) {
{
name: "unknown flag",
statusCode: http.StatusOK,
args: []string{"login", testURL, "--badflag"},
args: []string{"login", urlPlaceholder, "--badflag"},
wantErrOutputRegex: "unknown flag: --badflag",
},
{
name: "500 bad status code",
statusCode: http.StatusInternalServerError,
wantErrText: "500 Internal Server Error",
args: []string{"login", testURL, "--user", "admin", "--password-file", passwordFile.Name()},
args: []string{"login", urlPlaceholder, "--user", "admin", "--password-file", passwordFile.Name()},
},
{
name: "422 bad status code",
statusCode: http.StatusNotFound,
wantErrText: "404 Not Found",
args: []string{"login", testURL, "--user", "admin", "--password-file", passwordFile.Name()},
args: []string{"login", urlPlaceholder, "--user", "admin", "--password-file", passwordFile.Name()},
},
{
name: "login with user and password",
Expand Down Expand Up @@ -117,19 +115,19 @@ url: %s
{
name: "missing password flag",
statusCode: http.StatusOK,
args: []string{"login", testURL, "--user", "admin"},
args: []string{"login", urlPlaceholder, "--user", "admin"},
wantErrText: "if any flags in the group [user password-file] are set they must all be set; missing [password-file]",
},
{
name: "missing user flag",
statusCode: http.StatusOK,
args: []string{"login", testURL, "--password-file", passwordFile.Name()},
args: []string{"login", urlPlaceholder, "--password-file", passwordFile.Name()},
wantErrText: "if any flags in the group [user password-file] are set they must all be set; missing [user]",
},
{
name: "token and password mutually exclusive",
statusCode: http.StatusOK,
args: []string{"login", testURL, "--user", "admin", "--password-file", passwordFile.Name(), "--token", "5ba5e0fd15c2403bc8b2e3aa1dfb975ca2197fbf"},
args: []string{"login", urlPlaceholder, "--user", "admin", "--password-file", passwordFile.Name(), "--token", "5ba5e0fd15c2403bc8b2e3aa1dfb975ca2197fbf"},
wantErrText: "if any flags in the group [token password-file] are set none of the others can be; [password-file token] were all set",
},
}
Expand Down
28 changes: 1 addition & 27 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"

Expand Down Expand Up @@ -36,32 +35,7 @@ func NewRootCommand() *cobra.Command {
SilenceUsage: true,
DisableAutoGenTag: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// make sure the config file is set up correctly
_, err := os.Stat(viper.ConfigFileUsed())
if err != nil {
return fmt.Errorf("could not open the config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}
fmeserverUrl := viper.GetString("url")

// check the fme server URL is valid
_, err = url.ParseRequestURI(fmeserverUrl)
if err != nil {
return fmt.Errorf("invalid FME Server url in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}

// check there is a token to use for auth
fmeserverToken := viper.GetString("token")
if fmeserverToken == "" {
return fmt.Errorf("no token found in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}

// check there is a build set in the config file
fmeserverBuild := viper.GetString("build")
if fmeserverBuild == "" {
return fmt.Errorf("no build found in config file " + viper.ConfigFileUsed() + ". Have you called the login command? ")
}

return nil
return checkConfigFile(true)
},
}
cmds.ResetFlags()
Expand Down
36 changes: 22 additions & 14 deletions cmd/testFunctions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"

"github.com/spf13/viper"
Expand All @@ -24,21 +25,25 @@ type testCase struct {
body string // the body of the request that the test server should return
wantErrText string // the expected text in the error object to be returned
wantOutputRegex string // regex of the expected stdout to be returned
wantOutputJson string // regex of the expected stdout to be returned
wantOutputJson string // the expected json to be returned
wantErrOutputRegex string // regex of the expected stderr to be returned
wantFormParams map[string]string // array to ensure that all required URL form parameters exist
wantFormParamsList map[string][]string // for URL forms with multiple values
wantFileContents fileContents // check file contents
wantBodyRegEx string // check the contents of the body sent
fmeserverBuild int // build to pretend we are contacting
args []string // flags to pass into the command

httpServer *httptest.Server // custom http test server if needed
httpServer *httptest.Server // custom http test server if needed
omitConfig bool // set this to true if testing a command with no config file set up
omitConfigToken bool // set this to true if testing a command that reads from the config file but doesn't require a token
}

// random token to use for testing
var testToken = "57463e1b143db046ef3f4ae8ba1b0233e32ee9dd"

// string for when we need the test http server in a command
var urlPlaceholder = "[url]"

func runTests(tcs []testCase, t *testing.T) {
for _, tc := range tcs {
tc := tc
Expand Down Expand Up @@ -68,13 +73,17 @@ func runTests(tcs []testCase, t *testing.T) {

defer tc.httpServer.Close()

// set up the config file
viper.Set("url", tc.httpServer.URL)
viper.Set("token", testToken)
if tc.fmeserverBuild != 0 {
viper.Set("build", tc.fmeserverBuild)
} else {
viper.Set("build", 23159)
if !tc.omitConfig {
// set up the config file
viper.Set("url", tc.httpServer.URL)
if !tc.omitConfigToken {
viper.Set("token", testToken)
}
if tc.fmeserverBuild != 0 {
viper.Set("build", tc.fmeserverBuild)
} else {
viper.Set("build", 23159)
}
}

// create a new copy of the command for each test
Expand All @@ -86,10 +95,9 @@ func runTests(tcs []testCase, t *testing.T) {
cmd.SetOut(stdOut)
cmd.SetErr(stdErr)

// a bit of a hack to make login work
// requires that URL is passed in first in testing before flags
if tc.args[0] == "login" {
tc.args[1] = tc.httpServer.URL
// a bit of a hack to make login work as it needs the URL of the test server
for i, s := range tc.args {
tc.args[i] = strings.Replace(s, urlPlaceholder, tc.httpServer.URL, -1)
}

// if a config file isn't specified, generate a random file and set the config file flag
Expand Down

0 comments on commit 9fc2deb

Please sign in to comment.