feat(mfs): chroot command to change the root (#8648)
Some checks are pending
CodeQL / codeql (push) Waiting to run
Docker Check / lint (push) Waiting to run
Docker Check / build (push) Waiting to run
Gateway Conformance / gateway-conformance (push) Waiting to run
Gateway Conformance / gateway-conformance-libp2p-experiment (push) Waiting to run
Go Build / go-build (push) Waiting to run
Go Check / go-check (push) Waiting to run
Go Lint / go-lint (push) Waiting to run
Go Test / unit-tests (push) Waiting to run
Go Test / cli-tests (push) Waiting to run
Go Test / example-tests (push) Waiting to run
Interop / interop-prep (push) Waiting to run
Interop / helia-interop (push) Blocked by required conditions
Interop / ipfs-webui (push) Blocked by required conditions
Sharness / sharness-test (push) Waiting to run
Spell Check / spellcheck (push) Waiting to run

Co-authored-by: Marcin Rataj <lidel@lidel.org>
This commit is contained in:
Lucas Molas 2026-01-13 17:47:36 -03:00 committed by GitHub
parent ec973aeb38
commit 39c609b3db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 288 additions and 17 deletions

View File

@ -90,6 +90,7 @@ func TestCommands(t *testing.T) {
"/files/stat",
"/files/write",
"/files/chmod",
"/files/chroot",
"/files/touch",
"/filestore",
"/filestore/dups",

View File

@ -16,11 +16,15 @@ import (
"time"
humanize "github.com/dustin/go-humanize"
oldcmds "github.com/ipfs/kubo/commands"
"github.com/ipfs/kubo/config"
"github.com/ipfs/kubo/core"
"github.com/ipfs/kubo/core/commands/cmdenv"
"github.com/ipfs/kubo/core/node"
fsrepo "github.com/ipfs/kubo/repo/fsrepo"
bservice "github.com/ipfs/boxo/blockservice"
bstore "github.com/ipfs/boxo/blockstore"
offline "github.com/ipfs/boxo/exchange/offline"
dag "github.com/ipfs/boxo/ipld/merkledag"
ft "github.com/ipfs/boxo/ipld/unixfs"
@ -28,6 +32,7 @@ import (
"github.com/ipfs/boxo/path"
cid "github.com/ipfs/go-cid"
cidenc "github.com/ipfs/go-cidutil/cidenc"
"github.com/ipfs/go-datastore"
cmds "github.com/ipfs/go-ipfs-cmds"
ipld "github.com/ipfs/go-ipld-format"
logging "github.com/ipfs/go-log/v2"
@ -120,18 +125,19 @@ performance.`,
cmds.BoolOption(filesFlushOptionName, "f", "Flush target and ancestors after write.").WithDefault(true),
},
Subcommands: map[string]*cmds.Command{
"read": filesReadCmd,
"write": filesWriteCmd,
"mv": filesMvCmd,
"cp": filesCpCmd,
"ls": filesLsCmd,
"mkdir": filesMkdirCmd,
"stat": filesStatCmd,
"rm": filesRmCmd,
"flush": filesFlushCmd,
"chcid": filesChcidCmd,
"chmod": filesChmodCmd,
"touch": filesTouchCmd,
"read": filesReadCmd,
"write": filesWriteCmd,
"mv": filesMvCmd,
"cp": filesCpCmd,
"ls": filesLsCmd,
"mkdir": filesMkdirCmd,
"stat": filesStatCmd,
"rm": filesRmCmd,
"flush": filesFlushCmd,
"chcid": filesChcidCmd,
"chmod": filesChmodCmd,
"chroot": filesChrootCmd,
"touch": filesTouchCmd,
},
}
@ -1648,3 +1654,141 @@ Examples:
return mfs.Touch(nd.FilesRoot, path, ts)
},
}
const chrootConfirmOptionName = "confirm"
var filesChrootCmd = &cmds.Command{
Status: cmds.Experimental,
Helptext: cmds.HelpText{
Tagline: "Change the MFS root CID.",
ShortDescription: `
'ipfs files chroot' changes the root CID used by MFS (Mutable File System).
This is a recovery command for when MFS becomes corrupted and prevents the
daemon from starting.
When run without a CID argument, resets MFS to an empty directory.
WARNING: The old MFS root and its unpinned children will be removed during
the next garbage collection. Pin the old root first if you want to preserve.
This command can only run when the daemon is not running.
Examples:
# Reset MFS to empty directory (recovery from corruption)
$ ipfs files chroot --confirm
# Restore MFS to a known good directory CID
$ ipfs files chroot --confirm QmYourBackupCID
`,
},
Arguments: []cmds.Argument{
cmds.StringArg("cid", false, false, "New root CID (defaults to empty directory if not specified)."),
},
Options: []cmds.Option{
cmds.BoolOption(chrootConfirmOptionName, "Confirm this potentially destructive operation."),
},
NoRemote: true,
Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
confirm, _ := req.Options[chrootConfirmOptionName].(bool)
if !confirm {
return errors.New("this is a potentially destructive operation; pass --confirm to proceed")
}
// Determine new root CID
var newRootCid cid.Cid
if len(req.Arguments) > 0 {
var err error
newRootCid, err = cid.Decode(req.Arguments[0])
if err != nil {
return fmt.Errorf("invalid CID %q: %w", req.Arguments[0], err)
}
} else {
// Default to empty directory
newRootCid = ft.EmptyDirNode().Cid()
}
// Get config root to open repo directly
cctx := env.(*oldcmds.Context)
cfgRoot := cctx.ConfigRoot
// Open repo directly (daemon must not be running)
repo, err := fsrepo.Open(cfgRoot)
if err != nil {
return fmt.Errorf("opening repo (is the daemon running?): %w", err)
}
defer repo.Close()
localDS := repo.Datastore()
bs := bstore.NewBlockstore(localDS)
// Check new root exists locally and is a directory
hasBlock, err := bs.Has(req.Context, newRootCid)
if err != nil {
return fmt.Errorf("checking if new root exists: %w", err)
}
if !hasBlock {
// Special case: empty dir is always available (hardcoded in boxo)
emptyDirCid := ft.EmptyDirNode().Cid()
if !newRootCid.Equals(emptyDirCid) {
return fmt.Errorf("new root %s does not exist locally; fetch it first with 'ipfs block get'", newRootCid)
}
}
// Validate it's a directory (not a file)
if hasBlock {
blk, err := bs.Get(req.Context, newRootCid)
if err != nil {
return fmt.Errorf("reading new root block: %w", err)
}
pbNode, err := dag.DecodeProtobuf(blk.RawData())
if err != nil {
return fmt.Errorf("new root is not a valid dag-pb node: %w", err)
}
fsNode, err := ft.FSNodeFromBytes(pbNode.Data())
if err != nil {
return fmt.Errorf("new root is not a valid UnixFS node: %w", err)
}
if fsNode.Type() != ft.TDirectory && fsNode.Type() != ft.THAMTShard {
return fmt.Errorf("new root must be a directory, got %s", fsNode.Type())
}
}
// Get old root for display (if exists)
var oldRootStr string
oldRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey)
if err == nil {
oldRootCid, err := cid.Cast(oldRootBytes)
if err == nil {
oldRootStr = oldRootCid.String()
}
} else if !errors.Is(err, datastore.ErrNotFound) {
return fmt.Errorf("reading current MFS root: %w", err)
}
// Write new root
err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newRootCid.Bytes())
if err != nil {
return fmt.Errorf("writing new MFS root: %w", err)
}
// Build output message
var msg string
if oldRootStr != "" {
msg = fmt.Sprintf("MFS root changed from %s to %s\n", oldRootStr, newRootCid)
msg += fmt.Sprintf("The old root %s will be garbage collected unless pinned.\n", oldRootStr)
} else {
msg = fmt.Sprintf("MFS root set to %s\n", newRootCid)
}
return cmds.EmitOnce(res, &MessageOutput{Message: msg})
},
Type: MessageOutput{},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *MessageOutput) error {
_, err := fmt.Fprint(w, out.Message)
return err
}),
},
}

View File

@ -30,6 +30,9 @@ import (
"github.com/ipfs/kubo/repo"
)
// FilesRootDatastoreKey is the datastore key for the MFS files root CID.
var FilesRootDatastoreKey = datastore.NewKey("/local/filesroot")
// BlockService creates new blockservice which provides an interface to fetch content-addressable blocks
func BlockService(cfg *config.Config) func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
return func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
@ -181,7 +184,6 @@ func Dag(bs blockservice.BlockService) format.DAGService {
// Files loads persisted MFS root
func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) {
return func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) {
dsk := datastore.NewKey("/local/filesroot")
pf := func(ctx context.Context, c cid.Cid) error {
rootDS := repo.Datastore()
if err := rootDS.Sync(ctx, blockstore.BlockPrefix); err != nil {
@ -191,15 +193,15 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo
return err
}
if err := rootDS.Put(ctx, dsk, c.Bytes()); err != nil {
if err := rootDS.Put(ctx, FilesRootDatastoreKey, c.Bytes()); err != nil {
return err
}
return rootDS.Sync(ctx, dsk)
return rootDS.Sync(ctx, FilesRootDatastoreKey)
}
var nd *merkledag.ProtoNode
ctx := helpers.LifecycleCtx(mctx, lc)
val, err := repo.Datastore().Get(ctx, dsk)
val, err := repo.Datastore().Get(ctx, FilesRootDatastoreKey)
switch {
case errors.Is(err, datastore.ErrNotFound):
@ -243,7 +245,8 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo
root, err := mfs.NewRoot(ctx, dag, nd, pf, prov)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize MFS root from %s stored at %s: %w. "+
"If corrupted, use 'ipfs files chroot' to reset (see --help)", nd.Cid(), FilesRootDatastoreKey, err)
}
lc.Append(fx.Hook{

View File

@ -17,6 +17,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
- [Improved `ipfs dag stat` output](#improved-ipfs-dag-stat-output)
- [Skip bad keys when listing](#skip_bad_keys_when_listing)
- [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together)
- [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root)
- [📦️ Dependency updates](#-dependency-updates)
- [📝 Changelog](#-changelog)
- [👨‍👩‍👧‍👦 Contributors](#-contributors)
@ -96,6 +97,22 @@ Change the `ipfs key list` behavior to log an error and continue listing keys wh
Previously, provide operations could start before the Accelerated DHT Client discovered enough peers, causing sweep mode to lose its efficiency benefits. Now, providing waits for the initial network crawl (about 10 minutes). Your content will be properly distributed across DHT regions after initial DHT map is created. Check `ipfs provide stat` to see when providing begins.
#### 🔧 Recovery from corrupted MFS root
If your daemon fails to start because the MFS root is not a directory (due to misconfiguration, operational error, or disk corruption), you can now recover without deleting and recreating your repository in a new `IPFS_PATH`.
The new `ipfs files chroot` command lets you reset the MFS (Mutable File System) root or restore it to a known valid CID:
```console
# Reset MFS to an empty directory
$ ipfs files chroot --confirm
# Or restore from a previously saved directory CID
$ ipfs files chroot --confirm QmYourBackupCID
```
See `ipfs files chroot --help` for details.
#### 📦️ Dependency updates
- update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0)

View File

@ -353,3 +353,109 @@ func TestFilesNoFlushLimit(t *testing.T) {
assert.Contains(t, res.Stderr.String(), "reached limit of 5 unflushed MFS operations")
})
}
func TestFilesChroot(t *testing.T) {
t.Parallel()
// Known CIDs for testing
emptyDirCid := "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"
t.Run("requires --confirm flag", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init()
// Don't start daemon - chroot runs offline
res := node.RunIPFS("files", "chroot")
require.NotNil(t, res.ExitErr)
assert.NotEqual(t, 0, res.ExitErr.ExitCode())
assert.Contains(t, res.Stderr.String(), "pass --confirm to proceed")
})
t.Run("resets to empty directory", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init()
// Start daemon to create MFS state
node.StartDaemon()
node.IPFS("files", "mkdir", "/testdir")
node.StopDaemon()
// Reset MFS to empty - should exit 0
res := node.RunIPFS("files", "chroot", "--confirm")
assert.Nil(t, res.ExitErr, "expected exit code 0")
assert.Contains(t, res.Stdout.String(), emptyDirCid)
// Verify daemon starts and MFS is empty
node.StartDaemon()
defer node.StopDaemon()
lsRes := node.IPFS("files", "ls", "/")
assert.Empty(t, lsRes.Stdout.Trimmed())
})
t.Run("replaces with valid directory CID", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init()
// Start daemon to add content
node.StartDaemon()
node.IPFS("files", "mkdir", "/mydir")
// Create a temp file for content
tempFile := filepath.Join(node.Dir, "testfile.txt")
require.NoError(t, os.WriteFile(tempFile, []byte("hello"), 0644))
node.IPFS("files", "write", "--create", "/mydir/file.txt", tempFile)
statRes := node.IPFS("files", "stat", "--hash", "/mydir")
dirCid := statRes.Stdout.Trimmed()
node.StopDaemon()
// Reset to empty first
node.IPFS("files", "chroot", "--confirm")
// Set root to the saved directory - should exit 0
res := node.RunIPFS("files", "chroot", "--confirm", dirCid)
assert.Nil(t, res.ExitErr, "expected exit code 0")
assert.Contains(t, res.Stdout.String(), dirCid)
// Verify content
node.StartDaemon()
defer node.StopDaemon()
readRes := node.IPFS("files", "read", "/file.txt")
assert.Equal(t, "hello", readRes.Stdout.Trimmed())
})
t.Run("fails with non-existent CID", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init()
res := node.RunIPFS("files", "chroot", "--confirm", "bafybeibdxtd5thfoitjmnfhxhywokebwdmwnuqgkzjjdjhwjz7qh77777a")
require.NotNil(t, res.ExitErr)
assert.NotEqual(t, 0, res.ExitErr.ExitCode())
assert.Contains(t, res.Stderr.String(), "does not exist locally")
})
t.Run("fails with file CID", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init()
// Add a file to get a file CID
node.StartDaemon()
fileCid := node.IPFSAddStr("hello world")
node.StopDaemon()
// Try to set file as root - should fail with non-zero exit
res := node.RunIPFS("files", "chroot", "--confirm", fileCid)
require.NotNil(t, res.ExitErr)
assert.NotEqual(t, 0, res.ExitErr.ExitCode())
assert.Contains(t, res.Stderr.String(), "must be a directory")
})
t.Run("fails while daemon is running", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
res := node.RunIPFS("files", "chroot", "--confirm")
require.NotNil(t, res.ExitErr)
assert.NotEqual(t, 0, res.ExitErr.ExitCode())
assert.Contains(t, res.Stderr.String(), "opening repo")
})
}