Skip to content

Commit 8b9d74c

Browse files
committed
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 1c20a28 commit 8b9d74c

1 file changed

Lines changed: 144 additions & 36 deletions

File tree

e2e_fed_tests/cache_scitokens_config_test.go

Lines changed: 144 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@
2121
package fed_tests
2222

2323
import (
24+
"bytes"
2425
"context"
26+
"encoding/json"
27+
"fmt"
28+
"net/http"
29+
"net/url"
2530
"os"
2631
"path/filepath"
32+
"strings"
2733
"testing"
2834
"time"
2935

3036
_ "github.com/glebarez/sqlite"
3137
"github.com/stretchr/testify/assert"
3238
"github.com/stretchr/testify/require"
3339

34-
"github.com/pelicanplatform/pelican/cache"
3540
"github.com/pelicanplatform/pelican/client"
3641
"github.com/pelicanplatform/pelican/config"
3742
"github.com/pelicanplatform/pelican/fed_test_utils"
@@ -41,17 +46,16 @@ import (
4146
"github.com/pelicanplatform/pelican/test_utils"
4247
"github.com/pelicanplatform/pelican/token"
4348
"github.com/pelicanplatform/pelican/token_scopes"
44-
"github.com/pelicanplatform/pelican/xrootd"
4549
)
4650

4751
// TestCacheScitokensConfigOverride tests that Xrootd.ScitokensConfig works for caches
4852
// to serve cached objects during origin downtime. This test:
4953
// 1. Sets up a full federation with private reads and pulls a file through the cache
50-
// 2. Simulates origin downtime by removing namespace ads from the cache
54+
// 2. Simulates origin downtime by POSTing a new origin ad without the /test namespace
5155
// 3. Triggers cache authz refresh by overwriting Xrootd.ScitokensConfig with unrelated issuer
52-
// 4. Verifies data is no longer accessible through the cache
56+
// 4. Verifies data is no longer accessible through the cache (authorization removed)
5357
// 5. Triggers another authz refresh with proper authorization for the test prefix
54-
// 6. Verifies cached object is now accessible even without origin
58+
// 6. Verifies cached object is now accessible even with origin "offline"
5559
func TestCacheScitokensConfigOverride(t *testing.T) {
5660
t.Cleanup(test_utils.SetupTestLogging(t))
5761
server_utils.ResetTestState()
@@ -75,6 +79,12 @@ func TestCacheScitokensConfigOverride(t *testing.T) {
7579
scitokensConfigPath := filepath.Join(tmpDir, "scitokens.cfg")
7680
require.NoError(t, param.Set(param.Xrootd_ScitokensConfig.GetName(), scitokensConfigPath))
7781

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+
7888
// Create origin configuration with private reads (requires tokens)
7989
originConfig := `
8090
Origin:
@@ -92,38 +102,124 @@ Origin:
92102
serverIssuerUrl, err := config.GetServerIssuerURL()
93103
require.NoError(t, err, "Failed to get server issuer URL")
94104

95-
// Get federation info
96-
fedInfo, err := config.GetFederation(ctx)
97-
require.NoError(t, err)
98-
99105
// Create a token for accessing the object
100106
tokenConfig := token.NewWLCGToken()
101107
tokenConfig.Lifetime = 30 * time.Minute
102108
tokenConfig.Issuer = serverIssuerUrl
103109
tokenConfig.Subject = "test-subject"
104-
tokenConfig.AddAudiences("https://wlcg.cern.ch/jwt/v1/any")
105-
tokenConfig.AddResourceScopes(token_scopes.NewResourceScope(token_scopes.Wlcg_Storage_Read, "/test"))
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...)
106120

107121
tok, err := tokenConfig.CreateToken()
108122
require.NoError(t, err)
109123

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+
110132
// Step 1: Download through the federation to populate the cache
111133
destPath1 := filepath.Join(tmpDir, "downloaded1.txt")
112-
transferResults, err := client.DoGet(ctx, "pelican://"+fedInfo.DirectorEndpoint+"/test/"+testFileName, destPath1, false, client.WithToken(tok))
134+
transferResults, err := client.DoGet(ctx, pelicanUrl, destPath1, false, client.WithToken(tok))
113135
require.NoError(t, err, "Should be able to download file through federation")
114136
assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes)
115137

116138
content1, err := os.ReadFile(destPath1)
117139
require.NoError(t, err)
118140
assert.Equal(t, testFileContent, string(content1), "Downloaded content should match original")
119141

120-
// Get the cache server to manipulate its state
121-
cacheServer := &cache.CacheServer{}
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+
}
122209

123-
// Step 2: Simulate origin downtime by removing namespace ads from cache
124-
cacheServer.SetNamespaceAds([]server_structs.NamespaceAdV2{})
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")
125218

126219
// 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.
127223
unrelatedConfig := `
128224
[Global]
129225
audience = https://wlcg.cern.ch/jwt/v1/any
@@ -136,15 +232,20 @@ default_user = xrootd
136232
err = os.WriteFile(scitokensConfigPath, []byte(unrelatedConfig), 0644)
137233
require.NoError(t, err)
138234

139-
// Trigger the refresh by calling EmitScitokensConfig
140-
err = xrootd.EmitScitokensConfig(cacheServer)
141-
require.NoError(t, err)
142-
143-
// Verify the refresh happened by checking the generated file
235+
// Wait for the background cache process to emit the config with /unrelated/path
144236
generatedConfigPath := filepath.Join(param.Cache_RunLocation.GetString(), "scitokens-cache-generated.cfg")
145-
generatedContent, err := os.ReadFile(generatedConfigPath)
146-
require.NoError(t, err)
147-
assert.Contains(t, string(generatedContent), "unrelated-issuer.example.com", "Generated config should contain the unrelated issuer")
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")
148249

149250
// Step 4: Verify data is no longer accessible through the cache
150251
// Try to download directly from cache - should fail because authorization is missing
@@ -166,21 +267,28 @@ default_user = xrootd
166267
err = os.WriteFile(scitokensConfigPath, []byte(properConfig), 0644)
167268
require.NoError(t, err)
168269

169-
// Trigger the refresh again
170-
err = xrootd.EmitScitokensConfig(cacheServer)
171-
require.NoError(t, err)
172-
173-
// Verify the refresh happened by checking the generated file
174-
generatedContent, err = os.ReadFile(generatedConfigPath)
175-
require.NoError(t, err)
176-
assert.Contains(t, string(generatedContent), serverIssuerUrl, "Generated config should contain the server issuer")
177-
assert.Contains(t, string(generatedContent), "/test", "Generated config should contain the /test base path")
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")
178283

179-
// Step 6: Verify cached object is now accessible even with origin "offline"
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
180286
destPath3 := filepath.Join(tmpDir, "downloaded3.txt")
181-
transferResults3, err := client.DoGet(ctx, cacheUrl+"/test/"+testFileName, destPath3, false, client.WithToken(tok))
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))
182290
require.NoError(t, err, "Should be able to access cached data with proper authorization")
183-
assert.Equal(t, int64(len(testFileContent)), transferResults3[0].TransferredBytes)
291+
assert.Equal(t, int64(len(testFileContent)), transferResults[0].TransferredBytes)
184292

185293
content3, err := os.ReadFile(destPath3)
186294
require.NoError(t, err)

0 commit comments

Comments
 (0)