diff --git a/client/director.go b/client/director.go index 9df8545f5..41ba9dde9 100644 --- a/client/director.go +++ b/client/director.go @@ -124,7 +124,9 @@ func CreateNsFromDirectorResp(dirResp *http.Response) (namespace namespaces.Name return } -func QueryDirector(source string, directorUrl string) (resp *http.Response, err error) { +// Make a request to the director for a given verb/resource; return the +// HTTP response object only if a 307 is returned. +func queryDirector(verb, source, directorUrl string) (resp *http.Response, err error) { resourceUrl := directorUrl + source // Here we use http.Transport to prevent the client from following the director's // redirect. We use the Location url elsewhere (plus we still need to do the token @@ -138,7 +140,7 @@ func QueryDirector(source string, directorUrl string) (resp *http.Response, err }, } - req, err := http.NewRequest("GET", resourceUrl, nil) + req, err := http.NewRequest(verb, resourceUrl, nil) if err != nil { log.Errorln("Failed to create an HTTP request:", err) return nil, err @@ -172,7 +174,7 @@ func QueryDirector(source string, directorUrl string) (resp *http.Response, err if unmarshalErr := json.Unmarshal(body, &respErr); unmarshalErr != nil { // Error creating json return nil, errors.Wrap(unmarshalErr, "Could not unmarshall the director's response") } - return nil, errors.Errorf("The director reported an error: %s\n", respErr.Error) + return resp, errors.Errorf("The director reported an error: %s", respErr.Error) } return diff --git a/client/director_test.go b/client/director_test.go index b2f134c7d..200d0352c 100644 --- a/client/director_test.go +++ b/client/director_test.go @@ -20,13 +20,14 @@ package client import ( "bytes" - "github.com/stretchr/testify/assert" "io" "net/http" "net/http/httptest" "os" "testing" + "github.com/stretchr/testify/assert" + namespaces "github.com/pelicanplatform/pelican/namespaces" ) @@ -194,7 +195,7 @@ func TestQueryDirector(t *testing.T) { defer server.Close() // Call QueryDirector with the test server URL and a source path - actualResp, err := QueryDirector("/foo/bar", server.URL) + actualResp, err := queryDirector("GET", "/foo/bar", server.URL) if err != nil { t.Fatal(err) } diff --git a/client/handle_http.go b/client/handle_http.go index a53640d81..df9f58a92 100644 --- a/client/handle_http.go +++ b/client/handle_http.go @@ -19,7 +19,6 @@ package client import ( - "bytes" "context" "fmt" "io" @@ -849,40 +848,16 @@ func UploadFile(src string, origDest *url.URL, token string, namespace namespace nonZeroSize = fileInfo.Size() > 0 } - // call a GET on the director, director will respond with our endpoint - directorUrlStr := param.Federation_DirectorUrl.GetString() - directorUrl, err := url.Parse(directorUrlStr) - if err != nil { - return 0, errors.Wrap(err, "failed to parse director url") - } - directorUrl.Path, err = url.JoinPath("/api/v1.0/director/origin", origDest.Path) - if err != nil { - return 0, errors.Wrap(err, "failed to parse director path for upload") - } - - payload := []byte("forPUT") - req, err := http.NewRequest("GET", directorUrl.String(), bytes.NewBuffer(payload)) + // Parse the writeback host as a URL + writebackhostUrl, err := url.Parse(namespace.WriteBackHost) if err != nil { - return 0, errors.Wrap(err, "failed to construct request for director-origin query") + return 0, err } - client := &http.Client{ - Transport: config.GetTransport(), - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Do(req) - if err != nil { - return 0, errors.Wrap(err, "failed to send request to director to obtain upload endpoint") - } - if resp.StatusCode == 405 { - return 0, errors.New("Error 405: No writeable origins were found") - } - defer resp.Body.Close() - dest, err := url.Parse(resp.Header.Get("Location")) - if err != nil { - return 0, errors.Wrap(err, "failed to parse location header from director response") + dest := &url.URL{ + Host: writebackhostUrl.Host, + Scheme: "https", + Path: origDest.Path, } // Create the wrapped reader and send it to the request diff --git a/client/handle_http_test.go b/client/handle_http_test.go index ff5bc90ad..91282ac86 100644 --- a/client/handle_http_test.go +++ b/client/handle_http_test.go @@ -386,7 +386,7 @@ func TestFailedUpload(t *testing.T) { func generateFileTestScitoken() (string, error) { // Issuer is whichever server that initiates the test, so it's the server itself issuerUrl := param.Origin_Url.GetString() - if issuerUrl == "" { // if both are empty, then error + if issuerUrl == "" { // if empty, then error return "", errors.New("Failed to create token: Invalid iss, Server_ExternalWebUrl is empty") } jti_bytes := make([]byte, 16) @@ -400,7 +400,7 @@ func generateFileTestScitoken() (string, error) { Claim("wlcg.ver", "1.0"). JwtID(jti). Issuer(issuerUrl). - Audience([]string{"https://wlcg.cern.ch/jwt/v1/any"}). + Audience([]string{param.Origin_Url.GetString()}). Subject("origin"). Expiration(time.Now().Add(time.Minute)). IssuedAt(time.Now()). @@ -452,7 +452,7 @@ func TestFullUpload(t *testing.T) { viper.Set("ConfigDir", tmpPath) // Increase the log level; otherwise, its difficult to debug failures - // viper.Set("Logging.Level", "Debug") + viper.Set("Logging.Level", "Debug") config.InitConfig() originDir, err := os.MkdirTemp("", "Origin") @@ -469,10 +469,11 @@ func TestFullUpload(t *testing.T) { viper.Set("Origin.EnableCmsd", false) viper.Set("Origin.EnableMacaroons", false) viper.Set("Origin.EnableVoms", false) - viper.Set("Origin.WriteEnabled", true) + viper.Set("Origin.EnableWrite", true) viper.Set("TLSSkipVerify", true) viper.Set("Server.EnableUI", false) viper.Set("Registry.DbLocation", filepath.Join(t.TempDir(), "ns-registry.sqlite")) + viper.Set("Xrootd.RunLocation", tmpPath) err = config.InitServer(ctx, modules) require.NoError(t, err) @@ -525,7 +526,7 @@ func TestFullUpload(t *testing.T) { defer os.Remove(tempToken.Name()) _, err = tempToken.WriteString(token) assert.NoError(t, err, "Error writing to temp token file") - tempFile.Close() + tempToken.Close() ObjectClientOptions.Token = tempToken.Name() // Upload the file diff --git a/client/main.go b/client/main.go index 89b87598e..a6e829e68 100644 --- a/client/main.go +++ b/client/main.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net" + "net/http" "net/url" "regexp" "runtime/debug" @@ -412,6 +413,58 @@ func discoverHTCondorToken(tokenName string) string { return tokenLocation } +// Retrieve federation namespace information for a given URL. +// If OSDFDirectorUrl is non-empty, then the namespace information will be pulled from the director; +// otherwise, it is pulled from topology. +func getNamespaceInfo(resourcePath, OSDFDirectorUrl string, isPut bool) (ns namespaces.Namespace, err error) { + // If we have a director set, go through that for namespace info, otherwise use topology + if OSDFDirectorUrl != "" { + log.Debugln("Will query director at", OSDFDirectorUrl, "for object", resourcePath) + verb := "GET" + if isPut { + verb = "PUT" + } + var dirResp *http.Response + dirResp, err = queryDirector(verb, resourcePath, OSDFDirectorUrl) + if err != nil { + if isPut && dirResp != nil && dirResp.StatusCode == 405 { + err = errors.New("Error 405: No writeable origins were found") + AddError(err) + return + } else { + log.Errorln("Error while querying the Director:", err) + AddError(err) + return + } + } + ns, err = CreateNsFromDirectorResp(dirResp) + if err != nil { + AddError(err) + return + } + + // if we are doing a PUT, we need to get our endpoint from the director + if isPut { + var writeBackUrl *url.URL + location := dirResp.Header.Get("Location") + writeBackUrl, err = url.Parse(location) + if err != nil { + log.Errorf("The director responded with an invalid location (does not parse as URL: %v): %s", err, location) + return + } + ns.WriteBackHost = "https://" + writeBackUrl.Host + } + return + } else { + ns, err = namespaces.MatchNamespace(resourcePath) + if err != nil { + AddError(err) + return + } + return + } +} + // Start the transfer, whether read or write back func DoStashCPSingle(sourceFile string, destination string, methods []string, recursive bool) (bytesTransferred int64, err error) { @@ -499,41 +552,14 @@ func DoStashCPSingle(sourceFile string, destination string, methods []string, re // For read it will be the source. OSDFDirectorUrl := param.Federation_DirectorUrl.GetString() - useOSDFDirector := viper.IsSet("Federation.DirectorURL") + isPut := destScheme == "stash" || destScheme == "osdf" || destScheme == "pelican" - if destScheme == "stash" || destScheme == "osdf" || destScheme == "pelican" { - log.Debugln("Detected writeback") - if !strings.HasPrefix(destination, "/") { - destination = strings.TrimPrefix(destination, destScheme+"://") - } - var ns namespaces.Namespace - // If we have a director set, go through that for namespace info, otherwise use topology - if useOSDFDirector { - directorOriginsUrl, err := url.Parse(OSDFDirectorUrl) - if err != nil { - return 0, err - } - directorOriginsUrl.Path, err = url.JoinPath("api", "v1.0", "director", "origin") - if err != nil { - return 0, err - } - dirResp, err := QueryDirector(destination, directorOriginsUrl.String()) - if err != nil { - log.Errorln("Error while querying the Director:", err) - AddError(err) - return 0, err - } - ns, err = CreateNsFromDirectorResp(dirResp) - if err != nil { - AddError(err) - return 0, err - } - } else { - ns, err = namespaces.MatchNamespace(dest_url.Path) - if err != nil { - AddError(err) - return 0, err - } + if isPut { + log.Debugln("Detected object write to remote federation object", dest_url.Path) + ns, err := getNamespaceInfo(dest_url.Path, OSDFDirectorUrl, isPut) + if err != nil { + log.Errorln(err) + return 0, errors.New("Failed to get namespace information from destination") } uploadedBytes, err := doWriteBack(source_url.Path, dest_url, ns, recursive) AddError(err) @@ -552,26 +578,10 @@ func DoStashCPSingle(sourceFile string, destination string, methods []string, re sourceFile = "/" + sourceFile } - var ns namespaces.Namespace - // If we have a director set, go through that for namespace info, otherwise use topology - if useOSDFDirector { - dirResp, err := QueryDirector(sourceFile, OSDFDirectorUrl) - if err != nil { - log.Errorln("Error while querying the Director:", err) - AddError(err) - return 0, err - } - ns, err = CreateNsFromDirectorResp(dirResp) - if err != nil { - AddError(err) - return 0, err - } - } else { - ns, err = namespaces.MatchNamespace(source_url.Path) - if err != nil { - AddError(err) - return 0, err - } + ns, err := getNamespaceInfo(sourceFile, OSDFDirectorUrl, isPut) + if err != nil { + log.Errorln(err) + return 0, errors.New("Failed to get namespace information from source") } // get absolute path diff --git a/client/sharing_url.go b/client/sharing_url.go index d13a3bdaa..ea55b2739 100644 --- a/client/sharing_url.go +++ b/client/sharing_url.go @@ -84,7 +84,7 @@ func CreateSharingUrl(objectUrl *url.URL, isWrite bool) (string, error) { objectUrl.Path = "/" + strings.TrimPrefix(objectUrl.Path, "/") log.Debugln("Will query director for path", objectUrl.Path) - dirResp, err := QueryDirector(objectUrl.Path, directorUrl) + dirResp, err := queryDirector("GET", objectUrl.Path, directorUrl) if err != nil { log.Errorln("Error while querying the Director:", err) return "", errors.Wrapf(err, "Error while querying the director at %s", directorUrl) diff --git a/cmd/origin.go b/cmd/origin.go index 845a4c0b0..8405ede61 100644 --- a/cmd/origin.go +++ b/cmd/origin.go @@ -122,7 +122,7 @@ func init() { // The -w flag is used if we want the origin to be writeable. originServeCmd.Flags().BoolP("writeable", "", true, "Allow/disable writting to the origin") - if err := viper.BindPFlag("Origin.WriteEnabled", originServeCmd.Flags().Lookup("writeable")); err != nil { + if err := viper.BindPFlag("Origin.EnableWrite", originServeCmd.Flags().Lookup("writeable")); err != nil { panic(err) } diff --git a/config/resources/defaults.yaml b/config/resources/defaults.yaml index 1f52dc7ad..0bb477752 100644 --- a/config/resources/defaults.yaml +++ b/config/resources/defaults.yaml @@ -31,6 +31,7 @@ Origin: EnableMacaroons: false EnableVoms: true EnableUI: true + EnableWrite: true SelfTest: true Monitoring: PortLower: 9930 diff --git a/director/advertise.go b/director/advertise.go index 5da7b163a..ba32f1cb3 100644 --- a/director/advertise.go +++ b/director/advertise.go @@ -36,7 +36,7 @@ func parseServerAd(server utils.Server, serverType ServerType) ServerAd { serverAd.Type = serverType serverAd.Name = server.Resource - serverAd.WriteEnabled = param.Origin_WriteEnabled.GetBool() + serverAd.EnableWrite = param.Origin_EnableWrite.GetBool() // url.Parse requires that the scheme be present before the hostname, // but endpoints do not have a scheme. As such, we need to add one for the. // correct parsing. Luckily, we don't use this anywhere else (it's just to diff --git a/director/cache_ads.go b/director/cache_ads.go index c71afc752..dffb6c0c8 100644 --- a/director/cache_ads.go +++ b/director/cache_ads.go @@ -46,14 +46,15 @@ type ( } ServerAd struct { - Name string - AuthURL url.URL - 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 - WriteEnabled bool + Name string + AuthURL url.URL + 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 + EnableWrite bool + EnableFallbackRead bool // True if reads from the origin are permitted when no cache is available } ServerType string diff --git a/director/origin_api.go b/director/origin_api.go index 13aaa2790..39588e6be 100644 --- a/director/origin_api.go +++ b/director/origin_api.go @@ -41,11 +41,12 @@ import ( type ( OriginAdvertise struct { - Name string `json:"name"` - URL string `json:"url"` // 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 []NamespaceAd `json:"namespaces"` - WriteEnabled bool `json:"writeenabled"` + Name string `json:"name"` + URL string `json:"url"` // 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 []NamespaceAd `json:"namespaces"` + EnableWrite bool `json:"enablewrite"` + EnableFallbackRead bool `json:"enable-fallback-read"` // True if the origin will allow direct client reads when no caches are available } ) diff --git a/director/redirect.go b/director/redirect.go index 6f960cf02..638ff5f73 100644 --- a/director/redirect.go +++ b/director/redirect.go @@ -21,7 +21,6 @@ package director import ( "context" "fmt" - "io" "net/http" "net/netip" "net/url" @@ -180,7 +179,7 @@ func RedirectToCache(ginCtx *gin.Context) { authzBearerEscaped := getAuthzEscaped(ginCtx.Request) - namespaceAd, _, cacheAds := GetAdsForPath(reqPath) + 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 @@ -190,13 +189,22 @@ func RedirectToCache(ginCtx *gin.Context) { } // If the namespace prefix DOES exist, then it makes sense to say we couldn't find a valid cache. if len(cacheAds) == 0 { - ginCtx.String(404, "No cache found for path\n") - return - } - cacheAds, err = SortServers(ipAddr, cacheAds) - if err != nil { - ginCtx.String(500, "Failed to determine server ordering") - return + for _, originAd := range originAds { + if originAd.EnableFallbackRead { + 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 { + ginCtx.String(http.StatusInternalServerError, "Failed to determine server ordering") + return + } } redirectURL := getRedirectURL(reqPath, cacheAds[0], namespaceAd.RequireToken) @@ -287,16 +295,10 @@ func RedirectToOrigin(ginCtx *gin.Context) { namespaceAd.Path, namespaceAd.RequireToken, namespaceAd.DirlistHost)} var redirectURL url.URL - body, err := io.ReadAll(ginCtx.Request.Body) - if err != nil { - ginCtx.String(http.StatusInternalServerError, "Could not read body of request\n") - return - } - // If we are doing a PUT, check to see if any origins are writeable - if strings.Contains(string(body), "forPUT") { + if ginCtx.Request.Method == "PUT" { for idx, ad := range originAds { - if ad.WriteEnabled { + if ad.EnableWrite { redirectURL = getRedirectURL(reqPath, originAds[idx], namespaceAd.RequireToken) ginCtx.Redirect(http.StatusTemporaryRedirect, getFinalRedirectURL(redirectURL, authzBearerEscaped)) return @@ -351,6 +353,13 @@ func ShortcutMiddleware(defaultResponse string) gin.HandlerFunc { 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 @@ -462,12 +471,13 @@ func registerServeAd(engineCtx context.Context, ctx *gin.Context, sType ServerTy } sAd := ServerAd{ - Name: ad.Name, - AuthURL: *ad_url, - URL: *ad_url, - WebURL: *adWebUrl, - Type: sType, - WriteEnabled: ad.WriteEnabled, + Name: ad.Name, + AuthURL: *ad_url, + URL: *ad_url, + WebURL: *adWebUrl, + Type: sType, + EnableWrite: ad.EnableWrite, + EnableFallbackRead: ad.EnableFallbackRead, } hasOriginAdInCache := serverAds.Has(sAd) @@ -552,6 +562,7 @@ func RegisterDirector(ctx context.Context, router *gin.RouterGroup) { // Establish the routes used for cache/origin redirection router.GET("/api/v1.0/director/object/*any", RedirectToCache) router.GET("/api/v1.0/director/origin/*any", RedirectToOrigin) + router.PUT("/api/v1.0/director/origin/*any", RedirectToOrigin) router.POST("/api/v1.0/director/registerOrigin", func(gctx *gin.Context) { RegisterOrigin(ctx, gctx) }) // 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. diff --git a/director/redirect_test.go b/director/redirect_test.go index 6c387c153..577230dc7 100644 --- a/director/redirect_test.go +++ b/director/redirect_test.go @@ -679,6 +679,13 @@ func TestRedirects(t *testing.T) { 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) diff --git a/docs/parameters.yaml b/docs/parameters.yaml index 95b1080b7..d12663f63 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -302,13 +302,21 @@ type: string default: none components: ["origin"] --- -name: Origin.WriteEnabled +name: Origin.EnableWrite description: >- - A boolean indicating if an origin is writeable on startup + A boolean indicating if an origin allows write access type: bool default: true components: ["origin"] --- +name: Origin.EnableFallbackRead +description: >- + Set to `true` if the origin permits clients to directly read from it + when no cache service is available +type: bool +default: false +components: ["origin"] +--- name: Origin.Multiuser description: >- A bool indicating whether an origin is "multiuser", ie whether the underlying XRootD instance must be configured in multi user mode. diff --git a/origin_ui/advertise.go b/origin_ui/advertise.go index f95774a8c..bf2df6103 100644 --- a/origin_ui/advertise.go +++ b/origin_ui/advertise.go @@ -53,7 +53,6 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrl string, o prefix := param.Origin_NamespacePrefix.GetString() - writeEnabled := param.Origin_WriteEnabled.GetBool() // TODO: Need to figure out where to get some of these values // so that they aren't hardcoded... nsAd := director.NamespaceAd{ @@ -65,11 +64,12 @@ func (server *OriginServer) CreateAdvertisement(name string, originUrl string, o BasePath: prefix, } ad = director.OriginAdvertise{ - Name: name, - URL: originUrl, - WebURL: originWebUrl, - Namespaces: []director.NamespaceAd{nsAd}, - WriteEnabled: writeEnabled, + Name: name, + URL: originUrl, + WebURL: originWebUrl, + Namespaces: []director.NamespaceAd{nsAd}, + EnableWrite: param.Origin_EnableWrite.GetBool(), + EnableFallbackRead: param.Origin_EnableFallbackRead.GetBool(), } return ad, nil diff --git a/param/parameters.go b/param/parameters.go index a9edc2dec..e793442f9 100644 --- a/param/parameters.go +++ b/param/parameters.go @@ -170,13 +170,14 @@ var ( Monitoring_MetricAuthorization = BoolParam{"Monitoring.MetricAuthorization"} Origin_EnableCmsd = BoolParam{"Origin.EnableCmsd"} Origin_EnableDirListing = BoolParam{"Origin.EnableDirListing"} + Origin_EnableFallbackRead = BoolParam{"Origin.EnableFallbackRead"} Origin_EnableIssuer = BoolParam{"Origin.EnableIssuer"} Origin_EnableUI = BoolParam{"Origin.EnableUI"} Origin_EnableVoms = BoolParam{"Origin.EnableVoms"} + Origin_EnableWrite = BoolParam{"Origin.EnableWrite"} Origin_Multiuser = BoolParam{"Origin.Multiuser"} Origin_ScitokensMapSubject = BoolParam{"Origin.ScitokensMapSubject"} Origin_SelfTest = BoolParam{"Origin.SelfTest"} - Origin_WriteEnabled = BoolParam{"Origin.WriteEnabled"} Registry_RequireKeyChaining = BoolParam{"Registry.RequireKeyChaining"} Server_EnableUI = BoolParam{"Server.EnableUI"} StagePlugin_Hook = BoolParam{"StagePlugin.Hook"} diff --git a/param/parameters_struct.go b/param/parameters_struct.go index a2a276c38..37c9bfdd7 100644 --- a/param/parameters_struct.go +++ b/param/parameters_struct.go @@ -84,9 +84,11 @@ type config struct { Origin struct { EnableCmsd bool EnableDirListing bool + EnableFallbackRead bool EnableIssuer bool EnableUI bool EnableVoms bool + EnableWrite bool ExportVolume string Mode string Multiuser bool @@ -104,7 +106,6 @@ type config struct { ScitokensUsernameClaim string SelfTest bool Url string - WriteEnabled bool XRootDPrefix string } Plugin struct { @@ -247,9 +248,11 @@ type configWithType struct { Origin struct { EnableCmsd struct { Type string; Value bool } EnableDirListing struct { Type string; Value bool } + EnableFallbackRead struct { Type string; Value bool } EnableIssuer struct { Type string; Value bool } EnableUI struct { Type string; Value bool } EnableVoms struct { Type string; Value bool } + EnableWrite struct { Type string; Value bool } ExportVolume struct { Type string; Value string } Mode struct { Type string; Value string } Multiuser struct { Type string; Value bool } @@ -267,7 +270,6 @@ type configWithType struct { ScitokensUsernameClaim struct { Type string; Value string } SelfTest struct { Type string; Value bool } Url struct { Type string; Value string } - WriteEnabled struct { Type string; Value bool } XRootDPrefix struct { Type string; Value string } } Plugin struct {