| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/ipfs/go-test/random" |
| 19 | "github.com/ipfs/kubo/test/cli/harness" |
| 20 | "github.com/stretchr/testify/assert" |
| 21 | "github.com/stretchr/testify/require" |
| 22 | ) |
| 23 | |
| 24 | const ( |
| 25 | timeStep = 20 * time.Millisecond |
| 26 | timeout = 30 * time.Second |
| 27 | ) |
| 28 | |
| 29 | type cfgApplier func(*harness.Node) |
| 30 | |
| 31 | // uniq appends a nanosecond timestamp to s, ensuring unique CIDs |
| 32 | // across test runs and parallel subtests. |
| 33 | func uniq(s string) string { |
| 34 | return s + " " + strconv.FormatInt(time.Now().UnixNano(), 10) |
| 35 | } |
| 36 | |
| 37 | // awaitReprovideFunc waits until at least minCIDs have been provided |
| 38 | // and returns the total number of CIDs provided so far. The returned |
| 39 | // count can be passed as minCIDs to a subsequent call to wait for the |
| 40 | // next reprovide cycle. |
| 41 | type awaitReprovideFunc func(t *testing.T, n *harness.Node, minCIDs int64) int64 |
| 42 | |
| 43 | func runProviderSuite(t *testing.T, sweep bool, apply cfgApplier, awaitReprovide awaitReprovideFunc) { |
| 44 | t.Helper() |
| 45 | |
| 46 | initNodes := func(t *testing.T, n int, fn func(n *harness.Node)) harness.Nodes { |
| 47 | h := harness.NewT(t) |
| 48 | nodes := h.NewNodes(n).Init() |
| 49 | nodes.ForEachPar(apply) |
| 50 | nodes.ForEachPar(fn) |
| 51 | h.BootstrapWithStubDHT(nodes) |
| 52 | nodes = nodes.StartDaemons().Connect() |
| 53 | time.Sleep(500 * time.Millisecond) // wait for DHT clients to be bootstrapped |
| 54 | return nodes |
| 55 | } |
| 56 | |
| 57 | initNodesWithoutStart := func(t *testing.T, n int, fn func(n *harness.Node)) harness.Nodes { |
| 58 | h := harness.NewT(t) |
| 59 | nodes := h.NewNodes(n).Init() |
| 60 | nodes.ForEachPar(apply) |
| 61 | nodes.ForEachPar(fn) |
| 62 | h.BootstrapWithStubDHT(nodes) |
| 63 | return nodes |
| 64 | } |
| 65 | |
| 66 | expectNoProviders := func(t *testing.T, cid string, nodes ...*harness.Node) { |
| 67 | for _, node := range nodes { |
| 68 | res := node.IPFS("routing", "findprovs", "-n=1", cid) |
| 69 | require.Empty(t, res.Stdout.String()) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | expectProviders := func(t *testing.T, cid, expectedProvider string, nodes ...*harness.Node) { |
| 74 | outerLoop: |
| 75 | for _, node := range nodes { |
| 76 | for i := time.Duration(0); i*timeStep < timeout; i++ { |
| 77 | res := node.IPFS("routing", "findprovs", "-n=1", cid) |
| 78 | if res.Stdout.Trimmed() == expectedProvider { |
| 79 | continue outerLoop |
| 80 | } |
| 81 | } |
| 82 | require.FailNowf(t, "found no providers", "expected a provider for %s", cid) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | t.Run("Provide.Enabled=true announces new CIDs created by ipfs add", func(t *testing.T) { |
| 87 | t.Parallel() |
| 88 | |
| 89 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 90 | n.SetIPFSConfig("Provide.Enabled", true) |
| 91 | }) |
| 92 | defer nodes.StopDaemons() |
| 93 | |
| 94 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 95 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 96 | }) |
| 97 | |
| 98 | t.Run("Provide.Enabled=true announces new CIDs created by ipfs add --pin=false with default strategy", func(t *testing.T) { |
| 99 | t.Parallel() |
| 100 | |
| 101 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 102 | n.SetIPFSConfig("Provide.Enabled", true) |
| 103 | // Default strategy is "all" which should provide even unpinned content |
| 104 | }) |
| 105 | defer nodes.StopDaemons() |
| 106 | |
| 107 | cid := nodes[0].IPFSAddStr(time.Now().String(), "--pin=false") |
| 108 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 109 | }) |
| 110 | |
| 111 | t.Run("Provide.Enabled=true announces new CIDs created by ipfs block put --pin=false with default strategy", func(t *testing.T) { |
| 112 | t.Parallel() |
| 113 | |
| 114 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 115 | n.SetIPFSConfig("Provide.Enabled", true) |
| 116 | // Default strategy is "all" which should provide unpinned content from block put |
| 117 | }) |
| 118 | defer nodes.StopDaemons() |
| 119 | |
| 120 | data := random.Bytes(256) |
| 121 | cid := nodes[0].IPFSBlockPut(bytes.NewReader(data), "--pin=false") |
| 122 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 123 | }) |
| 124 | |
| 125 | t.Run("Provide.Enabled=true announces new CIDs created by ipfs dag put --pin=false with default strategy", func(t *testing.T) { |
| 126 | t.Parallel() |
| 127 | |
| 128 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 129 | n.SetIPFSConfig("Provide.Enabled", true) |
| 130 | // Default strategy is "all" which should provide unpinned content from dag put |
| 131 | }) |
| 132 | defer nodes.StopDaemons() |
| 133 | |
| 134 | dagData := `{"hello": "world", "timestamp": "` + time.Now().String() + `"}` |
| 135 | cid := nodes[0].IPFSDAGPut(bytes.NewReader([]byte(dagData)), "--pin=false") |
| 136 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 137 | }) |
| 138 | |
| 139 | t.Run("Provide.Enabled=false disables announcement of new CID from ipfs add", func(t *testing.T) { |
| 140 | t.Parallel() |
| 141 | |
| 142 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 143 | n.SetIPFSConfig("Provide.Enabled", false) |
| 144 | }) |
| 145 | defer nodes.StopDaemons() |
| 146 | |
| 147 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 148 | expectNoProviders(t, cid, nodes[1:]...) |
| 149 | }) |
| 150 | |
| 151 | t.Run("Provide.Enabled=false disables manual announcement via RPC command", func(t *testing.T) { |
| 152 | t.Parallel() |
| 153 | |
| 154 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 155 | n.SetIPFSConfig("Provide.Enabled", false) |
| 156 | }) |
| 157 | defer nodes.StopDaemons() |
| 158 | |
| 159 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 160 | res := nodes[0].RunIPFS("routing", "provide", cid) |
| 161 | assert.Contains(t, res.Stderr.Trimmed(), "invalid configuration: Provide.Enabled is set to 'false'") |
| 162 | assert.Equal(t, 1, res.ExitCode()) |
| 163 | |
| 164 | expectNoProviders(t, cid, nodes[1:]...) |
| 165 | }) |
| 166 | |
| 167 | t.Run("manual provide fails when no libp2p peers and no custom HTTP router", func(t *testing.T) { |
| 168 | t.Parallel() |
| 169 | |
| 170 | h := harness.NewT(t) |
| 171 | node := h.NewNode().Init() |
| 172 | apply(node) |
| 173 | node.SetIPFSConfig("Provide.Enabled", true) |
| 174 | node.StartDaemon() |
| 175 | defer node.StopDaemon() |
| 176 | |
| 177 | cid := node.IPFSAddStr(time.Now().String()) |
| 178 | res := node.RunIPFS("routing", "provide", cid) |
| 179 | assert.Contains(t, res.Stderr.Trimmed(), "cannot provide, no connected peers") |
| 180 | assert.Equal(t, 1, res.ExitCode()) |
| 181 | }) |
| 182 | |
| 183 | t.Run("manual provide succeeds via custom HTTP router when no libp2p peers", func(t *testing.T) { |
| 184 | t.Parallel() |
| 185 | |
| 186 | // Create a mock HTTP server that accepts provide requests. |
| 187 | // This simulates the undocumented API behavior described in |
| 188 | // https://discuss.ipfs.tech/t/only-peers-found-from-dht-seem-to-be-getting-used-as-relays-so-cant-use-http-routers/19545/9 |
| 189 | // Note: This is NOT IPIP-378, which was not implemented. |
| 190 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 191 | // Accept both PUT and POST requests to /routing/v1/providers and /routing/v1/ipns |
| 192 | if (r.Method == http.MethodPut || r.Method == http.MethodPost) && |
| 193 | (strings.HasPrefix(r.URL.Path, "/routing/v1/providers") || strings.HasPrefix(r.URL.Path, "/routing/v1/ipns")) { |
| 194 | // Return HTTP 200 to indicate successful publishing |
| 195 | w.WriteHeader(http.StatusOK) |
| 196 | } else { |
| 197 | w.WriteHeader(http.StatusNotFound) |
| 198 | } |
| 199 | })) |
| 200 | defer mockServer.Close() |
| 201 | |
| 202 | h := harness.NewT(t) |
| 203 | node := h.NewNode().Init() |
| 204 | apply(node) |
| 205 | node.SetIPFSConfig("Provide.Enabled", true) |
| 206 | // Configure a custom HTTP router for providing. |
| 207 | // Using our mock server that will accept the provide requests. |
| 208 | routingConf := map[string]any{ |
| 209 | "Type": "custom", // https://github.com/ipfs/kubo/blob/master/docs/delegated-routing.md#configuration-file-example |
| 210 | "Methods": map[string]any{ |
| 211 | "provide": map[string]any{"RouterName": "MyCustomRouter"}, |
| 212 | "get-ipns": map[string]any{"RouterName": "MyCustomRouter"}, |
| 213 | "put-ipns": map[string]any{"RouterName": "MyCustomRouter"}, |
| 214 | "find-peers": map[string]any{"RouterName": "MyCustomRouter"}, |
| 215 | "find-providers": map[string]any{"RouterName": "MyCustomRouter"}, |
| 216 | }, |
| 217 | "Routers": map[string]any{ |
| 218 | "MyCustomRouter": map[string]any{ |
| 219 | "Type": "http", |
| 220 | "Parameters": map[string]any{ |
| 221 | // Use the mock server URL |
| 222 | "Endpoint": mockServer.URL, |
| 223 | }, |
| 224 | }, |
| 225 | }, |
| 226 | } |
| 227 | node.SetIPFSConfig("Routing", routingConf) |
| 228 | node.StartDaemon() |
| 229 | defer node.StopDaemon() |
| 230 | |
| 231 | cid := node.IPFSAddStr(time.Now().String()) |
| 232 | // The command should successfully provide via HTTP even without libp2p peers |
| 233 | res := node.RunIPFS("routing", "provide", cid) |
| 234 | assert.Empty(t, res.Stderr.String(), "Should have no errors when providing via HTTP router") |
| 235 | assert.Equal(t, 0, res.ExitCode(), "Should succeed with exit code 0") |
| 236 | }) |
| 237 | |
| 238 | t.Run("ipfs provide once works when Provide.DHT.Interval=0", func(t *testing.T) { |
| 239 | t.Parallel() |
| 240 | |
| 241 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 242 | n.SetIPFSConfig("Provide.Enabled", true) |
| 243 | // No periodic reprovide schedule; provide once is the only |
| 244 | // way new content reaches peers in this configuration. |
| 245 | n.SetIPFSConfig("Provide.DHT.Interval", "0") |
| 246 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 247 | }) |
| 248 | defer nodes.StopDaemons() |
| 249 | |
| 250 | publisher := nodes[0] |
| 251 | cid := publisher.IPFSAddStr(uniq("interval=0"), "--pin=false") |
| 252 | expectNoProviders(t, cid, nodes[1:]...) |
| 253 | |
| 254 | res := publisher.RunIPFS("provide", "once", cid) |
| 255 | assert.Equal(t, 0, res.ExitCode(), "provide once should succeed with Interval=0") |
| 256 | expectProviders(t, cid, publisher.PeerID().String(), nodes[1:]...) |
| 257 | }) |
| 258 | |
| 259 | t.Run("Provide.Enabled=false disables ipfs provide once", func(t *testing.T) { |
| 260 | t.Parallel() |
| 261 | |
| 262 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 263 | n.SetIPFSConfig("Provide.Enabled", false) |
| 264 | }) |
| 265 | defer nodes.StopDaemons() |
| 266 | |
| 267 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 268 | res := nodes[0].RunIPFS("provide", "once", cid) |
| 269 | assert.Contains(t, res.Stderr.Trimmed(), "cannot provide: Provide.Enabled is false") |
| 270 | assert.Equal(t, 1, res.ExitCode()) |
| 271 | |
| 272 | expectNoProviders(t, cid, nodes[1:]...) |
| 273 | }) |
| 274 | |
| 275 | t.Run("ipfs provide once announces a CID and finds providers", func(t *testing.T) { |
| 276 | t.Parallel() |
| 277 | |
| 278 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 279 | n.SetIPFSConfig("Provide.Enabled", true) |
| 280 | // "roots" so add-time providing is skipped and we know the |
| 281 | // announcement comes from `provide once`, not from ipfs add. |
| 282 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 283 | }) |
| 284 | defer nodes.StopDaemons() |
| 285 | |
| 286 | cid := nodes[0].IPFSAddStr(uniq("provide once"), "--pin=false") |
| 287 | expectNoProviders(t, cid, nodes[1:]...) |
| 288 | |
| 289 | res := nodes[0].RunIPFS("provide", "once", cid) |
| 290 | assert.Equal(t, 0, res.ExitCode(), "provide once should succeed") |
| 291 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 292 | }) |
| 293 | |
| 294 | t.Run("ipfs provide once errors when CID is not in local blockstore", func(t *testing.T) { |
| 295 | t.Parallel() |
| 296 | |
| 297 | nodes := initNodes(t, 1, func(n *harness.Node) { |
| 298 | n.SetIPFSConfig("Provide.Enabled", true) |
| 299 | }) |
| 300 | defer nodes.StopDaemons() |
| 301 | |
| 302 | // CID for content the node has never seen. |
| 303 | missing := "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" |
| 304 | res := nodes[0].RunIPFS("provide", "once", missing) |
| 305 | assert.Contains(t, res.Stderr.Trimmed(), "not found locally, cannot provide") |
| 306 | assert.Equal(t, 1, res.ExitCode()) |
| 307 | }) |
| 308 | |
| 309 | t.Run("ipfs provide once --recursive announces every block in the DAG", func(t *testing.T) { |
| 310 | t.Parallel() |
| 311 | |
| 312 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 313 | n.SetIPFSConfig("Provide.Enabled", true) |
| 314 | // Selective strategy + --pin=false below means nothing is |
| 315 | // auto-provided; everything findable comes from `provide once`. |
| 316 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 317 | // 1 MiB chunks so a 2 MiB file produces multiple leaf blocks. |
| 318 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") |
| 319 | }) |
| 320 | defer nodes.StopDaemons() |
| 321 | |
| 322 | publisher := nodes[0] |
| 323 | data := random.Bytes(2 * 1024 * 1024) |
| 324 | cidRoot := publisher.IPFSAdd(bytes.NewReader(data), "-Q", "--pin=false") |
| 325 | |
| 326 | // Discover a chunk CID via the root's DAG links. |
| 327 | dagOut := publisher.IPFS("dag", "get", cidRoot) |
| 328 | var dagNode struct { |
| 329 | Links []struct { |
| 330 | Hash map[string]string `json:"Hash"` |
| 331 | } `json:"Links"` |
| 332 | } |
| 333 | require.NoError(t, json.Unmarshal(dagOut.Stdout.Bytes(), &dagNode)) |
| 334 | require.Greater(t, len(dagNode.Links), 1, "2 MiB file with 1 MiB chunker should have multiple chunks") |
| 335 | cidChunk := dagNode.Links[0].Hash["/"] |
| 336 | require.NotEmpty(t, cidChunk) |
| 337 | |
| 338 | // Recursive provide should announce both the root and every chunk. |
| 339 | res := publisher.RunIPFS("provide", "once", "-r", cidRoot) |
| 340 | assert.Equal(t, 0, res.ExitCode(), "provide once -r should succeed") |
| 341 | expectProviders(t, cidRoot, publisher.PeerID().String(), nodes[1:]...) |
| 342 | expectProviders(t, cidChunk, publisher.PeerID().String(), nodes[1:]...) |
| 343 | }) |
| 344 | |
| 345 | t.Run("ipfs provide once accepts multiple CIDs and reports count", func(t *testing.T) { |
| 346 | t.Parallel() |
| 347 | |
| 348 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 349 | n.SetIPFSConfig("Provide.Enabled", true) |
| 350 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 351 | }) |
| 352 | defer nodes.StopDaemons() |
| 353 | |
| 354 | publisher := nodes[0] |
| 355 | c1 := publisher.IPFSAddStr(uniq("multi 1"), "--pin=false") |
| 356 | c2 := publisher.IPFSAddStr(uniq("multi 2"), "--pin=false") |
| 357 | c3 := publisher.IPFSAddStr(uniq("multi 3"), "--pin=false") |
| 358 | |
| 359 | res := publisher.RunIPFS("provide", "once", c1, c2, c3) |
| 360 | assert.Equal(t, 0, res.ExitCode(), "provide once with multiple CIDs should succeed") |
| 361 | assert.Contains(t, res.Stdout.Trimmed(), "queued 3 CID(s) for immediate provide") |
| 362 | |
| 363 | expectProviders(t, c1, publisher.PeerID().String(), nodes[1:]...) |
| 364 | expectProviders(t, c2, publisher.PeerID().String(), nodes[1:]...) |
| 365 | expectProviders(t, c3, publisher.PeerID().String(), nodes[1:]...) |
| 366 | }) |
| 367 | |
| 368 | t.Run("ipfs provide once reads CIDs streamed from stdin", func(t *testing.T) { |
| 369 | t.Parallel() |
| 370 | |
| 371 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 372 | n.SetIPFSConfig("Provide.Enabled", true) |
| 373 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 374 | }) |
| 375 | defer nodes.StopDaemons() |
| 376 | |
| 377 | publisher := nodes[0] |
| 378 | c1 := publisher.IPFSAddStr(uniq("stdin 1"), "--pin=false") |
| 379 | c2 := publisher.IPFSAddStr(uniq("stdin 2"), "--pin=false") |
| 380 | c3 := publisher.IPFSAddStr(uniq("stdin 3"), "--pin=false") |
| 381 | |
| 382 | res := publisher.Runner.Run(harness.RunRequest{ |
| 383 | Path: publisher.IPFSBin, |
| 384 | Args: []string{"provide", "once"}, |
| 385 | CmdOpts: []harness.CmdOpt{ |
| 386 | harness.RunWithStdinStr(c1 + "\n" + c2 + "\n" + c3 + "\n"), |
| 387 | }, |
| 388 | }) |
| 389 | assert.Equal(t, 0, res.ExitCode(), "provide once with stdin should succeed") |
| 390 | assert.Contains(t, res.Stdout.Trimmed(), "queued 3 CID(s) for immediate provide") |
| 391 | |
| 392 | expectProviders(t, c1, publisher.PeerID().String(), nodes[1:]...) |
| 393 | expectProviders(t, c2, publisher.PeerID().String(), nodes[1:]...) |
| 394 | expectProviders(t, c3, publisher.PeerID().String(), nodes[1:]...) |
| 395 | }) |
| 396 | |
| 397 | t.Run("ipfs provide once deduplicates repeated CIDs", func(t *testing.T) { |
| 398 | t.Parallel() |
| 399 | |
| 400 | nodes := initNodes(t, 1, func(n *harness.Node) { |
| 401 | n.SetIPFSConfig("Provide.Enabled", true) |
| 402 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 403 | }) |
| 404 | defer nodes.StopDaemons() |
| 405 | |
| 406 | publisher := nodes[0] |
| 407 | c1 := publisher.IPFSAddStr(uniq("dedup 1"), "--pin=false") |
| 408 | c2 := publisher.IPFSAddStr(uniq("dedup 2"), "--pin=false") |
| 409 | |
| 410 | // 4 args, 2 unique CIDs. The repeated ones should not produce |
| 411 | // extra events on the wire. |
| 412 | res := publisher.RunIPFS("provide", "once", "--enc=json", c1, c2, c1, c2) |
| 413 | assert.Equal(t, 0, res.ExitCode()) |
| 414 | |
| 415 | var queued []string |
| 416 | for line := range strings.Lines(res.Stdout.String()) { |
| 417 | line = strings.TrimSpace(line) |
| 418 | if line == "" { |
| 419 | continue |
| 420 | } |
| 421 | var ev struct{ Queued string } |
| 422 | require.NoError(t, json.Unmarshal([]byte(line), &ev)) |
| 423 | queued = append(queued, ev.Queued) |
| 424 | } |
| 425 | assert.ElementsMatch(t, []string{c1, c2}, queued, "duplicates should be filtered") |
| 426 | }) |
| 427 | |
| 428 | t.Run("ipfs provide once --enc=json streams one event per CID", func(t *testing.T) { |
| 429 | t.Parallel() |
| 430 | |
| 431 | nodes := initNodes(t, 1, func(n *harness.Node) { |
| 432 | n.SetIPFSConfig("Provide.Enabled", true) |
| 433 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 434 | }) |
| 435 | defer nodes.StopDaemons() |
| 436 | |
| 437 | publisher := nodes[0] |
| 438 | c1 := publisher.IPFSAddStr(uniq("json 1"), "--pin=false") |
| 439 | c2 := publisher.IPFSAddStr(uniq("json 2"), "--pin=false") |
| 440 | |
| 441 | res := publisher.RunIPFS("provide", "once", "--enc=json", c1, c2) |
| 442 | assert.Equal(t, 0, res.ExitCode(), "provide once --enc=json should succeed") |
| 443 | |
| 444 | // Parse one JSON object per non-empty line. |
| 445 | var queued []string |
| 446 | for line := range strings.Lines(res.Stdout.String()) { |
| 447 | line = strings.TrimSpace(line) |
| 448 | if line == "" { |
| 449 | continue |
| 450 | } |
| 451 | var ev struct{ Queued string } |
| 452 | require.NoError(t, json.Unmarshal([]byte(line), &ev), "each line must parse as JSON: %q", line) |
| 453 | queued = append(queued, ev.Queued) |
| 454 | } |
| 455 | assert.ElementsMatch(t, []string{c1, c2}, queued) |
| 456 | }) |
| 457 | |
| 458 | t.Run("Provide.DHT.Interval=0 keeps announcing new CIDs (fast-provide-root)", func(t *testing.T) { |
| 459 | t.Parallel() |
| 460 | |
| 461 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 462 | // Required: Interval=0 alone is rejected by the validator |
| 463 | // since the new semantic only disables the schedule. |
| 464 | n.SetIPFSConfig("Provide.Enabled", true) |
| 465 | n.SetIPFSConfig("Provide.DHT.Interval", "0") |
| 466 | }) |
| 467 | defer nodes.StopDaemons() |
| 468 | |
| 469 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 470 | expectProviders(t, cid, nodes[0].PeerID().String(), nodes[1:]...) |
| 471 | }) |
| 472 | |
| 473 | // `routing reprovide` is only available with the legacy provider. |
| 474 | // Sweep provider reprovides automatically on schedule. |
| 475 | if !sweep { |
| 476 | t.Run("Manual Reprovide trigger does not work when periodic reprovide is disabled", func(t *testing.T) { |
| 477 | t.Parallel() |
| 478 | |
| 479 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 480 | n.SetIPFSConfig("Provide.Enabled", true) |
| 481 | n.SetIPFSConfig("Provide.DHT.Interval", "0") |
| 482 | }) |
| 483 | defer nodes.StopDaemons() |
| 484 | |
| 485 | res := nodes[0].RunIPFS("routing", "reprovide") |
| 486 | assert.Contains(t, res.Stderr.Trimmed(), "invalid configuration: Provide.DHT.Interval is set to '0'") |
| 487 | assert.Equal(t, 1, res.ExitCode()) |
| 488 | }) |
| 489 | |
| 490 | t.Run("Manual Reprovide trigger does not work when Provide system is disabled", func(t *testing.T) { |
| 491 | t.Parallel() |
| 492 | |
| 493 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 494 | n.SetIPFSConfig("Provide.Enabled", false) |
| 495 | }) |
| 496 | defer nodes.StopDaemons() |
| 497 | |
| 498 | cid := nodes[0].IPFSAddStr(time.Now().String()) |
| 499 | |
| 500 | expectNoProviders(t, cid, nodes[1:]...) |
| 501 | |
| 502 | res := nodes[0].RunIPFS("routing", "reprovide") |
| 503 | assert.Contains(t, res.Stderr.Trimmed(), "invalid configuration: Provide.Enabled is set to 'false'") |
| 504 | assert.Equal(t, 1, res.ExitCode()) |
| 505 | |
| 506 | expectNoProviders(t, cid, nodes[1:]...) |
| 507 | }) |
| 508 | } |
| 509 | |
| 510 | t.Run("Provide with 'all' strategy", func(t *testing.T) { |
| 511 | t.Parallel() |
| 512 | |
| 513 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 514 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 515 | }) |
| 516 | defer nodes.StopDaemons() |
| 517 | publisher := nodes[0] |
| 518 | |
| 519 | cid := publisher.IPFSAddStr(uniq("all strategy")) |
| 520 | expectProviders(t, cid, publisher.PeerID().String(), nodes[1:]...) |
| 521 | }) |
| 522 | |
| 523 | t.Run("Provide with 'pinned' strategy", func(t *testing.T) { |
| 524 | t.Parallel() |
| 525 | |
| 526 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 527 | n.SetIPFSConfig("Provide.Strategy", "pinned") |
| 528 | }) |
| 529 | defer nodes.StopDaemons() |
| 530 | publisher := nodes[0] |
| 531 | |
| 532 | // Add a non-pinned CID (should not be provided) |
| 533 | cid := publisher.IPFSAddStr(uniq("pinned strategy"), "--pin=false") |
| 534 | expectNoProviders(t, cid, nodes[1:]...) |
| 535 | |
| 536 | // Pin the CID (should now be provided) |
| 537 | publisher.IPFS("pin", "add", cid) |
| 538 | expectProviders(t, cid, publisher.PeerID().String(), nodes[1:]...) |
| 539 | }) |
| 540 | |
| 541 | t.Run("Provide with 'pinned+mfs' strategy", func(t *testing.T) { |
| 542 | t.Parallel() |
| 543 | |
| 544 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 545 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs") |
| 546 | }) |
| 547 | defer nodes.StopDaemons() |
| 548 | publisher := nodes[0] |
| 549 | |
| 550 | cidPinned := publisher.IPFSAddStr(uniq("pinned content")) |
| 551 | cidUnpinned := publisher.IPFSAddStr(uniq("unpinned content"), "--pin=false") |
| 552 | cidMFS := publisher.IPFSAddStr(uniq("mfs content"), "--pin=false") |
| 553 | publisher.IPFS("files", "cp", "/ipfs/"+cidMFS, "/myfile") |
| 554 | |
| 555 | expectProviders(t, cidPinned, publisher.PeerID().String(), nodes[1:]...) |
| 556 | expectNoProviders(t, cidUnpinned, nodes[1:]...) |
| 557 | expectProviders(t, cidMFS, publisher.PeerID().String(), nodes[1:]...) |
| 558 | }) |
| 559 | |
| 560 | // addLargeFileInSubdir adds a 2 MiB file inside /subdir/ in MFS and |
| 561 | // returns the MFS root CID, the file root CID, and a chunk CID. |
| 562 | // The file is large enough to be split into multiple blocks. |
| 563 | // The resulting DAG: root-dir/subdir/largefile (2+ chunks). |
| 564 | addLargeFileInSubdir := func(t *testing.T, publisher *harness.Node) (cidRoot, cidSubdir, cidFile, cidChunk string) { |
| 565 | t.Helper() |
| 566 | largeData := random.Bytes(2 * 1024 * 1024) // 2 MiB = 2 chunks at 1 MiB |
| 567 | |
| 568 | // Add file without pinning, then build directory structure in MFS |
| 569 | cidFile = publisher.IPFSAdd(bytes.NewReader(largeData), "-Q", "--pin=false") |
| 570 | publisher.IPFS("files", "mkdir", "-p", "/subdir") |
| 571 | publisher.IPFS("files", "cp", "/ipfs/"+cidFile, "/subdir/largefile") |
| 572 | |
| 573 | // Get CIDs for the directory structure |
| 574 | cidRoot = publisher.IPFS("files", "stat", "--hash", "/").Stdout.Trimmed() |
| 575 | cidSubdir = publisher.IPFS("files", "stat", "--hash", "/subdir").Stdout.Trimmed() |
| 576 | |
| 577 | // Get a chunk CID from the file's DAG links |
| 578 | dagOut := publisher.IPFS("dag", "get", cidFile) |
| 579 | var dagNode struct { |
| 580 | Links []struct { |
| 581 | Hash map[string]string `json:"Hash"` |
| 582 | } `json:"Links"` |
| 583 | } |
| 584 | require.NoError(t, json.Unmarshal(dagOut.Stdout.Bytes(), &dagNode)) |
| 585 | require.Greater(t, len(dagNode.Links), 1, "file should have multiple chunks") |
| 586 | cidChunk = dagNode.Links[0].Hash["/"] |
| 587 | require.NotEmpty(t, cidChunk) |
| 588 | |
| 589 | return cidRoot, cidSubdir, cidFile, cidChunk |
| 590 | } |
| 591 | |
| 592 | // +unique and +entities tests verify which CIDs end up in the DHT |
| 593 | // (strategy scope). Bloom filter deduplication correctness and |
| 594 | // entity type detection are tested in boxo/dag/walker/*_test.go. |
| 595 | |
| 596 | t.Run("Provide with 'pinned+mfs+unique' strategy", func(t *testing.T) { |
| 597 | t.Parallel() |
| 598 | |
| 599 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 600 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs+unique") |
| 601 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 602 | }) |
| 603 | defer nodes.StopDaemons() |
| 604 | publisher, peers := nodes[0], nodes[1:] |
| 605 | |
| 606 | // +unique provides all blocks in pinned DAGs (same scope as |
| 607 | // pinned+mfs but with bloom filter dedup across pins). |
| 608 | // Use --fast-provide-dag and --fast-provide-wait on pin add |
| 609 | // so we can verify which blocks the strategy includes. |
| 610 | cidRoot, cidSubdir, cidFile, cidChunk := addLargeFileInSubdir(t, publisher) |
| 611 | publisher.IPFS("pin", "add", "--fast-provide-dag", "--fast-provide-wait", cidRoot) |
| 612 | cidUnpinned := publisher.IPFSAddStr(uniq("unpinned content"), "--pin=false") |
| 613 | |
| 614 | pid := publisher.PeerID().String() |
| 615 | // All blocks in the pinned DAG should be provided (including chunks) |
| 616 | expectProviders(t, cidRoot, pid, peers...) |
| 617 | expectProviders(t, cidSubdir, pid, peers...) |
| 618 | expectProviders(t, cidFile, pid, peers...) |
| 619 | expectProviders(t, cidChunk, pid, peers...) |
| 620 | expectNoProviders(t, cidUnpinned, peers...) |
| 621 | }) |
| 622 | |
| 623 | t.Run("Provide with 'pinned+mfs+entities' strategy", func(t *testing.T) { |
| 624 | t.Parallel() |
| 625 | |
| 626 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 627 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs+entities") |
| 628 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 629 | }) |
| 630 | defer nodes.StopDaemons() |
| 631 | publisher, peers := nodes[0], nodes[1:] |
| 632 | |
| 633 | // +entities provides only entity roots (files, directories, |
| 634 | // HAMT shards) and skips internal file chunks. |
| 635 | // Use --fast-provide-dag and --fast-provide-wait on pin add |
| 636 | // so we can verify which blocks the strategy skips. |
| 637 | cidRoot, cidSubdir, cidFile, cidChunk := addLargeFileInSubdir(t, publisher) |
| 638 | publisher.IPFS("pin", "add", "--fast-provide-dag", "--fast-provide-wait", cidRoot) |
| 639 | |
| 640 | pid := publisher.PeerID().String() |
| 641 | // Entity roots: directories and file root |
| 642 | expectProviders(t, cidRoot, pid, peers...) |
| 643 | expectProviders(t, cidSubdir, pid, peers...) |
| 644 | expectProviders(t, cidFile, pid, peers...) |
| 645 | // Internal chunk should NOT be provided (+entities skips chunks) |
| 646 | expectNoProviders(t, cidChunk, peers...) |
| 647 | }) |
| 648 | |
| 649 | t.Run("ipfs add --fast-provide-dag honors +entities (no chunk providing)", func(t *testing.T) { |
| 650 | t.Parallel() |
| 651 | |
| 652 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 653 | n.SetIPFSConfig("Provide.Strategy", "pinned+entities") |
| 654 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 655 | }) |
| 656 | defer nodes.StopDaemons() |
| 657 | publisher, peers := nodes[0], nodes[1:] |
| 658 | |
| 659 | // Regression test for the providingDagService double-providing |
| 660 | // path. Before the fix, ipfs add --pin --fast-provide-dag wrapped |
| 661 | // the DAGService with providingDagService, which announced every |
| 662 | // block as it was written -- including chunks -- regardless of |
| 663 | // the +entities modifier. The post-add ExecuteFastProvideDAG |
| 664 | // walk then ran in parallel, so chunks ended up in the DHT |
| 665 | // despite +entities saying they should be skipped. |
| 666 | // |
| 667 | // After the fix, ExecuteFastProvideDAG is the single mechanism |
| 668 | // for --fast-provide-dag and respects the active strategy. |
| 669 | largeData := random.Bytes(2 * 1024 * 1024) // 2 MiB = 2 chunks |
| 670 | cidFile := publisher.IPFSAdd(bytes.NewReader(largeData), |
| 671 | "--fast-provide-dag", "--fast-provide-wait") |
| 672 | |
| 673 | // Get a chunk CID from the file's DAG links |
| 674 | dagOut := publisher.IPFS("dag", "get", cidFile) |
| 675 | var dagNode struct { |
| 676 | Links []struct { |
| 677 | Hash map[string]string `json:"Hash"` |
| 678 | } `json:"Links"` |
| 679 | } |
| 680 | require.NoError(t, json.Unmarshal(dagOut.Stdout.Bytes(), &dagNode)) |
| 681 | require.Greater(t, len(dagNode.Links), 1, "file should have multiple chunks") |
| 682 | cidChunk := dagNode.Links[0].Hash["/"] |
| 683 | require.NotEmpty(t, cidChunk) |
| 684 | |
| 685 | pid := publisher.PeerID().String() |
| 686 | // File root (entity) should be provided |
| 687 | expectProviders(t, cidFile, pid, peers...) |
| 688 | // Chunk should NOT be provided (+entities skips chunks) |
| 689 | expectNoProviders(t, cidChunk, peers...) |
| 690 | }) |
| 691 | |
| 692 | // addLargeFilestoreFile writes a 2 MiB file to the publisher's |
| 693 | // node directory and adds it via --nocopy, returning the root CID |
| 694 | // and a chunk CID from the file's DAG links. With the configured |
| 695 | // 1 MiB chunker the file produces multiple leaf blocks so we can |
| 696 | // distinguish root-level from chunk-level provide behavior. |
| 697 | addLargeFilestoreFile := func(t *testing.T, publisher *harness.Node, addArgs ...string) (cidRoot, cidChunk string) { |
| 698 | t.Helper() |
| 699 | filePath := filepath.Join(publisher.Dir, "filestore-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".bin") |
| 700 | require.NoError(t, os.WriteFile(filePath, random.Bytes(2*1024*1024), 0o644)) |
| 701 | |
| 702 | args := append([]string{"add", "-q", "--nocopy"}, addArgs...) |
| 703 | args = append(args, filePath) |
| 704 | cidRoot = strings.TrimSpace(publisher.IPFS(args...).Stdout.String()) |
| 705 | |
| 706 | dagOut := publisher.IPFS("dag", "get", cidRoot) |
| 707 | var dagNode struct { |
| 708 | Links []struct { |
| 709 | Hash map[string]string `json:"Hash"` |
| 710 | } `json:"Links"` |
| 711 | } |
| 712 | require.NoError(t, json.Unmarshal(dagOut.Stdout.Bytes(), &dagNode)) |
| 713 | require.Greater(t, len(dagNode.Links), 1, "filestore file should have multiple chunks") |
| 714 | cidChunk = dagNode.Links[0].Hash["/"] |
| 715 | require.NotEmpty(t, cidChunk) |
| 716 | return cidRoot, cidChunk |
| 717 | } |
| 718 | |
| 719 | t.Run("Filestore --nocopy with 'all' strategy provides every block", func(t *testing.T) { |
| 720 | t.Parallel() |
| 721 | |
| 722 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 723 | n.SetIPFSConfig("Experimental.FilestoreEnabled", true) |
| 724 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 725 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 726 | }) |
| 727 | defer nodes.StopDaemons() |
| 728 | publisher, peers := nodes[0], nodes[1:] |
| 729 | |
| 730 | // Positive control: with the default 'all' strategy the |
| 731 | // filestore Put path provides every block as it is written, |
| 732 | // including non-root chunks. |
| 733 | cidRoot, cidChunk := addLargeFilestoreFile(t, publisher) |
| 734 | |
| 735 | pid := publisher.PeerID().String() |
| 736 | expectProviders(t, cidRoot, pid, peers...) |
| 737 | expectProviders(t, cidChunk, pid, peers...) |
| 738 | }) |
| 739 | |
| 740 | t.Run("Filestore --nocopy with selective strategy skips write-time provide", func(t *testing.T) { |
| 741 | t.Parallel() |
| 742 | |
| 743 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 744 | n.SetIPFSConfig("Experimental.FilestoreEnabled", true) |
| 745 | n.SetIPFSConfig("Provide.Strategy", "pinned") |
| 746 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 747 | }) |
| 748 | defer nodes.StopDaemons() |
| 749 | publisher, peers := nodes[0], nodes[1:] |
| 750 | |
| 751 | // With a selective strategy the filestore must not eagerly |
| 752 | // announce blocks at write time. --pin=false skips the pin |
| 753 | // (so fast-provide-root has nothing to do) and |
| 754 | // --fast-provide-root=false disables it explicitly, isolating |
| 755 | // the assertion to the filestore's internal provide path. |
| 756 | cidRoot, cidChunk := addLargeFilestoreFile(t, publisher, |
| 757 | "--pin=false", "--fast-provide-root=false") |
| 758 | |
| 759 | expectNoProviders(t, cidRoot, peers...) |
| 760 | expectNoProviders(t, cidChunk, peers...) |
| 761 | }) |
| 762 | |
| 763 | t.Run("Filestore --nocopy + selective strategy + --fast-provide-dag walks DAG", func(t *testing.T) { |
| 764 | t.Parallel() |
| 765 | |
| 766 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 767 | n.SetIPFSConfig("Experimental.FilestoreEnabled", true) |
| 768 | n.SetIPFSConfig("Provide.Strategy", "pinned") |
| 769 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 770 | }) |
| 771 | defer nodes.StopDaemons() |
| 772 | publisher, peers := nodes[0], nodes[1:] |
| 773 | |
| 774 | // The selective-strategy gate skips the filestore's write-time |
| 775 | // provide, but the post-add ExecuteFastProvideDAG walk reads |
| 776 | // blocks through the wrapping blockstore (which transparently |
| 777 | // serves filestore-backed content) and announces each block, |
| 778 | // honoring the active strategy. This is the integration test |
| 779 | // behind the changelog claim that filestore content now plays |
| 780 | // well with the fast-provide-dag flag. |
| 781 | cidRoot, cidChunk := addLargeFilestoreFile(t, publisher, |
| 782 | "--fast-provide-dag", "--fast-provide-wait") |
| 783 | |
| 784 | pid := publisher.PeerID().String() |
| 785 | expectProviders(t, cidRoot, pid, peers...) |
| 786 | expectProviders(t, cidChunk, pid, peers...) |
| 787 | }) |
| 788 | |
| 789 | t.Run("Provide with 'roots' strategy", func(t *testing.T) { |
| 790 | t.Parallel() |
| 791 | |
| 792 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 793 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 794 | }) |
| 795 | defer nodes.StopDaemons() |
| 796 | publisher := nodes[0] |
| 797 | |
| 798 | // Add with -w: the wrapper directory is the recursive pin root, |
| 799 | // the file inside is a child block of that pin (not a root). |
| 800 | // Use --only-hash first to learn the child CID without providing. |
| 801 | data := random.Bytes(1000) |
| 802 | cidChild := publisher.IPFSAdd(bytes.NewReader(data), "-Q", "--only-hash") |
| 803 | cidRoot := publisher.IPFSAdd(bytes.NewReader(data), "-Q", "-w") |
| 804 | |
| 805 | // 'roots' strategy provides only pin roots, not child blocks. |
| 806 | expectProviders(t, cidRoot, publisher.PeerID().String(), nodes[1:]...) |
| 807 | expectNoProviders(t, cidChild, nodes[1:]...) |
| 808 | }) |
| 809 | |
| 810 | t.Run("Provide with 'mfs' strategy", func(t *testing.T) { |
| 811 | t.Parallel() |
| 812 | |
| 813 | nodes := initNodes(t, 2, func(n *harness.Node) { |
| 814 | n.SetIPFSConfig("Provide.Strategy", "mfs") |
| 815 | }) |
| 816 | defer nodes.StopDaemons() |
| 817 | publisher := nodes[0] |
| 818 | |
| 819 | // 'mfs' only provides content in MFS. Pinned content outside |
| 820 | // MFS should NOT be provided (mfs excludes pinned by default). |
| 821 | cidPinned := publisher.IPFSAddStr(uniq("pinned but not mfs")) |
| 822 | expectNoProviders(t, cidPinned, nodes[1:]...) |
| 823 | |
| 824 | // Add to MFS (should be provided) |
| 825 | data := random.Bytes(1000) |
| 826 | cidMFS := publisher.IPFSAdd(bytes.NewReader(data), "-Q", "--pin=false") |
| 827 | publisher.IPFS("files", "cp", "/ipfs/"+cidMFS, "/myfile") |
| 828 | expectProviders(t, cidMFS, publisher.PeerID().String(), nodes[1:]...) |
| 829 | |
| 830 | // Pinned CID still not provided (mfs strategy ignores pins) |
| 831 | expectNoProviders(t, cidPinned, nodes[1:]...) |
| 832 | }) |
| 833 | |
| 834 | // Reprovide tests: add content offline, start daemon, wait for reprovide. |
| 835 | // |
| 836 | // Each test waits for TWO reprovide cycles to confirm the schedule |
| 837 | // works repeatedly, not just on the initial bootstrap. The second |
| 838 | // cycle also catches bugs where state isn't persisted across cycles. |
| 839 | // |
| 840 | // Legacy: `routing reprovide` blocks until the reprovide cycle finishes, |
| 841 | // so we call it and check results immediately after. |
| 842 | // |
| 843 | // Sweep: no manual trigger exists. Instead, we set |
| 844 | // Provide.DHT.Interval=30s on the importing node and poll |
| 845 | // `provide stat` until the cycle completes. |
| 846 | |
| 847 | // verifyReprovide waits for two reprovide cycles and asserts which |
| 848 | // CIDs are/aren't findable after each. minCIDs is the expected |
| 849 | // number of provided CIDs per cycle. |
| 850 | verifyReprovide := func( |
| 851 | t *testing.T, |
| 852 | publisher *harness.Node, |
| 853 | queriers harness.Nodes, |
| 854 | minCIDs int64, |
| 855 | provided []string, |
| 856 | notProvided []string, |
| 857 | ) { |
| 858 | t.Helper() |
| 859 | pid := publisher.PeerID().String() |
| 860 | check := func() { |
| 861 | for _, c := range provided { |
| 862 | expectProviders(t, c, pid, queriers...) |
| 863 | } |
| 864 | for _, c := range notProvided { |
| 865 | expectNoProviders(t, c, queriers...) |
| 866 | } |
| 867 | } |
| 868 | |
| 869 | after1 := awaitReprovide(t, publisher, minCIDs) |
| 870 | check() |
| 871 | // Second cycle: confirms the schedule runs repeatedly. |
| 872 | awaitReprovide(t, publisher, after1+minCIDs) |
| 873 | check() |
| 874 | } |
| 875 | |
| 876 | { |
| 877 | |
| 878 | t.Run("Reprovides with 'all' strategy when strategy is '' (empty)", func(t *testing.T) { |
| 879 | t.Parallel() |
| 880 | |
| 881 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 882 | n.SetIPFSConfig("Provide.Strategy", "") |
| 883 | }) |
| 884 | publisher := nodes[0] |
| 885 | if sweep { |
| 886 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 887 | } |
| 888 | |
| 889 | cid := publisher.IPFSAddStr(time.Now().String()) |
| 890 | |
| 891 | nodes = nodes.StartDaemons().Connect() |
| 892 | defer nodes.StopDaemons() |
| 893 | peers := nodes[1:] |
| 894 | |
| 895 | verifyReprovide(t, publisher, peers, 1, // 1 block added |
| 896 | []string{cid}, nil) |
| 897 | }) |
| 898 | |
| 899 | t.Run("Reprovides with 'all' strategy", func(t *testing.T) { |
| 900 | t.Parallel() |
| 901 | |
| 902 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 903 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 904 | }) |
| 905 | publisher := nodes[0] |
| 906 | if sweep { |
| 907 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 908 | } |
| 909 | |
| 910 | cid := publisher.IPFSAddStr(time.Now().String()) |
| 911 | |
| 912 | nodes = nodes.StartDaemons().Connect() |
| 913 | defer nodes.StopDaemons() |
| 914 | peers := nodes[1:] |
| 915 | |
| 916 | verifyReprovide(t, publisher, peers, 1, // 1 block added |
| 917 | []string{cid}, nil) |
| 918 | }) |
| 919 | |
| 920 | t.Run("Reprovides with 'pinned' strategy", func(t *testing.T) { |
| 921 | t.Parallel() |
| 922 | |
| 923 | foo := random.Bytes(1000) |
| 924 | bar := random.Bytes(1000) |
| 925 | |
| 926 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 927 | n.SetIPFSConfig("Provide.Strategy", "pinned") |
| 928 | }) |
| 929 | publisher := nodes[0] |
| 930 | if sweep { |
| 931 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 932 | } |
| 933 | |
| 934 | // Add a pin while offline |
| 935 | cidBarDir := publisher.IPFSAdd(bytes.NewReader(bar), "-Q", "-w") |
| 936 | |
| 937 | nodes = nodes.StartDaemons().Connect() |
| 938 | defer nodes.StopDaemons() |
| 939 | peers := nodes[1:] |
| 940 | |
| 941 | // Add content without pinning while daemon is online |
| 942 | cidFoo := publisher.IPFSAdd(bytes.NewReader(foo), "--pin=false") |
| 943 | cidBar := publisher.IPFSAdd(bytes.NewReader(bar), "--pin=false") |
| 944 | |
| 945 | verifyReprovide(t, publisher, peers, 2, // cidBar + cidBarDir (bar is child of the wrapped dir pin) |
| 946 | []string{cidBar, cidBarDir}, |
| 947 | []string{cidFoo}) // cidFoo not pinned |
| 948 | }) |
| 949 | |
| 950 | t.Run("Reprovides with 'roots' strategy", func(t *testing.T) { |
| 951 | t.Parallel() |
| 952 | |
| 953 | bar := random.Bytes(1000) |
| 954 | |
| 955 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 956 | n.SetIPFSConfig("Provide.Strategy", "roots") |
| 957 | }) |
| 958 | publisher := nodes[0] |
| 959 | if sweep { |
| 960 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 961 | } |
| 962 | |
| 963 | // Compute the child CID without storing anything (safe |
| 964 | // offline, daemon not started yet). |
| 965 | cidChild := publisher.IPFSAdd(bytes.NewReader(bar), "-Q", "--only-hash") |
| 966 | // Add with -w: pins the wrapper directory as root. The file |
| 967 | // inside is a child block of that pin, not a root. |
| 968 | cidRoot := publisher.IPFSAdd(bytes.NewReader(bar), "-Q", "-w") |
| 969 | |
| 970 | nodes = nodes.StartDaemons().Connect() |
| 971 | defer nodes.StopDaemons() |
| 972 | peers := nodes[1:] |
| 973 | |
| 974 | verifyReprovide(t, publisher, peers, 1, // cidRoot (only pin root) |
| 975 | []string{cidRoot}, |
| 976 | []string{cidChild}) // child of pin, not a root |
| 977 | }) |
| 978 | |
| 979 | t.Run("Reprovides with 'mfs' strategy", func(t *testing.T) { |
| 980 | t.Parallel() |
| 981 | |
| 982 | bar := random.Bytes(1000) |
| 983 | |
| 984 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 985 | n.SetIPFSConfig("Provide.Strategy", "mfs") |
| 986 | }) |
| 987 | publisher := nodes[0] |
| 988 | if sweep { |
| 989 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 990 | } |
| 991 | |
| 992 | // Add to MFS (should be provided) |
| 993 | cidMFS := publisher.IPFSAdd(bytes.NewReader(bar), "--pin=false", "-Q") |
| 994 | publisher.IPFS("files", "cp", "/ipfs/"+cidMFS, "/myfile") |
| 995 | // Pin something NOT in MFS (should NOT be provided) |
| 996 | cidPinned := publisher.IPFSAddStr(uniq("pinned but not mfs")) |
| 997 | |
| 998 | nodes = nodes.StartDaemons().Connect() |
| 999 | defer nodes.StopDaemons() |
| 1000 | peers := nodes[1:] |
| 1001 | |
| 1002 | verifyReprovide(t, publisher, peers, 1, // cidMFS only |
| 1003 | []string{cidMFS}, |
| 1004 | []string{cidPinned}) // mfs strategy ignores pinned content outside MFS |
| 1005 | }) |
| 1006 | |
| 1007 | t.Run("Reprovides with 'pinned+mfs' strategy", func(t *testing.T) { |
| 1008 | t.Parallel() |
| 1009 | |
| 1010 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 1011 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs") |
| 1012 | }) |
| 1013 | publisher := nodes[0] |
| 1014 | if sweep { |
| 1015 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 1016 | } |
| 1017 | |
| 1018 | // Add a pinned CID (should be provided) |
| 1019 | cidPinned := publisher.IPFSAddStr(uniq("pinned content"), "--pin=true") |
| 1020 | // Add a CID to MFS (should be provided) |
| 1021 | cidMFS := publisher.IPFSAddStr(uniq("mfs content")) |
| 1022 | publisher.IPFS("files", "cp", "/ipfs/"+cidMFS, "/myfile") |
| 1023 | // Add a CID that is neither pinned nor in MFS (should not be provided) |
| 1024 | cidNeither := publisher.IPFSAddStr(uniq("neither content"), "--pin=false") |
| 1025 | |
| 1026 | nodes = nodes.StartDaemons().Connect() |
| 1027 | defer nodes.StopDaemons() |
| 1028 | peers := nodes[1:] |
| 1029 | |
| 1030 | verifyReprovide(t, publisher, peers, 2, // cidPinned + cidMFS |
| 1031 | []string{cidPinned, cidMFS}, |
| 1032 | []string{cidNeither}) // neither pinned nor in MFS |
| 1033 | }) |
| 1034 | |
| 1035 | t.Run("Reprovides with 'pinned+mfs+unique' strategy", func(t *testing.T) { |
| 1036 | t.Parallel() |
| 1037 | |
| 1038 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 1039 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs+unique") |
| 1040 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 1041 | }) |
| 1042 | publisher := nodes[0] |
| 1043 | if sweep { |
| 1044 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 1045 | } |
| 1046 | |
| 1047 | // Build a directory DAG with a multi-chunk file in MFS, then pin it. |
| 1048 | cidRoot, cidSubdir, cidFile, cidChunk := addLargeFileInSubdir(t, publisher) |
| 1049 | publisher.IPFS("pin", "add", cidRoot) |
| 1050 | cidUnpinned := publisher.IPFSAddStr(uniq("unpinned content"), "--pin=false") |
| 1051 | |
| 1052 | nodes = nodes.StartDaemons().Connect() |
| 1053 | defer nodes.StopDaemons() |
| 1054 | peers := nodes[1:] |
| 1055 | |
| 1056 | // +unique provides all blocks in pinned DAGs (same as pinned+mfs) |
| 1057 | verifyReprovide(t, publisher, peers, 4, // root + subdir + file + chunks |
| 1058 | []string{cidRoot, cidSubdir, cidFile, cidChunk}, |
| 1059 | []string{cidUnpinned}) |
| 1060 | }) |
| 1061 | |
| 1062 | t.Run("Reprovides with 'pinned+mfs+entities' strategy", func(t *testing.T) { |
| 1063 | t.Parallel() |
| 1064 | |
| 1065 | nodes := initNodesWithoutStart(t, 2, func(n *harness.Node) { |
| 1066 | n.SetIPFSConfig("Provide.Strategy", "pinned+mfs+entities") |
| 1067 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1048576") // 1 MiB chunks |
| 1068 | }) |
| 1069 | publisher := nodes[0] |
| 1070 | if sweep { |
| 1071 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 1072 | } |
| 1073 | |
| 1074 | // Build a directory DAG with a multi-chunk file in MFS, then pin it. |
| 1075 | cidRoot, cidSubdir, cidFile, cidChunk := addLargeFileInSubdir(t, publisher) |
| 1076 | publisher.IPFS("pin", "add", cidRoot) |
| 1077 | |
| 1078 | nodes = nodes.StartDaemons().Connect() |
| 1079 | defer nodes.StopDaemons() |
| 1080 | peers := nodes[1:] |
| 1081 | |
| 1082 | // Entity roots: directories and file root (not chunks) |
| 1083 | verifyReprovide(t, publisher, peers, 3, // root + subdir + file (not chunks) |
| 1084 | []string{cidRoot, cidSubdir, cidFile}, |
| 1085 | []string{cidChunk}) // chunks skipped by +entities |
| 1086 | }) |
| 1087 | } |
| 1088 | |
| 1089 | t.Run("provide clear command removes items from provide queue", func(t *testing.T) { |
| 1090 | t.Parallel() |
| 1091 | |
| 1092 | nodes := harness.NewT(t).NewNodes(1).Init() |
| 1093 | nodes.ForEachPar(func(n *harness.Node) { |
| 1094 | n.SetIPFSConfig("Provide.Enabled", true) |
| 1095 | n.SetIPFSConfig("Provide.DHT.Interval", "22h") |
| 1096 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 1097 | }) |
| 1098 | nodes.StartDaemons() |
| 1099 | defer nodes.StopDaemons() |
| 1100 | |
| 1101 | // Clear the provide queue first time - works regardless of queue state |
| 1102 | res1 := nodes[0].IPFS("provide", "clear") |
| 1103 | require.NoError(t, res1.Err) |
| 1104 | |
| 1105 | // Should report cleared items and proper message format |
| 1106 | assert.Contains(t, res1.Stdout.String(), "removed") |
| 1107 | assert.Contains(t, res1.Stdout.String(), "items from provide queue") |
| 1108 | |
| 1109 | // Clear the provide queue second time - should definitely report 0 items |
| 1110 | res2 := nodes[0].IPFS("provide", "clear") |
| 1111 | require.NoError(t, res2.Err) |
| 1112 | |
| 1113 | // Should report 0 items cleared since queue was already cleared |
| 1114 | assert.Contains(t, res2.Stdout.String(), "removed 0 items from provide queue") |
| 1115 | }) |
| 1116 | |
| 1117 | t.Run("provide clear command with quiet option", func(t *testing.T) { |
| 1118 | t.Parallel() |
| 1119 | |
| 1120 | nodes := harness.NewT(t).NewNodes(1).Init() |
| 1121 | nodes.ForEachPar(func(n *harness.Node) { |
| 1122 | n.SetIPFSConfig("Provide.Enabled", true) |
| 1123 | n.SetIPFSConfig("Provide.DHT.Interval", "22h") |
| 1124 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 1125 | }) |
| 1126 | nodes.StartDaemons() |
| 1127 | defer nodes.StopDaemons() |
| 1128 | |
| 1129 | // Clear the provide queue with quiet option |
| 1130 | res := nodes[0].IPFS("provide", "clear", "-q") |
| 1131 | require.NoError(t, res.Err) |
| 1132 | |
| 1133 | // Should have no output when quiet |
| 1134 | assert.Empty(t, res.Stdout.String()) |
| 1135 | }) |
| 1136 | |
| 1137 | t.Run("provide clear command works when provider is disabled", func(t *testing.T) { |
| 1138 | t.Parallel() |
| 1139 | |
| 1140 | nodes := harness.NewT(t).NewNodes(1).Init() |
| 1141 | nodes.ForEachPar(func(n *harness.Node) { |
| 1142 | n.SetIPFSConfig("Provide.Enabled", false) |
| 1143 | n.SetIPFSConfig("Provide.DHT.Interval", "22h") |
| 1144 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 1145 | }) |
| 1146 | nodes.StartDaemons() |
| 1147 | defer nodes.StopDaemons() |
| 1148 | |
| 1149 | // Clear should succeed even when provider is disabled |
| 1150 | res := nodes[0].IPFS("provide", "clear") |
| 1151 | require.NoError(t, res.Err) |
| 1152 | }) |
| 1153 | |
| 1154 | t.Run("provide clear command returns JSON with removed item count", func(t *testing.T) { |
| 1155 | t.Parallel() |
| 1156 | |
| 1157 | nodes := harness.NewT(t).NewNodes(1).Init() |
| 1158 | nodes.ForEachPar(func(n *harness.Node) { |
| 1159 | n.SetIPFSConfig("Provide.Enabled", true) |
| 1160 | n.SetIPFSConfig("Provide.DHT.Interval", "22h") |
| 1161 | n.SetIPFSConfig("Provide.Strategy", "all") |
| 1162 | }) |
| 1163 | nodes.StartDaemons() |
| 1164 | defer nodes.StopDaemons() |
| 1165 | |
| 1166 | // Clear the provide queue with JSON encoding |
| 1167 | res := nodes[0].IPFS("provide", "clear", "--enc=json") |
| 1168 | require.NoError(t, res.Err) |
| 1169 | |
| 1170 | // Should return valid JSON with the number of removed items |
| 1171 | output := res.Stdout.String() |
| 1172 | assert.NotEmpty(t, output) |
| 1173 | |
| 1174 | // Parse JSON to verify structure |
| 1175 | var result int |
| 1176 | err := json.Unmarshal([]byte(output), &result) |
| 1177 | require.NoError(t, err, "Output should be valid JSON") |
| 1178 | |
| 1179 | // Should be a non-negative integer (0 or positive) |
| 1180 | assert.GreaterOrEqual(t, result, 0) |
| 1181 | }) |
| 1182 | } |
| 1183 | |
| 1184 | // runResumeTests validates Provide.DHT.ResumeEnabled behavior for SweepingProvider. |
| 1185 | // |
| 1186 | // Background: The provider tracks current_time_offset = (now - cycleStart) % interval |
| 1187 | // where cycleStart is the timestamp marking the beginning of the reprovide cycle. |
| 1188 | // With ResumeEnabled=true, cycleStart persists in the datastore across restarts. |
| 1189 | // With ResumeEnabled=false, cycleStart resets to 'now' on each startup. |
| 1190 | func runResumeTests(t *testing.T, apply cfgApplier) { |
| 1191 | t.Helper() |
| 1192 | |
| 1193 | const ( |
| 1194 | reprovideInterval = 30 * time.Second |
| 1195 | initialRuntime = 10 * time.Second // Let cycle progress |
| 1196 | downtime = 5 * time.Second // Simulated offline period |
| 1197 | restartTime = 2 * time.Second // Daemon restart stabilization |
| 1198 | |
| 1199 | // Thresholds account for timing jitter (~2-3s margin) |
| 1200 | minOffsetBeforeRestart = 8 * time.Second // Expect ~10s |
| 1201 | minOffsetAfterResume = 12 * time.Second // Expect ~17s (10s + 5s + 2s) |
| 1202 | maxOffsetAfterReset = 5 * time.Second // Expect ~2s (fresh start) |
| 1203 | ) |
| 1204 | |
| 1205 | setupNode := func(t *testing.T, resumeEnabled bool) *harness.Node { |
| 1206 | node := harness.NewT(t).NewNode().Init() |
| 1207 | apply(node) // Sets Provide.DHT.SweepEnabled=true |
| 1208 | node.SetIPFSConfig("Provide.DHT.ResumeEnabled", resumeEnabled) |
| 1209 | node.SetIPFSConfig("Provide.DHT.Interval", reprovideInterval.String()) |
| 1210 | node.SetIPFSConfig("Bootstrap", []string{}) |
| 1211 | node.StartDaemon() |
| 1212 | return node |
| 1213 | } |
| 1214 | |
| 1215 | t.Run("preserves cycle state across restart", func(t *testing.T) { |
| 1216 | t.Parallel() |
| 1217 | |
| 1218 | node := setupNode(t, true) |
| 1219 | defer node.StopDaemon() |
| 1220 | |
| 1221 | for i := range 10 { |
| 1222 | node.IPFSAddStr(fmt.Sprintf("resume-test-%d-%d", i, time.Now().UnixNano())) |
| 1223 | } |
| 1224 | |
| 1225 | time.Sleep(initialRuntime) |
| 1226 | |
| 1227 | beforeRestart := node.IPFS("provide", "stat", "--enc=json") |
| 1228 | offsetBeforeRestart, _, err := parseProvideStatJSON(beforeRestart.Stdout.String()) |
| 1229 | require.NoError(t, err) |
| 1230 | require.Greater(t, offsetBeforeRestart, minOffsetBeforeRestart, |
| 1231 | "cycle should have progressed") |
| 1232 | |
| 1233 | node.StopDaemon() |
| 1234 | time.Sleep(downtime) |
| 1235 | node.StartDaemon() |
| 1236 | time.Sleep(restartTime) |
| 1237 | |
| 1238 | afterRestart := node.IPFS("provide", "stat", "--enc=json") |
| 1239 | offsetAfterRestart, _, err := parseProvideStatJSON(afterRestart.Stdout.String()) |
| 1240 | require.NoError(t, err) |
| 1241 | |
| 1242 | assert.GreaterOrEqual(t, offsetAfterRestart, minOffsetAfterResume, |
| 1243 | "offset should account for downtime") |
| 1244 | }) |
| 1245 | |
| 1246 | t.Run("resets cycle when disabled", func(t *testing.T) { |
| 1247 | t.Parallel() |
| 1248 | |
| 1249 | node := setupNode(t, false) |
| 1250 | defer node.StopDaemon() |
| 1251 | |
| 1252 | for i := range 10 { |
| 1253 | node.IPFSAddStr(fmt.Sprintf("no-resume-%d-%d", i, time.Now().UnixNano())) |
| 1254 | } |
| 1255 | |
| 1256 | time.Sleep(initialRuntime) |
| 1257 | |
| 1258 | beforeRestart := node.IPFS("provide", "stat", "--enc=json") |
| 1259 | offsetBeforeRestart, _, err := parseProvideStatJSON(beforeRestart.Stdout.String()) |
| 1260 | require.NoError(t, err) |
| 1261 | require.Greater(t, offsetBeforeRestart, minOffsetBeforeRestart, |
| 1262 | "cycle should have progressed") |
| 1263 | |
| 1264 | node.StopDaemon() |
| 1265 | time.Sleep(downtime) |
| 1266 | node.StartDaemon() |
| 1267 | time.Sleep(restartTime) |
| 1268 | |
| 1269 | afterRestart := node.IPFS("provide", "stat", "--enc=json") |
| 1270 | offsetAfterRestart, _, err := parseProvideStatJSON(afterRestart.Stdout.String()) |
| 1271 | require.NoError(t, err) |
| 1272 | |
| 1273 | assert.Less(t, offsetAfterRestart, maxOffsetAfterReset, |
| 1274 | "offset should reset to near zero") |
| 1275 | }) |
| 1276 | } |
| 1277 | |
| 1278 | type provideStatJSON struct { |
| 1279 | Sweep struct { |
| 1280 | Timing struct { |
| 1281 | CurrentTimeOffset int64 `json:"current_time_offset"` // nanoseconds |
| 1282 | } `json:"timing"` |
| 1283 | Schedule struct { |
| 1284 | NextReprovidePrefix string `json:"next_reprovide_prefix"` |
| 1285 | } `json:"schedule"` |
| 1286 | Operations struct { |
| 1287 | Ongoing struct { |
| 1288 | KeyReprovides int `json:"key_reprovides"` |
| 1289 | } `json:"ongoing"` |
| 1290 | Past struct { |
| 1291 | KeysProvided int64 `json:"keys_provided"` |
| 1292 | } `json:"past"` |
| 1293 | } `json:"operations"` |
| 1294 | Queues struct { |
| 1295 | PendingKeyProvides int64 `json:"pending_key_provides"` |
| 1296 | } `json:"queues"` |
| 1297 | } `json:"Sweep"` |
| 1298 | } |
| 1299 | |
| 1300 | // parseProvideStatJSON extracts timing and schedule information from |
| 1301 | // the JSON output of 'ipfs provide stat --enc=json'. |
| 1302 | func parseProvideStatJSON(output string) (offset time.Duration, prefix string, err error) { |
| 1303 | var stat provideStatJSON |
| 1304 | if err := json.Unmarshal([]byte(output), &stat); err != nil { |
| 1305 | return 0, "", err |
| 1306 | } |
| 1307 | offset = time.Duration(stat.Sweep.Timing.CurrentTimeOffset) |
| 1308 | prefix = stat.Sweep.Schedule.NextReprovidePrefix |
| 1309 | return offset, prefix, nil |
| 1310 | } |
| 1311 | |
| 1312 | // waitForSweepReprovide polls `provide stat --enc=json` until the |
| 1313 | // sweep provider has provided at least minCIDs and no work is pending. |
| 1314 | // Pass 0 for minCIDs to just wait for any provide activity to finish. |
| 1315 | // Returns the total CIDs provided so far (for use as minCIDs in a |
| 1316 | // subsequent call to wait for the next cycle). |
| 1317 | // The importing node must have a short Provide.DHT.Interval so the |
| 1318 | // reprovide cycle completes within the timeout. |
| 1319 | func waitForSweepReprovide(t *testing.T, n *harness.Node, timeout time.Duration, minCIDs int64) int64 { |
| 1320 | t.Helper() |
| 1321 | if minCIDs == 0 { |
| 1322 | minCIDs = 1 |
| 1323 | } |
| 1324 | deadline := time.Now().Add(timeout) |
| 1325 | for time.Now().Before(deadline) { |
| 1326 | res := n.RunIPFS("provide", "stat", "--enc=json") |
| 1327 | if res.ExitCode() == 0 { |
| 1328 | var stat provideStatJSON |
| 1329 | if err := json.Unmarshal(res.Stdout.Bytes(), &stat); err == nil { |
| 1330 | s := stat.Sweep |
| 1331 | if s.Operations.Past.KeysProvided >= minCIDs && |
| 1332 | s.Queues.PendingKeyProvides == 0 && |
| 1333 | s.Operations.Ongoing.KeyReprovides == 0 { |
| 1334 | return s.Operations.Past.KeysProvided |
| 1335 | } |
| 1336 | } |
| 1337 | } |
| 1338 | time.Sleep(500 * time.Millisecond) |
| 1339 | } |
| 1340 | t.Fatalf("sweep reprovide: expected at least %d CIDs provided within %s", minCIDs, timeout) |
| 1341 | return 0 |
| 1342 | } |
| 1343 | |
| 1344 | func TestProvider(t *testing.T) { |
| 1345 | t.Parallel() |
| 1346 | |
| 1347 | variants := []struct { |
| 1348 | name string |
| 1349 | sweep bool |
| 1350 | apply cfgApplier |
| 1351 | awaitReprovide awaitReprovideFunc |
| 1352 | }{ |
| 1353 | { |
| 1354 | name: "LegacyProvider", |
| 1355 | sweep: false, |
| 1356 | apply: func(n *harness.Node) { |
| 1357 | n.SetIPFSConfig("Provide.DHT.SweepEnabled", false) |
| 1358 | }, |
| 1359 | // `routing reprovide` blocks until the cycle finishes. |
| 1360 | // minCIDs is ignored (legacy has no stat counter). |
| 1361 | awaitReprovide: func(t *testing.T, n *harness.Node, minCIDs int64) int64 { |
| 1362 | n.IPFS("routing", "reprovide") |
| 1363 | return minCIDs |
| 1364 | }, |
| 1365 | }, |
| 1366 | { |
| 1367 | name: "SweepingProvider", |
| 1368 | sweep: true, |
| 1369 | apply: func(n *harness.Node) { |
| 1370 | n.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1371 | }, |
| 1372 | // No manual trigger exists for sweep. Poll `provide stat` |
| 1373 | // until the reprovide cycle completes. |
| 1374 | awaitReprovide: func(t *testing.T, n *harness.Node, minCIDs int64) int64 { |
| 1375 | // 90s accounts for provider bootstrap time (connecting |
| 1376 | // to ephemeral peers, measuring prefix length) before |
| 1377 | // the 30s reprovide cycle starts. On CI with parallel |
| 1378 | // tests, bootstrap can take 20-30s. |
| 1379 | return waitForSweepReprovide(t, n, 90*time.Second, minCIDs) |
| 1380 | }, |
| 1381 | }, |
| 1382 | } |
| 1383 | |
| 1384 | for _, v := range variants { |
| 1385 | t.Run(v.name, func(t *testing.T) { |
| 1386 | // t.Parallel() |
| 1387 | runProviderSuite(t, v.sweep, v.apply, v.awaitReprovide) |
| 1388 | |
| 1389 | // Resume tests only apply to SweepingProvider |
| 1390 | if v.sweep { |
| 1391 | runResumeTests(t, v.apply) |
| 1392 | } |
| 1393 | }) |
| 1394 | } |
| 1395 | } |
| 1396 | |
| 1397 | // TestProviderUniqueDedupLogging verifies that the +unique bloom filter |
| 1398 | // deduplication produces a "skippedBranches" log with a value > 0 when |
| 1399 | // two pins share content. Tests both the fast-provide-dag path (immediate |
| 1400 | // provide on pin add) and the reprovide cycle path. |
| 1401 | func TestProviderUniqueDedupLogging(t *testing.T) { |
| 1402 | t.Parallel() |
| 1403 | |
| 1404 | // Shared data that both pins will reference. Two pins containing |
| 1405 | // the same file block give the bloom something to dedup. |
| 1406 | sharedData := random.Bytes(10 * 1024) // 10 KiB, single block |
| 1407 | |
| 1408 | t.Run("fast-provide-dag dedup across pins in single call", func(t *testing.T) { |
| 1409 | t.Parallel() |
| 1410 | |
| 1411 | h := harness.NewT(t) |
| 1412 | node := h.NewNode().Init() |
| 1413 | node.SetIPFSConfig("Provide.Strategy", "pinned+unique") |
| 1414 | node.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1415 | node.SetIPFSConfig("Import.UnixFSChunker", "size-5120") // 5 KiB chunks |
| 1416 | h.BootstrapWithStubDHT(harness.Nodes{node}) |
| 1417 | |
| 1418 | node.StartDaemonWithReq(harness.RunRequest{ |
| 1419 | CmdOpts: []harness.CmdOpt{ |
| 1420 | harness.RunWithEnv(map[string]string{ |
| 1421 | // dagwalker: bloom creation log |
| 1422 | // core/commands/cmdenv: fast-provide-dag finished log |
| 1423 | "GOLOG_LOG_LEVEL": "error,dagwalker=info,core/commands/cmdenv=info", |
| 1424 | }), |
| 1425 | }, |
| 1426 | }, "") |
| 1427 | defer node.StopDaemon() |
| 1428 | |
| 1429 | // 10 KiB file with 5 KiB chunks = 1 file root + 2 chunks = 3 blocks. |
| 1430 | // Two dirs each containing the file under different names: |
| 1431 | // dirA/fileA → same 3 blocks |
| 1432 | // dirB/fileB → same 3 blocks |
| 1433 | // Pinning both in a single `pin add` shares one bloom tracker. |
| 1434 | // Walking dirA: dirA + file root + chunk1 + chunk2 = 4 provided. |
| 1435 | // Walking dirB: dirB + file root (bloom hit, skip subtree) = 1 provided, 1 skipped. |
| 1436 | // Total: 5 provided, 1 skipped branch (file root in dirB; its |
| 1437 | // 2 chunks are never visited because the parent was skipped). |
| 1438 | cidFile := node.IPFSAdd(bytes.NewReader(sharedData), "-Q", "--pin=false") |
| 1439 | node.IPFS("files", "mkdir", "-p", "/dirA") |
| 1440 | node.IPFS("files", "cp", "/ipfs/"+cidFile, "/dirA/fileA") |
| 1441 | cidDirA := node.IPFS("files", "stat", "--hash", "/dirA").Stdout.Trimmed() |
| 1442 | node.IPFS("files", "mkdir", "-p", "/dirB") |
| 1443 | node.IPFS("files", "cp", "/ipfs/"+cidFile, "/dirB/fileB") |
| 1444 | cidDirB := node.IPFS("files", "stat", "--hash", "/dirB").Stdout.Trimmed() |
| 1445 | require.NotEqual(t, cidDirA, cidDirB, "dirs must differ to test dedup") |
| 1446 | // Single pin add with both CIDs shares one bloom. |
| 1447 | node.IPFS("pin", "add", "--fast-provide-dag", "--fast-provide-wait", cidDirA, cidDirB) |
| 1448 | |
| 1449 | daemonLog := node.Daemon.Stderr.String() |
| 1450 | require.Contains(t, daemonLog, "bloom tracker created") |
| 1451 | require.NotContains(t, daemonLog, "bloom tracker autoscaled") |
| 1452 | require.Contains(t, daemonLog, `"providedCIDs": 5`) |
| 1453 | require.Contains(t, daemonLog, `"skippedBranches": 1`) |
| 1454 | }) |
| 1455 | |
| 1456 | t.Run("reprovide cycle dedup across pins", func(t *testing.T) { |
| 1457 | t.Parallel() |
| 1458 | |
| 1459 | h := harness.NewT(t) |
| 1460 | nodes := h.NewNodes(2).Init() |
| 1461 | for _, n := range nodes { |
| 1462 | n.SetIPFSConfig("Provide.Strategy", "pinned+unique") |
| 1463 | n.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1464 | n.SetIPFSConfig("Import.UnixFSChunker", "size-5120") // 5 KiB chunks |
| 1465 | } |
| 1466 | publisher := nodes[0] |
| 1467 | publisher.SetIPFSConfig("Provide.DHT.Interval", "30s") |
| 1468 | h.BootstrapWithStubDHT(nodes) |
| 1469 | |
| 1470 | // Same file structure as fast-provide-dag test above. |
| 1471 | // The reprovide cycle walks all recursive pins: |
| 1472 | // pin dirA: dirA + file root + chunk1 + chunk2 = 4 provided |
| 1473 | // pin empty MFS root (always present): 1 provided |
| 1474 | // pin dirB: dirB + file root (bloom hit, skip subtree) = 1 provided, 1 skipped |
| 1475 | // Total: 6 provided, 1 skipped branch. |
| 1476 | cidFile := publisher.IPFSAdd(bytes.NewReader(sharedData), "-Q", "--pin=false") |
| 1477 | publisher.IPFS("files", "mkdir", "-p", "/dirA") |
| 1478 | publisher.IPFS("files", "cp", "/ipfs/"+cidFile, "/dirA/fileA") |
| 1479 | cidDirA := publisher.IPFS("files", "stat", "--hash", "/dirA").Stdout.Trimmed() |
| 1480 | publisher.IPFS("pin", "add", cidDirA) |
| 1481 | publisher.IPFS("files", "mkdir", "-p", "/dirB") |
| 1482 | publisher.IPFS("files", "cp", "/ipfs/"+cidFile, "/dirB/fileB") |
| 1483 | cidDirB := publisher.IPFS("files", "stat", "--hash", "/dirB").Stdout.Trimmed() |
| 1484 | require.NotEqual(t, cidDirA, cidDirB, "dirs must differ to test dedup") |
| 1485 | publisher.IPFS("pin", "add", cidDirB) |
| 1486 | |
| 1487 | nodes[0].StartDaemonWithReq(harness.RunRequest{ |
| 1488 | CmdOpts: []harness.CmdOpt{ |
| 1489 | harness.RunWithEnv(map[string]string{ |
| 1490 | "GOLOG_LOG_LEVEL": "error,dagwalker=info,provider=info", |
| 1491 | }), |
| 1492 | }, |
| 1493 | }, "") |
| 1494 | nodes[1].StartDaemon() |
| 1495 | defer nodes.StopDaemons() |
| 1496 | nodes.Connect() |
| 1497 | |
| 1498 | waitForSweepReprovide(t, publisher, 90*time.Second, 6) |
| 1499 | |
| 1500 | daemonLog := publisher.Daemon.Stderr.String() |
| 1501 | require.Contains(t, daemonLog, "bloom tracker created") |
| 1502 | require.NotContains(t, daemonLog, "bloom tracker autoscaled") |
| 1503 | require.Contains(t, daemonLog, `"providedCIDs": 6`) |
| 1504 | require.Contains(t, daemonLog, `"skippedBranches": 1`) |
| 1505 | }) |
| 1506 | } |
| 1507 | |
| 1508 | // TestProviderFastProvideDAGAsyncSurvives verifies that |
| 1509 | // --fast-provide-dag without --fast-provide-wait runs a background |
| 1510 | // DAG walk that outlives the command handler and publishes every |
| 1511 | // block of the newly added DAG to the routing system. |
| 1512 | // |
| 1513 | // The async walk runs in a goroutine parented on the IpfsNode |
| 1514 | // lifetime context (not req.Context), so it keeps running after |
| 1515 | // `ipfs add` returns and is only cancelled on daemon shutdown. |
| 1516 | // |
| 1517 | // Provide.DHT.Interval is set high so the scheduled reprovide |
| 1518 | // cycle cannot fire during the test window. That makes the async |
| 1519 | // walk the only path that can publish non-root block CIDs. |
| 1520 | func TestProviderFastProvideDAGAsyncSurvives(t *testing.T) { |
| 1521 | t.Parallel() |
| 1522 | |
| 1523 | h := harness.NewT(t) |
| 1524 | nodes := h.NewNodes(2).Init() |
| 1525 | for _, n := range nodes { |
| 1526 | n.SetIPFSConfig("Provide.Strategy", "pinned") |
| 1527 | n.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1528 | // Small chunks so a modest file produces many leaf blocks. |
| 1529 | n.SetIPFSConfig("Import.UnixFSChunker", "size-1024") |
| 1530 | } |
| 1531 | publisher, peers := nodes[0], nodes[1:] |
| 1532 | publisher.SetIPFSConfig("Provide.DHT.Interval", "1h") |
| 1533 | h.BootstrapWithStubDHT(nodes) |
| 1534 | |
| 1535 | publisher.StartDaemonWithReq(harness.RunRequest{ |
| 1536 | CmdOpts: []harness.CmdOpt{ |
| 1537 | harness.RunWithEnv(map[string]string{ |
| 1538 | "GOLOG_LOG_LEVEL": "error,core/commands/cmdenv=info", |
| 1539 | }), |
| 1540 | }, |
| 1541 | }, "") |
| 1542 | nodes[1].StartDaemon() |
| 1543 | defer nodes.StopDaemons() |
| 1544 | nodes.Connect() |
| 1545 | |
| 1546 | // 16 KiB + 1 KiB chunks yields a file root plus many leaf |
| 1547 | // blocks, so the providedCIDs count after the walk is |
| 1548 | // unambiguous. |
| 1549 | data := random.Bytes(16 * 1024) |
| 1550 | cidFile := publisher.IPFSAdd(bytes.NewReader(data), "-Q", |
| 1551 | "--pin=true", |
| 1552 | "--fast-provide-dag=true", |
| 1553 | // --fast-provide-wait deliberately omitted: the walk |
| 1554 | // runs in the background after `ipfs add` returns. |
| 1555 | ) |
| 1556 | |
| 1557 | // Pull a chunk CID out of the file DAG. Chunks are not pin |
| 1558 | // roots, so fast-provide-root does not touch them; only the |
| 1559 | // DAG walk can announce them. |
| 1560 | dagOut := publisher.IPFS("dag", "get", cidFile) |
| 1561 | var dagNode struct { |
| 1562 | Links []struct { |
| 1563 | Hash map[string]string `json:"Hash"` |
| 1564 | } `json:"Links"` |
| 1565 | } |
| 1566 | require.NoError(t, json.Unmarshal(dagOut.Stdout.Bytes(), &dagNode)) |
| 1567 | require.Greater(t, len(dagNode.Links), 1, "file should have multiple chunks") |
| 1568 | cidChunk := dagNode.Links[0].Hash["/"] |
| 1569 | require.NotEmpty(t, cidChunk) |
| 1570 | |
| 1571 | // The async walk logs "fast-provide-dag: finished" with a |
| 1572 | // providedCIDs count on completion. A full walk of this file |
| 1573 | // visits the root plus every leaf chunk, so the count is much |
| 1574 | // larger than 2. |
| 1575 | providedRe := regexp.MustCompile(`"providedCIDs": (\d+)`) |
| 1576 | var providedCount int |
| 1577 | require.Eventually(t, func() bool { |
| 1578 | m := providedRe.FindStringSubmatch(publisher.Daemon.Stderr.String()) |
| 1579 | if len(m) != 2 { |
| 1580 | return false |
| 1581 | } |
| 1582 | n, err := strconv.Atoi(m[1]) |
| 1583 | if err != nil { |
| 1584 | return false |
| 1585 | } |
| 1586 | providedCount = n |
| 1587 | return true |
| 1588 | }, 30*time.Second, 200*time.Millisecond, "async fast-provide-dag walk did not log 'finished'") |
| 1589 | |
| 1590 | require.Greater(t, providedCount, 2, |
| 1591 | "providedCIDs=%d is too small for a full walk of the file DAG", providedCount) |
| 1592 | |
| 1593 | // End-to-end: the peer can find the publisher as a provider |
| 1594 | // for a chunk CID, which only the async walk could have |
| 1595 | // announced within the test window. |
| 1596 | pid := publisher.PeerID().String() |
| 1597 | var found bool |
| 1598 | for _, peer := range peers { |
| 1599 | for i := time.Duration(0); i*timeStep < timeout; i++ { |
| 1600 | res := peer.IPFS("routing", "findprovs", "-n=1", cidChunk) |
| 1601 | if res.Stdout.Trimmed() == pid { |
| 1602 | found = true |
| 1603 | break |
| 1604 | } |
| 1605 | } |
| 1606 | } |
| 1607 | require.True(t, found, "chunk %s not announced by the async walk", cidChunk) |
| 1608 | } |
| 1609 | |
| 1610 | // TestHTTPOnlyProviderWithSweepEnabled tests that provider records are correctly |
| 1611 | // sent to HTTP routers when Routing.Type="custom" with only HTTP routers configured, |
| 1612 | // even when Provide.DHT.SweepEnabled=true (the default since v0.39). |
| 1613 | // |
| 1614 | // This is a regression test for https://github.com/ipfs/kubo/issues/11089 |
| 1615 | func TestHTTPOnlyProviderWithSweepEnabled(t *testing.T) { |
| 1616 | t.Parallel() |
| 1617 | |
| 1618 | // Track provide requests received by the mock HTTP router |
| 1619 | var provideRequests atomic.Int32 |
| 1620 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 1621 | if (r.Method == http.MethodPut || r.Method == http.MethodPost) && |
| 1622 | strings.HasPrefix(r.URL.Path, "/routing/v1/providers") { |
| 1623 | provideRequests.Add(1) |
| 1624 | w.WriteHeader(http.StatusOK) |
| 1625 | } else if strings.HasPrefix(r.URL.Path, "/routing/v1/providers") && r.Method == http.MethodGet { |
| 1626 | // Return empty providers for findprovs |
| 1627 | w.Header().Set("Content-Type", "application/x-ndjson") |
| 1628 | w.WriteHeader(http.StatusOK) |
| 1629 | } else { |
| 1630 | w.WriteHeader(http.StatusNotFound) |
| 1631 | } |
| 1632 | })) |
| 1633 | defer mockServer.Close() |
| 1634 | |
| 1635 | h := harness.NewT(t) |
| 1636 | node := h.NewNode().Init() |
| 1637 | |
| 1638 | // Explicitly set SweepEnabled=true (the default since v0.39, but be explicit for test clarity) |
| 1639 | node.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1640 | node.SetIPFSConfig("Provide.Enabled", true) |
| 1641 | |
| 1642 | // Configure HTTP-only custom routing (no DHT) with explicit Routing.Type=custom |
| 1643 | routingConf := map[string]any{ |
| 1644 | "Type": "custom", // Explicitly set Routing.Type=custom |
| 1645 | "Methods": map[string]any{ |
| 1646 | "provide": map[string]any{"RouterName": "HTTPRouter"}, |
| 1647 | "get-ipns": map[string]any{"RouterName": "HTTPRouter"}, |
| 1648 | "put-ipns": map[string]any{"RouterName": "HTTPRouter"}, |
| 1649 | "find-peers": map[string]any{"RouterName": "HTTPRouter"}, |
| 1650 | "find-providers": map[string]any{"RouterName": "HTTPRouter"}, |
| 1651 | }, |
| 1652 | "Routers": map[string]any{ |
| 1653 | "HTTPRouter": map[string]any{ |
| 1654 | "Type": "http", |
| 1655 | "Parameters": map[string]any{ |
| 1656 | "Endpoint": mockServer.URL, |
| 1657 | }, |
| 1658 | }, |
| 1659 | }, |
| 1660 | } |
| 1661 | node.SetIPFSConfig("Routing", routingConf) |
| 1662 | node.StartDaemon() |
| 1663 | defer node.StopDaemon() |
| 1664 | |
| 1665 | // Add content and manually provide it |
| 1666 | cid := node.IPFSAddStr(time.Now().String()) |
| 1667 | |
| 1668 | // Manual provide should succeed even without libp2p peers |
| 1669 | res := node.RunIPFS("routing", "provide", cid) |
| 1670 | // Check that the command succeeded (exit code 0) and no provide-related errors |
| 1671 | assert.Equal(t, 0, res.ExitCode(), "routing provide should succeed with HTTP-only routing and SweepEnabled=true") |
| 1672 | assert.NotContains(t, res.Stderr.String(), "cannot provide", "should not have provide errors") |
| 1673 | |
| 1674 | // Verify HTTP router received at least one provide request |
| 1675 | assert.Greater(t, provideRequests.Load(), int32(0), |
| 1676 | "HTTP router should have received provide requests") |
| 1677 | |
| 1678 | // Verify 'provide stat' works with HTTP-only routing (regression test for stats) |
| 1679 | statRes := node.RunIPFS("provide", "stat") |
| 1680 | assert.Equal(t, 0, statRes.ExitCode(), "provide stat should succeed with HTTP-only routing") |
| 1681 | assert.NotContains(t, statRes.Stderr.String(), "stats not available", |
| 1682 | "should not report stats unavailable") |
| 1683 | // LegacyProvider outputs "TotalReprovides:" in its stats |
| 1684 | assert.Contains(t, statRes.Stdout.String(), "TotalReprovides:", |
| 1685 | "should show legacy provider stats") |
| 1686 | } |
| 1687 | |
| 1688 | // TestProviderKeystoreDatastoreCompaction verifies that the SweepingProvider's |
| 1689 | // keystore uses a datastore factory that creates separate physical datastores |
| 1690 | // and reclaims disk space by deleting old datastores after each reset cycle. |
| 1691 | // |
| 1692 | // The keystore uses two alternating namespaces ("0" and "1") plus a "meta" |
| 1693 | // namespace. The lifecycle is: |
| 1694 | // 1. First start: namespace "0" is created as the initial active datastore |
| 1695 | // 2. First reset (keystore sync at startup): "1" is created, data is written, |
| 1696 | // namespaces swap, "0" is destroyed from disk via os.RemoveAll |
| 1697 | // 3. Restart: "1" and "meta" survive on disk |
| 1698 | // 4. Second reset: "0" is recreated, namespaces swap, "1" is destroyed |
| 1699 | func TestProviderKeystoreDatastorePurge(t *testing.T) { |
| 1700 | t.Parallel() |
| 1701 | |
| 1702 | h := harness.NewT(t) |
| 1703 | node := h.NewNode().Init() |
| 1704 | node.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1705 | node.SetIPFSConfig("Provide.Enabled", true) |
| 1706 | node.SetIPFSConfig("Bootstrap", []string{}) |
| 1707 | |
| 1708 | // Add content offline so the keystore has something to sync on startup. |
| 1709 | for i := range 5 { |
| 1710 | node.IPFSAddStr(fmt.Sprintf("keystore-compaction-test-%d", i)) |
| 1711 | } |
| 1712 | |
| 1713 | keystoreBase := filepath.Join(node.Dir, "provider-keystore") |
| 1714 | ns0 := filepath.Join(keystoreBase, "0") |
| 1715 | ns1 := filepath.Join(keystoreBase, "1") |
| 1716 | |
| 1717 | // Directory should not exist before starting the daemon. |
| 1718 | _, err := os.Stat(keystoreBase) |
| 1719 | require.True(t, os.IsNotExist(err), "provider-keystore should not exist before daemon start") |
| 1720 | |
| 1721 | // --- First start: triggers keystore sync (ResetCids) --- |
| 1722 | // Init creates "0", then reset swaps to "1" and destroys "0". |
| 1723 | node.StartDaemon() |
| 1724 | |
| 1725 | require.Eventually(t, func() bool { |
| 1726 | return dirExists(ns1) && !dirExists(ns0) |
| 1727 | }, 30*time.Second, 200*time.Millisecond, |
| 1728 | "after first reset: ns1 should exist, ns0 should be destroyed") |
| 1729 | |
| 1730 | // --- Restart: triggers a second keystore sync (ResetCids) --- |
| 1731 | // Reset swaps back to "0" and destroys "1". |
| 1732 | node.StopDaemon() |
| 1733 | |
| 1734 | // Between restarts: ns1 survives on disk, ns0 does not. |
| 1735 | assert.True(t, dirExists(ns1), "ns1 should survive shutdown") |
| 1736 | assert.False(t, dirExists(ns0), "ns0 should not reappear between restarts") |
| 1737 | |
| 1738 | node.StartDaemon() |
| 1739 | |
| 1740 | require.Eventually(t, func() bool { |
| 1741 | return dirExists(ns0) && !dirExists(ns1) |
| 1742 | }, 30*time.Second, 200*time.Millisecond, |
| 1743 | "after second reset: ns0 should exist, ns1 should be destroyed") |
| 1744 | |
| 1745 | node.StopDaemon() |
| 1746 | } |
| 1747 | |
| 1748 | // TestProviderKeystoreMigrationPurge verifies that orphaned keystore data |
| 1749 | // left in the shared repo datastore by older Kubo versions is purged on |
| 1750 | // the first sweep-enabled daemon start. The migration is triggered by the |
| 1751 | // absence of the <repo>/provider-keystore/ directory. |
| 1752 | func TestProviderKeystoreMigrationPurge(t *testing.T) { |
| 1753 | t.Parallel() |
| 1754 | |
| 1755 | h := harness.NewT(t) |
| 1756 | node := h.NewNode().Init() |
| 1757 | node.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1758 | node.SetIPFSConfig("Provide.Enabled", true) |
| 1759 | node.SetIPFSConfig("Bootstrap", []string{}) |
| 1760 | |
| 1761 | keystoreBase := filepath.Join(node.Dir, "provider-keystore") |
| 1762 | |
| 1763 | // Pre-seed orphaned keystore data into the shared datastore, simulating |
| 1764 | // the layout produced by older Kubo that stored keystore entries inline. |
| 1765 | const numOrphans = 10 |
| 1766 | for i := range numOrphans { |
| 1767 | node.DatastorePut( |
| 1768 | fmt.Sprintf("/provider/keystore/%d/fake-key-%d", i%2, i), |
| 1769 | fmt.Sprintf("orphan-%d", i), |
| 1770 | ) |
| 1771 | } |
| 1772 | |
| 1773 | // The orphaned keys should be visible via diag datastore. |
| 1774 | count := node.DatastoreCount("/provider/keystore/") |
| 1775 | require.Equal(t, int64(numOrphans), count, "orphaned keys should be present before migration") |
| 1776 | |
| 1777 | // The provider-keystore directory must not exist yet (its absence |
| 1778 | // triggers the migration). |
| 1779 | require.False(t, dirExists(keystoreBase), |
| 1780 | "provider-keystore/ should not exist before first sweep-enabled start") |
| 1781 | |
| 1782 | // Start the daemon: this triggers the one-time migration purge. |
| 1783 | node.StartDaemon() |
| 1784 | node.StopDaemon() |
| 1785 | |
| 1786 | // After migration the seeded orphaned keys should be gone from the |
| 1787 | // shared datastore. The diag datastore count command mounts the |
| 1788 | // separate provider-keystore datastores, so we check for the specific |
| 1789 | // fake keys we seeded to confirm they were purged. |
| 1790 | for i := range numOrphans { |
| 1791 | key := fmt.Sprintf("/provider/keystore/%d/fake-key-%d", i%2, i) |
| 1792 | assert.False(t, node.DatastoreHasKey(key), |
| 1793 | "orphaned key %s should be purged after migration", key) |
| 1794 | } |
| 1795 | |
| 1796 | // The provider-keystore directory should now exist. |
| 1797 | assert.True(t, dirExists(keystoreBase), |
| 1798 | "provider-keystore/ should exist after sweep-enabled daemon ran") |
| 1799 | } |
| 1800 | |
| 1801 | func dirExists(path string) bool { |
| 1802 | info, err := os.Stat(path) |
| 1803 | return err == nil && info.IsDir() |
| 1804 | } |
| 1805 | |
| 1806 | // TestProviderKeystoreSyncShutdownQuiet verifies two shutdown UX |
| 1807 | // guarantees for a daemon running the sweeping provider with a |
| 1808 | // pin-walking strategy (see ipfs/kubo#11292): |
| 1809 | // |
| 1810 | // 1. Shutdown-caused keystore-sync errors never appear at Error |
| 1811 | // level. The fix classifies keystore.ErrClosed and context |
| 1812 | // cancellation as shutdown-caused and logs at Debug as |
| 1813 | // "interrupted by shutdown" instead. |
| 1814 | // 2. `ipfs pin ls --stream` running against the daemon returns a |
| 1815 | // meaningful error (no panic, no hang) when the daemon is |
| 1816 | // shutting down mid-stream. |
| 1817 | // |
| 1818 | // Determinism: with Provide.DHT.Interval=10ms the periodic |
| 1819 | // reprovide goroutine runs syncKeystore back-to-back (ticks coalesce |
| 1820 | // under the select), so it is always mid-sync when StopDaemon |
| 1821 | // closes the keystore. The line-scan below fails on the exact |
| 1822 | // Error+err=keystore-closed/context-canceled combination the old |
| 1823 | // code emitted. Empirically this catches the regression on most |
| 1824 | // runs (~3 of 5 on a fast workstation); the first few bug-free |
| 1825 | // runs were verified by temporarily reverting core/node/provider.go. |
| 1826 | func TestProviderKeystoreSyncShutdownQuiet(t *testing.T) { |
| 1827 | t.Parallel() |
| 1828 | |
| 1829 | h := harness.NewT(t) |
| 1830 | node := h.NewNode().Init() |
| 1831 | node.SetIPFSConfig("Provide.DHT.SweepEnabled", true) |
| 1832 | node.SetIPFSConfig("Provide.Enabled", true) |
| 1833 | node.SetIPFSConfig("Provide.Strategy", "pinned+mfs+entities") |
| 1834 | // Tight Interval: once the startup sync completes, the periodic |
| 1835 | // goroutine runs syncKeystore back-to-back (ticks coalesce under |
| 1836 | // the select), so it is always mid-sync when StopDaemon fires. |
| 1837 | // This makes the shutdown interrupt deterministic. Briefly |
| 1838 | // during startup the first periodic tick may overlap the startup |
| 1839 | // sync and emit "reset already in progress" at Error; the log |
| 1840 | // scan below explicitly ignores that unrelated class of error. |
| 1841 | node.SetIPFSConfig("Provide.DHT.Interval", "10ms") |
| 1842 | node.SetIPFSConfig("Bootstrap", []string{}) |
| 1843 | |
| 1844 | // Seed recursive pins so the keystore sync has meaningful work. |
| 1845 | // Offline bulk add + bulk pin is much faster than per-file |
| 1846 | // IPFSAddStr calls for this count. |
| 1847 | const nPins = 500 |
| 1848 | dir := t.TempDir() |
| 1849 | for i := range nPins { |
| 1850 | require.NoError(t, os.WriteFile( |
| 1851 | filepath.Join(dir, fmt.Sprintf("f%04d", i)), |
| 1852 | fmt.Appendf(nil, "keystore-shutdown-content-%d", i), |
| 1853 | 0o600, |
| 1854 | )) |
| 1855 | } |
| 1856 | // --pin=false so the wrapping dir is not auto-pinned; each file |
| 1857 | // is then pinned individually below to get nPins separate pin |
| 1858 | // index entries (one big recursive pin would not exercise the |
| 1859 | // pin-index streamIndex walk the same way). |
| 1860 | addRes := node.IPFS("add", "-r", "-q", "--pin=false", dir) |
| 1861 | addedCIDs := strings.Split(strings.TrimSpace(addRes.Stdout.String()), "\n") |
| 1862 | require.GreaterOrEqual(t, len(addedCIDs), nPins, "expected at least %d CIDs from bulk add", nPins) |
| 1863 | pinArgs := append([]string{"pin", "add"}, addedCIDs[:nPins]...) |
| 1864 | node.IPFS(pinArgs...) |
| 1865 | |
| 1866 | node.StartDaemonWithReq(harness.RunRequest{ |
| 1867 | CmdOpts: []harness.CmdOpt{ |
| 1868 | harness.RunWithEnv(map[string]string{ |
| 1869 | // Debug for the provider subsystem so the shutdown |
| 1870 | // Debug line is visible for post-hoc inspection. |
| 1871 | "GOLOG_LOG_LEVEL": "error,provider=debug", |
| 1872 | }), |
| 1873 | }, |
| 1874 | }, "") |
| 1875 | |
| 1876 | // Wait for the startup sync to complete so periodic has sole |
| 1877 | // access to the keystore when we shut down. |
| 1878 | require.Eventually(t, func() bool { |
| 1879 | return strings.Contains(node.Daemon.Stderr.String(), "provider keystore sync completed") |
| 1880 | }, 30*time.Second, 50*time.Millisecond, "startup keystore sync should complete") |
| 1881 | |
| 1882 | // Let periodic reprovide fire several times. |
| 1883 | time.Sleep(1 * time.Second) |
| 1884 | |
| 1885 | // Kick off `ipfs pin ls --stream` against the live RPC. The |
| 1886 | // server-side channel is held by the pinner's streamIndex |
| 1887 | // goroutine; when StopDaemon below tears down the keystore and |
| 1888 | // datastore, the HTTP stream closes under the CLI, which must |
| 1889 | // exit cleanly with a meaningful error (no panic, no hang). |
| 1890 | pinLsDone := make(chan *harness.RunResult, 1) |
| 1891 | go func() { |
| 1892 | pinLsDone <- node.RunIPFS("pin", "ls", "--stream") |
| 1893 | }() |
| 1894 | // Brief delay so the pin ls RPC has started streaming. |
| 1895 | time.Sleep(100 * time.Millisecond) |
| 1896 | |
| 1897 | node.StopDaemon() |
| 1898 | |
| 1899 | // --- Daemon-side assertions --- |
| 1900 | |
| 1901 | daemonLog := node.Daemon.Stderr.String() |
| 1902 | |
| 1903 | // Scan for the specific bug pattern: an Error-level line from |
| 1904 | // the provider subsystem about "keystore sync" whose err field |
| 1905 | // is the shutdown-caused "keystore is closed" or "context |
| 1906 | // canceled". The fix routes those to Debug; only unrelated |
| 1907 | // errors (e.g. "reset already in progress" from test-induced |
| 1908 | // overlap) remain at Error and are ignored by this check. |
| 1909 | for line := range strings.SplitSeq(daemonLog, "\n") { |
| 1910 | if !strings.Contains(line, "\tERROR\t") { |
| 1911 | continue |
| 1912 | } |
| 1913 | if !strings.Contains(line, "provider keystore sync") { |
| 1914 | continue |
| 1915 | } |
| 1916 | if strings.Contains(line, `"err": "keystore is closed"`) || |
| 1917 | strings.Contains(line, `"err": "context canceled"`) { |
| 1918 | t.Errorf("shutdown-caused keystore sync error should be logged at Debug, got Error:\n%s", line) |
| 1919 | } |
| 1920 | } |
| 1921 | |
| 1922 | // --- Client-side assertions (ipfs pin ls --stream) --- |
| 1923 | |
| 1924 | var pinLs *harness.RunResult |
| 1925 | select { |
| 1926 | case pinLs = <-pinLsDone: |
| 1927 | case <-time.After(15 * time.Second): |
| 1928 | t.Fatal("ipfs pin ls --stream did not return within 15s of daemon shutdown") |
| 1929 | } |
| 1930 | |
| 1931 | pinLsOut := pinLs.Stdout.String() + pinLs.Stderr.String() |
| 1932 | require.NotContains(t, pinLsOut, "panic:", |
| 1933 | "ipfs pin ls must not observe a daemon panic") |
| 1934 | // Either the stream drained before shutdown (exit 0) or the |
| 1935 | // server dropped it mid-stream (non-zero exit with a meaningful |
| 1936 | // error message). Silent non-zero exits are confusing and fail. |
| 1937 | if pinLs.ExitCode() != 0 { |
| 1938 | require.NotEmpty(t, strings.TrimSpace(pinLs.Stderr.String()), |
| 1939 | "pin ls exited non-zero but produced no error message") |
| 1940 | } |
| 1941 | } |