Skip to content

Commit a259732

Browse files
Copilotbbockelm
authored andcommitted
Provide an E2E cache test for offline origins
Shows that data can be accessed at a cache even when the origin is offline!
1 parent 72ec774 commit a259732

File tree

1 file changed

+296
-0
lines changed

1 file changed

+296
-0
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
//go:build !windows
2+
3+
/***************************************************************
4+
*
5+
* Copyright (C) 2026, Pelican Project, Morgridge Institute for Research
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License"); you
8+
* may not use this file except in compliance with the License. You may
9+
* obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*
19+
***************************************************************/
20+
21+
package fed_tests
22+
23+
import (
24+
"bytes"
25+
"context"
26+
"encoding/json"
27+
"fmt"
28+
"net/http"
29+
"net/url"
30+
"os"
31+
"path/filepath"
32+
"strings"
33+
"testing"
34+
"time"
35+
36+
_ "github.com/glebarez/sqlite"
37+
"github.com/stretchr/testify/assert"
38+
"github.com/stretchr/testify/require"
39+
40+
"github.com/pelicanplatform/pelican/client"
41+
"github.com/pelicanplatform/pelican/config"
42+
"github.com/pelicanplatform/pelican/fed_test_utils"
43+
"github.com/pelicanplatform/pelican/param"
44+
"github.com/pelicanplatform/pelican/server_structs"
45+
"github.com/pelicanplatform/pelican/server_utils"
46+
"github.com/pelicanplatform/pelican/test_utils"
47+
"github.com/pelicanplatform/pelican/token"
48+
"github.com/pelicanplatform/pelican/token_scopes"
49+
)
50+
51+
// TestCacheScitokensConfigOverride tests that Xrootd.ScitokensConfig works for caches
52+
// to serve cached objects during origin downtime. This test:
53+
// 1. Sets up a full federation with private reads and pulls a file through the cache
54+
// 2. Simulates origin downtime by POSTing a new origin ad without the /test namespace
55+
// 3. Triggers cache authz refresh by overwriting Xrootd.ScitokensConfig with unrelated issuer
56+
// 4. Verifies data is no longer accessible through the cache (authorization removed)
57+
// 5. Triggers another authz refresh with proper authorization for the test prefix
58+
// 6. Verifies cached object is now accessible even with origin "offline"
59+
func TestCacheScitokensConfigOverride(t *testing.T) {
60+
t.Cleanup(test_utils.SetupTestLogging(t))
61+
server_utils.ResetTestState()
62+
defer server_utils.ResetTestState()
63+
ctx, cancel, egrp := test_utils.TestContext(context.Background(), t)
64+
t.Cleanup(func() {
65+
cancel()
66+
require.NoError(t, egrp.Wait())
67+
server_utils.ResetTestState()
68+
})
69+
70+
// Create test directories and file
71+
tmpDir := t.TempDir()
72+
testFileContent := "test file content for cache scitokens override test"
73+
testFileName := "test_file.txt"
74+
testFilePath := filepath.Join(tmpDir, testFileName)
75+
err := os.WriteFile(testFilePath, []byte(testFileContent), 0644)
76+
require.NoError(t, err)
77+
78+
// Set up Xrootd.ScitokensConfig location
79+
scitokensConfigPath := filepath.Join(tmpDir, "scitokens.cfg")
80+
require.NoError(t, param.Set(param.Xrootd_ScitokensConfig.GetName(), scitokensConfigPath))
81+
82+
// Set floor to 0 to allow immediate director refreshes when scitokens config changes
83+
require.NoError(t, param.Set(param.Cache_MinDirectorRefreshInterval.GetName(), "0s"))
84+
85+
// Use long-lived ads so they don't expire during test
86+
require.NoError(t, param.Set(param.Server_AdLifetime.GetName(), "1h"))
87+
88+
// Create origin configuration with private reads (requires tokens)
89+
originConfig := `
90+
Origin:
91+
StorageType: posix
92+
Exports:
93+
- FederationPrefix: /test
94+
StoragePrefix: ` + tmpDir + `
95+
Capabilities: ["Reads", "Writes", "Listings"]
96+
`
97+
98+
// Set up the federation
99+
_ = fed_test_utils.NewFedTest(t, originConfig)
100+
101+
// Get the server issuer URL for creating tokens
102+
serverIssuerUrl, err := config.GetServerIssuerURL()
103+
require.NoError(t, err, "Failed to get server issuer URL")
104+
105+
// Create a token for accessing the object
106+
tokenConfig := token.NewWLCGToken()
107+
tokenConfig.Lifetime = 30 * time.Minute
108+
tokenConfig.Issuer = serverIssuerUrl
109+
tokenConfig.Subject = "test-subject"
110+
tokenConfig.AddAudienceAny()
111+
112+
scopes := []token_scopes.TokenScope{}
113+
readScope, err := token_scopes.Wlcg_Storage_Read.Path("/")
114+
require.NoError(t, err)
115+
scopes = append(scopes, readScope)
116+
modScope, err := token_scopes.Wlcg_Storage_Modify.Path("/")
117+
require.NoError(t, err)
118+
scopes = append(scopes, modScope)
119+
tokenConfig.AddScopes(scopes...)
120+
121+
tok, err := tokenConfig.CreateToken()
122+
require.NoError(t, err)
123+
124+
// Construct pelican URL for file operations
125+
pelicanUrl := fmt.Sprintf("pelican://%s:%d/test/%s",
126+
param.Server_Hostname.GetString(), param.Server_WebPort.GetInt(), testFileName)
127+
128+
// Upload the test file to the origin
129+
_, err = client.DoPut(ctx, testFilePath, pelicanUrl, false, client.WithToken(tok))
130+
require.NoError(t, err, "Should be able to upload file to origin")
131+
132+
// Step 1: Download through the federation to populate the cache
133+
destPath1 := filepath.Join(tmpDir, "downloaded1.txt")
134+
transferResults, err := client.DoGet(ctx, pelicanUrl, destPath1, false, client.WithToken(tok))
135+
require.NoError(t, err, "Should be able to download file through federation")
136+
assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes)
137+
138+
content1, err := os.ReadFile(destPath1)
139+
require.NoError(t, err)
140+
assert.Equal(t, testFileContent, string(content1), "Downloaded content should match original")
141+
142+
// Step 2: Simulate origin downtime by POSTing new origin ad without /test namespace
143+
metadata, err := server_utils.GetServerMetadata(ctx, server_structs.OriginType)
144+
require.NoError(t, err)
145+
146+
issuerUrlStr, err := config.GetServerIssuerURL()
147+
require.NoError(t, err)
148+
issuerUrl, err := url.Parse(issuerUrlStr)
149+
require.NoError(t, err)
150+
151+
// Create advertisement with empty namespace list
152+
emptyAd := server_structs.OriginAdvertiseV2{
153+
ServerID: metadata.ID,
154+
DataURL: param.Origin_Url.GetString(),
155+
WebURL: param.Server_ExternalWebUrl.GetString(),
156+
Namespaces: []server_structs.NamespaceAdV2{},
157+
Issuer: []server_structs.TokenIssuer{{
158+
IssuerUrl: *issuerUrl,
159+
}},
160+
StorageType: server_structs.OriginStoragePosix,
161+
}
162+
emptyAd.Initialize(metadata.Name)
163+
emptyAd.Now = time.Now()
164+
165+
body, err := json.Marshal(emptyAd)
166+
require.NoError(t, err)
167+
168+
directorUrlStr := param.Server_ExternalWebUrl.GetString() + "/api/v1.0/director/registerOrigin"
169+
directorUrl, err := url.Parse(directorUrlStr)
170+
require.NoError(t, err)
171+
172+
// Create advertisement token
173+
advTokenCfg := token.NewWLCGToken()
174+
advTokenCfg.Lifetime = time.Minute
175+
advTokenCfg.Issuer = issuerUrlStr
176+
advTokenCfg.AddAudienceAny()
177+
advTokenCfg.Subject = param.Server_Hostname.GetString()
178+
advTokenCfg.AddScopes(token_scopes.Pelican_Advertise)
179+
advTok, err := advTokenCfg.CreateToken()
180+
require.NoError(t, err)
181+
182+
// POST the empty advertisement
183+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, directorUrl.String(), bytes.NewBuffer(body))
184+
require.NoError(t, err)
185+
req.Header.Set("Content-Type", "application/json")
186+
req.Header.Set("Authorization", "Bearer "+advTok)
187+
req.Header.Set("User-Agent", "pelican-test/"+config.GetVersion())
188+
189+
tr := config.GetTransport()
190+
httpClient := &http.Client{Transport: tr}
191+
resp, err := httpClient.Do(req)
192+
require.NoError(t, err)
193+
defer resp.Body.Close()
194+
require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to register empty origin advertisement")
195+
196+
// Wait for director to process the empty advertisement and remove /test namespace
197+
namespacesUrl := param.Server_ExternalWebUrl.GetString() + "/api/v1.0/director/listNamespaces"
198+
require.Eventually(t, func() bool {
199+
resp, err := httpClient.Get(namespacesUrl)
200+
if err != nil {
201+
return false
202+
}
203+
defer resp.Body.Close()
204+
205+
var namespaces []server_structs.NamespaceAdV2
206+
if err := json.NewDecoder(resp.Body).Decode(&namespaces); err != nil {
207+
return false
208+
}
209+
210+
// Check that /test namespace is no longer present
211+
for _, ns := range namespaces {
212+
if ns.Path == "/test" {
213+
return false
214+
}
215+
}
216+
return true
217+
}, 5*time.Second, 100*time.Millisecond, "Director should remove /test namespace after processing empty advertisement")
218+
219+
// Step 3: Trigger cache authz refresh by overwriting Xrootd.ScitokensConfig with an unrelated issuer
220+
// The file watcher will detect this change and call EmitScitokensConfig, which uses cached namespace ads
221+
// from the cache's last GetNamespaceAdsFromDirector() call (which gets data from the director).
222+
// Since we just updated the director to remove /test, the cache's cached ads should now reflect that.
223+
unrelatedConfig := `
224+
[Global]
225+
audience = https://wlcg.cern.ch/jwt/v1/any
226+
227+
[Issuer UnrelatedIssuer]
228+
issuer = https://unrelated-issuer.example.com
229+
base_path = /unrelated/path
230+
default_user = xrootd
231+
`
232+
err = os.WriteFile(scitokensConfigPath, []byte(unrelatedConfig), 0644)
233+
require.NoError(t, err)
234+
235+
// Wait for the background cache process to emit the config with /unrelated/path
236+
generatedConfigPath := filepath.Join(param.Cache_RunLocation.GetString(), "scitokens-cache-generated.cfg")
237+
require.Eventually(t, func() bool {
238+
generatedContent, err := os.ReadFile(generatedConfigPath)
239+
if err != nil {
240+
return false
241+
}
242+
contentStr := string(generatedContent)
243+
// Check that the unrelated issuer override is present and /test is gone
244+
return len(contentStr) > 0 &&
245+
strings.Contains(contentStr, "unrelated-issuer.example.com") &&
246+
strings.Contains(contentStr, "/unrelated/path") &&
247+
!strings.Contains(contentStr, "/test")
248+
}, 10*time.Second, 100*time.Millisecond, "Generated config should contain unrelated issuer override and not /test")
249+
250+
// Step 4: Verify data is no longer accessible through the cache
251+
// Try to download directly from cache - should fail because authorization is missing
252+
destPath2 := filepath.Join(tmpDir, "downloaded2.txt")
253+
cacheUrl := param.Cache_Url.GetString()
254+
_, err = client.DoGet(ctx, cacheUrl+"/test/"+testFileName, destPath2, false, client.WithToken(tok))
255+
assert.Error(t, err, "Should not be able to access cached data without proper authorization")
256+
257+
// Step 5: Trigger another cache authz refresh with proper authorization for /test
258+
properConfig := `
259+
[Global]
260+
audience = https://wlcg.cern.ch/jwt/v1/any
261+
262+
[Issuer TestIssuer]
263+
issuer = ` + serverIssuerUrl + `
264+
base_path = /test
265+
default_user = xrootd
266+
`
267+
err = os.WriteFile(scitokensConfigPath, []byte(properConfig), 0644)
268+
require.NoError(t, err)
269+
270+
// Wait for the background cache process to emit the updated config with /test
271+
require.Eventually(t, func() bool {
272+
generatedContent, err := os.ReadFile(generatedConfigPath)
273+
if err != nil {
274+
return false
275+
}
276+
contentStr := string(generatedContent)
277+
// Check that the unrelated issuer is gone and our server issuer is present
278+
return len(contentStr) > 0 &&
279+
!strings.Contains(contentStr, "unrelated-issuer.example.com") &&
280+
strings.Contains(contentStr, serverIssuerUrl) &&
281+
strings.Contains(contentStr, "TestIssuer")
282+
}, 10*time.Second, 100*time.Millisecond, "Generated config should contain server issuer and not unrelated issuer")
283+
284+
// Step 6: Verify cached object is accessible with proper auth, even with origin "offline"
285+
// Use client.DoGet with WithCaches to force use of specific cache
286+
destPath3 := filepath.Join(tmpDir, "downloaded3.txt")
287+
cacheUrlParsed, err := url.Parse(cacheUrl)
288+
require.NoError(t, err)
289+
transferResults, err = client.DoGet(ctx, pelicanUrl, destPath3, false, client.WithToken(tok), client.WithCaches(cacheUrlParsed))
290+
require.NoError(t, err, "Should be able to access cached data with proper authorization")
291+
assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes)
292+
293+
content3, err := os.ReadFile(destPath3)
294+
require.NoError(t, err)
295+
assert.Equal(t, testFileContent, string(content3), "Content from cache should match original")
296+
}

0 commit comments

Comments
 (0)