mirror of
https://github.com/ipfs/kubo.git
synced 2026-02-21 10:27:46 +08:00
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
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:
parent
ec973aeb38
commit
39c609b3db
@ -90,6 +90,7 @@ func TestCommands(t *testing.T) {
|
||||
"/files/stat",
|
||||
"/files/write",
|
||||
"/files/chmod",
|
||||
"/files/chroot",
|
||||
"/files/touch",
|
||||
"/filestore",
|
||||
"/filestore/dups",
|
||||
|
||||
@ -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
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user