master
go 527 lines 16.7 KB
Raw
1 package cli
2
3 import (
4 "bufio"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/ipfs/kubo/test/cli/harness"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 const (
18 provideStatEventuallyTimeout = 15 * time.Second
19 provideStatEventuallyTick = 100 * time.Millisecond
20 )
21
22 // sweepStats mirrors the subset of JSON fields actually used by tests.
23 // This type is intentionally independent from upstream types to detect breaking changes.
24 // Only includes fields that tests actually access to keep it simple and maintainable.
25 type sweepStats struct {
26 Sweep struct {
27 Closed bool `json:"closed"`
28 Connectivity struct {
29 Status string `json:"status"`
30 } `json:"connectivity"`
31 Queues struct {
32 PendingKeyProvides int `json:"pending_key_provides"`
33 } `json:"queues"`
34 Schedule struct {
35 Keys int `json:"keys"`
36 } `json:"schedule"`
37 } `json:"Sweep"`
38 }
39
40 // parseSweepStats parses JSON output from ipfs provide stat command.
41 // Tests will naturally fail if upstream removes/renames fields we depend on.
42 func parseSweepStats(t *testing.T, jsonOutput string) sweepStats {
43 t.Helper()
44 var stats sweepStats
45 err := json.Unmarshal([]byte(jsonOutput), &stats)
46 require.NoError(t, err, "failed to parse provide stat JSON output")
47 return stats
48 }
49
50 // TestProvideStatAllMetricsDocumented verifies that all metrics output by
51 // `ipfs provide stat --all` are documented in docs/provide-stats.md.
52 //
53 // The test works as follows:
54 // 1. Starts an IPFS node with Provide.DHT.SweepEnabled=true
55 // 2. Runs `ipfs provide stat --all` to get all metrics
56 // 3. Parses the output and extracts all lines with exactly 2 spaces indent
57 // (these are the actual metric lines)
58 // 4. Reads docs/provide-stats.md and extracts all ### section headers
59 // 5. Ensures every metric in the output has a corresponding ### section in the docs
60 func TestProvideStatAllMetricsDocumented(t *testing.T) {
61 t.Parallel()
62
63 h := harness.NewT(t)
64 node := h.NewNode().Init()
65
66 // Enable sweep provider
67 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
68 node.SetIPFSConfig("Provide.Enabled", true)
69
70 node.StartDaemon()
71 defer node.StopDaemon()
72
73 // Run `ipfs provide stat --all` to get all metrics
74 res := node.IPFS("provide", "stat", "--all")
75 require.NoError(t, res.Err)
76
77 // Parse metrics from the command output
78 // Only consider lines with exactly two spaces of padding (" ")
79 // These are the actual metric lines as shown in provide.go
80 outputMetrics := make(map[string]bool)
81 scanner := bufio.NewScanner(strings.NewReader(res.Stdout.String()))
82 // Only consider lines that start with exactly two spaces
83 indent := " "
84 for scanner.Scan() {
85 line := scanner.Text()
86 if !strings.HasPrefix(line, indent) || strings.HasPrefix(line, indent) {
87 continue
88 }
89
90 // Remove the indent
91 line = strings.TrimPrefix(line, indent)
92
93 // Extract metric name - everything before the first ':'
94 parts := strings.SplitN(line, ":", 2)
95 if len(parts) >= 1 {
96 metricName := strings.TrimSpace(parts[0])
97 if metricName != "" {
98 outputMetrics[metricName] = true
99 }
100 }
101 }
102 require.NoError(t, scanner.Err())
103
104 // Read docs/provide-stats.md
105 // Find the repo root by looking for go.mod
106 repoRoot := ".."
107 for range 6 {
108 if _, err := os.Stat(filepath.Join(repoRoot, "go.mod")); err == nil {
109 break
110 }
111 repoRoot = filepath.Join("..", repoRoot)
112 }
113 docsPath := filepath.Join(repoRoot, "docs", "provide-stats.md")
114 docsFile, err := os.Open(docsPath)
115 require.NoError(t, err, "Failed to open provide-stats.md")
116 defer docsFile.Close()
117
118 // Parse all ### metric headers from the docs
119 documentedMetrics := make(map[string]bool)
120 docsScanner := bufio.NewScanner(docsFile)
121 for docsScanner.Scan() {
122 line := docsScanner.Text()
123 if metricName, found := strings.CutPrefix(line, "### "); found {
124 metricName = strings.TrimSpace(metricName)
125 documentedMetrics[metricName] = true
126 }
127 }
128 require.NoError(t, docsScanner.Err())
129
130 // Check that all output metrics are documented
131 var undocumentedMetrics []string
132 for metric := range outputMetrics {
133 if !documentedMetrics[metric] {
134 undocumentedMetrics = append(undocumentedMetrics, metric)
135 }
136 }
137
138 require.Empty(t, undocumentedMetrics,
139 "The following metrics from 'ipfs provide stat --all' are not documented in docs/provide-stats.md: %v\n"+
140 "All output metrics: %v\n"+
141 "Documented metrics: %v",
142 undocumentedMetrics, outputMetrics, documentedMetrics)
143 }
144
145 // TestProvideStatBasic tests basic functionality of ipfs provide stat
146 func TestProvideStatBasic(t *testing.T) {
147 t.Parallel()
148
149 t.Run("works with Sweep provider and shows brief output", func(t *testing.T) {
150 t.Parallel()
151
152 h := harness.NewT(t)
153 node := h.NewNode().Init()
154 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
155 node.SetIPFSConfig("Provide.Enabled", true)
156 node.StartDaemon()
157 defer node.StopDaemon()
158
159 res := node.IPFS("provide", "stat")
160 require.NoError(t, res.Err)
161 assert.Empty(t, res.Stderr.String())
162
163 output := res.Stdout.String()
164 // Brief output should contain specific full labels
165 assert.Contains(t, output, "Provide queue:")
166 assert.Contains(t, output, "Reprovide queue:")
167 assert.Contains(t, output, "CIDs scheduled:")
168 assert.Contains(t, output, "Regions scheduled:")
169 assert.Contains(t, output, "Avg record holders:")
170 assert.Contains(t, output, "Ongoing provides:")
171 assert.Contains(t, output, "Ongoing reprovides:")
172 assert.Contains(t, output, "Total CIDs provided:")
173 })
174
175 t.Run("requires daemon to be online", func(t *testing.T) {
176 t.Parallel()
177
178 h := harness.NewT(t)
179 node := h.NewNode().Init()
180
181 res := node.RunIPFS("provide", "stat")
182 assert.Error(t, res.Err)
183 assert.Contains(t, res.Stderr.String(), "this command must be run in online mode")
184 })
185 }
186
187 // TestProvideStatFlags tests various command flags
188 func TestProvideStatFlags(t *testing.T) {
189 t.Parallel()
190
191 t.Run("--all flag shows all sections with headings", func(t *testing.T) {
192 t.Parallel()
193
194 h := harness.NewT(t)
195 node := h.NewNode().Init()
196 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
197 node.SetIPFSConfig("Provide.Enabled", true)
198 node.StartDaemon()
199 defer node.StopDaemon()
200
201 res := node.IPFS("provide", "stat", "--all")
202 require.NoError(t, res.Err)
203
204 output := res.Stdout.String()
205 // Should contain section headings with colons
206 assert.Contains(t, output, "Connectivity:")
207 assert.Contains(t, output, "Queues:")
208 assert.Contains(t, output, "Schedule:")
209 assert.Contains(t, output, "Timings:")
210 assert.Contains(t, output, "Network:")
211 assert.Contains(t, output, "Operations:")
212 assert.Contains(t, output, "Workers:")
213
214 // Should contain detailed metrics not in brief mode
215 assert.Contains(t, output, "Uptime:")
216 assert.Contains(t, output, "Cycle started:")
217 assert.Contains(t, output, "Reprovide interval:")
218 assert.Contains(t, output, "Peers swept:")
219 assert.Contains(t, output, "Full keyspace coverage:")
220 })
221
222 t.Run("--compact requires --all", func(t *testing.T) {
223 t.Parallel()
224
225 h := harness.NewT(t)
226 node := h.NewNode().Init()
227 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
228 node.SetIPFSConfig("Provide.Enabled", true)
229 node.StartDaemon()
230 defer node.StopDaemon()
231
232 res := node.RunIPFS("provide", "stat", "--compact")
233 assert.Error(t, res.Err)
234 assert.Contains(t, res.Stderr.String(), "--compact requires --all flag")
235 })
236
237 t.Run("--compact with --all shows 2-column layout", func(t *testing.T) {
238 t.Parallel()
239
240 h := harness.NewT(t)
241 node := h.NewNode().Init()
242 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
243 node.SetIPFSConfig("Provide.Enabled", true)
244 node.StartDaemon()
245 defer node.StopDaemon()
246
247 res := node.IPFS("provide", "stat", "--all", "--compact")
248 require.NoError(t, res.Err)
249
250 output := res.Stdout.String()
251 lines := strings.Split(strings.TrimSpace(output), "\n")
252 require.NotEmpty(t, lines)
253
254 // In compact mode, find a line that has both Schedule and Connectivity metrics
255 // This confirms 2-column layout is working
256 foundTwoColumns := false
257 for _, line := range lines {
258 if strings.Contains(line, "CIDs scheduled:") && strings.Contains(line, "Status:") {
259 foundTwoColumns = true
260 break
261 }
262 }
263 assert.True(t, foundTwoColumns, "Should have at least one line with both 'CIDs scheduled:' and 'Status:' confirming 2-column layout")
264 })
265
266 t.Run("individual section flags work with full labels", func(t *testing.T) {
267 t.Parallel()
268
269 h := harness.NewT(t)
270 node := h.NewNode().Init()
271 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
272 node.SetIPFSConfig("Provide.Enabled", true)
273 node.StartDaemon()
274 defer node.StopDaemon()
275
276 testCases := []struct {
277 flag string
278 contains []string
279 }{
280 {
281 flag: "--connectivity",
282 contains: []string{"Status:"},
283 },
284 {
285 flag: "--queues",
286 contains: []string{"Provide queue:", "Reprovide queue:"},
287 },
288 {
289 flag: "--schedule",
290 contains: []string{"CIDs scheduled:", "Regions scheduled:", "Avg prefix length:", "Next region prefix:", "Next region reprovide:"},
291 },
292 {
293 flag: "--timings",
294 contains: []string{"Uptime:", "Current time offset:", "Cycle started:", "Reprovide interval:"},
295 },
296 {
297 flag: "--network",
298 contains: []string{"Avg record holders:", "Peers swept:", "Full keyspace coverage:", "Reachable peers:", "Avg region size:", "Replication factor:"},
299 },
300 {
301 flag: "--operations",
302 contains: []string{"Ongoing provides:", "Ongoing reprovides:", "Total CIDs provided:", "Total records provided:", "Total provide errors:"},
303 },
304 {
305 flag: "--workers",
306 contains: []string{"Active workers:", "Free workers:", "Workers stats:", "Periodic", "Burst"},
307 },
308 }
309
310 for _, tc := range testCases {
311 res := node.IPFS("provide", "stat", tc.flag)
312 require.NoError(t, res.Err, "flag %s should work", tc.flag)
313 output := res.Stdout.String()
314 for _, expected := range tc.contains {
315 assert.Contains(t, output, expected, "flag %s should contain '%s'", tc.flag, expected)
316 }
317 }
318 })
319
320 t.Run("multiple section flags can be combined", func(t *testing.T) {
321 t.Parallel()
322
323 h := harness.NewT(t)
324 node := h.NewNode().Init()
325 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
326 node.SetIPFSConfig("Provide.Enabled", true)
327 node.StartDaemon()
328 defer node.StopDaemon()
329
330 res := node.IPFS("provide", "stat", "--network", "--operations")
331 require.NoError(t, res.Err)
332
333 output := res.Stdout.String()
334 // Should have section headings when multiple flags combined
335 assert.Contains(t, output, "Network:")
336 assert.Contains(t, output, "Operations:")
337 assert.Contains(t, output, "Avg record holders:")
338 assert.Contains(t, output, "Ongoing provides:")
339 })
340 }
341
342 // TestProvideStatLegacyProvider tests Legacy provider specific behavior
343 func TestProvideStatLegacyProvider(t *testing.T) {
344 t.Parallel()
345
346 h := harness.NewT(t)
347 node := h.NewNode().Init()
348 node.SetIPFSConfig("Provide.DHT.SweepEnabled", false)
349 node.SetIPFSConfig("Provide.Enabled", true)
350 node.StartDaemon()
351 defer node.StopDaemon()
352
353 t.Run("shows legacy stats from old provider system", func(t *testing.T) {
354 res := node.IPFS("provide", "stat")
355 require.NoError(t, res.Err)
356
357 // Legacy provider shows stats from the old reprovider system
358 output := res.Stdout.String()
359 assert.Contains(t, output, "TotalReprovides:")
360 assert.Contains(t, output, "AvgReprovideDuration:")
361 assert.Contains(t, output, "LastReprovideDuration:")
362 })
363
364 t.Run("rejects flags with legacy provider", func(t *testing.T) {
365 flags := []string{"--all", "--connectivity", "--queues", "--network", "--workers"}
366 for _, flag := range flags {
367 res := node.RunIPFS("provide", "stat", flag)
368 assert.Error(t, res.Err, "flag %s should be rejected for legacy provider", flag)
369 assert.Contains(t, res.Stderr.String(), "cannot use flags with legacy provide stats")
370 }
371 })
372
373 t.Run("rejects --lan flag with legacy provider", func(t *testing.T) {
374 res := node.RunIPFS("provide", "stat", "--lan")
375 assert.Error(t, res.Err)
376 assert.Contains(t, res.Stderr.String(), "LAN stats only available for Sweep provider with Dual DHT")
377 })
378 }
379
380 // TestProvideStatOutputFormats tests different output formats
381 func TestProvideStatOutputFormats(t *testing.T) {
382 t.Parallel()
383
384 t.Run("JSON output with Sweep provider", func(t *testing.T) {
385 t.Parallel()
386
387 h := harness.NewT(t)
388 node := h.NewNode().Init()
389 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
390 node.SetIPFSConfig("Provide.Enabled", true)
391 node.StartDaemon()
392 defer node.StopDaemon()
393
394 res := node.IPFS("provide", "stat", "--enc=json")
395 require.NoError(t, res.Err)
396
397 // Parse JSON to verify structure
398 var result struct {
399 Sweep map[string]any `json:"Sweep"`
400 Legacy map[string]any `json:"Legacy"`
401 }
402 err := json.Unmarshal([]byte(res.Stdout.String()), &result)
403 require.NoError(t, err, "Output should be valid JSON")
404 assert.NotNil(t, result.Sweep, "Sweep stats should be present")
405 assert.Nil(t, result.Legacy, "Legacy stats should not be present")
406 })
407
408 t.Run("JSON output with Legacy provider", func(t *testing.T) {
409 t.Parallel()
410
411 h := harness.NewT(t)
412 node := h.NewNode().Init()
413 node.SetIPFSConfig("Provide.DHT.SweepEnabled", false)
414 node.SetIPFSConfig("Provide.Enabled", true)
415 node.StartDaemon()
416 defer node.StopDaemon()
417
418 res := node.IPFS("provide", "stat", "--enc=json")
419 require.NoError(t, res.Err)
420
421 // Parse JSON to verify structure
422 var result struct {
423 Sweep map[string]any `json:"Sweep"`
424 Legacy map[string]any `json:"Legacy"`
425 }
426 err := json.Unmarshal([]byte(res.Stdout.String()), &result)
427 require.NoError(t, err, "Output should be valid JSON")
428 assert.Nil(t, result.Sweep, "Sweep stats should not be present")
429 assert.NotNil(t, result.Legacy, "Legacy stats should be present")
430 })
431 }
432
433 // TestProvideStatIntegration tests integration with provide operations
434 func TestProvideStatIntegration(t *testing.T) {
435 t.Parallel()
436
437 t.Run("stats reflect content being added to schedule", func(t *testing.T) {
438 t.Parallel()
439
440 h := harness.NewT(t)
441 node := h.NewNode().Init()
442 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
443 node.SetIPFSConfig("Provide.Enabled", true)
444 node.SetIPFSConfig("Provide.DHT.Interval", "1h")
445 node.StartDaemon()
446 defer node.StopDaemon()
447
448 // Get initial scheduled CID count
449 res1 := node.IPFS("provide", "stat", "--enc=json")
450 require.NoError(t, res1.Err)
451 initialKeys := parseSweepStats(t, res1.Stdout.String()).Sweep.Schedule.Keys
452
453 // Add content - this should increase CIDs scheduled
454 node.IPFSAddStr("test content for stats")
455
456 // Wait for content to appear in schedule (with timeout)
457 // The buffered provider may take a moment to schedule items
458 require.Eventually(t, func() bool {
459 res := node.IPFS("provide", "stat", "--enc=json")
460 require.NoError(t, res.Err)
461 stats := parseSweepStats(t, res.Stdout.String())
462 return stats.Sweep.Schedule.Keys > initialKeys
463 }, provideStatEventuallyTimeout, provideStatEventuallyTick, "Content should appear in schedule after adding")
464 })
465
466 t.Run("stats work with all documented strategies", func(t *testing.T) {
467 t.Parallel()
468
469 // Test all strategies documented in docs/config.md#providestrategy
470 strategies := []string{"all", "pinned", "roots", "mfs", "pinned+mfs"}
471 for _, strategy := range strategies {
472 h := harness.NewT(t)
473 node := h.NewNode().Init()
474 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
475 node.SetIPFSConfig("Provide.Enabled", true)
476 node.SetIPFSConfig("Provide.Strategy", strategy)
477 node.StartDaemon()
478
479 res := node.IPFS("provide", "stat")
480 require.NoError(t, res.Err, "stats should work with strategy %s", strategy)
481 output := res.Stdout.String()
482 assert.NotEmpty(t, output)
483 assert.Contains(t, output, "CIDs scheduled:")
484
485 node.StopDaemon()
486 }
487 })
488 }
489
490 // TestProvideStatDisabledConfig tests behavior when provide system is disabled
491 func TestProvideStatDisabledConfig(t *testing.T) {
492 t.Parallel()
493
494 t.Run("Provide.Enabled=false returns error stats not available", func(t *testing.T) {
495 t.Parallel()
496
497 h := harness.NewT(t)
498 node := h.NewNode().Init()
499 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
500 node.SetIPFSConfig("Provide.Enabled", false)
501 node.StartDaemon()
502 defer node.StopDaemon()
503
504 res := node.RunIPFS("provide", "stat")
505 assert.Error(t, res.Err)
506 assert.Contains(t, res.Stderr.String(), "stats not available")
507 })
508
509 t.Run("Provide.Enabled=true with Provide.DHT.Interval=0 returns stats with zero schedule fields", func(t *testing.T) {
510 t.Parallel()
511
512 h := harness.NewT(t)
513 node := h.NewNode().Init()
514 node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
515 node.SetIPFSConfig("Provide.Enabled", true)
516 node.SetIPFSConfig("Provide.DHT.Interval", "0")
517 node.StartDaemon()
518 defer node.StopDaemon()
519
520 // Interval=0 disables only the periodic schedule; the provider
521 // is still wired and 'provide stat' returns valid stats with
522 // the schedule-related timing fields zeroed out.
523 res := node.RunIPFS("provide", "stat")
524 assert.Equal(t, 0, res.ExitCode())
525 assert.NotContains(t, res.Stderr.String(), "stats not available")
526 })
527 }