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"}, + }) + }) +}