mirror of
https://github.com/ipfs/kubo.git
synced 2026-02-21 18:37:45 +08:00
* chore: apply go fix modernizers from Go 1.26
automated refactoring: interface{} to any, slices.Contains,
and other idiomatic updates.
* feat(ci): add `go fix` check to Go analysis workflow
ensures Go 1.26 modernizers are applied, fails CI if `go fix ./...`
produces any changes (similar to existing `go fmt` enforcement)
525 lines
16 KiB
Go
525 lines
16 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ipfs/kubo/test/cli/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
provideStatEventuallyTimeout = 15 * time.Second
|
|
provideStatEventuallyTick = 100 * time.Millisecond
|
|
)
|
|
|
|
// sweepStats mirrors the subset of JSON fields actually used by tests.
|
|
// This type is intentionally independent from upstream types to detect breaking changes.
|
|
// Only includes fields that tests actually access to keep it simple and maintainable.
|
|
type sweepStats struct {
|
|
Sweep struct {
|
|
Closed bool `json:"closed"`
|
|
Connectivity struct {
|
|
Status string `json:"status"`
|
|
} `json:"connectivity"`
|
|
Queues struct {
|
|
PendingKeyProvides int `json:"pending_key_provides"`
|
|
} `json:"queues"`
|
|
Schedule struct {
|
|
Keys int `json:"keys"`
|
|
} `json:"schedule"`
|
|
} `json:"Sweep"`
|
|
}
|
|
|
|
// parseSweepStats parses JSON output from ipfs provide stat command.
|
|
// Tests will naturally fail if upstream removes/renames fields we depend on.
|
|
func parseSweepStats(t *testing.T, jsonOutput string) sweepStats {
|
|
t.Helper()
|
|
var stats sweepStats
|
|
err := json.Unmarshal([]byte(jsonOutput), &stats)
|
|
require.NoError(t, err, "failed to parse provide stat JSON output")
|
|
return stats
|
|
}
|
|
|
|
// TestProvideStatAllMetricsDocumented verifies that all metrics output by
|
|
// `ipfs provide stat --all` are documented in docs/provide-stats.md.
|
|
//
|
|
// The test works as follows:
|
|
// 1. Starts an IPFS node with Provide.DHT.SweepEnabled=true
|
|
// 2. Runs `ipfs provide stat --all` to get all metrics
|
|
// 3. Parses the output and extracts all lines with exactly 2 spaces indent
|
|
// (these are the actual metric lines)
|
|
// 4. Reads docs/provide-stats.md and extracts all ### section headers
|
|
// 5. Ensures every metric in the output has a corresponding ### section in the docs
|
|
func TestProvideStatAllMetricsDocumented(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
|
|
// Enable sweep provider
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
// Run `ipfs provide stat --all` to get all metrics
|
|
res := node.IPFS("provide", "stat", "--all")
|
|
require.NoError(t, res.Err)
|
|
|
|
// Parse metrics from the command output
|
|
// Only consider lines with exactly two spaces of padding (" ")
|
|
// These are the actual metric lines as shown in provide.go
|
|
outputMetrics := make(map[string]bool)
|
|
scanner := bufio.NewScanner(strings.NewReader(res.Stdout.String()))
|
|
// Only consider lines that start with exactly two spaces
|
|
indent := " "
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, indent) || strings.HasPrefix(line, indent) {
|
|
continue
|
|
}
|
|
|
|
// Remove the indent
|
|
line = strings.TrimPrefix(line, indent)
|
|
|
|
// Extract metric name - everything before the first ':'
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) >= 1 {
|
|
metricName := strings.TrimSpace(parts[0])
|
|
if metricName != "" {
|
|
outputMetrics[metricName] = true
|
|
}
|
|
}
|
|
}
|
|
require.NoError(t, scanner.Err())
|
|
|
|
// Read docs/provide-stats.md
|
|
// Find the repo root by looking for go.mod
|
|
repoRoot := ".."
|
|
for range 6 {
|
|
if _, err := os.Stat(filepath.Join(repoRoot, "go.mod")); err == nil {
|
|
break
|
|
}
|
|
repoRoot = filepath.Join("..", repoRoot)
|
|
}
|
|
docsPath := filepath.Join(repoRoot, "docs", "provide-stats.md")
|
|
docsFile, err := os.Open(docsPath)
|
|
require.NoError(t, err, "Failed to open provide-stats.md")
|
|
defer docsFile.Close()
|
|
|
|
// Parse all ### metric headers from the docs
|
|
documentedMetrics := make(map[string]bool)
|
|
docsScanner := bufio.NewScanner(docsFile)
|
|
for docsScanner.Scan() {
|
|
line := docsScanner.Text()
|
|
if metricName, found := strings.CutPrefix(line, "### "); found {
|
|
metricName = strings.TrimSpace(metricName)
|
|
documentedMetrics[metricName] = true
|
|
}
|
|
}
|
|
require.NoError(t, docsScanner.Err())
|
|
|
|
// Check that all output metrics are documented
|
|
var undocumentedMetrics []string
|
|
for metric := range outputMetrics {
|
|
if !documentedMetrics[metric] {
|
|
undocumentedMetrics = append(undocumentedMetrics, metric)
|
|
}
|
|
}
|
|
|
|
require.Empty(t, undocumentedMetrics,
|
|
"The following metrics from 'ipfs provide stat --all' are not documented in docs/provide-stats.md: %v\n"+
|
|
"All output metrics: %v\n"+
|
|
"Documented metrics: %v",
|
|
undocumentedMetrics, outputMetrics, documentedMetrics)
|
|
}
|
|
|
|
// TestProvideStatBasic tests basic functionality of ipfs provide stat
|
|
func TestProvideStatBasic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("works with Sweep provider and shows brief output", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat")
|
|
require.NoError(t, res.Err)
|
|
assert.Empty(t, res.Stderr.String())
|
|
|
|
output := res.Stdout.String()
|
|
// Brief output should contain specific full labels
|
|
assert.Contains(t, output, "Provide queue:")
|
|
assert.Contains(t, output, "Reprovide queue:")
|
|
assert.Contains(t, output, "CIDs scheduled:")
|
|
assert.Contains(t, output, "Regions scheduled:")
|
|
assert.Contains(t, output, "Avg record holders:")
|
|
assert.Contains(t, output, "Ongoing provides:")
|
|
assert.Contains(t, output, "Ongoing reprovides:")
|
|
assert.Contains(t, output, "Total CIDs provided:")
|
|
})
|
|
|
|
t.Run("requires daemon to be online", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
|
|
res := node.RunIPFS("provide", "stat")
|
|
assert.Error(t, res.Err)
|
|
assert.Contains(t, res.Stderr.String(), "this command must be run in online mode")
|
|
})
|
|
}
|
|
|
|
// TestProvideStatFlags tests various command flags
|
|
func TestProvideStatFlags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("--all flag shows all sections with headings", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat", "--all")
|
|
require.NoError(t, res.Err)
|
|
|
|
output := res.Stdout.String()
|
|
// Should contain section headings with colons
|
|
assert.Contains(t, output, "Connectivity:")
|
|
assert.Contains(t, output, "Queues:")
|
|
assert.Contains(t, output, "Schedule:")
|
|
assert.Contains(t, output, "Timings:")
|
|
assert.Contains(t, output, "Network:")
|
|
assert.Contains(t, output, "Operations:")
|
|
assert.Contains(t, output, "Workers:")
|
|
|
|
// Should contain detailed metrics not in brief mode
|
|
assert.Contains(t, output, "Uptime:")
|
|
assert.Contains(t, output, "Cycle started:")
|
|
assert.Contains(t, output, "Reprovide interval:")
|
|
assert.Contains(t, output, "Peers swept:")
|
|
assert.Contains(t, output, "Full keyspace coverage:")
|
|
})
|
|
|
|
t.Run("--compact requires --all", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.RunIPFS("provide", "stat", "--compact")
|
|
assert.Error(t, res.Err)
|
|
assert.Contains(t, res.Stderr.String(), "--compact requires --all flag")
|
|
})
|
|
|
|
t.Run("--compact with --all shows 2-column layout", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat", "--all", "--compact")
|
|
require.NoError(t, res.Err)
|
|
|
|
output := res.Stdout.String()
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
require.NotEmpty(t, lines)
|
|
|
|
// In compact mode, find a line that has both Schedule and Connectivity metrics
|
|
// This confirms 2-column layout is working
|
|
foundTwoColumns := false
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "CIDs scheduled:") && strings.Contains(line, "Status:") {
|
|
foundTwoColumns = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundTwoColumns, "Should have at least one line with both 'CIDs scheduled:' and 'Status:' confirming 2-column layout")
|
|
})
|
|
|
|
t.Run("individual section flags work with full labels", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
testCases := []struct {
|
|
flag string
|
|
contains []string
|
|
}{
|
|
{
|
|
flag: "--connectivity",
|
|
contains: []string{"Status:"},
|
|
},
|
|
{
|
|
flag: "--queues",
|
|
contains: []string{"Provide queue:", "Reprovide queue:"},
|
|
},
|
|
{
|
|
flag: "--schedule",
|
|
contains: []string{"CIDs scheduled:", "Regions scheduled:", "Avg prefix length:", "Next region prefix:", "Next region reprovide:"},
|
|
},
|
|
{
|
|
flag: "--timings",
|
|
contains: []string{"Uptime:", "Current time offset:", "Cycle started:", "Reprovide interval:"},
|
|
},
|
|
{
|
|
flag: "--network",
|
|
contains: []string{"Avg record holders:", "Peers swept:", "Full keyspace coverage:", "Reachable peers:", "Avg region size:", "Replication factor:"},
|
|
},
|
|
{
|
|
flag: "--operations",
|
|
contains: []string{"Ongoing provides:", "Ongoing reprovides:", "Total CIDs provided:", "Total records provided:", "Total provide errors:"},
|
|
},
|
|
{
|
|
flag: "--workers",
|
|
contains: []string{"Active workers:", "Free workers:", "Workers stats:", "Periodic", "Burst"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
res := node.IPFS("provide", "stat", tc.flag)
|
|
require.NoError(t, res.Err, "flag %s should work", tc.flag)
|
|
output := res.Stdout.String()
|
|
for _, expected := range tc.contains {
|
|
assert.Contains(t, output, expected, "flag %s should contain '%s'", tc.flag, expected)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("multiple section flags can be combined", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat", "--network", "--operations")
|
|
require.NoError(t, res.Err)
|
|
|
|
output := res.Stdout.String()
|
|
// Should have section headings when multiple flags combined
|
|
assert.Contains(t, output, "Network:")
|
|
assert.Contains(t, output, "Operations:")
|
|
assert.Contains(t, output, "Avg record holders:")
|
|
assert.Contains(t, output, "Ongoing provides:")
|
|
})
|
|
}
|
|
|
|
// TestProvideStatLegacyProvider tests Legacy provider specific behavior
|
|
func TestProvideStatLegacyProvider(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", false)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
t.Run("shows legacy stats from old provider system", func(t *testing.T) {
|
|
res := node.IPFS("provide", "stat")
|
|
require.NoError(t, res.Err)
|
|
|
|
// Legacy provider shows stats from the old reprovider system
|
|
output := res.Stdout.String()
|
|
assert.Contains(t, output, "TotalReprovides:")
|
|
assert.Contains(t, output, "AvgReprovideDuration:")
|
|
assert.Contains(t, output, "LastReprovideDuration:")
|
|
})
|
|
|
|
t.Run("rejects flags with legacy provider", func(t *testing.T) {
|
|
flags := []string{"--all", "--connectivity", "--queues", "--network", "--workers"}
|
|
for _, flag := range flags {
|
|
res := node.RunIPFS("provide", "stat", flag)
|
|
assert.Error(t, res.Err, "flag %s should be rejected for legacy provider", flag)
|
|
assert.Contains(t, res.Stderr.String(), "cannot use flags with legacy provide stats")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects --lan flag with legacy provider", func(t *testing.T) {
|
|
res := node.RunIPFS("provide", "stat", "--lan")
|
|
assert.Error(t, res.Err)
|
|
assert.Contains(t, res.Stderr.String(), "LAN stats only available for Sweep provider with Dual DHT")
|
|
})
|
|
}
|
|
|
|
// TestProvideStatOutputFormats tests different output formats
|
|
func TestProvideStatOutputFormats(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("JSON output with Sweep provider", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat", "--enc=json")
|
|
require.NoError(t, res.Err)
|
|
|
|
// Parse JSON to verify structure
|
|
var result struct {
|
|
Sweep map[string]any `json:"Sweep"`
|
|
Legacy map[string]any `json:"Legacy"`
|
|
}
|
|
err := json.Unmarshal([]byte(res.Stdout.String()), &result)
|
|
require.NoError(t, err, "Output should be valid JSON")
|
|
assert.NotNil(t, result.Sweep, "Sweep stats should be present")
|
|
assert.Nil(t, result.Legacy, "Legacy stats should not be present")
|
|
})
|
|
|
|
t.Run("JSON output with Legacy provider", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", false)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.IPFS("provide", "stat", "--enc=json")
|
|
require.NoError(t, res.Err)
|
|
|
|
// Parse JSON to verify structure
|
|
var result struct {
|
|
Sweep map[string]any `json:"Sweep"`
|
|
Legacy map[string]any `json:"Legacy"`
|
|
}
|
|
err := json.Unmarshal([]byte(res.Stdout.String()), &result)
|
|
require.NoError(t, err, "Output should be valid JSON")
|
|
assert.Nil(t, result.Sweep, "Sweep stats should not be present")
|
|
assert.NotNil(t, result.Legacy, "Legacy stats should be present")
|
|
})
|
|
}
|
|
|
|
// TestProvideStatIntegration tests integration with provide operations
|
|
func TestProvideStatIntegration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("stats reflect content being added to schedule", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.SetIPFSConfig("Provide.DHT.Interval", "1h")
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
// Get initial scheduled CID count
|
|
res1 := node.IPFS("provide", "stat", "--enc=json")
|
|
require.NoError(t, res1.Err)
|
|
initialKeys := parseSweepStats(t, res1.Stdout.String()).Sweep.Schedule.Keys
|
|
|
|
// Add content - this should increase CIDs scheduled
|
|
node.IPFSAddStr("test content for stats")
|
|
|
|
// Wait for content to appear in schedule (with timeout)
|
|
// The buffered provider may take a moment to schedule items
|
|
require.Eventually(t, func() bool {
|
|
res := node.IPFS("provide", "stat", "--enc=json")
|
|
require.NoError(t, res.Err)
|
|
stats := parseSweepStats(t, res.Stdout.String())
|
|
return stats.Sweep.Schedule.Keys > initialKeys
|
|
}, provideStatEventuallyTimeout, provideStatEventuallyTick, "Content should appear in schedule after adding")
|
|
})
|
|
|
|
t.Run("stats work with all documented strategies", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test all strategies documented in docs/config.md#providestrategy
|
|
strategies := []string{"all", "pinned", "roots", "mfs", "pinned+mfs"}
|
|
for _, strategy := range strategies {
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.SetIPFSConfig("Provide.Strategy", strategy)
|
|
node.StartDaemon()
|
|
|
|
res := node.IPFS("provide", "stat")
|
|
require.NoError(t, res.Err, "stats should work with strategy %s", strategy)
|
|
output := res.Stdout.String()
|
|
assert.NotEmpty(t, output)
|
|
assert.Contains(t, output, "CIDs scheduled:")
|
|
|
|
node.StopDaemon()
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestProvideStatDisabledConfig tests behavior when provide system is disabled
|
|
func TestProvideStatDisabledConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("Provide.Enabled=false returns error stats not available", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", false)
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.RunIPFS("provide", "stat")
|
|
assert.Error(t, res.Err)
|
|
assert.Contains(t, res.Stderr.String(), "stats not available")
|
|
})
|
|
|
|
t.Run("Provide.Enabled=true with Provide.DHT.Interval=0 returns error stats not available", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := harness.NewT(t)
|
|
node := h.NewNode().Init()
|
|
node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
|
|
node.SetIPFSConfig("Provide.Enabled", true)
|
|
node.SetIPFSConfig("Provide.DHT.Interval", "0")
|
|
node.StartDaemon()
|
|
defer node.StopDaemon()
|
|
|
|
res := node.RunIPFS("provide", "stat")
|
|
assert.Error(t, res.Err)
|
|
assert.Contains(t, res.Stderr.String(), "stats not available")
|
|
})
|
|
}
|