From 6df07bd5a69b232073300a011ebca10aecb86929 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 20 Dec 2025 04:29:15 +0100 Subject: [PATCH] fix: wire --config-file flag across all commands the --config-file global flag was defined but not properly used by most commands. this fixes the flag to work consistently, allowing config to be stored separately from the repo. useful for Kubernetes deployments where config comes from a ConfigMap and data lives on a PersistentVolume. changes: - fsrepo: add InitWithUserConfig/OpenWithUserConfig that respect custom config paths - fsrepo: fix InitWithUserConfig to create version/datastore_spec even when config file already exists (pre-populated from ConfigMap) - commands: update all commands that open repo to use the new functions - daemon: add CONFIGURATION FILE MANAGEMENT section to help text explaining difference between --init-config (one-time copy) and --config-file (persistent external path) - init: clarify that default-config template preserves Identity --- cmd/ipfs/kubo/daemon.go | 30 +- cmd/ipfs/kubo/init.go | 21 +- cmd/ipfs/kubo/start.go | 10 +- core/commands/bootstrap.go | 12 +- core/commands/cmdutils/utils.go | 3 + core/commands/config.go | 13 +- core/commands/keystore.go | 10 +- core/commands/pin/remotepin.go | 27 +- core/commands/root.go | 3 +- core/commands/swarm.go | 6 +- docs/changelogs/v0.40.md | 25 + plugin/loader/loader.go | 4 +- repo/fsrepo/config_test.go | 2 +- repo/fsrepo/fsrepo.go | 52 +- .../ipfsfetcher/ipfsfetcher_test.go | 2 +- test/cli/config_file_test.go | 632 ++++++++++++++++++ 16 files changed, 779 insertions(+), 73 deletions(-) create mode 100644 test/cli/config_file_test.go diff --git a/cmd/ipfs/kubo/daemon.go b/cmd/ipfs/kubo/daemon.go index 97d46c7cf0a..bf4bcb6acdc 100644 --- a/cmd/ipfs/kubo/daemon.go +++ b/cmd/ipfs/kubo/daemon.go @@ -155,6 +155,26 @@ environment variable: export IPFS_PATH=/path/to/ipfsrepo +CONFIGURATION FILE MANAGEMENT + +The --init-config and --config-file flags serve different purposes: + + --init-config + Copies a configuration template to $IPFS_PATH/config during --init. + This is a one-time operation; subsequent changes to the template + have no effect. + + --config-file + Uses an external configuration file directly for all commands. + Takes precedence over $IPFS_PATH/config. The config is never copied; + Kubo always reads from this path. Useful for Kubernetes ConfigMaps + or container deployments where config should be managed separately + from the repo data. + +Example using --config-file for Kubernetes: + + ipfs daemon --init --repo-dir /data/ipfs --config-file /etc/ipfs/config + DEPRECATION NOTICE Previously, Kubo used an environment variable as seen below: @@ -169,7 +189,7 @@ Headers. Options: []cmds.Option{ cmds.BoolOption(initOptionKwd, "Initialize Kubo with default settings if not already initialized"), - cmds.StringOption(initConfigOptionKwd, "Path to existing configuration file to be loaded during --init"), + cmds.StringOption(initConfigOptionKwd, "Path to configuration template to copy during --init (one-time). For persistent external config, use --config-file instead"), cmds.StringOption(initProfileOptionKwd, "Configuration profiles to apply for --init. See ipfs init --help for more"), cmds.StringOption(routingOptionKwd, "Overrides the routing option").WithDefault(routingOptionDefaultKwd), cmds.BoolOption(mountKwd, "Mounts IPFS to the filesystem using FUSE (experimental)"), @@ -266,6 +286,7 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment if initialize && !fsrepo.IsInitialized(cctx.ConfigRoot) { cfgLocation, _ := req.Options[initConfigOptionKwd].(string) profiles, _ := req.Options[initProfileOptionKwd].(string) + configFileOpt, _ := req.Options[commands.ConfigFileOption].(string) var conf *config.Config if cfgLocation != "" { @@ -287,7 +308,7 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment } } - if err = doInit(os.Stdout, cctx.ConfigRoot, false, profiles, conf); err != nil { + if err = doInit(os.Stdout, cctx.ConfigRoot, configFileOpt, false, profiles, conf); err != nil { return err } } @@ -297,7 +318,8 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment // acquire the repo lock _before_ constructing a node. we need to make // sure we are permitted to access the resources (datastore, etc.) - repo, err := fsrepo.Open(cctx.ConfigRoot) + configFileOpt, _ := req.Options[commands.ConfigFileOption].(string) + repo, err := fsrepo.OpenWithUserConfig(cctx.ConfigRoot, configFileOpt) switch err { default: return err @@ -347,7 +369,7 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment // Note: Migration caching/pinning functionality has been deprecated // The hybrid migration system handles legacy migrations more efficiently - repo, err = fsrepo.Open(cctx.ConfigRoot) + repo, err = fsrepo.OpenWithUserConfig(cctx.ConfigRoot, configFileOpt) if err != nil { return err } diff --git a/cmd/ipfs/kubo/init.go b/cmd/ipfs/kubo/init.go index 06312014884..e732c949ce7 100644 --- a/cmd/ipfs/kubo/init.go +++ b/cmd/ipfs/kubo/init.go @@ -57,7 +57,7 @@ environment variable: `, }, Arguments: []cmds.Argument{ - cmds.FileArg("default-config", false, false, "Initialize with the given configuration.").EnableStdin(), + cmds.FileArg("default-config", false, false, "Initialize using this configuration file as a template. Identity and all other values are preserved. The file is copied to --config-file location (or $IPFS_PATH/config if not set).").EnableStdin(), }, Options: []cmds.Option{ cmds.StringOption(algorithmOptionName, "a", "Cryptographic algorithm to use for key generation.").WithDefault(algorithmDefault), @@ -124,7 +124,8 @@ environment variable: } profiles, _ := req.Options[profileOptionName].(string) - return doInit(os.Stdout, cctx.ConfigRoot, empty, profiles, conf) + configFileOpt, _ := req.Options[commands.ConfigFileOption].(string) + return doInit(os.Stdout, cctx.ConfigRoot, configFileOpt, empty, profiles, conf) }, } @@ -146,7 +147,7 @@ func applyProfiles(conf *config.Config, profiles string) error { return nil } -func doInit(out io.Writer, repoRoot string, empty bool, confProfiles string, conf *config.Config) error { +func doInit(out io.Writer, repoRoot string, configFilePath string, empty bool, confProfiles string, conf *config.Config) error { if _, err := fmt.Fprintf(out, "initializing IPFS node at %s\n", repoRoot); err != nil { return err } @@ -163,17 +164,17 @@ func doInit(out io.Writer, repoRoot string, empty bool, confProfiles string, con return err } - if err := fsrepo.Init(repoRoot, conf); err != nil { + if err := fsrepo.InitWithUserConfig(repoRoot, configFilePath, conf); err != nil { return err } if !empty { - if err := addDefaultAssets(out, repoRoot); err != nil { + if err := addDefaultAssets(out, repoRoot, configFilePath); err != nil { return err } } - return initializeIpnsKeyspace(repoRoot) + return initializeIpnsKeyspace(repoRoot, configFilePath) } func checkWritable(dir string) error { @@ -204,11 +205,11 @@ func checkWritable(dir string) error { return err } -func addDefaultAssets(out io.Writer, repoRoot string) error { +func addDefaultAssets(out io.Writer, repoRoot string, configFilePath string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - r, err := fsrepo.Open(repoRoot) + r, err := fsrepo.OpenWithUserConfig(repoRoot, configFilePath) if err != nil { // NB: repo is owned by the node return err } @@ -233,11 +234,11 @@ func addDefaultAssets(out io.Writer, repoRoot string) error { return err } -func initializeIpnsKeyspace(repoRoot string) error { +func initializeIpnsKeyspace(repoRoot string, configFilePath string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - r, err := fsrepo.Open(repoRoot) + r, err := fsrepo.OpenWithUserConfig(repoRoot, configFilePath) if err != nil { // NB: repo is owned by the node return err } diff --git a/cmd/ipfs/kubo/start.go b/cmd/ipfs/kubo/start.go index b5aff3bc3ec..21d7189b8bd 100644 --- a/cmd/ipfs/kubo/start.go +++ b/cmd/ipfs/kubo/start.go @@ -74,8 +74,8 @@ const ( type PluginPreloader func(*loader.PluginLoader) error -func loadPlugins(repoPath string, preload PluginPreloader) (*loader.PluginLoader, error) { - plugins, err := loader.NewPluginLoader(repoPath) +func loadPlugins(repoPath string, configFilePath string, preload PluginPreloader) (*loader.PluginLoader, error) { + plugins, err := loader.NewPluginLoader(repoPath, configFilePath) if err != nil { return nil, fmt.Errorf("error loading plugins: %s", err) } @@ -116,7 +116,8 @@ func BuildEnv(pl PluginPreloader) func(ctx context.Context, req *cmds.Request) ( } log.Debugf("config path is %s", repoPath) - plugins, err := loadPlugins(repoPath, pl) + configFileOpt, _ := req.Options[corecmds.ConfigFileOption].(string) + plugins, err := loadPlugins(repoPath, configFileOpt, pl) if err != nil { return nil, err } @@ -132,7 +133,8 @@ func BuildEnv(pl PluginPreloader) func(ctx context.Context, req *cmds.Request) ( return nil, errors.New("constructing node without a request") } - r, err := fsrepo.Open(repoPath) + configFileOpt, _ := req.Options[corecmds.ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(repoPath, configFileOpt) if err != nil { // repo is owned by the node return nil, err } diff --git a/core/commands/bootstrap.go b/core/commands/bootstrap.go index e5a55dfab34..ae67221301c 100644 --- a/core/commands/bootstrap.go +++ b/core/commands/bootstrap.go @@ -79,7 +79,8 @@ the bootstrap list, which gets resolved using the AutoConf system. return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -139,7 +140,8 @@ var bootstrapRemoveCmd = &cmds.Command{ return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -184,7 +186,8 @@ var bootstrapRemoveAllCmd = &cmds.Command{ return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -224,7 +227,8 @@ var bootstrapListCmd = &cmds.Command{ return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } diff --git a/core/commands/cmdutils/utils.go b/core/commands/cmdutils/utils.go index c793f516e79..0ec1b6c6b64 100644 --- a/core/commands/cmdutils/utils.go +++ b/core/commands/cmdutils/utils.go @@ -14,6 +14,9 @@ const ( AllowBigBlockOptionName = "allow-big-block" SoftBlockLimit = 1024 * 1024 // https://github.com/ipfs/kubo/issues/7421#issuecomment-910833499 MaxPinNameBytes = 255 // Maximum number of bytes allowed for a pin name + + // ConfigFileOption is the name of the global --config-file option + ConfigFileOption = "config-file" ) var AllowBigBlockOption cmds.Option diff --git a/core/commands/config.go b/core/commands/config.go index c28466a9864..c039e211da3 100644 --- a/core/commands/config.go +++ b/core/commands/config.go @@ -103,7 +103,8 @@ Set multiple values in the 'Addresses.AppendAnnounce' array: if err != nil { return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -362,7 +363,8 @@ can't be undone. return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -414,7 +416,8 @@ var configProfileApplyCmd = &cmds.Command{ return err } - oldCfg, newCfg, err := transformConfig(cfgRoot, req.Arguments[0], profile.Transform, dryRun) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + oldCfg, newCfg, err := transformConfig(cfgRoot, configFileOpt, req.Arguments[0], profile.Transform, dryRun) if err != nil { return err } @@ -482,8 +485,8 @@ func scrubPrivKey(cfg *config.Config) (map[string]interface{}, error) { // If dryRun is true, repo's config should not be updated and persisted // to storage. Otherwise, repo's config should be updated and persisted // to storage. -func transformConfig(configRoot string, configName string, transformer config.Transformer, dryRun bool) (*config.Config, *config.Config, error) { - r, err := fsrepo.Open(configRoot) +func transformConfig(configRoot string, configFilePath string, configName string, transformer config.Transformer, dryRun bool) (*config.Config, *config.Config, error) { + r, err := fsrepo.OpenWithUserConfig(configRoot, configFilePath) if err != nil { return nil, nil, err } diff --git a/core/commands/keystore.go b/core/commands/keystore.go index 0ffd141891e..d8d9a506f39 100644 --- a/core/commands/keystore.go +++ b/core/commands/keystore.go @@ -412,7 +412,8 @@ The PEM format allows for key generation outside of the IPFS node: return err } - r, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -621,13 +622,14 @@ environment variable: if oldKey == "self" { return fmt.Errorf("keystore name for back up cannot be named 'self'") } - return doRotate(os.Stdout, cctx.ConfigRoot, oldKey, algorithm, nBitsForKeypair, nBitsGiven) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + return doRotate(os.Stdout, cctx.ConfigRoot, configFileOpt, oldKey, algorithm, nBitsForKeypair, nBitsGiven) }, } -func doRotate(out io.Writer, repoRoot string, oldKey string, algorithm string, nBitsForKeypair int, nBitsGiven bool) error { +func doRotate(out io.Writer, repoRoot string, configFilePath string, oldKey string, algorithm string, nBitsForKeypair int, nBitsGiven bool) error { // Open repo - repo, err := fsrepo.Open(repoRoot) + repo, err := fsrepo.OpenWithUserConfig(repoRoot, configFilePath) if err != nil { return fmt.Errorf("opening repo (%v)", err) } diff --git a/core/commands/pin/remotepin.go b/core/commands/pin/remotepin.go index 3936ce635df..9c31e8c36a2 100644 --- a/core/commands/pin/remotepin.go +++ b/core/commands/pin/remotepin.go @@ -473,7 +473,8 @@ TIP: if err != nil { return err } - repo, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[cmdutils.ConfigFileOption].(string) + repo, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -529,7 +530,8 @@ var rmRemotePinServiceCmd = &cmds.Command{ if err != nil { return err } - repo, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[cmdutils.ConfigFileOption].(string) + repo, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -580,7 +582,8 @@ TIP: pass '--enc=json' for more useful JSON output. if err != nil { return err } - repo, err := fsrepo.Open(cfgRoot) + configFileOpt, _ := req.Options[cmdutils.ConfigFileOption].(string) + repo, err := fsrepo.OpenWithUserConfig(cfgRoot, configFileOpt) if err != nil { return err } @@ -600,8 +603,8 @@ TIP: pass '--enc=json' for more useful JSON output. // if --pin-count is passed, we try to fetch pin numbers from remote service if req.Options[pinServiceStatOptionName].(bool) { - lsRemotePinCount := func(ctx context.Context, env cmds.Environment, svcName string) (*PinCount, error) { - c, err := getRemotePinService(env, svcName) + lsRemotePinCount := func(ctx context.Context, env cmds.Environment, configFileOpt string, svcName string) (*PinCount, error) { + c, err := getRemotePinService(env, configFileOpt, svcName) if err != nil { return nil, err } @@ -646,7 +649,7 @@ TIP: pass '--enc=json' for more useful JSON output. return pc, nil } - pinCount, err := lsRemotePinCount(ctx, env, svcName) + pinCount, err := lsRemotePinCount(ctx, env, configFileOpt, svcName) // PinCount is present only if we were able to fetch counts. // We don't want to break listing of services so this is best-effort. @@ -731,8 +734,8 @@ func getRemotePinServiceFromRequest(req *cmds.Request, env cmds.Environment) (*p } serviceStr := service.(string) - var err error - c, err := getRemotePinService(env, serviceStr) + configFileOpt, _ := req.Options[cmdutils.ConfigFileOption].(string) + c, err := getRemotePinService(env, configFileOpt, serviceStr) if err != nil { return nil, err } @@ -740,23 +743,23 @@ func getRemotePinServiceFromRequest(req *cmds.Request, env cmds.Environment) (*p return c, nil } -func getRemotePinService(env cmds.Environment, name string) (*pinclient.Client, error) { +func getRemotePinService(env cmds.Environment, configFilePath string, name string) (*pinclient.Client, error) { if name == "" { return nil, fmt.Errorf("remote pinning service name not specified") } - endpoint, key, err := getRemotePinServiceInfo(env, name) + endpoint, key, err := getRemotePinServiceInfo(env, configFilePath, name) if err != nil { return nil, err } return pinclient.NewClient(endpoint, key), nil } -func getRemotePinServiceInfo(env cmds.Environment, name string) (endpoint, key string, err error) { +func getRemotePinServiceInfo(env cmds.Environment, configFilePath string, name string) (endpoint, key string, err error) { cfgRoot, err := cmdenv.GetConfigRoot(env) if err != nil { return "", "", err } - repo, err := fsrepo.Open(cfgRoot) + repo, err := fsrepo.OpenWithUserConfig(cfgRoot, configFilePath) if err != nil { return "", "", err } diff --git a/core/commands/root.go b/core/commands/root.go index d70a49376b9..f727af1dad4 100644 --- a/core/commands/root.go +++ b/core/commands/root.go @@ -4,6 +4,7 @@ import ( "errors" cmdenv "github.com/ipfs/kubo/core/commands/cmdenv" + "github.com/ipfs/kubo/core/commands/cmdutils" dag "github.com/ipfs/kubo/core/commands/dag" name "github.com/ipfs/kubo/core/commands/name" ocmd "github.com/ipfs/kubo/core/commands/object" @@ -22,7 +23,7 @@ var ( const ( RepoDirOption = "repo-dir" - ConfigFileOption = "config-file" + ConfigFileOption = cmdutils.ConfigFileOption ConfigOption = "config" DebugOption = "debug" LocalOption = "local" // DEPRECATED: use OfflineOption diff --git a/core/commands/swarm.go b/core/commands/swarm.go index 533ccc07814..3f05883fa07 100644 --- a/core/commands/swarm.go +++ b/core/commands/swarm.go @@ -901,7 +901,8 @@ var swarmFiltersAddCmd = &cmds.Command{ return errors.New("no filters to add") } - r, err := fsrepo.Open(env.(*commands.Context).ConfigRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(env.(*commands.Context).ConfigRoot, configFileOpt) if err != nil { return err } @@ -953,7 +954,8 @@ var swarmFiltersRmCmd = &cmds.Command{ return ErrNotOnline } - r, err := fsrepo.Open(env.(*commands.Context).ConfigRoot) + configFileOpt, _ := req.Options[ConfigFileOption].(string) + r, err := fsrepo.OpenWithUserConfig(env.(*commands.Context).ConfigRoot, configFileOpt) if err != nil { return err } diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index 1b963611b94..0fb13a26dc4 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -12,6 +12,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [๐Ÿ”ฆ Highlights](#-highlights) - [Routing V1 HTTP API now exposed by default](#routing-v1-http-api-now-exposed-by-default) - [Track total size when adding pins](#track-total-size-when-adding-pins) + - [Improved config management for Kubernetes and containers](#improved-config-management-for-kubernetes-and-containers) - [๐Ÿ“ฆ๏ธ Dependency updates](#-dependency-updates) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -33,6 +34,30 @@ Example output: Fetched/Processed 336 nodes (83 MB) ``` +#### Improved config management for Kubernetes and containers + +The `--config-file` flag now works across all commands. You can store the IPFS config file separately from the repo, which is useful for: + +- **Kubernetes**: Mount config from a ConfigMap while storing data on a PersistentVolume +- **Containers**: Keep config in a read-only layer while data lives in a volume + +**`--init-config` vs `--config-file`:** + +```console +# --init-config: copies template to $IPFS_PATH/config (one-time) +$ ipfs init --init-config /etc/ipfs/template.json +# Result: config lives at ~/.ipfs/config +# Edits to /etc/ipfs/template.json have no effect after init + +# --config-file: uses external path directly (persistent) +$ ipfs init --repo-dir ~/.ipfs --config-file /etc/ipfs/config +$ ipfs daemon --repo-dir ~/.ipfs --config-file /etc/ipfs/config +# Result: no config in ~/.ipfs, always reads /etc/ipfs/config +# ConfigMap updates take effect on daemon restart +``` + +When initializing with a pre-existing config file, the Identity and all settings are preserved. + #### ๐Ÿ“ฆ๏ธ Dependency updates - update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0) diff --git a/plugin/loader/loader.go b/plugin/loader/loader.go index 62490761437..4515c26422c 100644 --- a/plugin/loader/loader.go +++ b/plugin/loader/loader.go @@ -95,10 +95,10 @@ type PluginLoader struct { } // NewPluginLoader creates new plugin loader. -func NewPluginLoader(repo string) (*PluginLoader, error) { +func NewPluginLoader(repo string, userConfigFile string) (*PluginLoader, error) { loader := &PluginLoader{plugins: make([]plugin.Plugin, 0, len(preloadPlugins)), repo: repo} if repo != "" { - switch plugins, err := readPluginsConfig(repo, config.DefaultConfigFile); { + switch plugins, err := readPluginsConfig(repo, userConfigFile); { case err == nil: loader.config = plugins case os.IsNotExist(err): diff --git a/repo/fsrepo/config_test.go b/repo/fsrepo/config_test.go index 3c914ff8202..45665a4d932 100644 --- a/repo/fsrepo/config_test.go +++ b/repo/fsrepo/config_test.go @@ -73,7 +73,7 @@ var measureConfig = []byte(`{ }`) func TestDefaultDatastoreConfig(t *testing.T) { - loader, err := loader.NewPluginLoader("") + loader, err := loader.NewPluginLoader("", "") if err != nil { t.Fatal(err) } diff --git a/repo/fsrepo/fsrepo.go b/repo/fsrepo/fsrepo.go index 718d5614d82..63d680eaddd 100644 --- a/repo/fsrepo/fsrepo.go +++ b/repo/fsrepo/fsrepo.go @@ -143,7 +143,7 @@ func open(repoPath string, userConfigFilePath string) (repo.Repo, error) { } // Check if its initialized - if err := checkInitialized(r.path); err != nil { + if err := checkInitialized(r.path, userConfigFilePath); err != nil { return nil, err } @@ -237,10 +237,10 @@ func newFSRepo(rpath string, userConfigFilePath string) (*FSRepo, error) { return &FSRepo{path: expPath, configFilePath: configFilePath}, nil } -func checkInitialized(path string) error { - if !isInitializedUnsynced(path) { +func checkInitialized(path string, userConfigFilePath string) error { + if !configIsInitialized(path, userConfigFilePath) { alt := strings.Replace(path, ".ipfs", ".go-ipfs", 1) - if isInitializedUnsynced(alt) { + if configIsInitialized(alt, "") { return ErrOldRepo } return NoRepoError{Path: path} @@ -250,8 +250,8 @@ func checkInitialized(path string) error { // configIsInitialized returns true if the repo is initialized at // provided |path|. -func configIsInitialized(path string) bool { - configFilename, err := config.Filename(path, "") +func configIsInitialized(path string, userConfigFilePath string) bool { + configFilename, err := config.Filename(path, userConfigFilePath) if err != nil { return false } @@ -261,14 +261,14 @@ func configIsInitialized(path string) bool { return true } -func initConfig(path string, conf *config.Config) error { - if configIsInitialized(path) { - return nil - } - configFilename, err := config.Filename(path, "") +func initConfigWithPath(repoPath string, userConfigFile string, conf *config.Config) error { + configFilename, err := config.Filename(repoPath, userConfigFile) if err != nil { return err } + if fsutil.FileExists(configFilename) { + return nil + } // initialization is the one time when it's okay to write to the config // without reading the config from disk and merging any user-provided keys // that may exist. @@ -301,16 +301,27 @@ func initSpec(path string, conf map[string]interface{}) error { // Init initializes a new FSRepo at the given path with the provided config. // TODO add support for custom datastores. func Init(repoPath string, conf *config.Config) error { + return InitWithUserConfig(repoPath, "", conf) +} + +// InitWithUserConfig initializes a new FSRepo at the given path with the provided config. +// If userConfigFile is non-empty, the config will be written to that location instead of +// the default location inside repoPath. The datastore_spec and version files are always +// written to repoPath. +// +// This function is idempotent: it can be called multiple times and will only +// create files that don't already exist. This supports scenarios where the config +// file is provided externally (e.g., via Kubernetes ConfigMap) but the repo +// infrastructure (datastore, version) needs to be initialized. +func InitWithUserConfig(repoPath string, userConfigFile string, conf *config.Config) error { // packageLock must be held to ensure that the repo is not initialized more // than once. packageLock.Lock() defer packageLock.Unlock() - if isInitializedUnsynced(repoPath) { - return nil - } - - if err := initConfig(repoPath, conf); err != nil { + // initConfigWithPath is idempotent - it skips if config already exists. + // This allows using a pre-existing config file (e.g., from a ConfigMap). + if err := initConfigWithPath(repoPath, userConfigFile, conf); err != nil { return err } @@ -775,19 +786,14 @@ var ( ) // IsInitialized returns true if the repo is initialized at provided |path|. +// It checks for the config file in the default location (path/config). func IsInitialized(path string) bool { // packageLock is held to ensure that another caller doesn't attempt to // Init or Remove the repo while this call is in progress. packageLock.Lock() defer packageLock.Unlock() - return isInitializedUnsynced(path) + return configIsInitialized(path, "") } // private methods below this point. NB: packageLock must held by caller. - -// isInitializedUnsynced reports whether the repo is initialized. Caller must -// hold the packageLock. -func isInitializedUnsynced(repoPath string) bool { - return configIsInitialized(repoPath) -} diff --git a/repo/fsrepo/migrations/ipfsfetcher/ipfsfetcher_test.go b/repo/fsrepo/migrations/ipfsfetcher/ipfsfetcher_test.go index 8fc568450ab..8e8478d5486 100644 --- a/repo/fsrepo/migrations/ipfsfetcher/ipfsfetcher_test.go +++ b/repo/fsrepo/migrations/ipfsfetcher/ipfsfetcher_test.go @@ -256,7 +256,7 @@ func setupPlugins() error { } // Load plugins. This will skip the repo if not available. - plugins, err := loader.NewPluginLoader(filepath.Join(defaultPath, "plugins")) + plugins, err := loader.NewPluginLoader(filepath.Join(defaultPath, "plugins"), "") if err != nil { return fmt.Errorf("error loading plugins: %w", err) } diff --git a/test/cli/config_file_test.go b/test/cli/config_file_test.go new file mode 100644 index 00000000000..1c191a9ccad --- /dev/null +++ b/test/cli/config_file_test.go @@ -0,0 +1,632 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ipfs/kubo/test/cli/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigFileOption(t *testing.T) { + t.Parallel() + + t.Run("daemon uses --config-file option", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + + // Create a directory outside IPFS_PATH for the config file + externalConfigDir := filepath.Join(h.Dir, "external-config") + require.NoError(t, os.MkdirAll(externalConfigDir, 0o755)) + + // Copy config to external location + originalConfigPath := node.ConfigFile() + externalConfigPath := filepath.Join(externalConfigDir, "config") + + configContent := node.ReadFile(originalConfigPath) + require.NoError(t, os.WriteFile(externalConfigPath, []byte(configContent), 0o600)) + + // Modify the external config to have a distinctive Gateway.RootRedirect + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "Gateway.RootRedirect", "/external-config-test"}, + }) + + // Verify the original config does not have this value + originalShow := node.RunIPFS("config", "show") + assert.NotContains(t, originalShow.Stdout.String(), "/external-config-test") + + // Start daemon with --config-file pointing to external config + node.StartDaemon("--config-file", externalConfigPath) + defer node.StopDaemon() + + // Verify daemon is using the external config by checking config show via API + // The daemon's config show should return the external config's value + res := node.IPFS("config", "Gateway.RootRedirect") + assert.Contains(t, res.Stdout.String(), "/external-config-test") + }) + + t.Run("ipfs config show --config-file works with external config", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + + // Create external config with a distinctive value + externalConfigDir := filepath.Join(h.Dir, "external-config-show") + require.NoError(t, os.MkdirAll(externalConfigDir, 0o755)) + + // Copy config to external location + externalConfigPath := filepath.Join(externalConfigDir, "config") + configContent := node.ReadFile(node.ConfigFile()) + require.NoError(t, os.WriteFile(externalConfigPath, []byte(configContent), 0o600)) + + // Modify the external config to have a distinctive value + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "Gateway.RootRedirect", "/test-redirect"}, + }) + + // Verify the external config was modified + res := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "show"}, + }) + assert.Contains(t, res.Stdout.String(), "/test-redirect") + + // Verify the original config was NOT modified + res = node.RunIPFS("config", "show") + assert.NotContains(t, res.Stdout.String(), "/test-redirect") + }) + + t.Run("config set uses --config-file", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + + // Create external config + externalConfigDir := filepath.Join(h.Dir, "external-config-set") + require.NoError(t, os.MkdirAll(externalConfigDir, 0o755)) + + externalConfigPath := filepath.Join(externalConfigDir, "config") + configContent := node.ReadFile(node.ConfigFile()) + require.NoError(t, os.WriteFile(externalConfigPath, []byte(configContent), 0o600)) + + // Set a distinctive value that we control - set a specific API.HTTPHeaders value + distinctiveValue := "X-Test-Header" + + // First, set the value in both configs to known initial states + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "--json", "API.HTTPHeaders", `{}`}, + }) + node.RunIPFS("config", "--json", "API.HTTPHeaders", `{}`) + + // Verify initial state - neither config has the header + initialExternal := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "API.HTTPHeaders"}, + }) + require.NotContains(t, initialExternal.Stdout.String(), distinctiveValue, + "external config should not have the test header initially") + + initialOriginal := node.RunIPFS("config", "API.HTTPHeaders") + require.NotContains(t, initialOriginal.Stdout.String(), distinctiveValue, + "original config should not have the test header initially") + + // Set the distinctive value ONLY in the external config + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "--json", "API.HTTPHeaders", `{"` + distinctiveValue + `": ["value"]}`}, + }) + + // Verify the external config was modified + res := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "API.HTTPHeaders"}, + }) + assert.Contains(t, res.Stdout.String(), distinctiveValue, + "external config should have the test header after setting") + + // Verify the original config was NOT modified + res = node.RunIPFS("config", "API.HTTPHeaders") + assert.NotContains(t, res.Stdout.String(), distinctiveValue, + "original config should not be modified by --config-file operation") + }) + + t.Run("config profile apply uses --config-file", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + + // Create external config + externalConfigDir := filepath.Join(h.Dir, "external-config-profile") + require.NoError(t, os.MkdirAll(externalConfigDir, 0o755)) + + externalConfigPath := filepath.Join(externalConfigDir, "config") + configContent := node.ReadFile(node.ConfigFile()) + require.NoError(t, os.WriteFile(externalConfigPath, []byte(configContent), 0o600)) + + // Set a known initial state: MDNS enabled = true (which local-discovery profile restores) + // We set it to false initially, then apply local-discovery to set it to true + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "--json", "Discovery.MDNS.Enabled", "false"}, + }) + node.RunIPFS("config", "--json", "Discovery.MDNS.Enabled", "false") + + // Verify initial state - both configs have MDNS disabled + initialExternal := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "Discovery.MDNS.Enabled"}, + }) + require.Contains(t, initialExternal.Stdout.String(), "false", + "external config should have MDNS disabled initially") + + initialOriginal := node.RunIPFS("config", "Discovery.MDNS.Enabled") + require.Contains(t, initialOriginal.Stdout.String(), "false", + "original config should have MDNS disabled initially") + + // Apply local-discovery profile to external config only + // This profile sets Discovery.MDNS.Enabled = true + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "profile", "apply", "local-discovery"}, + }) + + // Verify the external config was modified by the profile + res := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"config", "--config-file", externalConfigPath, "Discovery.MDNS.Enabled"}, + }) + assert.Contains(t, res.Stdout.String(), "true", + "external config should have MDNS enabled after applying local-discovery profile") + + // Verify the original config was NOT modified - it should still have MDNS disabled + res = node.RunIPFS("config", "Discovery.MDNS.Enabled") + assert.Contains(t, res.Stdout.String(), "false", + "original config should still have MDNS disabled - --config-file should not affect it") + }) + + t.Run("bootstrap commands use --config-file", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + + // Create external config + externalConfigDir := filepath.Join(h.Dir, "external-config-bootstrap") + require.NoError(t, os.MkdirAll(externalConfigDir, 0o755)) + + externalConfigPath := filepath.Join(externalConfigDir, "config") + configContent := node.ReadFile(node.ConfigFile()) + require.NoError(t, os.WriteFile(externalConfigPath, []byte(configContent), 0o600)) + + // The test profile sets Bootstrap to empty, so first we need to add some peers + // to have something to verify. We'll add a known test peer to both configs. + testPeer := "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN" + + // Add test peer to external config + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"bootstrap", "--config-file", externalConfigPath, "add", testPeer}, + }) + + // Add test peer to original config + node.RunIPFS("bootstrap", "add", testPeer) + + // Verify both configs have the test peer + externalList := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"bootstrap", "--config-file", externalConfigPath, "list"}, + }) + require.Contains(t, externalList.Stdout.String(), testPeer, + "external config should have the test bootstrap peer") + + originalList := node.RunIPFS("bootstrap", "list") + require.Contains(t, originalList.Stdout.String(), testPeer, + "original config should have the test bootstrap peer") + + // Remove all bootstrap peers from external config only + node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"bootstrap", "--config-file", externalConfigPath, "rm", "all"}, + }) + + // Verify the external config now has no bootstrap peers + res := node.Runner.MustRun(harness.RunRequest{ + Path: node.IPFSBin, + Args: []string{"bootstrap", "--config-file", externalConfigPath, "list"}, + }) + assert.Empty(t, res.Stdout.String(), + "external config should have no bootstrap peers after 'rm all'") + + // Verify the original config was NOT modified - it should still have the peer + res = node.RunIPFS("bootstrap", "list") + assert.Contains(t, res.Stdout.String(), testPeer, + "original config should still have bootstrap peer - --config-file should not affect it") + }) + + t.Run("init with --config-file writes to custom location", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create directories for repo and config + repoDir := filepath.Join(h.Dir, "repo") + configDir := filepath.Join(h.Dir, "config-dir") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + + externalConfigPath := filepath.Join(configDir, "my-config") + + // Initialize with --config-file + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", repoDir, "--config-file", externalConfigPath}, + }) + + // Verify config was written to the external location + _, err := os.Stat(externalConfigPath) + require.NoError(t, err, "config should exist at external path") + + // Verify config is NOT in repo dir + _, err = os.Stat(filepath.Join(repoDir, "config")) + require.True(t, os.IsNotExist(err), "config should NOT exist in repo dir") + + // Verify datastore IS in repo dir + _, err = os.Stat(filepath.Join(repoDir, "datastore")) + require.NoError(t, err, "datastore should exist in repo dir") + + // Verify keystore IS in repo dir + _, err = os.Stat(filepath.Join(repoDir, "keystore")) + require.NoError(t, err, "keystore should exist in repo dir") + }) + + t.Run("separation of config and repo paths", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create separate directories + repoDir := filepath.Join(h.Dir, "repo-separate") + configDir := filepath.Join(h.Dir, "config-separate") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + + externalConfigPath := filepath.Join(configDir, "config") + + // Initialize with both --repo-dir and --config-file + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", repoDir, "--config-file", externalConfigPath}, + }) + + // Verify file locations + // Config should ONLY be at external path + _, err := os.Stat(externalConfigPath) + require.NoError(t, err, "config should exist at external path") + + _, err = os.Stat(filepath.Join(repoDir, "config")) + require.True(t, os.IsNotExist(err), "config should NOT exist in repo dir") + + // Datastore spec should be in repo dir + _, err = os.Stat(filepath.Join(repoDir, "datastore_spec")) + require.NoError(t, err, "datastore_spec should exist in repo dir") + + // Version file should be in repo dir + _, err = os.Stat(filepath.Join(repoDir, "version")) + require.NoError(t, err, "version should exist in repo dir") + + // Keystore should be in repo dir + _, err = os.Stat(filepath.Join(repoDir, "keystore")) + require.NoError(t, err, "keystore should exist in repo dir") + }) + + // Kubernetes scenario: config from ConfigMap, init creates repo infrastructure + // This tests the case where a config file is pre-populated (e.g., from a ConfigMap) + // and the repo needs to be initialized using that config. + t.Run("init with pre-existing config creates repo infrastructure", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create directories - simulating a Kubernetes pod with: + // - ConfigMap mounted config file + // - Empty persistent volume for repo + configDir := filepath.Join(h.Dir, "configmap") + repoDir := filepath.Join(h.Dir, "repo-k8s") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + + externalConfigPath := filepath.Join(configDir, "config") + + // First, create a valid config file elsewhere (simulating a ConfigMap) + tempInitDir := filepath.Join(h.Dir, "temp-init") + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", tempInitDir}, + }) + + // Get the PeerID from the temp config for later verification + tempIDRes := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", tempInitDir, "config", "Identity.PeerID"}, + }) + expectedPeerID := tempIDRes.Stdout.Trimmed() + require.NotEmpty(t, expectedPeerID) + + // Copy config to "ConfigMap" location (simulating how Kubernetes mounts ConfigMaps) + configContent, err := os.ReadFile(filepath.Join(tempInitDir, "config")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(externalConfigPath, configContent, 0o600)) + + // Remove temp init dir - we only needed it to generate a valid config + require.NoError(t, os.RemoveAll(tempInitDir)) + + // Verify starting state: config exists, repo dir does not exist + _, err = os.Stat(externalConfigPath) + require.NoError(t, err, "external config should exist (simulating ConfigMap)") + + _, err = os.Stat(repoDir) + require.True(t, os.IsNotExist(err), "repo dir should not exist yet") + + // Initialize repo with pre-existing config + // This simulates: ipfs init --repo-dir /data/ipfs --config-file /etc/ipfs/config + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", repoDir, "--config-file", externalConfigPath}, + }) + + // Verify repo infrastructure was created in repo dir + _, err = os.Stat(filepath.Join(repoDir, "datastore")) + require.NoError(t, err, "datastore should exist in repo dir") + + _, err = os.Stat(filepath.Join(repoDir, "keystore")) + require.NoError(t, err, "keystore should exist in repo dir") + + _, err = os.Stat(filepath.Join(repoDir, "version")) + require.NoError(t, err, "version should exist in repo dir") + + _, err = os.Stat(filepath.Join(repoDir, "datastore_spec")) + require.NoError(t, err, "datastore_spec should exist in repo dir") + + // Verify config is NOT in repo dir (should only be at external location) + _, err = os.Stat(filepath.Join(repoDir, "config")) + require.True(t, os.IsNotExist(err), "config should NOT exist in repo dir - only at external path") + + // Verify the pre-existing config was NOT overwritten (check PeerID matches) + actualIDRes := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Identity.PeerID"}, + }) + assert.Equal(t, expectedPeerID, actualIDRes.Stdout.Trimmed(), + "pre-existing config should not be overwritten - PeerID should match") + }) + + // Test --init-config flag (daemon's template copy behavior) + t.Run("daemon --init --init-config copies template to repo", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create a config template with a distinctive value + // Apply test profile to use random ports and avoid conflicts + templateDir := filepath.Join(h.Dir, "template") + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", templateDir, "--profile=test"}, + }) + + templatePath := filepath.Join(templateDir, "config") + + // Set distinctive value in template + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", templateDir, "config", "Gateway.RootRedirect", "/template-value"}, + }) + + // Create a new node that will use --init-config + node := h.NewNode() + + // Start daemon with --init --init-config (copies template to node's repo) + // Use --init-profile=randomports to ensure unique ports + node.StartDaemon("--init", "--init-config", templatePath, "--init-profile=randomports") + defer node.StopDaemon() + + // Verify config was COPIED to node's repo dir + _, err := os.Stat(filepath.Join(node.Dir, "config")) + require.NoError(t, err, "config should be copied to repo dir when using --init-config") + + // Verify the value from template is present + res := node.IPFS("config", "Gateway.RootRedirect") + assert.Contains(t, res.Stdout.String(), "/template-value", + "daemon should use values from the --init-config template") + }) + + t.Run("--init-config preserves Identity from template", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create a config template and get its PeerID + templateDir := filepath.Join(h.Dir, "template-identity") + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", templateDir}, + }) + + templatePath := filepath.Join(templateDir, "config") + + // Get the PeerID from the template + templateIDRes := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", templateDir, "config", "Identity.PeerID"}, + }) + expectedPeerID := templateIDRes.Stdout.Trimmed() + require.NotEmpty(t, expectedPeerID) + + // Create a new node that will use --init-config + node := h.NewNode() + + // Start daemon with --init --init-config + node.StartDaemon("--init", "--init-config", templatePath) + defer node.StopDaemon() + + // Verify the PeerID matches the template (not a newly generated one) + actualPeerID := node.PeerID().String() + assert.Equal(t, expectedPeerID, actualPeerID, + "--init-config should preserve Identity from template, not generate new keypair") + }) + + // This test demonstrates the key behavioral difference between the two flags + t.Run("--init-config copies once vs --config-file references directly", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Part A: Test --init-config (one-time copy behavior) + // Note: --init-config is a daemon flag, not an init flag + t.Run("--init-config is a one-time copy", func(t *testing.T) { + // Create template with initial value (use test profile for random ports) + templateDir := filepath.Join(h.Dir, "template-copy") + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", templateDir, "--profile=test"}, + }) + templatePath := filepath.Join(templateDir, "config") + + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", templateDir, "config", "Gateway.RootRedirect", "/value-A"}, + }) + + // Create new node and start daemon with --init --init-config + node := h.NewNode() + node.StartDaemon("--init", "--init-config", templatePath, "--init-profile=randomports") + + // Verify the value from template is copied + res := node.IPFS("config", "Gateway.RootRedirect") + require.Contains(t, res.Stdout.String(), "/value-A") + + node.StopDaemon() + + // Now modify the template to /value-B + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", templateDir, "config", "Gateway.RootRedirect", "/value-B"}, + }) + + // Restart daemon - it should still see /value-A (from the copy) + node.StartDaemon() + defer node.StopDaemon() + + res = node.IPFS("config", "Gateway.RootRedirect") + assert.Contains(t, res.Stdout.String(), "/value-A", + "--init-config copies once; changes to template after init should have no effect") + assert.NotContains(t, res.Stdout.String(), "/value-B", + "repo should not see template changes after init") + }) + + // Part B: Test --config-file (persistent reference behavior) + t.Run("--config-file references directly", func(t *testing.T) { + // Create external config with initial value + configDir := filepath.Join(h.Dir, "external-ref") + repoDir := filepath.Join(h.Dir, "repo-config-file") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + externalConfigPath := filepath.Join(configDir, "config") + + // Initialize to create the config at external path + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", repoDir, "--config-file", externalConfigPath}, + }) + + // Set initial value + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect", "/value-A"}, + }) + + // Verify initial value + res := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect"}, + }) + require.Contains(t, res.Stdout.String(), "/value-A") + + // Update external config to /value-B + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect", "/value-B"}, + }) + + // Verify we now see /value-B (config is referenced directly, not copied) + res = h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect"}, + }) + assert.Contains(t, res.Stdout.String(), "/value-B", + "--config-file should reference config directly; changes should be visible immediately") + }) + }) + + t.Run("commands work with --repo-dir and --config-file together", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // Create separate directories for repo and config + repoDir := filepath.Join(h.Dir, "repo-combined") + configDir := filepath.Join(h.Dir, "config-combined") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + externalConfigPath := filepath.Join(configDir, "config") + + // Initialize with both flags + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"init", "--repo-dir", repoDir, "--config-file", externalConfigPath}, + }) + + // Verify repo infrastructure is in repoDir + _, err := os.Stat(filepath.Join(repoDir, "datastore")) + require.NoError(t, err, "datastore should be in repo dir") + _, err = os.Stat(filepath.Join(repoDir, "keystore")) + require.NoError(t, err, "keystore should be in repo dir") + + // Verify config is NOT in repo dir + _, err = os.Stat(filepath.Join(repoDir, "config")) + require.True(t, os.IsNotExist(err), "config should NOT exist in repo dir") + + // Verify config IS at external path + _, err = os.Stat(externalConfigPath) + require.NoError(t, err, "config should exist at external path") + + // Set a distinctive value + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect", "/combined-test"}, + }) + + // Verify config read works with both flags + res := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Gateway.RootRedirect"}, + }) + assert.Contains(t, res.Stdout.String(), "/combined-test", + "config should be read from --config-file path") + + // Get PeerID to verify identity was created + idRes := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "config", "Identity.PeerID"}, + }) + peerID := idRes.Stdout.Trimmed() + assert.NotEmpty(t, peerID, "PeerID should be set in config") + + // Verify ipfs id works offline with both flags + idRunRes := h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "id", "--offline"}, + }) + assert.Contains(t, idRunRes.Stdout.String(), peerID, + "ipfs id --offline should work with --repo-dir and --config-file") + + // Verify bootstrap command works with both flags + h.Runner.MustRun(harness.RunRequest{ + Path: h.IPFSBin, + Args: []string{"--repo-dir", repoDir, "--config-file", externalConfigPath, "bootstrap", "list"}, + }) + }) +}