diff --git a/README.md b/README.md index 35223f525f..e8f6fcb56a 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@

-

+

-

+

diff --git a/package.json b/package.json index 742c2eb9b9..a32cd13d41 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "ipfs-bitswap": "^0.26.0", "ipfs-block": "~0.8.1", "ipfs-block-service": "~0.16.0", - "ipfs-http-client": "^38.0.1", + "ipfs-http-client": "^38.1.0", "ipfs-http-response": "~0.3.1", "ipfs-mfs": "^0.13.0", "ipfs-multipart": "^0.2.0", @@ -124,6 +124,7 @@ "iso-url": "~0.4.6", "it-pipe": "^1.0.1", "it-to-stream": "^0.1.1", + "jsondiffpatch": "~0.3.11", "just-safe-set": "^2.1.0", "kind-of": "^6.0.2", "ky": "^0.14.0", @@ -202,7 +203,7 @@ "execa": "^2.0.4", "form-data": "^2.5.1", "hat": "0.0.3", - "interface-ipfs-core": "^0.115.3", + "interface-ipfs-core": "^0.117.2", "ipfs-interop": "~0.1.0", "ipfsd-ctl": "^0.47.2", "libp2p-websocket-star": "~0.10.2", diff --git a/src/cli/commands/config/profile.js b/src/cli/commands/config/profile.js new file mode 100644 index 0000000000..2b70510fde --- /dev/null +++ b/src/cli/commands/config/profile.js @@ -0,0 +1,15 @@ +'use strict' + +module.exports = { + command: 'profile ', + + description: 'Interact with config profiles.', + + builder (yargs) { + return yargs + .commandDir('profile') + }, + + handler (argv) { + } +} diff --git a/src/cli/commands/config/profile/apply.js b/src/cli/commands/config/profile/apply.js new file mode 100644 index 0000000000..8bd11a028f --- /dev/null +++ b/src/cli/commands/config/profile/apply.js @@ -0,0 +1,35 @@ +'use strict' + +const JSONDiff = require('jsondiffpatch') + +module.exports = { + command: 'apply ', + + describe: 'Apply profile to config', + + builder: { + 'dry-run': { + type: 'boolean', + describe: 'print difference between the current config and the config that would be generated.' + } + }, + + handler (argv) { + argv.resolve((async () => { + const ipfs = await argv.getIpfs() + const diff = await ipfs.config.profiles.apply(argv.profile, { dryRun: argv.dryRun }) + const delta = JSONDiff.diff(diff.original, diff.updated) + const res = JSONDiff.formatters.console.format(delta, diff.original) + + if (res) { + argv.print(res) + + if (ipfs.send) { + argv.print('\nThe IPFS daemon is running in the background, you may need to restart it for changes to take effect.') + } + } else { + argv.print(`IPFS config already contains the settings from the '${argv.profile}' profile`) + } + })()) + } +} diff --git a/src/cli/commands/config/profile/ls.js b/src/cli/commands/config/profile/ls.js new file mode 100644 index 0000000000..04bb74e37f --- /dev/null +++ b/src/cli/commands/config/profile/ls.js @@ -0,0 +1,21 @@ +'use strict' + +module.exports = { + command: 'ls', + + describe: 'List available config profiles', + + builder: {}, + + handler (argv) { + argv.resolve( + (async () => { + const ipfs = await argv.getIpfs() + + for (const profile of await ipfs.config.profiles.list()) { + argv.print(`${profile.name}:\n ${profile.description}`) + } + })() + ) + } +} diff --git a/src/cli/commands/init.js b/src/cli/commands/init.js index 0a3c5de095..02fd90c675 100644 --- a/src/cli/commands/init.js +++ b/src/cli/commands/init.js @@ -6,12 +6,15 @@ const { ipfsPathHelp } = require('../utils') module.exports = { command: 'init [default-config] [options]', - describe: 'Initialize a local IPFS node', + describe: 'Initialize a local IPFS node\n\n' + + 'If you are going to run IPFS in a server environment, you may want to ' + + 'initialize it using the \'server\' profile.\n\n' + + 'For the list of available profiles run `jsipfs config profile ls`', builder (yargs) { return yargs .epilog(ipfsPathHelp) .positional('default-config', { - describe: 'Initialize with the given configuration. Path to the config file. Check https://github.com/ipfs/js-ipfs#optionsconfig', + describe: 'Node config, this should be a path to a file or JSON and will be merged with the default config. See https://github.com/ipfs/js-ipfs#optionsconfig', type: 'string' }) .option('bits', { @@ -30,6 +33,14 @@ module.exports = { type: 'string', describe: 'Pre-generated private key to use for the repo' }) + .option('profile', { + alias: 'p', + type: 'string', + describe: 'Apply profile settings to config. Multiple profiles can be separated by \',\'', + coerce: (value) => { + return (value || '').split(',') + } + }) }, handler (argv) { @@ -66,6 +77,7 @@ module.exports = { bits: argv.bits, privateKey: argv.privateKey, emptyRepo: argv.emptyRepo, + profiles: argv.profile, pass: argv.pass, log: argv.print }) diff --git a/src/core/components/config.js b/src/core/components/config.js index 23d31a1d8a..f36e684521 100644 --- a/src/core/components/config.js +++ b/src/core/components/config.js @@ -1,11 +1,129 @@ 'use strict' const callbackify = require('callbackify') +const getDefaultConfig = require('../runtime/config-nodejs.js') +const log = require('debug')('ipfs:core:config') module.exports = function config (self) { return { get: callbackify.variadic(self._repo.config.get), set: callbackify(self._repo.config.set), - replace: callbackify.variadic(self._repo.config.set) + replace: callbackify.variadic(self._repo.config.set), + profiles: { + apply: callbackify.variadic(applyProfile), + list: callbackify.variadic(listProfiles) + } + } + + async function applyProfile (profileName, opts) { + opts = opts || {} + const { dryRun } = opts + + const profile = profiles[profileName] + + if (!profile) { + throw new Error(`No profile with name '${profileName}' exists`) + } + + try { + const oldCfg = await self.config.get() + let newCfg = JSON.parse(JSON.stringify(oldCfg)) // clone + newCfg = profile.transform(newCfg) + + if (!dryRun) { + await self.config.replace(newCfg) + } + + // Scrub private key from output + delete oldCfg.Identity.PrivKey + delete newCfg.Identity.PrivKey + + return { original: oldCfg, updated: newCfg } + } catch (err) { + log(err) + + throw new Error(`Could not apply profile '${profileName}' to config: ${err.message}`) + } + } +} + +async function listProfiles (options) { // eslint-disable-line require-await + return Object.keys(profiles).map(name => ({ + name, + description: profiles[name].description + })) +} + +const profiles = { + server: { + description: 'Disables local host discovery - recommended when running IPFS on machines with public IPv4 addresses.', + transform: (config) => { + config.Discovery.MDNS.Enabled = false + config.Discovery.webRTCStar.Enabled = false + + return config + } + }, + 'local-discovery': { + description: 'Enables local host discovery - inverse of "server" profile.', + transform: (config) => { + config.Discovery.MDNS.Enabled = true + config.Discovery.webRTCStar.Enabled = true + + return config + } + }, + lowpower: { + description: 'Reduces daemon overhead on the system - recommended for low power systems.', + transform: (config) => { + config.Swarm = config.Swarm || {} + config.Swarm.ConnMgr = config.Swarm.ConnMgr || {} + config.Swarm.ConnMgr.LowWater = 20 + config.Swarm.ConnMgr.HighWater = 40 + + return config + } + }, + 'default-power': { + description: 'Inverse of "lowpower" profile.', + transform: (config) => { + const defaultConfig = getDefaultConfig() + + config.Swarm = defaultConfig.Swarm + + return config + } + }, + test: { + description: 'Reduces external interference of IPFS daemon - for running the daemon in test environments.', + transform: (config) => { + const defaultConfig = getDefaultConfig() + + config.Addresses.API = defaultConfig.Addresses.API ? '/ip4/127.0.0.1/tcp/0' : '' + config.Addresses.Gateway = defaultConfig.Addresses.Gateway ? '/ip4/127.0.0.1/tcp/0' : '' + config.Addresses.Swarm = defaultConfig.Addresses.Swarm.length ? ['/ip4/127.0.0.1/tcp/0'] : [] + config.Bootstrap = [] + config.Discovery.MDNS.Enabled = false + config.Discovery.webRTCStar.Enabled = false + + return config + } + }, + 'default-networking': { + description: 'Restores default network settings - inverse of "test" profile.', + transform: (config) => { + const defaultConfig = getDefaultConfig() + + config.Addresses.API = defaultConfig.Addresses.API + config.Addresses.Gateway = defaultConfig.Addresses.Gateway + config.Addresses.Swarm = defaultConfig.Addresses.Swarm + config.Bootstrap = defaultConfig.Bootstrap + config.Discovery.MDNS.Enabled = defaultConfig.Discovery.MDNS.Enabled + config.Discovery.webRTCStar.Enabled = defaultConfig.Discovery.webRTCStar.Enabled + + return config + } } } + +module.exports.profiles = profiles diff --git a/src/core/components/files-mfs.js b/src/core/components/files-mfs.js index 77f2eed0ab..9d621ad60a 100644 --- a/src/core/components/files-mfs.js +++ b/src/core/components/files-mfs.js @@ -46,7 +46,7 @@ module.exports = (/** @type { import("../index") } */ ipfs) => { if (paths.length) { const options = args[args.length - 1] - if (options.preload !== false) { + if (options && options.preload !== false) { paths.forEach(path => ipfs._preload(path)) } } diff --git a/src/core/components/init.js b/src/core/components/init.js index 03e6caae13..768a8dd24e 100644 --- a/src/core/components/init.js +++ b/src/core/components/init.js @@ -16,6 +16,7 @@ const IPNS = require('../ipns') const OfflineDatastore = require('../ipns/routing/offline-datastore') const addDefaultAssets = require('./init-assets') +const { profiles } = require('./config') function createPeerId (self, opts) { if (opts.privateKey) { @@ -55,6 +56,8 @@ async function createRepo (self, opts) { const config = mergeOptions(defaultConfig(), self._options.config) + applyProfile(self, config, opts) + // Verify repo does not exist yet const exists = await self._repo.exists() self.log('repo exists?', exists) @@ -128,8 +131,24 @@ async function addRepoAssets (self, privateKey, opts) { } } +// Apply profiles (eg "server,lowpower") to config +function applyProfile (self, config, opts) { + if (opts.profiles) { + for (const name of opts.profiles) { + const profile = profiles[name] + + if (!profile) { + throw new Error(`Could not find profile with name '${name}'`) + } + + self.log(`applying profile ${name}`) + profile.transform(config) + } + } +} + module.exports = function init (self) { - return callbackify(async (opts) => { + return callbackify.variadic(async (opts) => { opts = opts || {} await createRepo(self, opts) diff --git a/src/http/api/resources/config.js b/src/http/api/resources/config.js index 6c6aa3dfca..bd45c00150 100644 --- a/src/http/api/resources/config.js +++ b/src/http/api/resources/config.js @@ -7,6 +7,8 @@ const log = debug('ipfs:http-api:config') log.error = debug('ipfs:http-api:config:error') const multipart = require('ipfs-multipart') const Boom = require('@hapi/boom') +const Joi = require('@hapi/joi') +const { profiles } = require('../../../core/components/config') const all = require('async-iterator-all') exports.getOrSet = { @@ -163,3 +165,53 @@ exports.replace = { return h.response() } } + +exports.profiles = { + apply: { + validate: { + query: Joi.object().keys({ + 'dry-run': Joi.boolean().default(false) + }).unknown() + }, + + // pre request handler that parses the args and returns `profile` which is assigned to `request.pre.args` + parseArgs: function (request, h) { + if (!request.query.arg) { + throw Boom.badRequest("Argument 'profile' is required") + } + + if (!profiles[request.query.arg]) { + throw Boom.badRequest("Argument 'profile' is not a valid profile name") + } + + return { profile: request.query.arg } + }, + + handler: async function (request, h) { + const { ipfs } = request.server.app + const { profile } = request.pre.args + const dryRun = request.query['dry-run'] + + try { + const diff = await ipfs.config.profiles.apply(profile, { dryRun }) + + return h.response({ OldCfg: diff.original, NewCfg: diff.updated }) + } catch (err) { + throw Boom.boomify(err, { message: 'Failed to apply profile' }) + } + } + }, + list: { + handler: async function (request, h) { + const { ipfs } = request.server.app + const list = await ipfs.config.profiles.list() + + return h.response( + list.map(profile => ({ + Name: profile.name, + Description: profile.description + })) + ) + } + } +} diff --git a/src/http/api/routes/config.js b/src/http/api/routes/config.js index 5315a043dc..0f3ea9ddcb 100644 --- a/src/http/api/routes/config.js +++ b/src/http/api/routes/config.js @@ -31,5 +31,21 @@ module.exports = [ ] }, handler: resources.config.replace.handler + }, + { + method: '*', + path: '/api/v0/config/profile/apply', + options: { + pre: [ + { method: resources.config.profiles.apply.parseArgs, assign: 'args' } + ], + validate: resources.config.profiles.apply.validate + }, + handler: resources.config.profiles.apply.handler + }, + { + method: '*', + path: '/api/v0/config/profile/list', + handler: resources.config.profiles.list.handler } ] diff --git a/test/cli/commands.js b/test/cli/commands.js index fc186a11ac..23cc091bd2 100644 --- a/test/cli/commands.js +++ b/test/cli/commands.js @@ -4,7 +4,7 @@ const { expect } = require('interface-ipfs-core/src/utils/mocha') const runOnAndOff = require('../utils/on-and-off') -const commandCount = 95 +const commandCount = 98 describe('commands', () => runOnAndOff((thing) => { let ipfs diff --git a/test/cli/config.js b/test/cli/config.js index a90df0468b..6b7741dcae 100644 --- a/test/cli/config.js +++ b/test/cli/config.js @@ -5,6 +5,8 @@ const { expect } = require('interface-ipfs-core/src/utils/mocha') const fs = require('fs') const path = require('path') const runOnAndOff = require('../utils/on-and-off') +const defaultConfig = require('../../src/core/runtime/config-nodejs')() +const { profiles } = require('../../src/core/components/config') describe('config', () => runOnAndOff((thing) => { let ipfs @@ -86,4 +88,51 @@ describe('config', () => runOnAndOff((thing) => { restoreConfig() }) }) + + describe('profile', function () { + this.timeout(40 * 1000) + + let originalConfig + + beforeEach(() => { + restoreConfig() + originalConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')) + }) + + after(() => { + restoreConfig() + }) + + Object.keys(profiles).forEach(profile => { + it(`applies profile '${profile}'`, async () => { + await ipfs(`config profile apply ${profile}`) + + expect(updatedConfig()).to.deep.equal(profiles[profile].transform(originalConfig)) + }) + }) + + it('--dry-run causes no change', async () => { + await ipfs('config profile apply --dry-run=true server') + const after = updatedConfig() + expect(after.Discovery.MDNS.Enabled).to.equal(defaultConfig.Discovery.MDNS.Enabled) + + await ipfs('config profile apply --dry-run=false server') + const updated = updatedConfig() + expect(updated.Discovery.MDNS.Enabled).to.equal(false) + }) + + it('Private key does not appear in output', async () => { + const out = await ipfs('config profile apply server') + expect(out).not.includes('PrivKey') + }) + + it('lists available config profiles', async () => { + const out = await ipfs('config profile ls') + + Object.keys(profiles => profile => { + expect(out).includes(profiles[profile].name) + expect(out).includes(profiles[profile].description) + }) + }) + }) })) diff --git a/test/cli/init.js b/test/cli/init.js index 234d5b4c39..97bb486fbc 100644 --- a/test/cli/init.js +++ b/test/cli/init.js @@ -26,6 +26,11 @@ describe('init', function () { return !f.startsWith('.') }) } + + const repoConfSync = (p) => { + return JSON.parse(fs.readFileSync(path.join(repoPath, 'config'))) + } + beforeEach(() => { repoPath = os.tmpdir() + '/ipfs-' + hat() ipfs = ipfsExec(repoPath) @@ -60,6 +65,37 @@ describe('init', function () { expect(repoExistsSync('version')).to.equal(true) }) + it('profile', async function () { + this.timeout(40 * 1000) + + await ipfs('init --profile lowpower') + expect(repoConfSync().Swarm.ConnMgr.LowWater).to.equal(20) + }) + + it('profile multiple', async function () { + this.timeout(40 * 1000) + + await ipfs('init --profile server,lowpower') + expect(repoConfSync().Discovery.MDNS.Enabled).to.equal(false) + expect(repoConfSync().Swarm.ConnMgr.LowWater).to.equal(20) + }) + + it('profile non-existent', async function () { + this.timeout(40 * 1000) + + try { + await ipfs('init --profile doesnt-exist') + } catch (err) { + expect(err.stdout).includes('Could not find profile') + } + }) + + it('should present ipfs path help when option help is received', async function () { + const res = await ipfs('init --help') + + expect(res).to.have.string('export IPFS_PATH=/path/to/ipfsrepo') + }) + it('should present ipfs path help when option help is received', async function () { const res = await ipfs('init --help') expect(res).to.have.string('export IPFS_PATH=/path/to/ipfsrepo') diff --git a/test/core/interface.spec.js b/test/core/interface.spec.js index 84a386e353..5ac674782c 100644 --- a/test/core/interface.spec.js +++ b/test/core/interface.spec.js @@ -12,26 +12,16 @@ describe('interface-ipfs-core tests', function () { tests.bitswap(defaultCommonFactory, { skip: !isNode }) - tests.block(defaultCommonFactory) + tests.block(defaultCommonFactory, { + skip: [{ + name: 'rm', + reason: 'Not implemented' + }] + }) tests.bootstrap(defaultCommonFactory) - tests.config(defaultCommonFactory, { - skip: [ - { - name: 'should set a number', - reason: 'Failing - needs to be fixed' - }, - { - name: 'should output changes but not save them for dry run', - reason: 'TODO unskip when https://github.com/ipfs/js-ipfs/pull/2165 is merged' - }, - { - name: 'should set a config profile', - reason: 'TODO unskip when https://github.com/ipfs/js-ipfs/pull/2165 is merged' - } - ] - }) + tests.config(defaultCommonFactory) tests.dag(defaultCommonFactory) diff --git a/test/core/name.spec.js b/test/core/name.spec.js index d517707c9e..f1da264e9d 100644 --- a/test/core/name.spec.js +++ b/test/core/name.spec.js @@ -5,10 +5,8 @@ const hat = require('hat') const { expect } = require('interface-ipfs-core/src/utils/mocha') const sinon = require('sinon') - const parallel = require('async/parallel') const series = require('async/series') - const IPFS = require('../../src') const ipnsPath = require('../../src/core/ipns/path') const ipnsRouting = require('../../src/core/ipns/routing/config') diff --git a/test/core/pin.js b/test/core/pin.js index d5e096ad3a..124a5a8acc 100644 --- a/test/core/pin.js +++ b/test/core/pin.js @@ -4,7 +4,6 @@ const { expect } = require('interface-ipfs-core/src/utils/mocha') const fs = require('fs') - const { DAGNode } = require('ipld-dag-pb') diff --git a/test/http-api/inject/config.js b/test/http-api/inject/config.js index 852a65a0d9..53eb9f8785 100644 --- a/test/http-api/inject/config.js +++ b/test/http-api/inject/config.js @@ -6,6 +6,7 @@ const fs = require('fs') const FormData = require('form-data') const streamToPromise = require('stream-to-promise') const path = require('path') +const { profiles } = require('../../../src/core/components/config') module.exports = (http) => { describe('/config', () => { @@ -189,5 +190,73 @@ module.exports = (http) => { expect(updatedConfig()).to.deep.equal(expectedConfig) }) }) + + describe('/config/profile/apply', () => { + let originalConfig + + beforeEach(() => { + originalConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')) + }) + + it('returns 400 if no config profile is provided', async () => { + const res = await api.inject({ + method: 'POST', + url: '/api/v0/config/profile/apply' + }) + + expect(res.statusCode).to.equal(400) + }) + + it('returns 400 if the config profile is invalid', async () => { + const res = await api.inject({ + method: 'POST', + url: '/api/v0/config/profile/apply?arg=derp' + }) + + expect(res.statusCode).to.equal(400) + }) + + it('does not apply config profile with dry-run argument', async () => { + const res = await api.inject({ + method: 'POST', + url: '/api/v0/config/profile/apply?arg=lowpower&dry-run=true' + }) + + expect(res.statusCode).to.equal(200) + expect(updatedConfig()).to.deep.equal(originalConfig) + }) + + Object.keys(profiles).forEach(profile => { + it(`applies config profile ${profile}`, async () => { + const res = await api.inject({ + method: 'POST', + url: `/api/v0/config/profile/apply?arg=${profile}` + }) + + expect(res.statusCode).to.equal(200) + expect(updatedConfig()).to.deep.equal(profiles[profile].transform(originalConfig)) + }) + }) + }) + + describe('/config/profile/list', () => { + it('lists available profiles', async () => { + const res = await api.inject({ + method: 'POST', + url: '/api/v0/config/profile/list' + }) + + expect(res.statusCode).to.equal(200) + + const listed = JSON.parse(res.payload) + + Object.keys(profiles).forEach(name => { + const profile = listed.find(profile => profile.Name === name) + + expect(profile).to.be.ok() + expect(profile.Description).to.equal(profiles[name].description) + }) + }) + }) }) } diff --git a/test/http-api/inject/name.js b/test/http-api/inject/name.js index 2fbf56c499..3fe7f2564c 100644 --- a/test/http-api/inject/name.js +++ b/test/http-api/inject/name.js @@ -3,7 +3,6 @@ 'use strict' const { expect } = require('interface-ipfs-core/src/utils/mocha') - const checkAll = (bits) => string => bits.every(bit => string.includes(bit)) module.exports = (http) => { diff --git a/test/http-api/interface.js b/test/http-api/interface.js index 3373f36069..7469be1564 100644 --- a/test/http-api/interface.js +++ b/test/http-api/interface.js @@ -12,26 +12,16 @@ describe('interface-ipfs-core over ipfs-http-client tests', () => { tests.bitswap(defaultCommonFactory) - tests.block(defaultCommonFactory) + tests.block(defaultCommonFactory, { + skip: [{ + name: 'rm', + reason: 'Not implemented' + }] + }) tests.bootstrap(defaultCommonFactory) - tests.config(defaultCommonFactory, { - skip: [ - { - name: 'should set a number', - reason: 'Failing - needs to be fixed' - }, - { - name: 'should output changes but not save them for dry run', - reason: 'TODO unskip when https://github.com/ipfs/js-ipfs/pull/2165 is merged' - }, - { - name: 'should set a config profile', - reason: 'TODO unskip when https://github.com/ipfs/js-ipfs/pull/2165 is merged' - } - ] - }) + tests.config(defaultCommonFactory) tests.dag(defaultCommonFactory, { skip: [{