Skip to content

Commit ae9291f

Browse files
authored
Merge pull request #2002 from jhiemstrawisc/issue-1954
Enable PROPFIND of Director health test API
2 parents 26cf446 + 005aaca commit ae9291f

File tree

5 files changed

+381
-67
lines changed

5 files changed

+381
-67
lines changed

director/director.go

+95-24
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
package director
2020

2121
import (
22+
"bytes"
2223
"context"
24+
"encoding/xml"
2325
"fmt"
2426
"net/http"
2527
"net/url"
@@ -1446,39 +1448,111 @@ func getPrefixByPath(ctx *gin.Context) {
14461448
ctx.JSON(http.StatusOK, res)
14471449
}
14481450

1451+
// Given a request for a health test file, validate the incoming path, the file extension and the timestamp.
1452+
func validateHealthTestRequest(fPath string) (string, error) {
1453+
// Incoming paths might look like
1454+
// - /pelican/monitoring/directorTest/director-test-2006-01-02T15:04:10Z.txt
1455+
// - /pelican/monitoring/selfTest/self-test-2006-01-02T15:04:10Z.txt
1456+
// These paths/prefixes are defined by the file transfer test utilities in server_utils/test_file_transfer.go
1457+
if fPath == "" || !strings.HasPrefix(fPath, server_utils.MonitoringBaseNs+"/") {
1458+
return "", fmt.Errorf("Path parameter is not a valid health test path: %s", fPath)
1459+
}
1460+
fName := strings.TrimPrefix(fPath, server_utils.MonitoringBaseNs+"/")
1461+
if fName == "" {
1462+
return "", fmt.Errorf("Path parameter is not a valid health test path because it contains no file name: %s", fPath)
1463+
}
1464+
fNameSplit := strings.Split(fName, "/")
1465+
if len(fNameSplit) == 0 {
1466+
return "", fmt.Errorf("Path parameter is not a valid health test path because it contains no file name: %s", fPath)
1467+
}
1468+
// Grab the filename from the remaining components
1469+
fName = fNameSplit[len(fNameSplit)-1]
1470+
1471+
// Validate the file extension
1472+
extSplit := strings.SplitN(fName, ".", 2)
1473+
if len(extSplit) != 2 {
1474+
return "", fmt.Errorf("Test file name is missing file extension: %s", fPath)
1475+
}
1476+
1477+
// validate the timestamp in the file name
1478+
var tStampStr string
1479+
if strings.HasPrefix(fName, server_utils.DirectorTest.String()) {
1480+
tStampStr = strings.TrimPrefix(fName, server_utils.DirectorTest.String()+"-")
1481+
} else if strings.HasPrefix(fName, server_utils.ServerSelfTest.String()) {
1482+
tStampStr = strings.TrimPrefix(fName, server_utils.ServerSelfTest.String()+"-")
1483+
} else {
1484+
return "", fmt.Errorf("File name does not have a valid prefix: %s", fName)
1485+
}
1486+
tStampStr = strings.TrimSuffix(tStampStr, "."+extSplit[1])
1487+
tStamp, err := time.Parse("2006-01-02T15:04:05Z07:00", tStampStr)
1488+
if err != nil {
1489+
return "", fmt.Errorf("Invalid timestamp in file name: '%s'. Should conform to 2006-01-02T15:04:05Z07:00 format (RFC 3339)", tStampStr)
1490+
}
1491+
formatted := tStamp.Format("Mon, 02 Jan 2006 15:04:05 GMT")
1492+
return formatted, nil
1493+
}
1494+
1495+
// Generate a PROPFIND response for a health test file. This is important for caches, where xrdcl-pelican's first query
1496+
// will always be a PROPFIND request to check whether the requested resource is an object or a collection.
1497+
func generatePROPFINDResponse(fPath string, size int, tStamp string) string {
1498+
// Generate the XML content. This template was obtained by sending a curl command to a real cache to query for a known test file:
1499+
// curl -v -X PROPFIND -i -H "Depth: 0" https://osdf-uw-cache.svc.osg-htc.org:8443/pelican/monitoring/directorTest/director-test-2025-01-24T12:16:59Z.txt
1500+
xmlContent := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
1501+
<D:multistatus xmlns:D="DAV:" xmlns:ns1="http://apache.org/dav/props/" xmlns:ns0="DAV:">
1502+
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/" xmlns:lp3="LCGDM:">
1503+
<D:href>%s</D:href>
1504+
<D:propstat>
1505+
<D:prop>
1506+
<lp1:getcontentlength>%d</lp1:getcontentlength>
1507+
<lp1:getlastmodified>%s</lp1:getlastmodified>
1508+
<lp1:iscollection>0</lp1:iscollection>
1509+
<lp1:executable>F</lp1:executable>
1510+
</D:prop>
1511+
<D:status>HTTP/1.1 200 OK</D:status>
1512+
</D:propstat>
1513+
</D:response>
1514+
</D:multistatus>`, fPath, size, tStamp)
1515+
1516+
return xmlContent
1517+
}
1518+
14491519
// Generate a mock file for caches to fetch. This is for director-based health tests for caches
14501520
// So that we don't require an origin to feed the test file to the cache
14511521
func getHealthTestFile(ctx *gin.Context) {
1452-
// Expected path: /pelican/monitoring/directorTest/2006-01-02T15:04:05Z07:00.txt
14531522
pathParam := ctx.Param("path")
14541523
cleanedPath := path.Clean(pathParam)
1455-
if cleanedPath == "" || !strings.HasPrefix(cleanedPath, server_utils.MonitoringBaseNs+"/") {
1456-
ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{
1457-
Status: server_structs.RespFailed,
1458-
Msg: "Path parameter is not a valid health test path: " + cleanedPath})
1459-
return
1460-
}
1461-
fileName := strings.TrimPrefix(cleanedPath, server_utils.MonitoringBaseNs+"/")
1462-
if fileName == "" {
1524+
tStamp, err := validateHealthTestRequest(cleanedPath)
1525+
if err != nil {
14631526
ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{
14641527
Status: server_structs.RespFailed,
1465-
Msg: "Path parameter is not a valid health test path: " + cleanedPath})
1466-
return
1467-
}
1468-
1469-
fileNameSplit := strings.SplitN(fileName, ".", 2)
1470-
1471-
if len(fileNameSplit) != 2 {
1472-
ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Test file name is missing file extension: " + cleanedPath})
1528+
Msg: err.Error(),
1529+
})
14731530
return
14741531
}
14751532

14761533
fileContent := server_utils.DirectorTestBody + "\n"
14771534

1478-
if ctx.Request.Method == "HEAD" {
1535+
method := ctx.Request.Method
1536+
switch method {
1537+
case "PROPFIND":
1538+
var buf bytes.Buffer
1539+
if err := xml.EscapeText(&buf, []byte(cleanedPath)); err != nil {
1540+
ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{
1541+
Status: server_structs.RespFailed,
1542+
Msg: errors.Wrapf(err, "Unable to properly escape XML content for path: %s", cleanedPath).Error(),
1543+
})
1544+
}
1545+
1546+
// Respond with the special Multi Status 207 code. Technically we're only sending a
1547+
// single XML response in our "multi-status" xml (notice the 200 OK in the XML body),
1548+
// but this is the PROPFIND status code clients will typically expect to see.
1549+
ctx.String(http.StatusMultiStatus, generatePROPFINDResponse(cleanedPath, len(fileContent), tStamp))
1550+
case "HEAD":
14791551
ctx.Header("Content-Length", strconv.Itoa(len(fileContent)))
1480-
} else {
1552+
case "GET":
14811553
ctx.String(http.StatusOK, fileContent)
1554+
default:
1555+
ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Unsupported method: " + method})
14821556
}
14831557
}
14841558

@@ -1554,18 +1628,15 @@ func RegisterDirectorAPI(ctx context.Context, router *gin.RouterGroup) {
15541628
directorAPIV1.HEAD("/origin/*any", redirectToOrigin)
15551629
directorAPIV1.PUT("/origin/*any", redirectToOrigin)
15561630
directorAPIV1.DELETE("/origin/*any", redirectToOrigin)
1631+
directorAPIV1.Handle("PROPFIND", "/origin/*any", redirectToOrigin)
15571632
directorAPIV1.POST("/registerOrigin", serverAdMetricMiddleware, func(gctx *gin.Context) { registerServeAd(ctx, gctx, server_structs.OriginType) })
15581633
directorAPIV1.POST("/registerCache", serverAdMetricMiddleware, func(gctx *gin.Context) { registerServeAd(ctx, gctx, server_structs.CacheType) })
15591634
directorAPIV1.GET("/listNamespaces", listNamespacesV1)
15601635
directorAPIV1.GET("/namespaces/prefix/*path", getPrefixByPath)
15611636
directorAPIV1.GET("/healthTest/*path", getHealthTestFile)
15621637
directorAPIV1.HEAD("/healthTest/*path", getHealthTestFile)
1638+
directorAPIV1.Handle("PROPFIND", "/healthTest/*path", getHealthTestFile)
15631639
directorAPIV1.GET("/listX509ClientPrefixes", listX509ClientPrefixes)
1564-
directorAPIV1.Any("/origin", func(gctx *gin.Context) { // Need to do this for PROPFIND since gin does not support it
1565-
if gctx.Request.Method == "PROPFIND" {
1566-
redirectToOrigin(gctx)
1567-
}
1568-
})
15691640

15701641
// In the foreseeable feature, director will scrape all servers in Pelican ecosystem (including registry)
15711642
// so that director can be our point of contact for collecting system-level metrics.

director/director_test.go

+134-43
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"net/http/httptest"
3333
"net/url"
3434
"path/filepath"
35+
"strings"
3536
"testing"
3637
"time"
3738

@@ -2010,52 +2011,142 @@ func TestHeaderGenFuncs(t *testing.T) {
20102011
}
20112012

20122013
func TestGetHealthTestFile(t *testing.T) {
2013-
router := gin.Default()
2014-
router.GET("/api/v1.0/director/healthTest/*path", getHealthTestFile)
2015-
2016-
t.Run("400-on-empty-path", func(t *testing.T) {
2017-
w := httptest.NewRecorder()
2018-
req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/", nil)
2019-
router.ServeHTTP(w, req)
2020-
2021-
assert.Equal(t, http.StatusBadRequest, w.Code)
2022-
})
2023-
2024-
t.Run("400-on-random-path", func(t *testing.T) {
2025-
w := httptest.NewRecorder()
2026-
req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/foo/bar", nil)
2027-
router.ServeHTTP(w, req)
2028-
2029-
assert.Equal(t, http.StatusBadRequest, w.Code)
2030-
})
2031-
2032-
t.Run("400-on-dir", func(t *testing.T) {
2033-
w := httptest.NewRecorder()
2034-
req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring", nil)
2035-
router.ServeHTTP(w, req)
2036-
2037-
assert.Equal(t, http.StatusBadRequest, w.Code)
2038-
})
2039-
2040-
t.Run("400-on-missing-file-ext", func(t *testing.T) {
2041-
w := httptest.NewRecorder()
2042-
req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring/testfile", nil)
2043-
router.ServeHTTP(w, req)
2044-
2045-
assert.Equal(t, http.StatusBadRequest, w.Code)
2046-
})
2014+
gEngine := gin.Default()
2015+
router := gEngine.Group("/")
2016+
ctx := context.Background()
2017+
ctx, cancel, _ := test_utils.TestContext(ctx, t)
2018+
defer cancel()
2019+
RegisterDirectorAPI(ctx, router)
20472020

2048-
t.Run("200-on-correct-request-file", func(t *testing.T) {
2049-
w := httptest.NewRecorder()
2050-
req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring/testfile.txt", nil)
2051-
router.ServeHTTP(w, req)
2021+
tests := []struct {
2022+
name string
2023+
method string
2024+
url string
2025+
wantStatus int
2026+
wantBody string
2027+
}{
2028+
{
2029+
name: "400-on-empty-path",
2030+
method: "GET",
2031+
url: "/api/v1.0/director/healthTest/",
2032+
wantStatus: http.StatusBadRequest,
2033+
},
2034+
{
2035+
name: "400-on-random-path",
2036+
method: "GET",
2037+
url: "/api/v1.0/director/healthTest/foo/bar",
2038+
wantStatus: http.StatusBadRequest,
2039+
},
2040+
{
2041+
name: "400-on-dir",
2042+
method: "GET",
2043+
url: "/api/v1.0/director/healthTest/pelican/monitoring",
2044+
wantStatus: http.StatusBadRequest,
2045+
},
2046+
{
2047+
name: "400-on-missing-file-ext-self-test",
2048+
method: "GET",
2049+
url: "/api/v1.0/director/healthTest/pelican/monitoring/selfTest/testfile",
2050+
wantStatus: http.StatusBadRequest,
2051+
wantBody: "{\"status\":\"error\",\"msg\":\"Test file name is missing file extension: /pelican/monitoring/selfTest/testfile\"}",
2052+
},
2053+
{
2054+
name: "400-on-missing-file-ext-director-test",
2055+
method: "GET",
2056+
url: "/api/v1.0/director/healthTest/pelican/monitoring/directorTest/testfile",
2057+
wantStatus: http.StatusBadRequest,
2058+
wantBody: "{\"status\":\"error\",\"msg\":\"Test file name is missing file extension: /pelican/monitoring/directorTest/testfile\"}",
2059+
},
2060+
{
2061+
name: "400-on-bad-timestamp-self-test",
2062+
method: "GET",
2063+
url: "/api/v1.0/director/healthTest/pelican/monitoring/selfTest/self-test-123123123123123.txt",
2064+
wantStatus: http.StatusBadRequest,
2065+
wantBody: "{\"status\":\"error\",\"msg\":\"Invalid timestamp in file name: '123123123123123'. Should conform to 2006-01-02T15:04:05Z07:00 format (RFC 3339)\"}",
2066+
},
2067+
{
2068+
name: "400-on-bad-timestamp-director-test",
2069+
method: "GET",
2070+
url: "/api/v1.0/director/healthTest/pelican/monitoring/directorTest/director-test-123123123123123.txt",
2071+
wantStatus: http.StatusBadRequest,
2072+
wantBody: "{\"status\":\"error\",\"msg\":\"Invalid timestamp in file name: '123123123123123'. Should conform to 2006-01-02T15:04:05Z07:00 format (RFC 3339)\"}",
2073+
},
2074+
{
2075+
name: "200-on-correct-request-file-self-test",
2076+
method: "GET",
2077+
url: "/api/v1.0/director/healthTest/pelican/monitoring/selfTest/self-test-2006-01-02T15:04:10Z.txt",
2078+
wantStatus: http.StatusOK,
2079+
wantBody: server_utils.DirectorTestBody + "\n",
2080+
},
2081+
{
2082+
name: "200-on-correct-request-file-director-test",
2083+
method: "GET",
2084+
url: "/api/v1.0/director/healthTest/pelican/monitoring/directorTest/director-test-2006-01-02T15:04:10Z.txt",
2085+
wantStatus: http.StatusOK,
2086+
wantBody: server_utils.DirectorTestBody + "\n",
2087+
},
2088+
{
2089+
name: "207-and-XML-on-PROPFIND-self-test",
2090+
method: "PROPFIND",
2091+
url: "/api/v1.0/director/healthTest/pelican/monitoring/selfTest/self-test-2006-01-02T15:04:10Z.txt",
2092+
wantStatus: http.StatusMultiStatus,
2093+
wantBody: `<?xml version="1.0" encoding="utf-8"?>
2094+
<D:multistatus xmlns:D="DAV:" xmlns:ns1="http://apache.org/dav/props/" xmlns:ns0="DAV:">
2095+
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/" xmlns:lp3="LCGDM:">
2096+
<D:href>/pelican/monitoring/selfTest/self-test-2006-01-02T15:04:10Z.txt</D:href>
2097+
<D:propstat>
2098+
<D:prop>
2099+
<lp1:getcontentlength>67</lp1:getcontentlength>
2100+
<lp1:getlastmodified>Mon, 02 Jan 2006 15:04:10 GMT</lp1:getlastmodified>
2101+
<lp1:iscollection>0</lp1:iscollection>
2102+
<lp1:executable>F</lp1:executable>
2103+
</D:prop>
2104+
<D:status>HTTP/1.1 200 OK</D:status>
2105+
</D:propstat>
2106+
</D:response>
2107+
</D:multistatus>`,
2108+
},
2109+
{
2110+
name: "207-and-XML-on-PROPFIND-director-test",
2111+
method: "PROPFIND",
2112+
url: "/api/v1.0/director/healthTest/pelican/monitoring/directorTest/director-test-2006-01-02T15:04:10Z.txt",
2113+
wantStatus: http.StatusMultiStatus,
2114+
wantBody: `<?xml version="1.0" encoding="utf-8"?>
2115+
<D:multistatus xmlns:D="DAV:" xmlns:ns1="http://apache.org/dav/props/" xmlns:ns0="DAV:">
2116+
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/" xmlns:lp3="LCGDM:">
2117+
<D:href>/pelican/monitoring/directorTest/director-test-2006-01-02T15:04:10Z.txt</D:href>
2118+
<D:propstat>
2119+
<D:prop>
2120+
<lp1:getcontentlength>67</lp1:getcontentlength>
2121+
<lp1:getlastmodified>Mon, 02 Jan 2006 15:04:10 GMT</lp1:getlastmodified>
2122+
<lp1:iscollection>0</lp1:iscollection>
2123+
<lp1:executable>F</lp1:executable>
2124+
</D:prop>
2125+
<D:status>HTTP/1.1 200 OK</D:status>
2126+
</D:propstat>
2127+
</D:response>
2128+
</D:multistatus>`,
2129+
},
2130+
}
20522131

2053-
require.Equal(t, http.StatusOK, w.Code)
2132+
for _, tt := range tests {
2133+
t.Run(tt.name, func(t *testing.T) {
2134+
w := httptest.NewRecorder()
2135+
req, _ := http.NewRequest(tt.method, tt.url, nil)
2136+
gEngine.ServeHTTP(w, req)
20542137

2055-
bytes, err := io.ReadAll(w.Result().Body)
2056-
require.NoError(t, err)
2057-
assert.Equal(t, server_utils.DirectorTestBody+"\n", string(bytes))
2058-
})
2138+
assert.Equal(t, tt.wantStatus, w.Code)
2139+
if tt.wantBody != "" {
2140+
bytes, err := io.ReadAll(w.Result().Body)
2141+
require.NoError(t, err)
2142+
// Normalize whitespace in the response body and expected body so
2143+
// we can compare them directly
2144+
actual := strings.Join(strings.Fields(string(bytes)), " ")
2145+
expected := strings.Join(strings.Fields(tt.wantBody), " ")
2146+
assert.Equal(t, expected, actual)
2147+
}
2148+
})
2149+
}
20592150
}
20602151

20612152
func TestHandleFilterServer(t *testing.T) {

e2e_fed_tests/README.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# End to End Fed Tests Package
2+
3+
This package is meant to be a repository of federation tests that test specific Pelican components
4+
when their interaction with other services is important. It has been created as its own package to
5+
avoid the potential for circular dependencies, and as such no functions here should ever be exported.
6+
7+
For example, the Director's health test utility API needs to function with both caches and origins.
8+
Testing these components together is most easily done by using `fed_test_utils` to spin up a new
9+
federation test. However, that package cannot be imported by Director, Cache, or Origin tests directly
10+
because the fed test itself _must_ import those packages, leading to a cyclical dependency.
11+
12+
The `github_scripts` directory contains a similar set of CI tests, but it's easier to write rigorous
13+
tests in go than it is to write them in bash.

0 commit comments

Comments
 (0)