diff --git a/broker/broker_test.go b/broker/broker_test.go index 3cb48554a..db44bb219 100644 --- a/broker/broker_test.go +++ b/broker/broker_test.go @@ -39,10 +39,10 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/registry" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" "github.com/pelicanplatform/pelican/token_scopes" @@ -309,7 +309,7 @@ func TestRetrieveTimeout(t *testing.T) { err = json.Unmarshal(responseBytes, &brokerResp) require.NoError(t, err) - assert.Equal(t, common.RespPollTimeout, brokerResp.Status) + assert.Equal(t, server_structs.RespPollTimeout, brokerResp.Status) ctx, cancelFunc := context.WithTimeout(ctx, 50*time.Millisecond) defer cancelFunc() diff --git a/broker/client.go b/broker/client.go index a8158100f..4e183d331 100644 --- a/broker/client.go +++ b/broker/client.go @@ -43,9 +43,9 @@ import ( "sync/atomic" "time" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -240,7 +240,7 @@ func ConnectToOrigin(ctx context.Context, brokerUrl, prefix, originName string) err = errors.Wrap(err, "Failure when reading response from broker response") } if resp.StatusCode >= 400 { - errResp := common.SimpleApiResp{} + errResp := server_structs.SimpleApiResp{} log.Errorf("Failure (status code %d) when invoking the broker: %s", resp.StatusCode, string(responseBytes)) if err = json.Unmarshal(responseBytes, &errResp); err != nil { err = errors.Errorf("Failure when invoking the broker (status code %d); unable to parse error message", resp.StatusCode) @@ -299,7 +299,7 @@ func ConnectToOrigin(ctx context.Context, brokerUrl, prefix, originName string) hj, ok := writer.(http.Hijacker) if !ok { log.Debug("Not able to hijack underlying TCP connection from server") - resp := common.SimpleApiResp{ + resp := server_structs.SimpleApiResp{ Msg: "Unable to reverse TCP connection; HTTP/2 in use", Status: "error", } @@ -470,7 +470,7 @@ func doCallback(ctx context.Context, brokerResp reversalRequest) (listener net.L } if resp.StatusCode >= 400 { - errResp := common.SimpleApiResp{} + errResp := server_structs.SimpleApiResp{} if err = json.Unmarshal(responseBytes, &errResp); err != nil { err = errors.Errorf("Failure when invoking cache %s callback (status code %d); unable to parse error message", brokerResp.CallbackUrl, resp.StatusCode) } else { @@ -617,7 +617,7 @@ func LaunchRequestMonitor(ctx context.Context, egrp *errgroup.Group, resultChan break } if resp.StatusCode >= 400 { - errResp := common.SimpleApiResp{} + errResp := server_structs.SimpleApiResp{} if err = json.Unmarshal(responseBytes, &errResp); err != nil { log.Errorf("Failure when invoking the broker (status code %d); unable to parse error message", resp.StatusCode) } else { @@ -634,7 +634,7 @@ func LaunchRequestMonitor(ctx context.Context, egrp *errgroup.Group, resultChan break } - if brokerResp.Status == common.RespOK { + if brokerResp.Status == server_structs.RespOK { listener, err := doCallback(ctx, brokerResp.Request) if err != nil { log.Errorln("Failed to callback to the cache:", err) @@ -642,9 +642,9 @@ func LaunchRequestMonitor(ctx context.Context, egrp *errgroup.Group, resultChan break } resultChan <- listener - } else if brokerResp.Status == common.RespFailed { + } else if brokerResp.Status == server_structs.RespFailed { log.Errorln("Broker responded to origin retrieve with an error:", brokerResp.Msg) - } else if brokerResp.Status != common.RespPollTimeout { // We expect timeouts; do not log them. + } else if brokerResp.Status != server_structs.RespPollTimeout { // We expect timeouts; do not log them. if brokerResp.Msg != "" { log.Errorf("Broker responded with unknown status (%s); msg: %s", brokerResp.Status, brokerResp.Msg) } else { diff --git a/broker/server_apis.go b/broker/server_apis.go index a09f912c3..4c4ef398e 100644 --- a/broker/server_apis.go +++ b/broker/server_apis.go @@ -25,8 +25,8 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -42,7 +42,7 @@ type ( // Response for a successful retrieval brokerRetrievalResp struct { - common.SimpleApiResp + server_structs.SimpleApiResp Request reversalRequest `json:"req"` } @@ -54,19 +54,19 @@ type ( func newBrokerReqResp(req reversalRequest) (result brokerRetrievalResp) { result.Request = req - result.SimpleApiResp.Status = common.RespOK + result.SimpleApiResp.Status = server_structs.RespOK return } -func newBrokerRespFail(msg string) common.SimpleApiResp { - return common.SimpleApiResp{ - Status: common.RespFailed, +func newBrokerRespFail(msg string) server_structs.SimpleApiResp { + return server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, Msg: msg, } } func newBrokerRespTimeout() (result brokerRetrievalResp) { - result.SimpleApiResp.Status = common.RespPollTimeout + result.SimpleApiResp.Status = server_structs.RespPollTimeout return } diff --git a/cache_ui/advertise.go b/cache/advertise.go similarity index 89% rename from cache_ui/advertise.go rename to cache/advertise.go index cda25541c..2c63edc01 100644 --- a/cache_ui/advertise.go +++ b/cache/advertise.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package cache_ui +package cache import ( "context" @@ -24,24 +24,22 @@ import ( "net/url" "strings" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/director" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/utils" "github.com/pkg/errors" ) type ( CacheServer struct { - server_utils.NamespaceHolder + server_structs.NamespaceHolder namespaceFilter map[string]struct{} } ) -func (server *CacheServer) CreateAdvertisement(name string, originUrl string, originWebUrl string) (*common.OriginAdvertiseV2, error) { - ad := common.OriginAdvertiseV2{ +func (server *CacheServer) CreateAdvertisement(name string, originUrl string, originWebUrl string) (*server_structs.OriginAdvertiseV2, error) { + ad := server_structs.OriginAdvertiseV2{ Name: name, DataURL: originUrl, WebURL: originWebUrl, @@ -68,13 +66,13 @@ func (server *CacheServer) SetFilters() { } } -func (server *CacheServer) filterAdsBasedOnNamespace(nsAds []common.NamespaceAdV2) []common.NamespaceAdV2 { +func (server *CacheServer) filterAdsBasedOnNamespace(nsAds []server_structs.NamespaceAdV2) []server_structs.NamespaceAdV2 { /* * Filters out ads based on the namespaces listed in server.NamespaceFilter * Note that this does a few checks for trailing and non-trailing "/" as it's assumed that the namespaces * from the director and the ones provided might differ. */ - filteredAds := []common.NamespaceAdV2{} + filteredAds := []server_structs.NamespaceAdV2{} if len(server.namespaceFilter) > 0 { for _, ad := range nsAds { ns := ad.Path @@ -111,7 +109,7 @@ func (server *CacheServer) filterAdsBasedOnNamespace(nsAds []common.NamespaceAdV func (server *CacheServer) GetNamespaceAdsFromDirector() error { // Get the endpoint of the director - var respNS []common.NamespaceAdV2 + var respNS []server_structs.NamespaceAdV2 directorEndpoint := param.Federation_DirectorUrl.GetString() if directorEndpoint == "" { @@ -140,14 +138,14 @@ func (server *CacheServer) GetNamespaceAdsFromDirector() error { return err } respData, err = utils.MakeRequest(context.Background(), directorNSListEndpointURL, "GET", nil, nil) - var respNSV1 []common.NamespaceAdV1 + var respNSV1 []server_structs.NamespaceAdV1 if err != nil { return errors.Wrap(err, "Failed to make request") } else { if jsonErr := json.Unmarshal(respData, &respNSV1); jsonErr == nil { // Error creating json return errors.Wrapf(err, "Failed to make request: %v", err) } - respNS = director.ConvertNamespaceAdsV1ToV2(respNSV1, nil) + respNS = server_structs.ConvertNamespaceAdsV1ToV2(respNSV1, nil) } } else { return errors.Wrap(err, "Failed to make request") diff --git a/cache_ui/advertise_test.go b/cache/advertise_test.go similarity index 96% rename from cache_ui/advertise_test.go rename to cache/advertise_test.go index 4e2a3a845..733afdebb 100644 --- a/cache_ui/advertise_test.go +++ b/cache/advertise_test.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package cache_ui +package cache import ( "encoding/json" @@ -24,7 +24,7 @@ import ( "net/http/httptest" "testing" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/server_structs" "github.com/spf13/viper" "github.com/stretchr/testify/require" ) @@ -83,7 +83,7 @@ func TestFilterNsAdsForCache(t *testing.T) { viper.Reset() defer viper.Reset() - nsAds := []common.NamespaceAdV2{ + nsAds := []server_structs.NamespaceAdV2{ { Path: "/ns1", }, diff --git a/cache_ui/broker_client.go b/cache/broker_client.go similarity index 99% rename from cache_ui/broker_client.go rename to cache/broker_client.go index 49b32e5b8..021deece2 100644 --- a/cache_ui/broker_client.go +++ b/cache/broker_client.go @@ -18,7 +18,7 @@ * ***************************************************************/ -package cache_ui +package cache import ( "bufio" diff --git a/cache_ui/broker_client_windows.go b/cache/broker_client_windows.go similarity index 98% rename from cache_ui/broker_client_windows.go rename to cache/broker_client_windows.go index d9ed3c3f2..4eca3f742 100644 --- a/cache_ui/broker_client_windows.go +++ b/cache/broker_client_windows.go @@ -18,7 +18,7 @@ * ***************************************************************/ -package cache_ui +package cache import ( "context" diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 000000000..3f56c09da --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,86 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package cache + +import ( + "context" + "errors" + "os" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_utils" + "golang.org/x/sync/errgroup" +) + +var ( + notificationChan = make(chan bool) +) + +func RegisterCacheAPI(router *gin.Engine, ctx context.Context, egrp *errgroup.Group) { + // start the timer for the director test report timeout + server_utils.LaunchPeriodicDirectorTimeout(ctx, egrp, notificationChan) + + group := router.Group("/api/v1.0/cache") + { + group.POST("/directorTest", func(ginCtx *gin.Context) { server_utils.HandleDirectorTestResponse(ginCtx, notificationChan) }) + } +} + +// Periodically scan the //pelican/monitoring directory to clean up test files +func LaunchDirectorTestFileCleanup(ctx context.Context) { + server_utils.LaunchWatcherMaintenance(ctx, + []string{filepath.Join(param.Cache_DataLocation.GetString(), "pelican", "monitoring")}, + "cache director-based health test clean up", + time.Minute, + func(notifyEvent bool) error { + // We run this function regardless of notifyEvent to do the cleanup + dirPath := filepath.Join(param.Cache_DataLocation.GetString(), "pelican", "monitoring") + dirInfo, err := os.Stat(dirPath) + if err != nil { + return err + } else { + if !dirInfo.IsDir() { + return errors.New("monitoring path is not a directory: " + dirPath) + } + } + dirItems, err := os.ReadDir(dirPath) + if err != nil { + return err + } + if len(dirItems) <= 2 { // At mininum there are the test file and .cinfo file, and we don't want to remove the last two + return nil + } + for idx, item := range dirItems { + // For all but the latest two files (test file and its .cinfo file) + // os.ReadDir sorts dirEntries in order of file names. Since our test file names are timestamped and is string comparable, + // the last two files should be the latest test files, which we want to keep + if idx < len(dirItems)-2 { + err := os.Remove(filepath.Join(dirPath, item.Name())) + if err != nil { + return err + } + } + } + return nil + }, + ) +} diff --git a/cache_ui/cinfo.go b/cache/cinfo.go similarity index 83% rename from cache_ui/cinfo.go rename to cache/cinfo.go index 04c726503..a7d8e64f0 100644 --- a/cache_ui/cinfo.go +++ b/cache/cinfo.go @@ -1,4 +1,22 @@ -package cache_ui +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package cache import ( "bytes" diff --git a/cache_ui/self_monitor.go b/cache/self_monitor.go similarity index 91% rename from cache_ui/self_monitor.go rename to cache/self_monitor.go index f43da2745..5261c7671 100644 --- a/cache_ui/self_monitor.go +++ b/cache/self_monitor.go @@ -1,4 +1,22 @@ -package cache_ui +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package cache import ( "context" diff --git a/client/fed_linux_test.go b/client/fed_linux_test.go index 5bc3d6e00..b76db620e 100644 --- a/client/fed_linux_test.go +++ b/client/fed_linux_test.go @@ -31,10 +31,10 @@ import ( "time" "github.com/pelicanplatform/pelican/client" - "github.com/pelicanplatform/pelican/common" config "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/fed_test_utils" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" @@ -47,7 +47,7 @@ func TestRecursiveUploadsAndDownloads(t *testing.T) { // Create instance of test federation ctx, _, _ := test_utils.TestContext(context.Background(), t) viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() fed := fed_test_utils.NewFedTest(t, mixedAuthOriginCfg) diff --git a/client/fed_test.go b/client/fed_test.go index d2218b93f..79eef8d27 100644 --- a/client/fed_test.go +++ b/client/fed_test.go @@ -39,7 +39,6 @@ import ( "github.com/stretchr/testify/require" "github.com/pelicanplatform/pelican/client" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/fed_test_utils" "github.com/pelicanplatform/pelican/launchers" @@ -95,9 +94,9 @@ func TestFullUpload(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() modules := config.ServerType(0) modules.Set(config.OriginType) @@ -226,7 +225,7 @@ func TestFullUpload(t *testing.T) { // A test that spins up a federation, and tests object get and put func TestGetAndPutAuth(t *testing.T) { viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() fed := fed_test_utils.NewFedTest(t, bothAuthOriginCfg) // Other set-up items: @@ -376,7 +375,7 @@ func TestGetAndPutAuth(t *testing.T) { func TestGetPublicRead(t *testing.T) { ctx, _, _ := test_utils.TestContext(context.Background(), t) viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() fed := fed_test_utils.NewFedTest(t, bothPublicOriginCfg) diff --git a/cmd/cache.go b/cmd/cache.go index b18f17147..5ec0d0918 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -19,8 +19,6 @@ package main import ( - "context" - "github.com/pelicanplatform/pelican/metrics" "github.com/spf13/cobra" ) @@ -30,7 +28,7 @@ var ( Use: "cache", Short: "Operate a Pelican cache service", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - err := initCache(cmd.Context()) + err := initCache() return err }, } @@ -43,9 +41,8 @@ var ( } ) -func initCache(ctx context.Context) error { +func initCache() error { metrics.SetComponentHealthStatus(metrics.OriginCache_XRootD, metrics.StatusCritical, "xrootd has not been started") - metrics.SetComponentHealthStatus(metrics.OriginCache_CMSD, metrics.StatusCritical, "cmsd has not been started") return nil } diff --git a/cmd/fed_serve_cache_test.go b/cmd/fed_serve_cache_test.go index f25f200c1..69ed5f7ad 100644 --- a/cmd/fed_serve_cache_test.go +++ b/cmd/fed_serve_cache_test.go @@ -31,7 +31,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/launchers" "github.com/pelicanplatform/pelican/param" @@ -45,9 +44,9 @@ func TestFedServeCache(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() modules := config.ServerType(0) modules.Set(config.CacheType) @@ -126,7 +125,7 @@ func TestFedServeCache(t *testing.T) { issuerUrl, err := config.GetServerIssuerURL() require.NoError(t, err) - ok, err := fileTests.RunTestsCache(ctx, param.Cache_Url.GetString(), issuerUrl, "/test/test-file.txt", "This is the content of the test file.") + ok, err := fileTests.TestCacheDownload(ctx, param.Cache_Url.GetString(), issuerUrl, "/test/test-file.txt", "This is the content of the test file.") require.NoError(t, err) require.True(t, ok) diff --git a/cmd/fed_serve_test.go b/cmd/fed_serve_test.go index 56dc94f1b..8aece0504 100644 --- a/cmd/fed_serve_test.go +++ b/cmd/fed_serve_test.go @@ -34,7 +34,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/launchers" "github.com/pelicanplatform/pelican/param" @@ -48,9 +47,9 @@ func TestFedServePosixOrigin(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() modules := config.ServerType(0) modules.Set(config.OriginType) diff --git a/cmd/origin.go b/cmd/origin.go index bb4b05daa..1db92786c 100644 --- a/cmd/origin.go +++ b/cmd/origin.go @@ -19,7 +19,6 @@ package main import ( - "context" "fmt" "os" @@ -33,7 +32,7 @@ var ( Use: "origin", Short: "Operate a Pelican origin service", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - err := initOrigin(cmd.Context()) + err := initOrigin() return err }, } @@ -98,7 +97,7 @@ func configOrigin( /*cmd*/ *cobra.Command /*args*/, []string) { os.Exit(1) } -func initOrigin(ctx context.Context) error { +func initOrigin() error { metrics.SetComponentHealthStatus(metrics.OriginCache_XRootD, metrics.StatusCritical, "xrootd has not been started") metrics.SetComponentHealthStatus(metrics.OriginCache_CMSD, metrics.StatusCritical, "cmsd has not been started") return nil diff --git a/cmd/plugin_stage.go b/cmd/plugin_stage.go index 4c320041b..3484b555d 100644 --- a/cmd/plugin_stage.go +++ b/cmd/plugin_stage.go @@ -188,7 +188,8 @@ func stagePluginMain(cmd *cobra.Command, args []string) { var result error var xformSources []string for _, src := range sources { - _, newSource, result := client.DoShadowIngest(context.Background(), src, mountPrefixStr, shadowOriginPrefixStr, client.WithTokenLocation(tokenLocation), client.WithAcquireToken(false)) + newSource := "" + _, newSource, result = client.DoShadowIngest(context.Background(), src, mountPrefixStr, shadowOriginPrefixStr, client.WithTokenLocation(tokenLocation), client.WithAcquireToken(false)) if result != nil { // What's the correct behavior on failure? For now, we silently put the transfer // back on the original list. This is arguably the wrong approach as it might diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go index b21bec198..c109826c0 100644 --- a/cmd/plugin_test.go +++ b/cmd/plugin_test.go @@ -39,7 +39,6 @@ import ( "golang.org/x/sync/errgroup" "github.com/pelicanplatform/pelican/classads" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/launchers" "github.com/pelicanplatform/pelican/param" @@ -174,7 +173,7 @@ func (f *FedTest) Spinup() { func (f *FedTest) Teardown() { os.RemoveAll(f.TmpPath) os.RemoveAll(f.OriginDir) - common.ResetOriginExports() + server_utils.ResetOriginExports() f.Cancel() f.FedCancel() assert.NoError(f.T, f.ErrGroup.Wait()) @@ -184,7 +183,7 @@ func (f *FedTest) Teardown() { // Test the main function for the pelican plugin func TestStashPluginMain(t *testing.T) { viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() config.SetPreferredPrefix("STASH") diff --git a/common/director.go b/common/director.go deleted file mode 100644 index cb877953f..000000000 --- a/common/director.go +++ /dev/null @@ -1,146 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research - * - * 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. - * - ***************************************************************/ - -// Common pacakge contains shared structs and methods between different Pelican pacakges. -package common - -import ( - "encoding/json" - "net/url" -) - -type ( - TokenIssuer struct { - BasePaths []string `json:"base-paths"` - RestrictedPaths []string `json:"restricted-paths"` - IssuerUrl url.URL `json:"issuer"` - } - - TokenGen struct { - Strategy StrategyType `json:"strategy"` - VaultServer string `json:"vault-server"` - MaxScopeDepth uint `json:"max-scope-depth"` - CredentialIssuer url.URL `json:"issuer"` - } - - Capabilities struct { - PublicReads bool `json:"PublicRead"` - Reads bool `json:"Read"` - Writes bool `json:"Write"` - Listings bool `json:"Listing"` - DirectReads bool `json:"FallBackRead"` - } - - NamespaceAdV2 struct { - PublicRead bool - Caps Capabilities // Namespace capabilities should be considered independently of the origin’s capabilities. - Path string `json:"path"` - Generation []TokenGen `json:"token-generation"` - Issuer []TokenIssuer `json:"token-issuer"` - } - - NamespaceAdV1 struct { - RequireToken bool `json:"requireToken"` - Path string `json:"path"` - Issuer url.URL `json:"url"` - MaxScopeDepth uint `json:"maxScopeDepth"` - Strategy StrategyType `json:"strategy"` - BasePath string `json:"basePath"` - VaultServer string `json:"vaultServer"` - DirlistHost string `json:"dirlisthost"` - } - - ServerAd struct { - Name string - AuthURL url.URL - BrokerURL url.URL // The URL of the broker service to use for this host. - URL url.URL // This is server's XRootD URL for file transfer - WebURL url.URL // This is server's Web interface and API - Type ServerType - Latitude float64 - Longitude float64 - Writes bool - DirectReads bool // True if reads from the origin are permitted when no cache is available - } - - ServerType string - StrategyType string - - OriginAdvertiseV2 struct { - Name string `json:"name"` - BrokerURL string `json:"broker-url,omitempty"` - DataURL string `json:"data-url" binding:"required"` - WebURL string `json:"web-url,omitempty"` - Caps Capabilities `json:"capabilities"` - Namespaces []NamespaceAdV2 `json:"namespaces"` - Issuer []TokenIssuer `json:"token-issuer"` - } - - OriginAdvertiseV1 struct { - Name string `json:"name"` - URL string `json:"url" binding:"required"` // This is the url for origin's XRootD service and file transfer - WebURL string `json:"web_url,omitempty"` // This is the url for origin's web engine and APIs - Namespaces []NamespaceAdV1 `json:"namespaces"` - Writes bool `json:"enablewrite"` - DirectReads bool `json:"enable-fallback-read"` // True if the origin will allow direct client reads when no caches are available - } - - DirectorTestResult struct { - Status string `json:"status"` - Message string `json:"message"` - Timestamp int64 `json:"timestamp"` - } - GetPrefixByPathRes struct { - Prefix string `json:"prefix"` - } -) - -const ( - CacheType ServerType = "Cache" - OriginType ServerType = "Origin" -) - -const ( - OAuthStrategy StrategyType = "OAuth2" - VaultStrategy StrategyType = "Vault" -) - -func (ad ServerAd) MarshalJSON() ([]byte, error) { - baseAd := struct { - Name string `json:"name"` - AuthURL string `json:"auth_url"` - URL string `json:"url"` - WebURL string `json:"web_url"` - Type ServerType `json:"type"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Writes bool `json:"enable_write"` - DirectReads bool `json:"enable_fallback_read"` - }{ - Name: ad.Name, - AuthURL: ad.AuthURL.String(), - URL: ad.URL.String(), - WebURL: ad.WebURL.String(), - Type: ad.Type, - Latitude: ad.Latitude, - Longitude: ad.Longitude, - Writes: ad.Writes, - DirectReads: ad.DirectReads, - } - return json.Marshal(baseAd) -} diff --git a/director/advertise.go b/director/advertise.go index 2507f3296..563f76a6b 100644 --- a/director/advertise.go +++ b/director/advertise.go @@ -27,13 +27,13 @@ import ( "github.com/pkg/errors" log "github.com/sirupsen/logrus" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/utils" ) -func parseServerAd(server utils.Server, serverType common.ServerType) common.ServerAd { - serverAd := common.ServerAd{} +func parseServerAd(server utils.Server, serverType server_structs.ServerType) server_structs.ServerAd { + serverAd := server_structs.ServerAd{} serverAd.Type = serverType serverAd.Name = server.Resource @@ -74,13 +74,13 @@ func AdvertiseOSDF() error { return errors.Wrapf(err, "Failed to get topology JSON") } - cacheAdMap := make(map[common.ServerAd][]common.NamespaceAdV2) - originAdMap := make(map[common.ServerAd][]common.NamespaceAdV2) - tGen := common.TokenGen{} + cacheAdMap := make(map[server_structs.ServerAd][]server_structs.NamespaceAdV2) + originAdMap := make(map[server_structs.ServerAd][]server_structs.NamespaceAdV2) + tGen := server_structs.TokenGen{} for _, ns := range namespaces.Namespaces { requireToken := ns.UseTokenOnRead - tokenIssuers := []common.TokenIssuer{} + tokenIssuers := []server_structs.TokenIssuer{} // A token is required on read, so scitokens will be populated if requireToken { credUrl, err := url.Parse(ns.CredentialGeneration.Issuer) @@ -90,7 +90,7 @@ func AdvertiseOSDF() error { } credIssuer := *credUrl - tGen.Strategy = common.StrategyType(ns.CredentialGeneration.Strategy) + tGen.Strategy = server_structs.StrategyType(ns.CredentialGeneration.Strategy) tGen.VaultServer = ns.CredentialGeneration.VaultServer tGen.MaxScopeDepth = uint(ns.CredentialGeneration.MaxScopeDepth) tGen.CredentialIssuer = credIssuer @@ -104,7 +104,7 @@ func AdvertiseOSDF() error { continue } issuer := *issuerURL - tIssuer := common.TokenIssuer{ + tIssuer := server_structs.TokenIssuer{ BasePaths: scitok.BasePath, RestrictedPaths: scitok.Restricted, IssuerUrl: issuer, @@ -121,16 +121,16 @@ func AdvertiseOSDF() error { write = false } - caps := common.Capabilities{ + caps := server_structs.Capabilities{ PublicReads: !ns.UseTokenOnRead, Reads: ns.ReadHTTPS, Writes: write, } - nsAd := common.NamespaceAdV2{ + nsAd := server_structs.NamespaceAdV2{ Path: ns.Path, PublicRead: !ns.UseTokenOnRead, Caps: caps, - Generation: []common.TokenGen{tGen}, + Generation: []server_structs.TokenGen{tGen}, Issuer: tokenIssuers, } @@ -139,12 +139,12 @@ func AdvertiseOSDF() error { // they're listed as inactive by topology). These namespaces will all be mapped to the // same useless origin ad, resulting in a 404 for queries to those namespaces for _, origin := range ns.Origins { - originAd := parseServerAd(origin, common.OriginType) + originAd := parseServerAd(origin, server_structs.OriginType) originAdMap[originAd] = append(originAdMap[originAd], nsAd) } for _, cache := range ns.Caches { - cacheAd := parseServerAd(cache, common.CacheType) + cacheAd := parseServerAd(cache, server_structs.CacheType) cacheAdMap[cacheAd] = append(cacheAdMap[cacheAd], nsAd) } } diff --git a/director/advertise_test.go b/director/advertise_test.go index 4e04fa939..4f3bb10f9 100644 --- a/director/advertise_test.go +++ b/director/advertise_test.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/utils" ) @@ -40,16 +40,16 @@ func TestParseServerAd(t *testing.T) { // Check that we populate all of the fields correctly -- note that lat/long don't get updated // until right before the ad is recorded, so we don't check for that here. - ad := parseServerAd(server, common.OriginType) + ad := parseServerAd(server, server_structs.OriginType) assert.Equal(t, ad.AuthURL.String(), "https://my-auth-endpoint.com") assert.Equal(t, ad.URL.String(), "http://my-endpoint.com") assert.Equal(t, ad.WebURL.String(), "") assert.Equal(t, ad.Name, "MY_SERVER") - assert.True(t, ad.Type == common.OriginType) + assert.True(t, ad.Type == server_structs.OriginType) // A quick check that type is set correctly - ad = parseServerAd(server, common.CacheType) - assert.True(t, ad.Type == common.CacheType) + ad = parseServerAd(server, server_structs.CacheType) + assert.True(t, ad.Type == server_structs.CacheType) } func JSONHandler(w http.ResponseWriter, r *http.Request) { diff --git a/director/cache_ads.go b/director/cache_ads.go index 0ae80c633..31d21f775 100644 --- a/director/cache_ads.go +++ b/director/cache_ads.go @@ -19,7 +19,6 @@ package director import ( - "errors" "fmt" "net" "net/netip" @@ -29,9 +28,11 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/param" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" + + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" ) type filterType string @@ -43,13 +44,13 @@ const ( ) var ( - serverAds = ttlcache.New(ttlcache.WithTTL[common.ServerAd, []common.NamespaceAdV2](15 * time.Minute)) + serverAds = ttlcache.New(ttlcache.WithTTL[server_structs.ServerAd, []server_structs.NamespaceAdV2](15 * time.Minute)) serverAdMutex = sync.RWMutex{} filteredServers = map[string]filterType{} filteredServersMutex = sync.RWMutex{} ) -func recordAd(ad common.ServerAd, namespaceAds *[]common.NamespaceAdV2) { +func recordAd(ad server_structs.ServerAd, namespaceAds *[]server_structs.NamespaceAdV2) { if err := updateLatLong(&ad); err != nil { log.Debugln("Failed to lookup GeoIP coordinates for host", ad.URL.Host) } @@ -64,7 +65,7 @@ func recordAd(ad common.ServerAd, namespaceAds *[]common.NamespaceAdV2) { } } -func updateLatLong(ad *common.ServerAd) error { +func updateLatLong(ad *server_structs.ServerAd) error { if ad == nil { return errors.New("Cannot provide a nil ad to UpdateLatLong") } @@ -89,8 +90,8 @@ func updateLatLong(ad *common.ServerAd) error { return nil } -func matchesPrefix(reqPath string, namespaceAds []common.NamespaceAdV2) *common.NamespaceAdV2 { - var best *common.NamespaceAdV2 +func matchesPrefix(reqPath string, namespaceAds []server_structs.NamespaceAdV2) *server_structs.NamespaceAdV2 { + var best *server_structs.NamespaceAdV2 for _, namespace := range namespaceAds { serverPath := namespace.Path @@ -119,7 +120,7 @@ func matchesPrefix(reqPath string, namespaceAds []common.NamespaceAdV2) *common. // Make the len comparison with tmpBest, because serverPath is one char longer now if strings.HasPrefix(reqPath, serverPath) && len(serverPath) > len(tmpBest) { if best == nil { - best = new(common.NamespaceAdV2) + best = new(server_structs.NamespaceAdV2) } *best = namespace } @@ -127,8 +128,7 @@ func matchesPrefix(reqPath string, namespaceAds []common.NamespaceAdV2) *common. return best } -// Get the longest matches namespace prefix with corresponding serverAds and namespace Ads given a path to an object -func getAdsForPath(reqPath string) (originNamespace common.NamespaceAdV2, originAds []common.ServerAd, cacheAds []common.ServerAd) { +func getAdsForPath(reqPath string) (originNamespace server_structs.NamespaceAdV2, originAds []server_structs.ServerAd, cacheAds []server_structs.ServerAd) { serverAdMutex.RLock() defer serverAdMutex.RUnlock() @@ -140,7 +140,7 @@ func getAdsForPath(reqPath string) (originNamespace common.NamespaceAdV2, origin // Iterate through all of the server ads. For each "item", the key // is the server ad itself (either cache or origin), and the value // is a slice of namespace prefixes are supported by that server - var best *common.NamespaceAdV2 + var best *server_structs.NamespaceAdV2 for _, item := range serverAds.Items() { if item == nil { continue @@ -150,7 +150,7 @@ func getAdsForPath(reqPath string) (originNamespace common.NamespaceAdV2, origin log.Debugf("Skipping %s server %s as it's in the filtered server list with type %s", serverAd.Type, serverAd.Name, ft) continue } - if serverAd.Type == common.OriginType { + if serverAd.Type == server_structs.OriginType { if ns := matchesPrefix(reqPath, item.Value()); ns != nil { if best == nil || len(ns.Path) > len(best.Path) { best = ns @@ -158,18 +158,18 @@ func getAdsForPath(reqPath string) (originNamespace common.NamespaceAdV2, origin // prefix, we overwrite that here because we found a better ns. We also clear // the other slice of server ads, because we know those aren't good anymore originAds = append(originAds[:0], serverAd) - cacheAds = []common.ServerAd{} + cacheAds = []server_structs.ServerAd{} } else if ns.Path == best.Path { originAds = append(originAds, serverAd) } } continue - } else if serverAd.Type == common.CacheType { + } else if serverAd.Type == server_structs.CacheType { if ns := matchesPrefix(reqPath, item.Value()); ns != nil { if best == nil || len(ns.Path) > len(best.Path) { best = ns cacheAds = append(cacheAds[:0], serverAd) - originAds = []common.ServerAd{} + originAds = []server_structs.ServerAd{} } else if ns.Path == best.Path { cacheAds = append(cacheAds, serverAd) } diff --git a/director/cache_ads_test.go b/director/cache_ads_test.go index 233b56242..04ebeefab 100644 --- a/director/cache_ads_test.go +++ b/director/cache_ads_test.go @@ -25,13 +25,13 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/server_structs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" ) -func hasServerAdWithName(serverAds []common.ServerAd, name string) bool { +func hasServerAdWithName(serverAds []server_structs.ServerAd, name string) bool { for _, serverAd := range serverAds { if serverAd.Name == name { return true @@ -50,11 +50,11 @@ func TestGetAdsForPath(t *testing.T) { - Record the ads - Query for a few paths and make sure the correct ads are returned */ - nsAd1 := common.NamespaceAdV2{ + nsAd1 := server_structs.NamespaceAdV2{ PublicRead: false, - Caps: common.Capabilities{PublicReads: false}, + Caps: server_structs.Capabilities{PublicReads: false}, Path: "/chtc", - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: url.URL{ Scheme: "https", Host: "wisc.edu", @@ -63,11 +63,11 @@ func TestGetAdsForPath(t *testing.T) { }, } - nsAd2 := common.NamespaceAdV2{ + nsAd2 := server_structs.NamespaceAdV2{ PublicRead: true, - Caps: common.Capabilities{PublicReads: true}, + Caps: server_structs.Capabilities{PublicReads: true}, Path: "/chtc/PUBLIC", - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: url.URL{ Scheme: "https", Host: "wisc.edu", @@ -76,11 +76,11 @@ func TestGetAdsForPath(t *testing.T) { }, } - nsAd3 := common.NamespaceAdV2{ + nsAd3 := server_structs.NamespaceAdV2{ PublicRead: true, - Caps: common.Capabilities{PublicReads: true}, + Caps: server_structs.Capabilities{PublicReads: true}, Path: "/chtc/PUBLIC2/", - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: url.URL{ Scheme: "https", Host: "wisc.edu", @@ -89,7 +89,7 @@ func TestGetAdsForPath(t *testing.T) { }, } - cacheAd1 := common.ServerAd{ + cacheAd1 := server_structs.ServerAd{ Name: "cache1", AuthURL: url.URL{ Scheme: "https", @@ -99,10 +99,10 @@ func TestGetAdsForPath(t *testing.T) { Scheme: "https", Host: "wisc.edu", }, - Type: common.CacheType, + Type: server_structs.CacheType, } - cacheAd2 := common.ServerAd{ + cacheAd2 := server_structs.ServerAd{ Name: "cache2", AuthURL: url.URL{ Scheme: "https", @@ -112,10 +112,10 @@ func TestGetAdsForPath(t *testing.T) { Scheme: "https", Host: "wisc.edu", }, - Type: common.CacheType, + Type: server_structs.CacheType, } - originAd1 := common.ServerAd{ + originAd1 := server_structs.ServerAd{ Name: "origin1", AuthURL: url.URL{ Scheme: "https", @@ -125,10 +125,10 @@ func TestGetAdsForPath(t *testing.T) { Scheme: "https", Host: "wisc.edu", }, - Type: common.OriginType, + Type: server_structs.OriginType, } - originAd2 := common.ServerAd{ + originAd2 := server_structs.ServerAd{ Name: "origin2", AuthURL: url.URL{ Scheme: "https", @@ -138,12 +138,12 @@ func TestGetAdsForPath(t *testing.T) { Scheme: "https", Host: "wisc.edu", }, - Type: common.OriginType, + Type: server_structs.OriginType, } - o1Slice := []common.NamespaceAdV2{nsAd1} - o2Slice := []common.NamespaceAdV2{nsAd2, nsAd3} - c1Slice := []common.NamespaceAdV2{nsAd1, nsAd2} + o1Slice := []server_structs.NamespaceAdV2{nsAd1} + o2Slice := []server_structs.NamespaceAdV2{nsAd2, nsAd3} + c1Slice := []server_structs.NamespaceAdV2{nsAd1, nsAd2} recordAd(originAd2, &o2Slice) recordAd(originAd1, &o1Slice) recordAd(cacheAd1, &c1Slice) @@ -215,7 +215,7 @@ func TestGetAdsForPath(t *testing.T) { } func TestConfigCacheEviction(t *testing.T) { - mockPelicanOriginServerAd := common.ServerAd{ + mockPelicanOriginServerAd := server_structs.ServerAd{ Name: "test-origin-server", AuthURL: url.URL{}, URL: url.URL{ @@ -226,16 +226,16 @@ func TestConfigCacheEviction(t *testing.T) { Scheme: "https", Host: "fake-origin.org:8444", }, - Type: common.OriginType, + Type: server_structs.OriginType, Latitude: 123.05, Longitude: 456.78, } - mockNamespaceAd := common.NamespaceAdV2{ + mockNamespaceAd := server_structs.NamespaceAdV2{ PublicRead: false, - Caps: common.Capabilities{PublicReads: false}, + Caps: server_structs.Capabilities{PublicReads: false}, Path: "/foo/bar/", - Issuer: []common.TokenIssuer{{IssuerUrl: url.URL{}}}, - Generation: []common.TokenGen{{ + Issuer: []server_structs.TokenIssuer{{IssuerUrl: url.URL{}}}, + Generation: []server_structs.TokenGen{{ MaxScopeDepth: 1, Strategy: "", VaultServer: "", @@ -261,11 +261,11 @@ func TestConfigCacheEviction(t *testing.T) { serverAdMutex.Lock() defer serverAdMutex.Unlock() serverAds.DeleteAll() - serverAds.Set(mockPelicanOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + serverAds.Set(mockPelicanOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) healthTestUtilsMutex.Lock() defer healthTestUtilsMutex.Unlock() // Clear the map for the new test - healthTestUtils = make(map[common.ServerAd]*healthTestUtil) + healthTestUtils = make(map[server_structs.ServerAd]*healthTestUtil) healthTestUtils[mockPelicanOriginServerAd] = &healthTestUtil{ Cancel: cancelFunc, ErrGrp: errgrp, @@ -304,7 +304,7 @@ func TestConfigCacheEviction(t *testing.T) { } func TestServerAdsCacheEviction(t *testing.T) { - mockServerAd := common.ServerAd{Name: "foo", Type: common.OriginType, URL: url.URL{}} + mockServerAd := server_structs.ServerAd{Name: "foo", Type: server_structs.OriginType, URL: url.URL{}} t.Run("evict-after-expire-time", func(t *testing.T) { // Start cache eviction @@ -325,7 +325,7 @@ func TestServerAdsCacheEviction(t *testing.T) { defer serverAdMutex.Unlock() serverAds.DeleteAll() - serverAds.Set(mockServerAd, []common.NamespaceAdV2{}, time.Second*2) + serverAds.Set(mockServerAd, []server_structs.NamespaceAdV2{}, time.Second*2) require.True(t, serverAds.Has(mockServerAd), "Failed to register server Ad") }() diff --git a/director/cache_monitor.go b/director/cache_monitor.go new file mode 100644 index 000000000..3e4f5e1e1 --- /dev/null +++ b/director/cache_monitor.go @@ -0,0 +1,70 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package director + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pelicanplatform/pelican/config" + "github.com/pkg/errors" +) + +const ( + cacheMonitroingBasePath = "/pelican/monitoring" + testFileContent = "This object was created by the Pelican director-test functionality. Test is issued at " +) + +func runCacheTest(ctx context.Context, cacheUrl url.URL) error { + nowStr := time.Now().Format(time.RFC3339) + cacheUrl.Path = fmt.Sprintf("/pelican/monitoring/%s.txt", nowStr) + client := http.Client{Transport: config.GetTransport()} + req, err := http.NewRequestWithContext(ctx, "GET", cacheUrl.String(), nil) + if err != nil { + urlErr, ok := err.(*url.Error) + if ok && urlErr.Err == context.Canceled { + // Shouldn't return error if the error is due to context being cancelled + return nil + } + return errors.Wrap(err, "failed to create an HTTP request") + } + res, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "failed to send request to cache for the test file") + } + byteBody, err := io.ReadAll(res.Body) + if err != nil { + return errors.Wrap(err, "failed to read response body. Response status code is "+res.Status) + } + if res.StatusCode != 200 { + return fmt.Errorf("cache responses with non-200 status code. Body is %s", string(byteBody)) + } + strBody := string(byteBody) + + if strings.TrimSuffix(strBody, "\n") == testFileContent+nowStr { + return nil + } else { + return fmt.Errorf("cache response file does not match expectation. Expected:%s, Got:%s", testFileContent+nowStr, strBody) + } +} diff --git a/director/director.go b/director/director.go index 851f10b47..5a96867b6 100644 --- a/director/director.go +++ b/director/director.go @@ -19,208 +19,832 @@ package director import ( + "context" + "fmt" "net/http" + "net/netip" + "net/url" "path" + "regexp" + "strconv" "strings" + "sync" "github.com/gin-gonic/gin" - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/web_ui" + "github.com/gin-gonic/gin/binding" + "github.com/hashicorp/go-version" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/token" + "github.com/pelicanplatform/pelican/token_scopes" ) type ( - listServerRequest struct { - ServerType string `form:"server_type"` // "cache" or "origin" + // status of director-based health tests to origins and caches + HealthTestStatus string + + // Prometheus HTTP discovery endpoint struct, used by director + // to dynamically return available origin/cache servers for Prometheus to scrape + PromDiscoveryItem struct { + Targets []string `json:"targets"` + Labels map[string]string `json:"labels"` } - listServerResponse struct { - Name string `json:"name"` - AuthURL string `json:"authUrl"` - BrokerURL string `json:"brokerUrl"` - URL string `json:"url"` // This is server's XRootD URL for file transfer - WebURL string `json:"webUrl"` // This is server's Web interface and API - Type common.ServerType `json:"type"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Writes bool `json:"enableWrite"` - DirectReads bool `json:"enableFallbackRead"` - Filtered bool `json:"filtered"` - FilteredType filterType `json:"filteredType"` - Status HealthTestStatus `json:"status"` + // Util struct to keep track of director-based health tests it created + healthTestUtil struct { + ErrGrp *errgroup.Group + ErrGrpContext context.Context + Cancel context.CancelFunc + Status HealthTestStatus } + // Util struct to keep track of `stat` call the director made to the origins + originStatUtil struct { + Context context.Context + Cancel context.CancelFunc + Errgroup *errgroup.Group + } +) + +const ( + HealthStatusUnknown HealthTestStatus = "Unknown" + HealthStatusInit HealthTestStatus = "Initializing" + HealthStatusOK HealthTestStatus = "OK" + HealthStatusError HealthTestStatus = "Error" +) + +var ( + minClientVersion, _ = version.NewVersion("7.0.0") + minOriginVersion, _ = version.NewVersion("7.0.0") + minCacheVersion, _ = version.NewVersion("7.3.0") + healthTestUtils = make(map[server_structs.ServerAd]*healthTestUtil) + healthTestUtilsMutex = sync.RWMutex{} - statResponse struct { - OK bool `json:"ok"` - Message string `json:"message"` - Metadata []*objectMetadata `json:"metadata"` + originStatUtils = make(map[url.URL]originStatUtil) + originStatUtilsMutex = sync.RWMutex{} +) + +func getRedirectURL(reqPath string, ad server_structs.ServerAd, requiresAuth bool) (redirectURL url.URL) { + var serverURL url.URL + if requiresAuth { + serverURL = ad.AuthURL + } else { + serverURL = ad.URL } + reqPath = path.Clean("/" + reqPath) + if requiresAuth { + redirectURL.Scheme = "https" + } else { + redirectURL.Scheme = "http" + } + redirectURL.Host = serverURL.Host + redirectURL.Path = reqPath + return +} - statRequest struct { - MinResponses int `form:"min_responses"` - MaxResponses int `form:"max_responses"` +func getRealIP(ginCtx *gin.Context) (ipAddr netip.Addr, err error) { + ip_addr_list := ginCtx.Request.Header["X-Real-Ip"] + if len(ip_addr_list) == 0 { + ipAddr, err = netip.ParseAddr(ginCtx.RemoteIP()) + return + } else { + ipAddr, err = netip.ParseAddr(ip_addr_list[0]) + return } -) +} + +// Calculate the depth attribute of Link header given the path to the file +// and the prefix of the namespace that can serve the file +// +// Ref: https://www.rfc-editor.org/rfc/rfc6249.html#section-3.4 +func getLinkDepth(filepath, prefix string) (int, error) { + if filepath == "" || prefix == "" { + return 0, errors.New("either filepath or prefix is an empty path") + } + if !strings.HasPrefix(filepath, prefix) { + return 0, errors.New("filepath does not contain the prefix") + } + // We want to remove shared prefix between filepath and prefix, then split the remaining string by slash. + // To make the final calculation easier, we also remove the head slash from the file path. + // e.g. filepath = /foo/bar/barz.txt prefix = /foo + // we want commonPath = bar/barz.txt + if !strings.HasSuffix(prefix, "/") && prefix != "/" { + prefix += "/" + } + commonPath := strings.TrimPrefix(filepath, prefix) + pathDepth := len(strings.Split(commonPath, "/")) + return pathDepth, nil +} + +func getAuthzEscaped(req *http.Request) (authzEscaped string) { + if authzQuery := req.URL.Query()["authz"]; len(authzQuery) > 0 { + authzEscaped = authzQuery[0] + // if the authz URL query is coming from XRootD, it probably has a "Bearer " tacked in front + // even though it's coming via a URL + authzEscaped = strings.TrimPrefix(authzEscaped, "Bearer ") + } else if authzHeader := req.Header["Authorization"]; len(authzHeader) > 0 { + authzEscaped = strings.TrimPrefix(authzHeader[0], "Bearer ") + authzEscaped = url.QueryEscape(authzEscaped) + } + return +} + +func getFinalRedirectURL(rurl url.URL, authzEscaped string) string { + if len(authzEscaped) > 0 { + if len(rurl.RawQuery) > 0 { + rurl.RawQuery += "&" + } + rurl.RawQuery += "authz=" + authzEscaped + } + return rurl.String() +} + +func versionCompatCheck(ginCtx *gin.Context) error { + // Check that the version of whichever service (eg client, origin, etc) is talking to the Director + // is actually something the Director thinks it can communicate with + + // The service/version is sent via User-Agent header in the form "pelican-/" + userAgentSlc := ginCtx.Request.Header["User-Agent"] + if len(userAgentSlc) < 1 { + return errors.New("No user agent could be found") + } + + // gin gives us a slice of user agents. Since pelican services should only ever + // send one UA, assume that it is the 0-th element of the slice. + userAgent := userAgentSlc[0] -func (req listServerRequest) ToInternalServerType() common.ServerType { - if req.ServerType == "cache" { - return common.CacheType + // Make sure we're working with something that's formatted the way we expect. If we + // don't match, then we're definitely not coming from one of the services, so we + // let things go without an error. Maybe someone is using curl? + uaRegExp := regexp.MustCompile(`^pelican-[^\/]+\/\d+\.\d+\.\d+`) + if matches := uaRegExp.MatchString(userAgent); !matches { + return nil } - if req.ServerType == "origin" { - return common.OriginType + + userAgentSplit := strings.Split(userAgent, "/") + // Grab the actual service/version that's using the Director. There may be different versioning + // requirements between origins, clients, and other services. + service := (strings.Split(userAgentSplit[0], "-"))[1] + reqVerStr := userAgentSplit[1] + reqVer, err := version.NewVersion(reqVerStr) + if err != nil { + return errors.Wrapf(err, "Could not parse service version as a semantic version: %s\n", reqVerStr) + } + + var minCompatVer *version.Version + switch service { + case "client": + minCompatVer = minClientVersion + case "origin": + minCompatVer = minOriginVersion + case "cache": + minCompatVer = minCacheVersion + default: + return errors.Errorf("Invalid version format. The director does not support your %s version (%s).", service, reqVer.String()) } - return "" + + if reqVer.LessThan(minCompatVer) { + return errors.Errorf("The director does not support your %s version (%s). Please update to %s or newer.", service, reqVer.String(), minCompatVer.String()) + } + + return nil } -func listServers(ctx *gin.Context) { - queryParams := listServerRequest{} - if ctx.ShouldBindQuery(&queryParams) != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) +func redirectToCache(ginCtx *gin.Context) { + err := versionCompatCheck(ginCtx) + if err != nil { + log.Warningf("A version incompatibility was encountered while redirecting to a cache and no response was served: %v", err) + ginCtx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) return } - var servers []common.ServerAd - if queryParams.ServerType != "" { - if !strings.EqualFold(queryParams.ServerType, string(common.OriginType)) && !strings.EqualFold(queryParams.ServerType, string(common.CacheType)) { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server type"}) + + reqPath := path.Clean("/" + ginCtx.Request.URL.Path) + reqPath = strings.TrimPrefix(reqPath, "/api/v1.0/director/object") + ipAddr, err := getRealIP(ginCtx) + if err != nil { + log.Errorln("Error in getRealIP:", err) + ginCtx.String(http.StatusInternalServerError, "Internal error: Unable to determine client IP") + return + } + + authzBearerEscaped := getAuthzEscaped(ginCtx.Request) + + namespaceAd, originAds, cacheAds := getAdsForPath(reqPath) + // if GetAdsForPath doesn't find any ads because the prefix doesn't exist, we should + // report the lack of path first -- this is most important for the user because it tells them + // they're trying to get an object that simply doesn't exist + if namespaceAd.Path == "" { + ginCtx.String(404, "No namespace found for path. Either it doesn't exist, or the Director is experiencing problems\n") + return + } + // if err != nil, depth == 0, which is the default value for depth + // so we can use it as the value for the header even with err + depth, err := getLinkDepth(reqPath, namespaceAd.Path) + if err != nil { + log.Errorf("Failed to get depth attribute for the redirecting request to %q, with best match namespace prefix %q", reqPath, namespaceAd.Path) + } + // If the namespace prefix DOES exist, then it makes sense to say we couldn't find a valid cache. + if len(cacheAds) == 0 { + for _, originAd := range originAds { + if originAd.DirectReads { + cacheAds = append(cacheAds, originAd) + break + } + } + if len(cacheAds) == 0 { + ginCtx.String(http.StatusNotFound, "No cache found for path") return } - servers = listServerAds([]common.ServerType{common.ServerType(queryParams.ToInternalServerType())}) } else { - servers = listServerAds([]common.ServerType{common.OriginType, common.CacheType}) - } - healthTestUtilsMutex.RLock() - defer healthTestUtilsMutex.RUnlock() - resList := make([]listServerResponse, 0) - for _, server := range servers { - healthStatus := HealthStatusUnknown - healthUtil, ok := healthTestUtils[server] - if ok { - healthStatus = healthUtil.Status - } - filtered, ft := checkFilter(server.Name) - res := listServerResponse{ - Name: server.Name, - BrokerURL: server.BrokerURL.String(), - AuthURL: server.AuthURL.String(), - URL: server.URL.String(), - WebURL: server.WebURL.String(), - Type: server.Type, - Latitude: server.Latitude, - Longitude: server.Longitude, - Writes: server.Writes, - DirectReads: server.DirectReads, - Filtered: filtered, - FilteredType: ft, - Status: healthStatus, - } - resList = append(resList, res) - } - ctx.JSON(http.StatusOK, resList) -} - -func queryOrigins(ctx *gin.Context) { - pathParam := ctx.Param("path") - path := path.Clean(pathParam) - if path == "" || strings.HasSuffix(path, "/") { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Path should not be empty or ended with slash '/'"}) + cacheAds, err = sortServers(ipAddr, cacheAds) + if err != nil { + log.Error("Error determining server ordering for cacheAds: ", err) + ginCtx.String(http.StatusInternalServerError, "Failed to determine server ordering") + return + } + } + redirectURL := getRedirectURL(reqPath, cacheAds[0], !namespaceAd.Caps.PublicReads) + + linkHeader := "" + first := true + for idx, ad := range cacheAds { + if first { + first = false + } else { + linkHeader += ", " + } + redirectURL := getRedirectURL(reqPath, ad, !namespaceAd.Caps.PublicReads) + linkHeader += fmt.Sprintf(`<%s>; rel="duplicate"; pri=%d; depth=%d`, redirectURL.String(), idx+1, depth) + } + ginCtx.Writer.Header()["Link"] = []string{linkHeader} + if len(namespaceAd.Issuer) != 0 { + + issStrings := []string{} + for _, tokIss := range namespaceAd.Issuer { + issStrings = append(issStrings, "issuer="+tokIss.IssuerUrl.String()) + } + ginCtx.Writer.Header()["X-Pelican-Authorization"] = issStrings + } + + if len(namespaceAd.Generation) != 0 { + tokenGen := "" + first := true + hdrVals := []string{namespaceAd.Generation[0].CredentialIssuer.String(), fmt.Sprint(namespaceAd.Generation[0].MaxScopeDepth), string(namespaceAd.Generation[0].Strategy)} + for idx, hdrKey := range []string{"issuer", "max-scope-depth", "strategy"} { + hdrVal := hdrVals[idx] + if hdrVal == "" { + continue + } + if !first { + tokenGen += ", " + } + first = false + tokenGen += hdrKey + "=" + hdrVal + } + if tokenGen != "" { + ginCtx.Writer.Header()["X-Pelican-Token-Generation"] = []string{tokenGen} + } + } + + var colUrl string + if namespaceAd.PublicRead { + colUrl = originAds[0].URL.String() + } else { + colUrl = originAds[0].AuthURL.String() + } + ginCtx.Writer.Header()["X-Pelican-Namespace"] = []string{fmt.Sprintf("namespace=%s, require-token=%v, collections-url=%s", + namespaceAd.Path, !namespaceAd.PublicRead, colUrl)} + + // Note we only append the `authz` query parameter in the case of the redirect response and not the + // duplicate link metadata above. This is purposeful: the Link header might get too long if we repeat + // the token 20 times for 20 caches. This means a "normal HTTP client" will correctly redirect but + // anything parsing the `Link` header for metalinks will need logic for redirecting appropriately. + ginCtx.Redirect(307, getFinalRedirectURL(redirectURL, authzBearerEscaped)) +} + +func redirectToOrigin(ginCtx *gin.Context) { + err := versionCompatCheck(ginCtx) + if err != nil { + log.Warningf("A version incompatibility was encountered while redirecting to an origin and no response was served: %v", err) + ginCtx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) return } - queryParams := statRequest{} - if ctx.ShouldBindQuery(&queryParams) != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + + reqPath := path.Clean("/" + ginCtx.Request.URL.Path) + reqPath = strings.TrimPrefix(reqPath, "/api/v1.0/director/origin") + + // /pelican/monitoring is the path for director-based health test + // where we have /director/healthTest API to mock a file for the cache to get + if strings.HasPrefix(reqPath, "/pelican/monitoring/") { + ginCtx.Redirect(http.StatusTemporaryRedirect, param.Server_ExternalWebUrl.GetString()+"/api/v1.0/director/healthTest"+reqPath) return } - meta, msg, err := NewObjectStat().Query(path, ctx, queryParams.MinResponses, queryParams.MaxResponses) + + // Each namespace may be exported by several origins, so we must still + // do the geolocation song and dance if we want to get the closest origin... + ipAddr, err := getRealIP(ginCtx) if err != nil { - if err == NoPrefixMatchError { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + authzBearerEscaped := getAuthzEscaped(ginCtx.Request) + + namespaceAd, originAds, _ := getAdsForPath(reqPath) + // if GetAdsForPath doesn't find any ads because the prefix doesn't exist, we should + // report the lack of path first -- this is most important for the user because it tells them + // they're trying to get an object that simply doesn't exist + if namespaceAd.Path == "" { + ginCtx.String(http.StatusNotFound, "No namespace found for path. Either it doesn't exist, or the Director is experiencing problems\n") + return + } + // If the namespace prefix DOES exist, then it makes sense to say we couldn't find the origin. + if len(originAds) == 0 { + ginCtx.String(http.StatusNotFound, "There are currently no origins exporting the provided namespace prefix\n") + return + } + // if err != nil, depth == 0, which is the default value for depth + // so we can use it as the value for the header even with err + depth, err := getLinkDepth(reqPath, namespaceAd.Path) + if err != nil { + log.Errorf("Failed to get depth attribute for the redirecting request to %q, with best match namespace prefix %q", reqPath, namespaceAd.Path) + } + + originAds, err = sortServers(ipAddr, originAds) + if err != nil { + log.Error("Error determining server ordering for originAds: ", err) + ginCtx.String(http.StatusInternalServerError, "Failed to determine origin ordering") + return + } + + linkHeader := "" + first := true + for idx, ad := range originAds { + if first { + first = false + } else { + linkHeader += ", " + } + redirectURL := getRedirectURL(reqPath, ad, !namespaceAd.PublicRead) + linkHeader += fmt.Sprintf(`<%s>; rel="duplicate"; pri=%d; depth=%d`, redirectURL.String(), idx+1, depth) + } + ginCtx.Writer.Header()["Link"] = []string{linkHeader} + + var colUrl string + if namespaceAd.PublicRead { + colUrl = originAds[0].URL.String() + } else { + colUrl = originAds[0].AuthURL.String() + } + ginCtx.Writer.Header()["X-Pelican-Namespace"] = []string{fmt.Sprintf("namespace=%s, require-token=%v, collections-url=%s", + namespaceAd.Path, !namespaceAd.PublicRead, colUrl)} + + var redirectURL url.URL + // If we are doing a PUT, check to see if any origins are writeable + if ginCtx.Request.Method == "PUT" { + for idx, ad := range originAds { + if ad.Writes { + redirectURL = getRedirectURL(reqPath, originAds[idx], !namespaceAd.PublicRead) + if brokerUrl := originAds[idx].BrokerURL; brokerUrl.String() != "" { + ginCtx.Header("X-Pelican-Broker", brokerUrl.String()) + } + ginCtx.Redirect(http.StatusTemporaryRedirect, getFinalRedirectURL(redirectURL, authzBearerEscaped)) + return + } + } + ginCtx.String(http.StatusMethodNotAllowed, "No origins on specified endpoint are writeable\n") + return + } else { // Otherwise, we are doing a GET + redirectURL := getRedirectURL(reqPath, originAds[0], !namespaceAd.PublicRead) + if brokerUrl := originAds[0].BrokerURL; brokerUrl.String() != "" { + ginCtx.Header("X-Pelican-Broker", brokerUrl.String()) + } + + // See note in RedirectToCache as to why we only add the authz query parameter to this URL, + // not those in the `Link`. + ginCtx.Redirect(http.StatusTemporaryRedirect, getFinalRedirectURL(redirectURL, authzBearerEscaped)) + } +} + +func checkHostnameRedirects(c *gin.Context, incomingHost string) { + oRedirectHosts := param.Director_OriginResponseHostnames.GetStringSlice() + cRedirectHosts := param.Director_CacheResponseHostnames.GetStringSlice() + + for _, hostname := range oRedirectHosts { + if hostname == incomingHost { + if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { + c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path + redirectToOrigin(c) + c.Abort() + log.Debugln("Director is serving an origin based on incoming 'Host' header value of '" + hostname + "'") + return + } + } + } + for _, hostname := range cRedirectHosts { + if hostname == incomingHost { + if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { + c.Request.URL.Path = "/api/v1.0/director/object" + c.Request.URL.Path + redirectToCache(c) + c.Abort() + log.Debugln("Director is serving a cache based on incoming 'Host' header value of '" + hostname + "'") + return + } + } + } +} + +// Middleware sends GET /foo/bar to the RedirectToCache function, as if the +// original request had been made to /api/v1.0/director/object/foo/bar +func ShortcutMiddleware(defaultResponse string) gin.HandlerFunc { + return func(c *gin.Context) { + // If this is a request for getting public key, don't modify the path + // If this is a request to the Prometheus API, don't modify the path + if strings.HasPrefix(c.Request.URL.Path, "/.well-known/") || + (strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/") && !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/")) { + c.Next() return - } else if err == ParameterError { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } + // Regardless of the remainder of the settings, we currently handle a PUT as a query to the origin endpoint + if c.Request.Method == "PUT" { + c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path + redirectToOrigin(c) + c.Abort() return - } else if err == InsufficientResError { - // Insufficient response does not cause a 500 error, but OK field in reponse is false - if len(meta) < 1 { - ctx.JSON(http.StatusNotFound, gin.H{"error": msg + " If no object is available, please check if the object is in a public namespace."}) + } + + // We grab the host and x-forwarded-host headers, which can be set by a client with the intent of changing the + // Director's default behavior (eg the director normally forwards to caches, but if it receives a request with + // a pre-configured hostname in its x-forwarded-host header, that indicates we should actually serve an origin.) + host, hostPresent := c.Request.Header["Host"] + xForwardedHost, xForwardedHostPresent := c.Request.Header["X-Forwarded-Host"] + + if hostPresent { + checkHostnameRedirects(c, host[0]) + } else if xForwardedHostPresent { + checkHostnameRedirects(c, xForwardedHost[0]) + } + + // If we're configured for cache mode or we haven't set the flag, + // we should use cache middleware + if defaultResponse == "cache" { + if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { + c.Request.URL.Path = "/api/v1.0/director/object" + c.Request.URL.Path + redirectToCache(c) + c.Abort() return } - res := statResponse{Message: msg, Metadata: meta, OK: false} - ctx.JSON(http.StatusOK, res) - } else { - log.Errorf("Error in NewObjectStat with path: %s, min responses: %d, max responses: %d. %v", path, queryParams.MinResponses, queryParams.MaxResponses, err) - ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + + // If the path starts with the correct prefix, continue with the next handler + c.Next() + } else if defaultResponse == "origin" { + if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { + c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path + redirectToOrigin(c) + c.Abort() + return + } + c.Next() + } + } +} + +func registerServeAd(engineCtx context.Context, ctx *gin.Context, sType server_structs.ServerType) { + tokens, present := ctx.Request.Header["Authorization"] + if !present || len(tokens) == 0 { + ctx.JSON(http.StatusForbidden, gin.H{"error": "Bearer token not present in the 'Authorization' header"}) + return + } + + err := versionCompatCheck(ctx) + if err != nil { + log.Warningf("A version incompatibility was encountered while registering %s and no response was served: %v", sType, err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) + return + } + + ad := server_structs.OriginAdvertiseV1{} + adV2 := server_structs.OriginAdvertiseV2{} + err = ctx.ShouldBindBodyWith(&ad, binding.JSON) + if err != nil { + // Failed binding to a V1 type, so should now check to see if it's a V2 type + adV2 = server_structs.OriginAdvertiseV2{} + err = ctx.ShouldBindBodyWith(&adV2, binding.JSON) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + sType + " registration"}) return } + } else { + // If the OriginAdvertisement is a V1 type, convert to a V2 type + adV2 = server_structs.ConvertOriginAdV1ToV2(ad) } - if len(meta) < 1 { - ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error() + " If no object is available, please check if the object is in a public namespace."}) + + if sType == server_structs.OriginType { + for _, namespace := range adV2.Namespaces { + // We're assuming there's only one token in the slice + token := strings.TrimPrefix(tokens[0], "Bearer ") + ok, err := verifyAdvertiseToken(engineCtx, token, namespace.Path) + if err != nil { + if err == adminApprovalErr { + log.Warningf("Failed to verify advertise token. Namespace %q requires administrator approval", namespace.Path) + ctx.JSON(http.StatusForbidden, gin.H{"approval_error": true, "error": fmt.Sprintf("The namespace %q was not approved by an administrator", namespace.Path)}) + return + } else { + log.Warningln("Failed to verify token:", err) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed"}) + return + } + } + if !ok { + log.Warningf("%s %v advertised to namespace %v without valid token scope\n", + sType, adV2.Name, namespace.Path) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed. Token missing required scope"}) + return + } + } + } else { + token := strings.TrimPrefix(tokens[0], "Bearer ") + prefix := path.Join("/caches", adV2.Name) + ok, err := verifyAdvertiseToken(engineCtx, token, prefix) + if err != nil { + if err == adminApprovalErr { + log.Warningf("Failed to verify token. Cache %q was not approved", adV2.Name) + ctx.JSON(http.StatusForbidden, gin.H{"approval_error": true, "error": fmt.Sprintf("Cache %q was not approved by an administrator", ad.Name)}) + return + } else { + log.Warningln("Failed to verify token:", err) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed."}) + return + } + } + if !ok { + log.Warningf("%s %v advertised without valid token scope\n", sType, adV2.Name) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed. Token missing required scope"}) + return + } } - res := statResponse{Message: msg, Metadata: meta, OK: true} - ctx.JSON(http.StatusOK, res) + + ad_url, err := url.Parse(adV2.DataURL) + if err != nil { + log.Warningf("Failed to parse %s URL %v: %v\n", sType, adV2.DataURL, err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + sType + " URL"}) + return + } + + adWebUrl, err := url.Parse(adV2.WebURL) + if err != nil && adV2.WebURL != "" { // We allow empty WebURL string for backward compatibility + log.Warningf("Failed to parse server Web URL %v: %v\n", adV2.WebURL, err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server Web URL"}) + return + } + + brokerUrl, err := url.Parse(adV2.BrokerURL) + if err != nil { + log.Warningf("Failed to parse broker URL %s: %s", adV2.BrokerURL, err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid broker URL"}) + } + + sAd := server_structs.ServerAd{ + Name: adV2.Name, + AuthURL: *ad_url, + URL: *ad_url, + WebURL: *adWebUrl, + BrokerURL: *brokerUrl, + Type: sType, + Writes: adV2.Caps.Writes, + DirectReads: adV2.Caps.DirectReads, + } + + recordAd(sAd, &adV2.Namespaces) + + // Start director periodic test of origin's health status if origin AD + // has WebURL field AND it's not already been registered + healthTestUtilsMutex.Lock() + defer healthTestUtilsMutex.Unlock() + if adV2.WebURL != "" { + if existingUtil, ok := healthTestUtils[sAd]; ok { + // Existing registration + if existingUtil != nil { + if existingUtil.ErrGrp != nil { + if existingUtil.ErrGrpContext.Err() != nil { + // ErrGroup has been Done. Start a new one + errgrp, errgrpCtx := errgroup.WithContext(engineCtx) + cancelCtx, cancel := context.WithCancel(errgrpCtx) + + errgrp.SetLimit(1) + healthTestUtils[sAd] = &healthTestUtil{ + Cancel: cancel, + ErrGrp: errgrp, + ErrGrpContext: errgrpCtx, + Status: HealthStatusInit, + } + errgrp.Go(func() error { + LaunchPeriodicDirectorTest(cancelCtx, sAd) + return nil + }) + log.Debugf("New director test suite issued for %s %s. Errgroup was evicted", string(sType), sAd.URL.String()) + } else { + cancelCtx, cancel := context.WithCancel(existingUtil.ErrGrpContext) + started := existingUtil.ErrGrp.TryGo(func() error { + LaunchPeriodicDirectorTest(cancelCtx, sAd) + return nil + }) + if !started { + cancel() + log.Debugf("New director test suite blocked for %s %s, existing test has been running", string(sType), sAd.URL.String()) + } else { + log.Debugf("New director test suite issued for %s %s. Existing registration", string(sType), sAd.URL.String()) + existingUtil.Cancel() + existingUtil.Cancel = cancel + } + } + } else { + log.Errorf("%s %s registration didn't start a new director test cycle: errgroup is nil", string(sType), &sAd.URL) + } + } else { + log.Errorf("%s %s registration didn't start a new director test cycle: healthTestUtils item is nil", string(sType), &sAd.URL) + } + } else { // No healthTestUtils found, new registration + errgrp, errgrpCtx := errgroup.WithContext(engineCtx) + cancelCtx, cancel := context.WithCancel(errgrpCtx) + + errgrp.SetLimit(1) + healthTestUtils[sAd] = &healthTestUtil{ + Cancel: cancel, + ErrGrp: errgrp, + ErrGrpContext: errgrpCtx, + Status: HealthStatusUnknown, + } + errgrp.Go(func() error { + LaunchPeriodicDirectorTest(cancelCtx, sAd) + return nil + }) + } + } + + if sType == server_structs.OriginType { + originStatUtilsMutex.Lock() + defer originStatUtilsMutex.Unlock() + statUtil, ok := originStatUtils[sAd.URL] + if !ok || statUtil.Errgroup == nil { + baseCtx, cancel := context.WithCancel(engineCtx) + concLimit := param.Director_StatConcurrencyLimit.GetInt() + statErrGrp := errgroup.Group{} + statErrGrp.SetLimit(concLimit) + newUtil := originStatUtil{ + Errgroup: &statErrGrp, + Cancel: cancel, + Context: baseCtx, + } + originStatUtils[sAd.URL] = newUtil + } + } + + ctx.JSON(http.StatusOK, gin.H{"msg": "Successful registration"}) } -// A gin route handler that given a server hostname through path variable `name`, -// checks and adds the server to a list of servers to be bypassed when the director redirects -// object requests from the client -func handleFilterServer(ctx *gin.Context) { - sn := strings.TrimPrefix(ctx.Param("name"), "/") - if sn == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "name is a required path parameter"}) +// Return a list of registered origins and caches in Prometheus HTTP SD format +// for director's Prometheus service discovery +func discoverOriginCache(ctx *gin.Context) { + authOption := token.AuthOption{ + Sources: []token.TokenSource{token.Header}, + Issuers: []token.TokenIssuer{token.LocalIssuer}, + Scopes: []token_scopes.TokenScope{token_scopes.Pelican_DirectorServiceDiscovery}, + } + + status, ok, err := token.Verify(ctx, authOption) + if !ok { + log.Warningf("Cannot verify token for accessing director's service discovery: %v", err) + ctx.JSON(status, gin.H{"error": err.Error()}) return } - filtered, filterType := checkFilter(sn) - if filtered { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Can't filter a server that already has been fitlered with type " + filterType}) + + serverAdMutex.RLock() + defer serverAdMutex.RUnlock() + serverAds := serverAds.Keys() + promDiscoveryRes := make([]PromDiscoveryItem, 0) + for _, ad := range serverAds { + if ad.WebURL.String() == "" { + // Origins and caches fetched from topology can't be scraped as they + // don't have a WebURL + continue + } + promDiscoveryRes = append(promDiscoveryRes, PromDiscoveryItem{ + Targets: []string{ad.WebURL.Hostname() + ":" + ad.WebURL.Port()}, + Labels: map[string]string{ + "server_type": string(ad.Type), + "server_name": ad.Name, + "server_auth_url": ad.AuthURL.String(), + "server_url": ad.URL.String(), + "server_web_url": ad.WebURL.String(), + "server_lat": fmt.Sprintf("%.4f", ad.Latitude), + "server_long": fmt.Sprintf("%.4f", ad.Longitude), + }, + }) + } + ctx.JSON(200, promDiscoveryRes) +} + +func listNamespacesV1(ctx *gin.Context) { + namespaceAdsV2 := listNamespacesFromOrigins() + + namespaceAdsV1 := server_structs.ConvertNamespaceAdsV2ToV1(namespaceAdsV2) + + ctx.JSON(http.StatusOK, namespaceAdsV1) +} + +func listNamespacesV2(ctx *gin.Context) { + namespacesAdsV2 := listNamespacesFromOrigins() + namespacesAdsV2 = append(namespacesAdsV2, server_structs.NamespaceAdV2{ + PublicRead: true, + Caps: server_structs.Capabilities{ + PublicReads: true, + Reads: true, + }, + Path: "/pelican/monitoring", + }) + ctx.JSON(http.StatusOK, namespacesAdsV2) +} + +func getPrefixByPath(ctx *gin.Context) { + pathParam := ctx.Param("path") + if pathParam == "" || pathParam == "/" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Bad request. Path is empty or '/' "}) return } - filteredServersMutex.Lock() - defer filteredServersMutex.Unlock() + namespaceKeysMutex.Lock() + defer namespaceKeysMutex.Unlock() - // If we previously temporarily allowed a server, we switch to permFiltered (reset) - if filterType == tempAllowed { - filteredServers[sn] = permFiltered - } else { - filteredServers[sn] = tempFiltered + originNs, _, _ := getAdsForPath(pathParam) + + // If originNs.Path is an empty value, then the namespace is not found + if originNs.Path == "" { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Namespace prefix not found for " + pathParam}) + return } - ctx.JSON(http.StatusOK, gin.H{"message": "success"}) + + res := server_structs.GetPrefixByPathRes{Prefix: originNs.Path} + ctx.JSON(http.StatusOK, res) } -// A gin route handler that given a server hostname through path variable `name`, -// checks and removes the server from a list of servers to be bypassed when the director redirects -// object requests from the client -func handleAllowServer(ctx *gin.Context) { - sn := strings.TrimPrefix(ctx.Param("name"), "/") - if sn == "" { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "name is a required path parameter"}) +// Generate a mock file for caches to fetch. This is for director-based health tests for caches +// So that we don't require an origin to feed the test file to the cache +func getHealthTestFile(ctx *gin.Context) { + // Expected path: /pelican/monitoring/2006-01-02T15:04:05Z07:00.txt + pathParam := ctx.Param("path") + cleanedPath := path.Clean(pathParam) + if cleanedPath == "" || !strings.HasPrefix(cleanedPath, cacheMonitroingBasePath+"/") { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Path parameter is not a valid health test path: " + cleanedPath}) return } - filtered, ft := checkFilter(sn) - if !filtered { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Can't allow a server that is not being filtered. " + ft}) + fileName := strings.TrimPrefix(cleanedPath, cacheMonitroingBasePath+"/") + if fileName == "" { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Path parameter is not a valid health test path: " + cleanedPath}) return } - filteredServersMutex.Lock() - defer filteredServersMutex.Unlock() + fileNameSplit := strings.SplitN(fileName, ".", 2) - if ft == tempFiltered { - // For temporarily filtered server, allowing them by removing the server from the map - delete(filteredServers, sn) - } else if ft == permFiltered { - // For servers to filter from the config, temporarily allow the server - filteredServers[sn] = tempAllowed + if len(fileNameSplit) != 2 { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Test file name is missing file extension: " + cleanedPath}) + return + } + + filenameWoExt := fileNameSplit[0] + + fileContent := fmt.Sprintf("%s%s\n", testFileContent, filenameWoExt) + + if ctx.Request.Method == "HEAD" { + ctx.Header("Content-Length", strconv.Itoa(len(fileContent))) + } else { + ctx.String(http.StatusOK, fileContent) } - ctx.JSON(http.StatusOK, gin.H{"message": "success"}) } -func RegisterDirectorWebAPI(router *gin.RouterGroup) { - directorWebAPI := router.Group("/api/v1.0/director_ui") - // Follow RESTful schema +func RegisterDirectorAPI(ctx context.Context, router *gin.RouterGroup) { + directorAPIV1 := router.Group("/api/v1.0/director") + { + // Establish the routes used for cache/origin redirection + directorAPIV1.GET("/object/*any", redirectToCache) + directorAPIV1.GET("/origin/*any", redirectToOrigin) + directorAPIV1.PUT("/origin/*any", redirectToOrigin) + directorAPIV1.POST("/registerOrigin", func(gctx *gin.Context) { registerServeAd(ctx, gctx, server_structs.OriginType) }) + directorAPIV1.POST("/registerCache", func(gctx *gin.Context) { registerServeAd(ctx, gctx, server_structs.CacheType) }) + directorAPIV1.GET("/listNamespaces", listNamespacesV1) + directorAPIV1.GET("/namespaces/prefix/*path", getPrefixByPath) + directorAPIV1.GET("/healthTest/*path", getHealthTestFile) + directorAPIV1.HEAD("/healthTest/*path", getHealthTestFile) + + // In the foreseeable feature, director will scrape all servers in Pelican ecosystem (including registry) + // so that director can be our point of contact for collecting system-level metrics. + // Rename the endpoint to reflect such plan. + directorAPIV1.GET("/discoverServers", discoverOriginCache) + } + + directorAPIV2 := router.Group("/api/v2.0/director") { - directorWebAPI.GET("/servers", listServers) - directorWebAPI.PATCH("/servers/filter/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleFilterServer) - directorWebAPI.PATCH("/servers/allow/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleAllowServer) - directorWebAPI.GET("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) - directorWebAPI.HEAD("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) + directorAPIV2.GET("/listNamespaces", listNamespacesV2) } } diff --git a/director/director_api.go b/director/director_api.go index 9b3a0e509..21f2ee611 100644 --- a/director/director_api.go +++ b/director/director_api.go @@ -23,23 +23,23 @@ import ( "fmt" "github.com/jellydator/ttlcache/v3" - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/param" - log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" ) // List all namespaces from origins registered at the director -func listNamespacesFromOrigins() []common.NamespaceAdV2 { +func listNamespacesFromOrigins() []server_structs.NamespaceAdV2 { serverAdMutex.RLock() defer serverAdMutex.RUnlock() serverAdItems := serverAds.Items() - namespaces := make([]common.NamespaceAdV2, 0, len(serverAdItems)) + namespaces := make([]server_structs.NamespaceAdV2, 0, len(serverAdItems)) for _, item := range serverAdItems { - if item.Key().Type == common.OriginType { + if item.Key().Type == server_structs.OriginType { namespaces = append(namespaces, item.Value()...) } } @@ -47,10 +47,10 @@ func listNamespacesFromOrigins() []common.NamespaceAdV2 { } // List all serverAds in the cache that matches the serverType array -func listServerAds(serverTypes []common.ServerType) []common.ServerAd { +func listServerAds(serverTypes []server_structs.ServerType) []server_structs.ServerAd { serverAdMutex.RLock() defer serverAdMutex.RUnlock() - ads := make([]common.ServerAd, 0) + ads := make([]server_structs.ServerAd, 0) for _, ad := range serverAds.Keys() { for _, serverType := range serverTypes { if ad.Type == serverType { @@ -96,7 +96,7 @@ func ConfigTTLCache(ctx context.Context, egrp *errgroup.Group) { go serverAds.Start() go namespaceKeys.Start() - serverAds.OnEviction(func(ctx context.Context, er ttlcache.EvictionReason, i *ttlcache.Item[common.ServerAd, []common.NamespaceAdV2]) { + serverAds.OnEviction(func(ctx context.Context, er ttlcache.EvictionReason, i *ttlcache.Item[server_structs.ServerAd, []server_structs.NamespaceAdV2]) { healthTestUtilsMutex.RLock() defer healthTestUtilsMutex.RUnlock() if util, exists := healthTestUtils[i.Key()]; exists { @@ -115,7 +115,7 @@ func ConfigTTLCache(ctx context.Context, egrp *errgroup.Group) { log.Debugf("healthTestUtil not found for %s when evicting TTL cache item", i.Key().Name) } - if i.Key().Type == common.OriginType { + if i.Key().Type == server_structs.OriginType { originStatUtilsMutex.Lock() defer originStatUtilsMutex.Unlock() statUtil, ok := originStatUtils[i.Key().URL] diff --git a/director/director_api_test.go b/director/director_api_test.go index 361a4ccc8..e70c79868 100644 --- a/director/director_api_test.go +++ b/director/director_api_test.go @@ -24,44 +24,44 @@ import ( "testing" "github.com/jellydator/ttlcache/v3" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/server_structs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var mockOriginServerAd common.ServerAd = common.ServerAd{ +var mockOriginServerAd server_structs.ServerAd = server_structs.ServerAd{ Name: "test-origin-server", AuthURL: url.URL{}, URL: url.URL{}, - Type: common.OriginType, + Type: server_structs.OriginType, Latitude: 123.05, Longitude: 456.78, } -var mockCacheServerAd common.ServerAd = common.ServerAd{ +var mockCacheServerAd server_structs.ServerAd = server_structs.ServerAd{ Name: "test-cache-server", AuthURL: url.URL{}, URL: url.URL{}, - Type: common.CacheType, + Type: server_structs.CacheType, Latitude: 45.67, Longitude: 123.05, } const mockPathPreix string = "/foo/bar/" -func mockNamespaceAds(size int, serverPrefix string) []common.NamespaceAdV2 { - namespaceAds := make([]common.NamespaceAdV2, size) +func mockNamespaceAds(size int, serverPrefix string) []server_structs.NamespaceAdV2 { + namespaceAds := make([]server_structs.NamespaceAdV2, size) for i := 0; i < size; i++ { - namespaceAds[i] = common.NamespaceAdV2{ + namespaceAds[i] = server_structs.NamespaceAdV2{ PublicRead: false, - Caps: common.Capabilities{ + Caps: server_structs.Capabilities{ PublicReads: false, }, Path: mockPathPreix + serverPrefix + "/" + fmt.Sprint(i), - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: url.URL{}, }}, - Generation: []common.TokenGen{{ + Generation: []server_structs.TokenGen{{ MaxScopeDepth: 1, Strategy: "", VaultServer: "", @@ -71,7 +71,7 @@ func mockNamespaceAds(size int, serverPrefix string) []common.NamespaceAdV2 { return namespaceAds } -func namespaceAdContainsPath(ns []common.NamespaceAdV2, path string) bool { +func namespaceAdContainsPath(ns []server_structs.NamespaceAdV2, path string) bool { for _, v := range ns { if v.Path == path { return true @@ -146,7 +146,7 @@ func TestListServerAds(t *testing.T) { defer serverAdMutex.Unlock() serverAds.DeleteAll() }() - ads := listServerAds([]common.ServerType{common.OriginType, common.CacheType}) + ads := listServerAds([]server_structs.ServerType{server_structs.OriginType, server_structs.CacheType}) assert.Equal(t, 0, len(ads)) }) @@ -156,16 +156,16 @@ func TestListServerAds(t *testing.T) { defer serverAdMutex.Unlock() serverAds.DeleteAll() }() - serverAds.Set(mockOriginServerAd, []common.NamespaceAdV2{}, ttlcache.DefaultTTL) - serverAds.Set(mockCacheServerAd, []common.NamespaceAdV2{}, ttlcache.DefaultTTL) - adsAll := listServerAds([]common.ServerType{common.OriginType, common.CacheType}) + serverAds.Set(mockOriginServerAd, []server_structs.NamespaceAdV2{}, ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd, []server_structs.NamespaceAdV2{}, ttlcache.DefaultTTL) + adsAll := listServerAds([]server_structs.ServerType{server_structs.OriginType, server_structs.CacheType}) assert.Equal(t, 2, len(adsAll)) - adsOrigin := listServerAds([]common.ServerType{common.OriginType}) + adsOrigin := listServerAds([]server_structs.ServerType{server_structs.OriginType}) require.Equal(t, 1, len(adsOrigin)) assert.True(t, adsOrigin[0] == mockOriginServerAd) - adsCache := listServerAds([]common.ServerType{common.CacheType}) + adsCache := listServerAds([]server_structs.ServerType{server_structs.CacheType}) require.Equal(t, 1, len(adsCache)) assert.True(t, adsCache[0] == mockCacheServerAd) }) diff --git a/director/director_test.go b/director/director_test.go index 1d7c5e73f..e0805b4ca 100644 --- a/director/director_test.go +++ b/director/director_test.go @@ -19,137 +19,1076 @@ package director import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" + "errors" + "fmt" "io" "net/http" "net/http/httptest" + "net/url" + "path/filepath" "testing" + "time" "github.com/gin-gonic/gin" "github.com/jellydator/ttlcache/v3" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/test_utils" + "github.com/pelicanplatform/pelican/token" + "github.com/pelicanplatform/pelican/token_scopes" ) -func TestListServers(t *testing.T) { - router := gin.Default() +type MockCache struct { + GetFn func(u string, kset *jwk.Set) (jwk.Set, error) + RegisterFn func(*MockCache) error + + keyset jwk.Set +} + +func (m *MockCache) Get(ctx context.Context, u string) (jwk.Set, error) { + return m.GetFn(u, &m.keyset) +} + +func (m *MockCache) Register(u string, options ...jwk.RegisterOption) error { + m.keyset = jwk.NewSet() + return m.RegisterFn(m) +} + +func NamespaceAdContainsPath(ns []server_structs.NamespaceAdV2, path string) bool { + for _, v := range ns { + if v.Path == path { + return true + } + } + return false +} + +func TestGetLinkDepth(t *testing.T) { + tests := []struct { + name string + filepath string + prefix string + err error + depth int + }{ + { + name: "empty-file-prefix", + err: errors.New("either filepath or prefix is an empty path"), + }, { + name: "empty-file", + err: errors.New("either filepath or prefix is an empty path"), + }, { + name: "empty-prefix", + err: errors.New("either filepath or prefix is an empty path"), + }, { + name: "no-match", + filepath: "/foo/bar/barz.txt", + prefix: "/bar", + err: errors.New("filepath does not contain the prefix"), + }, { + name: "depth-1-case", + filepath: "/foo/bar/barz.txt", + prefix: "/foo/bar", + depth: 1, + }, { + name: "depth-1-w-trailing-slash", + filepath: "/foo/bar/barz.txt", + prefix: "/foo/bar/", + depth: 1, + }, { + name: "depth-2-case", + filepath: "/foo/bar/barz.txt", + prefix: "/foo", + depth: 2, + }, + { + name: "depth-2-w-trailing-slash", + filepath: "/foo/bar/barz.txt", + prefix: "/foo/", + depth: 2, + }, + { + name: "depth-3-case", + filepath: "/foo/bar/barz.txt", + prefix: "/", + depth: 3, + }, + { + name: "short-path", + filepath: "/foo/barz.txt", + prefix: "/foo", + depth: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + depth, err := getLinkDepth(tt.filepath, tt.prefix) + if tt.err == nil { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Equal(t, tt.err.Error(), err.Error()) + } + assert.Equal(t, tt.depth, depth) + }) + } +} + +func TestDirectorRegistration(t *testing.T) { + /* + * Tests the RegisterOrigin endpoint. Specifically it creates a keypair and + * corresponding token and invokes the registration endpoint, it then does + * so again with an invalid token and confirms that the correct error is returned + */ + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) + defer func() { require.NoError(t, egrp.Wait()) }() + defer cancel() + + viper.Reset() + + // Mock registry server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == "POST" && req.URL.Path == "/api/v1.0/registry/checkNamespaceStatus" { + res := server_structs.CheckNamespaceStatusRes{Approved: true} + resByte, err := json.Marshal(res) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, err = w.Write(resByte) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + viper.Set("Federation.RegistryUrl", ts.URL) + + setupContext := func() (*gin.Context, *gin.Engine, *httptest.ResponseRecorder) { + // Setup httptest recorder and context for the the unit test + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + return c, r, w + } + + generateToken := func() (jwk.Key, string, url.URL) { + // Create a private key to use for the test + privateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + assert.NoError(t, err, "Error generating private key") + + // Convert from raw ecdsa to jwk.Key + pKey, err := jwk.FromRaw(privateKey) + assert.NoError(t, err, "Unable to convert ecdsa.PrivateKey to jwk.Key") + + //Assign Key id to the private key + err = jwk.AssignKeyID(pKey) + assert.NoError(t, err, "Error assigning kid to private key") + + //Set an algorithm for the key + err = pKey.Set(jwk.AlgorithmKey, jwa.ES256) + assert.NoError(t, err, "Unable to set algorithm for pKey") + + issuerURL := url.URL{ + Scheme: "https", + Path: ts.URL, + } + + // Create a token to be inserted + tok, err := jwt.NewBuilder(). + Issuer(issuerURL.String()). + Claim("scope", token_scopes.Pelican_Advertise.String()). + Audience([]string{"director.test"}). + Subject("origin"). + Build() + assert.NoError(t, err, "Error creating token") + + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, pKey)) + assert.NoError(t, err, "Error signing token") - router.GET("/servers", listServers) + return pKey, string(signed), issuerURL + } - func() { + generateReadToken := func(key jwk.Key, object, issuer string) string { + tc := token.NewWLCGToken() + tc.Lifetime = time.Minute + tc.Issuer = issuer + tc.AddAudiences("director") + tc.Subject = "test" + tc.Claims = map[string]string{"scope": "storage.read:" + object} + tok, err := tc.CreateTokenWithKey(key) + require.NoError(t, err) + return tok + } + + setupRequest := func(c *gin.Context, r *gin.Engine, bodyByt []byte, token string) { + r.POST("/", func(gctx *gin.Context) { registerServeAd(ctx, gctx, server_structs.OriginType) }) + c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBuffer(bodyByt)) + c.Request.Header.Set("Authorization", "Bearer "+token) + c.Request.Header.Set("Content-Type", "application/json") + // Hard code the current min version. When this test starts failing because of new stuff in the Director, + // we'll know that means it's time to update the min version in redirect.go + c.Request.Header.Set("User-Agent", "pelican-origin/7.0.0") + } + + // Configure the request context and Gin router to generate a redirect + setupRedirect := func(c *gin.Context, r *gin.Engine, object, token string) { + r.GET("/api/v1.0/director/origin/*any", redirectToOrigin) + c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1.0/director/origin"+object, nil) + c.Request.Header.Set("X-Real-Ip", "1.1.1.1") + c.Request.Header.Set("Authorization", "Bearer "+token) + c.Request.Header.Set("User-Agent", "pelican-origin/7.0.0") + } + + // Inject into the cache, using a mock cache to avoid dealing with + // real namespaces + setupMockCache := func(t *testing.T, publicKey jwk.Key) MockCache { + return MockCache{ + GetFn: func(key string, keyset *jwk.Set) (jwk.Set, error) { + expectedKey := ts.URL + "/api/v1.0/registry/foo/bar/.well-known/issuer.jwks" + if key != expectedKey { + t.Errorf("expecting: %q, got %q", expectedKey, key) + } + return *keyset, nil + }, + RegisterFn: func(m *MockCache) error { + err := jwk.Set.AddKey(m.keyset, publicKey) + if err != nil { + t.Error(err) + } + return nil + }, + } + } + + // Perform injections (ar.Register will create a jwk.keyset with the publickey in it) + useMockCache := func(ar MockCache, issuerURL url.URL) { + if err := ar.Register(issuerURL.String(), jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { + t.Errorf("this should never happen, should actually be impossible, including check for the linter") + } + namespaceKeysMutex.Lock() + defer namespaceKeysMutex.Unlock() + namespaceKeys.Set("/foo/bar", &ar, ttlcache.DefaultTTL) + } + + teardown := func() { serverAdMutex.Lock() defer serverAdMutex.Unlock() - serverAds.Set(mockOriginServerAd, mockNamespaceAds(5, "origin1"), ttlcache.DefaultTTL) - serverAds.Set(mockCacheServerAd, mockNamespaceAds(4, "cache1"), ttlcache.DefaultTTL) - require.True(t, serverAds.Has(mockOriginServerAd)) - require.True(t, serverAds.Has(mockCacheServerAd)) - }() - - mocklistOriginRes := listServerResponse{ - Name: mockOriginServerAd.Name, - BrokerURL: mockOriginServerAd.BrokerURL.String(), - AuthURL: mockOriginServerAd.AuthURL.String(), - URL: mockOriginServerAd.URL.String(), - WebURL: mockOriginServerAd.WebURL.String(), - Type: mockOriginServerAd.Type, - Latitude: mockOriginServerAd.Latitude, - Longitude: mockOriginServerAd.Longitude, - Writes: mockOriginServerAd.Writes, - DirectReads: mockOriginServerAd.DirectReads, - Status: HealthStatusUnknown, + serverAds.DeleteAll() + } + + t.Run("valid-token-V1", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV1{Name: "test", URL: "https://or-url.org", Namespaces: []server_structs.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + // Check to see that the code exits with status code 200 after given it a good token + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + + namaspaceADs := listNamespacesFromOrigins() + // If the origin was successfully registed at director, we should be able to find it in director's originAds + assert.True(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Coudln't find namespace in the director cache.") + teardown() + }) + + t.Run("valid-token-V2", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV2{ + BrokerURL: "https://broker-url.org", + DataURL: "https://or-url.org", + Name: "test", + Namespaces: []server_structs.NamespaceAdV2{{ + Path: "/foo/bar", + Issuer: []server_structs.TokenIssuer{{IssuerUrl: isurl}}, + }}, + } + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + // Check to see that the code exits with status code 200 after given it a good token + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + + namaspaceADs := listNamespacesFromOrigins() + // If the origin was successfully registed at director, we should be able to find it in director's originAds + assert.True(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Coudln't find namespace in the director cache.") + teardown() + }) + + // Now repeat the above test, but with an invalid token + t.Run("invalid-token-V1", func(t *testing.T) { + c, r, w := setupContext() + wrongPrivateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + assert.NoError(t, err, "Error creating another private key") + _, token, issuerURL := generateToken() + + wrongPublicKey, err := jwk.PublicKeyOf(wrongPrivateKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, wrongPublicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV1{Name: "test", URL: "https://or-url.org", Namespaces: []server_structs.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, http.StatusForbidden, w.Result().StatusCode, "Expected failing status code of 403") + body, _ := io.ReadAll(w.Result().Body) + assert.Equal(t, `{"error":"Authorization token verification failed"}`, string(body), "Failure wasn't because token verification failed") + + namaspaceADs := listNamespacesFromOrigins() + assert.False(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Found namespace in the director cache even if the token validation failed.") + teardown() + }) + + t.Run("invalid-token-V2", func(t *testing.T) { + c, r, w := setupContext() + wrongPrivateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + assert.NoError(t, err, "Error creating another private key") + _, token, issuerURL := generateToken() + + wrongPublicKey, err := jwk.PublicKeyOf(wrongPrivateKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, wrongPublicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV2{Name: "test", DataURL: "https://or-url.org", Namespaces: []server_structs.NamespaceAdV2{{ + Path: "/foo/bar", + Issuer: []server_structs.TokenIssuer{{IssuerUrl: isurl}}, + }}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, http.StatusForbidden, w.Result().StatusCode, "Expected failing status code of 403") + body, _ := io.ReadAll(w.Result().Body) + assert.Equal(t, `{"error":"Authorization token verification failed"}`, string(body), "Failure wasn't because token verification failed") + + namaspaceADs := listNamespacesFromOrigins() + assert.False(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Found namespace in the director cache even if the token validation failed.") + teardown() + }) + + t.Run("valid-token-with-web-url-V1", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV1{URL: "https://or-url.org", WebURL: "https://localhost:8844", Namespaces: []server_structs.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") + assert.Equal(t, "https://localhost:8844", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds does not match data in origin registration request") + teardown() + }) + + t.Run("valid-token-with-web-url-V2", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV2{DataURL: "https://or-url.org", WebURL: "https://localhost:8844", Namespaces: []server_structs.NamespaceAdV2{{ + Path: "/foo/bar", + Issuer: []server_structs.TokenIssuer{{IssuerUrl: isurl}}, + }}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") + assert.Equal(t, "https://localhost:8844", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds does not match data in origin registration request") + teardown() + }) + + // We want to ensure backwards compatibility for WebURL + t.Run("valid-token-without-web-url-V1", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV1{URL: "https://or-url.org", Namespaces: []server_structs.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") + assert.Equal(t, "", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds isn't empty with no WebURL provided in registration") + teardown() + }) + + t.Run("valid-token-without-web-url-V2", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + ad := server_structs.OriginAdvertiseV2{DataURL: "https://or-url.org", Namespaces: []server_structs.NamespaceAdV2{{Path: "/foo/bar", + Issuer: []server_structs.TokenIssuer{{IssuerUrl: isurl}}}}} + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") + assert.Equal(t, "", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds isn't empty with no WebURL provided in registration") + teardown() + }) + + // Determines if the broker URL set in the advertisement is the same one received on redirect + t.Run("broker-url-redirect", func(t *testing.T) { + c, r, w := setupContext() + pKey, token, issuerURL := generateToken() + publicKey, err := jwk.PublicKeyOf(pKey) + assert.NoError(t, err, "Error creating public key from private key") + + ar := setupMockCache(t, publicKey) + useMockCache(ar, issuerURL) + + isurl := url.URL{} + isurl.Path = ts.URL + + brokerUrl := "https://broker-url.org/some/path?origin=foo" + + ad := server_structs.OriginAdvertiseV2{ + DataURL: "https://or-url.org", + BrokerURL: brokerUrl, + Name: "test", + Namespaces: []server_structs.NamespaceAdV2{{ + Path: "/foo/bar", + Issuer: []server_structs.TokenIssuer{{IssuerUrl: isurl}}, + }}, + } + + jsonad, err := json.Marshal(ad) + assert.NoError(t, err, "Error marshalling OriginAdvertise") + + setupRequest(c, r, jsonad, token) + + r.ServeHTTP(w, c.Request) + + // Check to see that the code exits with status code 200 after given it a good token + require.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") + + c, r, w = setupContext() + token = generateReadToken(pKey, "/foo/bar", isurl.String()) + setupRedirect(c, r, "/foo/bar/baz", token) + + r.ServeHTTP(w, c.Request) + + assert.Equal(t, http.StatusTemporaryRedirect, w.Result().StatusCode) + if w.Result().StatusCode != http.StatusTemporaryRedirect { + body, err := io.ReadAll(w.Result().Body) + assert.NoError(t, err) + assert.Fail(t, "Error when generating redirect: "+string(body)) + } + assert.Equal(t, brokerUrl, w.Result().Header.Get("X-Pelican-Broker")) + }) + +} + +func TestGetAuthzEscaped(t *testing.T) { + // Test passing a token via header with no bearer prefix + req, err := http.NewRequest(http.MethodPost, "http://fake-server.com", bytes.NewBuffer([]byte("a body"))) + assert.NoError(t, err) + req.Header.Set("Authorization", "tokenstring") + escapedToken := getAuthzEscaped(req) + assert.Equal(t, escapedToken, "tokenstring") + + // Test passing a token via query with no bearer prefix + req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=tokenstring", bytes.NewBuffer([]byte("a body"))) + assert.NoError(t, err) + escapedToken = getAuthzEscaped(req) + assert.Equal(t, escapedToken, "tokenstring") + + // Test passing the token via header with Bearer prefix + req, err = http.NewRequest(http.MethodPost, "http://fake-server.com", bytes.NewBuffer([]byte("a body"))) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer tokenstring") + escapedToken = getAuthzEscaped(req) + assert.Equal(t, escapedToken, "tokenstring") + + // Test passing the token via URL with Bearer prefix and + encoded space + req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=Bearer+tokenstring", bytes.NewBuffer([]byte("a body"))) + assert.NoError(t, err) + escapedToken = getAuthzEscaped(req) + assert.Equal(t, escapedToken, "tokenstring") + + // Finally, the same test as before, but test with %20 encoded space + req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=Bearer%20tokenstring", bytes.NewBuffer([]byte("a body"))) + assert.NoError(t, err) + escapedToken = getAuthzEscaped(req) + assert.Equal(t, escapedToken, "tokenstring") +} + +func TestDiscoverOriginCache(t *testing.T) { + mockPelicanOriginServerAd := server_structs.ServerAd{ + Name: "1-test-origin-server", + AuthURL: url.URL{}, + URL: url.URL{ + Scheme: "https", + Host: "fake-origin.org:8443", + }, + WebURL: url.URL{ + Scheme: "https", + Host: "fake-origin.org:8444", + }, + Type: server_structs.OriginType, + Latitude: 123.05, + Longitude: 456.78, } - mocklistCacheRes := listServerResponse{ - Name: mockCacheServerAd.Name, - BrokerURL: mockCacheServerAd.BrokerURL.String(), - AuthURL: mockCacheServerAd.AuthURL.String(), - URL: mockCacheServerAd.URL.String(), - WebURL: mockCacheServerAd.WebURL.String(), - Type: mockCacheServerAd.Type, - Latitude: mockCacheServerAd.Latitude, - Longitude: mockCacheServerAd.Longitude, - Writes: mockCacheServerAd.Writes, - DirectReads: mockCacheServerAd.DirectReads, - Status: HealthStatusUnknown, + + mockTopoOriginServerAd := server_structs.ServerAd{ + Name: "test-topology-origin-server", + AuthURL: url.URL{}, + URL: url.URL{ + Scheme: "https", + Host: "fake-topology-origin.org:8443", + }, + Type: server_structs.OriginType, + Latitude: 123.05, + Longitude: 456.78, } - t.Run("query-origin", func(t *testing.T) { - // Create a request to the endpoint + mockCacheServerAd := server_structs.ServerAd{ + Name: "2-test-cache-server", + AuthURL: url.URL{}, + URL: url.URL{ + Scheme: "https", + Host: "fake-cache.org:8443", + }, + WebURL: url.URL{ + Scheme: "https", + Host: "fake-cache.org:8444", + }, + Type: server_structs.CacheType, + Latitude: 45.67, + Longitude: 123.05, + } + + mockNamespaceAd := server_structs.NamespaceAdV2{ + PublicRead: false, + Path: "/foo/bar/", + Issuer: []server_structs.TokenIssuer{{ + BasePaths: []string{""}, + IssuerUrl: url.URL{}, + }}, + } + + mockDirectorUrl := "https://fake-director.org:8888" + + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) + defer func() { require.NoError(t, egrp.Wait()) }() + defer cancel() + + viper.Reset() + // Direcor SD will only be used for director's Prometheus scraper to get available origins, + // so the token issuer is issentially the director server itself + // There's no need to rely on Federation.DirectorUrl as token issuer in this case + viper.Set("Server.ExternalWebUrl", mockDirectorUrl) + + tDir := t.TempDir() + kfile := filepath.Join(tDir, "testKey") + viper.Set("IssuerKey", kfile) + + config.InitConfig() + err := config.InitServer(ctx, config.DirectorType) + require.NoError(t, err) + + // Generate a private key to use for the test + _, err = config.GetIssuerPublicJWKS() + assert.NoError(t, err, "Error generating private key") + // Get private key + privateKey, err := config.GetIssuerPrivateJWK() + assert.NoError(t, err, "Error loading private key") + + // Batch set up different tokens + setupToken := func(wrongIssuer string) []byte { + issuerURL, err := url.Parse(mockDirectorUrl) + assert.NoError(t, err, "Error parsing director's URL") + tokenIssuerString := "" + if wrongIssuer != "" { + tokenIssuerString = wrongIssuer + } else { + tokenIssuerString = issuerURL.String() + } + + tok, err := jwt.NewBuilder(). + Issuer(tokenIssuerString). + Claim("scope", token_scopes.Pelican_DirectorServiceDiscovery). + Audience([]string{"director.test"}). + Subject("director"). + Expiration(time.Now().Add(time.Hour)). + Build() + assert.NoError(t, err, "Error creating token") + + err = jwk.AssignKeyID(privateKey) + assert.NoError(t, err, "Error assigning key id") + + // Sign token with previously created private key + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, privateKey)) + assert.NoError(t, err, "Error signing token") + return signed + } + + areSlicesEqualIgnoreOrder := func(slice1, slice2 []PromDiscoveryItem) bool { + if len(slice1) != len(slice2) { + return false + } + + counts := make(map[string]int) + + for _, item := range slice1 { + bytes, err := json.Marshal(item) + require.NoError(t, err) + counts[string(bytes)]++ + } + + for _, item := range slice2 { + bytes, err := json.Marshal(item) + require.NoError(t, err) + counts[string(bytes)]-- + if counts[string(bytes)] < 0 { + return false + } + } + + return true + } + + r := gin.Default() + r.GET("/test", discoverOriginCache) + + t.Run("no-token-should-give-401", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/test", nil) + if err != nil { + t.Fatalf("Could not make a GET request: %v", err) + } + w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/servers?server_type=origin", nil) - router.ServeHTTP(w, req) + r.ServeHTTP(w, req) - // Check the response - require.Equal(t, 200, w.Code) + assert.Equal(t, 403, w.Code) + assert.Equal(t, `{"error":"Authentication is required but no token is present."}`, w.Body.String()) + }) + t.Run("token-present-with-wrong-issuer-should-give-403", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/test", nil) + if err != nil { + t.Fatalf("Could not make a GET request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+string(setupToken("https://wrong-issuer.org"))) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - var got []listServerResponse - err := json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, 403, w.Code) + assert.Equal(t, `{"error":"Cannot verify token: Cannot verify token with server issuer: Token issuer https://wrong-issuer.org does not match the local issuer on the current server. Expecting https://fake-director.org:8888\n"}`, w.Body.String()) + }) + t.Run("token-present-valid-should-give-200-and-empty-array", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/test", nil) if err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) + t.Fatalf("Could not make a GET request: %v", err) } - require.Equal(t, 1, len(got)) - assert.Equal(t, mocklistOriginRes, got[0], "Response data does not match expected") + + req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, `[]`, w.Body.String()) }) + t.Run("response-should-match-serverAds", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/test", nil) + if err != nil { + t.Fatalf("Could not make a GET request: %v", err) + } + + func() { + serverAdMutex.Lock() + defer serverAdMutex.Unlock() + serverAds.DeleteAll() + serverAds.Set(mockPelicanOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + // Server fetched from topology should not be present in SD response + serverAds.Set(mockTopoOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + }() + + expectedRes := []PromDiscoveryItem{{ + Targets: []string{mockCacheServerAd.WebURL.Hostname() + ":" + mockCacheServerAd.WebURL.Port()}, + Labels: map[string]string{ + "server_type": string(mockCacheServerAd.Type), + "server_name": mockCacheServerAd.Name, + "server_auth_url": mockCacheServerAd.AuthURL.String(), + "server_url": mockCacheServerAd.URL.String(), + "server_web_url": mockCacheServerAd.WebURL.String(), + "server_lat": fmt.Sprintf("%.4f", mockCacheServerAd.Latitude), + "server_long": fmt.Sprintf("%.4f", mockCacheServerAd.Longitude), + }, + }, { + Targets: []string{mockPelicanOriginServerAd.WebURL.Hostname() + ":" + mockPelicanOriginServerAd.WebURL.Port()}, + Labels: map[string]string{ + "server_type": string(mockPelicanOriginServerAd.Type), + "server_name": mockPelicanOriginServerAd.Name, + "server_auth_url": mockPelicanOriginServerAd.AuthURL.String(), + "server_url": mockPelicanOriginServerAd.URL.String(), + "server_web_url": mockPelicanOriginServerAd.WebURL.String(), + "server_lat": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Latitude), + "server_long": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Longitude), + }, + }} + + req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) - t.Run("query-cache", func(t *testing.T) { - // Create a request to the endpoint w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/servers?server_type=cache", nil) - router.ServeHTTP(w, req) + r.ServeHTTP(w, req) - // Check the response require.Equal(t, 200, w.Code) - var got []listServerResponse - err := json.Unmarshal(w.Body.Bytes(), &got) + var resMarshalled []PromDiscoveryItem + err = json.Unmarshal(w.Body.Bytes(), &resMarshalled) + require.NoError(t, err, "Error unmarshall response to json") + + assert.True(t, areSlicesEqualIgnoreOrder(expectedRes, resMarshalled)) + }) + + t.Run("no-duplicated-origins", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/test", nil) if err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) + t.Fatalf("Could not make a GET request: %v", err) } - require.Equal(t, 1, len(got)) - assert.Equal(t, mocklistCacheRes, got[0], "Response data does not match expected") + + func() { + serverAdMutex.Lock() + defer serverAdMutex.Unlock() + serverAds.DeleteAll() + // Add multiple same serverAds + serverAds.Set(mockPelicanOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + serverAds.Set(mockPelicanOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + serverAds.Set(mockPelicanOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + // Server fetched from topology should not be present in SD response + serverAds.Set(mockTopoOriginServerAd, []server_structs.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) + }() + + expectedRes := []PromDiscoveryItem{{ + Targets: []string{mockPelicanOriginServerAd.WebURL.Hostname() + ":" + mockPelicanOriginServerAd.WebURL.Port()}, + Labels: map[string]string{ + "server_type": string(mockPelicanOriginServerAd.Type), + "server_name": mockPelicanOriginServerAd.Name, + "server_auth_url": mockPelicanOriginServerAd.AuthURL.String(), + "server_url": mockPelicanOriginServerAd.URL.String(), + "server_web_url": mockPelicanOriginServerAd.WebURL.String(), + "server_lat": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Latitude), + "server_long": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Longitude), + }, + }} + + resStr, err := json.Marshal(expectedRes) + assert.NoError(t, err, "Could not marshal json response") + + req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, string(resStr), w.Body.String(), "Reponse doesn't match expected") }) +} + +func TestRedirects(t *testing.T) { + + router := gin.Default() + router.GET("/api/v1.0/director/origin/*any", redirectToOrigin) + + // Check that the checkkHostnameRedirects uses the pre-configured hostnames to redirect + // requests that come in at the default paths, but not if the request is made + // specifically for an object or a cache via the API. + t.Run("redirect-check-hostnames", func(t *testing.T) { + // Note that we don't test here for the case when hostname redirects is turned off + // because the checkHostnameRedirects function should be unreachable via ShortcutMiddleware + // in that case, ie if we call this function and the incoming hostname matches, we should do + // the redirect specified + viper.Set("Director.OriginResponseHostnames", []string{"origin-hostname.com"}) + viper.Set("Director.CacheResponseHostnames", []string{"cache-hostname.com"}) + + // base path with origin-redirect hostname, should redirect to origin + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest("GET", "/foo/bar", nil) + c.Request = req + checkHostnameRedirects(c, "origin-hostname.com") + expectedPath := "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // base path with cache-redirect hostname, should redirect to cache + req = httptest.NewRequest("GET", "/foo/bar", nil) + c.Request = req + checkHostnameRedirects(c, "cache-hostname.com") + expectedPath = "/api/v1.0/director/object/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // API path that should ALWAYS redirect to an origin + req = httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) + c.Request = req + // Tell it cache, but it shouldn't switch what it redirects to + checkHostnameRedirects(c, "cache-hostname.com") + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // API path that should ALWAYS redirect to a cache + req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) + c.Request = req + // Tell it origin, but it shouldn't switch what it redirects to + checkHostnameRedirects(c, "origin-hostname.com") + expectedPath = "/api/v1.0/director/object/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + viper.Reset() + }) + + t.Run("redirect-middleware", func(t *testing.T) { + // First test that two API endpoints are functioning properly + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) + c.Request = req + + // test both APIs when in cache mode + ShortcutMiddleware("cache")(c) + expectedPath := "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) + c.Request = req + ShortcutMiddleware("cache")(c) + expectedPath = "/api/v1.0/director/object/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // test both APIs when in origin mode + req = httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) + c.Request = req + ShortcutMiddleware("origin")(c) + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) + c.Request = req + ShortcutMiddleware("origin")(c) + expectedPath = "/api/v1.0/director/object/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // Test the base paths + // test that we get an origin at the base path when in origin mode + req = httptest.NewRequest("GET", "/foo/bar", nil) + c.Request = req + ShortcutMiddleware("origin")(c) + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // test that we get a cache at the base path when in cache mode + req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) + c.Request = req + ShortcutMiddleware("cache")(c) + expectedPath = "/api/v1.0/director/object/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // test a PUT request always goes to the origin endpoint + req = httptest.NewRequest("PUT", "/foo/bar", nil) + c.Request = req + ShortcutMiddleware("cache")(c) + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // Host-aware tests + // Test that we can turn on host-aware redirects and get one appropriate redirect from each + // type of header (as we've already tested that hostname redirects function) + + // Host header + viper.Set("Director.OriginResponseHostnames", []string{"origin-hostname.com"}) + viper.Set("Director.HostAwareRedirects", true) + req = httptest.NewRequest("GET", "/foo/bar", nil) + c.Request = req + c.Request.Header.Set("Host", "origin-hostname.com") + ShortcutMiddleware("cache")(c) + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) + + // X-Forwarded-Host header + req = httptest.NewRequest("GET", "/foo/bar", nil) + c.Request = req + c.Request.Header.Set("X-Forwarded-Host", "origin-hostname.com") + ShortcutMiddleware("cache")(c) + expectedPath = "/api/v1.0/director/origin/foo/bar" + assert.Equal(t, expectedPath, c.Request.URL.Path) - t.Run("query-all-with-empty-server-type", func(t *testing.T) { + viper.Reset() + }) + + t.Run("cache-test-file-redirect", func(t *testing.T) { + viper.Set("Server.ExternalWebUrl", "https://example.com") // Create a request to the endpoint w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/servers?server_type=", nil) + req, _ := http.NewRequest("GET", "/api/v1.0/director/origin/pelican/monitoring/test.txt", nil) + req.Header.Add("User-Agent", "pelican-v7.6.1") router.ServeHTTP(w, req) - // Check the response - require.Equal(t, 200, w.Code) + require.Equal(t, http.StatusTemporaryRedirect, w.Code) + assert.NotEmpty(t, w.Header().Get("Location")) + assert.Equal(t, "https://example.com/api/v1.0/director/healthTest/pelican/monitoring/test.txt", w.Header().Get("Location")) + }) +} - var got []listServerResponse - err := json.Unmarshal(w.Body.Bytes(), &got) - if err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) - } - require.Equal(t, 2, len(got)) +func TestGetHealthTestFile(t *testing.T) { + router := gin.Default() + router.GET("/api/v1.0/director/healthTest/*path", getHealthTestFile) + + t.Run("400-on-empty-path", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("query-all-without-query-param", func(t *testing.T) { - // Create a request to the endpoint + t.Run("400-on-random-path", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/servers", nil) + req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/foo/bar", nil) router.ServeHTTP(w, req) - // Check the response - require.Equal(t, 200, w.Code) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) - var got []listServerResponse - err := json.Unmarshal(w.Body.Bytes(), &got) - if err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) - } - require.Equal(t, 2, len(got)) + t.Run("400-on-dir", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("query-with-invalid-param", func(t *testing.T) { - // Create a request to the endpoint + t.Run("400-on-missing-file-ext", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/servers?server_type=staging", nil) + req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring/testfile", nil) router.ServeHTTP(w, req) - // Check the response - require.Equal(t, 400, w.Code) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("200-on-correct-request-file", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1.0/director/healthTest/pelican/monitoring/testfile.txt", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + bytes, err := io.ReadAll(w.Result().Body) + require.NoError(t, err) + assert.Equal(t, testFileContent+"testfile\n", string(bytes)) }) } diff --git a/director/director_ui.go b/director/director_ui.go new file mode 100644 index 000000000..c0a352a18 --- /dev/null +++ b/director/director_ui.go @@ -0,0 +1,226 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package director + +import ( + "net/http" + "path" + "strings" + + "github.com/gin-gonic/gin" + "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/web_ui" + log "github.com/sirupsen/logrus" +) + +type ( + listServerRequest struct { + ServerType string `form:"server_type"` // "cache" or "origin" + } + + listServerResponse struct { + Name string `json:"name"` + AuthURL string `json:"authUrl"` + BrokerURL string `json:"brokerUrl"` + URL string `json:"url"` // This is server's XRootD URL for file transfer + WebURL string `json:"webUrl"` // This is server's Web interface and API + Type server_structs.ServerType `json:"type"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Writes bool `json:"enableWrite"` + DirectReads bool `json:"enableFallbackRead"` + Filtered bool `json:"filtered"` + FilteredType filterType `json:"filteredType"` + Status HealthTestStatus `json:"status"` + } + + statResponse struct { + OK bool `json:"ok"` + Message string `json:"message"` + Metadata []*objectMetadata `json:"metadata"` + } + + statRequest struct { + MinResponses int `form:"min_responses"` + MaxResponses int `form:"max_responses"` + } +) + +func (req listServerRequest) ToInternalServerType() server_structs.ServerType { + if req.ServerType == "cache" { + return server_structs.CacheType + } + if req.ServerType == "origin" { + return server_structs.OriginType + } + return "" +} + +func listServers(ctx *gin.Context) { + queryParams := listServerRequest{} + if ctx.ShouldBindQuery(&queryParams) != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + var servers []server_structs.ServerAd + if queryParams.ServerType != "" { + if !strings.EqualFold(queryParams.ServerType, string(server_structs.OriginType)) && !strings.EqualFold(queryParams.ServerType, string(server_structs.CacheType)) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server type"}) + return + } + servers = listServerAds([]server_structs.ServerType{server_structs.ServerType(queryParams.ToInternalServerType())}) + } else { + servers = listServerAds([]server_structs.ServerType{server_structs.OriginType, server_structs.CacheType}) + } + healthTestUtilsMutex.RLock() + defer healthTestUtilsMutex.RUnlock() + resList := make([]listServerResponse, 0) + for _, server := range servers { + healthStatus := HealthStatusUnknown + healthUtil, ok := healthTestUtils[server] + if ok { + healthStatus = healthUtil.Status + } + filtered, ft := checkFilter(server.Name) + res := listServerResponse{ + Name: server.Name, + BrokerURL: server.BrokerURL.String(), + AuthURL: server.AuthURL.String(), + URL: server.URL.String(), + WebURL: server.WebURL.String(), + Type: server.Type, + Latitude: server.Latitude, + Longitude: server.Longitude, + Writes: server.Writes, + DirectReads: server.DirectReads, + Filtered: filtered, + FilteredType: ft, + Status: healthStatus, + } + resList = append(resList, res) + } + ctx.JSON(http.StatusOK, resList) +} + +func queryOrigins(ctx *gin.Context) { + pathParam := ctx.Param("path") + path := path.Clean(pathParam) + if path == "" || strings.HasSuffix(path, "/") { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Path should not be empty or ended with slash '/'"}) + return + } + queryParams := statRequest{} + if ctx.ShouldBindQuery(&queryParams) != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"}) + return + } + meta, msg, err := NewObjectStat().Query(path, ctx, queryParams.MinResponses, queryParams.MaxResponses) + if err != nil { + if err == NoPrefixMatchError { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } else if err == ParameterError { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } else if err == InsufficientResError { + // Insufficient response does not cause a 500 error, but OK field in reponse is false + if len(meta) < 1 { + ctx.JSON(http.StatusNotFound, gin.H{"error": msg + " If no object is available, please check if the object is in a public namespace."}) + return + } + res := statResponse{Message: msg, Metadata: meta, OK: false} + ctx.JSON(http.StatusOK, res) + } else { + log.Errorf("Error in NewObjectStat with path: %s, min responses: %d, max responses: %d. %v", path, queryParams.MinResponses, queryParams.MaxResponses, err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if len(meta) < 1 { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error() + " If no object is available, please check if the object is in a public namespace."}) + } + res := statResponse{Message: msg, Metadata: meta, OK: true} + ctx.JSON(http.StatusOK, res) +} + +// A gin route handler that given a server hostname through path variable `name`, +// checks and adds the server to a list of servers to be bypassed when the director redirects +// object requests from the client +func handleFilterServer(ctx *gin.Context) { + sn := strings.TrimPrefix(ctx.Param("name"), "/") + if sn == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "name is a required path parameter"}) + return + } + filtered, filterType := checkFilter(sn) + if filtered { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Can't filter a server that already has been fitlered with type " + filterType}) + return + } + filteredServersMutex.Lock() + defer filteredServersMutex.Unlock() + + // If we previously temporarily allowed a server, we switch to permFiltered (reset) + if filterType == tempAllowed { + filteredServers[sn] = permFiltered + } else { + filteredServers[sn] = tempFiltered + } + ctx.JSON(http.StatusOK, gin.H{"message": "success"}) +} + +// A gin route handler that given a server hostname through path variable `name`, +// checks and removes the server from a list of servers to be bypassed when the director redirects +// object requests from the client +func handleAllowServer(ctx *gin.Context) { + sn := strings.TrimPrefix(ctx.Param("name"), "/") + if sn == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "name is a required path parameter"}) + return + } + filtered, ft := checkFilter(sn) + if !filtered { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Can't allow a server that is not being filtered. " + ft}) + return + } + + filteredServersMutex.Lock() + defer filteredServersMutex.Unlock() + + if ft == tempFiltered { + // For temporarily filtered server, allowing them by removing the server from the map + delete(filteredServers, sn) + } else if ft == permFiltered { + // For servers to filter from the config, temporarily allow the server + filteredServers[sn] = tempAllowed + } + ctx.JSON(http.StatusOK, gin.H{"message": "success"}) +} + +func RegisterDirectorWebAPI(router *gin.RouterGroup) { + directorWebAPI := router.Group("/api/v1.0/director_ui") + // Follow RESTful schema + { + directorWebAPI.GET("/servers", listServers) + directorWebAPI.PATCH("/servers/filter/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleFilterServer) + directorWebAPI.PATCH("/servers/allow/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleAllowServer) + directorWebAPI.GET("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) + directorWebAPI.HEAD("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) + } +} diff --git a/director/director_ui_test.go b/director/director_ui_test.go new file mode 100644 index 000000000..3e1ad5415 --- /dev/null +++ b/director/director_ui_test.go @@ -0,0 +1,154 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package director + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jellydator/ttlcache/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListServers(t *testing.T) { + router := gin.Default() + + router.GET("/servers", listServers) + + func() { + serverAdMutex.Lock() + defer serverAdMutex.Unlock() + serverAds.DeleteAll() + serverAds.Set(mockOriginServerAd, mockNamespaceAds(5, "origin1"), ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd, mockNamespaceAds(4, "cache1"), ttlcache.DefaultTTL) + require.True(t, serverAds.Has(mockOriginServerAd)) + require.True(t, serverAds.Has(mockCacheServerAd)) + }() + + mocklistOriginRes := listServerResponse{ + Name: mockOriginServerAd.Name, + BrokerURL: mockOriginServerAd.BrokerURL.String(), + AuthURL: mockOriginServerAd.AuthURL.String(), + URL: mockOriginServerAd.URL.String(), + WebURL: mockOriginServerAd.WebURL.String(), + Type: mockOriginServerAd.Type, + Latitude: mockOriginServerAd.Latitude, + Longitude: mockOriginServerAd.Longitude, + Writes: mockOriginServerAd.Writes, + DirectReads: mockOriginServerAd.DirectReads, + Status: HealthStatusUnknown, + } + mocklistCacheRes := listServerResponse{ + Name: mockCacheServerAd.Name, + BrokerURL: mockCacheServerAd.BrokerURL.String(), + AuthURL: mockCacheServerAd.AuthURL.String(), + URL: mockCacheServerAd.URL.String(), + WebURL: mockCacheServerAd.WebURL.String(), + Type: mockCacheServerAd.Type, + Latitude: mockCacheServerAd.Latitude, + Longitude: mockCacheServerAd.Longitude, + Writes: mockCacheServerAd.Writes, + DirectReads: mockCacheServerAd.DirectReads, + Status: HealthStatusUnknown, + } + + t.Run("query-origin", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers?server_type=origin", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got []listServerResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + if err != nil { + t.Fatalf("Failed to unmarshal response body: %v", err) + } + require.Equal(t, 1, len(got)) + assert.Equal(t, mocklistOriginRes, got[0], "Response data does not match expected") + }) + + t.Run("query-cache", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers?server_type=cache", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got []listServerResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + if err != nil { + t.Fatalf("Failed to unmarshal response body: %v", err) + } + require.Equal(t, 1, len(got)) + assert.Equal(t, mocklistCacheRes, got[0], "Response data does not match expected") + }) + + t.Run("query-all-with-empty-server-type", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers?server_type=", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got []listServerResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + if err != nil { + t.Fatalf("Failed to unmarshal response body: %v", err) + } + require.Equal(t, 2, len(got)) + }) + + t.Run("query-all-without-query-param", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got []listServerResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + if err != nil { + t.Fatalf("Failed to unmarshal response body: %v", err) + } + require.Equal(t, 2, len(got)) + }) + + t.Run("query-with-invalid-param", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers?server_type=staging", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 400, w.Code) + }) +} diff --git a/director/discovery.go b/director/discovery.go index 1d873bae5..fa5fdf53c 100644 --- a/director/discovery.go +++ b/director/discovery.go @@ -38,6 +38,8 @@ const ( directorJWKSPath string = "/.well-known/issuer.jwks" ) +// Director hosts a discovery endpoint at federationDiscoveryPath to provide URLs to various +// Pelican central servers in a federation. func federationDiscoveryHandler(ctx *gin.Context) { directorUrl := param.Federation_DirectorUrl.GetString() if len(directorUrl) == 0 { @@ -71,7 +73,7 @@ func federationDiscoveryHandler(ctx *gin.Context) { // Director metadata discovery endpoint for OpenID style // token authentication, providing issuer endpoint and director's jwks endpoint -func openIdDiscoveryHandler(ctx *gin.Context) { +func oidcDiscoveryHandler(ctx *gin.Context) { directorUrl := param.Federation_DirectorUrl.GetString() if len(directorUrl) == 0 { ctx.JSON(500, gin.H{"error": "Bad server configuration: Director URL is not set"}) @@ -111,8 +113,8 @@ func jwksHandler(ctx *gin.Context) { } } -func RegisterDirectorAuth(router *gin.RouterGroup) { +func RegisterDirectorOIDCAPI(router *gin.RouterGroup) { router.GET(federationDiscoveryPath, federationDiscoveryHandler) - router.GET(openIdDiscoveryPath, openIdDiscoveryHandler) + router.GET(openIdDiscoveryPath, oidcDiscoveryHandler) router.GET(directorJWKSPath, jwksHandler) } diff --git a/director/monitor.go b/director/monitor.go new file mode 100644 index 000000000..32dc9c62b --- /dev/null +++ b/director/monitor.go @@ -0,0 +1,350 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package director + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + + "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/metrics" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/token" + "github.com/pelicanplatform/pelican/token_scopes" +) + +var originReportNotFoundError = errors.New("Origin does not support new reporting API") + +// Report the health status of test file transfer to storage server +func reportStatusToServer(ctx context.Context, serverWebUrl string, status string, message string, serverType server_structs.ServerType, fallback bool) error { + directorUrl, err := url.Parse(param.Server_ExternalWebUrl.GetString()) + if err != nil { + return errors.Wrapf(err, "failed to parse external URL %v", param.Server_ExternalWebUrl.GetString()) + } + + testTokenCfg := token.NewWLCGToken() + testTokenCfg.Lifetime = time.Minute + testTokenCfg.Issuer = directorUrl.String() + testTokenCfg.AddAudiences(serverWebUrl) + testTokenCfg.Subject = "director" + testTokenCfg.AddScopes(token_scopes.Pelican_DirectorTestReport) + + tok, err := testTokenCfg.CreateToken() + if err != nil { + return errors.Wrap(err, "failed to create director test report token") + } + + reportUrl, err := url.Parse(serverWebUrl) + if err != nil { + return errors.Wrap(err, "the server URL is not parseable as an URL") + } + + if status != "ok" && status != "error" { + return errors.Errorf("bad status for reporting director test %s", status) + } + + if serverType == server_structs.OriginType { + if fallback { + reportUrl.Path = "/api/v1.0/origin-api/directorTest" + } else { + reportUrl.Path = "/api/v1.0/origin/directorTest" + } + } else if serverType == server_structs.CacheType { + reportUrl.Path = "/api/v1.0/cache/directorTest" + } + + dt := server_structs.DirectorTestResult{ + Status: status, + Message: message, + Timestamp: time.Now().Unix(), + } + + jsonData, err := json.Marshal(dt) + if err != nil { + return errors.Wrap(err, "failed to parse request body for reporting director test") + } + + reqBody := bytes.NewBuffer(jsonData) + + log.Debugf("Director is sending %s server test result to %s", string(serverType), reportUrl.String()) + req, err := http.NewRequestWithContext(ctx, "POST", reportUrl.String(), reqBody) + if err != nil { + return errors.Wrap(err, "failed to create POST request for reporting director test") + } + + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + + tr := config.GetTransport() + client := http.Client{Transport: tr} + resp, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "Failed to start request for reporting director test") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "Failed to read response body for reporting director test") + } + + if resp.StatusCode > 404 { // For all servers, >404 is a failure + return errors.Errorf("error response %v from reporting director test: %v", resp.StatusCode, string(body)) + } + if serverType == server_structs.OriginType && resp.StatusCode != 200 { + return errors.Errorf("error response %v from reporting director test: %v", resp.StatusCode, string(body)) + } + if serverType == server_structs.CacheType && resp.StatusCode == 404 { + return errors.New("cache reports a 404 error. For cache version < v7.7.0, director-based test is not supported") + } + if serverType == server_structs.OriginType && resp.StatusCode == 404 { + return originReportNotFoundError + } + + return nil +} + +// Run a periodic test file transfer against an origin to ensure +// it's talking to the director +func LaunchPeriodicDirectorTest(ctx context.Context, serverAd server_structs.ServerAd) { + serverName := serverAd.Name + serverUrl := serverAd.URL.String() + serverWebUrl := serverAd.WebURL.String() + + log.Debug(fmt.Sprintf("Starting a new director test suite for %s server %s at %s", serverAd.Type, serverName, serverUrl)) + + metrics.PelicanDirectorFileTransferTestSuite.With( + prometheus.Labels{ + "server_name": serverName, "server_web_url": serverWebUrl, "server_type": string(serverAd.Type), + }).Inc() + + metrics.PelicanDirectorActiveFileTransferTestSuite.With( + prometheus.Labels{ + "server_name": serverName, "server_web_url": serverWebUrl, "server_type": string(serverAd.Type), + }).Inc() + + customInterval := param.Director_OriginCacheHealthTestInterval.GetDuration() + if customInterval < 15*time.Second { + log.Warningf("You set Director.OriginCacheHealthTestInterval to a very small number %s, which will cause high traffic volume to xrootd servers.", customInterval.String()) + } + if customInterval == 0 { + customInterval = 15 * time.Second + log.Error("Invalid config value: Director.OriginCacheHealthTestInterval is 0. Fallback to 15s.") + } + ticker := time.NewTicker(customInterval) + + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Debug(fmt.Sprintf("End director test suite for %s server %s at %s", serverAd.Type, serverName, serverUrl)) + + metrics.PelicanDirectorActiveFileTransferTestSuite.With( + prometheus.Labels{ + "server_name": serverName, "server_web_url": serverWebUrl, "server_type": string(serverAd.Type), + }).Dec() + + return + case <-ticker.C: + log.Debug(fmt.Sprintf("Starting a director test cycle for %s server %s at %s", serverAd.Type, serverName, serverUrl)) + ok := true + var err error + if serverAd.Type == server_structs.OriginType { + fileTests := server_utils.TestFileTransferImpl{} + ok, err = fileTests.RunTests(ctx, serverUrl, serverUrl, "", server_utils.DirectorFileTest) + } else if serverAd.Type == server_structs.CacheType { + err = runCacheTest(ctx, serverAd.URL) + } + + // Successfully run a test, no error + if ok && err == nil { + log.Debugf("Director file transfer test cycle succeeded at %s for %s server with URL at %s", time.Now().Format(time.RFC3339), serverAd.Type, serverUrl) + func() { + healthTestUtilsMutex.Lock() + defer healthTestUtilsMutex.Unlock() + if existingUtil, ok := healthTestUtils[serverAd]; ok { + existingUtil.Status = HealthStatusOK + } else { + log.Debugln("HealthTestUtil missing for ", serverAd.Type, " server: ", serverUrl, " Failed to update internal status") + } + }() + + // Report error back to origin/server + if err := reportStatusToServer( + ctx, + serverWebUrl, + "ok", "Director test cycle succeeded at "+time.Now().Format(time.RFC3339), + serverAd.Type, + false, + ); err != nil { + // origin <7.7 only supports legacy report endpoint. Fallback to the legacy one + if err == originReportNotFoundError { + newErr := reportStatusToServer( + ctx, + serverWebUrl, + "ok", "Director test cycle succeeded at "+time.Now().Format(time.RFC3339), + serverAd.Type, + true, // Fallback to legacy endpoint + ) + // If legacy endpoint still reports error + if newErr != nil { + log.Warningf("Failed to report director test result to %s server at %s: %v", serverAd.Type, serverAd.WebURL.String(), err) + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestSucceeded), + "report_status": string(metrics.FTXTestFailed), + }, + ).Inc() + // Successfully report to the origin/cache via the legacy endpoint + } else { + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestSucceeded), + "report_status": string(metrics.FTXTestSucceeded), + }, + ).Inc() + } + // If the error is not originReportNotFoundError, then we record the error right away + } else { + log.Warningf("Failed to report director test result to %s server at %s: %v", serverAd.Type, serverAd.WebURL.String(), err) + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestSucceeded), + "report_status": string(metrics.FTXTestFailed), + }, + ).Inc() + } + // No error when reporting the result, we are good + } else { + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestSucceeded), + "report_status": string(metrics.FTXTestSucceeded), + }, + ).Inc() + } + // The file tests failed. Report failure back to origin/cache + } else { + log.Warningln("Director file transfer test cycle failed for ", serverAd.Type, " server: ", serverUrl, " ", err) + func() { + healthTestUtilsMutex.Lock() + defer healthTestUtilsMutex.Unlock() + if existingUtil, ok := healthTestUtils[serverAd]; ok { + existingUtil.Status = HealthStatusError + } else { + log.Debugln("HealthTestUtil missing for", serverAd.Type, " server: ", serverUrl, " Failed to update internal status") + } + }() + + if err := reportStatusToServer( + ctx, + serverWebUrl, + "error", "Director file transfer test cycle failed for origin: "+serverUrl+" "+err.Error(), + serverAd.Type, + false, + ); err != nil { + // origin <7.7 only supports legacy report endpoint. Fallback to the legacy one + if err == originReportNotFoundError { + newErr := reportStatusToServer( + ctx, + serverWebUrl, + "ok", "Director test cycle succeeded at "+time.Now().Format(time.RFC3339), + serverAd.Type, + true, // Fallback to legacy endpoint + ) + // If legacy endpoint still reports error + if newErr != nil { + log.Warningf("Failed to report director test result to %s server at %s: %v", serverAd.Type, serverAd.WebURL.String(), err) + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestFailed), + "report_status": string(metrics.FTXTestFailed), + }, + ).Inc() + // Successfully report to the origin/cache via the legacy endpoint + } else { + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestFailed), + "report_status": string(metrics.FTXTestSucceeded), + }, + ).Inc() + } + // If the error is not originReportNotFoundError, then we record the error right away + } else { + log.Warningf("Failed to report director test result to %s server at %s: %v", serverAd.Type, serverAd.WebURL.String(), err) + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestFailed), + "report_status": string(metrics.FTXTestFailed), + }, + ).Inc() + } + + } else { + // No error when reporting the result, we are good + metrics.PelicanDirectorFileTransferTestsRuns.With( + prometheus.Labels{ + "server_name": serverName, + "server_web_url": serverWebUrl, + "server_type": string(serverAd.Type), + "status": string(metrics.FTXTestFailed), + "report_status": string(metrics.FTXTestSucceeded), + }, + ).Inc() + } + } + + } + } +} diff --git a/director/origin_api.go b/director/origin_api.go index 0117e1a2f..be09bb4ec 100644 --- a/director/origin_api.go +++ b/director/origin_api.go @@ -36,9 +36,9 @@ import ( "github.com/pkg/errors" log "github.com/sirupsen/logrus" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/token_scopes" ) @@ -64,7 +64,7 @@ func checkNamespaceStatus(prefix string, registryWebUrlStr string) (bool, error) } reqUrl := registryUrl.JoinPath("/api/v1.0/registry/checkNamespaceStatus") - reqBody := common.CheckNamespaceStatusReq{Prefix: prefix} + reqBody := server_structs.CheckNamespaceStatusReq{Prefix: prefix} reqByte, err := json.Marshal(reqBody) if err != nil { return false, err @@ -91,7 +91,7 @@ func checkNamespaceStatus(prefix string, registryWebUrlStr string) (bool, error) } } - resBody := common.CheckNamespaceStatusRes{} + resBody := server_structs.CheckNamespaceStatusRes{} bodyByte, err := io.ReadAll(res.Body) if err != nil { return false, err @@ -107,7 +107,7 @@ func checkNamespaceStatus(prefix string, registryWebUrlStr string) (bool, error) // Given a token and a location in the namespace to advertise in, // see if the entity is authorized to advertise an origin for the // namespace -func VerifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, error) { +func verifyAdvertiseToken(ctx context.Context, token, namespace string) (bool, error) { issuerUrl, err := server_utils.GetNSIssuerURL(namespace) if err != nil { return false, err diff --git a/director/origin_api_test.go b/director/origin_api_test.go index a14861755..9b1de8559 100644 --- a/director/origin_api_test.go +++ b/director/origin_api_test.go @@ -34,8 +34,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" "github.com/pelicanplatform/pelican/token" @@ -63,7 +63,7 @@ func TestVerifyAdvertiseToken(t *testing.T) { // Mock registry server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.Method == "POST" && req.URL.Path == "/api/v1.0/registry/checkNamespaceStatus" { - res := common.CheckNamespaceStatusRes{Approved: true} + res := server_structs.CheckNamespaceStatusRes{Approved: true} resByte, err := json.Marshal(res) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -121,7 +121,7 @@ func TestVerifyAdvertiseToken(t *testing.T) { tok, err := advTokenCfg.CreateToken() assert.NoError(t, err, "failed to create director prometheus token") - ok, err := VerifyAdvertiseToken(ctx, tok, "/test-namespace") + ok, err := verifyAdvertiseToken(ctx, tok, "/test-namespace") assert.NoError(t, err) assert.Equal(t, true, ok, "Expected scope to be 'pelican.advertise'") @@ -135,7 +135,7 @@ func TestVerifyAdvertiseToken(t *testing.T) { tok, err = scopelessTokCfg.CreateToken() assert.NoError(t, err, "error creating scopeless token. Should have succeeded") - ok, err = VerifyAdvertiseToken(ctx, tok, "/test-namespace") + ok, err = verifyAdvertiseToken(ctx, tok, "/test-namespace") assert.Equal(t, false, ok) assert.Equal(t, "No scope is present; required to advertise to director", err.Error()) @@ -150,7 +150,7 @@ func TestVerifyAdvertiseToken(t *testing.T) { tok, err = wrongScopeTokenCfg.CreateToken() assert.NoError(t, err, "error creating wrong-scope token. Should have succeeded") - ok, err = VerifyAdvertiseToken(ctx, tok, "/test-namespace") + ok, err = verifyAdvertiseToken(ctx, tok, "/test-namespace") assert.Equal(t, false, ok, "Should fail due to incorrect scope name") assert.NoError(t, err, "Incorrect scope name should not throw and error") } diff --git a/director/origin_monitor.go b/director/origin_monitor.go deleted file mode 100644 index 10f7ac034..000000000 --- a/director/origin_monitor.go +++ /dev/null @@ -1,217 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research - * - * 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. - * - ***************************************************************/ - -package director - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" - - "github.com/pkg/errors" - "github.com/prometheus/client_golang/prometheus" - log "github.com/sirupsen/logrus" - - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/metrics" - "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_utils" - "github.com/pelicanplatform/pelican/token" - "github.com/pelicanplatform/pelican/token_scopes" -) - -// Report the health status of test file transfer to origin -func reportStatusToOrigin(ctx context.Context, originWebUrl string, status string, message string) error { - directorUrl, err := url.Parse(param.Server_ExternalWebUrl.GetString()) - if err != nil { - return errors.Wrapf(err, "failed to parse external URL %v", param.Server_ExternalWebUrl.GetString()) - } - - testTokenCfg := token.NewWLCGToken() - testTokenCfg.Lifetime = time.Minute - testTokenCfg.Issuer = directorUrl.String() - testTokenCfg.AddAudiences(originWebUrl) - testTokenCfg.Subject = "director" - testTokenCfg.AddScopes(token_scopes.Pelican_DirectorTestReport) - - tok, err := testTokenCfg.CreateToken() - if err != nil { - return errors.Wrap(err, "failed to create director test report token") - } - - reportUrl, err := url.Parse(originWebUrl) - if err != nil { - return errors.Wrap(err, "The origin URL is not parseable as a URL") - } - - if status != "ok" && status != "error" { - return errors.Errorf("Bad status for reporting director test") - } - - reportUrl.Path = "/api/v1.0/origin-api/directorTest" - - dt := common.DirectorTestResult{ - Status: status, - Message: message, - Timestamp: time.Now().Unix(), - } - - jsonData, err := json.Marshal(dt) - if err != nil { - // handle error - return errors.Wrap(err, "Failed to parse request body for reporting director test") - } - - reqBody := bytes.NewBuffer(jsonData) - - log.Debugln("Director is uploading origin test results to", reportUrl.String()) - req, err := http.NewRequestWithContext(ctx, "POST", reportUrl.String(), reqBody) - if err != nil { - return errors.Wrap(err, "Failed to create POST request for reporting director test") - } - - req.Header.Set("Authorization", "Bearer "+tok) - req.Header.Set("Content-Type", "application/json") - - tr := config.GetTransport() - client := http.Client{Transport: tr} - resp, err := client.Do(req) - if err != nil { - return errors.Wrap(err, "Failed to start request for reporting director test") - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return errors.Wrap(err, "Failed to read response body for reporting director test") - } - - if resp.StatusCode > 299 { - return errors.Errorf("Error response %v from reporting director test: %v", resp.StatusCode, string(body)) - } - - return nil -} - -// Run a periodic test file transfer against an origin to ensure -// it's talking to the director -func LaunchPeriodicDirectorTest(ctx context.Context, originAd common.ServerAd) { - originName := originAd.Name - originUrl := originAd.URL.String() - originWebUrl := originAd.WebURL.String() - - log.Debug(fmt.Sprintf("Starting a new director test suite for origin %s at %s", originName, originUrl)) - - metrics.PelicanDirectorFileTransferTestSuite.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), - }).Inc() - - metrics.PelicanDirectorActiveFileTransferTestSuite.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), - }).Inc() - - customInterval := param.Director_OriginCacheHealthTestInterval.GetDuration() - if customInterval < 15*time.Second { - log.Warningf("You set Director.OriginCacheHealthTestInterval to a very small number %s, which will cause high traffic volume to xrootd servers.", customInterval.String()) - } - if customInterval == 0 { - customInterval = 15 * time.Second - log.Error("Invalid config value: Director.OriginCacheHealthTestInterval is 0. Fallback to 15s.") - } - ticker := time.NewTicker(customInterval) - - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Debug(fmt.Sprintf("End director test suite for origin: %s at %s", originName, originUrl)) - - metrics.PelicanDirectorActiveFileTransferTestSuite.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), - }).Dec() - - return - case <-ticker.C: - log.Debug(fmt.Sprintf("Starting a director test cycle for origin: %s at %s", originName, originUrl)) - fileTests := server_utils.TestFileTransferImpl{} - ok, err := fileTests.RunTests(ctx, originUrl, originUrl, "", server_utils.DirectorFileTest) - if ok && err == nil { - log.Debugln("Director file transfer test cycle succeeded at", time.Now().Format(time.UnixDate), " for origin: ", originUrl) - func() { - healthTestUtilsMutex.Lock() - defer healthTestUtilsMutex.Unlock() - if existingUtil, ok := healthTestUtils[originAd]; ok { - existingUtil.Status = HealthStatusOK - } else { - log.Debugln("HealthTestUtil missing for origin: ", originUrl, " Failed to update internal status") - } - }() - if err := reportStatusToOrigin(ctx, originWebUrl, "ok", "Director test cycle succeeded at "+time.Now().Format(time.RFC3339)); err != nil { - log.Warningln("Failed to report director test result to origin:", err) - metrics.PelicanDirectorFileTransferTestsRuns.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), "status": string(metrics.FTXTestSucceeded), "report_status": string(metrics.FTXTestFailed), - }, - ).Inc() - } else { - metrics.PelicanDirectorFileTransferTestsRuns.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), "status": string(metrics.FTXTestSucceeded), "report_status": string(metrics.FTXTestSucceeded), - }, - ).Inc() - } - } else { - log.Warningln("Director file transfer test cycle failed for origin: ", originUrl, " ", err) - func() { - healthTestUtilsMutex.Lock() - defer healthTestUtilsMutex.Unlock() - if existingUtil, ok := healthTestUtils[originAd]; ok { - existingUtil.Status = HealthStatusError - } else { - log.Debugln("HealthTestUtil missing for origin: ", originUrl, " Failed to update internal status") - } - }() - if err := reportStatusToOrigin(ctx, originWebUrl, "error", "Director file transfer test cycle failed for origin: "+originUrl+" "+err.Error()); err != nil { - log.Warningln("Failed to report director test result to origin: ", err) - metrics.PelicanDirectorFileTransferTestsRuns.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), "status": string(metrics.FTXTestFailed), "report_status": string(metrics.FTXTestFailed), - }, - ).Inc() - } else { - metrics.PelicanDirectorFileTransferTestsRuns.With( - prometheus.Labels{ - "server_name": originName, "server_web_url": originWebUrl, "server_type": string(originAd.Type), "status": string(metrics.FTXTestFailed), "report_status": string(metrics.FTXTestSucceeded), - }, - ).Inc() - } - } - - } - } -} diff --git a/director/redirect.go b/director/redirect.go deleted file mode 100644 index 774b47900..000000000 --- a/director/redirect.go +++ /dev/null @@ -1,800 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research - * - * 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. - * - ***************************************************************/ - -package director - -import ( - "context" - "fmt" - "net/http" - "net/netip" - "net/url" - "path" - "regexp" - "strings" - "sync" - - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/token" - "github.com/pelicanplatform/pelican/token_scopes" - "golang.org/x/sync/errgroup" - - "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin/binding" - "github.com/hashicorp/go-version" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" -) - -type ( - HealthTestStatus string - PromDiscoveryItem struct { - Targets []string `json:"targets"` - Labels map[string]string `json:"labels"` - } - - healthTestUtil struct { - ErrGrp *errgroup.Group - ErrGrpContext context.Context - Cancel context.CancelFunc - Status HealthTestStatus - } - originStatUtil struct { - Context context.Context - Cancel context.CancelFunc - Errgroup *errgroup.Group - } -) - -const ( - HealthStatusUnknown HealthTestStatus = "Unknown" - HealthStatusInit HealthTestStatus = "Initializing" - HealthStatusOK HealthTestStatus = "OK" - HealthStatusError HealthTestStatus = "Error" -) - -var ( - minClientVersion, _ = version.NewVersion("7.0.0") - minOriginVersion, _ = version.NewVersion("7.0.0") - minCacheVersion, _ = version.NewVersion("7.3.0") - healthTestUtils = make(map[common.ServerAd]*healthTestUtil) - healthTestUtilsMutex = sync.RWMutex{} - - originStatUtils = make(map[url.URL]originStatUtil) - originStatUtilsMutex = sync.RWMutex{} -) - -func getRedirectURL(reqPath string, ad common.ServerAd, requiresAuth bool) (redirectURL url.URL) { - var serverURL url.URL - if requiresAuth { - serverURL = ad.AuthURL - } else { - serverURL = ad.URL - } - reqPath = path.Clean("/" + reqPath) - if requiresAuth { - redirectURL.Scheme = "https" - } else { - redirectURL.Scheme = "http" - } - redirectURL.Host = serverURL.Host - redirectURL.Path = reqPath - return -} - -func getRealIP(ginCtx *gin.Context) (ipAddr netip.Addr, err error) { - ip_addr_list := ginCtx.Request.Header["X-Real-Ip"] - if len(ip_addr_list) == 0 { - ipAddr, err = netip.ParseAddr(ginCtx.RemoteIP()) - return - } else { - ipAddr, err = netip.ParseAddr(ip_addr_list[0]) - return - } -} - -// Calculate the depth attribute of Link header given the path to the file -// and the prefix of the namespace that can serve the file -// -// Ref: https://www.rfc-editor.org/rfc/rfc6249.html#section-3.4 -func getLinkDepth(filepath, prefix string) (int, error) { - if filepath == "" || prefix == "" { - return 0, errors.New("either filepath or prefix is an empty path") - } - if !strings.HasPrefix(filepath, prefix) { - return 0, errors.New("filepath does not contain the prefix") - } - // We want to remove shared prefix between filepath and prefix, then split the remaining string by slash. - // To make the final calculation easier, we also remove the head slash from the file path. - // e.g. filepath = /foo/bar/barz.txt prefix = /foo - // we want commonPath = bar/barz.txt - if !strings.HasSuffix(prefix, "/") && prefix != "/" { - prefix += "/" - } - commonPath := strings.TrimPrefix(filepath, prefix) - pathDepth := len(strings.Split(commonPath, "/")) - return pathDepth, nil -} - -func getAuthzEscaped(req *http.Request) (authzEscaped string) { - if authzQuery := req.URL.Query()["authz"]; len(authzQuery) > 0 { - authzEscaped = authzQuery[0] - // if the authz URL query is coming from XRootD, it probably has a "Bearer " tacked in front - // even though it's coming via a URL - authzEscaped = strings.TrimPrefix(authzEscaped, "Bearer ") - } else if authzHeader := req.Header["Authorization"]; len(authzHeader) > 0 { - authzEscaped = strings.TrimPrefix(authzHeader[0], "Bearer ") - authzEscaped = url.QueryEscape(authzEscaped) - } - return -} - -func getFinalRedirectURL(rurl url.URL, authzEscaped string) string { - if len(authzEscaped) > 0 { - if len(rurl.RawQuery) > 0 { - rurl.RawQuery += "&" - } - rurl.RawQuery += "authz=" + authzEscaped - } - return rurl.String() -} - -func versionCompatCheck(ginCtx *gin.Context) error { - // Check that the version of whichever service (eg client, origin, etc) is talking to the Director - // is actually something the Director thinks it can communicate with - - // The service/version is sent via User-Agent header in the form "pelican-/" - userAgentSlc := ginCtx.Request.Header["User-Agent"] - if len(userAgentSlc) < 1 { - return errors.New("No user agent could be found") - } - - // gin gives us a slice of user agents. Since pelican services should only ever - // send one UA, assume that it is the 0-th element of the slice. - userAgent := userAgentSlc[0] - - // Make sure we're working with something that's formatted the way we expect. If we - // don't match, then we're definitely not coming from one of the services, so we - // let things go without an error. Maybe someone is using curl? - uaRegExp := regexp.MustCompile(`^pelican-[^\/]+\/\d+\.\d+\.\d+`) - if matches := uaRegExp.MatchString(userAgent); !matches { - return nil - } - - userAgentSplit := strings.Split(userAgent, "/") - // Grab the actual service/version that's using the Director. There may be different versioning - // requirements between origins, clients, and other services. - service := (strings.Split(userAgentSplit[0], "-"))[1] - reqVerStr := userAgentSplit[1] - reqVer, err := version.NewVersion(reqVerStr) - if err != nil { - return errors.Wrapf(err, "Could not parse service version as a semantic version: %s\n", reqVerStr) - } - - var minCompatVer *version.Version - switch service { - case "client": - minCompatVer = minClientVersion - case "origin": - minCompatVer = minOriginVersion - case "cache": - minCompatVer = minCacheVersion - default: - return errors.Errorf("Invalid version format. The director does not support your %s version (%s).", service, reqVer.String()) - } - - if reqVer.LessThan(minCompatVer) { - return errors.Errorf("The director does not support your %s version (%s). Please update to %s or newer.", service, reqVer.String(), minCompatVer.String()) - } - - return nil -} - -func redirectToCache(ginCtx *gin.Context) { - err := versionCompatCheck(ginCtx) - if err != nil { - log.Warningf("A version incompatibility was encountered while redirecting to a cache and no response was served: %v", err) - ginCtx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) - return - } - - reqPath := path.Clean("/" + ginCtx.Request.URL.Path) - reqPath = strings.TrimPrefix(reqPath, "/api/v1.0/director/object") - ipAddr, err := getRealIP(ginCtx) - if err != nil { - log.Errorln("Error in getRealIP:", err) - ginCtx.String(http.StatusInternalServerError, "Internal error: Unable to determine client IP") - return - } - - authzBearerEscaped := getAuthzEscaped(ginCtx.Request) - - namespaceAd, originAds, cacheAds := getAdsForPath(reqPath) - // if GetAdsForPath doesn't find any ads because the prefix doesn't exist, we should - // report the lack of path first -- this is most important for the user because it tells them - // they're trying to get an object that simply doesn't exist - if namespaceAd.Path == "" { - ginCtx.String(404, "No namespace found for path. Either it doesn't exist, or the Director is experiencing problems\n") - return - } - // if err != nil, depth == 0, which is the default value for depth - // so we can use it as the value for the header even with err - depth, err := getLinkDepth(reqPath, namespaceAd.Path) - if err != nil { - log.Errorf("Failed to get depth attribute for the redirecting request to %q, with best match namespace prefix %q", reqPath, namespaceAd.Path) - } - // If the namespace prefix DOES exist, then it makes sense to say we couldn't find a valid cache. - if len(cacheAds) == 0 { - for _, originAd := range originAds { - if originAd.DirectReads { - cacheAds = append(cacheAds, originAd) - break - } - } - if len(cacheAds) == 0 { - ginCtx.String(http.StatusNotFound, "No cache found for path") - return - } - } else { - cacheAds, err = sortServers(ipAddr, cacheAds) - if err != nil { - log.Error("Error determining server ordering for cacheAds: ", err) - ginCtx.String(http.StatusInternalServerError, "Failed to determine server ordering") - return - } - } - redirectURL := getRedirectURL(reqPath, cacheAds[0], !namespaceAd.Caps.PublicReads) - - linkHeader := "" - first := true - for idx, ad := range cacheAds { - if first { - first = false - } else { - linkHeader += ", " - } - redirectURL := getRedirectURL(reqPath, ad, !namespaceAd.Caps.PublicReads) - linkHeader += fmt.Sprintf(`<%s>; rel="duplicate"; pri=%d; depth=%d`, redirectURL.String(), idx+1, depth) - } - ginCtx.Writer.Header()["Link"] = []string{linkHeader} - if len(namespaceAd.Issuer) != 0 { - - issStrings := []string{} - for _, tokIss := range namespaceAd.Issuer { - issStrings = append(issStrings, "issuer="+tokIss.IssuerUrl.String()) - } - ginCtx.Writer.Header()["X-Pelican-Authorization"] = issStrings - } - - if len(namespaceAd.Generation) != 0 { - tokenGen := "" - first := true - hdrVals := []string{namespaceAd.Generation[0].CredentialIssuer.String(), fmt.Sprint(namespaceAd.Generation[0].MaxScopeDepth), string(namespaceAd.Generation[0].Strategy)} - for idx, hdrKey := range []string{"issuer", "max-scope-depth", "strategy"} { - hdrVal := hdrVals[idx] - if hdrVal == "" { - continue - } - if !first { - tokenGen += ", " - } - first = false - tokenGen += hdrKey + "=" + hdrVal - } - if tokenGen != "" { - ginCtx.Writer.Header()["X-Pelican-Token-Generation"] = []string{tokenGen} - } - } - - var colUrl string - if namespaceAd.PublicRead { - colUrl = originAds[0].URL.String() - } else { - colUrl = originAds[0].AuthURL.String() - } - ginCtx.Writer.Header()["X-Pelican-Namespace"] = []string{fmt.Sprintf("namespace=%s, require-token=%v, collections-url=%s", - namespaceAd.Path, !namespaceAd.PublicRead, colUrl)} - - // Note we only append the `authz` query parameter in the case of the redirect response and not the - // duplicate link metadata above. This is purposeful: the Link header might get too long if we repeat - // the token 20 times for 20 caches. This means a "normal HTTP client" will correctly redirect but - // anything parsing the `Link` header for metalinks will need logic for redirecting appropriately. - ginCtx.Redirect(307, getFinalRedirectURL(redirectURL, authzBearerEscaped)) -} - -func redirectToOrigin(ginCtx *gin.Context) { - err := versionCompatCheck(ginCtx) - if err != nil { - log.Warningf("A version incompatibility was encountered while redirecting to an origin and no response was served: %v", err) - ginCtx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) - return - } - - reqPath := path.Clean("/" + ginCtx.Request.URL.Path) - reqPath = strings.TrimPrefix(reqPath, "/api/v1.0/director/origin") - - // Each namespace may be exported by several origins, so we must still - // do the geolocation song and dance if we want to get the closest origin... - ipAddr, err := getRealIP(ginCtx) - if err != nil { - return - } - - authzBearerEscaped := getAuthzEscaped(ginCtx.Request) - - namespaceAd, originAds, _ := getAdsForPath(reqPath) - // if GetAdsForPath doesn't find any ads because the prefix doesn't exist, we should - // report the lack of path first -- this is most important for the user because it tells them - // they're trying to get an object that simply doesn't exist - if namespaceAd.Path == "" { - ginCtx.String(http.StatusNotFound, "No namespace found for path. Either it doesn't exist, or the Director is experiencing problems\n") - return - } - // If the namespace prefix DOES exist, then it makes sense to say we couldn't find the origin. - if len(originAds) == 0 { - ginCtx.String(http.StatusNotFound, "There are currently no origins exporting the provided namespace prefix\n") - return - } - // if err != nil, depth == 0, which is the default value for depth - // so we can use it as the value for the header even with err - depth, err := getLinkDepth(reqPath, namespaceAd.Path) - if err != nil { - log.Errorf("Failed to get depth attribute for the redirecting request to %q, with best match namespace prefix %q", reqPath, namespaceAd.Path) - } - - originAds, err = sortServers(ipAddr, originAds) - if err != nil { - log.Error("Error determining server ordering for originAds: ", err) - ginCtx.String(http.StatusInternalServerError, "Failed to determine origin ordering") - return - } - - linkHeader := "" - first := true - for idx, ad := range originAds { - if first { - first = false - } else { - linkHeader += ", " - } - redirectURL := getRedirectURL(reqPath, ad, !namespaceAd.PublicRead) - linkHeader += fmt.Sprintf(`<%s>; rel="duplicate"; pri=%d; depth=%d`, redirectURL.String(), idx+1, depth) - } - ginCtx.Writer.Header()["Link"] = []string{linkHeader} - - var colUrl string - if namespaceAd.PublicRead { - colUrl = originAds[0].URL.String() - } else { - colUrl = originAds[0].AuthURL.String() - } - ginCtx.Writer.Header()["X-Pelican-Namespace"] = []string{fmt.Sprintf("namespace=%s, require-token=%v, collections-url=%s", - namespaceAd.Path, !namespaceAd.PublicRead, colUrl)} - - var redirectURL url.URL - // If we are doing a PUT, check to see if any origins are writeable - if ginCtx.Request.Method == "PUT" { - for idx, ad := range originAds { - if ad.Writes { - redirectURL = getRedirectURL(reqPath, originAds[idx], !namespaceAd.PublicRead) - if brokerUrl := originAds[idx].BrokerURL; brokerUrl.String() != "" { - ginCtx.Header("X-Pelican-Broker", brokerUrl.String()) - } - ginCtx.Redirect(http.StatusTemporaryRedirect, getFinalRedirectURL(redirectURL, authzBearerEscaped)) - return - } - } - ginCtx.String(http.StatusMethodNotAllowed, "No origins on specified endpoint are writeable\n") - return - } else { // Otherwise, we are doing a GET - redirectURL := getRedirectURL(reqPath, originAds[0], !namespaceAd.PublicRead) - if brokerUrl := originAds[0].BrokerURL; brokerUrl.String() != "" { - ginCtx.Header("X-Pelican-Broker", brokerUrl.String()) - } - - // See note in RedirectToCache as to why we only add the authz query parameter to this URL, - // not those in the `Link`. - ginCtx.Redirect(http.StatusTemporaryRedirect, getFinalRedirectURL(redirectURL, authzBearerEscaped)) - } -} - -func checkHostnameRedirects(c *gin.Context, incomingHost string) { - oRedirectHosts := param.Director_OriginResponseHostnames.GetStringSlice() - cRedirectHosts := param.Director_CacheResponseHostnames.GetStringSlice() - - for _, hostname := range oRedirectHosts { - if hostname == incomingHost { - if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { - c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path - redirectToOrigin(c) - c.Abort() - log.Debugln("Director is serving an origin based on incoming 'Host' header value of '" + hostname + "'") - return - } - } - } - for _, hostname := range cRedirectHosts { - if hostname == incomingHost { - if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { - c.Request.URL.Path = "/api/v1.0/director/object" + c.Request.URL.Path - redirectToCache(c) - c.Abort() - log.Debugln("Director is serving a cache based on incoming 'Host' header value of '" + hostname + "'") - return - } - } - } -} - -// Middleware sends GET /foo/bar to the RedirectToCache function, as if the -// original request had been made to /api/v1.0/director/object/foo/bar -func ShortcutMiddleware(defaultResponse string) gin.HandlerFunc { - return func(c *gin.Context) { - // If this is a request for getting public key, don't modify the path - // If this is a request to the Prometheus API, don't modify the path - if strings.HasPrefix(c.Request.URL.Path, "/.well-known/") || - (strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/") && !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/")) { - c.Next() - return - } - // Regardless of the remainder of the settings, we currently handle a PUT as a query to the origin endpoint - if c.Request.Method == "PUT" { - c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path - redirectToOrigin(c) - c.Abort() - return - } - - // We grab the host and x-forwarded-host headers, which can be set by a client with the intent of changing the - // Director's default behavior (eg the director normally forwards to caches, but if it receives a request with - // a pre-configured hostname in its x-forwarded-host header, that indicates we should actually serve an origin.) - host, hostPresent := c.Request.Header["Host"] - xForwardedHost, xForwardedHostPresent := c.Request.Header["X-Forwarded-Host"] - - if hostPresent { - checkHostnameRedirects(c, host[0]) - } else if xForwardedHostPresent { - checkHostnameRedirects(c, xForwardedHost[0]) - } - - // If we're configured for cache mode or we haven't set the flag, - // we should use cache middleware - if defaultResponse == "cache" { - if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { - c.Request.URL.Path = "/api/v1.0/director/object" + c.Request.URL.Path - redirectToCache(c) - c.Abort() - return - } - - // If the path starts with the correct prefix, continue with the next handler - c.Next() - } else if defaultResponse == "origin" { - if !strings.HasPrefix(c.Request.URL.Path, "/api/v1.0/director/") { - c.Request.URL.Path = "/api/v1.0/director/origin" + c.Request.URL.Path - redirectToOrigin(c) - c.Abort() - return - } - c.Next() - } - } -} - -func registerServeAd(engineCtx context.Context, ctx *gin.Context, sType common.ServerType) { - tokens, present := ctx.Request.Header["Authorization"] - if !present || len(tokens) == 0 { - ctx.JSON(http.StatusForbidden, gin.H{"error": "Bearer token not present in the 'Authorization' header"}) - return - } - - err := versionCompatCheck(ctx) - if err != nil { - log.Warningf("A version incompatibility was encountered while registering %s and no response was served: %v", sType, err) - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Incompatible versions detected: " + fmt.Sprintf("%v", err)}) - return - } - - ad := common.OriginAdvertiseV1{} - adV2 := common.OriginAdvertiseV2{} - err = ctx.ShouldBindBodyWith(&ad, binding.JSON) - if err != nil { - // Failed binding to a V1 type, so should now check to see if it's a V2 type - adV2 = common.OriginAdvertiseV2{} - err = ctx.ShouldBindBodyWith(&adV2, binding.JSON) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + sType + " registration"}) - return - } - } else { - // If the OriginAdvertisement is a V1 type, convert to a V2 type - adV2 = convertOriginAd(ad) - } - - if sType == common.OriginType { - for _, namespace := range adV2.Namespaces { - // We're assuming there's only one token in the slice - token := strings.TrimPrefix(tokens[0], "Bearer ") - ok, err := VerifyAdvertiseToken(engineCtx, token, namespace.Path) - if err != nil { - if err == adminApprovalErr { - log.Warningf("Failed to verify advertise token. Namespace %q requires administrator approval", namespace.Path) - ctx.JSON(http.StatusForbidden, gin.H{"approval_error": true, "error": fmt.Sprintf("The namespace %q was not approved by an administrator", namespace.Path)}) - return - } else { - log.Warningln("Failed to verify token:", err) - ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed"}) - return - } - } - if !ok { - log.Warningf("%s %v advertised to namespace %v without valid token scope\n", - sType, adV2.Name, namespace.Path) - ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed. Token missing required scope"}) - return - } - } - } else { - token := strings.TrimPrefix(tokens[0], "Bearer ") - prefix := path.Join("/caches", adV2.Name) - ok, err := VerifyAdvertiseToken(engineCtx, token, prefix) - if err != nil { - if err == adminApprovalErr { - log.Warningf("Failed to verify token. Cache %q was not approved", adV2.Name) - ctx.JSON(http.StatusForbidden, gin.H{"approval_error": true, "error": fmt.Sprintf("Cache %q was not approved by an administrator", ad.Name)}) - return - } else { - log.Warningln("Failed to verify token:", err) - ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed."}) - return - } - } - if !ok { - log.Warningf("%s %v advertised without valid token scope\n", sType, adV2.Name) - ctx.JSON(http.StatusForbidden, gin.H{"error": "Authorization token verification failed. Token missing required scope"}) - return - } - } - - ad_url, err := url.Parse(adV2.DataURL) - if err != nil { - log.Warningf("Failed to parse %s URL %v: %v\n", sType, adV2.DataURL, err) - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + sType + " URL"}) - return - } - - adWebUrl, err := url.Parse(adV2.WebURL) - if err != nil && adV2.WebURL != "" { // We allow empty WebURL string for backward compatibility - log.Warningf("Failed to parse server Web URL %v: %v\n", adV2.WebURL, err) - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server Web URL"}) - return - } - - brokerUrl, err := url.Parse(adV2.BrokerURL) - if err != nil { - log.Warningf("Failed to parse broker URL %s: %s", adV2.BrokerURL, err) - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid broker URL"}) - } - - sAd := common.ServerAd{ - Name: adV2.Name, - AuthURL: *ad_url, - URL: *ad_url, - WebURL: *adWebUrl, - BrokerURL: *brokerUrl, - Type: sType, - Writes: adV2.Caps.Writes, - DirectReads: adV2.Caps.DirectReads, - } - - recordAd(sAd, &adV2.Namespaces) - - // Start director periodic test of origin's health status if origin AD - // has WebURL field AND it's not already been registered - healthTestUtilsMutex.Lock() - defer healthTestUtilsMutex.Unlock() - if sAd.Type == common.OriginType && adV2.WebURL != "" { - if existingUtil, ok := healthTestUtils[sAd]; ok { - // Existing registration - if existingUtil != nil { - if existingUtil.ErrGrp != nil { - if existingUtil.ErrGrpContext.Err() != nil { - // ErrGroup has been Done. Start a new one - errgrp, errgrpCtx := errgroup.WithContext(engineCtx) - cancelCtx, cancel := context.WithCancel(errgrpCtx) - - errgrp.SetLimit(1) - healthTestUtils[sAd] = &healthTestUtil{ - Cancel: cancel, - ErrGrp: errgrp, - ErrGrpContext: errgrpCtx, - Status: HealthStatusInit, - } - errgrp.Go(func() error { - LaunchPeriodicDirectorTest(cancelCtx, sAd) - return nil - }) - log.Debugf("New director test suite issued for %s %s. Errgroup was evicted", string(sType), sAd.URL.String()) - } else { - cancelCtx, cancel := context.WithCancel(existingUtil.ErrGrpContext) - started := existingUtil.ErrGrp.TryGo(func() error { - LaunchPeriodicDirectorTest(cancelCtx, sAd) - return nil - }) - if !started { - cancel() - log.Debugf("New director test suite blocked for %s %s, existing test has been running", string(sType), sAd.URL.String()) - } else { - log.Debugf("New director test suite issued for %s %s. Existing registration", string(sType), sAd.URL.String()) - existingUtil.Cancel() - existingUtil.Cancel = cancel - } - } - } else { - log.Errorf("%s %s registration didn't start a new director test cycle: errgroup is nil", string(sType), &sAd.URL) - } - } else { - log.Errorf("%s %s registration didn't start a new director test cycle: healthTestUtils item is nil", string(sType), &sAd.URL) - } - } else { // No healthTestUtils found, new registration - errgrp, errgrpCtx := errgroup.WithContext(engineCtx) - cancelCtx, cancel := context.WithCancel(errgrpCtx) - - errgrp.SetLimit(1) - healthTestUtils[sAd] = &healthTestUtil{ - Cancel: cancel, - ErrGrp: errgrp, - ErrGrpContext: errgrpCtx, - Status: HealthStatusUnknown, - } - errgrp.Go(func() error { - LaunchPeriodicDirectorTest(cancelCtx, sAd) - return nil - }) - } - } - - if sType == common.OriginType { - originStatUtilsMutex.Lock() - defer originStatUtilsMutex.Unlock() - statUtil, ok := originStatUtils[sAd.URL] - if !ok || statUtil.Errgroup == nil { - baseCtx, cancel := context.WithCancel(engineCtx) - concLimit := param.Director_StatConcurrencyLimit.GetInt() - statErrGrp := errgroup.Group{} - statErrGrp.SetLimit(concLimit) - newUtil := originStatUtil{ - Errgroup: &statErrGrp, - Cancel: cancel, - Context: baseCtx, - } - originStatUtils[sAd.URL] = newUtil - } - } - - ctx.JSON(http.StatusOK, gin.H{"msg": "Successful registration"}) -} - -// Return a list of registered origins and caches in Prometheus HTTP SD format -// for director's Prometheus service discovery -func discoverOriginCache(ctx *gin.Context) { - authOption := token.AuthOption{ - Sources: []token.TokenSource{token.Header}, - Issuers: []token.TokenIssuer{token.LocalIssuer}, - Scopes: []token_scopes.TokenScope{token_scopes.Pelican_DirectorServiceDiscovery}, - } - - status, ok, err := token.Verify(ctx, authOption) - if !ok { - log.Warningf("Cannot verify token for accessing director's service discovery: %v", err) - ctx.JSON(status, gin.H{"error": err.Error()}) - return - } - - serverAdMutex.RLock() - defer serverAdMutex.RUnlock() - serverAds := serverAds.Keys() - promDiscoveryRes := make([]PromDiscoveryItem, 0) - for _, ad := range serverAds { - if ad.WebURL.String() == "" { - // Origins and caches fetched from topology can't be scraped as they - // don't have a WebURL - continue - } - promDiscoveryRes = append(promDiscoveryRes, PromDiscoveryItem{ - Targets: []string{ad.WebURL.Hostname() + ":" + ad.WebURL.Port()}, - Labels: map[string]string{ - "server_type": string(ad.Type), - "server_name": ad.Name, - "server_auth_url": ad.AuthURL.String(), - "server_url": ad.URL.String(), - "server_web_url": ad.WebURL.String(), - "server_lat": fmt.Sprintf("%.4f", ad.Latitude), - "server_long": fmt.Sprintf("%.4f", ad.Longitude), - }, - }) - } - ctx.JSON(200, promDiscoveryRes) -} - -func registerOrigin(ctx context.Context, gctx *gin.Context) { - registerServeAd(ctx, gctx, common.OriginType) -} - -func registerCache(ctx context.Context, gctx *gin.Context) { - registerServeAd(ctx, gctx, common.CacheType) -} - -func listNamespacesV1(ctx *gin.Context) { - namespaceAdsV2 := listNamespacesFromOrigins() - - namespaceAdsV1 := convertNamespaceAdsV2ToV1(namespaceAdsV2) - - ctx.JSON(http.StatusOK, namespaceAdsV1) -} - -func listNamespacesV2(ctx *gin.Context) { - namespacesAdsV2 := listNamespacesFromOrigins() - ctx.JSON(http.StatusOK, namespacesAdsV2) -} - -func getPrefixByPath(ctx *gin.Context) { - pathParam := ctx.Param("path") - if pathParam == "" || pathParam == "/" { - ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Bad request. Path is empty or '/' "}) - return - } - namespaceKeysMutex.Lock() - defer namespaceKeysMutex.Unlock() - - originNs, _, _ := getAdsForPath(pathParam) - - // If originNs.Path is an empty value, then the namespace is not found - if originNs.Path == "" { - ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "Namespace prefix not found for " + pathParam}) - return - } - - res := common.GetPrefixByPathRes{Prefix: originNs.Path} - ctx.JSON(http.StatusOK, res) -} - -func RegisterDirector(ctx context.Context, router *gin.RouterGroup) { - directorAPIV1 := router.Group("/api/v1.0/director") - { - // Establish the routes used for cache/origin redirection - directorAPIV1.GET("/object/*any", redirectToCache) - directorAPIV1.GET("/origin/*any", redirectToOrigin) - directorAPIV1.PUT("/origin/*any", redirectToOrigin) - directorAPIV1.POST("/registerOrigin", func(gctx *gin.Context) { registerOrigin(ctx, gctx) }) - directorAPIV1.POST("/registerCache", func(gctx *gin.Context) { registerCache(ctx, gctx) }) - directorAPIV1.GET("/listNamespaces", listNamespacesV1) - directorAPIV1.GET("/namespaces/prefix/*path", getPrefixByPath) - - // In the foreseeable feature, director will scrape all servers in Pelican ecosystem (including registry) - // so that director can be our point of contact for collecting system-level metrics. - // Rename the endpoint to reflect such plan. - directorAPIV1.GET("/discoverServers", discoverOriginCache) - } - - directorAPIV2 := router.Group("/api/v2.0/director") - { - directorAPIV2.GET("/listNamespaces", listNamespacesV2) - } -} diff --git a/director/redirect_test.go b/director/redirect_test.go deleted file mode 100644 index aaf4d8d83..000000000 --- a/director/redirect_test.go +++ /dev/null @@ -1,1027 +0,0 @@ -/*************************************************************** - * - * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research - * - * 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. - * - ***************************************************************/ - -package director - -import ( - "bytes" - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/jellydator/ttlcache/v3" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/pelicanplatform/pelican/common" - "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/test_utils" - "github.com/pelicanplatform/pelican/token" - "github.com/pelicanplatform/pelican/token_scopes" -) - -type MockCache struct { - GetFn func(u string, kset *jwk.Set) (jwk.Set, error) - RegisterFn func(*MockCache) error - - keyset jwk.Set -} - -func (m *MockCache) Get(ctx context.Context, u string) (jwk.Set, error) { - return m.GetFn(u, &m.keyset) -} - -func (m *MockCache) Register(u string, options ...jwk.RegisterOption) error { - m.keyset = jwk.NewSet() - return m.RegisterFn(m) -} - -func NamespaceAdContainsPath(ns []common.NamespaceAdV2, path string) bool { - for _, v := range ns { - if v.Path == path { - return true - } - } - return false -} - -func TestGetLinkDepth(t *testing.T) { - tests := []struct { - name string - filepath string - prefix string - err error - depth int - }{ - { - name: "empty-file-prefix", - err: errors.New("either filepath or prefix is an empty path"), - }, { - name: "empty-file", - err: errors.New("either filepath or prefix is an empty path"), - }, { - name: "empty-prefix", - err: errors.New("either filepath or prefix is an empty path"), - }, { - name: "no-match", - filepath: "/foo/bar/barz.txt", - prefix: "/bar", - err: errors.New("filepath does not contain the prefix"), - }, { - name: "depth-1-case", - filepath: "/foo/bar/barz.txt", - prefix: "/foo/bar", - depth: 1, - }, { - name: "depth-1-w-trailing-slash", - filepath: "/foo/bar/barz.txt", - prefix: "/foo/bar/", - depth: 1, - }, { - name: "depth-2-case", - filepath: "/foo/bar/barz.txt", - prefix: "/foo", - depth: 2, - }, - { - name: "depth-2-w-trailing-slash", - filepath: "/foo/bar/barz.txt", - prefix: "/foo/", - depth: 2, - }, - { - name: "depth-3-case", - filepath: "/foo/bar/barz.txt", - prefix: "/", - depth: 3, - }, - { - name: "short-path", - filepath: "/foo/barz.txt", - prefix: "/foo", - depth: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - depth, err := getLinkDepth(tt.filepath, tt.prefix) - if tt.err == nil { - require.NoError(t, err) - } else { - require.Error(t, err) - assert.Equal(t, tt.err.Error(), err.Error()) - } - assert.Equal(t, tt.depth, depth) - }) - } -} - -func TestDirectorRegistration(t *testing.T) { - /* - * Tests the RegisterOrigin endpoint. Specifically it creates a keypair and - * corresponding token and invokes the registration endpoint, it then does - * so again with an invalid token and confirms that the correct error is returned - */ - ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) - defer func() { require.NoError(t, egrp.Wait()) }() - defer cancel() - - viper.Reset() - - // Mock registry server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method == "POST" && req.URL.Path == "/api/v1.0/registry/checkNamespaceStatus" { - res := common.CheckNamespaceStatusRes{Approved: true} - resByte, err := json.Marshal(res) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - _, err = w.Write(resByte) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusNotFound) - } - })) - defer ts.Close() - - viper.Set("Federation.RegistryUrl", ts.URL) - - setupContext := func() (*gin.Context, *gin.Engine, *httptest.ResponseRecorder) { - // Setup httptest recorder and context for the the unit test - w := httptest.NewRecorder() - c, r := gin.CreateTestContext(w) - return c, r, w - } - - generateToken := func() (jwk.Key, string, url.URL) { - // Create a private key to use for the test - privateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) - assert.NoError(t, err, "Error generating private key") - - // Convert from raw ecdsa to jwk.Key - pKey, err := jwk.FromRaw(privateKey) - assert.NoError(t, err, "Unable to convert ecdsa.PrivateKey to jwk.Key") - - //Assign Key id to the private key - err = jwk.AssignKeyID(pKey) - assert.NoError(t, err, "Error assigning kid to private key") - - //Set an algorithm for the key - err = pKey.Set(jwk.AlgorithmKey, jwa.ES256) - assert.NoError(t, err, "Unable to set algorithm for pKey") - - issuerURL := url.URL{ - Scheme: "https", - Path: ts.URL, - } - - // Create a token to be inserted - tok, err := jwt.NewBuilder(). - Issuer(issuerURL.String()). - Claim("scope", token_scopes.Pelican_Advertise.String()). - Audience([]string{"director.test"}). - Subject("origin"). - Build() - assert.NoError(t, err, "Error creating token") - - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, pKey)) - assert.NoError(t, err, "Error signing token") - - return pKey, string(signed), issuerURL - } - - generateReadToken := func(key jwk.Key, object, issuer string) string { - tc := token.NewWLCGToken() - tc.Lifetime = time.Minute - tc.Issuer = issuer - tc.AddAudiences("director") - tc.Subject = "test" - tc.Claims = map[string]string{"scope": "storage.read:" + object} - tok, err := tc.CreateTokenWithKey(key) - require.NoError(t, err) - return tok - } - - setupRequest := func(c *gin.Context, r *gin.Engine, bodyByt []byte, token string) { - r.POST("/", func(gctx *gin.Context) { registerOrigin(ctx, gctx) }) - c.Request, _ = http.NewRequest(http.MethodPost, "/", bytes.NewBuffer(bodyByt)) - c.Request.Header.Set("Authorization", "Bearer "+token) - c.Request.Header.Set("Content-Type", "application/json") - // Hard code the current min version. When this test starts failing because of new stuff in the Director, - // we'll know that means it's time to update the min version in redirect.go - c.Request.Header.Set("User-Agent", "pelican-origin/7.0.0") - } - - // Configure the request context and Gin router to generate a redirect - setupRedirect := func(c *gin.Context, r *gin.Engine, object, token string) { - r.GET("/api/v1.0/director/origin/*any", redirectToOrigin) - c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1.0/director/origin"+object, nil) - c.Request.Header.Set("X-Real-Ip", "1.1.1.1") - c.Request.Header.Set("Authorization", "Bearer "+token) - c.Request.Header.Set("User-Agent", "pelican-origin/7.0.0") - } - - // Inject into the cache, using a mock cache to avoid dealing with - // real namespaces - setupMockCache := func(t *testing.T, publicKey jwk.Key) MockCache { - return MockCache{ - GetFn: func(key string, keyset *jwk.Set) (jwk.Set, error) { - expectedKey := ts.URL + "/api/v1.0/registry/foo/bar/.well-known/issuer.jwks" - if key != expectedKey { - t.Errorf("expecting: %q, got %q", expectedKey, key) - } - return *keyset, nil - }, - RegisterFn: func(m *MockCache) error { - err := jwk.Set.AddKey(m.keyset, publicKey) - if err != nil { - t.Error(err) - } - return nil - }, - } - } - - // Perform injections (ar.Register will create a jwk.keyset with the publickey in it) - useMockCache := func(ar MockCache, issuerURL url.URL) { - if err := ar.Register(issuerURL.String(), jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { - t.Errorf("this should never happen, should actually be impossible, including check for the linter") - } - namespaceKeysMutex.Lock() - defer namespaceKeysMutex.Unlock() - namespaceKeys.Set("/foo/bar", &ar, ttlcache.DefaultTTL) - } - - teardown := func() { - serverAdMutex.Lock() - defer serverAdMutex.Unlock() - serverAds.DeleteAll() - } - - t.Run("valid-token-V1", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV1{Name: "test", URL: "https://or-url.org", Namespaces: []common.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - // Check to see that the code exits with status code 200 after given it a good token - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - - namaspaceADs := listNamespacesFromOrigins() - // If the origin was successfully registed at director, we should be able to find it in director's originAds - assert.True(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Coudln't find namespace in the director cache.") - teardown() - }) - - t.Run("valid-token-V2", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV2{ - BrokerURL: "https://broker-url.org", - DataURL: "https://or-url.org", - Name: "test", - Namespaces: []common.NamespaceAdV2{{ - Path: "/foo/bar", - Issuer: []common.TokenIssuer{{IssuerUrl: isurl}}, - }}, - } - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - // Check to see that the code exits with status code 200 after given it a good token - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - - namaspaceADs := listNamespacesFromOrigins() - // If the origin was successfully registed at director, we should be able to find it in director's originAds - assert.True(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Coudln't find namespace in the director cache.") - teardown() - }) - - // Now repeat the above test, but with an invalid token - t.Run("invalid-token-V1", func(t *testing.T) { - c, r, w := setupContext() - wrongPrivateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) - assert.NoError(t, err, "Error creating another private key") - _, token, issuerURL := generateToken() - - wrongPublicKey, err := jwk.PublicKeyOf(wrongPrivateKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, wrongPublicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV1{Name: "test", URL: "https://or-url.org", Namespaces: []common.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, http.StatusForbidden, w.Result().StatusCode, "Expected failing status code of 403") - body, _ := io.ReadAll(w.Result().Body) - assert.Equal(t, `{"error":"Authorization token verification failed"}`, string(body), "Failure wasn't because token verification failed") - - namaspaceADs := listNamespacesFromOrigins() - assert.False(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Found namespace in the director cache even if the token validation failed.") - teardown() - }) - - t.Run("invalid-token-V2", func(t *testing.T) { - c, r, w := setupContext() - wrongPrivateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) - assert.NoError(t, err, "Error creating another private key") - _, token, issuerURL := generateToken() - - wrongPublicKey, err := jwk.PublicKeyOf(wrongPrivateKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, wrongPublicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV2{Name: "test", DataURL: "https://or-url.org", Namespaces: []common.NamespaceAdV2{{ - Path: "/foo/bar", - Issuer: []common.TokenIssuer{{IssuerUrl: isurl}}, - }}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, http.StatusForbidden, w.Result().StatusCode, "Expected failing status code of 403") - body, _ := io.ReadAll(w.Result().Body) - assert.Equal(t, `{"error":"Authorization token verification failed"}`, string(body), "Failure wasn't because token verification failed") - - namaspaceADs := listNamespacesFromOrigins() - assert.False(t, NamespaceAdContainsPath(namaspaceADs, "/foo/bar"), "Found namespace in the director cache even if the token validation failed.") - teardown() - }) - - t.Run("valid-token-with-web-url-V1", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV1{URL: "https://or-url.org", WebURL: "https://localhost:8844", Namespaces: []common.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") - assert.Equal(t, "https://localhost:8844", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds does not match data in origin registration request") - teardown() - }) - - t.Run("valid-token-with-web-url-V2", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV2{DataURL: "https://or-url.org", WebURL: "https://localhost:8844", Namespaces: []common.NamespaceAdV2{{ - Path: "/foo/bar", - Issuer: []common.TokenIssuer{{IssuerUrl: isurl}}, - }}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") - assert.Equal(t, "https://localhost:8844", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds does not match data in origin registration request") - teardown() - }) - - // We want to ensure backwards compatibility for WebURL - t.Run("valid-token-without-web-url-V1", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV1{URL: "https://or-url.org", Namespaces: []common.NamespaceAdV1{{Path: "/foo/bar", Issuer: isurl}}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") - assert.Equal(t, "", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds isn't empty with no WebURL provided in registration") - teardown() - }) - - t.Run("valid-token-without-web-url-V2", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - ad := common.OriginAdvertiseV2{DataURL: "https://or-url.org", Namespaces: []common.NamespaceAdV2{{Path: "/foo/bar", - Issuer: []common.TokenIssuer{{IssuerUrl: isurl}}}}} - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - assert.Equal(t, 1, len(serverAds.Keys()), "Origin fail to register at serverAds") - assert.Equal(t, "", serverAds.Keys()[0].WebURL.String(), "WebURL in serverAds isn't empty with no WebURL provided in registration") - teardown() - }) - - // Determines if the broker URL set in the advertisement is the same one received on redirect - t.Run("broker-url-redirect", func(t *testing.T) { - c, r, w := setupContext() - pKey, token, issuerURL := generateToken() - publicKey, err := jwk.PublicKeyOf(pKey) - assert.NoError(t, err, "Error creating public key from private key") - - ar := setupMockCache(t, publicKey) - useMockCache(ar, issuerURL) - - isurl := url.URL{} - isurl.Path = ts.URL - - brokerUrl := "https://broker-url.org/some/path?origin=foo" - - ad := common.OriginAdvertiseV2{ - DataURL: "https://or-url.org", - BrokerURL: brokerUrl, - Name: "test", - Namespaces: []common.NamespaceAdV2{{ - Path: "/foo/bar", - Issuer: []common.TokenIssuer{{IssuerUrl: isurl}}, - }}, - } - - jsonad, err := json.Marshal(ad) - assert.NoError(t, err, "Error marshalling OriginAdvertise") - - setupRequest(c, r, jsonad, token) - - r.ServeHTTP(w, c.Request) - - // Check to see that the code exits with status code 200 after given it a good token - require.Equal(t, 200, w.Result().StatusCode, "Expected status code of 200") - - c, r, w = setupContext() - token = generateReadToken(pKey, "/foo/bar", isurl.String()) - setupRedirect(c, r, "/foo/bar/baz", token) - - r.ServeHTTP(w, c.Request) - - assert.Equal(t, http.StatusTemporaryRedirect, w.Result().StatusCode) - if w.Result().StatusCode != http.StatusTemporaryRedirect { - body, err := io.ReadAll(w.Result().Body) - assert.NoError(t, err) - assert.Fail(t, "Error when generating redirect: "+string(body)) - } - assert.Equal(t, brokerUrl, w.Result().Header.Get("X-Pelican-Broker")) - }) - -} - -func TestGetAuthzEscaped(t *testing.T) { - // Test passing a token via header with no bearer prefix - req, err := http.NewRequest(http.MethodPost, "http://fake-server.com", bytes.NewBuffer([]byte("a body"))) - assert.NoError(t, err) - req.Header.Set("Authorization", "tokenstring") - escapedToken := getAuthzEscaped(req) - assert.Equal(t, escapedToken, "tokenstring") - - // Test passing a token via query with no bearer prefix - req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=tokenstring", bytes.NewBuffer([]byte("a body"))) - assert.NoError(t, err) - escapedToken = getAuthzEscaped(req) - assert.Equal(t, escapedToken, "tokenstring") - - // Test passing the token via header with Bearer prefix - req, err = http.NewRequest(http.MethodPost, "http://fake-server.com", bytes.NewBuffer([]byte("a body"))) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer tokenstring") - escapedToken = getAuthzEscaped(req) - assert.Equal(t, escapedToken, "tokenstring") - - // Test passing the token via URL with Bearer prefix and + encoded space - req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=Bearer+tokenstring", bytes.NewBuffer([]byte("a body"))) - assert.NoError(t, err) - escapedToken = getAuthzEscaped(req) - assert.Equal(t, escapedToken, "tokenstring") - - // Finally, the same test as before, but test with %20 encoded space - req, err = http.NewRequest(http.MethodPost, "http://fake-server.com/foo?authz=Bearer%20tokenstring", bytes.NewBuffer([]byte("a body"))) - assert.NoError(t, err) - escapedToken = getAuthzEscaped(req) - assert.Equal(t, escapedToken, "tokenstring") -} - -func TestDiscoverOriginCache(t *testing.T) { - mockPelicanOriginServerAd := common.ServerAd{ - Name: "1-test-origin-server", - AuthURL: url.URL{}, - URL: url.URL{ - Scheme: "https", - Host: "fake-origin.org:8443", - }, - WebURL: url.URL{ - Scheme: "https", - Host: "fake-origin.org:8444", - }, - Type: common.OriginType, - Latitude: 123.05, - Longitude: 456.78, - } - - mockTopoOriginServerAd := common.ServerAd{ - Name: "test-topology-origin-server", - AuthURL: url.URL{}, - URL: url.URL{ - Scheme: "https", - Host: "fake-topology-origin.org:8443", - }, - Type: common.OriginType, - Latitude: 123.05, - Longitude: 456.78, - } - - mockCacheServerAd := common.ServerAd{ - Name: "2-test-cache-server", - AuthURL: url.URL{}, - URL: url.URL{ - Scheme: "https", - Host: "fake-cache.org:8443", - }, - WebURL: url.URL{ - Scheme: "https", - Host: "fake-cache.org:8444", - }, - Type: common.CacheType, - Latitude: 45.67, - Longitude: 123.05, - } - - mockNamespaceAd := common.NamespaceAdV2{ - PublicRead: false, - Path: "/foo/bar/", - Issuer: []common.TokenIssuer{{ - BasePaths: []string{""}, - IssuerUrl: url.URL{}, - }}, - } - - mockDirectorUrl := "https://fake-director.org:8888" - - ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) - defer func() { require.NoError(t, egrp.Wait()) }() - defer cancel() - - viper.Reset() - // Direcor SD will only be used for director's Prometheus scraper to get available origins, - // so the token issuer is issentially the director server itself - // There's no need to rely on Federation.DirectorUrl as token issuer in this case - viper.Set("Server.ExternalWebUrl", mockDirectorUrl) - - tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") - viper.Set("IssuerKey", kfile) - - config.InitConfig() - err := config.InitServer(ctx, config.DirectorType) - require.NoError(t, err) - - // Generate a private key to use for the test - _, err = config.GetIssuerPublicJWKS() - assert.NoError(t, err, "Error generating private key") - // Get private key - privateKey, err := config.GetIssuerPrivateJWK() - assert.NoError(t, err, "Error loading private key") - - // Batch set up different tokens - setupToken := func(wrongIssuer string) []byte { - issuerURL, err := url.Parse(mockDirectorUrl) - assert.NoError(t, err, "Error parsing director's URL") - tokenIssuerString := "" - if wrongIssuer != "" { - tokenIssuerString = wrongIssuer - } else { - tokenIssuerString = issuerURL.String() - } - - tok, err := jwt.NewBuilder(). - Issuer(tokenIssuerString). - Claim("scope", token_scopes.Pelican_DirectorServiceDiscovery). - Audience([]string{"director.test"}). - Subject("director"). - Expiration(time.Now().Add(time.Hour)). - Build() - assert.NoError(t, err, "Error creating token") - - err = jwk.AssignKeyID(privateKey) - assert.NoError(t, err, "Error assigning key id") - - // Sign token with previously created private key - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, privateKey)) - assert.NoError(t, err, "Error signing token") - return signed - } - - areSlicesEqualIgnoreOrder := func(slice1, slice2 []PromDiscoveryItem) bool { - if len(slice1) != len(slice2) { - return false - } - - counts := make(map[string]int) - - for _, item := range slice1 { - bytes, err := json.Marshal(item) - require.NoError(t, err) - counts[string(bytes)]++ - } - - for _, item := range slice2 { - bytes, err := json.Marshal(item) - require.NoError(t, err) - counts[string(bytes)]-- - if counts[string(bytes)] < 0 { - return false - } - } - - return true - } - - r := gin.Default() - r.GET("/test", discoverOriginCache) - - t.Run("no-token-should-give-401", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/test", nil) - if err != nil { - t.Fatalf("Could not make a GET request: %v", err) - } - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, 403, w.Code) - assert.Equal(t, `{"error":"Authentication is required but no token is present."}`, w.Body.String()) - }) - t.Run("token-present-with-wrong-issuer-should-give-403", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/test", nil) - if err != nil { - t.Fatalf("Could not make a GET request: %v", err) - } - - req.Header.Set("Authorization", "Bearer "+string(setupToken("https://wrong-issuer.org"))) - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, 403, w.Code) - assert.Equal(t, `{"error":"Cannot verify token: Cannot verify token with server issuer: Token issuer https://wrong-issuer.org does not match the local issuer on the current server. Expecting https://fake-director.org:8888\n"}`, w.Body.String()) - }) - t.Run("token-present-valid-should-give-200-and-empty-array", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/test", nil) - if err != nil { - t.Fatalf("Could not make a GET request: %v", err) - } - - req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, 200, w.Code) - assert.Equal(t, `[]`, w.Body.String()) - }) - t.Run("response-should-match-serverAds", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/test", nil) - if err != nil { - t.Fatalf("Could not make a GET request: %v", err) - } - - func() { - serverAdMutex.Lock() - defer serverAdMutex.Unlock() - serverAds.DeleteAll() - serverAds.Set(mockPelicanOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - // Server fetched from topology should not be present in SD response - serverAds.Set(mockTopoOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - serverAds.Set(mockCacheServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - }() - - expectedRes := []PromDiscoveryItem{{ - Targets: []string{mockCacheServerAd.WebURL.Hostname() + ":" + mockCacheServerAd.WebURL.Port()}, - Labels: map[string]string{ - "server_type": string(mockCacheServerAd.Type), - "server_name": mockCacheServerAd.Name, - "server_auth_url": mockCacheServerAd.AuthURL.String(), - "server_url": mockCacheServerAd.URL.String(), - "server_web_url": mockCacheServerAd.WebURL.String(), - "server_lat": fmt.Sprintf("%.4f", mockCacheServerAd.Latitude), - "server_long": fmt.Sprintf("%.4f", mockCacheServerAd.Longitude), - }, - }, { - Targets: []string{mockPelicanOriginServerAd.WebURL.Hostname() + ":" + mockPelicanOriginServerAd.WebURL.Port()}, - Labels: map[string]string{ - "server_type": string(mockPelicanOriginServerAd.Type), - "server_name": mockPelicanOriginServerAd.Name, - "server_auth_url": mockPelicanOriginServerAd.AuthURL.String(), - "server_url": mockPelicanOriginServerAd.URL.String(), - "server_web_url": mockPelicanOriginServerAd.WebURL.String(), - "server_lat": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Latitude), - "server_long": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Longitude), - }, - }} - - req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, 200, w.Code) - - var resMarshalled []PromDiscoveryItem - err = json.Unmarshal(w.Body.Bytes(), &resMarshalled) - require.NoError(t, err, "Error unmarshall response to json") - - assert.True(t, areSlicesEqualIgnoreOrder(expectedRes, resMarshalled)) - }) - - t.Run("no-duplicated-origins", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/test", nil) - if err != nil { - t.Fatalf("Could not make a GET request: %v", err) - } - - func() { - serverAdMutex.Lock() - defer serverAdMutex.Unlock() - serverAds.DeleteAll() - // Add multiple same serverAds - serverAds.Set(mockPelicanOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - serverAds.Set(mockPelicanOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - serverAds.Set(mockPelicanOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - // Server fetched from topology should not be present in SD response - serverAds.Set(mockTopoOriginServerAd, []common.NamespaceAdV2{mockNamespaceAd}, ttlcache.DefaultTTL) - }() - - expectedRes := []PromDiscoveryItem{{ - Targets: []string{mockPelicanOriginServerAd.WebURL.Hostname() + ":" + mockPelicanOriginServerAd.WebURL.Port()}, - Labels: map[string]string{ - "server_type": string(mockPelicanOriginServerAd.Type), - "server_name": mockPelicanOriginServerAd.Name, - "server_auth_url": mockPelicanOriginServerAd.AuthURL.String(), - "server_url": mockPelicanOriginServerAd.URL.String(), - "server_web_url": mockPelicanOriginServerAd.WebURL.String(), - "server_lat": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Latitude), - "server_long": fmt.Sprintf("%.4f", mockPelicanOriginServerAd.Longitude), - }, - }} - - resStr, err := json.Marshal(expectedRes) - assert.NoError(t, err, "Could not marshal json response") - - req.Header.Set("Authorization", "Bearer "+string(setupToken(""))) - - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, 200, w.Code) - assert.Equal(t, string(resStr), w.Body.String(), "Reponse doesn't match expected") - }) -} - -func TestRedirects(t *testing.T) { - // Check that the checkkHostnameRedirects uses the pre-configured hostnames to redirect - // requests that come in at the default paths, but not if the request is made - // specifically for an object or a cache via the API. - t.Run("redirect-check-hostnames", func(t *testing.T) { - // Note that we don't test here for the case when hostname redirects is turned off - // because the checkHostnameRedirects function should be unreachable via ShortcutMiddleware - // in that case, ie if we call this function and the incoming hostname matches, we should do - // the redirect specified - viper.Set("Director.OriginResponseHostnames", []string{"origin-hostname.com"}) - viper.Set("Director.CacheResponseHostnames", []string{"cache-hostname.com"}) - - // base path with origin-redirect hostname, should redirect to origin - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - req := httptest.NewRequest("GET", "/foo/bar", nil) - c.Request = req - checkHostnameRedirects(c, "origin-hostname.com") - expectedPath := "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // base path with cache-redirect hostname, should redirect to cache - req = httptest.NewRequest("GET", "/foo/bar", nil) - c.Request = req - checkHostnameRedirects(c, "cache-hostname.com") - expectedPath = "/api/v1.0/director/object/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // API path that should ALWAYS redirect to an origin - req = httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) - c.Request = req - // Tell it cache, but it shouldn't switch what it redirects to - checkHostnameRedirects(c, "cache-hostname.com") - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // API path that should ALWAYS redirect to a cache - req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) - c.Request = req - // Tell it origin, but it shouldn't switch what it redirects to - checkHostnameRedirects(c, "origin-hostname.com") - expectedPath = "/api/v1.0/director/object/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - viper.Reset() - }) - - t.Run("redirect-middleware", func(t *testing.T) { - // First test that two API endpoints are functioning properly - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - req := httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) - c.Request = req - - // test both APIs when in cache mode - ShortcutMiddleware("cache")(c) - expectedPath := "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) - c.Request = req - ShortcutMiddleware("cache")(c) - expectedPath = "/api/v1.0/director/object/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // test both APIs when in origin mode - req = httptest.NewRequest("GET", "/api/v1.0/director/origin/foo/bar", nil) - c.Request = req - ShortcutMiddleware("origin")(c) - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) - c.Request = req - ShortcutMiddleware("origin")(c) - expectedPath = "/api/v1.0/director/object/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // Test the base paths - // test that we get an origin at the base path when in origin mode - req = httptest.NewRequest("GET", "/foo/bar", nil) - c.Request = req - ShortcutMiddleware("origin")(c) - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // test that we get a cache at the base path when in cache mode - req = httptest.NewRequest("GET", "/api/v1.0/director/object/foo/bar", nil) - c.Request = req - ShortcutMiddleware("cache")(c) - expectedPath = "/api/v1.0/director/object/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // test a PUT request always goes to the origin endpoint - req = httptest.NewRequest("PUT", "/foo/bar", nil) - c.Request = req - ShortcutMiddleware("cache")(c) - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // Host-aware tests - // Test that we can turn on host-aware redirects and get one appropriate redirect from each - // type of header (as we've already tested that hostname redirects function) - - // Host header - viper.Set("Director.OriginResponseHostnames", []string{"origin-hostname.com"}) - viper.Set("Director.HostAwareRedirects", true) - req = httptest.NewRequest("GET", "/foo/bar", nil) - c.Request = req - c.Request.Header.Set("Host", "origin-hostname.com") - ShortcutMiddleware("cache")(c) - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - // X-Forwarded-Host header - req = httptest.NewRequest("GET", "/foo/bar", nil) - c.Request = req - c.Request.Header.Set("X-Forwarded-Host", "origin-hostname.com") - ShortcutMiddleware("cache")(c) - expectedPath = "/api/v1.0/director/origin/foo/bar" - assert.Equal(t, expectedPath, c.Request.URL.Path) - - viper.Reset() - }) -} diff --git a/director/sort.go b/director/sort.go index 7ea409e70..fdb096ac6 100644 --- a/director/sort.go +++ b/director/sort.go @@ -41,8 +41,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/viper" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" ) const ( @@ -163,7 +163,7 @@ func getLatLong(addr netip.Addr) (lat float64, long float64, err error) { return } -func sortServers(addr netip.Addr, ads []common.ServerAd) ([]common.ServerAd, error) { +func sortServers(addr netip.Addr, ads []server_structs.ServerAd) ([]server_structs.ServerAd, error) { distances := make(SwapMaps, len(ads)) lat, long, err := getLatLong(addr) // If we don't get a valid coordinate set for the incoming address, either because @@ -181,7 +181,7 @@ func sortServers(addr netip.Addr, ads []common.ServerAd) ([]common.ServerAd, err } } sort.Sort(distances) - resultAds := make([]common.ServerAd, len(ads)) + resultAds := make([]server_structs.ServerAd, len(ads)) for idx, distance := range distances { resultAds[idx] = ads[distance.Index] } diff --git a/director/stat.go b/director/stat.go index 682917854..b33237186 100644 --- a/director/stat.go +++ b/director/stat.go @@ -27,9 +27,9 @@ import ( "strconv" "time" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" @@ -38,7 +38,7 @@ import ( type ( objectMetadata struct { - // ServerAd common.ServerAd `json:"server_ad"` + // ServerAd server_structs.ServerAd `json:"server_ad"` URL url.URL `json:"url"` Checksum string `json:"checksum"` ContentLength int `json:"content_length"` @@ -196,7 +196,7 @@ func (stat *ObjectStat) queryOriginsForObject(objectName string, cancelContext c } // Have to use an anonymous func to wrap the egrp call to pass loop variable safely // to goroutine - func(intOriginAd common.ServerAd) { + func(intOriginAd server_structs.ServerAd) { originUtil.Errgroup.Go(func() error { metadata, err := stat.ReqHandler(objectName, intOriginAd.URL, timeout, maxCancelCtx) diff --git a/director/stat_test.go b/director/stat_test.go index 323229b3d..e7cd03341 100644 --- a/director/stat_test.go +++ b/director/stat_test.go @@ -29,8 +29,8 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/server_structs" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -57,28 +57,28 @@ func TestQueryOriginsForObject(t *testing.T) { func() { serverAdMutex.Lock() defer serverAdMutex.Unlock() - serverAds = ttlcache.New[common.ServerAd, []common.NamespaceAdV2](ttlcache.WithTTL[common.ServerAd, []common.NamespaceAdV2](15 * time.Minute)) + serverAds = ttlcache.New[server_structs.ServerAd, []server_structs.NamespaceAdV2](ttlcache.WithTTL[server_structs.ServerAd, []server_structs.NamespaceAdV2](15 * time.Minute)) }() mockTTLCache := func() { serverAdMutex.Lock() defer serverAdMutex.Unlock() - mockServerAd1 := common.ServerAd{Name: "origin1", URL: url.URL{Host: "example1.com", Scheme: "https"}, Type: common.OriginType} - mockServerAd2 := common.ServerAd{Name: "origin2", URL: url.URL{Host: "example2.com", Scheme: "https"}, Type: common.OriginType} - mockServerAd3 := common.ServerAd{Name: "origin3", URL: url.URL{Host: "example3.com", Scheme: "https"}, Type: common.OriginType} - mockServerAd4 := common.ServerAd{Name: "origin4", URL: url.URL{Host: "example4.com", Scheme: "https"}, Type: common.OriginType} - mockServerAd5 := common.ServerAd{Name: "cache1", URL: url.URL{Host: "cache1.com", Scheme: "https"}, Type: common.OriginType} - mockNsAd0 := common.NamespaceAdV2{Path: "/foo"} - mockNsAd1 := common.NamespaceAdV2{Path: "/foo/bar"} - mockNsAd2 := common.NamespaceAdV2{Path: "/foo/x"} - mockNsAd3 := common.NamespaceAdV2{Path: "/foo/bar/barz"} - mockNsAd4 := common.NamespaceAdV2{Path: "/unrelated"} - mockNsAd5 := common.NamespaceAdV2{Path: "/caches/hostname"} - serverAds.Set(mockServerAd1, []common.NamespaceAdV2{mockNsAd0}, ttlcache.DefaultTTL) - serverAds.Set(mockServerAd2, []common.NamespaceAdV2{mockNsAd1}, ttlcache.DefaultTTL) - serverAds.Set(mockServerAd3, []common.NamespaceAdV2{mockNsAd1, mockNsAd4}, ttlcache.DefaultTTL) - serverAds.Set(mockServerAd4, []common.NamespaceAdV2{mockNsAd2, mockNsAd3}, ttlcache.DefaultTTL) - serverAds.Set(mockServerAd5, []common.NamespaceAdV2{mockNsAd5}, ttlcache.DefaultTTL) + mockServerAd1 := server_structs.ServerAd{Name: "origin1", URL: url.URL{Host: "example1.com", Scheme: "https"}, Type: server_structs.OriginType} + mockServerAd2 := server_structs.ServerAd{Name: "origin2", URL: url.URL{Host: "example2.com", Scheme: "https"}, Type: server_structs.OriginType} + mockServerAd3 := server_structs.ServerAd{Name: "origin3", URL: url.URL{Host: "example3.com", Scheme: "https"}, Type: server_structs.OriginType} + mockServerAd4 := server_structs.ServerAd{Name: "origin4", URL: url.URL{Host: "example4.com", Scheme: "https"}, Type: server_structs.OriginType} + mockServerAd5 := server_structs.ServerAd{Name: "cache1", URL: url.URL{Host: "cache1.com", Scheme: "https"}, Type: server_structs.OriginType} + mockNsAd0 := server_structs.NamespaceAdV2{Path: "/foo"} + mockNsAd1 := server_structs.NamespaceAdV2{Path: "/foo/bar"} + mockNsAd2 := server_structs.NamespaceAdV2{Path: "/foo/x"} + mockNsAd3 := server_structs.NamespaceAdV2{Path: "/foo/bar/barz"} + mockNsAd4 := server_structs.NamespaceAdV2{Path: "/unrelated"} + mockNsAd5 := server_structs.NamespaceAdV2{Path: "/caches/hostname"} + serverAds.Set(mockServerAd1, []server_structs.NamespaceAdV2{mockNsAd0}, ttlcache.DefaultTTL) + serverAds.Set(mockServerAd2, []server_structs.NamespaceAdV2{mockNsAd1}, ttlcache.DefaultTTL) + serverAds.Set(mockServerAd3, []server_structs.NamespaceAdV2{mockNsAd1, mockNsAd4}, ttlcache.DefaultTTL) + serverAds.Set(mockServerAd4, []server_structs.NamespaceAdV2{mockNsAd2, mockNsAd3}, ttlcache.DefaultTTL) + serverAds.Set(mockServerAd5, []server_structs.NamespaceAdV2{mockNsAd5}, ttlcache.DefaultTTL) } cleanupMock := func() { @@ -382,7 +382,7 @@ func TestSendHeadReqToOrigin(t *testing.T) { realServerUrl, err := url.Parse(server.URL) require.NoError(t, err) - mockOriginAd := common.ServerAd{Type: common.OriginType} + mockOriginAd := server_structs.ServerAd{Type: server_structs.OriginType} mockOriginAd.URL = *realServerUrl tDir := t.TempDir() diff --git a/fed_test_utils/fed.go b/fed_test_utils/fed.go index 35b229c3c..1f947f331 100644 --- a/fed_test_utils/fed.go +++ b/fed_test_utils/fed.go @@ -37,7 +37,6 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/launchers" "github.com/pelicanplatform/pelican/param" @@ -49,7 +48,7 @@ import ( type ( FedTest struct { - Exports *[]common.OriginExports + Exports *[]server_utils.OriginExports Token string Ctx context.Context Egrp *errgroup.Group @@ -90,7 +89,7 @@ func NewFedTest(t *testing.T, originConfig string) (ft *FedTest) { require.NoError(t, err, "error reading config") } // Now call GetOriginExports and check the struct - exports, err := common.GetOriginExports() + exports, err := server_utils.GetOriginExports() require.NoError(t, err, "error getting origin exports") ft.Exports = exports @@ -107,7 +106,7 @@ func NewFedTest(t *testing.T, originConfig string) (ft *FedTest) { ((*ft.Exports)[i]).StoragePrefix = originDir // Our exports object becomes global -- we must reset in between each fed test t.Cleanup(func() { - common.ResetOriginExports() + server_utils.ResetOriginExports() }) // Change the permissions of the temporary origin directory diff --git a/server_ui/advertise.go b/launcher_utils/advertise.go similarity index 88% rename from server_ui/advertise.go rename to launcher_utils/advertise.go index 800de6d43..893d70cc8 100644 --- a/server_ui/advertise.go +++ b/launcher_utils/advertise.go @@ -16,7 +16,10 @@ * ***************************************************************/ -package server_ui +// launcher_utils are utility functions for laucnhers package. +// It should only be imported by the launchers package +// It should NOT be imported to any server pacakges (origin/cache/registry) or other lower level packages (config/utils/etc) +package launcher_utils import ( "bytes" @@ -36,7 +39,7 @@ import ( "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/metrics" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" ) @@ -46,7 +49,7 @@ type directorResponse struct { ApprovalError bool `json:"approval_error"` } -func doAdvertise(ctx context.Context, servers []server_utils.XRootDServer) { +func doAdvertise(ctx context.Context, servers []server_structs.XRootDServer) { log.Debugf("About to advertise %d XRootD servers", len(servers)) err := Advertise(ctx, servers) if err != nil { @@ -57,7 +60,8 @@ func doAdvertise(ctx context.Context, servers []server_utils.XRootDServer) { } } -func LaunchPeriodicAdvertise(ctx context.Context, egrp *errgroup.Group, servers []server_utils.XRootDServer) error { +// Launch periodic advertise of xrootd servers (origin and cache) to the director, in the errogroup +func LaunchPeriodicAdvertise(ctx context.Context, egrp *errgroup.Group, servers []server_structs.XRootDServer) error { doAdvertise(ctx, servers) ticker := time.NewTicker(1 * time.Minute) @@ -85,7 +89,8 @@ func LaunchPeriodicAdvertise(ctx context.Context, egrp *errgroup.Group, servers return nil } -func Advertise(ctx context.Context, servers []server_utils.XRootDServer) error { +// Advertise ONCE the xrootd servers (origin and cache) to the director +func Advertise(ctx context.Context, servers []server_structs.XRootDServer) error { var firstErr error for _, server := range servers { err := advertiseInternal(ctx, server) @@ -96,7 +101,7 @@ func Advertise(ctx context.Context, servers []server_utils.XRootDServer) error { return firstErr } -func advertiseInternal(ctx context.Context, server server_utils.XRootDServer) error { +func advertiseInternal(ctx context.Context, server server_structs.XRootDServer) error { name := param.Xrootd_Sitename.GetString() if name == "" { return errors.New(fmt.Sprintf("%s name isn't set", server.GetServerType())) diff --git a/server_ui/register_namespace.go b/launcher_utils/register_namespace.go similarity index 97% rename from server_ui/register_namespace.go rename to launcher_utils/register_namespace.go index b8446d99f..bc4204cc0 100644 --- a/server_ui/register_namespace.go +++ b/launcher_utils/register_namespace.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package server_ui +package launcher_utils import ( "bytes" @@ -29,11 +29,11 @@ import ( "time" "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/metrics" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/registry" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" @@ -84,7 +84,7 @@ func keyIsRegistered(privkey jwk.Key, registryUrlStr string, prefix string) (key return noKeyPresent, err } - keyCheckReq := common.CheckNamespaceExistsReq{Prefix: prefix, PubKey: string(pubkeyStr)} + keyCheckReq := server_structs.CheckNamespaceExistsReq{Prefix: prefix, PubKey: string(pubkeyStr)} jsonData, err := json.Marshal(keyCheckReq) if err != nil { return noKeyPresent, errors.Wrap(err, "Error marshaling request to json string") @@ -112,7 +112,7 @@ func keyIsRegistered(privkey jwk.Key, registryUrlStr string, prefix string) (key // For Pelican's registry at /api/v1.0/registry/checkNamespaceExists, it only returns 200, 400, and 500. // If it returns 404, that means we are not hitting Pelican's registry but OSDF's registry or Pelican registry < v7.4.0 if resp.StatusCode != http.StatusNotFound { - resData := common.CheckNamespaceExistsRes{} + resData := server_structs.CheckNamespaceExistsRes{} if err := json.Unmarshal(body, &resData); err != nil { log.Warningln("Failed to unmarshal error message response from namespace registry", err) } @@ -162,7 +162,7 @@ func keyIsRegistered(privkey jwk.Key, registryUrlStr string, prefix string) (key if resp.StatusCode == 404 || resp.StatusCode == 500 { return noKeyPresent, nil } else if resp.StatusCode != 200 { - resData := common.CheckNamespaceExistsRes{} + resData := server_structs.CheckNamespaceExistsRes{} if err := json.Unmarshal(OSDFBody, &resData); err != nil { log.Warningln("Failed to unmarshal error message response from namespace registry", err) } diff --git a/server_ui/register_namespace_test.go b/launcher_utils/register_namespace_test.go similarity index 99% rename from server_ui/register_namespace_test.go rename to launcher_utils/register_namespace_test.go index e5be5f6cf..32ca50908 100644 --- a/server_ui/register_namespace_test.go +++ b/launcher_utils/register_namespace_test.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package server_ui +package launcher_utils import ( "context" diff --git a/server_ui/xrootd_servers.go b/launcher_utils/xrootd_servers.go similarity index 96% rename from server_ui/xrootd_servers.go rename to launcher_utils/xrootd_servers.go index 702058984..373c16e64 100644 --- a/server_ui/xrootd_servers.go +++ b/launcher_utils/xrootd_servers.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package server_ui +package launcher_utils import ( "fmt" @@ -26,7 +26,7 @@ import ( "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/metrics" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/xrootd" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -44,7 +44,7 @@ func checkConfigFileReadable(fileName string, errMsg string) error { return nil } -func CheckDefaults(server server_utils.XRootDServer) error { +func CheckDefaults(server server_structs.XRootDServer) error { requiredConfigs := []param.StringParam{param.Server_TLSCertificate, param.Server_TLSKey, param.Xrootd_RobotsTxtFile} for _, configName := range requiredConfigs { mgr := configName.GetString() diff --git a/launchers/cache_serve.go b/launchers/cache_serve.go index 079473825..e8e608f49 100644 --- a/launchers/cache_serve.go +++ b/launchers/cache_serve.go @@ -27,30 +27,32 @@ import ( "github.com/gin-gonic/gin" "github.com/pelicanplatform/pelican/broker" - "github.com/pelicanplatform/pelican/cache_ui" + "github.com/pelicanplatform/pelican/cache" "github.com/pelicanplatform/pelican/daemon" + "github.com/pelicanplatform/pelican/launcher_utils" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_ui" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/xrootd" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" ) -func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) (server_utils.XRootDServer, error) { +func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) (server_structs.XRootDServer, error) { err := xrootd.SetUpMonitoring(ctx, egrp) if err != nil { return nil, err } - cacheServer := &cache_ui.CacheServer{} + cache.RegisterCacheAPI(engine, ctx, egrp) + + cacheServer := &cache.CacheServer{} err = cacheServer.GetNamespaceAdsFromDirector() cacheServer.SetFilters() if err != nil { return nil, err } - err = server_ui.CheckDefaults(cacheServer) + err = launcher_utils.CheckDefaults(cacheServer) if err != nil { return nil, err } @@ -64,13 +66,15 @@ func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) ( xrootd.LaunchXrootdMaintenance(ctx, cacheServer, 2*time.Minute) + cache.LaunchDirectorTestFileCleanup(ctx) + if param.Cache_SelfTest.GetBool() { - err = cache_ui.InitSelfTestDir() + err = cache.InitSelfTestDir() if err != nil { return nil, err } - cache_ui.PeriodicCacheSelfTest(ctx, egrp) + cache.PeriodicCacheSelfTest(ctx, egrp) } log.Info("Launching cache") @@ -88,5 +92,5 @@ func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) ( // Finish configuration of the cache server. func CacheServeFinish(ctx context.Context, egrp *errgroup.Group) error { - return server_ui.RegisterNamespaceWithRetry(ctx, egrp, "/caches/"+param.Xrootd_Sitename.GetString()) + return launcher_utils.RegisterNamespaceWithRetry(ctx, egrp, "/caches/"+param.Xrootd_Sitename.GetString()) } diff --git a/launchers/cache_serve_windows.go b/launchers/cache_serve_windows.go index aa09f3d27..22770edaa 100644 --- a/launchers/cache_serve_windows.go +++ b/launchers/cache_serve_windows.go @@ -24,12 +24,12 @@ import ( "context" "github.com/gin-gonic/gin" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) -func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) (server_utils.XRootDServer, error) { +func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group) (server_structs.XRootDServer, error) { return nil, errors.New("Cache module is not supported on Windows") } diff --git a/launchers/director_serve.go b/launchers/director_serve.go index 4ebf75c0d..8fc3e2477 100644 --- a/launchers/director_serve.go +++ b/launchers/director_serve.go @@ -61,10 +61,10 @@ func DirectorServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group } log.Debugf("The director will redirect to %ss by default", defaultResponse) rootGroup := engine.Group("/") - director.RegisterDirectorAuth(rootGroup) + director.RegisterDirectorOIDCAPI(rootGroup) director.RegisterDirectorWebAPI(rootGroup) engine.Use(director.ShortcutMiddleware(defaultResponse)) - director.RegisterDirector(ctx, rootGroup) + director.RegisterDirectorAPI(ctx, rootGroup) return nil } diff --git a/launchers/launcher.go b/launchers/launcher.go index 6fa1dd81a..bebf5fb25 100644 --- a/launchers/launcher.go +++ b/launchers/launcher.go @@ -35,12 +35,12 @@ import ( "golang.org/x/sync/errgroup" "github.com/pelicanplatform/pelican/broker" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/launcher_utils" "github.com/pelicanplatform/pelican/local_cache" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_ui" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/web_ui" ) @@ -135,12 +135,14 @@ func LaunchModules(ctx context.Context, modules config.ServerType) (context.Canc }() config.UpdateConfigFromListener(ln) - servers := make([]server_utils.XRootDServer, 0) + servers := make([]server_structs.XRootDServer, 0) if modules.IsEnabled(config.OriginType) { mode := param.Origin_StorageType.GetString() - originExports, err := common.GetOriginExports() + + originExports, err := server_utils.GetOriginExports() + if err != nil { return shutdownCancel, err } @@ -192,7 +194,7 @@ func LaunchModules(ctx context.Context, modules config.ServerType) (context.Canc // NOTE: Until the Broker supports multi-export origins, we've made the assumption that there // is only one namespace prefix available here and that it lives in Origin.FederationPrefix if param.Origin_EnableBroker.GetBool() { - if err = origin_ui.LaunchBrokerListener(ctx, egrp); err != nil { + if err = origin.LaunchBrokerListener(ctx, egrp); err != nil { return shutdownCancel, err } } @@ -236,7 +238,7 @@ func LaunchModules(ctx context.Context, modules config.ServerType) (context.Canc // Origin needs to advertise once before the cache starts if modules.IsEnabled(config.CacheType) && modules.IsEnabled(config.OriginType) { log.Debug("Advertise Origin") - if err = server_ui.Advertise(ctx, servers); err != nil { + if err = launcher_utils.Advertise(ctx, servers); err != nil { return shutdownCancel, err } @@ -245,7 +247,7 @@ func LaunchModules(ctx context.Context, modules config.ServerType) (context.Canc // of the namespaces and doesn't have to wait an entire cycle to learn about them from the director // To check all of the advertisements, we'll launch a WaitUntilWorking concurrently for each of them. - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return shutdownCancel, err } @@ -311,7 +313,7 @@ func LaunchModules(ctx context.Context, modules config.ServerType) (context.Canc if modules.IsEnabled(config.OriginType) || modules.IsEnabled(config.CacheType) { log.Debug("Launching periodic advertise") - if err := server_ui.LaunchPeriodicAdvertise(ctx, egrp, servers); err != nil { + if err := launcher_utils.LaunchPeriodicAdvertise(ctx, egrp, servers); err != nil { return shutdownCancel, err } } diff --git a/launchers/origin_serve.go b/launchers/origin_serve.go index 9ce6c9ef0..8357cb08f 100644 --- a/launchers/origin_serve.go +++ b/launchers/origin_serve.go @@ -28,37 +28,37 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/launcher_utils" "github.com/pelicanplatform/pelican/oa4mp" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_ui" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/xrootd" ) -func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, modules config.ServerType) (server_utils.XRootDServer, error) { +func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, modules config.ServerType) (server_structs.XRootDServer, error) { err := xrootd.SetUpMonitoring(ctx, egrp) if err != nil { return nil, err } - originServer := &origin_ui.OriginServer{} - err = server_ui.CheckDefaults(originServer) + originServer := &origin.OriginServer{} + err = launcher_utils.CheckDefaults(originServer) if err != nil { return nil, err } // Set up the APIs unrelated to UI, which only contains director-based health test reporting endpoint for now - if err = origin_ui.ConfigureOriginAPI(engine, ctx, egrp); err != nil { + if err = origin.RegisterOriginAPI(engine, ctx, egrp); err != nil { return nil, err } // Director also registers this metadata URL; avoid registering twice. if !modules.IsEnabled(config.DirectorType) { - if err = origin_ui.ConfigIssJWKS(engine.Group("/.well-known")); err != nil { + if err = origin.RegisterOriginOIDCAPI(engine.Group("/.well-known")); err != nil { return nil, err } } @@ -75,7 +75,7 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, } if param.Origin_SelfTest.GetBool() { - egrp.Go(func() error { return origin_ui.PeriodicSelfTest(ctx) }) + egrp.Go(func() error { return origin.PeriodicSelfTest(ctx) }) } privileged := param.Origin_Multiuser.GetBool() @@ -106,13 +106,13 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, // Finish configuration of the origin server. To be invoked after the web UI components // have been launched. func OriginServeFinish(ctx context.Context, egrp *errgroup.Group) error { - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return err } for _, export := range *originExports { - if err := server_ui.RegisterNamespaceWithRetry(ctx, egrp, export.FederationPrefix); err != nil { + if err := launcher_utils.RegisterNamespaceWithRetry(ctx, egrp, export.FederationPrefix); err != nil { return err } } diff --git a/launchers/origin_serve_windows.go b/launchers/origin_serve_windows.go index 54c5f4a0d..70e94ada0 100644 --- a/launchers/origin_serve_windows.go +++ b/launchers/origin_serve_windows.go @@ -25,12 +25,12 @@ import ( "github.com/gin-gonic/gin" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) -func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, modules config.ServerType) (server_utils.XRootDServer, error) { +func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, modules config.ServerType) (server_structs.XRootDServer, error) { return nil, errors.New("Origin module is not supported on Windows") } diff --git a/local_cache/cache_api.go b/local_cache/cache_api.go index a5f46eb0a..77950ca4b 100644 --- a/local_cache/cache_api.go +++ b/local_cache/cache_api.go @@ -32,8 +32,8 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" @@ -143,23 +143,23 @@ func (lc *LocalCache) purgeCmd(ginCtx *gin.Context) { if status == http.StatusOK { status = http.StatusInternalServerError } - ginCtx.AbortWithStatusJSON(status, common.SimpleApiResp{Status: common.RespFailed, Msg: err.Error()}) + ginCtx.AbortWithStatusJSON(status, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: err.Error()}) return } else if !verified { - ginCtx.AbortWithStatusJSON(http.StatusInternalServerError, common.SimpleApiResp{Status: common.RespFailed, Msg: "Unknown verification error"}) + ginCtx.AbortWithStatusJSON(http.StatusInternalServerError, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Unknown verification error"}) return } err = lc.purge() if err != nil { if err == purgeTimeout { - // Note we don't use common.RespTimeout here; that is reserved for a long-poll timeout. - ginCtx.AbortWithStatusJSON(http.StatusRequestTimeout, common.SimpleApiResp{Status: common.RespFailed, Msg: err.Error()}) + // Note we don't use server_structs.RespTimeout here; that is reserved for a long-poll timeout. + ginCtx.AbortWithStatusJSON(http.StatusRequestTimeout, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: err.Error()}) } else { // Note we don't pass uncategorized errors to the user to avoid leaking potentially sensitive information. - ginCtx.AbortWithStatusJSON(http.StatusInternalServerError, common.SimpleApiResp{Status: common.RespFailed, Msg: "Failed to successfully run purge"}) + ginCtx.AbortWithStatusJSON(http.StatusInternalServerError, server_structs.SimpleApiResp{Status: server_structs.RespFailed, Msg: "Failed to successfully run purge"}) } return } - ginCtx.JSON(http.StatusOK, common.SimpleApiResp{Status: common.RespOK}) + ginCtx.JSON(http.StatusOK, server_structs.SimpleApiResp{Status: server_structs.RespOK}) } diff --git a/local_cache/cache_authz.go b/local_cache/cache_authz.go index 77b4d13b7..9175b4f1f 100644 --- a/local_cache/cache_authz.go +++ b/local_cache/cache_authz.go @@ -29,8 +29,8 @@ import ( "github.com/jellydator/ttlcache/v3" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" @@ -40,7 +40,7 @@ import ( type ( authConfig struct { - ns atomic.Pointer[[]common.NamespaceAdV2] + ns atomic.Pointer[[]server_structs.NamespaceAdV2] issuers atomic.Pointer[map[string]bool] issuerKeys *ttlcache.Cache[string, authConfigItem] tokenAuthz *ttlcache.Cache[string, acls] @@ -114,7 +114,7 @@ func newAuthConfig(ctx context.Context, egrp *errgroup.Group) (ac *authConfig) { return } -func (ac *authConfig) updateConfig(nsAds []common.NamespaceAdV2) error { +func (ac *authConfig) updateConfig(nsAds []server_structs.NamespaceAdV2) error { issuers := make(map[string]bool) for _, nsAd := range nsAds { for _, issuer := range nsAd.Issuer { diff --git a/local_cache/local_cache.go b/local_cache/local_cache.go index a765a0a6d..7adbef359 100644 --- a/local_cache/local_cache.go +++ b/local_cache/local_cache.go @@ -37,8 +37,8 @@ import ( "github.com/google/uuid" "github.com/lestrrat-go/option" "github.com/pelicanplatform/pelican/client" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pelicanplatform/pelican/utils" "github.com/pkg/errors" @@ -701,7 +701,7 @@ func (lc *LocalCache) Stat(path, token string) (uint64, error) { func (sc *LocalCache) updateConfig() error { // Get the endpoint of the director - var respNS []common.NamespaceAdV2 + var respNS []server_structs.NamespaceAdV2 directorEndpoint := param.Federation_DirectorUrl.GetString() if directorEndpoint == "" { diff --git a/origin_ui/advertise.go b/origin/advertise.go similarity index 85% rename from origin_ui/advertise.go rename to origin/advertise.go index af8997250..5b8c4bcb2 100644 --- a/origin_ui/advertise.go +++ b/origin/advertise.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package origin_ui +package origin import ( "net/url" @@ -24,15 +24,15 @@ import ( "github.com/pkg/errors" log "github.com/sirupsen/logrus" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" ) type ( OriginServer struct { - server_utils.NamespaceHolder + server_structs.NamespaceHolder } ) @@ -44,7 +44,7 @@ func (server *OriginServer) GetNamespaceAdsFromDirector() error { return nil } -func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string, originWebUrl string) (*common.OriginAdvertiseV2, error) { +func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string, originWebUrl string) (*server_structs.OriginAdvertiseV2, error) { // Here we instantiate the namespaceAd slice, but we still need to define the namespace issuerUrlStr, err := config.GetServerIssuerURL() if err != nil { @@ -69,9 +69,9 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string return nil, err } - var nsAds []common.NamespaceAdV2 + var nsAds []server_structs.NamespaceAdV2 var prefixes []string - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return nil, err } @@ -79,20 +79,20 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string for _, export := range *originExports { // PublicReads implies reads reads := export.Capabilities.PublicReads || export.Capabilities.Reads - nsAds = append(nsAds, common.NamespaceAdV2{ + nsAds = append(nsAds, server_structs.NamespaceAdV2{ PublicRead: export.Capabilities.PublicReads, - Caps: common.Capabilities{ + Caps: server_structs.Capabilities{ PublicReads: export.Capabilities.PublicReads, Reads: reads, Writes: export.Capabilities.Writes, }, Path: export.FederationPrefix, - Generation: []common.TokenGen{{ - Strategy: common.StrategyType("OAuth2"), + Generation: []server_structs.TokenGen{{ + Strategy: server_structs.StrategyType("OAuth2"), MaxScopeDepth: 3, CredentialIssuer: *originUrlURL, }}, - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ BasePaths: []string{export.FederationPrefix}, IssuerUrl: *issuerUrl, }}, @@ -102,18 +102,18 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string // PublicReads implies reads reads := param.Origin_EnableReads.GetBool() || param.Origin_EnablePublicReads.GetBool() - ad := common.OriginAdvertiseV2{ + ad := server_structs.OriginAdvertiseV2{ Name: name, DataURL: originUrlStr, WebURL: originWebUrl, Namespaces: nsAds, - Caps: common.Capabilities{ + Caps: server_structs.Capabilities{ PublicReads: param.Origin_EnablePublicReads.GetBool(), Reads: reads, Writes: param.Origin_EnableWrites.GetBool(), DirectReads: param.Origin_EnableDirectReads.GetBool(), }, - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ BasePaths: prefixes, IssuerUrl: *issuerUrl, }}, @@ -145,7 +145,7 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrlStr string // Used to calculate the base_paths in the scitokens.cfg, for eaxmple func (server *OriginServer) GetAuthorizedPrefixes() ([]string, error) { var prefixes []string - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return nil, err } diff --git a/origin_ui/broker_client.go b/origin/broker_client.go similarity index 99% rename from origin_ui/broker_client.go rename to origin/broker_client.go index aa6291f79..5382154ea 100644 --- a/origin_ui/broker_client.go +++ b/origin/broker_client.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package origin_ui +package origin import ( "context" diff --git a/origin/origin.go b/origin/origin.go new file mode 100644 index 000000000..729efe246 --- /dev/null +++ b/origin/origin.go @@ -0,0 +1,89 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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. + * + ***************************************************************/ + +package origin + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/metrics" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_utils" +) + +var ( + notificationChan = make(chan bool) +) + +func exportOpenIDConfig(c *gin.Context) { + issuerURL, _ := url.Parse(param.Server_ExternalWebUrl.GetString()) + jwksUri, _ := url.JoinPath(issuerURL.String(), "/.well-known/issuer.jwks") + jsonData := gin.H{ + "issuer": issuerURL.String(), + "jwks_uri": jwksUri, + } + + c.JSON(http.StatusOK, jsonData) +} + +func exportIssuerJWKS(c *gin.Context) { + keys, _ := config.GetIssuerPublicJWKS() + buf, _ := json.MarshalIndent(keys, "", " ") + + c.Data(http.StatusOK, "application/json; charset=utf-8", buf) +} + +func RegisterOriginOIDCAPI(router *gin.RouterGroup) error { + if router == nil { + return errors.New("Origin configuration passed a nil pointer") + } + + router.GET("/openid-configuration", exportOpenIDConfig) + router.GET("/issuer.jwks", exportIssuerJWKS) + return nil +} + +// Configure API endpoints for origin that are not tied to UI +func RegisterOriginAPI(router *gin.Engine, ctx context.Context, egrp *errgroup.Group) error { + if router == nil { + return errors.New("Origin configuration passed a nil pointer") + } + + metrics.SetComponentHealthStatus(metrics.OriginCache_Director, metrics.StatusWarning, "Initializing origin, unknown status for director") + // start the timer for the director test report timeout + server_utils.LaunchPeriodicDirectorTimeout(ctx, egrp, notificationChan) + + deprecatedGroup := router.Group("/api/v1.0/origin-api") + { + deprecatedGroup.POST("/directorTest", func(ctx *gin.Context) { server_utils.HandleDirectorTestResponse(ctx, notificationChan) }) + } + + group := router.Group("/api/v1.0/origin") + { + group.POST("/directorTest", func(ctx *gin.Context) { server_utils.HandleDirectorTestResponse(ctx, notificationChan) }) + } + return nil +} diff --git a/origin_ui/origin.go b/origin/origin_api.go similarity index 68% rename from origin_ui/origin.go rename to origin/origin_api.go index dd6eaeb13..3840b90fb 100644 --- a/origin_ui/origin.go +++ b/origin/origin_api.go @@ -16,19 +16,14 @@ * ***************************************************************/ -package origin_ui +package origin import ( - "encoding/json" - "net/http" - "net/url" "os" "path/filepath" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/param" - - "github.com/gin-gonic/gin" "github.com/pkg/errors" ) @@ -62,31 +57,3 @@ func ConfigureXrootdMonitoringDir() error { return nil } - -func ConfigIssJWKS(router *gin.RouterGroup) error { - if router == nil { - return errors.New("Origin configuration passed a nil pointer") - } - - router.GET("/openid-configuration", ExportOpenIDConfig) - router.GET("/issuer.jwks", ExportIssuerJWKS) - return nil -} - -func ExportOpenIDConfig(c *gin.Context) { - issuerURL, _ := url.Parse(param.Server_ExternalWebUrl.GetString()) - jwksUri, _ := url.JoinPath(issuerURL.String(), "/.well-known/issuer.jwks") - jsonData := gin.H{ - "issuer": issuerURL.String(), - "jwks_uri": jwksUri, - } - - c.JSON(http.StatusOK, jsonData) -} - -func ExportIssuerJWKS(c *gin.Context) { - keys, _ := config.GetIssuerPublicJWKS() - buf, _ := json.MarshalIndent(keys, "", " ") - - c.Data(http.StatusOK, "application/json; charset=utf-8", buf) -} diff --git a/origin_ui/self_monitor.go b/origin/self_monitor.go similarity index 99% rename from origin_ui/self_monitor.go rename to origin/self_monitor.go index 11aba1e98..eec038c33 100644 --- a/origin_ui/self_monitor.go +++ b/origin/self_monitor.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package origin_ui +package origin import ( "context" diff --git a/origin_ui/assets/favicon.ico b/origin_ui/assets/favicon.ico deleted file mode 100644 index a76030948..000000000 Binary files a/origin_ui/assets/favicon.ico and /dev/null differ diff --git a/origin_ui/assets/index.html b/origin_ui/assets/index.html deleted file mode 100644 index 6c82ae139..000000000 --- a/origin_ui/assets/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - Pelican Origin Web UI - - -

This is the Pelican origin web UI

- - diff --git a/registry/registry.go b/registry/registry.go index 90ed7ed8a..6f552d718 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -44,10 +44,10 @@ import ( "github.com/gin-gonic/gin" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/oauth2" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token_scopes" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -776,7 +776,7 @@ func wildcardHandler(ctx *gin.Context) { // Check if a namespace prefix exists and its public key matches the registry record func checkNamespaceExistsHandler(ctx *gin.Context) { - req := common.CheckNamespaceExistsReq{} + req := server_structs.CheckNamespaceExistsReq{} if err := ctx.ShouldBind(&req); err != nil { log.Debug("Failed to parse request body for namespace exits check: ", err) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse request body"}) @@ -815,7 +815,7 @@ func checkNamespaceExistsHandler(ctx *gin.Context) { if !found { // We return 200 even with prefix not found so that 404 can be used to check if the route exists (OSDF) // and fallback to OSDF way of checking if we do get 404 - res := common.CheckNamespaceExistsRes{PrefixExists: false, Message: "Prefix was not found in database"} + res := server_structs.CheckNamespaceExistsRes{PrefixExists: false, Message: "Prefix was not found in database"} ctx.JSON(http.StatusOK, res) return } @@ -829,22 +829,22 @@ func checkNamespaceExistsHandler(ctx *gin.Context) { registryKey, isPresent := jwksDb.LookupKeyID(jwkReq.KeyID()) if !isPresent { - res := common.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: false, Message: "Given JWK is not present in the JWKS from database"} + res := server_structs.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: false, Message: "Given JWK is not present in the JWKS from database"} ctx.JSON(http.StatusOK, res) return } else if jwk.Equal(registryKey, jwkReq) { - res := common.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: true} + res := server_structs.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: true} ctx.JSON(http.StatusOK, res) return } else { - res := common.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: false, Message: "Given JWK does not equal to the JWK from database"} + res := server_structs.CheckNamespaceExistsRes{PrefixExists: true, KeyMatch: false, Message: "Given JWK does not equal to the JWK from database"} ctx.JSON(http.StatusOK, res) return } } func checkNamespaceStatusHandler(ctx *gin.Context) { - req := common.CheckNamespaceStatusReq{} + req := server_structs.CheckNamespaceStatusReq{} if err := ctx.ShouldBind(&req); err != nil { log.Debug("Failed to parse request body for namespace status check: ", err) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse request body"}) @@ -866,28 +866,28 @@ func checkNamespaceStatusHandler(ctx *gin.Context) { if ns.AdminMetadata != emptyMetadata { // Caches if strings.HasPrefix(req.Prefix, "/caches") && param.Registry_RequireCacheApproval.GetBool() { - res := common.CheckNamespaceStatusRes{Approved: ns.AdminMetadata.Status == Approved} + res := server_structs.CheckNamespaceStatusRes{Approved: ns.AdminMetadata.Status == Approved} ctx.JSON(http.StatusOK, res) return } else if !param.Registry_RequireCacheApproval.GetBool() { - res := common.CheckNamespaceStatusRes{Approved: true} + res := server_structs.CheckNamespaceStatusRes{Approved: true} ctx.JSON(http.StatusOK, res) return } else { // Origins if param.Registry_RequireOriginApproval.GetBool() { - res := common.CheckNamespaceStatusRes{Approved: ns.AdminMetadata.Status == Approved} + res := server_structs.CheckNamespaceStatusRes{Approved: ns.AdminMetadata.Status == Approved} ctx.JSON(http.StatusOK, res) return } else { - res := common.CheckNamespaceStatusRes{Approved: true} + res := server_structs.CheckNamespaceStatusRes{Approved: true} ctx.JSON(http.StatusOK, res) return } } } else { // For legacy Pelican (<=7.3.0) registry schema without Admin_Metadata - res := common.CheckNamespaceStatusRes{Approved: true} + res := server_structs.CheckNamespaceStatusRes{Approved: true} ctx.JSON(http.StatusOK, res) } } diff --git a/common/api_resp.go b/server_structs/api_resp.go similarity index 79% rename from common/api_resp.go rename to server_structs/api_resp.go index f677f38d4..dbf421b19 100644 --- a/common/api_resp.go +++ b/server_structs/api_resp.go @@ -16,7 +16,12 @@ * ***************************************************************/ -package common +// server_structs pacakge shares struct and their methods used across multiple server pacakges (origin/cache/registry/director). +// It should only import lower level packages (config/param/etc). +// It should NEVER import any server pacakges (origin/cache/registry/director) or server_utils package. +// +// For functions used across multiple server pacakges, put them in server_utils pacakge instead +package server_structs type ( diff --git a/director/ad_conversion.go b/server_structs/director.go similarity index 51% rename from director/ad_conversion.go rename to server_structs/director.go index 35dacd3f1..d25063ba6 100644 --- a/director/ad_conversion.go +++ b/server_structs/director.go @@ -16,25 +16,145 @@ * ***************************************************************/ -package director +package server_structs import ( + "encoding/json" "net/url" +) + +type ( + TokenIssuer struct { + BasePaths []string `json:"base-paths"` + RestrictedPaths []string `json:"restricted-paths"` + IssuerUrl url.URL `json:"issuer"` + } + + TokenGen struct { + Strategy StrategyType `json:"strategy"` + VaultServer string `json:"vault-server"` + MaxScopeDepth uint `json:"max-scope-depth"` + CredentialIssuer url.URL `json:"issuer"` + } + + Capabilities struct { + PublicReads bool `json:"PublicRead"` + Reads bool `json:"Read"` + Writes bool `json:"Write"` + Listings bool `json:"Listing"` + DirectReads bool `json:"FallBackRead"` + } + + NamespaceAdV2 struct { + PublicRead bool + Caps Capabilities // Namespace capabilities should be considered independently of the origin’s capabilities. + Path string `json:"path"` + Generation []TokenGen `json:"token-generation"` + Issuer []TokenIssuer `json:"token-issuer"` + } + + NamespaceAdV1 struct { + RequireToken bool `json:"requireToken"` + Path string `json:"path"` + Issuer url.URL `json:"url"` + MaxScopeDepth uint `json:"maxScopeDepth"` + Strategy StrategyType `json:"strategy"` + BasePath string `json:"basePath"` + VaultServer string `json:"vaultServer"` + DirlistHost string `json:"dirlisthost"` + } + + ServerAd struct { + Name string + AuthURL url.URL + BrokerURL url.URL // The URL of the broker service to use for this host. + URL url.URL // This is server's XRootD URL for file transfer + WebURL url.URL // This is server's Web interface and API + Type ServerType + Latitude float64 + Longitude float64 + Writes bool + DirectReads bool // True if reads from the origin are permitted when no cache is available + } + + ServerType string + StrategyType string - "github.com/pelicanplatform/pelican/common" + OriginAdvertiseV2 struct { + Name string `json:"name"` + BrokerURL string `json:"broker-url,omitempty"` + DataURL string `json:"data-url" binding:"required"` + WebURL string `json:"web-url,omitempty"` + Caps Capabilities `json:"capabilities"` + Namespaces []NamespaceAdV2 `json:"namespaces"` + Issuer []TokenIssuer `json:"token-issuer"` + } + + OriginAdvertiseV1 struct { + Name string `json:"name"` + URL string `json:"url" binding:"required"` // This is the url for origin's XRootD service and file transfer + WebURL string `json:"web_url,omitempty"` // This is the url for origin's web engine and APIs + Namespaces []NamespaceAdV1 `json:"namespaces"` + Writes bool `json:"enablewrite"` + DirectReads bool `json:"enable-fallback-read"` // True if the origin will allow direct client reads when no caches are available + } + + DirectorTestResult struct { + Status string `json:"status"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` + } + GetPrefixByPathRes struct { + Prefix string `json:"prefix"` + } +) + +const ( + CacheType ServerType = "Cache" + OriginType ServerType = "Origin" +) + +const ( + OAuthStrategy StrategyType = "OAuth2" + VaultStrategy StrategyType = "Vault" ) -func convertNamespaceAdsV2ToV1(nsV2 []common.NamespaceAdV2) []common.NamespaceAdV1 { +func (ad ServerAd) MarshalJSON() ([]byte, error) { + baseAd := struct { + Name string `json:"name"` + AuthURL string `json:"auth_url"` + URL string `json:"url"` + WebURL string `json:"web_url"` + Type ServerType `json:"type"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Writes bool `json:"enable_write"` + DirectReads bool `json:"enable_fallback_read"` + }{ + Name: ad.Name, + AuthURL: ad.AuthURL.String(), + URL: ad.URL.String(), + WebURL: ad.WebURL.String(), + Type: ad.Type, + Latitude: ad.Latitude, + Longitude: ad.Longitude, + Writes: ad.Writes, + DirectReads: ad.DirectReads, + } + return json.Marshal(baseAd) +} + +func ConvertNamespaceAdsV2ToV1(nsV2 []NamespaceAdV2) []NamespaceAdV1 { // Converts a list of V2 namespace ads to a list of V1 namespace ads. // This is for backwards compatibility in the case an old version of a client calls // out to a newer verion of the director - nsV1 := []common.NamespaceAdV1{} + nsV1 := []NamespaceAdV1{} for _, nsAd := range nsV2 { if len(nsAd.Issuer) != 0 { for _, iss := range nsAd.Issuer { for _, bp := range iss.BasePaths { - v1Ad := common.NamespaceAdV1{ + v1Ad := NamespaceAdV1{ Path: nsAd.Path, RequireToken: !nsAd.Caps.PublicReads, Issuer: iss.IssuerUrl, @@ -47,7 +167,7 @@ func convertNamespaceAdsV2ToV1(nsV2 []common.NamespaceAdV2) []common.NamespaceAd } } } else { - v1Ad := common.NamespaceAdV1{ + v1Ad := NamespaceAdV1{ Path: nsAd.Path, RequireToken: false, } @@ -58,7 +178,7 @@ func convertNamespaceAdsV2ToV1(nsV2 []common.NamespaceAdV2) []common.NamespaceAd return nsV1 } -func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.OriginAdvertiseV1) []common.NamespaceAdV2 { +func ConvertNamespaceAdsV1ToV2(nsAdsV1 []NamespaceAdV1, oAd *OriginAdvertiseV1) []NamespaceAdV2 { //Convert a list of V1 namespace ads to a list of V2 namespace ads, note that this //isn't the most efficient way of doing so (an interative search as opposed to some sort //of index or hash based search) @@ -74,7 +194,7 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi fallback = true wr = false } - nsAdsV2 := []common.NamespaceAdV2{} + nsAdsV2 := []NamespaceAdV2{} for _, nsAd := range nsAdsV1 { nsFound := false for i := range nsAdsV2 { @@ -108,7 +228,7 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi credurl = nsAd.Issuer } - tIss := common.TokenIssuer{ + tIss := TokenIssuer{ BasePaths: []string{nsAd.BasePath}, RestrictedPaths: []string{}, IssuerUrl: nsAd.Issuer, @@ -117,13 +237,13 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi tis := append(nsAdsV2[i].Issuer, tIss) (*v2NS).Issuer = tis if len(nsAdsV2[i].Generation) == 0 { - tGen := common.TokenGen{ + tGen := TokenGen{ Strategy: nsAd.Strategy, VaultServer: nsAd.VaultServer, MaxScopeDepth: nsAd.MaxScopeDepth, CredentialIssuer: credurl, } - (*v2NS).Generation = []common.TokenGen{tGen} + (*v2NS).Generation = []TokenGen{tGen} } } } @@ -143,7 +263,7 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi credurl = nsAd.Issuer } - caps := common.Capabilities{ + caps := Capabilities{ PublicReads: !nsAd.RequireToken, Reads: true, Writes: wr, @@ -151,20 +271,20 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi DirectReads: fallback, } - newNS := common.NamespaceAdV2{ + newNS := NamespaceAdV2{ PublicRead: !nsAd.RequireToken, Caps: caps, Path: nsAd.Path, } if nsAd.RequireToken { - tGen := []common.TokenGen{{ + tGen := []TokenGen{{ Strategy: nsAd.Strategy, VaultServer: nsAd.VaultServer, MaxScopeDepth: nsAd.MaxScopeDepth, CredentialIssuer: credurl, }} - tIss := []common.TokenIssuer{{ + tIss := []TokenIssuer{{ BasePaths: []string{nsAd.BasePath}, RestrictedPaths: []string{}, IssuerUrl: nsAd.Issuer, @@ -180,10 +300,11 @@ func ConvertNamespaceAdsV1ToV2(nsAdsV1 []common.NamespaceAdV1, oAd *common.Origi return nsAdsV2 } -func convertOriginAd(oAd1 common.OriginAdvertiseV1) common.OriginAdvertiseV2 { - // Converts a V1 origin ad ot a V2 origin ad +// Converts a V1 origin advertisement to a V2 origin advertisement +func ConvertOriginAdV1ToV2(oAd1 OriginAdvertiseV1) OriginAdvertiseV2 { + nsAdsV2 := ConvertNamespaceAdsV1ToV2(oAd1.Namespaces, &oAd1) - tokIssuers := []common.TokenIssuer{} + tokIssuers := []TokenIssuer{} for _, v2Ad := range nsAdsV2 { tokIssuers = append(tokIssuers, v2Ad.Issuer...) @@ -192,7 +313,7 @@ func convertOriginAd(oAd1 common.OriginAdvertiseV1) common.OriginAdvertiseV2 { //Origin Capabilities may be different from Namespace Capabilities, but since the original //origin didn't contain capabilities, these are currently the defaults - we might want to potentially //change this in the future - caps := common.Capabilities{ + caps := Capabilities{ PublicReads: true, Reads: true, Writes: oAd1.Writes, @@ -200,7 +321,7 @@ func convertOriginAd(oAd1 common.OriginAdvertiseV1) common.OriginAdvertiseV2 { DirectReads: oAd1.DirectReads, } - oAd2 := common.OriginAdvertiseV2{ + oAd2 := OriginAdvertiseV2{ Name: oAd1.Name, DataURL: oAd1.URL, WebURL: oAd1.WebURL, diff --git a/director/ad_conversion_test.go b/server_structs/director_test.go similarity index 84% rename from director/ad_conversion_test.go rename to server_structs/director_test.go index 38f1d4c5f..9d2d693e1 100644 --- a/director/ad_conversion_test.go +++ b/server_structs/director_test.go @@ -16,13 +16,12 @@ * ***************************************************************/ -package director +package server_structs import ( "net/url" "testing" - "github.com/pelicanplatform/pelican/common" "github.com/stretchr/testify/require" ) @@ -37,16 +36,16 @@ func TestConversion(t *testing.T) { issUrl2, err := url.Parse("https://issuer2.org") require.NoError(t, err, "error parsing test issuer url") - v2Ads := []common.NamespaceAdV2{{ + v2Ads := []NamespaceAdV2{{ PublicRead: false, - Caps: common.Capabilities{PublicReads: false, Reads: true, Writes: true, DirectReads: false, Listings: true}, + Caps: Capabilities{PublicReads: false, Reads: true, Writes: true, DirectReads: false, Listings: true}, Path: "/foo/bar", - Generation: []common.TokenGen{{ + Generation: []TokenGen{{ Strategy: "OAuth2", MaxScopeDepth: 3, CredentialIssuer: *credUrl, }}, - Issuer: []common.TokenIssuer{ + Issuer: []TokenIssuer{ { BasePaths: []string{"/foo/bar/baz", "/foo/bar/wazzit"}, IssuerUrl: *issUrl1, @@ -60,7 +59,7 @@ func TestConversion(t *testing.T) { }, { PublicRead: true, - Caps: common.Capabilities{ + Caps: Capabilities{ PublicReads: true, Reads: true, Writes: true, @@ -70,7 +69,7 @@ func TestConversion(t *testing.T) { }, } - v1Ads := []common.NamespaceAdV1{ + v1Ads := []NamespaceAdV1{ { RequireToken: true, Path: "/foo/bar", @@ -101,11 +100,11 @@ func TestConversion(t *testing.T) { }, } - v1Conv := convertNamespaceAdsV2ToV1(v2Ads) + v1Conv := ConvertNamespaceAdsV2ToV1(v2Ads) require.Equal(t, v1Ads, v1Conv) - oAdV1 := common.OriginAdvertiseV1{ + oAdV1 := OriginAdvertiseV1{ Name: "OriginTest", URL: "https://origin-url.org", WebURL: "https://WebUrl.org", @@ -114,19 +113,19 @@ func TestConversion(t *testing.T) { DirectReads: false, } - oAdV2 := common.OriginAdvertiseV2{ + oAdV2 := OriginAdvertiseV2{ Name: "OriginTest", DataURL: "https://origin-url.org", WebURL: "https://WebUrl.org", Namespaces: v2Ads, - Caps: common.Capabilities{ + Caps: Capabilities{ PublicReads: true, Writes: true, DirectReads: false, Listings: true, Reads: true, }, - Issuer: []common.TokenIssuer{ + Issuer: []TokenIssuer{ { BasePaths: []string{"/foo/bar/baz", "/foo/bar/wazzit"}, IssuerUrl: *issUrl1, @@ -140,7 +139,7 @@ func TestConversion(t *testing.T) { }, } - OAdConv := convertOriginAd(oAdV1) + OAdConv := ConvertOriginAdV1ToV2(oAdV1) require.Equal(t, oAdV2, OAdConv) diff --git a/common/registry.go b/server_structs/registry.go similarity index 98% rename from common/registry.go rename to server_structs/registry.go index b257bbd93..21ad76d78 100644 --- a/common/registry.go +++ b/server_structs/registry.go @@ -16,7 +16,7 @@ * ***************************************************************/ -package common +package server_structs type CheckNamespaceExistsReq struct { Prefix string `json:"prefix"` diff --git a/server_utils/server_struct.go b/server_structs/xrootd_server.go similarity index 74% rename from server_utils/server_struct.go rename to server_structs/xrootd_server.go index f8c27eb2c..253961c5c 100644 --- a/server_utils/server_struct.go +++ b/server_structs/xrootd_server.go @@ -16,31 +16,30 @@ * ***************************************************************/ -package server_utils +package server_structs import ( - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" ) type ( XRootDServer interface { GetServerType() config.ServerType - SetNamespaceAds([]common.NamespaceAdV2) - GetNamespaceAds() []common.NamespaceAdV2 - CreateAdvertisement(name string, serverUrl string, serverWebUrl string) (*common.OriginAdvertiseV2, error) + SetNamespaceAds([]NamespaceAdV2) + GetNamespaceAds() []NamespaceAdV2 + CreateAdvertisement(name string, serverUrl string, serverWebUrl string) (*OriginAdvertiseV2, error) GetNamespaceAdsFromDirector() error } NamespaceHolder struct { - namespaceAds []common.NamespaceAdV2 + namespaceAds []NamespaceAdV2 } ) -func (ns *NamespaceHolder) SetNamespaceAds(ads []common.NamespaceAdV2) { +func (ns *NamespaceHolder) SetNamespaceAds(ads []NamespaceAdV2) { ns.namespaceAds = ads } -func (ns *NamespaceHolder) GetNamespaceAds() []common.NamespaceAdV2 { +func (ns *NamespaceHolder) GetNamespaceAds() []NamespaceAdV2 { return ns.namespaceAds } diff --git a/origin_ui/origin_api.go b/server_utils/monitor.go similarity index 72% rename from origin_ui/origin_api.go rename to server_utils/monitor.go index 4bdccf84b..81986733c 100644 --- a/origin_ui/origin_api.go +++ b/server_utils/monitor.go @@ -16,21 +16,19 @@ * ***************************************************************/ -package origin_ui +package server_utils import ( "context" "fmt" "net/http" - "sync" "time" "github.com/gin-gonic/gin" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/metrics" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/token" "github.com/pelicanplatform/pelican/token_scopes" - "github.com/pkg/errors" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" ) @@ -38,15 +36,11 @@ import ( var ( // Duration to wait before timeout directorTimeoutDuration = 30 * time.Second - - notifyResponseOnce sync.Once - notifyChannel chan bool ) -// Notify the periodic ticker that we have received a new response and it +// Notify the periodic ticker for director-based health test timeout that we have received a new response and it // should reset -func notifyNewDirectorResponse(ctx context.Context) { - nChan := getNotifyChannel() +func notifyNewDirectorResponse(ctx context.Context, nChan chan bool) { select { case <-ctx.Done(): return @@ -55,18 +49,10 @@ func notifyNewDirectorResponse(ctx context.Context) { } } -// Get the notification channel in a thread-safe manner -func getNotifyChannel() chan bool { - notifyResponseOnce.Do(func() { - notifyChannel = make(chan bool) - }) - return notifyChannel -} - -// Reset the timer safely -func LaunchPeriodicDirectorTimeout(ctx context.Context, egrp *errgroup.Group) { +// Launch a go routine in errorgroup to report timeout if director-based health test +// response was not sent within the defined time limit +func LaunchPeriodicDirectorTimeout(ctx context.Context, egrp *errgroup.Group, nChan chan bool) { directorTimeoutTicker := time.NewTicker(directorTimeoutDuration) - nChan := getNotifyChannel() egrp.Go(func() error { for { @@ -89,7 +75,7 @@ func LaunchPeriodicDirectorTimeout(ctx context.Context, egrp *errgroup.Group) { // The director periodically uploads/downloads files to/from all online // origins for testing. It sends a request reporting the status of the test result to this endpoint, // and we will update origin internal health status metric by what director returns. -func directorTestResponse(ctx *gin.Context) { +func HandleDirectorTestResponse(ctx *gin.Context, nChan chan bool) { status, ok, err := token.Verify(ctx, token.AuthOption{ Sources: []token.TokenSource{token.Header}, Issuers: []token.TokenIssuer{token.FederationIssuer}, @@ -99,14 +85,14 @@ func directorTestResponse(ctx *gin.Context) { ctx.JSON(status, gin.H{"error": err.Error()}) } - dt := common.DirectorTestResult{} + dt := server_structs.DirectorTestResult{} if err := ctx.ShouldBind(&dt); err != nil { log.Errorf("Invalid director test response: %v", err) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid director test response: " + err.Error()}) return } // We will let the timer go timeout if director didn't send a valid json request - notifyNewDirectorResponse(ctx) + notifyNewDirectorResponse(ctx, nChan) if dt.Status == "ok" { metrics.SetComponentHealthStatus(metrics.OriginCache_Director, metrics.StatusOK, fmt.Sprintf("Director timestamp: %v", dt.Timestamp)) ctx.JSON(http.StatusOK, gin.H{"msg": "Success"}) @@ -118,19 +104,3 @@ func directorTestResponse(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid director test response status: %s", dt.Status)}) } } - -// Configure API endpoints for origin that are not tied to UI -func ConfigureOriginAPI(router *gin.Engine, ctx context.Context, egrp *errgroup.Group) error { - if router == nil { - return errors.New("Origin configuration passed a nil pointer") - } - - metrics.SetComponentHealthStatus(metrics.OriginCache_Director, metrics.StatusWarning, "Initializing origin, unknown status for director") - // start the timer for the director test report timeout - LaunchPeriodicDirectorTimeout(ctx, egrp) - - group := router.Group("/api/v1.0/origin-api") - group.POST("/directorTest", directorTestResponse) - - return nil -} diff --git a/common/origin.go b/server_utils/origin.go similarity index 96% rename from common/origin.go rename to server_utils/origin.go index 9be4771aa..40914de40 100644 --- a/common/origin.go +++ b/server_utils/origin.go @@ -16,8 +16,7 @@ * ***************************************************************/ -// Common pacakge contains shared structs and methods between different Pelican pacakges. -package common +package server_utils import ( "fmt" @@ -31,6 +30,7 @@ import ( "github.com/spf13/viper" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" ) var originExports *[]OriginExports @@ -39,7 +39,7 @@ type ( OriginExports struct { StoragePrefix string FederationPrefix string - Capabilities Capabilities + Capabilities server_structs.Capabilities } ) @@ -73,7 +73,7 @@ func StringListToCapsHookFunc() mapstructure.DecodeHookFuncType { } // Check that we're decoding to the appropriate struct type - if to != reflect.TypeOf(Capabilities{}) { + if to != reflect.TypeOf(server_structs.Capabilities{}) { return data, nil } @@ -85,7 +85,7 @@ func StringListToCapsHookFunc() mapstructure.DecodeHookFuncType { } // Convert the string slice to ExportCapabilities struct - exportCaps := Capabilities{} + exportCaps := server_structs.Capabilities{} for _, cap := range caps { switch cap { case "PublicReads": @@ -155,7 +155,7 @@ func GetOriginExports() (*[]OriginExports, error) { originExport := OriginExports{ FederationPrefix: federationPrefix, StoragePrefix: storagePrefix, - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ PublicReads: param.Origin_EnablePublicReads.GetBool(), Writes: param.Origin_EnableWrites.GetBool(), Listings: param.Origin_EnableListings.GetBool(), @@ -207,7 +207,7 @@ func GetOriginExports() (*[]OriginExports, error) { originExport := OriginExports{ FederationPrefix: param.Origin_FederationPrefix.GetString(), StoragePrefix: param.Origin_StoragePrefix.GetString(), - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ PublicReads: param.Origin_EnablePublicReads.GetBool(), Writes: param.Origin_EnableWrites.GetBool(), Listings: param.Origin_EnableListings.GetBool(), @@ -230,7 +230,7 @@ func GetOriginExports() (*[]OriginExports, error) { originExport := OriginExports{ FederationPrefix: federationPrefix, StoragePrefix: "", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ PublicReads: param.Origin_EnablePublicReads.GetBool(), Writes: param.Origin_EnableWrites.GetBool(), Listings: param.Origin_EnableListings.GetBool(), diff --git a/common/origin_test.go b/server_utils/origin_test.go similarity index 95% rename from common/origin_test.go rename to server_utils/origin_test.go index 0e94bf070..a60e9c38a 100644 --- a/common/origin_test.go +++ b/server_utils/origin_test.go @@ -18,13 +18,14 @@ * ***************************************************************/ -package common +package server_utils import ( _ "embed" "strings" "testing" + "github.com/pelicanplatform/pelican/server_structs" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -95,7 +96,7 @@ func TestGetExports(t *testing.T) { expectedExport1 := OriginExports{ StoragePrefix: "/test1", FederationPrefix: "/first/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: true, PublicReads: true, Listings: true, @@ -108,7 +109,7 @@ func TestGetExports(t *testing.T) { expectedExport2 := OriginExports{ StoragePrefix: "/test2", FederationPrefix: "/second/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: true, PublicReads: false, Listings: false, @@ -128,7 +129,7 @@ func TestGetExports(t *testing.T) { expectedExport1 := OriginExports{ StoragePrefix: "/test1", FederationPrefix: "/first/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: false, PublicReads: false, Listings: true, @@ -141,7 +142,7 @@ func TestGetExports(t *testing.T) { expectedExport2 := OriginExports{ StoragePrefix: "/test2", FederationPrefix: "/second/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: false, PublicReads: false, Listings: true, @@ -163,7 +164,7 @@ func TestGetExports(t *testing.T) { expectedExport := OriginExports{ StoragePrefix: "/test1", FederationPrefix: "/first/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: true, PublicReads: true, Listings: false, @@ -192,7 +193,7 @@ func TestGetExports(t *testing.T) { expectedExport := OriginExports{ StoragePrefix: "/test1", FederationPrefix: "/first/namespace", - Capabilities: Capabilities{ + Capabilities: server_structs.Capabilities{ Writes: false, PublicReads: true, Listings: false, diff --git a/common/resources/env-var-mimic.yml b/server_utils/resources/env-var-mimic.yml similarity index 100% rename from common/resources/env-var-mimic.yml rename to server_utils/resources/env-var-mimic.yml diff --git a/common/resources/export-volumes-valid.yml b/server_utils/resources/export-volumes-valid.yml similarity index 100% rename from common/resources/export-volumes-valid.yml rename to server_utils/resources/export-volumes-valid.yml diff --git a/common/resources/multi-export-valid.yml b/server_utils/resources/multi-export-valid.yml similarity index 100% rename from common/resources/multi-export-valid.yml rename to server_utils/resources/multi-export-valid.yml diff --git a/common/resources/single-export-block.yml b/server_utils/resources/single-export-block.yml similarity index 100% rename from common/resources/single-export-block.yml rename to server_utils/resources/single-export-block.yml diff --git a/common/resources/single-export-volume.yml b/server_utils/resources/single-export-volume.yml similarity index 100% rename from common/resources/single-export-volume.yml rename to server_utils/resources/single-export-volume.yml diff --git a/server_utils/server_utils.go b/server_utils/server_utils.go index 1d6de5256..707c51e71 100644 --- a/server_utils/server_utils.go +++ b/server_utils/server_utils.go @@ -16,6 +16,11 @@ * ***************************************************************/ +// server_utils package shares utility functions used across multiple server pacakges (origin, cache, registry, director). +// It should only import lower level packages (config, param, etc), or server_structs package. +// It should never import any server pacakges (origin, cache, registry, director) or upeer level packages (launcher_utils, cmd, etc). +// +// For structs used across multiple server pacakges, put them in common pacakge instead package server_utils import ( diff --git a/server_utils/test_file_transfer.go b/server_utils/test_file_transfer.go index 420d79cd8..dcbfd31d9 100644 --- a/server_utils/test_file_transfer.go +++ b/server_utils/test_file_transfer.go @@ -241,7 +241,7 @@ func (t TestFileTransferImpl) RunTests(ctx context.Context, baseUrl, audienceUrl // WLCG rules for issuer metadata discovery and public key access // // Read more: https://github.com/WLCG-AuthZ-WG/common-jwt-profile/blob/master/profile.md#token-verification -func (t TestFileTransferImpl) RunTestsCache(ctx context.Context, cacheUrl, issuerUrl string, filePath string, body string) (bool, error) { +func (t TestFileTransferImpl) TestCacheDownload(ctx context.Context, cacheUrl, issuerUrl string, filePath string, body string) (bool, error) { t.audiences = []string{"https://wlcg.cern.ch/jwt/v1/any"} t.issuerUrl = issuerUrl t.testBody = body diff --git a/xrootd/authorization.go b/xrootd/authorization.go index c6becf08d..3151ed5b0 100644 --- a/xrootd/authorization.go +++ b/xrootd/authorization.go @@ -42,11 +42,11 @@ import ( "github.com/pkg/errors" log "github.com/sirupsen/logrus" - "github.com/pelicanplatform/pelican/cache_ui" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/cache" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" ) @@ -210,7 +210,7 @@ func writeScitokensConfiguration(modules config.ServerType, cfg *ScitokensCfg) e // This function queries the topology url for the specific authfiles for the cache and origin // and returns a pointer to a byte buffer containing the file contents, returns nil if the // authfile doesn't exist - considering it an empty file -func getOSDFAuthFiles(server server_utils.XRootDServer) ([]byte, error) { +func getOSDFAuthFiles(server server_structs.XRootDServer) ([]byte, error) { var stype string if server.GetServerType().IsEnabled(config.OriginType) { stype = "origin" @@ -259,7 +259,7 @@ func getOSDFAuthFiles(server server_utils.XRootDServer) ([]byte, error) { // Parse the input xrootd authfile, add any default configurations, and then save it // into the xrootd runtime directory -func EmitAuthfile(server server_utils.XRootDServer) error { +func EmitAuthfile(server server_structs.XRootDServer) error { authfile := param.Xrootd_Authfile.GetString() log.Debugln("Location of input authfile:", authfile) contents, err := os.ReadFile(authfile) @@ -293,7 +293,7 @@ func EmitAuthfile(server server_utils.XRootDServer) error { if server.GetServerType().IsEnabled(config.OriginType) { outStr := "u * /.well-known lr " // Set up public reads only for the namespaces that are public - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return errors.Wrapf(err, "Failed to get origin exports") } @@ -323,7 +323,7 @@ func EmitAuthfile(server server_utils.XRootDServer) error { outStr := "u * /.well-known lr" // Configure the Authfile for each of the public exports we have in the origin - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return errors.Wrapf(err, "Failed to get origin exports") } @@ -537,14 +537,14 @@ func makeSciTokensCfg() (cfg ScitokensCfg, err error) { } // Writes out the server's scitokens.cfg configuration -func EmitScitokensConfig(server server_utils.XRootDServer) error { - if originServer, ok := server.(*origin_ui.OriginServer); ok { +func EmitScitokensConfig(server server_structs.XRootDServer) error { + if originServer, ok := server.(*origin.OriginServer); ok { authedPrefixes, err := originServer.GetAuthorizedPrefixes() if err != nil { return err } return WriteOriginScitokensConfig(authedPrefixes) - } else if cacheServer, ok := server.(*cache_ui.CacheServer); ok { + } else if cacheServer, ok := server.(*cache.CacheServer); ok { return WriteCacheScitokensConfig(cacheServer.GetNamespaceAds()) } else { return errors.New("Internal error: server object is neither cache nor origin") @@ -588,7 +588,7 @@ func WriteOriginScitokensConfig(exportedPaths []string) error { } // Writes out the cache's scitokens.cfg configuration -func WriteCacheScitokensConfig(nsAds []common.NamespaceAdV2) error { +func WriteCacheScitokensConfig(nsAds []server_structs.NamespaceAdV2) error { cfg, err := makeSciTokensCfg() if err != nil { return err diff --git a/xrootd/authorization_test.go b/xrootd/authorization_test.go index 217bb8e98..008dc84a6 100644 --- a/xrootd/authorization_test.go +++ b/xrootd/authorization_test.go @@ -36,11 +36,11 @@ import ( "strings" "testing" - "github.com/pelicanplatform/pelican/cache_ui" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/cache" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" "github.com/spf13/viper" @@ -167,7 +167,7 @@ func TestOSDFAuthRetrieval(t *testing.T) { viper.Set("Federation.TopologyUrl", "https://topology.opensciencegrid.org/") viper.Set("Server.Hostname", "sc-origin.chtc.wisc.edu") - originServer := &origin_ui.OriginServer{} + originServer := &origin.OriginServer{} _, err := getOSDFAuthFiles(originServer) require.NoError(t, err, "error") @@ -179,49 +179,49 @@ func TestOSDFAuthCreation(t *testing.T) { desc string authIn string authOut string - server server_utils.XRootDServer + server server_structs.XRootDServer hostname string }{ { desc: "osdf-origin-auth-no-merge", authIn: "", authOut: mergedAuthfileEntries, - server: &origin_ui.OriginServer{}, + server: &origin.OriginServer{}, hostname: "origin-test", }, { desc: "osdf-origin-auth-merge", authIn: cacheAuthfileMultilineInput, authOut: otherMergedAuthfileEntries, - server: &origin_ui.OriginServer{}, + server: &origin.OriginServer{}, hostname: "origin-test", }, { desc: "osdf-cache-auth-no-merge", authIn: "", authOut: cacheAuthfileEntries, - server: &cache_ui.CacheServer{}, + server: &cache.CacheServer{}, hostname: "cache-test", }, { desc: "osdf-cach-auth-merge", authIn: cacheAuthfileMultilineInput, authOut: cacheMergedAuthfileEntries, - server: &cache_ui.CacheServer{}, + server: &cache.CacheServer{}, hostname: "cache-test", }, { desc: "osdf-origin-no-authfile", authIn: "", authOut: "u * /.well-known lr\n", - server: &origin_ui.OriginServer{}, + server: &origin.OriginServer{}, hostname: "origin-test-empty", }, { desc: "osdf-cache-no-authfile", authIn: "", authOut: "", - server: &cache_ui.CacheServer{}, + server: &cache.CacheServer{}, hostname: "cache-test-empty", }, } @@ -261,9 +261,9 @@ func TestOSDFAuthCreation(t *testing.T) { t.Run(testInput.desc, func(t *testing.T) { dirName := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Xrootd.Authfile", filepath.Join(dirName, "authfile")) viper.Set("Federation.TopologyUrl", ts.URL) @@ -332,12 +332,12 @@ func TestEmitAuthfile(t *testing.T) { t.Run(testInput.desc, func(t *testing.T) { dirName := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Xrootd.Authfile", filepath.Join(dirName, "authfile")) viper.Set("Origin.RunLocation", dirName) - server := &origin_ui.OriginServer{} + server := &origin.OriginServer{} err := os.WriteFile(filepath.Join(dirName, "authfile"), []byte(testInput.authIn), fs.FileMode(0600)) require.NoError(t, err) @@ -356,9 +356,9 @@ func TestEmitAuthfile(t *testing.T) { func TestEmitCfg(t *testing.T) { dirname := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.RunLocation", dirname) err := config.InitClient() assert.Nil(t, err) @@ -387,9 +387,9 @@ func TestEmitCfg(t *testing.T) { func TestLoadScitokensConfig(t *testing.T) { dirname := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.RunLocation", dirname) err := config.InitClient() assert.Nil(t, err) @@ -422,9 +422,9 @@ func TestLoadScitokensConfig(t *testing.T) { func TestMergeConfig(t *testing.T) { dirname := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.RunLocation", dirname) viper.Set("Origin.Port", 8443) // We don't inherit any defaults at this level of code -- in order to recognize @@ -446,7 +446,7 @@ func TestMergeConfig(t *testing.T) { err = config.InitServer(ctx, config.OriginType) require.NoError(t, err) - err = EmitScitokensConfig(&origin_ui.OriginServer{}) + err = EmitScitokensConfig(&origin.OriginServer{}) require.NoError(t, err) cfg, err := LoadScitokensConfig(filepath.Join(dirname, "scitokens-origin-generated.cfg")) @@ -467,9 +467,9 @@ func TestGenerateConfig(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.SelfTest", false) issuer, err := GenerateMonitoringIssuer() require.NoError(t, err) @@ -509,11 +509,11 @@ func TestGenerateConfig(t *testing.T) { func TestWriteOriginAuthFiles(t *testing.T) { viper.Reset() - common.ResetOriginExports() - originAuthTester := func(server server_utils.XRootDServer, authStart string, authResult string) func(t *testing.T) { + server_utils.ResetOriginExports() + originAuthTester := func(server server_structs.XRootDServer, authStart string, authResult string) func(t *testing.T) { return func(t *testing.T) { defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.StorageType", "posix") dirname := t.TempDir() viper.Set("Origin.RunLocation", dirname) @@ -534,14 +534,14 @@ func TestWriteOriginAuthFiles(t *testing.T) { assert.Equal(t, authResult, string(authGen)) } } - nsAds := []common.NamespaceAdV2{} + nsAds := []server_structs.NamespaceAdV2{} - originServer := &origin_ui.OriginServer{} + originServer := &origin.OriginServer{} originServer.SetNamespaceAds(nsAds) t.Run("MultiIssuer", originAuthTester(originServer, "u * t1 lr t2 lr t3 lr", "u * /.well-known lr t1 lr t2 lr t3 lr\n")) - nsAds = []common.NamespaceAdV2{} + nsAds = []server_structs.NamespaceAdV2{} originServer.SetNamespaceAds(nsAds) t.Run("EmptyAuth", originAuthTester(originServer, "", "u * /.well-known lr\n")) @@ -553,7 +553,7 @@ func TestWriteOriginAuthFiles(t *testing.T) { func TestWriteCacheAuthFiles(t *testing.T) { - cacheAuthTester := func(server server_utils.XRootDServer, sciTokenResult string, authResult string) func(t *testing.T) { + cacheAuthTester := func(server server_structs.XRootDServer, sciTokenResult string, authResult string) func(t *testing.T) { return func(t *testing.T) { dirname := t.TempDir() @@ -604,22 +604,22 @@ func TestWriteCacheAuthFiles(t *testing.T) { issuer4URL.Scheme = "https" issuer4URL.Host = "issuer4.com" - PublicCaps := common.Capabilities{ + PublicCaps := server_structs.Capabilities{ PublicReads: true, Reads: true, Writes: true, } - PrivateCaps := common.Capabilities{ + PrivateCaps := server_structs.Capabilities{ PublicReads: false, Reads: true, Writes: true, } - nsAds := []common.NamespaceAdV2{ + nsAds := []server_structs.NamespaceAdV2{ { PublicRead: false, Caps: PrivateCaps, - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: issuer1URL, BasePaths: []string{"/p1"}, RestrictedPaths: []string{"/p1/nope", "p1/still_nope"}}}, @@ -627,7 +627,7 @@ func TestWriteCacheAuthFiles(t *testing.T) { { PublicRead: false, Caps: PrivateCaps, - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: issuer2URL, BasePaths: []string{"/p2/path", "/p2/foo", "/p2/baz"}, }}, @@ -640,7 +640,7 @@ func TestWriteCacheAuthFiles(t *testing.T) { { PublicRead: false, Caps: PrivateCaps, - Issuer: []common.TokenIssuer{{ + Issuer: []server_structs.TokenIssuer{{ IssuerUrl: issuer1URL, BasePaths: []string{"/p1_again"}, }, { @@ -660,12 +660,12 @@ func TestWriteCacheAuthFiles(t *testing.T) { }, } - cacheServer := &cache_ui.CacheServer{} + cacheServer := &cache.CacheServer{} cacheServer.SetNamespaceAds(nsAds) t.Run("MultiIssuer", cacheAuthTester(cacheServer, cacheSciOutput, "u * /p3 lr /p4/depth lr /p2_noauth lr \n")) - nsAds = []common.NamespaceAdV2{} + nsAds = []server_structs.NamespaceAdV2{} cacheServer.SetNamespaceAds(nsAds) t.Run("EmptyNS", cacheAuthTester(cacheServer, cacheEmptyOutput, "")) diff --git a/xrootd/origin_test.go b/xrootd/origin_test.go index ebb9ca3ed..33d6dcabb 100644 --- a/xrootd/origin_test.go +++ b/xrootd/origin_test.go @@ -40,9 +40,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" @@ -55,7 +54,7 @@ var ( ) func originMockup(ctx context.Context, egrp *errgroup.Group, t *testing.T) context.CancelFunc { - originServer := &origin_ui.OriginServer{} + originServer := &origin.OriginServer{} // Create our own temp directory (for some reason t.TempDir() does not play well with xrootd) tmpPathPattern := "XRD-Tst_Orgn*" @@ -87,7 +86,7 @@ func originMockup(ctx context.Context, egrp *errgroup.Group, t *testing.T) conte engine, err := web_ui.GetEngine() require.NoError(t, err) - err = origin_ui.ConfigIssJWKS(engine.Group("/.well-known")) + err = origin.RegisterOriginOIDCAPI(engine.Group("/.well-known")) require.NoError(t, err) shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) @@ -144,9 +143,9 @@ func TestOrigin(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.StoragePrefix", t.TempDir()) viper.Set("Origin.FederationPrefix", "/test") @@ -184,15 +183,15 @@ func TestMultiExportOrigin(t *testing.T) { viper.Reset() defer viper.Reset() - common.ResetOriginExports() - defer common.ResetOriginExports() + server_utils.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.SetConfigType("yaml") // Use viper to read in the embedded config err := viper.ReadConfig(strings.NewReader(multiExportOriginConfig)) require.NoError(t, err, "error reading config") - exports, err := common.GetOriginExports() + exports, err := server_utils.GetOriginExports() require.NoError(t, err) require.Len(t, *exports, 2) // Override the object store prefix to a temp directory @@ -231,9 +230,9 @@ func TestS3OriginConfig(t *testing.T) { defer cancel() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() tmpDir := t.TempDir() // We need to start up a minio server, which is how we emulate S3. Open to better ways to do this! diff --git a/xrootd/xrootd_config.go b/xrootd/xrootd_config.go index be47f367f..f3951d483 100644 --- a/xrootd/xrootd_config.go +++ b/xrootd/xrootd_config.go @@ -43,12 +43,12 @@ import ( "github.com/spf13/viper" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/cache_ui" - "github.com/pelicanplatform/pelican/common" + "github.com/pelicanplatform/pelican/cache" "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/metrics" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/utils" ) @@ -97,7 +97,7 @@ type ( S3AccessKeyfile string S3SecretKeyfile string S3UrlStyle string - Exports []common.OriginExports + Exports []server_utils.OriginExports } CacheConfig struct { @@ -172,10 +172,10 @@ type ( // CheckOriginXrootdEnv is almost a misnomer -- it does both checking and configuring. In partcicular, // it is responsible for setting up the exports and handling all the symlinking we use // to export our directories. -func CheckOriginXrootdEnv(exportPath string, server server_utils.XRootDServer, uid int, gid int, groupname string) error { +func CheckOriginXrootdEnv(exportPath string, server server_structs.XRootDServer, uid int, gid int, groupname string) error { // First we check if our config yaml contains the Exports block. If it does, we use that instead of the older legacy // options for all this configuration - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return err } @@ -207,7 +207,7 @@ func CheckOriginXrootdEnv(exportPath string, server server_utils.XRootDServer, u } if param.Origin_SelfTest.GetBool() { - if err := origin_ui.ConfigureXrootdMonitoringDir(); err != nil { + if err := origin.ConfigureXrootdMonitoringDir(); err != nil { return err } } @@ -243,7 +243,7 @@ func CheckOriginXrootdEnv(exportPath string, server server_utils.XRootDServer, u " to desired daemon group %v", macaroonsSecret, groupname) } // If the scitokens.cfg does not exist, create one - if originServer, ok := server.(*origin_ui.OriginServer); ok { + if originServer, ok := server.(*origin.OriginServer); ok { authedPrefixes, err := originServer.GetAuthorizedPrefixes() if err != nil { return err @@ -253,14 +253,14 @@ func CheckOriginXrootdEnv(exportPath string, server server_utils.XRootDServer, u return err } } - if err := origin_ui.ConfigureXrootdMonitoringDir(); err != nil { + if err := origin.ConfigureXrootdMonitoringDir(); err != nil { return err } return nil } -func CheckCacheXrootdEnv(exportPath string, server server_utils.XRootDServer, uid int, gid int) (string, error) { +func CheckCacheXrootdEnv(exportPath string, server server_structs.XRootDServer, uid int, gid int) (string, error) { viper.Set("Xrootd.Mount", exportPath) filepath.Join(exportPath, "/") err := config.MkdirAll(exportPath, 0775, uid, gid) @@ -325,7 +325,7 @@ func CheckCacheXrootdEnv(exportPath string, server server_utils.XRootDServer, ui return "", errors.New("One of Federation.DiscoveryUrl or Federation.DirectorUrl must be set to configure a cache") } - if cacheServer, ok := server.(*cache_ui.CacheServer); ok { + if cacheServer, ok := server.(*cache.CacheServer); ok { err := WriteCacheScitokensConfig(cacheServer.GetNamespaceAds()) if err != nil { return "", errors.Wrap(err, "Failed to create scitokens configuration for the cache") @@ -334,7 +334,7 @@ func CheckCacheXrootdEnv(exportPath string, server server_utils.XRootDServer, ui return exportPath, nil } -func CheckXrootdEnv(server server_utils.XRootDServer) error { +func CheckXrootdEnv(server server_structs.XRootDServer) error { uid, err := config.GetDaemonUID() if err != nil { return err @@ -477,7 +477,7 @@ func CheckXrootdEnv(server server_utils.XRootDServer) error { // certificate shows up atomically from XRootD's perspective. // Adjusts the ownership and mode to match that expected // by the XRootD framework. -func CopyXrootdCertificates(server server_utils.XRootDServer) error { +func CopyXrootdCertificates(server server_structs.XRootDServer) error { user, err := config.GetDaemonUserInfo() if err != nil { return errors.Wrap(err, "Unable to copy certificates to xrootd runtime directory; failed xrootd user lookup") @@ -537,7 +537,7 @@ func CopyXrootdCertificates(server server_utils.XRootDServer) error { // Launch a separate goroutine that performs the XRootD maintenance tasks. // For maintenance that is periodic, `sleepTime` is the maintenance period. -func LaunchXrootdMaintenance(ctx context.Context, server server_utils.XRootDServer, sleepTime time.Duration) { +func LaunchXrootdMaintenance(ctx context.Context, server server_structs.XRootDServer, sleepTime time.Duration) { server_utils.LaunchWatcherMaintenance( ctx, []string{ @@ -586,14 +586,14 @@ func ConfigXrootd(ctx context.Context, origin bool) (string, error) { var xrdConfig XrootdConfig xrdConfig.Xrootd.LocalMonitoringPort = -1 - if err := viper.Unmarshal(&xrdConfig, viper.DecodeHook(common.StringListToCapsHookFunc())); err != nil { + if err := viper.Unmarshal(&xrdConfig, viper.DecodeHook(server_utils.StringListToCapsHookFunc())); err != nil { return "", errors.Wrap(err, "failed to unmarshal xrootd config") } // To make sure we get the correct exports, we overwrite the exports in the xrdConfig struct with the exports - // we get from the common.GetOriginExports() function. Failure to do so will cause us to hit viper again, + // we get from the server_structs.GetOriginExports() function. Failure to do so will cause us to hit viper again, // which in the case of tests prevents us from overwriting some exports with temp dirs. - originExports, err := common.GetOriginExports() + originExports, err := server_utils.GetOriginExports() if err != nil { return "", errors.Wrap(err, "failed to generate Origin export list for xrootd config") } diff --git a/xrootd/xrootd_config_test.go b/xrootd/xrootd_config_test.go index 558ff9da6..34ee0fec7 100644 --- a/xrootd/xrootd_config_test.go +++ b/xrootd/xrootd_config_test.go @@ -38,10 +38,10 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/pelicanplatform/pelican/common" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/origin_ui" + "github.com/pelicanplatform/pelican/origin" "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" ) @@ -52,7 +52,7 @@ type xrootdTest struct { func (x *xrootdTest) setup() { viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() config.InitConfig() dirname, err := os.MkdirTemp("", "tmpDir") require.NoError(x.T, err) @@ -80,9 +80,9 @@ func TestXrootDOriginConfig(t *testing.T) { os.RemoveAll(dirname) }) viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Origin.RunLocation", dirname) viper.Set("Xrootd.RunLocation", dirname) config.InitConfig() @@ -250,7 +250,7 @@ func TestXrootDCacheConfig(t *testing.T) { os.RemoveAll(dirname) }) viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() viper.Set("Cache.RunLocation", dirname) config.InitConfig() configPath, err := ConfigXrootd(ctx, false) @@ -259,7 +259,7 @@ func TestXrootDCacheConfig(t *testing.T) { t.Run("TestCacheThrottlePluginEnabled", func(t *testing.T) { defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() xrootd := xrootdTest{T: t} xrootd.setup() @@ -284,7 +284,7 @@ func TestXrootDCacheConfig(t *testing.T) { t.Run("TestCacheThrottlePluginDisabled", func(t *testing.T) { defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() xrootd := xrootdTest{T: t} xrootd.setup() @@ -530,9 +530,9 @@ func TestUpdateAuth(t *testing.T) { runDirname := t.TempDir() configDirname := t.TempDir() viper.Reset() - common.ResetOriginExports() + server_utils.ResetOriginExports() defer viper.Reset() - defer common.ResetOriginExports() + defer server_utils.ResetOriginExports() viper.Set("Logging.Level", "Debug") viper.Set("Origin.RunLocation", runDirname) viper.Set("ConfigDir", configDirname) @@ -568,7 +568,7 @@ default_user = user2 err = os.WriteFile(authfileName, []byte(authfileDemo), fs.FileMode(0600)) require.NoError(t, err) - server := &origin_ui.OriginServer{} + server := &origin.OriginServer{} err = EmitScitokensConfig(server) require.NoError(t, err) @@ -632,14 +632,14 @@ func TestCopyCertificates(t *testing.T) { config.InitConfig() // First, invoke CopyXrootdCertificates directly, ensure it works. - err := CopyXrootdCertificates(&origin_ui.OriginServer{}) + err := CopyXrootdCertificates(&origin.OriginServer{}) assert.ErrorIs(t, err, errBadKeyPair) err = config.InitServer(ctx, config.OriginType) require.NoError(t, err) err = config.MkdirAll(path.Dir(param.Xrootd_Authfile.GetString()), 0755, -1, -1) require.NoError(t, err) - err = CopyXrootdCertificates(&origin_ui.OriginServer{}) + err = CopyXrootdCertificates(&origin.OriginServer{}) require.NoError(t, err) destKeyPairName := filepath.Join(param.Origin_RunLocation.GetString(), "copied-tls-creds.crt") assert.FileExists(t, destKeyPairName) @@ -659,7 +659,7 @@ func TestCopyCertificates(t *testing.T) { err = os.Rename(certName, certName+".orig") require.NoError(t, err) - err = CopyXrootdCertificates(&origin_ui.OriginServer{}) + err = CopyXrootdCertificates(&origin.OriginServer{}) assert.ErrorIs(t, err, errBadKeyPair) err = os.Rename(keyName, keyName+".orig") @@ -668,14 +668,14 @@ func TestCopyCertificates(t *testing.T) { err = config.InitServer(ctx, config.OriginType) require.NoError(t, err) - err = CopyXrootdCertificates(&origin_ui.OriginServer{}) + err = CopyXrootdCertificates(&origin.OriginServer{}) require.NoError(t, err) secondKeyPairContents, err := os.ReadFile(destKeyPairName) require.NoError(t, err) assert.False(t, bytes.Equal(firstKeyPairContents, secondKeyPairContents)) - originServer := &origin_ui.OriginServer{} + originServer := &origin.OriginServer{} LaunchXrootdMaintenance(ctx, originServer, 2*time.Hour) // Helper function to wait for a copy of the first cert to show up