Skip to content

Commit 4cf0bbf

Browse files
committed
feat(files): add 'ipfs files chroot' command
adds recovery command for corrupted MFS root (issue #10762): - `ipfs files chroot [--confirm] [<cid>]` replaces the MFS root CID - defaults to empty directory if no CID specified - validates new CID exists locally and is a directory - exports FilesRootDatastoreKey constant for reuse - improved error message shows CID and suggests recovery replaces the previous `replace-root` approach with renamed command following the `ch*` pattern (chcid, chmod, chroot).
1 parent 9e7bf8f commit 4cf0bbf

File tree

5 files changed

+297
-124
lines changed

5 files changed

+297
-124
lines changed

core/commands/commands_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func TestCommands(t *testing.T) {
9090
"/files/stat",
9191
"/files/write",
9292
"/files/chmod",
93+
"/files/chroot",
9394
"/files/touch",
9495
"/filestore",
9596
"/filestore/dups",

core/commands/files.go

Lines changed: 165 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import (
1616
"time"
1717

1818
humanize "github.com/dustin/go-humanize"
19+
oldcmds "github.com/ipfs/kubo/commands"
1920
"github.com/ipfs/kubo/config"
2021
"github.com/ipfs/kubo/core"
2122
"github.com/ipfs/kubo/core/commands/cmdenv"
2223
"github.com/ipfs/kubo/core/node"
2324
fsrepo "github.com/ipfs/kubo/repo/fsrepo"
2425

2526
bservice "github.com/ipfs/boxo/blockservice"
27+
bstore "github.com/ipfs/boxo/blockstore"
2628
offline "github.com/ipfs/boxo/exchange/offline"
2729
dag "github.com/ipfs/boxo/ipld/merkledag"
2830
ft "github.com/ipfs/boxo/ipld/unixfs"
@@ -31,7 +33,6 @@ import (
3133
cid "github.com/ipfs/go-cid"
3234
cidenc "github.com/ipfs/go-cidutil/cidenc"
3335
"github.com/ipfs/go-datastore"
34-
fslock "github.com/ipfs/go-fs-lock"
3536
cmds "github.com/ipfs/go-ipfs-cmds"
3637
ipld "github.com/ipfs/go-ipld-format"
3738
logging "github.com/ipfs/go-log/v2"
@@ -124,19 +125,19 @@ performance.`,
124125
cmds.BoolOption(filesFlushOptionName, "f", "Flush target and ancestors after write.").WithDefault(true),
125126
},
126127
Subcommands: map[string]*cmds.Command{
127-
"read": filesReadCmd,
128-
"write": filesWriteCmd,
129-
"mv": filesMvCmd,
130-
"cp": filesCpCmd,
131-
"ls": filesLsCmd,
132-
"mkdir": filesMkdirCmd,
133-
"stat": filesStatCmd,
134-
"rm": filesRmCmd,
135-
"flush": filesFlushCmd,
136-
"chcid": filesChcidCmd,
137-
"chmod": filesChmodCmd,
138-
"touch": filesTouchCmd,
139-
"replace-root": filesReplaceRoot,
128+
"read": filesReadCmd,
129+
"write": filesWriteCmd,
130+
"mv": filesMvCmd,
131+
"cp": filesCpCmd,
132+
"ls": filesLsCmd,
133+
"mkdir": filesMkdirCmd,
134+
"stat": filesStatCmd,
135+
"rm": filesRmCmd,
136+
"flush": filesFlushCmd,
137+
"chcid": filesChcidCmd,
138+
"chmod": filesChmodCmd,
139+
"chroot": filesChrootCmd,
140+
"touch": filesTouchCmd,
140141
},
141142
}
142143

@@ -1366,111 +1367,6 @@ Remove files or directories.
13661367
},
13671368
}
13681369

1369-
var filesReplaceRoot = &cmds.Command{
1370-
Helptext: cmds.HelpText{
1371-
Tagline: "Replace the MFS root.",
1372-
ShortDescription: `
1373-
Replace the filesystem root with another CID when the filesystem. Usually used as recovery method if MFS has been corrupted.
1374-
`,
1375-
LongDescription: `
1376-
Replace the filesystem root with another CID.
1377-
This command is meant to run in standalone mode when the daemon isn't running.
1378-
It is an advanced command that you normally do *not* want to run except when
1379-
the filesystem has been corrupted and the daemon refuses to run.
1380-
1381-
FIXME: Add an example of the daemon not running once https://github.com/ipfs/go-ipfs/issues/7183
1382-
is resolved.
1383-
1384-
$ ipfs init
1385-
[...]
1386-
ipfs cat /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme # <- init dir
1387-
[...]
1388-
$ ipfs files ls /
1389-
[nothing; empty dir]
1390-
$ ipfs files stat / --hash
1391-
QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn
1392-
# FIXME(BLOCKING): Need the following to somehow "start" the root dir, otherwise
1393-
# the replace-root will fail to find the '/local/filesroot' entry in the repo
1394-
$ ipfs files cp /ipfs/QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc/readme /file
1395-
$ GOLOG_LOG_LEVEL="info" ipfs files replace-root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc # init dir from before
1396-
[...] replaced MFS files root QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc [...]
1397-
[here we have the CID of the old root to "undo" in case of error]
1398-
$ ipfs files ls /
1399-
[contents from init dir now set as the root of the filesystem]
1400-
about
1401-
contact
1402-
help
1403-
ping
1404-
quick-start
1405-
readme
1406-
security-notes
1407-
$ ipfs files replace-root QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn # empty dir from init
1408-
$ ipfs files ls /
1409-
[nothing; empty dir]
1410-
`,
1411-
},
1412-
Arguments: []cmds.Argument{
1413-
cmds.StringArg("new-root", true, false, "New root to use."),
1414-
},
1415-
// FIXME(BLOCKING): Can/should we do this with the repo running?
1416-
NoRemote: true,
1417-
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
1418-
if len(req.Arguments) < 1 {
1419-
return fmt.Errorf("new root not provided")
1420-
}
1421-
newFilesRootCid, err := cid.Parse(req.Arguments[0])
1422-
if err != nil {
1423-
return fmt.Errorf("files root argument provided %s is not a valid CID: %w", req.Arguments[0], err)
1424-
1425-
}
1426-
// FIXME(BLOCKING): Check (a) that this CID exists *locally* and (b)
1427-
// that it's a dir.
1428-
1429-
cfgRoot, err := cmdenv.GetConfigRoot(env)
1430-
if err != nil {
1431-
return err
1432-
}
1433-
1434-
repo, err := fsrepo.Open(cfgRoot)
1435-
if err != nil {
1436-
if pathError, ok := err.(*os.PathError); ok {
1437-
if _, isLockError := pathError.Unwrap().(fslock.LockedError); isLockError {
1438-
return fmt.Errorf("aquiring the repo lock (make sure the daemon isn't running): %w", err)
1439-
}
1440-
}
1441-
return err
1442-
}
1443-
localDS := repo.Datastore()
1444-
defer repo.Close()
1445-
1446-
filesRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey)
1447-
if err == datastore.ErrNotFound {
1448-
return fmt.Errorf("MFS files root %s not found in repo", node.FilesRootDatastoreKey)
1449-
} else if err != nil {
1450-
return fmt.Errorf("looking for MFS files root: %w", err)
1451-
}
1452-
filesRootCid, err := cid.Cast(filesRootBytes)
1453-
if err != nil {
1454-
return fmt.Errorf("casting MFS files root %s as CID: %w", filesRootBytes, err)
1455-
}
1456-
1457-
err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newFilesRootCid.Bytes())
1458-
if err != nil {
1459-
return fmt.Errorf("storing new files root: %w", err)
1460-
}
1461-
// FIXME(BLOCKING): Do we need this if we're closing the repo at the end
1462-
// of the command? Likely not.
1463-
err = localDS.Sync(req.Context, node.FilesRootDatastoreKey)
1464-
if err != nil {
1465-
return fmt.Errorf("syncing new files root: %w", err)
1466-
}
1467-
1468-
log.Infof("replaced MFS files root %s with %s", filesRootCid, newFilesRootCid)
1469-
1470-
return nil
1471-
},
1472-
}
1473-
14741370
func removePath(filesRoot *mfs.Root, path string, force bool, dashr bool) error {
14751371
if path == "/" {
14761372
return fmt.Errorf("cannot delete root")
@@ -1758,3 +1654,153 @@ Examples:
17581654
return mfs.Touch(nd.FilesRoot, path, ts)
17591655
},
17601656
}
1657+
1658+
const chrootConfirmOptionName = "confirm"
1659+
1660+
var filesChrootCmd = &cmds.Command{
1661+
Status: cmds.Experimental,
1662+
Helptext: cmds.HelpText{
1663+
Tagline: "Change the MFS root CID.",
1664+
ShortDescription: `
1665+
'ipfs files chroot' changes the root CID used by MFS (Mutable File System).
1666+
This is a recovery command for when MFS becomes corrupted and prevents the
1667+
daemon from starting.
1668+
1669+
When run without a CID argument, resets MFS to an empty directory.
1670+
1671+
WARNING: The old MFS root and its unpinned children will be removed during
1672+
the next garbage collection. Pin the old root first if you want to preserve.
1673+
1674+
This command can only run when the daemon is not running.
1675+
`,
1676+
LongDescription: `
1677+
'ipfs files chroot' changes the root CID used by MFS (Mutable File System).
1678+
This is a recovery command for when MFS becomes corrupted and prevents the
1679+
daemon from starting.
1680+
1681+
When run without a CID argument, resets MFS to an empty directory.
1682+
1683+
WARNING: The old MFS root and its unpinned children will be removed during
1684+
the next garbage collection. Pin the old root first if you want to preserve.
1685+
1686+
This command can only run when the daemon is not running.
1687+
1688+
Examples:
1689+
1690+
# Reset MFS to empty directory (recovery from corruption)
1691+
$ ipfs files chroot --confirm
1692+
1693+
# Restore MFS to a known good directory CID
1694+
$ ipfs files chroot --confirm QmYourBackupCID
1695+
`,
1696+
},
1697+
Arguments: []cmds.Argument{
1698+
cmds.StringArg("cid", false, false, "New root CID (defaults to empty directory if not specified)."),
1699+
},
1700+
Options: []cmds.Option{
1701+
cmds.BoolOption(chrootConfirmOptionName, "Confirm this potentially destructive operation."),
1702+
},
1703+
NoRemote: true,
1704+
Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
1705+
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
1706+
confirm, _ := req.Options[chrootConfirmOptionName].(bool)
1707+
if !confirm {
1708+
return errors.New("this is a potentially destructive operation; pass --confirm to proceed")
1709+
}
1710+
1711+
// Determine new root CID
1712+
var newRootCid cid.Cid
1713+
if len(req.Arguments) > 0 {
1714+
var err error
1715+
newRootCid, err = cid.Decode(req.Arguments[0])
1716+
if err != nil {
1717+
return fmt.Errorf("invalid CID %q: %w", req.Arguments[0], err)
1718+
}
1719+
} else {
1720+
// Default to empty directory
1721+
newRootCid = ft.EmptyDirNode().Cid()
1722+
}
1723+
1724+
// Get config root to open repo directly
1725+
cctx := env.(*oldcmds.Context)
1726+
cfgRoot := cctx.ConfigRoot
1727+
1728+
// Open repo directly (daemon must not be running)
1729+
repo, err := fsrepo.Open(cfgRoot)
1730+
if err != nil {
1731+
return fmt.Errorf("opening repo (is the daemon running?): %w", err)
1732+
}
1733+
defer repo.Close()
1734+
1735+
localDS := repo.Datastore()
1736+
bs := bstore.NewBlockstore(localDS)
1737+
1738+
// Check new root exists locally and is a directory
1739+
hasBlock, err := bs.Has(req.Context, newRootCid)
1740+
if err != nil {
1741+
return fmt.Errorf("checking if new root exists: %w", err)
1742+
}
1743+
if !hasBlock {
1744+
// Special case: empty dir is always available (hardcoded in boxo)
1745+
emptyDirCid := ft.EmptyDirNode().Cid()
1746+
if !newRootCid.Equals(emptyDirCid) {
1747+
return fmt.Errorf("new root %s does not exist locally; fetch it first with 'ipfs block get'", newRootCid)
1748+
}
1749+
}
1750+
1751+
// Validate it's a directory (not a file)
1752+
if hasBlock {
1753+
blk, err := bs.Get(req.Context, newRootCid)
1754+
if err != nil {
1755+
return fmt.Errorf("reading new root block: %w", err)
1756+
}
1757+
pbNode, err := dag.DecodeProtobuf(blk.RawData())
1758+
if err != nil {
1759+
return fmt.Errorf("new root is not a valid dag-pb node: %w", err)
1760+
}
1761+
fsNode, err := ft.FSNodeFromBytes(pbNode.Data())
1762+
if err != nil {
1763+
return fmt.Errorf("new root is not a valid UnixFS node: %w", err)
1764+
}
1765+
if fsNode.Type() != ft.TDirectory && fsNode.Type() != ft.THAMTShard {
1766+
return fmt.Errorf("new root must be a directory, got %s", fsNode.Type())
1767+
}
1768+
}
1769+
1770+
// Get old root for display (if exists)
1771+
var oldRootStr string
1772+
oldRootBytes, err := localDS.Get(req.Context, node.FilesRootDatastoreKey)
1773+
if err == nil {
1774+
oldRootCid, err := cid.Cast(oldRootBytes)
1775+
if err == nil {
1776+
oldRootStr = oldRootCid.String()
1777+
}
1778+
} else if !errors.Is(err, datastore.ErrNotFound) {
1779+
return fmt.Errorf("reading current MFS root: %w", err)
1780+
}
1781+
1782+
// Write new root
1783+
err = localDS.Put(req.Context, node.FilesRootDatastoreKey, newRootCid.Bytes())
1784+
if err != nil {
1785+
return fmt.Errorf("writing new MFS root: %w", err)
1786+
}
1787+
1788+
// Build output message
1789+
var msg string
1790+
if oldRootStr != "" {
1791+
msg = fmt.Sprintf("MFS root changed from %s to %s\n", oldRootStr, newRootCid)
1792+
msg += fmt.Sprintf("The old root %s will be garbage collected unless pinned.\n", oldRootStr)
1793+
} else {
1794+
msg = fmt.Sprintf("MFS root set to %s\n", newRootCid)
1795+
}
1796+
1797+
return cmds.EmitOnce(res, &MessageOutput{Message: msg})
1798+
},
1799+
Type: MessageOutput{},
1800+
Encoders: cmds.EncoderMap{
1801+
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *MessageOutput) error {
1802+
_, err := fmt.Fprint(w, out.Message)
1803+
return err
1804+
}),
1805+
},
1806+
}

core/node/core.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import (
3030
"github.com/ipfs/kubo/repo"
3131
)
3232

33+
// FilesRootDatastoreKey is the datastore key for the MFS files root CID.
34+
var FilesRootDatastoreKey = datastore.NewKey("/local/filesroot")
35+
3336
// BlockService creates new blockservice which provides an interface to fetch content-addressable blocks
3437
func BlockService(cfg *config.Config) func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
3538
return func(lc fx.Lifecycle, bs blockstore.Blockstore, rem exchange.Interface) blockservice.BlockService {
@@ -181,7 +184,6 @@ func Dag(bs blockservice.BlockService) format.DAGService {
181184
// Files loads persisted MFS root
182185
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) {
183186
return func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo repo.Repo, dag format.DAGService, bs blockstore.Blockstore, prov DHTProvider) (*mfs.Root, error) {
184-
dsk := datastore.NewKey("/local/filesroot")
185187
pf := func(ctx context.Context, c cid.Cid) error {
186188
rootDS := repo.Datastore()
187189
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
191193
return err
192194
}
193195

194-
if err := rootDS.Put(ctx, dsk, c.Bytes()); err != nil {
196+
if err := rootDS.Put(ctx, FilesRootDatastoreKey, c.Bytes()); err != nil {
195197
return err
196198
}
197-
return rootDS.Sync(ctx, dsk)
199+
return rootDS.Sync(ctx, FilesRootDatastoreKey)
198200
}
199201

200202
var nd *merkledag.ProtoNode
201203
ctx := helpers.LifecycleCtx(mctx, lc)
202-
val, err := repo.Datastore().Get(ctx, dsk)
204+
val, err := repo.Datastore().Get(ctx, FilesRootDatastoreKey)
203205

204206
switch {
205207
case errors.Is(err, datastore.ErrNotFound):
@@ -243,7 +245,8 @@ func Files(strategy string) func(mctx helpers.MetricsCtx, lc fx.Lifecycle, repo
243245

244246
root, err := mfs.NewRoot(ctx, dag, nd, pf, prov)
245247
if err != nil {
246-
return nil, err
248+
return nil, fmt.Errorf("failed to initialize MFS root from %s stored at %s: %w. "+
249+
"If corrupted, use 'ipfs files chroot' to reset (see --help)", nd.Cid(), FilesRootDatastoreKey, err)
247250
}
248251

249252
lc.Append(fx.Hook{

0 commit comments

Comments
 (0)