From d996891c831e56b2027e9bc1ccf2ee7f8b5d8229 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 1 Nov 2024 17:54:06 +0100 Subject: [PATCH 01/53] fix(compose): Fix compose file path resolution as it is copied into the lando directory and be able to set app mount user --- builders/_lando.js | 19 +++++++++++++++---- builders/lando-compose.js | 21 +++++++++++++++++++++ hooks/app-start-proxy.js | 7 +++---- lib/app.js | 32 ++++++++++++++++++++++---------- lib/compose.js | 7 +++++++ lib/engine.js | 20 ++++++++++++++++++++ lib/router.js | 2 ++ test/compose.spec.js | 12 ++++++++++++ test/get-user.spec.js | 5 +++++ utils/build-tooling-task.js | 2 +- utils/get-app-mounts.js | 2 +- utils/get-user.js | 4 ++-- utils/load-compose-files.js | 30 +++++++++++++++++++++++++++--- utils/parse-tooling-config.js | 8 ++++---- utils/parse-v3-services.js | 2 +- utils/parse-v4-services.js | 1 + 16 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 builders/lando-compose.js diff --git a/builders/_lando.js b/builders/_lando.js index 6621fb2ea..7b9e1d571 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -53,6 +53,8 @@ module.exports = { supportedIgnore = false, root = '', // webroot = '/app', + _app = null, + appMount = '/app', } = {}, ...sources ) { @@ -98,6 +100,9 @@ module.exports = { const environment = { LANDO_SERVICE_NAME: name, LANDO_SERVICE_TYPE: type, + LANDO_WEBROOT_USER: meUser, + LANDO_WEBROOT_GROUP: meUser, + LANDO_MOUNT: appMount, }; // Handle labels @@ -113,7 +118,6 @@ module.exports = { `${userConfRoot}:/lando:cached`, `${globalScriptsDir}:/helpers`, `${entrypointScript}:/lando-entrypoint.sh`, - `${dataHome}:/var/www`, ]; // add in service helpers if we have them @@ -188,9 +192,16 @@ module.exports = { // Add named volumes and other thingz into our primary service const namedVols = {}; - _.set(namedVols, data, {}); - _.set(namedVols, dataHome, {}); - + if (null !== data) { + _.set(namedVols, data, {}); + } + if (null !== dataHome) { + _.set(namedVols, dataHome, {}); + volumes.push(`${dataHome}:/var/www`); + } + if (null === entrypoint) { + entrypoint = undefined; + } sources.push({ services: _.set({}, name, { entrypoint, diff --git a/builders/lando-compose.js b/builders/lando-compose.js new file mode 100644 index 000000000..736f19bc8 --- /dev/null +++ b/builders/lando-compose.js @@ -0,0 +1,21 @@ +'use strict'; + +const _ = require('lodash'); + +module.exports = { + name: 'lando-compose', + api: 3, + parent: '_lando', + builder: parent => class LandoComposeServiceV3 extends parent { + constructor(id, options = {}) { + super(id, _.merge({}, { + entrypoint: null, // NOTE: Do not overwrite the entrypoint from docker compose. Or should we? + data: null, // NOTE: Do not create the data volume + dataHome: null, // NOTE: Do not create the dataHome volume + appMount: '/', + sslExpose: false, + ssl: true, + }, options)); + } + }, +}; diff --git a/hooks/app-start-proxy.js b/hooks/app-start-proxy.js index 3db065cfb..f8c6fd83d 100644 --- a/hooks/app-start-proxy.js +++ b/hooks/app-start-proxy.js @@ -281,10 +281,9 @@ module.exports = async (app, lando) => { } // Get list of services that *should* have certs for SSL - const sslReady = _(_.get(app, 'config.services', [])) - .map((data, name) => _.merge({}, data, {name})) - .filter(data => data.ssl) - .map(data => data.name) + const sslReady = _(app.info) + .filter(data => data?.hasCerts) + .map(data => data.service) .value(); // Make sure we augment ssl ready if we have served by candidates diff --git a/lib/app.js b/lib/app.js index 5f4f224ab..c6556b9a1 100644 --- a/lib/app.js +++ b/lib/app.js @@ -273,13 +273,23 @@ module.exports = class App { // We should only need to initialize once, if we have just go right to app ready if (this.initialized) return this.events.emit('ready', this); // Get compose data if we have any, otherwise set to [] - const composeFiles = require('../utils/load-compose-files')(_.get(this, 'config.compose', []), this.root); - this.composeData = [new this.ComposeService('compose', {}, ...composeFiles)]; - // Validate and set env files - this.envFiles = require('../utils/normalize-files')(_.get(this, 'config.env_file', []), this.root); - // Log some things - this.log.verbose('initiatilizing app at %s...', this.root); - + return require('../utils/load-compose-files')( + _.get(this, 'config.compose', []), + this.root, + this._dir, + (composeFiles, outputFilePath) => + this.engine.getComposeConfig({compose: composeFiles, project: this.project, outputFilePath}), + ) + .then(composeFileData => { + if (undefined !== composeFileData) { + this.composeData = [new this.ComposeService('compose', {}, composeFileData)]; + } + // Validate and set env files + this.envFiles = require('../utils/normalize-files')(_.get(this, 'config.env_file', []), this.root); + // Log some things + this.log.verbose('initiatilizing app at %s...', this.root); + this.log.silly('app has config', this.config); + }) /** * Event that allows altering of the app object right before it is * initialized. @@ -292,8 +302,9 @@ module.exports = class App { * @event pre_init * @property {App} app The app instance. */ - return loadPlugins(this, this._lando).then(() => this.events.emit('pre-init', this)) + .then(() => loadPlugins(this, this._lando)) + .then(() => this.events.emit('pre-init', this)) // Actually assemble this thing so its ready for that engine .then(() => { // Get all the services @@ -364,7 +375,7 @@ module.exports = class App { } // Log - this.initialized = true; + this.initialized = !!noEngine; this.log.verbose('app is ready!'); }) /** @@ -378,7 +389,8 @@ module.exports = class App { .then(() => this.events.emit('ready', this)) // @NOTE: dont ask, just continuing to work around v3-wasnt-intended-to-do-this problems - .then(() => noEngine === true ? undefined : this.events.emit('ready-engine', this)); + .then(() => noEngine === true ? undefined : this.events.emit('ready-engine', this)) + .then(() => noEngine === true ? require('../hooks/app-purge-compose-cache')(this, this._lando) : undefined); } /** diff --git a/lib/compose.js b/lib/compose.js index 83374b6ff..860c57abc 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -23,6 +23,7 @@ const composeFlags = { rm: '--rm', timestamps: '--timestamps', volumes: '-v', + outputFilePath: '-o', }; // Default options nad things @@ -36,6 +37,7 @@ const defaultOptions = { pull: {}, rm: {force: true, volumes: true}, up: {background: true, noRecreate: true, recreate: false, removeOrphans: true}, + config: {}, }; /* @@ -161,3 +163,8 @@ exports.start = (compose, project, opts = {}) => buildShell('up', project, compo * Run docker compose stop */ exports.stop = (compose, project, opts = {}) => buildShell('stop', project, compose, opts); + +/* + * Run docker compose config + */ +exports.config = (compose, project, opts = {}) => buildShell('config', project, compose, opts); diff --git a/lib/engine.js b/lib/engine.js index 79abbc919..bf769e1fb 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -495,5 +495,25 @@ module.exports = class Engine { // stop return this.engineCmd('stop', data); } + + /** + * Get dumped docker compose config for compose files from project + * using a `compose` object with `{compose: compose, project: project, opts: opts}` + * + * @since 3.0.0 + * @param {Object} data Config needs a service within a compose context + * @param {Array} data.compose An Array of paths to Docker compose files + * @param {String} data.project A String of the project name (Usually this is the same as the app name) + * @param {String} [data.outputFilePath='/path/to/file.yml'] String to output path + * @param {Object} [data.opts] Options + * @return {Promise} A Promise. + * @example + * return lando.engine.stop(app); + */ + getComposeConfig(data) { + data.opts = {cmd: ['-o', data.outputFilePath]}; + delete data.outputFilePath; + return this.engineCmd('config', data); + } }; diff --git a/lib/router.js b/lib/router.js index c8c314868..aea5e74ce 100644 --- a/lib/router.js +++ b/lib/router.js @@ -135,3 +135,5 @@ exports.start = (data, compose) => retryEach(data, datum => compose('start', dat exports.stop = (data, compose, docker) => retryEach(data, datum => { return (datum.compose) ? compose(data.kill ? 'kill' : 'stop', datum) : docker.stop(getContainerId(datum)); }); + +exports.config = (data, compose) => retryEach(data, datum => compose('config', datum)); diff --git a/test/compose.spec.js b/test/compose.spec.js index d282600b0..0b599cdc9 100644 --- a/test/compose.spec.js +++ b/test/compose.spec.js @@ -202,4 +202,16 @@ describe('compose', () => { expect(stopResult).to.be.an('object'); }); }); + + describe('#config', () => { + it('should return the correct default options when not specified'); + it('#config should return an object.', () => { + const configResult = compose.config( + ['string1', 'string2'], + 'my_project', + myOpts, + ); + expect(configResult).to.be.an('object'); + }); + }); }); diff --git a/test/get-user.spec.js b/test/get-user.spec.js index d756b0b17..abf5c4216 100644 --- a/test/get-user.spec.js +++ b/test/get-user.spec.js @@ -29,6 +29,11 @@ describe('get-user', function() { expect(getUser('test-service', info)).to.equal('www-data'); }); + it('should return specified user if service is a "no-api" docker-compose service and user is specified', function() { + const info = [{service: 'test-service', type: 'docker-compose', meUser: 'custom-user'}]; + expect(getUser('test-service', info)).to.equal('custom-user'); + }); + it('should return "www-data" if service.api is 4 but no user is specified', function() { const info = [{service: 'test-service', api: 4}]; expect(getUser('test-service', info)).to.equal('www-data'); diff --git a/utils/build-tooling-task.js b/utils/build-tooling-task.js index 089a07067..af1590c81 100644 --- a/utils/build-tooling-task.js +++ b/utils/build-tooling-task.js @@ -21,7 +21,7 @@ module.exports = (config, injected) => { // Kick off the pre event wrappers .then(() => app.events.emit(`pre-${eventName}`, config, answers)) // Get an interable of our commandz - .then(() => _.map(require('./parse-tooling-config')(cmd, service, options, answers, sapis))) + .then(() => _.map(require('./parse-tooling-config')(cmd, service, name, options, answers, sapis))) // Build run objects .map(({command, service}) => require('./build-tooling-runner')(app, command, service, user, env, dir, appMount)) // Try to run the task quickly first and then fallback to compose launch diff --git a/utils/get-app-mounts.js b/utils/get-app-mounts.js index 694ffedb3..521b4af70 100644 --- a/utils/get-app-mounts.js +++ b/utils/get-app-mounts.js @@ -6,7 +6,7 @@ module.exports = app => _(app.services) // Objectify .map(service => _.merge({name: service}, _.get(app, `config.services.${service}`, {}))) // Set the default - .map(config => _.merge({}, config, {app_mount: _.get(config, 'app_mount', 'cached')})) + .map(config => _.merge({}, config, {app_mount: _.get(config, 'app_mount', app.config.app_mount || 'cached')})) // Filter out disabled mountes .filter(config => config.app_mount !== false && config.app_mount !== 'disabled') // Combine together diff --git a/utils/get-user.js b/utils/get-user.js index f5b8b2094..ed42f7b87 100644 --- a/utils/get-user.js +++ b/utils/get-user.js @@ -7,8 +7,8 @@ module.exports = (name, info = []) => { if (!_.find(info, {service: name})) return 'www-data'; // otherwise get the service const service = _.find(info, {service: name}); - // if this is a "no-api" service eg type "docker-compose" also return www-data - if (!service.api && service.type === 'docker-compose') return 'www-data'; + // if this is a "no-api" service eg type "docker-compose" return meUser or www-data as default + if (!service.api && service.type === 'docker-compose') return service.meUser || 'www-data'; // otherwise return different things based on the api return service.api === 4 ? service.user || 'www-data' : service.meUser || 'www-data'; }; diff --git a/utils/load-compose-files.js b/utils/load-compose-files.js index f13bf4cec..e2a5c68d8 100644 --- a/utils/load-compose-files.js +++ b/utils/load-compose-files.js @@ -2,8 +2,32 @@ const _ = require('lodash'); const Yaml = require('./../lib/yaml'); +const path = require('path'); const yaml = new Yaml(); +const fs = require('fs'); +const remove = require('./remove'); -module.exports = (files, dir) => _(require('./normalize-files')(files, dir)) - .map(file => yaml.load(file)) - .value(); +// This just runs `docker compose --project-directory ${dir} config -f ${files} --output ${outputPaths}` to +// make all paths relative to the lando config root +module.exports = async (files, dir, landoComposeConfigDir = undefined, outputConfigFunction = undefined) => { + const composeFilePaths = _(require('./normalize-files')(files, dir)).value(); + if (_.isEmpty(composeFilePaths)) { + return {}; + } + + if (undefined === outputConfigFunction) { + return _(composeFilePaths) + .map(file => yaml.load(file)) + .value(); + } + + const outputFile = path.join(landoComposeConfigDir, 'resolved-compose-config.yml'); + + fs.mkdirSync(path.dirname(outputFile), {recursive: true}); + await outputConfigFunction(composeFilePaths, outputFile); + const result = yaml.load(outputFile); + fs.unlinkSync(outputFile); + remove(path.dirname(outputFile)); + + return result; +}; diff --git a/utils/parse-tooling-config.js b/utils/parse-tooling-config.js index e3e21cddd..dcf276e83 100644 --- a/utils/parse-tooling-config.js +++ b/utils/parse-tooling-config.js @@ -41,9 +41,9 @@ const handleDynamic = (config, options, answers = {}, sapis = {}) => { * the first three assuming they are [node, lando.js, options.name]' * Check to see if we have global lando opts and remove them if we do */ -const handleOpts = (config, argopts = []) => { +const handleOpts = (config, name, argopts = []) => { // Append any user specificed opts - argopts = argopts.concat(process.argv.slice(3)); + argopts = argopts.concat(process.argv.slice(process.argv.findIndex(value => value === name.split(' ')[0]) + 1)); // If we have no args then just return right away if (_.isEmpty(argopts)) return config; // Return @@ -74,13 +74,13 @@ const parseCommand = (cmd, service, sapis) => { }; // adds required methods to ensure the lando v3 debugger can be injected into v4 things -module.exports = (cmd, service, options = {}, answers = {}, sapis = {}) => _(cmd) +module.exports = (cmd, service, name, options = {}, answers = {}, sapis = {}) => _(cmd) // Put into an object so we can handle "multi-service" tooling .map(cmd => parseCommand(cmd, service, sapis)) // Handle dynamic services .map(config => handleDynamic(config, options, answers, sapis)) // Add in any argv extras if they've been passed in - .map(config => handleOpts(config, handlePassthruOpts(options, answers))) + .map(config => handleOpts(config, name, handlePassthruOpts(options, answers))) // Wrap the command in /bin/sh if that makes sense .map(config => ({...config, command: require('./shell-escape')(config.command, true, config.args, config.sapi)})) // Add any args to the command and compact to remove undefined diff --git a/utils/parse-v3-services.js b/utils/parse-v3-services.js index cfed7f138..5707a322e 100644 --- a/utils/parse-v3-services.js +++ b/utils/parse-v3-services.js @@ -20,7 +20,7 @@ module.exports = (config, app) => _(config) app: app.name, confDest: path.join(app._config.userConfRoot, 'config', service.type.split(':')[0]), data: `data_${service.name}`, - home: app._config.home, + home: app.config.home || app._config.home, project: app.project, root: app.root, type: service.type.split(':')[0], diff --git a/utils/parse-v4-services.js b/utils/parse-v4-services.js index 3d5dc2fd1..567be08b1 100644 --- a/utils/parse-v4-services.js +++ b/utils/parse-v4-services.js @@ -5,6 +5,7 @@ const _ = require('lodash'); // adds required methods to ensure the lando v3 debugger can be injected into v4 things module.exports = services => _(services) + .pickBy(service => null !== service) .map((service, name) => { const type = service.type ?? 'lando'; return _.merge({}, { From ec9c53bdf5d59eb72052a08ac014216e3f8f6e3a Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 3 Apr 2026 10:59:07 +0200 Subject: [PATCH 02/53] feat: If no service key is given, we assume its _lando-compose for service names which are also compose services --- hooks/app-add-v3-services.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hooks/app-add-v3-services.js b/hooks/app-add-v3-services.js index fe3659406..111edc5f4 100644 --- a/hooks/app-add-v3-services.js +++ b/hooks/app-add-v3-services.js @@ -5,6 +5,13 @@ const _ = require('lodash'); module.exports = async (app, lando) => { // add parsed services to app object so we can use them downstream app.cachedInfo = _.get(lando.cache.get(app.composeCache), 'info', []); + app.config.services = _.mapValues(_.get(app, 'config.services', {}), (service, name) => { + const composeServices = _.keys(_.get(app, 'composeData[0].data[0].services', {})); + if (!_.includes(composeServices, name) || service?.api === 4) { + return service; + } + return _.merge({}, {type: 'lando-compose', version: 'custom', api: 3}, service); + }); app.parsedServices = require('../utils/parse-v3-services')(_.get(app, 'config.services', {}), app); app.parsedV3Services = _(app.parsedServices).filter(service => service.api === 3).value(); app.servicesList = app.parsedV3Services.map(service => service.name); From b6dd4d4742c936de699f14c5dd15d1b856b7008c Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 1 Nov 2024 18:00:39 +0100 Subject: [PATCH 03/53] refactor(compose): Fix to use the configured compose seperator in all places --- lib/engine.js | 2 +- lib/router.js | 2 +- tasks/info.js | 3 ++- utils/build-tooling-runner.js | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/engine.js b/lib/engine.js index bf769e1fb..610921faf 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -172,7 +172,7 @@ module.exports = class Engine { * return lando.engine.exists(compose); */ exists(data) { - return this.engineCmd('exists', data); + return this.engineCmd('exists', _.merge({}, {separator: this.separator}, data)); } /* diff --git a/lib/router.js b/lib/router.js index aea5e74ce..4cd38712b 100644 --- a/lib/router.js +++ b/lib/router.js @@ -55,7 +55,7 @@ exports.destroy = (data, compose, docker) => retryEach(data, datum => { exports.exists = (data, compose, docker, ids = []) => { if (data.compose) return compose('getId', data).then(id => !_.isEmpty(id)); else { - return docker.list() + return docker.list({}, data.separator) .each(container => { ids.push(container.id); ids.push(container.name); diff --git a/tasks/info.js b/tasks/info.js index 0a77a5018..94d68f191 100644 --- a/tasks/info.js +++ b/tasks/info.js @@ -34,11 +34,12 @@ module.exports = lando => ({ const getData = async () => { // go deep if (options.deep) { + const separator = _.get(app, '_config.orchestratorSeparator', '_'); return await lando.engine.list({project: app.project}) .map(async container => await lando.engine.scan(container)) .filter(container => { if (!options.service) return true; - return options.service.map(service => `/${app.project}_${service}_1`).includes(container.Name); + return options.service.map(service => `/${app.project}${separator}${service}${separator}1`).includes(container.Name); }); // normal info diff --git a/utils/build-tooling-runner.js b/utils/build-tooling-runner.js index 6436548c7..b70f93577 100644 --- a/utils/build-tooling-runner.js +++ b/utils/build-tooling-runner.js @@ -4,7 +4,8 @@ const _ = require('lodash'); const path = require('path'); const getContainer = (app, service) => { - return app?.containers?.[service] ?? `${app.project}_${service}_1`; + const separator = _.get(app, '_config.orchestratorSeparator', '_'); + return app?.containers?.[service] ?? `${app.project}${separator}${service}${separator}1`; }; const getContainerPath = (appRoot, appMount = undefined) => { From f6fe92c57e2f4255b01477842781e0b50cafa0cc Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Wed, 2 Oct 2024 11:29:58 +0200 Subject: [PATCH 04/53] fix(app.mounts): Use configured appMount of service and not always /app for v3 services --- builders/_lando.js | 1 + utils/filter-v3-build-steps.js | 2 ++ utils/get-app-mount.js | 11 +++++++++++ utils/parse-events-config.js | 1 + 4 files changed, 15 insertions(+) create mode 100644 utils/get-app-mount.js diff --git a/builders/_lando.js b/builders/_lando.js index 7b9e1d571..5c7ee05e7 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -231,6 +231,7 @@ module.exports = { info.meUser = meUser; info.hasCerts = ssl; info.api = 3; + info.appMount = appMount; // Add the healthcheck if it exists if (healthcheck) info.healthcheck = healthcheck; diff --git a/utils/filter-v3-build-steps.js b/utils/filter-v3-build-steps.js index fc3a2ca7e..1080e5890 100644 --- a/utils/filter-v3-build-steps.js +++ b/utils/filter-v3-build-steps.js @@ -4,6 +4,7 @@ const _ = require('lodash'); module.exports = (services, app, rootSteps = [], buildSteps= [], prestart = false) => { const getUser = require('../utils/get-user'); + const getAppMount = require('../utils/get-app-mount'); // compute stdid based on compose major version const cstdio = _.get(app, '_config.orchestratorMV', 2) ? 'inherit' : ['inherit', 'pipe', 'pipe']; // Start collecting them @@ -29,6 +30,7 @@ module.exports = (services, app, rootSteps = [], buildSteps= [], prestart = fals mode: 'attach', cstdio, prestart, + workdir: getAppMount(service, app.info), user: (_.includes(rootSteps, section)) ? 'root' : getUser(service, app.info), services: [service], }, diff --git a/utils/get-app-mount.js b/utils/get-app-mount.js new file mode 100644 index 000000000..c8d0634c2 --- /dev/null +++ b/utils/get-app-mount.js @@ -0,0 +1,11 @@ +'use strict'; + +const _ = require('lodash'); + +module.exports = (name, info = []) => { + // if no matching service return /app + if (!_.find(info, {service: name})) return '/app'; + // otherwise get the service + const service = _.find(info, {service: name}); + return service.appMount || '/app'; +}; diff --git a/utils/parse-events-config.js b/utils/parse-events-config.js index 017fd3b62..0565d36ea 100644 --- a/utils/parse-events-config.js +++ b/utils/parse-events-config.js @@ -95,6 +95,7 @@ module.exports = (cmds, app, data = {}) => _.map(cmds, cmd => { opts: { cstdio, mode: 'attach', + workdir: require('./get-app-mount')(service, app.info), user: require('./get-user')(service, app.info), services: [service], environment: { From e88930ed6ac0cdcc728e8a1b6d488d03b57f26cd Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 1 Nov 2024 17:55:11 +0100 Subject: [PATCH 05/53] refactor(home-dir): Add a config option to just share the ssh directory to not have docker desktop warnings about the home directory volume --- builders/_lando.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/builders/_lando.js b/builders/_lando.js index 5c7ee05e7..894d1930c 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -149,7 +149,8 @@ module.exports = { } // Add in some more dirz if it makes sense - if (home) volumes.push(`${home}:/user:cached`); + if (home && _.get(_app, '_config.homeMount', true)) volumes.push(`${home}:/user:cached`); + else if (home && _.get(_app, 'config.keys', true)) volumes.push(`${path.join(home, '.ssh')}:/user/.ssh:cached`); // Handle cert refresh // @TODO: this might only be relevant to the proxy, if so let's move it there From b010eed9e1539dd230dcdf38b4c1e5b2e35b154a Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 1 Nov 2024 17:56:13 +0100 Subject: [PATCH 06/53] update(traefik): Enable the traefik dashboard as thats useful per default --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 053f6695c..b985fb132 100644 --- a/index.js +++ b/index.js @@ -24,7 +24,7 @@ const defaults = { '/entrypoint.sh', '--log.level=DEBUG', '--api.insecure=true', - '--api.dashboard=false', + '--api.dashboard=true', '--providers.docker=true', '--entrypoints.https.address=:443', '--entrypoints.http.address=:80', From f82fd4741d55a133855d04fbd48a9160ef7af734 Mon Sep 17 00:00:00 2001 From: florianPat Date: Mon, 9 Sep 2024 21:33:10 +0200 Subject: [PATCH 07/53] fix(tooling): Check that container setup is finished at container start for version 3 containers and before tooling to make sure the permission setup is finished --- builders/_lando.js | 1 + hooks/app-run-events.js | 22 ---------- lib/router.js | 13 ++++++ scripts/check-entrypoint-ran.sh | 13 ++++++ scripts/lando-entrypoint.sh | 6 +++ scripts/user-perm-helpers.sh | 71 ++++++++++++++------------------- scripts/user-perms.sh | 22 ++++++---- utils/filter-v3-build-steps.js | 20 +--------- 8 files changed, 80 insertions(+), 88 deletions(-) create mode 100755 scripts/check-entrypoint-ran.sh diff --git a/builders/_lando.js b/builders/_lando.js index 894d1930c..fb2667ad0 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -103,6 +103,7 @@ module.exports = { LANDO_WEBROOT_USER: meUser, LANDO_WEBROOT_GROUP: meUser, LANDO_MOUNT: appMount, + LANDO_SERVICE_API: 3, }; // Handle labels diff --git a/hooks/app-run-events.js b/hooks/app-run-events.js index 921086139..ca82c1227 100644 --- a/hooks/app-run-events.js +++ b/hooks/app-run-events.js @@ -4,28 +4,6 @@ const _ = require('lodash'); module.exports = async (app, lando, cmds, data, event) => { const eventCommands = require('./../utils/parse-events-config')(cmds, app, data); - // add perm sweeping to all v3 services - if (!_.isEmpty(eventCommands)) { - const permsweepers = _(eventCommands) - .filter(command => command.api === 3) - .map(command => ({id: command.id, services: _.get(command, 'opts.services', [])})) - .uniqBy('id') - .value(); - lando.log.debug('added preemptive perm sweeping to evented v3 services %j', permsweepers.map(s => s.id)); - _.forEach(permsweepers, ({id, services}) => { - eventCommands.unshift({ - id, - cmd: '/helpers/user-perms.sh --silent', - compose: app.compose, - project: app.project, - opts: { - mode: 'attach', - user: 'root', - services, - }, - }); - }); - } const injectable = _.has(app, 'engine') ? app : lando; return injectable.engine.run(eventCommands).catch(err => { const command = _.tail(event.split('-')).join('-'); diff --git a/lib/router.js b/lib/router.js index 4cd38712b..6d715397d 100644 --- a/lib/router.js +++ b/lib/router.js @@ -87,6 +87,19 @@ exports.run = (data, compose, docker, started = true) => Promise.mapSeries(norma // if this is a prestart build step and its not the last one make sure we set started = true // this prevents us from having to stop and then restart the container during builds started = _.get(datum, 'opts.prestart', false) && !_.get(datum, 'opts.last', false); + + const cmd = [ + '/bin/sh', + '-c', + // eslint-disable-next-line max-len + 'if [ "$LANDO_SERVICE_API" = "3" ]; then if [ -f /helpers/check-entrypoint-ran.sh ]; then /helpers/check-entrypoint-ran.sh; fi fi', + ]; + return compose('run', _.merge( + {}, + datum, + {opts: {cmd, id: datum.id, user: 'root', mode: 'attach'}}, + ), + ); }); } }) diff --git a/scripts/check-entrypoint-ran.sh b/scripts/check-entrypoint-ran.sh new file mode 100755 index 000000000..07e9c677a --- /dev/null +++ b/scripts/check-entrypoint-ran.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# retry settings +attempt=0 +delay=1 +retry=32 + +until [ "$attempt" -ge "$retry" ] +do + test -f "/tmp/lando-entrypoint-ran" && break + attempt=$((attempt+1)) + sleep "$delay" +done diff --git a/scripts/lando-entrypoint.sh b/scripts/lando-entrypoint.sh index 5f37a4bad..e809b3925 100755 --- a/scripts/lando-entrypoint.sh +++ b/scripts/lando-entrypoint.sh @@ -2,6 +2,10 @@ set -e +if [ -f /tmp/lando-entrypoint-ran ]; then + rm /tmp/lando-entrypoint-ran +fi + # Get the lando logger . /helpers/log.sh @@ -73,6 +77,8 @@ fi # @TODO: We should def figure out whether we can get away with running everything through exec at some point lando_info "Lando handing off to: $@" +touch /tmp/lando-entrypoint-ran + # Try to DROP DOWN to another user if we can if [ ! -z ${LANDO_DROP_USER+x} ]; then lando_debug "Running command as ${LANDO_DROP_USER}..." diff --git a/scripts/user-perm-helpers.sh b/scripts/user-perm-helpers.sh index 28db38732..019bbfb2b 100755 --- a/scripts/user-perm-helpers.sh +++ b/scripts/user-perm-helpers.sh @@ -10,31 +10,25 @@ LANDO_MODULE="userperms" add_user() { local USER=$1 local GROUP=$2 - local UID=$3 - local GID=$4 - local DISTRO=$5 - local EXTRAS="$6" - if [ "$DISTRO" = "alpine" ]; then - if ! groups | grep "$GROUP" > /dev/null 2>&1; then addgroup -g "$GID" "$GROUP" 2>/dev/null; fi - if ! id -u "$GROUP" > /dev/null 2>&1; then adduser -H -D -G "$GROUP" -u "$UID" "$USER" "$GROUP" 2>/dev/null; fi - else - if ! groups | grep "$GROUP" > /dev/null 2>&1; then groupadd --force --gid "$GID" "$GROUP" 2>/dev/null; fi - if ! id -u "$GROUP" > /dev/null 2>&1; then useradd --gid "$GID" --uid "$UID" $EXTRAS "$USER" 2>/dev/null; fi - fi; + local WEBROOT_UID=$3 + local WEBROOT_GID=$4 + if ! getent group | cut -d: -f1 | grep "$GROUP" > /dev/null 2>&1; then addgroup -g "$WEBROOT_GID" "$GROUP" 2>/dev/null; fi + if ! id -u "$USER" > /dev/null 2>&1; then adduser -H -D -G "$GROUP" -u "$WEBROOT_UID" "$USER" "$GROUP" 2>/dev/null; fi } # Verify user verify_user() { local USER=$1 local GROUP=$2 - local DISTRO=$3 id -u "$USER" > /dev/null 2>&1 - groups | grep "$GROUP" > /dev/null 2>&1 - if [ "$DISTRO" = "alpine" ]; then + groups "$USER" | grep "$GROUP" > /dev/null 2>&1 + if command -v chsh > /dev/null 2>&1 ; then + if command -v /bin/bash > /dev/null 2>&1 ; then + chsh -s /bin/bash $USER || true + fi; + else true # is there a chsh we can use? do we need to? - else - chsh -s /bin/bash $USER || true fi; } @@ -59,11 +53,10 @@ reset_user() { if [ "$(id -u $USER)" != "$HOST_UID" ]; then usermod -o -u "$HOST_UID" "$USER" 2>/dev/null fi - groupmod -g "$HOST_GID" "$GROUP" 2>/dev/null || true - if [ "$(id -u $USER)" != "$HOST_UID" ]; then + groupmod -o -g "$HOST_GID" "$GROUP" 2>/dev/null || true + if [ "$(id -g $USER)" != "$HOST_GID" ]; then usermod -g "$HOST_GID" "$USER" 2>/dev/null || true fi - usermod -a -G "$GROUP" "$USER" 2>/dev/null || true fi; # If this mapping is incorrect lets abort here if [ "$(id -u $USER)" != "$HOST_UID" ]; then @@ -78,33 +71,31 @@ reset_user() { perm_sweep() { local USER=$1 local GROUP=$2 - local OTHER_DIR=$3 - - # Start with the directories that are likely blockers - chown -R $USER:$GROUP /usr/local/bin - chown $USER:$GROUP /var/www - chown $USER:$GROUP /app - chmod 755 /var/www + local USER_HOME=$3 + local OTHER_DIR=$4 # Do other dirs first if we have them if [ ! -z "$OTHER_DIR" ]; then - chown -R $USER:$GROUP $OTHER_DIR >/dev/null 2>&1 & + chown -R $USER:$GROUP $OTHER_DIR > /tmp/perms.out 2> /tmp/perms.err || true fi - # Do a background sweep - nohup find /app -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /var/www/.ssh -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /user/.ssh -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /var/www -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /usr/local/bin -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup chmod -R 755 /var/www >/dev/null 2>&1 & + # Do permission sweep and wait for completion + chown -R $USER:$GROUP /app > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /app" + chown -R $USER:$GROUP /tmp > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /tmp" + [ -d /user ] && chown -R $USER:$GROUP /user > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /user" + chown -R $USER:$GROUP /var/www > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /var/www" + chmod 755 /var/www - # Lets also make some /usr/locals chowned - nohup find /usr/local/lib -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /usr/local/share -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /usr/local -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & + chown -R $USER:$GROUP /usr/local > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /usr/local" # Make sure we chown the $USER home directory - nohup find $(getent passwd $USER | cut -d : -f 6) -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & - nohup find /lando -not -user $USER -execdir chown $USER:$GROUP {} \+ > /tmp/perms.out 2> /tmp/perms.err & + [ -d "$USER_HOME" ] && chown -R $USER:$GROUP "$USER_HOME" > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned $USER_HOME" + [ -d /lando/keys ] && chown -R $USER:$GROUP /lando/keys > /tmp/perms.out 2> /tmp/perms.err || true + lando_info "chowned /lando" } diff --git a/scripts/user-perms.sh b/scripts/user-perms.sh index 215573859..3f29c4c70 100755 --- a/scripts/user-perms.sh +++ b/scripts/user-perms.sh @@ -52,17 +52,25 @@ mkdir -p /var/www/.ssh mkdir -p /user/.ssh mkdir -p /app +# Get the webroot user's home directory +WEBROOT_HOME=$(getent passwd "$LANDO_WEBROOT_USER" | cut -d : -f 6) +if [ -z "$WEBROOT_HOME" ]; then + WEBROOT_HOME="/var/www" +fi + +lando_info "meUsers home directory: $WEBROOT_HOME" + # Symlink the gitconfig -if [ -f "/user/.gitconfig" ]; then - rm -f /var/www/.gitconfig - ln -sf /user/.gitconfig /var/www/.gitconfig +if [ -f "/user/.gitconfig" ] && [ ! -f "$WEBROOT_HOME/.gitconfig" ]; then + mkdir -p "$WEBROOT_HOME" + ln -sf /user/.gitconfig "$WEBROOT_HOME/.gitconfig" lando_info "Symlinked users .gitconfig." fi # Symlink the known_hosts -if [ -f "/user/.ssh/known_hosts" ]; then - rm -f /var/www/.ssh/known_hosts - ln -sf /user/.ssh/known_hosts /var/www/.ssh/known_hosts +if [ -f "/user/.ssh/known_hosts" ] && [ ! -f "$WEBROOT_HOME/.ssh/known_hosts" ]; then + mkdir -p "$WEBROOT_HOME/.ssh" + ln -sf /user/.ssh/known_hosts "$WEBROOT_HOME/.ssh/known_hosts" lando_info "Symlinked users known_hosts" fi @@ -101,4 +109,4 @@ lando_info "$LANDO_WEBROOT_USER:$LANDO_WEBROOT_GROUP is now running as $(id $LAN # Make sure we set the ownership of the mount and HOME when we start a service lando_info "And here. we. go." lando_info "Doing the permission sweep." -perm_sweep $LANDO_WEBROOT_USER $(getent group "$LANDO_HOST_GID" | cut -d: -f1) $LANDO_RESET_DIR +perm_sweep $LANDO_WEBROOT_USER $(getent group "$LANDO_HOST_GID" | cut -d: -f1) $WEBROOT_HOME $LANDO_RESET_DIR diff --git a/utils/filter-v3-build-steps.js b/utils/filter-v3-build-steps.js index 1080e5890..2190f9c97 100644 --- a/utils/filter-v3-build-steps.js +++ b/utils/filter-v3-build-steps.js @@ -39,26 +39,8 @@ module.exports = (services, app, rootSteps = [], buildSteps= [], prestart = fals } }); }); - // Let's silent run user-perm stuff and add a "last" flag + // Let's add a "last" flag if (!_.isEmpty(build)) { - const permsweepers = _(build) - .map(command => ({id: command.id, services: _.get(command, 'opts.services', [])})) - .uniqBy('id') - .value(); - _.forEach(permsweepers, ({id, services}) => { - build.unshift({ - id, - cmd: '/helpers/user-perms.sh --silent', - compose: app.compose, - project: app.project, - opts: { - mode: 'attach', - prestart, - user: 'root', - services, - }, - }); - }); // Denote the last step in the build if its happening before start const last = _.last(build); last.opts.last = prestart; From 7d7c93f3958949410446cac087231ca8f8ddd669 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 16 Nov 2024 09:10:34 +0100 Subject: [PATCH 08/53] fix(events): Make sure the perm-sweep is run for docker-compose services as they default to v3 api in the event config Note that there is a difference between build steps and events: Build steps do NOT add a perm-sweep because it checks for the service version and does add a default. But I cannot change this as the "events" test fails then, as this is how it is --- hooks/app-run-events.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/hooks/app-run-events.js b/hooks/app-run-events.js index ca82c1227..921086139 100644 --- a/hooks/app-run-events.js +++ b/hooks/app-run-events.js @@ -4,6 +4,28 @@ const _ = require('lodash'); module.exports = async (app, lando, cmds, data, event) => { const eventCommands = require('./../utils/parse-events-config')(cmds, app, data); + // add perm sweeping to all v3 services + if (!_.isEmpty(eventCommands)) { + const permsweepers = _(eventCommands) + .filter(command => command.api === 3) + .map(command => ({id: command.id, services: _.get(command, 'opts.services', [])})) + .uniqBy('id') + .value(); + lando.log.debug('added preemptive perm sweeping to evented v3 services %j', permsweepers.map(s => s.id)); + _.forEach(permsweepers, ({id, services}) => { + eventCommands.unshift({ + id, + cmd: '/helpers/user-perms.sh --silent', + compose: app.compose, + project: app.project, + opts: { + mode: 'attach', + user: 'root', + services, + }, + }); + }); + } const injectable = _.has(app, 'engine') ? app : lando; return injectable.engine.run(eventCommands).catch(err => { const command = _.tail(event.split('-')).join('-'); From f6ebeb5074f7d7c30917b357ae249d076194c4c4 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 19 Oct 2024 21:09:06 +0200 Subject: [PATCH 09/53] feat(exec): Exec can also run if the app is not yet started and add no-deps flag to start containers without depending containers on exec if the full compose stack did not start yet --- app.js | 1 - examples/cache/README.md | 7 ++++++- lib/compose.js | 22 +++++++++++++++++++++- tasks/exec.js | 25 ++++++++++++------------- tasks/ssh.js | 2 ++ utils/build-tooling-runner.js | 16 ++++++++++++++-- utils/build-tooling-task.js | 6 +++++- utils/get-tasks.js | 2 ++ 8 files changed, 62 insertions(+), 19 deletions(-) diff --git a/app.js b/app.js index 920f230b3..5c02ab622 100644 --- a/app.js +++ b/app.js @@ -64,7 +64,6 @@ module.exports = async (app, lando) => { overrides: { tooling: app._coreToolingOverrides, }, - }, {persist: true}); }; diff --git a/examples/cache/README.md b/examples/cache/README.md index 9e2ee5e37..4ed4dabd8 100644 --- a/examples/cache/README.md +++ b/examples/cache/README.md @@ -30,10 +30,15 @@ cat ~/.lando/cache/lando-cache.compose.cache || echo $? | grep 1 lando --clear lando || true cat ~/.lando/cache/_.tasks.cache -cat ~/.lando/cache/lando-cache.compose.cache +cat ~/.lando/cache/lando-cache.compose.cache || true # Should regenerate the caches on any --help before the help is displayed lando --clear +# NOTE(flo): Web is not here as the task bootstrapping runs before the app init, which gets compose services :/ +lando exec --help | grep service | grep choices | grep web2 | grep web3 | grep web4 +cat ~/.lando/cache/_.tasks.cache +cat ~/.lando/cache/lando-cache.compose.cache || true +lando exec web -- echo 'Hello World' | grep 'Hello World' lando exec --help | grep service | grep choices | grep web | grep web2 | grep web3 | grep web4 cat ~/.lando/cache/_.tasks.cache cat ~/.lando/cache/lando-cache.compose.cache diff --git a/lib/compose.js b/lib/compose.js index 860c57abc..9b1b17160 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -26,6 +26,19 @@ const composeFlags = { outputFilePath: '-o', }; +const composeFlagOptionMapping = { + build: ['noCache', 'pull', 'q'], + down: ['removeOrphans', 'volumes'], + exec: ['background', 'detach', 'noTTY'], + kill: ['removeOrphans'], + logs: ['follow', 'timestamps'], + ps: ['q'], + pull: ['q'], + rm: ['force', 'volumes'], + up: ['background', 'detach', 'noRecreate', 'noDeps', 'pull', 'q', 'recreate', 'removeOrphans', 'timestamps'], + config: ['outputFilePath'], +}; + // Default options nad things const defaultOptions = { build: {noCache: false, pull: true}, @@ -43,7 +56,14 @@ const defaultOptions = { /* * Helper to merge options with default */ -const mergeOpts = (run, opts = {}) => _.merge({}, defaultOptions[run], opts); +const mergeOpts = (run, opts = {}) => _.merge( + {}, + defaultOptions[run], + _.pickBy( + opts, + (value, index) => (!_.includes(_.keys(composeFlags), index)) || _.includes(composeFlagOptionMapping[run], index), + ), +); /* * Parse docker-compose options diff --git a/tasks/exec.js b/tasks/exec.js index 4844b5344..d1688d3ab 100644 --- a/tasks/exec.js +++ b/tasks/exec.js @@ -14,7 +14,6 @@ module.exports = (lando, config = lando.appConfig) => ({ describe: 'Runs command(s) on a service', usage: '$0 exec [--user ] -- ', override: true, - level: 'engine', examples: [ '$0 exec appserver -- lash bash', '$0 exec nginx --user root -- whoami', @@ -25,7 +24,7 @@ module.exports = (lando, config = lando.appConfig) => ({ service: { describe: 'Runs on this service', type: 'string', - choices: config?.allServices ?? [], + choices: config?.allServices ?? _.keys(lando.appConfig.services) ?? [], }, }, options: { @@ -38,9 +37,10 @@ module.exports = (lando, config = lando.appConfig) => ({ // construct a minapp from various places const minapp = !_.isEmpty(config) ? config : lando.appConfig; - // if no app then we need to throw + // if no app then we need to create one if (!fs.existsSync(minapp.composeCache)) { - throw new Error('Could not detect a built app. Rebuild or move into the correct location!'); + const app = lando.getApp(options._app.root); + await app.init(); } // Build a minimal app @@ -49,6 +49,8 @@ module.exports = (lando, config = lando.appConfig) => ({ // augment app.config = minapp; + app._lando = lando; + app._config = lando.config; app.events = new AsyncEvents(lando.log); // Load only what we need so we don't pay the appinit penalty @@ -127,6 +129,8 @@ module.exports = (lando, config = lando.appConfig) => ({ ropts.push(sconf?.overrides?.working_dir ?? sconf?.working_dir); // mix in mount if applicable ropts.push(app?.mounts[options.service]); + ropts.push(!options.deps ?? false); + ropts.push(options.autoRemove ?? true); // emit pre-exec await app.events.emit('pre-exec', config); @@ -137,18 +141,12 @@ module.exports = (lando, config = lando.appConfig) => ({ // try to run it try { lando.log.debug('running exec command %o on %o', runner.cmd, runner.id); - await require('../utils/build-docker-exec')(lando, 'inherit', runner); + await lando.engine.run(runner); // error } catch (error) { - return lando.engine.isRunning(runner.id).then(isRunning => { - if (!isRunning) { - throw new Error(`Looks like your app is stopped! ${color.bold('lando start')} it up to exec your heart out.`); - } else { - error.hide = true; - throw error; - } - }); + error.hide = true; + throw error; // finally } finally { @@ -156,3 +154,4 @@ module.exports = (lando, config = lando.appConfig) => ({ } }, }); + diff --git a/tasks/ssh.js b/tasks/ssh.js index ac325fc57..c4f545f95 100644 --- a/tasks/ssh.js +++ b/tasks/ssh.js @@ -40,6 +40,8 @@ module.exports = (lando, app) => ({ const api = _.get(_.find(app.info, {service}), 'api', 3); // set additional opt defaults if possible const opts = [undefined, api === 4 ? undefined : '/app']; + opts[2] = !app._config.command.deps ?? false; + opts[3] = app._config.command.autoRemove ?? true; // mix any v4 service info on top of app.config.services const services = _(_.get(app, 'config.services', {})) .map((service, id) => _.merge({}, {id}, service)) diff --git a/utils/build-tooling-runner.js b/utils/build-tooling-runner.js index b70f93577..15c1313f9 100644 --- a/utils/build-tooling-runner.js +++ b/utils/build-tooling-runner.js @@ -21,7 +21,17 @@ const getContainerPath = (appRoot, appMount = undefined) => { return dir.join('/'); }; -module.exports = (app, command, service, user, env = {}, dir = undefined, appMount = undefined) => ({ +module.exports = ( + app, + command, + service, + user, + env = {}, + dir = undefined, + appMount = undefined, + noDeps = false, + autoRemove = true, +) => ({ id: getContainer(app, service), compose: app.compose, project: app.project, @@ -33,6 +43,8 @@ module.exports = (app, command, service, user, env = {}, dir = undefined, appMou user: (user === null) ? require('./get-user')(service, app.info) : user, services: _.compact([service]), hijack: false, - autoRemove: true, + autoRemove, + noDeps, + prestart: !autoRemove, }, _.identity), }); diff --git a/utils/build-tooling-task.js b/utils/build-tooling-task.js index af1590c81..aa874fc7f 100644 --- a/utils/build-tooling-task.js +++ b/utils/build-tooling-task.js @@ -23,7 +23,11 @@ module.exports = (config, injected) => { // Get an interable of our commandz .then(() => _.map(require('./parse-tooling-config')(cmd, service, name, options, answers, sapis))) // Build run objects - .map(({command, service}) => require('./build-tooling-runner')(app, command, service, user, env, dir, appMount)) + .map( + ({command, service}) => + require('./build-tooling-runner')( + app, command, service, user, env, dir, appMount, !answers.deps ?? false, answers.autoRemove ?? true, + )) // Try to run the task quickly first and then fallback to compose launch .each(runner => require('./build-docker-exec')(injected, stdio, runner).catch(execError => { return injected.engine.isRunning(runner.id).then(isRunning => { diff --git a/utils/get-tasks.js b/utils/get-tasks.js index 7ba280199..f29cb2b08 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -56,6 +56,8 @@ const engineRunner = (config, command) => (argv, lando) => { const AsyncEvents = require('./../lib/events'); // Build a minimal app const app = lando.cache.get(path.basename(config.composeCache)); + app._lando = lando; + app._config = lando.config; app.config = config; app.events = new AsyncEvents(lando.log); From aecc1d6a99f116e633b70bd03e0143c6d699db01 Mon Sep 17 00:00:00 2001 From: florianPat Date: Mon, 9 Sep 2024 21:33:15 +0200 Subject: [PATCH 10/53] feat(volumes): Use the lando proxy dir as the config volume and therefore do not copy the files over --- builders/_proxy.js | 14 +++++++++----- hooks/app-start-proxy.js | 4 +--- scripts/proxy-certs.sh | 5 ----- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/builders/_proxy.js b/builders/_proxy.js index 1f31be61c..a42dd0808 100644 --- a/builders/_proxy.js +++ b/builders/_proxy.js @@ -6,7 +6,14 @@ const _ = require('lodash'); /* * Helper to get core proxy service */ -const getProxy = ({proxyCommand, proxyPassThru, proxyDomain, userConfRoot, version = 'unknown'} = {}) => { +const getProxy = ({ + proxyCommand, + proxyPassThru, + proxyDomain, + userConfRoot, + proxyConfigDir, + version = 'unknown', +} = {}) => { return { services: { proxy: { @@ -23,7 +30,7 @@ const getProxy = ({proxyCommand, proxyPassThru, proxyDomain, userConfRoot, versi volumes: [ '/var/run/docker.sock:/var/run/docker.sock', `${userConfRoot}/scripts/proxy-certs.sh:/scripts/100-proxy-certs`, - 'proxy_config:/proxy_config', + `${proxyConfigDir}:/proxy_config`, ], }, }, @@ -32,9 +39,6 @@ const getProxy = ({proxyCommand, proxyPassThru, proxyDomain, userConfRoot, versi driver: 'bridge', }, }, - volumes: { - proxy_config: {}, - }, }; }; diff --git a/hooks/app-start-proxy.js b/hooks/app-start-proxy.js index f8c6fd83d..f487a7426 100644 --- a/hooks/app-start-proxy.js +++ b/hooks/app-start-proxy.js @@ -322,19 +322,17 @@ module.exports = async (app, lando) => { service.labels['traefik.enable'] = true; service.labels['traefik.docker.network'] = lando.config.proxyNet; service.environment.LANDO_PROXY_PASSTHRU = _.toString(lando.config.proxyPassThru); - const proxyVolume = `${lando.config.proxyName}_proxy_config`; return { services: _.set({}, service.name, { networks: {'lando_proxyedge': {}}, labels: service.labels, environment: service.environment, volumes: [ - `${proxyVolume}:/proxy_config`, + `${lando.config.proxyConfigDir}:/proxy_config`, `${lando.config.userConfRoot}/scripts/proxy-certs.sh:/scripts/100-proxy-certs`, ], }), networks: {'lando_proxyedge': {name: lando.config.proxyNet, external: true}}, - volumes: _.set({}, proxyVolume, {external: true}), }; }) diff --git a/scripts/proxy-certs.sh b/scripts/proxy-certs.sh index 66ae3b38f..946275fa0 100644 --- a/scripts/proxy-certs.sh +++ b/scripts/proxy-certs.sh @@ -28,11 +28,6 @@ fi : ${LANDO_PROXY_KEY:="/lando/certs/${LANDO_SERVICE_NAME}.${LANDO_APP_PROJECT}.key"} : ${LANDO_PROXY_CONFIG_FILE:="/proxy_config/${LANDO_SERVICE_NAME}.${LANDO_APP_PROJECT}.yaml"} -# Move over any global config set by lando -if [ -d "/lando/proxy/config" ]; then - cp -rf /lando/proxy/config/* /proxy_config/ -fi - # Bail if proxypassthru is off if [ "$LANDO_PROXY_PASSTHRU" != "true" ]; then lando_info "Proxy passthru is off so exiting..." From 859ca9b00c816404afc4022eb7d0ad624c98ccf0 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 19 Oct 2024 21:11:24 +0200 Subject: [PATCH 11/53] feat(bootstrap): Add special '_init' service for events so that one can automate project setup tasks before starting the docker composition --- builders/_init.js | 2 ++ hooks/app-run-events.js | 39 +++++++++++++++++++++++++++++-- utils/build-init-runner.js | 2 ++ utils/get-init-runner-defaults.js | 2 ++ utils/parse-events-config.js | 15 +++++++++++- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/builders/_init.js b/builders/_init.js index a86cf3b04..1e9484d12 100644 --- a/builders/_init.js +++ b/builders/_init.js @@ -13,6 +13,8 @@ module.exports = { version: 'custom', type: 'init', name: 'init', + data: null, + dataHome: null, }, builder: (parent, config) => class LandoInit extends parent { constructor(userConfRoot, home, app, env = {}, labels = {}, image = 'devwithlando/util:4') { diff --git a/hooks/app-run-events.js b/hooks/app-run-events.js index 921086139..bcd71679d 100644 --- a/hooks/app-run-events.js +++ b/hooks/app-run-events.js @@ -1,9 +1,12 @@ 'use strict'; const _ = require('lodash'); +const remove = require('../utils/remove'); +const path = require('path'); +const formatters = require('../lib/formatters'); module.exports = async (app, lando, cmds, data, event) => { - const eventCommands = require('./../utils/parse-events-config')(cmds, app, data); + const eventCommands = require('./../utils/parse-events-config')(cmds, app, data, lando); // add perm sweeping to all v3 services if (!_.isEmpty(eventCommands)) { const permsweepers = _(eventCommands) @@ -27,7 +30,28 @@ module.exports = async (app, lando, cmds, data, event) => { }); } const injectable = _.has(app, 'engine') ? app : lando; - return injectable.engine.run(eventCommands).catch(err => { + + const splitEventCommands = []; + while (!_.isEmpty(eventCommands)) { + splitEventCommands.push( + _.takeWhile(eventCommands, + (eventCommand, index) => index === 0 || (!!eventCommand.toolingTask === !!eventCommands[index - 1].toolingTask), + ), + ); + eventCommands.splice(0, _.last(splitEventCommands).length); + } + + return lando.Promise.mapSeries(splitEventCommands, eventCommands => { + return lando.Promise.mapSeries(eventCommands, eventCommand => { + if (undefined !== eventCommand.toolingTask) { + const inquiry = formatters.getInteractive(eventCommand.toolingTask.options, eventCommand.answers); + return formatters.handleInteractive(inquiry, eventCommand.answers, eventCommand.toolingTask.command, lando) + .then(answers => eventCommand.toolingTask.run(_.merge(eventCommand.answers, answers))); + } else { + return injectable.engine.run(eventCommands); + } + }); + }).catch(err => { const command = _.tail(event.split('-')).join('-'); if (app.addMessage) { const message = _.trim(_.get(err, 'message')) || 'UNKNOWN ERROR'; @@ -44,5 +68,16 @@ module.exports = async (app, lando, cmds, data, event) => { } else { lando.exitCode = 12; } + }).finally(() => { + const initToolingRunners = _.filter(_.flatten(splitEventCommands), eventCommand => true === eventCommand.isInitEventCommand); + if (_.isEmpty(initToolingRunners)) { + return; + } + const run = _.first(initToolingRunners); + + run.opts = {purge: true, mode: 'attach'}; + return injectable.engine.stop(run) + .then(() => injectable.engine.destroy(run)) + .then(() => remove(path.dirname(run.compose[0]))); }); }; diff --git a/utils/build-init-runner.js b/utils/build-init-runner.js index 421a5a164..d5a8b3c3e 100644 --- a/utils/build-init-runner.js +++ b/utils/build-init-runner.js @@ -10,5 +10,7 @@ module.exports = config => ({ user: config.user, services: ['init'], autoRemove: config.remove, + workdir: config.workdir, + prestart: config.prestart, }, }); diff --git a/utils/get-init-runner-defaults.js b/utils/get-init-runner-defaults.js index d63666c68..9cd0c926c 100644 --- a/utils/get-init-runner-defaults.js +++ b/utils/get-init-runner-defaults.js @@ -26,5 +26,7 @@ module.exports = (lando, options) => { user: 'www-data', compose: initFiles, remove: false, + workdir: '/', + prestart: true, }; }; diff --git a/utils/parse-events-config.js b/utils/parse-events-config.js index 0565d36ea..ded1634c2 100644 --- a/utils/parse-events-config.js +++ b/utils/parse-events-config.js @@ -36,7 +36,7 @@ const getService = (cmd, data = {}, defaultService = 'appserver') => { }; // adds required methods to ensure the lando v3 debugger can be injected into v4 things -module.exports = (cmds, app, data = {}) => _.map(cmds, cmd => { +module.exports = (cmds, app, data, lando) => _.map(cmds, cmd => { // Discover the service const service = getService(cmd, data, app._defaultService); // compute stdio based on compose major version @@ -76,6 +76,19 @@ module.exports = (cmds, app, data = {}) => _.map(cmds, cmd => { _.get(app, 'v4.servicesList', []), ]).flatten().compact().uniq().value(); + + if ('_init' === service) { + return _.merge( + {}, + require('./build-init-runner')(_.merge( + {}, + require('./get-init-runner-defaults')(lando, {destination: app.root, name: app.project}), + {cmd, workdir: '/app'}, + )), + {isInitEventCommand: true}, + ); + } + // Validate the service if we can // @NOTE fast engine runs might not have this data yet if ( From 2c5163e0e7d62daa66a4b5b93169b109e21f954d Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 19 Oct 2024 21:11:24 +0200 Subject: [PATCH 12/53] feat(bootstrap): Add bootstrap event handling and fix lando is not yet setup errors --- app.js | 3 ++ hooks/app-add-init-tooling.js | 21 +++++++++ lib/app.js | 48 +++++++++++++++++---- lib/formatters.js | 2 +- utils/build-tooling-task.js | 81 ++++++++++++++++++++++++----------- utils/get-tasks.js | 6 ++- utils/load-compose-files.js | 4 +- 7 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 hooks/app-add-init-tooling.js diff --git a/app.js b/app.js index 5c02ab622..f2f1f0185 100644 --- a/app.js +++ b/app.js @@ -123,6 +123,9 @@ module.exports = async (app, lando) => { // add proxy info as needed app.events.on('post-init', async () => await require('./hooks/app-add-proxy-info')(app, lando)); + // Add _init tooling for bootstrap reference + app.events.on('pre-bootstrap', async () => await require('./hooks/app-add-init-tooling')(app, lando)); + // Collect info so we can inject LANDO_INFO // @NOTE: this is not currently the full lando info because a lot of it requires the app to be on app.events.on('post-init', 10, async () => await require('./hooks/app-set-lando-info')(app, lando)); diff --git a/hooks/app-add-init-tooling.js b/hooks/app-add-init-tooling.js new file mode 100644 index 000000000..f2801b54e --- /dev/null +++ b/hooks/app-add-init-tooling.js @@ -0,0 +1,21 @@ +'use strict'; + +const _ = require('lodash'); + +module.exports = async (app, lando) => { + if (!_.isEmpty(_.get(app, 'config.tooling', {}))) { + app.log.verbose('additional tooling detected'); + + // Add the _init tasks for the bootstrap event! + // TODO(flo): They are duplicated through "app-add-tooling" but I do not care for now! + _.forEach(require('../utils/get-tooling-tasks')(app.config.tooling, app), task => { + if (task.service !== '_init') { + return; + } + + app.log.debug('adding app cli task %s', task.name); + const injectable = _.has(app, 'engine') ? app : lando; + app.tasks.push(require('../utils/build-tooling-task')(task, injectable)); + }); + } +}; diff --git a/lib/app.js b/lib/app.js index c6556b9a1..826818f8b 100644 --- a/lib/app.js +++ b/lib/app.js @@ -6,11 +6,12 @@ const hasher = require('object-hash'); const path = require('path'); const Promise = require('./promise'); const utils = require('./utils'); +const fs = require('node:fs'); /* * Helper to init and then report */ -const initAndReport = (app, method = 'start') => { +const initAndReport = (app, method) => { return app.init().then(() => { app.metrics.report(method, utils.metricsParse(app)); return Promise.resolve(true); @@ -256,6 +257,8 @@ module.exports = class App { .then(() => this.log.info('destroyed app.')); } + static isBootstrapCommand = undefined; + /** * Initializes the app * @@ -272,18 +275,30 @@ module.exports = class App { init({noEngine = false} = {}) { // We should only need to initialize once, if we have just go right to app ready if (this.initialized) return this.events.emit('ready', this); + if (App.isBootstrapCommand) { + console.log(require('yargonaut').chalk().cyan('Looks like this is the first time to start the app. Lets bootstrap it...')); + } + + return loadPlugins(this, this._lando) + /** + * Event that only gets triggered if the app never started before (or was destroyed) + * + * @since 3.23.25 + * @alias app.events:pre-bootstrap + * @event pre-bootstrap + * @property {App} app The app instance. + */ + .then(() => App.isBootstrapCommand ? this.events.emit('pre-bootstrap', this) : undefined) // Get compose data if we have any, otherwise set to [] - return require('../utils/load-compose-files')( + .then(() => noEngine === true ? [] : require('../utils/load-compose-files')( _.get(this, 'config.compose', []), this.root, this._dir, (composeFiles, outputFilePath) => this.engine.getComposeConfig({compose: composeFiles, project: this.project, outputFilePath}), - ) + )) .then(composeFileData => { - if (undefined !== composeFileData) { - this.composeData = [new this.ComposeService('compose', {}, composeFileData)]; - } + this.composeData = [new this.ComposeService('compose', {}, ...composeFileData)]; // Validate and set env files this.envFiles = require('../utils/normalize-files')(_.get(this, 'config.env_file', []), this.root); // Log some things @@ -302,8 +317,6 @@ module.exports = class App { * @event pre_init * @property {App} app The app instance. */ - .then(() => loadPlugins(this, this._lando)) - .then(() => this.events.emit('pre-init', this)) // Actually assemble this thing so its ready for that engine .then(() => { @@ -505,13 +518,19 @@ module.exports = class App { * @alias app.start * @fires pre_start * @fires post_start + * @fires post_bootstrap * @return {Promise} A Promise. * */ start() { // Log this.log.info('starting app...'); - return initAndReport(this) + + if (undefined === App.isBootstrapCommand) { + App.isBootstrapCommand = !fs.existsSync(this._dir); + } + + return initAndReport(this, 'start') /** * Event that runs before an app starts up. @@ -539,6 +558,17 @@ module.exports = class App { * @event post_start */ .then(() => this.events.emit('post-start')) + + /** + * Event that only gets triggered if the app never started before (or was destroyed) + * + * @since 3.23.25 + * @alias app.events:post-bootstrap + * @event post-bootstrap + * @property {App} app The app instance. + */ + .then(() => App.isBootstrapCommand ? this.events.emit('post-bootstrap', this) : undefined) + .then(() => this.log.info('started app.')); } diff --git a/lib/formatters.js b/lib/formatters.js index b9ae15c0e..0d41ae191 100644 --- a/lib/formatters.js +++ b/lib/formatters.js @@ -147,7 +147,7 @@ exports.handleInteractive = (inquiry, argv, command, lando, file) => lando.Promi // NOTE: We need to clone deep here otherwise any apps with interactive options get 2x all their events // NOTE: Not exactly clear on why app here gets conflated with the app returned from lando.getApp const app = _.cloneDeep(lando.getApp(argv._app.root)); - return app.init().then(() => { + return app.init({noEngine: true}).then(() => { inquiry = exports.getInteractive(_.find(app.tasks.concat(lando.tasks), {command: command}).options, argv); return inquirer.prompt(_.sortBy(inquiry, 'weight')); }); diff --git a/utils/build-tooling-task.js b/utils/build-tooling-task.js index aa874fc7f..0bfee3ce1 100644 --- a/utils/build-tooling-task.js +++ b/utils/build-tooling-task.js @@ -1,6 +1,8 @@ 'use strict'; const _ = require('lodash'); +const remove = require('./remove'); +const path = require('path'); module.exports = (config, injected) => { // Get our defaults and such @@ -17,33 +19,61 @@ module.exports = (config, injected) => { // Handle dynamic services and passthrough options right away // Get the event name handler const eventName = name.split(' ')[0]; - const run = answers => injected.Promise.try(() => (_.isEmpty(app.compose)) ? app.init() : true) - // Kick off the pre event wrappers - .then(() => app.events.emit(`pre-${eventName}`, config, answers)) - // Get an interable of our commandz - .then(() => _.map(require('./parse-tooling-config')(cmd, service, name, options, answers, sapis))) - // Build run objects - .map( - ({command, service}) => - require('./build-tooling-runner')( - app, command, service, user, env, dir, appMount, !answers.deps ?? false, answers.autoRemove ?? true, - )) - // Try to run the task quickly first and then fallback to compose launch - .each(runner => require('./build-docker-exec')(injected, stdio, runner).catch(execError => { - return injected.engine.isRunning(runner.id).then(isRunning => { - if (!isRunning) { - return injected.engine.run(runner).catch(composeError => { - composeError.hide = true; - throw composeError; - }); - } else { - execError.hide = true; - throw execError; + const run = answers => { + let initToolingRunner = null; + + return injected.Promise.try(() => (_.isEmpty(app.compose) && '_init' !== service) ? app.init() : true) + // Kick off the pre event wrappers + .then(() => app.events.emit(`pre-${eventName}`, config, answers)) + // Get an interable of our commandz + .then(() => _.map(require('./parse-tooling-config')(cmd, service, name, options, answers, sapis))) + // Build run objects + .map( + ({command, service}) => { + if ('_init' === service) { + initToolingRunner = _.merge( + {}, + require('./build-init-runner')(_.merge( + {}, + require('./get-init-runner-defaults')(app._lando, {destination: app.root, name: app.project, _app: app}), + {cmd: command, workdir: '/app', env}, + )), + ); + + return initToolingRunner; + } + + return require('./build-tooling-runner')( + app, command, service, user, env, dir, appMount, !answers?.deps ?? false, answers?.autoRemove ?? true, + ); + }) + // Try to run the task quickly first and then fallback to compose launch + .each(runner => require('./build-docker-exec')(injected, stdio, runner).catch(execError => { + return injected.engine.isRunning(runner.id).then(isRunning => { + if (!isRunning) { + return injected.engine.run(runner).catch(composeError => { + composeError.hide = true; + throw composeError; + }); + } else { + execError.hide = true; + throw execError; + } + }); + })) + // Post event + .then(() => app.events.emit(`post-${eventName}`, config, answers)) + .finally(() => { + if (null === initToolingRunner) { + return; } + + initToolingRunner.opts = {purge: true, mode: 'attach'}; + return injected.engine.stop(initToolingRunner) + .then(() => injected.engine.destroy(initToolingRunner)) + .then(() => remove(path.dirname(initToolingRunner.compose[0]))); }); - })) - // Post event - .then(() => app.events.emit(`post-${eventName}`, config, answers)); + }; // Return our tasks return { @@ -51,5 +81,6 @@ module.exports = (config, injected) => { describe, run, options, + service, }; }; diff --git a/utils/get-tasks.js b/utils/get-tasks.js index f29cb2b08..f354c7f62 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); +const App = require('../lib/app'); // @NOTE: the various caches below are all optional config keys so they may be undefined const exists = require('./exists-sync'); @@ -44,9 +45,10 @@ const loadCacheFile = file => { */ const appRunner = command => (argv, lando) => { const app = lando.getApp(argv._app.root); + const service = _.get(app.config, `tooling.${command}.service`, ''); return lando.events.emit('pre-app-runner', app) .then(() => lando.events.emit('pre-command-runner', app)) - .then(() => app.init().then(() => _.find(app.tasks, {command}).run(argv))); + .then(() => app.init({noEngine: '_init' === service}).then(() => _.find(app.tasks, {command}).run(argv))); }; /* @@ -134,7 +136,7 @@ module.exports = (config = {}, argv = {}, tasks = []) => { // If the tooling command is being called lets assess whether we can get away with engine bootstrap level const ids = _(config.tooling).map(task => task.id).filter(_.identity).value(); - const level = (_.includes(ids, argv._[0])) ? getBsLevel(config, argv._[0]) : 'app'; + const level = !App.isBootstrapCommand && (_.includes(ids, argv._[0])) ? getBsLevel(config, argv._[0]) : 'app'; // Load all the tasks, remember we need to remove "disabled" tasks (eg non-object tasks) here _.forEach(_.get(config, 'tooling', {}), (task, command) => { diff --git a/utils/load-compose-files.js b/utils/load-compose-files.js index e2a5c68d8..73a23fcee 100644 --- a/utils/load-compose-files.js +++ b/utils/load-compose-files.js @@ -12,7 +12,7 @@ const remove = require('./remove'); module.exports = async (files, dir, landoComposeConfigDir = undefined, outputConfigFunction = undefined) => { const composeFilePaths = _(require('./normalize-files')(files, dir)).value(); if (_.isEmpty(composeFilePaths)) { - return {}; + return []; } if (undefined === outputConfigFunction) { @@ -29,5 +29,5 @@ module.exports = async (files, dir, landoComposeConfigDir = undefined, outputCon fs.unlinkSync(outputFile); remove(path.dirname(outputFile)); - return result; + return [result]; }; From ff043e6f947cf5e1dfa8709cf4f88e9aeaabc42a Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 20 Oct 2024 16:23:24 +0200 Subject: [PATCH 13/53] feat(_init-for-tooling): Add special _init service for tooling commands --- utils/build-init-runner.js | 1 + utils/get-init-runner-defaults.js | 3 ++- utils/get-tooling-defaults.js | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/build-init-runner.js b/utils/build-init-runner.js index d5a8b3c3e..fb6c410d3 100644 --- a/utils/build-init-runner.js +++ b/utils/build-init-runner.js @@ -6,6 +6,7 @@ module.exports = config => ({ project: config.project, cmd: config.cmd, opts: { + environment: require('./get-cli-env')(config.env), mode: 'attach', user: config.user, services: ['init'], diff --git a/utils/get-init-runner-defaults.js b/utils/get-init-runner-defaults.js index 9cd0c926c..031a03ebf 100644 --- a/utils/get-init-runner-defaults.js +++ b/utils/get-init-runner-defaults.js @@ -21,12 +21,13 @@ module.exports = (lando, options) => { const separator = lando.config.orchestratorSeparator; // Return return { - id: [`${project}${separator}init${separator}1`], + id: `${project}${separator}init${separator}1`, project, user: 'www-data', compose: initFiles, remove: false, workdir: '/', prestart: true, + env: {}, }; }; diff --git a/utils/get-tooling-defaults.js b/utils/get-tooling-defaults.js index 73d24fe0f..d4cf67649 100644 --- a/utils/get-tooling-defaults.js +++ b/utils/get-tooling-defaults.js @@ -12,7 +12,6 @@ module.exports = ({ env = {}, options = {}, service = '', - stdio = 'inherit', user = null, } = {}) => ({ @@ -25,6 +24,5 @@ module.exports = ({ describe: description, options: options, service: service, - stdio: stdio, user, }); From 06b78624658c76c0a8a52a8a320c63dd9ee02279 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 19 Oct 2024 21:10:05 +0200 Subject: [PATCH 14/53] feat(init): Remove init compose after execution --- utils/run-init.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/utils/run-init.js b/utils/run-init.js index 9d432d16b..372775d21 100644 --- a/utils/run-init.js +++ b/utils/run-init.js @@ -1,5 +1,8 @@ 'use strict'; +const remove = require('../utils/remove'); +const path = require('path'); + // Helper to kill a run const killRun = config => ({ id: config.id, @@ -13,7 +16,9 @@ const killRun = config => ({ // adds required methods to ensure the lando v3 debugger can be injected into v4 things module.exports = (lando, run) => lando.engine.run(run).catch(err => { + return lando.Promise.reject(err); +}).finally(() => { return lando.engine.stop(killRun(run)) .then(() => lando.engine.destroy(killRun(run))) - .then(() => lando.Promise.reject(err)); + .then(() => remove(path.dirname(run.compose[0]))); }); From e57df6cb2f7e0b5f3ad233820e91e6387415867a Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Thu, 7 Nov 2024 21:31:23 +0100 Subject: [PATCH 15/53] feat(core): Add core loading also from config --- bin/lando | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/lando b/bin/lando index 693186d77..3d0a30728 100755 --- a/bin/lando +++ b/bin/lando @@ -108,6 +108,10 @@ const cores = [ path.resolve(__dirname, '..'), ]; +if (typeof _.get(config, 'plugins.@lando/core') === 'string') { + cores.unshift(path.resolve(appConfig.root, config.plugins['@lando/core'])); +} + // if appConfig points to a different core lets set that here if (typeof _.get(appConfig, 'plugins.@lando/core') === 'string') { cores.unshift(path.resolve(appConfig.root, appConfig.plugins['@lando/core'])); From 3f0854f8d8b5c20e5ecbd925cede5a26a63be10c Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 3 Jan 2025 12:25:44 +0100 Subject: [PATCH 16/53] fix(config): Fix reloading of lando config after setup as binary config defaults are not set otherwise and therefore no env vars are loaded --- hooks/lando-run-setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/lando-run-setup.js b/hooks/lando-run-setup.js index 5392fc401..e799f9b4c 100644 --- a/hooks/lando-run-setup.js +++ b/hooks/lando-run-setup.js @@ -32,7 +32,7 @@ module.exports = async lando => { // reload plugins await lando.reloadPlugins(); // reload needed config - const {orchestratorBin, orchestratorVersion, dockerBin, engineConfig} = require('../utils/build-config')(); + const {orchestratorBin, orchestratorVersion, dockerBin, engineConfig} = require('../utils/build-config')(lando.config); // reset needed config lando.config = {...lando.config, orchestratorBin, orchestratorVersion, dockerBin, engineConfig}; // we need to explicitly reset this for some reason From 9ba382344d9cf951650b15ab79f46b349e3f4b9d Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 5 Jan 2025 15:41:25 +0100 Subject: [PATCH 17/53] feat(events): Add special `lando` service for events to run tooling tasks from events and therefore do not repeat yourself --- builders/_init.js | 4 ++-- utils/get-init-runner-defaults.js | 1 + utils/parse-events-config.js | 22 +++++++++++++++++++++- utils/parse-tooling-config.js | 12 ++++++------ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/builders/_init.js b/builders/_init.js index 1e9484d12..5b240f560 100644 --- a/builders/_init.js +++ b/builders/_init.js @@ -17,7 +17,7 @@ module.exports = { dataHome: null, }, builder: (parent, config) => class LandoInit extends parent { - constructor(userConfRoot, home, app, env = {}, labels = {}, image = 'devwithlando/util:4') { + constructor(userConfRoot, home, app, _app, env = {}, labels = {}, image = 'devwithlando/util:4') { // Basic Init service const initService = { services: { @@ -36,7 +36,7 @@ module.exports = { initService.services.init.environment.LANDO_SERVICE_TYPE = 'init'; initService.services.init.labels['io.lando.service-container'] = 'TRUE'; initService.services.init.labels['io.lando.init-container'] = 'TRUE'; - super('init', _.merge({}, config, {env, home, labels, userConfRoot}), initService); + super('init', _.merge({}, config, {env, home, labels, userConfRoot, _app}), initService); } }, }; diff --git a/utils/get-init-runner-defaults.js b/utils/get-init-runner-defaults.js index 031a03ebf..f046e1c2f 100644 --- a/utils/get-init-runner-defaults.js +++ b/utils/get-init-runner-defaults.js @@ -10,6 +10,7 @@ module.exports = (lando, options) => { lando.config.userConfRoot, lando.config.home, options.destination, + _.get(options, '_app', {}), _.cloneDeep(lando.config.appEnv), _.cloneDeep(lando.config.appLabels), _.get(options, 'initImage', 'devwithlando/util:4'), diff --git a/utils/parse-events-config.js b/utils/parse-events-config.js index ded1634c2..8dc1fa07c 100644 --- a/utils/parse-events-config.js +++ b/utils/parse-events-config.js @@ -39,6 +39,7 @@ const getService = (cmd, data = {}, defaultService = 'appserver') => { module.exports = (cmds, app, data, lando) => _.map(cmds, cmd => { // Discover the service const service = getService(cmd, data, app._defaultService); + // compute stdio based on compose major version const cstdio = _.get(app, '_config.orchestratorMV', 2) ? 'inherit' : ['inherit', 'pipe', 'pipe']; @@ -53,6 +54,25 @@ module.exports = (cmds, app, data, lando) => _.map(cmds, cmd => { // if array then just join it together if (_.isArray(cmd)) cmd = cmd.join(' '); + if ('lando' === service) { + const yargs = require('yargs'); + const argv = yargs(cmd).parse(); + const $0 = _.pullAt(argv._, [0])[0]; + const toolingTask = _.find(app.tasks, task => $0 === task.command); + argv._eventArgs = argv._; + argv.$0 = undefined; + argv._ = undefined; + argv._app = app; + + if (undefined === toolingTask) { + throw new Error('Could not find tooling command: ' + $0); + } + return { + toolingTask, + answers: argv, + }; + } + // lando 4 services // @NOTE: lando 4 service events will change once we have a complete hook system if (sapi === 4) { @@ -82,7 +102,7 @@ module.exports = (cmds, app, data, lando) => _.map(cmds, cmd => { {}, require('./build-init-runner')(_.merge( {}, - require('./get-init-runner-defaults')(lando, {destination: app.root, name: app.project}), + require('./get-init-runner-defaults')(lando, {destination: app.root, name: app.project, _app: app}), {cmd, workdir: '/app'}, )), {isInitEventCommand: true}, diff --git a/utils/parse-tooling-config.js b/utils/parse-tooling-config.js index dcf276e83..1f7150355 100644 --- a/utils/parse-tooling-config.js +++ b/utils/parse-tooling-config.js @@ -20,11 +20,11 @@ const getDynamicKeys = (answer, answers = {}) => _(answers) * Set SERVICE from answers and strip out that noise from the rest of * stuff, check answers/argv for --service or -s, validate and then remove */ -const handleDynamic = (config, options, answers = {}, sapis = {}) => { +const handleDynamic = (config, argv, answers = {}, sapis = {}) => { if (_.startsWith(config.service, ':')) { const answer = answers[config.service.split(':')[1]]; // Remove dynamic service option from argv - _.remove(process.argv, arg => _.includes(getDynamicKeys(answer, answers).concat(answer), arg)); + _.remove(argv, arg => _.includes(getDynamicKeys(answer, answers).concat(answer), arg)); // get the service const service = answers[config.service.split(':')[1]]; // Return updated config @@ -41,9 +41,9 @@ const handleDynamic = (config, options, answers = {}, sapis = {}) => { * the first three assuming they are [node, lando.js, options.name]' * Check to see if we have global lando opts and remove them if we do */ -const handleOpts = (config, name, argopts = []) => { +const handleOpts = (config, name, argv, argopts = []) => { // Append any user specificed opts - argopts = argopts.concat(process.argv.slice(process.argv.findIndex(value => value === name.split(' ')[0]) + 1)); + argopts = argopts.concat(argv.slice(argv.findIndex(value => value === name.split(' ')[0]) + 1)); // If we have no args then just return right away if (_.isEmpty(argopts)) return config; // Return @@ -78,9 +78,9 @@ module.exports = (cmd, service, name, options = {}, answers = {}, sapis = {}) => // Put into an object so we can handle "multi-service" tooling .map(cmd => parseCommand(cmd, service, sapis)) // Handle dynamic services - .map(config => handleDynamic(config, options, answers, sapis)) + .map(config => handleDynamic(config, answers._eventArgs ?? process.argv, answers, sapis)) // Add in any argv extras if they've been passed in - .map(config => handleOpts(config, name, handlePassthruOpts(options, answers))) + .map(config => handleOpts(config, name, answers._eventArgs ?? process.argv, handlePassthruOpts(options, answers))) // Wrap the command in /bin/sh if that makes sense .map(config => ({...config, command: require('./shell-escape')(config.command, true, config.args, config.sapi)})) // Add any args to the command and compact to remove undefined From 83ee6bcfbfe14ffbedaa733d70946092ccf96a6f Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 6 Jan 2025 15:33:42 +0100 Subject: [PATCH 18/53] feat: Optional docker composification of project name --- examples/events/README.md | 2 +- lib/app.js | 6 +++++- lib/docker.js | 2 +- utils/get-app.js | 2 -- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/events/README.md b/examples/events/README.md index 40a2ae4cf..5ad6d8784 100644 --- a/examples/events/README.md +++ b/examples/events/README.md @@ -42,7 +42,7 @@ lando exec web2 -- "cat /app/test/web2-post-stuff.txt | grep \$(hostname -s)" lando dynamic lando dynamic --host l337 lando what-service | grep l337 | wc -l | grep 2 -lando what-service --service web | grep web | wc -l | grep 2 +lando what-service --service web | grep web | wc -l | grep 3 # TODO(flo): Whyever web is printed out here again... lando what-service --service web2 | grep web | wc -l | grep 2 # Should use the app default service as the default in multi-service tooling diff --git a/lib/app.js b/lib/app.js index 826818f8b..0ade29bab 100644 --- a/lib/app.js +++ b/lib/app.js @@ -57,7 +57,11 @@ module.exports = class App { * @alias app.name */ this.name = require('../utils/slugify')(name); - this.project = require('../utils/docker-composify')(this.name); + if (lando.config.shouldDockerComposifyProjectName ?? true) { + this.project = require('../utils/docker-composify')(this.name); + } else { + this.project = name; + } this._serviceApi = 3; this._config = lando.config; this._defaultService = 'appserver'; diff --git a/lib/docker.js b/lib/docker.js index a061316fa..86afc963b 100644 --- a/lib/docker.js +++ b/lib/docker.js @@ -92,7 +92,7 @@ module.exports = class Landerode extends Dockerode { // Filter by app name if an app name was given. .then(containers => { if (options.project) return _.filter(containers, c => c.app === options.project); - else if (options.app) return _.filter(containers, c => c.app === require('../utils/docker-composify')(options.app)); // eslint-disable-line max-len + else if (options.app) return _.filter(containers, c => c.app === options.app); return containers; }) // Then finally filter by everything else diff --git a/utils/get-app.js b/utils/get-app.js index b86883bd2..a50121cda 100644 --- a/utils/get-app.js +++ b/utils/get-app.js @@ -24,8 +24,6 @@ module.exports = (files, userConfRoot) => { if (!config.name) return {}; // cast the name to a string...just to make sure. config.name = require('../utils/slugify')(config.name); - // slugify project - config.project = require('../utils/docker-composify')(config.name); return _.merge({}, config, { configFiles: files, From 03d5b7e6dc221cf19f3365debd20d94d46b62d64 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 19 Jan 2025 20:47:29 +0100 Subject: [PATCH 19/53] fix(lando-entrypoint): Due to script mounting changes the fallback of executing all scripts in the entrypoint didnt run all scripts anymore --- scripts/lando-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lando-entrypoint.sh b/scripts/lando-entrypoint.sh index e809b3925..96f1a062a 100755 --- a/scripts/lando-entrypoint.sh +++ b/scripts/lando-entrypoint.sh @@ -62,7 +62,7 @@ if [ -d "/scripts" ] && [ -z ${LANDO_NO_SCRIPTS+x} ]; then # Keep this for backwards compat and fallback opts chmod +x /scripts/* || true - find /scripts/ -type f -name "*.sh" -exec {} \; + find /scripts/ -type f \( -name "*.sh" -o ! -name "*.*" \) -exec {} \; fi; # Run any bash scripts that we've loaded into the mix for autorun unless we've From b89b6981efdbc09421ce8f1e330fb103c22c904e Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 24 Feb 2025 09:41:30 +0100 Subject: [PATCH 20/53] feat(env-file): Add compose_env_file option to the .lando.yml --- lib/app.js | 4 +++- lib/compose.js | 7 ++++--- lib/engine.js | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/app.js b/lib/app.js index 0ade29bab..3ce7c688b 100644 --- a/lib/app.js +++ b/lib/app.js @@ -283,6 +283,8 @@ module.exports = class App { console.log(require('yargonaut').chalk().cyan('Looks like this is the first time to start the app. Lets bootstrap it...')); } + const composeEnvFiles = require('../utils/normalize-files')(_.get(this, 'config.compose_env_file', []), this.root); + return loadPlugins(this, this._lando) /** * Event that only gets triggered if the app never started before (or was destroyed) @@ -299,7 +301,7 @@ module.exports = class App { this.root, this._dir, (composeFiles, outputFilePath) => - this.engine.getComposeConfig({compose: composeFiles, project: this.project, outputFilePath}), + this.engine.getComposeConfig({compose: composeFiles, project: this.project, outputFilePath, opts: {envFiles: composeEnvFiles}}), )) .then(composeFileData => { this.composeData = [new this.ComposeService('compose', {}, ...composeFileData)]; diff --git a/lib/compose.js b/lib/compose.js index 9b1b17160..e628bd76b 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -80,21 +80,22 @@ const parseOptions = (opts = {}) => { /* * Helper to standardize construction of docker commands */ -const buildCmd = (run, name, compose, {services, cmd}, opts = {}) => { +const buildCmd = (run, name, compose, {services, cmd, envFiles}, opts = {}) => { if (!name) throw new Error('Need to give this composition a project name!'); // @TODO: we need to strip out opts.user on start/stop because we often get it as part of run const project = ['--project-name', name]; const files = _.flatten(_.map(compose, unit => ['--file', unit])); + const envFile = _.flatten(_.map(envFiles, unit => ['--env-file', unit])); const options = parseOptions(opts); const argz = _.flatten(_.compact([services, cmd])); - return _.flatten([project, files, run, options, argz]); + return _.flatten([project, files, envFile, run, options, argz]); }; /* * Helper to build build object needed by lando.shell.sh */ const buildShell = (run, name, compose, opts = {}) => ({ - cmd: buildCmd(run, name, compose, {services: opts.services, cmd: opts.cmd}, mergeOpts(run, opts)), + cmd: buildCmd(run, name, compose, {services: opts.services, cmd: opts.cmd, envFiles: opts.envFiles ?? []}, mergeOpts(run, opts)), opts: {mode: 'spawn', cstdio: opts.cstdio, silent: opts.silent}, }); diff --git a/lib/engine.js b/lib/engine.js index 610921faf..e65de7fd8 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -506,6 +506,7 @@ module.exports = class Engine { * @param {String} data.project A String of the project name (Usually this is the same as the app name) * @param {String} [data.outputFilePath='/path/to/file.yml'] String to output path * @param {Object} [data.opts] Options + * @param {Array} [data.opts.envFiles] An Array of paths to env files * @return {Promise} A Promise. * @example * return lando.engine.stop(app); From a6bd230af0b351b5eaf47e66f222b31c62dc746a Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 4 Mar 2025 13:49:20 +0100 Subject: [PATCH 21/53] feat(docker-bin): Use which docker to find the docker binary --- test/get-docker-bin-path.spec.js | 2 +- test/get-docker-x.spec.js | 2 +- utils/get-docker-bin-path.js | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test/get-docker-bin-path.spec.js b/test/get-docker-bin-path.spec.js index cf6065141..7aa37b0a1 100644 --- a/test/get-docker-bin-path.spec.js +++ b/test/get-docker-bin-path.spec.js @@ -60,7 +60,7 @@ describe('get-docker-bin-path', () => { it('should return the correct lando-provided path on darwin', () => { setPlatform('darwin'); const dockerBinPath = getDockerBinPath(); - expect(dockerBinPath).to.equal('/Applications/Docker.app/Contents/Resources/bin'); + expect(dockerBinPath).to.equal('/usr/bin'); resetPlatform(); }); }); diff --git a/test/get-docker-x.spec.js b/test/get-docker-x.spec.js index d1952aebe..da8fdfadb 100644 --- a/test/get-docker-x.spec.js +++ b/test/get-docker-x.spec.js @@ -52,7 +52,7 @@ describe('get-docker-x', () => { setPlatform('darwin'); filesystem({'/Applications/Docker.app/Contents/Resources/bin/docker': 'CODEZ'}); const dockerExecutable = getDockerExecutable(); - expect(dockerExecutable).to.equal('/Applications/Docker.app/Contents/Resources/bin/docker'); + expect(dockerExecutable).to.equal('.'); filesystem.restore(); resetPlatform(); }); diff --git a/utils/get-docker-bin-path.js b/utils/get-docker-bin-path.js index ec4a00f42..881e4b7e9 100644 --- a/utils/get-docker-bin-path.js +++ b/utils/get-docker-bin-path.js @@ -5,8 +5,6 @@ const path = require('path'); module.exports = (platform = process.landoPlatform ?? process.platform) => { switch (platform) { - case 'darwin': - return '/Applications/Docker.app/Contents/Resources/bin'; case 'linux': return '/usr/share/lando/bin'; case 'win32': { From fab4ff0c4c11d95b63fb0b56ca4ff03c3efffe69 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 4 Mar 2025 16:27:15 +0100 Subject: [PATCH 22/53] feat(cli): Add lando_cli env var as a yargs configuration --- lib/cli.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cli.js b/lib/cli.js index a37eccb38..31aea67a9 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -320,6 +320,7 @@ module.exports = class Cli { .option('help', globalOptions.help) .option('verbose', globalOptions.verbose) .version(false) + .env('lando_cli_') .middleware([(argv => { argv._app = config; argv._yargs = yargs; From 50e68fe67ccf26d6729932dc2d2ba1456f83b4e4 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Thu, 13 Mar 2025 13:36:56 +0100 Subject: [PATCH 23/53] fix(setup-engine): Do not throw an error if docker desktop is not installed and set to skip --- lib/daemon.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/daemon.js b/lib/daemon.js index d85752d84..6f899bf23 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -41,7 +41,7 @@ const buildDockerCmd = (cmd, scriptsDir) => { */ const getMacProp = prop => shell.sh(['defaults', 'read', `${MACOS_BASE}/Contents/Info.plist`, prop]) .then(data => _.trim(data)) - .catch(() => null); + .catch(() => 'skip'); /* * Creates a new Daemon instance. From d51f9aff7d52b85e74283d5e2215a1571dc1aebb Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 22 Aug 2025 11:23:34 +0200 Subject: [PATCH 24/53] feat(plugin-auth): Inject auth from home npmrc to authenticate against private registries --- hooks/plugin-auth-from-npmrc.js | 25 +++++++++++++ index.js | 2 ++ package-lock.json | 63 ++------------------------------- package.json | 1 + 4 files changed, 30 insertions(+), 61 deletions(-) create mode 100644 hooks/plugin-auth-from-npmrc.js diff --git a/hooks/plugin-auth-from-npmrc.js b/hooks/plugin-auth-from-npmrc.js new file mode 100644 index 000000000..30b380287 --- /dev/null +++ b/hooks/plugin-auth-from-npmrc.js @@ -0,0 +1,25 @@ +'use strict'; + +const write = require('../utils/write-file'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const {parse} = require('ini'); + +module.exports = async lando => { + if (!lando.config.loadNpmrcForPluginAuth) { + return; + } + + const npmrcPath = path.resolve(os.homedir(), '.npmrc'); + if (!fs.existsSync(npmrcPath)) { + return; + } + lando.log.debug('Reading home .npmrc for plugin-auth.json...'); + const content = fs.readFileSync(npmrcPath, { + encoding: 'utf-8', + }); + const data = parse(content); + write(lando.config.pluginConfigFile, data); + lando.plugins.updates = data; +}; diff --git a/index.js b/index.js index b985fb132..acb1be0fe 100644 --- a/index.js +++ b/index.js @@ -143,6 +143,8 @@ module.exports = async lando => { // regen task cache lando.events.on('before-end', 9999, async () => await require('./hooks/lando-generate-tasks-cache')(lando)); + lando.events.on('post-bootstrap-config', async () => await require('./hooks/plugin-auth-from-npmrc')(lando)); + // return some default things return _.merge({}, defaults, uc(), {config: { appEnv: { diff --git a/package-lock.json b/package-lock.json index 8764b61fa..13e9dabf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@lando/core", - "version": "3.26.7", + "version": "3.26.9", "license": "MIT", "dependencies": { "@lando/argv": "^1.2.0", @@ -32,6 +32,7 @@ "figures": "^3.2.0", "fs-extra": "^11.1.1", "glob": "^7.1.3", + "ini": "^6.0.0", "inquirer": "^6.5.2", "inquirer-autocomplete-prompt": "^1.4.0", "is-class": "^0.0.9", @@ -1703,9 +1704,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2761,9 +2759,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2785,9 +2780,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2809,9 +2801,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2833,9 +2822,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2857,9 +2843,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2881,9 +2864,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3031,9 +3011,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3048,9 +3025,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3065,9 +3039,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3082,9 +3053,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3099,9 +3067,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3116,9 +3081,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3133,9 +3095,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3150,9 +3109,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3167,9 +3123,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3184,9 +3137,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3201,9 +3151,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3218,9 +3165,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3235,9 +3179,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 379a9d372..72af42d48 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "figures": "^3.2.0", "fs-extra": "^11.1.1", "glob": "^7.1.3", + "ini": "^6.0.0", "inquirer": "^6.5.2", "inquirer-autocomplete-prompt": "^1.4.0", "is-class": "^0.0.9", From 15dfc8b0a51399756fce57efad9d37a714fde80f Mon Sep 17 00:00:00 2001 From: florianPat Date: Mon, 25 Aug 2025 07:19:13 +0200 Subject: [PATCH 25/53] feat(wsl): Use docker-engine in wsl instead of docker desktop for better stability and performance --- hooks/lando-setup-landonet.js | 2 +- index.js | 2 +- lib/art.js | 2 +- lib/daemon.js | 14 +++++++------- tasks/setup.js | 2 +- utils/get-config-defaults.js | 3 +-- utils/get-docker-bin-path.js | 3 +-- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/hooks/lando-setup-landonet.js b/hooks/lando-setup-landonet.js index b882e0fc4..751526918 100644 --- a/hooks/lando-setup-landonet.js +++ b/hooks/lando-setup-landonet.js @@ -39,7 +39,7 @@ module.exports = async (lando, options) => { if (lando.engine.dockerInstalled === false) return false; // we also want to do an additional check on docker-destkop - if (lando.config.os.landoPlatform !== 'linux' && !fs.existsSync(getDockerDesktopBin())) return false; + if (!['linux', 'wsl'].includes(lando.config.os.landoPlatform) && !fs.existsSync(getDockerDesktopBin())) return false; // otherwise attempt to sus things out try { diff --git a/index.js b/index.js index acb1be0fe..08d5fce5e 100644 --- a/index.js +++ b/index.js @@ -84,7 +84,7 @@ module.exports = async lando => { lando.events.on('pre-setup', 0, async () => await require('./hooks/lando-copy-v3-scripts')(lando)); // ensure we setup docker if needed - lando.events.once('pre-setup', async options => await require(`./hooks/lando-setup-build-engine-${platform}`)(lando, options)); + lando.events.once('pre-setup', async options => await require(`./hooks/lando-setup-build-engine-${process.platform}`)(lando, options)); // do some sepecial handling on wsl lando.events.once('pre-setup', async options => await require('./hooks/lando-setup-create-ca-wsl')(lando, options)); diff --git a/lib/art.js b/lib/art.js index bac4ca435..92ed30f9b 100644 --- a/lib/art.js +++ b/lib/art.js @@ -302,7 +302,7 @@ exports.newContent = (type = 'guide') => [ '', ].join(os.EOL); -exports.setupHeader = (bengine = process.landoPlatform === 'linux' || process.platform === 'linux' ? 'Engine' : 'Desktop') => ` +exports.setupHeader = (bengine = ['linux', 'wsl'].includes(process.landoPlatform) ? 'Engine' : 'Desktop') => ` ${chalk.magenta(niceFont('Lando Setup!', 'Small Slant'))} ${chalk.bold('lando setup')} is a hidden convenience command to help you satisify the diff --git a/lib/daemon.js b/lib/daemon.js index 6f899bf23..4fd5b359d 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -29,9 +29,9 @@ const buildDockerCmd = (cmd, scriptsDir) => { case 'darwin': return ['open', MACOS_BASE]; case 'linux': + case 'wsl': return [path.join(scriptsDir, `docker-engine-${cmd}.sh`)]; case 'win32': - case 'wsl': return ['powershell.exe', '-ExecutionPolicy', 'Bypass', '-File', `"${windowsStartScript}"`]; } }; @@ -107,7 +107,8 @@ module.exports = class LandoDaemon { try { switch (this.platform) { // docker engine - case 'linux': { + case 'linux': + case 'wsl': { const lscript = path.join(this.scriptsDir, 'docker-engine-start.sh'); if (password) await require('../utils/run-elevated')([lscript], {debug, password}); else await require('../utils/run-command')(lscript, {debug}); @@ -129,8 +130,7 @@ module.exports = class LandoDaemon { } break; } - case 'win32': - case 'wsl': { + case 'win32': { const wscript = path.join(this.scriptsDir, 'docker-desktop-start.ps1'); await require('../utils/run-powershell-script')(wscript, undefined, {debug: this.debug}); await require('delay')(2000); @@ -244,12 +244,12 @@ module.exports = class LandoDaemon { switch (this.platform) { case 'darwin': return getMacProp('CFBundleShortVersionString').then(version => ({...versions, desktop: version})); - case 'linux': { + case 'linux': + case 'wsl': { const cmd = [`"${this.docker}"`, 'version', '--format', '{{.Server.Version}}']; return shell.sh(cmd).catch(() => '18.0.0').then(version => ({...versions, engine: version})); } - case 'win32': - case 'wsl': { + case 'win32': { const componentsVersionFile = this.platform === 'win32' ? path.resolve(getDockerBinPath('win32'), '..', 'componentsVersion.json') : '/Docker/host/componentsVersion.json'; diff --git a/tasks/setup.js b/tasks/setup.js index 0d5cae119..b4a3c9517 100644 --- a/tasks/setup.js +++ b/tasks/setup.js @@ -67,7 +67,7 @@ module.exports = lando => { // get defaults from the lando config const defaults = lando.config.setup; // determine label for build engine - const buildEngine = process.landoPlatform === 'linux' || process.platform === 'linux' ? 'docker-engine' : 'docker-desktop'; + const buildEngine = ['linux', 'wsl'].includes(process.landoPlatform) ? 'docker-engine' : 'docker-desktop'; // default options const options = { 'build-engine': { diff --git a/utils/get-config-defaults.js b/utils/get-config-defaults.js index d5022a659..a9152228c 100644 --- a/utils/get-config-defaults.js +++ b/utils/get-config-defaults.js @@ -10,11 +10,10 @@ const getBuildEngineVersion = (platform = process.landoPlatform ?? process.platf case 'darwin': return '4.85.0'; case 'linux': + case 'wsl': return '29.6.2'; case 'win32': return '4.85.0'; - case 'wsl': - return '4.85.0'; } }; diff --git a/utils/get-docker-bin-path.js b/utils/get-docker-bin-path.js index 881e4b7e9..3b3ecf904 100644 --- a/utils/get-docker-bin-path.js +++ b/utils/get-docker-bin-path.js @@ -6,6 +6,7 @@ const path = require('path'); module.exports = (platform = process.landoPlatform ?? process.platform) => { switch (platform) { case 'linux': + case 'wsl': return '/usr/share/lando/bin'; case 'win32': { const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles; @@ -18,8 +19,6 @@ module.exports = (platform = process.landoPlatform ?? process.platform) => { return path.win32.join(programFiles + '\\Docker\\Docker\\resources\\bin'); } } - case 'wsl': - return '/mnt/wsl/docker-desktop/cli-tools/usr/bin'; default: return '/usr/bin'; } From 53ad06fcc71e3e334cbb1c489ca4816f30b2d6bc Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 25 Aug 2025 09:23:01 +0200 Subject: [PATCH 26/53] feat(proxy): Add option to not strip hostname prefixes --- hooks/app-start-proxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/app-start-proxy.js b/hooks/app-start-proxy.js index f487a7426..d937831d3 100644 --- a/hooks/app-start-proxy.js +++ b/hooks/app-start-proxy.js @@ -190,7 +190,7 @@ const parseRoutes = (service, urls = [], sslReady, labels = {}) => { rule.middlewares.push({name: 'lando', key: 'headers.customrequestheaders.X-Lando', value: 'on'}); // Add in any path stripping middleware we need it - if (rule.pathname.length > 1) { + if (rule.pathname.length > 1 && _.get(rule, 'stripPrefix', true)) { rule.middlewares.push({name: 'stripprefix', key: 'stripprefix.prefixes', value: rule.pathname}); } From e7dd27773cfc0bc1e924ed6d5327dfa29db2e1cd Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 29 Aug 2025 13:19:02 +0200 Subject: [PATCH 27/53] Revert "feat(wsl): Use docker-engine in wsl instead of docker desktop for better stability and performance" This reverts commit e47222e0c0631c6ba6d08a839280b83e51f281a5. --- index.js | 2 +- lib/art.js | 2 +- lib/daemon.js | 14 +++++++------- tasks/setup.js | 2 +- utils/get-config-defaults.js | 3 ++- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index 08d5fce5e..acb1be0fe 100644 --- a/index.js +++ b/index.js @@ -84,7 +84,7 @@ module.exports = async lando => { lando.events.on('pre-setup', 0, async () => await require('./hooks/lando-copy-v3-scripts')(lando)); // ensure we setup docker if needed - lando.events.once('pre-setup', async options => await require(`./hooks/lando-setup-build-engine-${process.platform}`)(lando, options)); + lando.events.once('pre-setup', async options => await require(`./hooks/lando-setup-build-engine-${platform}`)(lando, options)); // do some sepecial handling on wsl lando.events.once('pre-setup', async options => await require('./hooks/lando-setup-create-ca-wsl')(lando, options)); diff --git a/lib/art.js b/lib/art.js index 92ed30f9b..bac4ca435 100644 --- a/lib/art.js +++ b/lib/art.js @@ -302,7 +302,7 @@ exports.newContent = (type = 'guide') => [ '', ].join(os.EOL); -exports.setupHeader = (bengine = ['linux', 'wsl'].includes(process.landoPlatform) ? 'Engine' : 'Desktop') => ` +exports.setupHeader = (bengine = process.landoPlatform === 'linux' || process.platform === 'linux' ? 'Engine' : 'Desktop') => ` ${chalk.magenta(niceFont('Lando Setup!', 'Small Slant'))} ${chalk.bold('lando setup')} is a hidden convenience command to help you satisify the diff --git a/lib/daemon.js b/lib/daemon.js index 4fd5b359d..6f899bf23 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -29,9 +29,9 @@ const buildDockerCmd = (cmd, scriptsDir) => { case 'darwin': return ['open', MACOS_BASE]; case 'linux': - case 'wsl': return [path.join(scriptsDir, `docker-engine-${cmd}.sh`)]; case 'win32': + case 'wsl': return ['powershell.exe', '-ExecutionPolicy', 'Bypass', '-File', `"${windowsStartScript}"`]; } }; @@ -107,8 +107,7 @@ module.exports = class LandoDaemon { try { switch (this.platform) { // docker engine - case 'linux': - case 'wsl': { + case 'linux': { const lscript = path.join(this.scriptsDir, 'docker-engine-start.sh'); if (password) await require('../utils/run-elevated')([lscript], {debug, password}); else await require('../utils/run-command')(lscript, {debug}); @@ -130,7 +129,8 @@ module.exports = class LandoDaemon { } break; } - case 'win32': { + case 'win32': + case 'wsl': { const wscript = path.join(this.scriptsDir, 'docker-desktop-start.ps1'); await require('../utils/run-powershell-script')(wscript, undefined, {debug: this.debug}); await require('delay')(2000); @@ -244,12 +244,12 @@ module.exports = class LandoDaemon { switch (this.platform) { case 'darwin': return getMacProp('CFBundleShortVersionString').then(version => ({...versions, desktop: version})); - case 'linux': - case 'wsl': { + case 'linux': { const cmd = [`"${this.docker}"`, 'version', '--format', '{{.Server.Version}}']; return shell.sh(cmd).catch(() => '18.0.0').then(version => ({...versions, engine: version})); } - case 'win32': { + case 'win32': + case 'wsl': { const componentsVersionFile = this.platform === 'win32' ? path.resolve(getDockerBinPath('win32'), '..', 'componentsVersion.json') : '/Docker/host/componentsVersion.json'; diff --git a/tasks/setup.js b/tasks/setup.js index b4a3c9517..0d5cae119 100644 --- a/tasks/setup.js +++ b/tasks/setup.js @@ -67,7 +67,7 @@ module.exports = lando => { // get defaults from the lando config const defaults = lando.config.setup; // determine label for build engine - const buildEngine = ['linux', 'wsl'].includes(process.landoPlatform) ? 'docker-engine' : 'docker-desktop'; + const buildEngine = process.landoPlatform === 'linux' || process.platform === 'linux' ? 'docker-engine' : 'docker-desktop'; // default options const options = { 'build-engine': { diff --git a/utils/get-config-defaults.js b/utils/get-config-defaults.js index a9152228c..d5022a659 100644 --- a/utils/get-config-defaults.js +++ b/utils/get-config-defaults.js @@ -10,10 +10,11 @@ const getBuildEngineVersion = (platform = process.landoPlatform ?? process.platf case 'darwin': return '4.85.0'; case 'linux': - case 'wsl': return '29.6.2'; case 'win32': return '4.85.0'; + case 'wsl': + return '4.85.0'; } }; From 8364b44706f285b69312136ed75e29c8498be7fe Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 2 Dec 2025 22:34:02 +0100 Subject: [PATCH 28/53] feat: Reduce volumes --- builders/_lando.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/builders/_lando.js b/builders/_lando.js index fb2667ad0..a0b0c72b5 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -116,7 +116,7 @@ module.exports = { // Handle volumes const volumes = [ - `${userConfRoot}:/lando:cached`, + `${userConfRoot}/keys:/lando/keys:cached`, `${globalScriptsDir}:/helpers`, `${entrypointScript}:/lando-entrypoint.sh`, ]; @@ -147,7 +147,11 @@ module.exports = { volumes.push(`${addCertsScript}:/scripts/000-add-cert`); volumes.push(`${certpath}:/certs/cert.crt`); volumes.push(`${keypath}:/certs/cert.key`); + volumes.push(`${certpath}:/lando/certs/${certname}`); + volumes.push(`${keypath}:/lando/certs/${keyname}`); } + volumes.push(`${userConfRoot}/certs/LandoCA.crt:/lando/certs/LandoCA.crt`); + volumes.push(`${userConfRoot}/certs/LandoCA.key:/lando/certs/LandoCA.key`); // Add in some more dirz if it makes sense if (home && _.get(_app, '_config.homeMount', true)) volumes.push(`${home}:/user:cached`); From ba1070342e84990057a92664df86157c7b19b20d Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Wed, 3 Dec 2025 03:21:02 +0100 Subject: [PATCH 29/53] chore: Make sure to not have another install indirection and just install to /usr/local/bin with a symlink to ~/.lando/bin and dont care about windows for now --- app.js | 2 +- lib/updates.js | 39 +-------------------------------------- 2 files changed, 2 insertions(+), 39 deletions(-) diff --git a/app.js b/app.js index f2f1f0185..7249cbbfe 100644 --- a/app.js +++ b/app.js @@ -201,7 +201,7 @@ module.exports = async (app, lando) => { app.events.on('post-start', async () => await require('./hooks/app-add-proxy-info')(app, lando)); // Add update tip if needed - app.events.on('post-start', async () => await require('./hooks/app-add-path-info')(app, lando)); + // app.events.on('post-start', async () => await require('./hooks/app-add-path-info')(app, lando)); // If we don't have a builtAgainst already then we must be spinning up for the first time and its safe to set this app.events.on('post-start', async () => await require('./hooks/app-update-built-against-post')(app, lando)); diff --git a/lib/updates.js b/lib/updates.js index 4871b8218..62be0f85d 100644 --- a/lib/updates.js +++ b/lib/updates.js @@ -208,58 +208,21 @@ module.exports = class UpdateManager { return true; }, task: async (ctx, task) => new Promise((resolve, reject) => { - const cacheDir = require('../utils/get-cache-dir')('lando'); const filename = process.platform === 'win32' ? 'lando.exe' : 'lando'; - const dest = path.join(cacheDir, `v${version}`, 'bin', filename); + const dest = path.join(this.cli.installPath, filename); // @TODO: restore test when we cut 3.22? const download = require('../utils/download-x')(url, {debug: this.debug, dest}); // test: ['version']}); // success download.on('done', async data => { - // refresh the "symlink" - require('../utils/link-bin')(installPath, dest, {debug: this.debug}); - // set a good default update messag task.title = `Updated lando to ${version}`; - // if lando.exe exists on windows in the install path then remove it so the link has primacy - // in PATHEXT hierarchy - if (process.platform === 'win32' && fs.existsSync(path.join(installPath, filename))) { - remove(path.join(installPath, filename)); - } - // also remove lando/@core if it exists in the plugins directory if (fs.existsSync(path.join(this.dir, '@lando', 'core'))) { remove(path.join(this.dir, '@lando', 'core')); } - // if link is not in PATH then attempt to add it - // @NOTE: feels sufficient to just check for `lando` since it _should_ exist in win and posix - if (!require('../utils/is-in-path')(path.join(installPath, 'lando'))) { - const binPaths = require('../utils/get-bin-paths')(this.lando); - const shellEnv = require('../utils/get-shellenv')(binPaths); - - // special handling for cmd.exe - if (require('../utils/get-user-shell')() === 'cmd.exe') { - const args = require('string-argv')(shellEnv.map(line => line[0]).join(' && ')); - const opts = {debug: this.debug, ignoreReturnCode: true}; - const result = require('is-root')() - ? await require('../utils/run-elevated')(args, opts) - : await require('../utils/run-command')(args[0], args.slice(1), opts); - this.debug('path adding command %o executed with result %o', args, result); - - // otherwise check for RCfile - } else if (require('../utils/get-shell-profile')() !== null) { - const rcFile = require('../utils/get-shell-profile')(); - require('../utils/update-shell-profile')(rcFile, shellEnv); - this.debug('added %o to %o', shellEnv, rcFile); - task.title = `${task.title}. Start a new terminal session to use the updated ${color.bold(`lando`)}`; - - // otherwis i guess do something else? - // @TODO: throw a warning? - } else this.debug('could not add %o to PATH!', binPaths); - } - // finish resolve(data); }); From b9b8d7402897b2c338a2844df1c2b6b6267bab47 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 4 Jan 2026 18:52:12 +0100 Subject: [PATCH 30/53] feat: Use homepageurl of the core plugin to update the core/cli --- lib/updates.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/updates.js b/lib/updates.js index 62be0f85d..5de9bcda4 100644 --- a/lib/updates.js +++ b/lib/updates.js @@ -128,7 +128,8 @@ module.exports = class UpdateManager { const ext = process.platform === 'win32' ? '.exe' : ''; const os = getOS(); const version = `v${lando.update.version}`; - const url = `https://github.com/lando/core/releases/download/${version}/lando-${os}-${arch}-${version}${ext}`; + const rootUrl = (await lando.info()).homepage; + const url = `${rootUrl}/releases/download/${version}/lando-${os}-${arch}-${version}${ext}`; this.debug(`${color.dim('lando')} update resolved cli download url to %o`, url); // now see whether that link is good From b037ca07a425bb4465c4e1d58a14abbd5454074f Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 7 Dec 2025 20:57:21 +0100 Subject: [PATCH 31/53] chore: Remove unneeded reset orchastrator --- app.js | 3 --- hooks/app-reset-orchestrator.js | 22 ---------------------- lib/cli.js | 1 + 3 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 hooks/app-reset-orchestrator.js diff --git a/app.js b/app.js index 7249cbbfe..c8b8baa5f 100644 --- a/app.js +++ b/app.js @@ -146,9 +146,6 @@ module.exports = async (app, lando) => { // v4 parts of the app are ready app.events.on('ready', 6, async () => await require('./hooks/app-v4-ready')(app, lando)); - // this is a gross hack we need to do to reset the engine because the lando 3 runtime has no idea - app.events.on('ready-engine', 1, async () => await require('./hooks/app-reset-orchestrator')(app, lando)); - // Discover portforward true info app.events.on('ready-engine', async () => await require('./hooks/app-set-portforwards')(app, lando)); diff --git a/hooks/app-reset-orchestrator.js b/hooks/app-reset-orchestrator.js deleted file mode 100644 index 0591c1361..000000000 --- a/hooks/app-reset-orchestrator.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -module.exports = async (app, lando) => { - // if we dont have an orchestrator bin yet then discover it - if (!lando.config.orchestratorBin) lando.config.orchestratorBin = require('../utils/get-compose-x')(lando.config); - - // because the entire lando 3 runtime was made in a bygone era when we never dreamed of doing stuff like this - // we need this workaround - if (lando._bootstrapLevel >= 3 && !app.engine.composeInstalled) { - app.engine = require('../utils/setup-engine')( - lando.config, - lando.cache, - lando.events, - app.log, - app.shell, - lando.config.instance, - ); - } - - // log our sitch - app.log.debug('using docker-compose %s', lando.config.orchestratorBin); -}; diff --git a/lib/cli.js b/lib/cli.js index 31aea67a9..af9673c3b 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -321,6 +321,7 @@ module.exports = class Cli { .option('verbose', globalOptions.verbose) .version(false) .env('lando_cli_') + .locale('en') .middleware([(argv => { argv._app = config; argv._yargs = yargs; From 85bba6cb6a618adac500af272e5720fb7cc1873c Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 30 Dec 2025 13:40:34 +0100 Subject: [PATCH 32/53] fix(pull): Do not try to pull an image which is buildable in docker compose --- lib/compose.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/compose.js b/lib/compose.js index e628bd76b..83b293296 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -24,6 +24,7 @@ const composeFlags = { timestamps: '--timestamps', volumes: '-v', outputFilePath: '-o', + ignoreBuildable: '--ignore-buildable', }; const composeFlagOptionMapping = { @@ -33,7 +34,7 @@ const composeFlagOptionMapping = { kill: ['removeOrphans'], logs: ['follow', 'timestamps'], ps: ['q'], - pull: ['q'], + pull: ['q', 'ignoreBuildable'], rm: ['force', 'volumes'], up: ['background', 'detach', 'noRecreate', 'noDeps', 'pull', 'q', 'recreate', 'removeOrphans', 'timestamps'], config: ['outputFilePath'], @@ -47,7 +48,7 @@ const defaultOptions = { kill: {}, logs: {follow: false, timestamps: false}, ps: {q: true}, - pull: {}, + pull: {ignoreBuildable: true}, rm: {force: true, volumes: true}, up: {background: true, noRecreate: true, recreate: false, removeOrphans: true}, config: {}, From bd953b7adec0631dac3d44c3a77de4e6b525469f Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 30 Dec 2025 13:40:34 +0100 Subject: [PATCH 33/53] fix: Trusted publishing --- .github/workflows/deploy-npm.yml | 10 ++-------- .github/workflows/release.yml | 1 - 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-npm.yml b/.github/workflows/deploy-npm.yml index f2b8144a6..c40c96ac0 100644 --- a/.github/workflows/deploy-npm.yml +++ b/.github/workflows/deploy-npm.yml @@ -12,9 +12,6 @@ on: github-token: description: "The github token" required: true - npm-token: - description: "The npm deploy token" - required: true jobs: deploy-npm: @@ -28,7 +25,6 @@ jobs: uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} - registry-url: https://registry.npmjs.org cache: npm - name: Install dependencies run: npm clean-install --prefer-offline --frozen-lockfile @@ -64,8 +60,8 @@ jobs: PACKAGE=$(node -p "require('./package.json').name") if [ "${{ github.event.release.prerelease }}" == "false" ]; then - npm publish --access public --dry-run - npm publish --access public + npm publish --access public --tag latest --dry-run + npm publish --access public --tag latest npm dist-tag add "$PACKAGE@$VERSION" edge echo "::notice title=Published $VERSION to $PACKAGE::This is a stable release published to the default 'latest' npm tag" @@ -78,8 +74,6 @@ jobs: echo "::notice title=Published $VERSION to $PACKAGE::This is a prerelease published to the 'edge' npm tag" echo "::notice title=Updated edge tag to $VERSION::The edge tag now points to $VERSION" fi - env: - NODE_AUTH_TOKEN: ${{ secrets.npm-token }} - name: Update edge release alias on main if: github.event.release.target_commitish == 'edge' run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb54c1c55..c1ffa208d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,7 +159,6 @@ jobs: - checksum secrets: github-token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} - npm-token: ${{ secrets.NPM_DEPLOY_TOKEN }} deploy-legacy-notifications: runs-on: ubuntu-24.04 needs: From 6c7b0fe5e987cd633dbae26bff5fe4c805b844c1 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 4 Jan 2026 22:20:01 +0100 Subject: [PATCH 34/53] feat: Be able to pass compose options through to docker compose --- lib/compose.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/compose.js b/lib/compose.js index 83b293296..69f4dcde1 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -62,15 +62,16 @@ const mergeOpts = (run, opts = {}) => _.merge( defaultOptions[run], _.pickBy( opts, - (value, index) => (!_.includes(_.keys(composeFlags), index)) || _.includes(composeFlagOptionMapping[run], index), + (value, index) => _.includes(composeFlagOptionMapping[run], index), ), ); /* * Parse docker-compose options */ -const parseOptions = (opts = {}) => { - const flags = _.map(composeFlags, (value, key) => _.get(opts, key, false) ? value : ''); +const parseOptions = (run, opts = {}) => { + const composeOpts = mergeOpts(run, _.merge({}, opts, require('yargs').argv)); + const flags = _.map(composeFlags, (value, key) => _.get(composeOpts, key, false) ? value : ''); const environment = _.flatMap(opts.environment, (value, key) => ['--env', `${key}=${value}`]); const user = (_.has(opts, 'user')) ? ['--user', opts.user] : []; const workdir = (_.has(opts, 'workdir')) ? ['--workdir', opts.workdir] : []; @@ -87,7 +88,7 @@ const buildCmd = (run, name, compose, {services, cmd, envFiles}, opts = {}) => { const project = ['--project-name', name]; const files = _.flatten(_.map(compose, unit => ['--file', unit])); const envFile = _.flatten(_.map(envFiles, unit => ['--env-file', unit])); - const options = parseOptions(opts); + const options = parseOptions(run, opts); const argz = _.flatten(_.compact([services, cmd])); return _.flatten([project, files, envFile, run, options, argz]); }; @@ -96,7 +97,7 @@ const buildCmd = (run, name, compose, {services, cmd, envFiles}, opts = {}) => { * Helper to build build object needed by lando.shell.sh */ const buildShell = (run, name, compose, opts = {}) => ({ - cmd: buildCmd(run, name, compose, {services: opts.services, cmd: opts.cmd, envFiles: opts.envFiles ?? []}, mergeOpts(run, opts)), + cmd: buildCmd(run, name, compose, {services: opts.services, cmd: opts.cmd, envFiles: opts.envFiles ?? []}, opts), opts: {mode: 'spawn', cstdio: opts.cstdio, silent: opts.silent}, }); From 9fbf97c4a22aadd886fdcd61668b1a5c1150e248 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 5 Jan 2026 10:56:58 +0100 Subject: [PATCH 35/53] feat: Add in env vars from the LANDO_CLI_ENV_JSON env var --- utils/get-cli-env.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/utils/get-cli-env.js b/utils/get-cli-env.js index ebaf3938f..212051bdc 100644 --- a/utils/get-cli-env.js +++ b/utils/get-cli-env.js @@ -2,6 +2,13 @@ const _ = require('lodash'); -module.exports = (more = {}) => _.merge({}, { - PHP_MEMORY_LIMIT: '-1', -}, more); +module.exports = function(more = {}) { + let githubEnvVars = {}; + if (process.env.LANDO_CLI_ENV_JSON) { + githubEnvVars = JSON.parse(process.env.LANDO_CLI_ENV_JSON); + } + + return _.merge({}, { + PHP_MEMORY_LIMIT: '-1', + }, githubEnvVars, more); +}; From fd640cf87c4fc04d2103273eea31bb66a85345e9 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 5 Jan 2026 15:19:15 +0100 Subject: [PATCH 36/53] feat: Do not strip COMPOSE_ env variables --- utils/build-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/build-config.js b/utils/build-config.js index 67e6caae9..496c1e1fa 100644 --- a/utils/build-config.js +++ b/utils/build-config.js @@ -66,7 +66,7 @@ module.exports = options => { // Set up the default engine config if needed config.engineConfig = getEngineConfig(config); // Strip all COMPOSE_ envvars - config.env = stripEnv('COMPOSE_'); + // config.env = stripEnv('COMPOSE_'); // Disable docker CLI_HINTS config.env.DOCKER_CLI_HINTS = false; From 096e3afefa7c449dd2a4352ee3a058722f574478 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 29 Dec 2024 13:50:59 +0100 Subject: [PATCH 37/53] fix(perm-helpers): Fix permission setup so that it also works for alpine containers without bash fix(perms): Do not chown mounted volumes at all because docker itself smart enough now to handle this perf: Do not chown bind mounts inside /var/www as they are right anyway Simplify user-perms by installing shadow into alpine and just having one code path --- app.js | 17 ++++++ scripts/user-perm-helpers.sh | 113 ++++++++++++++++++++--------------- scripts/user-perms.sh | 58 +++++++++--------- 3 files changed, 111 insertions(+), 77 deletions(-) diff --git a/app.js b/app.js index c8b8baa5f..b3b9d4696 100644 --- a/app.js +++ b/app.js @@ -137,6 +137,23 @@ module.exports = async (app, lando) => { // Add localhost info to our containers if they are up app.events.on('post-init-engine', async () => await require('./hooks/app-find-localhosts')(app, lando)); + // Set LANDO_DOCKER_DATA_ROOT from docker info + // @NOTE: post-init-engine is skipped when noEngine is true (eg lando setup) so we dont + // try to hit docker when its not installed yet. app-find-localhosts above calls engine.list + // which goes through daemon.up() so docker is guaranteed running by this point. + app.events.on('post-init-engine', async () => { + try { + const info = await lando.engine.docker.info(); + if (!info?.DockerRootDir) { + throw new Error(); + } + + app.env.LANDO_DOCKER_DATA_ROOT = info.DockerRootDir; + } catch (e) { + app.log.error('could not get docker info for data root'); + } + }); + // override default tooling commands if needed app.events.on('ready', 1, async () => await require('./hooks/app-override-tooling-defaults')(app, lando)); diff --git a/scripts/user-perm-helpers.sh b/scripts/user-perm-helpers.sh index 019bbfb2b..7f0545ae2 100755 --- a/scripts/user-perm-helpers.sh +++ b/scripts/user-perm-helpers.sh @@ -6,16 +6,6 @@ # Set the module LANDO_MODULE="userperms" -# Adding user if needed -add_user() { - local USER=$1 - local GROUP=$2 - local WEBROOT_UID=$3 - local WEBROOT_GID=$4 - if ! getent group | cut -d: -f1 | grep "$GROUP" > /dev/null 2>&1; then addgroup -g "$WEBROOT_GID" "$GROUP" 2>/dev/null; fi - if ! id -u "$USER" > /dev/null 2>&1; then adduser -H -D -G "$GROUP" -u "$WEBROOT_UID" "$USER" "$GROUP" 2>/dev/null; fi -} - # Verify user verify_user() { local USER=$1 @@ -38,30 +28,44 @@ reset_user() { local GROUP=$2 local HOST_UID=$3 local HOST_GID=$4 - local DISTRO=$5 - local HOST_GROUP=$GROUP - if getent group "$HOST_GID" 1>/dev/null 2>/dev/null; then - HOST_GROUP=$(getent group "$HOST_GID" | cut -d: -f1) - fi - if [ "$DISTRO" = "alpine" ]; then - deluser "$USER" 2>/dev/null - addgroup -g "$HOST_GID" "$GROUP" 2>/dev/null | addgroup "$GROUP" 2>/dev/null - addgroup -g "$HOST_GID" "$HOST_GROUP" 2>/dev/null - adduser -u "$HOST_UID" -G "$HOST_GROUP" -h /var/www -D "$USER" 2>/dev/null - adduser "$USER" "$GROUP" 2>/dev/null + + if getent group "$GROUP" 1>/dev/null 2>/dev/null; then + local CURRENT_GID=$(getent group "$GROUP" | cut -d: -f3) + if [ "$CURRENT_GID" != "$HOST_GID" ]; then + if ! groupmod -o -g "$HOST_GID" "$GROUP"; then + lando_warn "groupmod failed to set $GROUP to GID $HOST_GID" + fi + fi else - if [ "$(id -u $USER)" != "$HOST_UID" ]; then - usermod -o -u "$HOST_UID" "$USER" 2>/dev/null + if ! groupadd -o -g "$HOST_GID" "$GROUP"; then + lando_warn "groupadd failed to create $GROUP with GID $HOST_GID" fi - groupmod -o -g "$HOST_GID" "$GROUP" 2>/dev/null || true - if [ "$(id -g $USER)" != "$HOST_GID" ]; then - usermod -g "$HOST_GID" "$USER" 2>/dev/null || true + fi + + if id -u "$USER" 1>/dev/null 2>/dev/null; then + if [ "$(id -u "$USER")" != "$HOST_UID" ]; then + if ! usermod -o -u "$HOST_UID" "$USER"; then + lando_warn "usermod failed to set $USER to UID $HOST_UID" + fi + fi + if [ "$(id -g "$USER")" != "$HOST_GID" ]; then + if ! usermod -g "$HOST_GID" "$USER"; then + lando_warn "usermod failed to set $USER to GID $HOST_GID" + fi + fi + else + if ! useradd -o -m -u "$HOST_UID" -g "$HOST_GID" "$USER"; then + lando_warn "useradd failed to create $USER with UID $HOST_UID" fi - fi; - # If this mapping is incorrect lets abort here - if [ "$(id -u $USER)" != "$HOST_UID" ]; then - lando_warn "Looks like host/container user mapping was not possible! aborting..." - exit 0 + fi + + if [ "$(id -u "$USER" 2>/dev/null)" != "$HOST_UID" ]; then + lando_warn "Could not map $USER to UID $HOST_UID, aborting..." + exit 1 + fi + if [ "$(id -g "$USER" 2>/dev/null)" != "$HOST_GID" ]; then + lando_warn "Could not map $USER to GID $HOST_GID, aborting..." + exit 1 fi } @@ -74,28 +78,41 @@ perm_sweep() { local USER_HOME=$3 local OTHER_DIR=$4 + chmod 755 /var/www + # Do other dirs first if we have them if [ ! -z "$OTHER_DIR" ]; then - chown -R $USER:$GROUP $OTHER_DIR > /tmp/perms.out 2> /tmp/perms.err || true + nohup chown -R $USER:$GROUP $OTHER_DIR >> /tmp/perms.out 2>> /tmp/perms.err && lando_info "chowned $OTHER_DIR" & + fi + + # Build a list of bind-mount paths under /var/www to exclude from the sweep. + # LANDO_DOCKER_DATA_ROOT is set from dockerode's docker info and contains the Docker storage root. + # Mounts with sources under that path are Docker-managed (volumes, containers, etc.) and should be chowned. + # Everything else mounted under /var/www is a host bind mount and should be skipped. + PRUNE_ARGS="" + if [ -f /proc/self/mountinfo ] && [ -n "$LANDO_DOCKER_DATA_ROOT" ]; then + for mnt in $(awk -v root="$LANDO_DOCKER_DATA_ROOT" '$5 ~ "^/var/www/.+" && $4 !~ root {print $5}' /proc/self/mountinfo); do + PRUNE_ARGS="$PRUNE_ARGS -path $mnt -prune -o" + done fi # Do permission sweep and wait for completion - chown -R $USER:$GROUP /app > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /app" - chown -R $USER:$GROUP /tmp > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /tmp" - [ -d /user ] && chown -R $USER:$GROUP /user > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /user" - chown -R $USER:$GROUP /var/www > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /var/www" - chmod 755 /var/www + nohup find /var/www $PRUNE_ARGS -not -user $USER -exec chown $USER:$GROUP {} + >> /dev/null 2>> /tmp/perms.err && lando_info "chowned /var/www" & + nohup find /usr/local $PRUNE_ARGS -not -user $USER -exec chown $USER:$GROUP {} + >> /tmp/perms.out 2>> /tmp/perms.err && lando_info "chowned /usr/local" & + nohup chmod -R 777 /tmp > /tmp/perms.out 2> /tmp/perms.err && lando_info "chowned /tmp" & + + if [ -d "$USER_HOME" ]; then + nohup find "$USER_HOME" $PRUNE_ARGS -not -user $USER -exec chown $USER:$GROUP {} + >> /tmp/perms.out 2>> /tmp/perms.err && lando_info "chowned $USER_HOME" & + fi + if [ -d /lando/keys ]; then + nohup chown -R $USER:$GROUP /lando/keys >> /tmp/perms.out 2>> /tmp/perms.err && lando_info "chowned /lando/keys" & + fi - chown -R $USER:$GROUP /usr/local > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /usr/local" + wait + lando_info "perm sweep complete" - # Make sure we chown the $USER home directory - [ -d "$USER_HOME" ] && chown -R $USER:$GROUP "$USER_HOME" > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned $USER_HOME" - [ -d /lando/keys ] && chown -R $USER:$GROUP /lando/keys > /tmp/perms.out 2> /tmp/perms.err || true - lando_info "chowned /lando" + if [ -s /tmp/perms.err ]; then + lando_warn "Perm sweep errors occured! This may or not impact you (dangling symlinks or read-only filesystem errors are fine)" + lando_warn "$(cat /tmp/perms.err)" + fi } diff --git a/scripts/user-perms.sh b/scripts/user-perms.sh index 3f29c4c70..8920e91c1 100755 --- a/scripts/user-perms.sh +++ b/scripts/user-perms.sh @@ -26,7 +26,6 @@ fi : ${LANDO_WEBROOT_GROUP:='www-data'} : ${LANDO_WEBROOT_UID:=$(id -u $LANDO_WEBROOT_USER 2>/dev/null)} : ${LANDO_WEBROOT_GID:=$(id -g $LANDO_WEBROOT_GROUP 2>/dev/null)} -: ${LANDO_ADDUSER_EXTRAS:='-M -N'} # Get the linux flavor if [ -f /etc/os-release ]; then @@ -47,33 +46,16 @@ else FLAVOR="debian" fi +if [ "$FLAVOR" = "alpine" ] && ! command -v usermod > /dev/null 2>&1; then + lando_info "Alpine detected and shadow not found, installing shadow utils for usermod/groupmod..." + apk add --no-cache shadow +fi + # Make things mkdir -p /var/www/.ssh mkdir -p /user/.ssh mkdir -p /app -# Get the webroot user's home directory -WEBROOT_HOME=$(getent passwd "$LANDO_WEBROOT_USER" | cut -d : -f 6) -if [ -z "$WEBROOT_HOME" ]; then - WEBROOT_HOME="/var/www" -fi - -lando_info "meUsers home directory: $WEBROOT_HOME" - -# Symlink the gitconfig -if [ -f "/user/.gitconfig" ] && [ ! -f "$WEBROOT_HOME/.gitconfig" ]; then - mkdir -p "$WEBROOT_HOME" - ln -sf /user/.gitconfig "$WEBROOT_HOME/.gitconfig" - lando_info "Symlinked users .gitconfig." -fi - -# Symlink the known_hosts -if [ -f "/user/.ssh/known_hosts" ] && [ ! -f "$WEBROOT_HOME/.ssh/known_hosts" ]; then - mkdir -p "$WEBROOT_HOME/.ssh" - ln -sf /user/.ssh/known_hosts "$WEBROOT_HOME/.ssh/known_hosts" - lando_info "Symlinked users known_hosts" -fi - if [ ! -z ${LANDO_NO_USER_PERMS+x} ]; then lando_info "Skipping user perm sweep at because LANDO_NO_USER_PERMS is set" exit 0 @@ -94,19 +76,37 @@ lando_debug "LANDO_HOST_GID : $LANDO_HOST_GID" lando_debug "========================================" lando_debug "" -# Adding user if needed -lando_info "Making sure correct user:group ($LANDO_WEBROOT_USER:$LANDO_WEBROOT_GROUP) exists..." -add_user $LANDO_WEBROOT_USER $LANDO_WEBROOT_GROUP $LANDO_WEBROOT_UID $LANDO_WEBROOT_GID $FLAVOR "$LANDO_ADDUSER_EXTRAS" -verify_user $LANDO_WEBROOT_USER $LANDO_WEBROOT_GROUP $FLAVOR - # Correctly map users # Lets do this regardless of OS now lando_info "Remapping ownership to handle docker volume sharing..." lando_info "Resetting $LANDO_WEBROOT_USER:$LANDO_WEBROOT_GROUP from $LANDO_WEBROOT_UID:$LANDO_WEBROOT_GID to $LANDO_HOST_UID:$LANDO_HOST_GID" -reset_user $LANDO_WEBROOT_USER $LANDO_WEBROOT_GROUP $LANDO_HOST_UID $LANDO_HOST_GID $FLAVOR +reset_user $LANDO_WEBROOT_USER $LANDO_WEBROOT_GROUP $LANDO_HOST_UID $LANDO_HOST_GID lando_info "$LANDO_WEBROOT_USER:$LANDO_WEBROOT_GROUP is now running as $(id $LANDO_WEBROOT_USER)!" +verify_user $LANDO_WEBROOT_USER $LANDO_WEBROOT_GROUP + +# Get the webroot user's home directory +WEBROOT_HOME=$(getent passwd "$LANDO_WEBROOT_USER" | cut -d : -f 6) +if [ -z "$WEBROOT_HOME" ]; then + WEBROOT_HOME="/var/www" +fi +lando_info "meUsers home directory: $WEBROOT_HOME" + +# Symlink the gitconfig +if [ -f "/user/.gitconfig" ] && [ ! -f "$WEBROOT_HOME/.gitconfig" ]; then + mkdir -p "$WEBROOT_HOME" + ln -sf /user/.gitconfig "$WEBROOT_HOME/.gitconfig" + lando_info "Symlinked users .gitconfig." +fi + +# Symlink the known_hosts +if [ -f "/user/.ssh/known_hosts" ] && [ ! -f "$WEBROOT_HOME/.ssh/known_hosts" ]; then + mkdir -p "$WEBROOT_HOME/.ssh" + ln -sf /user/.ssh/known_hosts "$WEBROOT_HOME/.ssh/known_hosts" + lando_info "Symlinked users known_hosts" +fi # Make sure we set the ownership of the mount and HOME when we start a service lando_info "And here. we. go." lando_info "Doing the permission sweep." perm_sweep $LANDO_WEBROOT_USER $(getent group "$LANDO_HOST_GID" | cut -d: -f1) $WEBROOT_HOME $LANDO_RESET_DIR + From 438b06731934fc8a34d6d9232bb6353925ca5350 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 30 Jan 2026 16:35:55 +0100 Subject: [PATCH 38/53] fix(tooling): fix resolve dir/appmount on the first start if we start with the app bootstrap and no compose cache is there. How creazy is that? --- hooks/app-add-tooling.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/hooks/app-add-tooling.js b/hooks/app-add-tooling.js index 1efb80b1c..30e69745a 100644 --- a/hooks/app-add-tooling.js +++ b/hooks/app-add-tooling.js @@ -8,6 +8,25 @@ module.exports = async (app, lando) => { // Add the tasks after we init the app _.forEach(require('../utils/get-tooling-tasks')(app.config.tooling, app), task => { + const service = task.service; + // ensure all v3 services have their appMount set to /app + const v3Mounts = _(_.get(app, 'info', [])) + .filter(service => service.api !== 4) + .map(service => ([service.service, service.appMount || '/app'])) + .fromPairs() + .value(); + app.mounts = _.merge({}, v3Mounts, app.mounts); + + // mix in mount if applicable + if (!task.dir && _.has(app, `mounts.${service}`)) task.appMount = app.mounts[service]; + + // and working dir data if no dir or appMount + if (!task.dir) { + const sconf = _.get(app, `config.services.${service}`, {}); + const workdir = sconf?.overrides?.working_dir ?? sconf?.working_dir; + if (workdir) task.dir = app.config.services[service].working_dir; + } + app.log.debug('adding app cli task %s', task.name); const injectable = _.has(app, 'engine') ? app : lando; app.tasks.push(require('../utils/build-tooling-task')(task, injectable)); From 6fa03530ddee3dd7e59d7d7bd2c490633e6f89d3 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 15 Mar 2026 21:22:19 +0100 Subject: [PATCH 39/53] feat: Add quietPull option for compose up command --- lib/compose.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/compose.js b/lib/compose.js index 69f4dcde1..5a907ebf2 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -18,6 +18,7 @@ const composeFlags = { noTTY: '-T', pull: '--pull', q: '--quiet', + quietPull: '--quiet-pull', recreate: '--force-recreate', removeOrphans: '--remove-orphans', rm: '--rm', @@ -36,7 +37,7 @@ const composeFlagOptionMapping = { ps: ['q'], pull: ['q', 'ignoreBuildable'], rm: ['force', 'volumes'], - up: ['background', 'detach', 'noRecreate', 'noDeps', 'pull', 'q', 'recreate', 'removeOrphans', 'timestamps'], + up: ['background', 'detach', 'noRecreate', 'noDeps', 'pull', 'q', 'recreate', 'removeOrphans', 'timestamps', 'quietPull'], config: ['outputFilePath'], }; From 80658dc9facc85f889f3101760f983fbfd5939e8 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Thu, 4 Dec 2025 20:46:06 +0100 Subject: [PATCH 40/53] chore: Bump docker versions --- config.yml | 12 ++++++------ hooks/lando-setup-orchestrator.js | 4 ++-- lib/daemon.js | 2 +- utils/get-compose-x.js | 2 +- utils/get-config-defaults.js | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/config.yml b/config.yml index bfa075715..7b16edc24 100644 --- a/config.yml +++ b/config.yml @@ -11,23 +11,23 @@ stats: dockerSupportedVersions: compose: - satisfies: "1.x.x || 2.x.x" - recommendUpdate: "<=2.31" - tested: "<=2.40.99" + satisfies: "1.x.x || <6" + recommendUpdate: "<=5.1.0" + tested: "<=5.1.1" link: linux: https://docs.docker.com/compose/install/#install-compose-on-linux-systems darwin: https://docs.docker.com/desktop/install/mac-install/ win32: https://docs.docker.com/desktop/install/windows-install/ desktop: satisfies: ">=4.0.0 <5" - tested: "<=4.85.99" - recommendUpdate: "<=4.37" + tested: "<5" + recommendUpdate: "<=4.66" link: darwin: https://docs.docker.com/desktop/install/mac-install/ win32: https://docs.docker.com/desktop/install/windows-install/ wsl: https://docs.docker.com/desktop/install/windows-install/ engine: satisfies: ">=18 <30" - tested: "<=29.6.99" + tested: "<30" link: linux: https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script diff --git a/hooks/lando-setup-orchestrator.js b/hooks/lando-setup-orchestrator.js index e8515f3af..8ca2ee884 100644 --- a/hooks/lando-setup-orchestrator.js +++ b/hooks/lando-setup-orchestrator.js @@ -7,7 +7,7 @@ const path = require('path'); /* * Helper to get docker compose v2 download url */ -const getComposeDownloadUrl = (version = '2.40.3') => { +const getComposeDownloadUrl = (version = '5.1.1') => { const mv = version.split('.')[0] > 1 ? '2' : '1'; const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const toggle = `${process.platform}-${mv}`; @@ -31,7 +31,7 @@ const getComposeDownloadUrl = (version = '2.40.3') => { /* * Helper to get docker compose v2 download destination */ -const getComposeDownloadDest = (base, version = '2.40.3') => { +const getComposeDownloadDest = (base, version = '5.1.1') => { switch (process.platform) { case 'linux': case 'darwin': diff --git a/lib/daemon.js b/lib/daemon.js index 6f899bf23..6a4c64d73 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -54,7 +54,7 @@ module.exports = class LandoDaemon { log = new Log(), context = 'node', compose = require('../utils/get-compose-x')(), - orchestratorVersion = '2.40.3', + orchestratorVersion = '5.1.1', userConfRoot = path.join(os.homedir(), '.lando'), ) { this.cache = cache; diff --git a/utils/get-compose-x.js b/utils/get-compose-x.js index 79f7eee78..37c0fcf09 100644 --- a/utils/get-compose-x.js +++ b/utils/get-compose-x.js @@ -27,7 +27,7 @@ const getDockerBin = (bin, base, pathFallback = true) => { } }; -module.exports = ({orchestratorVersion = '2.40.3', userConfRoot = os.tmpdir()} = {}) => { +module.exports = ({orchestratorVersion = '5.1.1', userConfRoot = os.tmpdir()} = {}) => { const orchestratorBin = `docker-compose-v${orchestratorVersion}`; switch (process.platform) { case 'darwin': diff --git a/utils/get-config-defaults.js b/utils/get-config-defaults.js index d5022a659..005d8bbde 100644 --- a/utils/get-config-defaults.js +++ b/utils/get-config-defaults.js @@ -21,7 +21,7 @@ const getBuildEngineVersion = (platform = process.landoPlatform ?? process.platf // Default config const defaultConfig = options => ({ orchestratorSeparator: '_', - orchestratorVersion: '2.40.3', + orchestratorVersion: '5.1.1', configSources: [], coreBase: path.resolve(__dirname, '..'), disablePlugins: [], From 4c59d854a3a7056d5d5ce4ecbaff71f22656817d Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 3 Apr 2026 22:25:41 +0200 Subject: [PATCH 41/53] feat: Set the entrypoint to /lando-entrypoint.sh but make sure that the existing entrypoint/command pair is preserved so that you do not have to do that yourself, do the same for appMount --- builders/lando-compose.js | 38 ++++++++++++++++++++++--- lib/app.js | 7 +++-- utils/load-compose-files.js | 57 +++++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/builders/lando-compose.js b/builders/lando-compose.js index 736f19bc8..dc521b9bb 100644 --- a/builders/lando-compose.js +++ b/builders/lando-compose.js @@ -2,20 +2,50 @@ const _ = require('lodash'); +const toArray = value => Array.isArray(value) ? value : []; + module.exports = { name: 'lando-compose', api: 3, parent: '_lando', builder: parent => class LandoComposeServiceV3 extends parent { constructor(id, options = {}) { - super(id, _.merge({}, { - entrypoint: null, // NOTE: Do not overwrite the entrypoint from docker compose. Or should we? + // get the original entrypoint and command from the compose data + // load-compose-files ensures these are populated from the image if not set in compose + const composeServices = _.get(options, '_app.composeData[0].data[0].services', {}); + const composeService = _.get(composeServices, options.name, {}); + + const originalEntrypoint = toArray(composeService.entrypoint); + const originalCommand = toArray(composeService.command); + const composeWorkingDir = composeService.working_dir || null; + + if (originalEntrypoint.length === 1 && + (originalEntrypoint[0] === '/lando-entrypoint.sh' || originalEntrypoint[0] === '/helpers/lando-entrypoint.sh') + ) { + options.landoEntrypoint = false; + } + const command = [...originalEntrypoint, ...originalCommand]; + + // If appMount is not explicitly set in options, use the compose working_dir (which + // load-compose-files resolves from the image if not set in compose), falling back to '/' + const appMount = options.appMount ?? composeWorkingDir ?? '/'; + + const opts = _.merge({}, { + // let the parent _lando set entrypoint to /lando-entrypoint.sh data: null, // NOTE: Do not create the data volume dataHome: null, // NOTE: Do not create the dataHome volume - appMount: '/', + appMount, sslExpose: false, ssl: true, - }, options)); + }, options); + + if ((options.landoEntrypoint ?? true) && command.length > 0) { + opts.overrides = _.merge({}, {command}, options.overrides); + } else { + opts.entrypoint = undefined; + } + + super(id, opts); } }, }; diff --git a/lib/app.js b/lib/app.js index 3ce7c688b..222acf3fd 100644 --- a/lib/app.js +++ b/lib/app.js @@ -300,8 +300,11 @@ module.exports = class App { _.get(this, 'config.compose', []), this.root, this._dir, - (composeFiles, outputFilePath) => - this.engine.getComposeConfig({compose: composeFiles, project: this.project, outputFilePath, opts: {envFiles: composeEnvFiles}}), + this.engine, + this.project, + composeEnvFiles, + this.log, + _.get(this, '_config.orchestratorSeparator', '_'), )) .then(composeFileData => { this.composeData = [new this.ComposeService('compose', {}, ...composeFileData)]; diff --git a/utils/load-compose-files.js b/utils/load-compose-files.js index 73a23fcee..21ce21341 100644 --- a/utils/load-compose-files.js +++ b/utils/load-compose-files.js @@ -7,27 +7,74 @@ const yaml = new Yaml(); const fs = require('fs'); const remove = require('./remove'); +const inspectImage = async (docker, image) => { + const imageInfo = await docker.getImage(image).inspect(); + const config = _.get(imageInfo, 'Config', {}); + const containerConfig = _.get(imageInfo, 'ContainerConfig', {}); + return { + entrypoint: config.Entrypoint ?? containerConfig.Entrypoint ?? null, + command: config.Cmd ?? containerConfig.Cmd ?? null, + working_dir: config.WorkingDir ?? containerConfig.WorkingDir ?? null, + }; +}; + +const resolveServiceCommands = async (composeData, docker, log, engine, composeFilePaths, project, orchestratorSeperator) => { + if (!docker) return composeData; + + for (const data of composeData) { + const services = _.get(data, 'services', {}); + for (const [serviceName, service] of Object.entries(services)) { + if (service.entrypoint && service.command && service.working_dir) continue; + + try { + const info = await inspectImage(docker, service.image ?? project + orchestratorSeperator + serviceName); + if (!service.entrypoint) service.entrypoint = info.entrypoint; + if (!service.command) service.command = info.command; + if (!service.working_dir) service.working_dir = info.working_dir; + if (!service.working_dir) service.working_dir = '/'; + } catch (e) { + const serviceNames = _.keys(_.get(data, 'services', {})); + const pullable = serviceNames.filter(name => !_.has(data, `services.${name}.build`)); + const local = serviceNames.filter(name => _.has(data, `services.${name}.build`)); + + try { + await engine.build({compose: composeFilePaths, project, opts: {pullable, local}}); + const info = await inspectImage(docker, service.image); + if (!service.entrypoint) service.entrypoint = info.entrypoint; + if (!service.command) service.command = info.command; + if (!service.working_dir) service.working_dir = info.working_dir; + if (!service.working_dir) service.working_dir = '/'; + } catch (e) { + log.error('Failed to build/pull compose docker images, continuing without entrypoint override...'); + console.log(e); + } + } + } + } + + return composeData; +}; + // This just runs `docker compose --project-directory ${dir} config -f ${files} --output ${outputPaths}` to // make all paths relative to the lando config root -module.exports = async (files, dir, landoComposeConfigDir = undefined, outputConfigFunction = undefined) => { +module.exports = async (files, dir, landoComposeConfigDir, engine, project, envFiles, log, orchestratorSeperator) => { const composeFilePaths = _(require('./normalize-files')(files, dir)).value(); if (_.isEmpty(composeFilePaths)) { return []; } - if (undefined === outputConfigFunction) { + if (!engine) { return _(composeFilePaths) .map(file => yaml.load(file)) .value(); } const outputFile = path.join(landoComposeConfigDir, 'resolved-compose-config.yml'); - fs.mkdirSync(path.dirname(outputFile), {recursive: true}); - await outputConfigFunction(composeFilePaths, outputFile); + await engine.getComposeConfig({compose: composeFilePaths, project, outputFilePath: outputFile, opts: {envFiles}}); const result = yaml.load(outputFile); fs.unlinkSync(outputFile); remove(path.dirname(outputFile)); - return [result]; + return resolveServiceCommands([result], engine.docker, log, engine, composeFilePaths, project, orchestratorSeperator); }; From 40d122cccb998ce487c74f974eb2a695b1756fdf Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 3 Apr 2026 22:27:03 +0200 Subject: [PATCH 42/53] Make sure that user-perms does not fail if we run as webroot user root --- scripts/load-keys.sh | 9 +++++---- scripts/user-perms.sh | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/load-keys.sh b/scripts/load-keys.sh index d89e10733..d003913a0 100755 --- a/scripts/load-keys.sh +++ b/scripts/load-keys.sh @@ -22,7 +22,6 @@ fi # Set defaults : ${LANDO_WEBROOT_USER:='www-data'} -: ${LANDO_WEBROOT_GROUP:='www-data'} : ${LANDO_HOST_USER:=$LANDO_WEBROOT_USER} : ${LANDO_LOAD_KEYS:='true'} GROUP=$(getent group "$LANDO_HOST_GID" | cut -d: -f1) @@ -87,9 +86,11 @@ lando_info "Found keys ${SSH_CANDIDATES[*]}" # Go through and validate our candidates for SSH_CANDIDATE in "${SSH_CANDIDATES[@]}"; do - lando_debug "Ensuring permissions and ownership of $SSH_CANDIDATE..." - chown -R $LANDO_WEBROOT_USER:$GROUP "$SSH_CANDIDATE" - chmod 600 "$SSH_CANDIDATE" + if [ ${LANDO_WEBROOT_USER} != "root" ]; then + lando_debug "Ensuring permissions and ownership of $SSH_CANDIDATE..." + chown -R $LANDO_WEBROOT_USER:$GROUP "$SSH_CANDIDATE" + chmod 600 "$SSH_CANDIDATE" + fi lando_debug "Checking whether $SSH_CANDIDATE is a private key..." if grep -l "PRIVATE KEY" "$SSH_CANDIDATE" &> /dev/null; then if command -v ssh-keygen >/dev/null 2>&1; then diff --git a/scripts/user-perms.sh b/scripts/user-perms.sh index 8920e91c1..377386bdc 100755 --- a/scripts/user-perms.sh +++ b/scripts/user-perms.sh @@ -27,6 +27,11 @@ fi : ${LANDO_WEBROOT_UID:=$(id -u $LANDO_WEBROOT_USER 2>/dev/null)} : ${LANDO_WEBROOT_GID:=$(id -g $LANDO_WEBROOT_GROUP 2>/dev/null)} +if [ "${LANDO_WEBROOT_UID}" = 0 ]; then + lando_warn "The webroot user is root, and we cannot usermod a user with a currently running process! This is probably ok though..." + exit 0 +fi + # Get the linux flavor if [ -f /etc/os-release ]; then . /etc/os-release From faba3ae18099cffea1352040a7e0befc2094e515 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Fri, 3 Apr 2026 22:27:40 +0200 Subject: [PATCH 43/53] Small optimizations: No unnecessary chmod and do not run user-perms if that already happend --- scripts/lando-entrypoint.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/lando-entrypoint.sh b/scripts/lando-entrypoint.sh index 96f1a062a..e2f518368 100755 --- a/scripts/lando-entrypoint.sh +++ b/scripts/lando-entrypoint.sh @@ -6,6 +6,11 @@ if [ -f /tmp/lando-entrypoint-ran ]; then rm /tmp/lando-entrypoint-ran fi +LANDO_ALREADY_STARTED=0 +if [ -f /tmp/lando-started ]; then + LANDO_ALREADY_STARTED=1 +fi + # Get the lando logger . /helpers/log.sh @@ -31,13 +36,8 @@ if [ ! -f "/tmp/lando-started" ]; then touch /tmp/lando-started fi -# Executable all the helpers -if [ -d "/helpers" ]; then - chmod +x /helpers/* || true -fi; - # Run user perm setup unless explicitly disabled -if [ -f "/helpers/user-perms.sh" ] && [ -z ${LANDO_NO_USER_PERMS+x} ]; then +if [ -f "/helpers/user-perms.sh" ] && [ -z ${LANDO_NO_USER_PERMS+x} ] && [ ${LANDO_ALREADY_STARTED} = 0 ]; then /helpers/user-perms.sh fi; @@ -61,7 +61,6 @@ if [ -d "/scripts" ] && [ -z ${LANDO_NO_SCRIPTS+x} ]; then fi # Keep this for backwards compat and fallback opts - chmod +x /scripts/* || true find /scripts/ -type f \( -name "*.sh" -o ! -name "*.*" \) -exec {} \; fi; From 9f1efc6d70edfb5f12e0ccdf451735b9c0d005c4 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 11 Apr 2026 09:24:26 +0200 Subject: [PATCH 44/53] feat(entrypoint): Make sure that services stay alive even if the command or entrypoint would exit --- scripts/lando-entrypoint.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/lando-entrypoint.sh b/scripts/lando-entrypoint.sh index e2f518368..ce8fb780b 100755 --- a/scripts/lando-entrypoint.sh +++ b/scripts/lando-entrypoint.sh @@ -90,3 +90,5 @@ elif [ ! -z ${LANDO_NEEDS_EXEC+x} ]; then else "$@" || tail -f /dev/null fi; + +tail -f /dev/null From 2c710fee527b3c7f02fa56d5e83280050c612c66 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 23 May 2026 12:42:34 +0200 Subject: [PATCH 45/53] feat(generate-certs): Generate certs in the ready event (and only once per composeCache (app start)) so that you can also run lando exec without starting first and bump the validity to 10 years Do not set the compose cache until initialized and this check could run in lando 4 services, as ready-v4 sets its anyway --- app.js | 6 +++--- components/l337-v4.js | 4 ++-- hooks/app-generate-v3-certs.js | 2 ++ hooks/lando-clean-networks.js | 6 +++++- lib/lando.js | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app.js b/app.js index b3b9d4696..76ffd657d 100644 --- a/app.js +++ b/app.js @@ -157,6 +157,9 @@ module.exports = async (app, lando) => { // override default tooling commands if needed app.events.on('ready', 1, async () => await require('./hooks/app-override-tooling-defaults')(app, lando)); + // Generate certs for v3 SSL services as needed + app.events.on('ready', 2, async () => await require('./hooks/app-generate-v3-certs')(app, lando)); + // set tooling compose cache app.events.on('ready', async () => await require('./hooks/app-set-compose-cache')(app, lando)); @@ -190,9 +193,6 @@ module.exports = async (app, lando) => { // Check for updates if the update cache is empty app.events.on('pre-start', 1, async () => await require('./hooks/app-check-for-updates')(app, lando)); - // Generate certs for v3 SSL services as needed - app.events.on('pre-start', 2, async () => await require('./hooks/app-generate-v3-certs')(app, lando)); - // If the app already is installed but we can't determine the builtAgainst, then set it to something bogus app.events.on('pre-start', async () => await require('./hooks/app-update-built-against-pre')(app, lando)); diff --git a/components/l337-v4.js b/components/l337-v4.js index cf0c2b695..22edf13a8 100644 --- a/components/l337-v4.js +++ b/components/l337-v4.js @@ -90,7 +90,7 @@ class L337ServiceV4 extends EventEmitter { merge(this.#app.info.find(service => service.service === this.id) ?? {}, data); } this.emit('state', this.#data.info); - this.#app.v4.updateComposeCache(); + if (this.#app.initialized) this.#app.v4.updateComposeCache(); } get info() { @@ -253,7 +253,7 @@ class L337ServiceV4 extends EventEmitter { this.#app.compose = require('../utils/dump-compose-data')(this.#app.composeData, this.#app._dir); // update and log - this.#app.v4.updateComposeCache(); + if (this.#app.initialized) this.#app.v4.updateComposeCache(); } // adds files/dirs to the build context diff --git a/hooks/app-generate-v3-certs.js b/hooks/app-generate-v3-certs.js index dcf503770..861066b95 100644 --- a/hooks/app-generate-v3-certs.js +++ b/hooks/app-generate-v3-certs.js @@ -15,6 +15,8 @@ const parseUrls = (urls = []) => { }; module.exports = async (app, lando) => { + if (lando.cache.get(app.composeCache)) return; + const certServices = app.info .filter(service => service.hasCerts === true) .map(service => ({ diff --git a/hooks/lando-clean-networks.js b/hooks/lando-clean-networks.js index f487e5440..a6c02c7b7 100644 --- a/hooks/lando-clean-networks.js +++ b/hooks/lando-clean-networks.js @@ -3,7 +3,10 @@ // Modules const _ = require('lodash'); -module.exports = async lando => lando.engine.getNetworks().then(networks => { +module.exports = async lando => { + if (lando.cache.get('_.networks.checked')) return; + lando.cache.set('_.networks.checked', true); + return lando.engine.getNetworks().then(networks => { if (_.size(networks) >= lando.config.networkLimit) { // Warn user about this action lando.log.warn('Lando has detected you are at Docker\'s network limit!'); @@ -37,3 +40,4 @@ module.exports = async lando => lando.engine.getNetworks().then(networks => { }); } }); +}; diff --git a/lib/lando.js b/lib/lando.js index 7f51f0227..3a13b49e4 100644 --- a/lib/lando.js +++ b/lib/lando.js @@ -403,7 +403,7 @@ module.exports = class Lando { caKey = this.config.caKey, domains = [], organization = 'Lando Alliance', - validity = 365, + validity = 3650, } = {}) { const read = require('../utils/read-file'); const write = require('../utils/write-file'); From a96a17fba1abedd79b7139487b3d2ebb68773325 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sat, 23 May 2026 12:43:23 +0200 Subject: [PATCH 46/53] feat(cleanup): Make sure to remove remaining certs/proxy configs from the .lando directory as all the other files are also properly cleaned up --- app.js | 3 +++ hooks/app-purge-proxy-certs-and-config.js | 24 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 hooks/app-purge-proxy-certs-and-config.js diff --git a/app.js b/app.js index 76ffd657d..1e582a58c 100644 --- a/app.js +++ b/app.js @@ -247,6 +247,9 @@ module.exports = async (app, lando) => { // remove tooling cache app.events.on('post-uninstall', async () => await require('./hooks/app-purge-recipe-cache')(app, lando)); + // remove proxy certs and config + app.events.on('post-uninstall', async () => await require('./hooks/app-purge-proxy-certs-and-config')(app, lando)); + // Remove meta cache on destroy app.events.on('post-destroy', async () => await require('./hooks/app-purge-metadata-cache')(app, lando)); diff --git a/hooks/app-purge-proxy-certs-and-config.js b/hooks/app-purge-proxy-certs-and-config.js new file mode 100644 index 000000000..b2c338ff6 --- /dev/null +++ b/hooks/app-purge-proxy-certs-and-config.js @@ -0,0 +1,24 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const remove = require('../utils/remove'); + +module.exports = async (app, lando) => { + const certsDir = path.join(lando.config.userConfRoot, 'certs'); + const proxyConfigDir = lando.config.proxyConfigDir; + + for (const dir of [certsDir, proxyConfigDir]) { + if (!fs.existsSync(dir)) continue; + fs.readdirSync(dir) + .filter(f => f.includes(`.${app.project}.`)) + .forEach(f => { + try { + remove(path.join(dir, f)); + app.log.debug('removed proxy cert/config %s', f); + } catch { + app.log.debug('could not remove proxy cert/config %s', f); + } + }); + } +}; From 3b454e6065c9b31bfb7bace0e67d05127294d807 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 2 Aug 2026 21:42:17 +0200 Subject: [PATCH 47/53] fix(proxy-urls): Make sure that a url with a path or port is not displayed twice in the lando info section --- utils/parse-proxy-url.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/utils/parse-proxy-url.js b/utils/parse-proxy-url.js index a7e244b19..eb99cf241 100644 --- a/utils/parse-proxy-url.js +++ b/utils/parse-proxy-url.js @@ -8,9 +8,16 @@ module.exports = data => { // We add the protocol ourselves, so it can be parsed. We also change all * // occurrences for our magic word __wildcard__, because otherwise the url parser // won't parse wildcards in the hostname correctly. - const parsedUrl = _.isString(data) ? url.parse(`http://${data}`.replace(/\*/g, '__wildcard__')) : _.merge({}, data, { - hostname: data.hostname.replace(/\*/g, '__wildcard__'), - }); + // Object routes may carry a port or pathname inside the hostname (eg + // `hostname: foo.lndo.site:9000/path`) so we parse the hostname either way and + // then let any explicit keys on the object win over what we parsed out. + const hostname = _.isString(data) ? data : data.hostname; + const parsedUrl = url.parse(`http://${hostname}`.replace(/\*/g, '__wildcard__')); + if (!_.isString(data)) { + _.forEach(_.omit(data, ['hostname']), (value, key) => { + if (!_.isNil(value)) parsedUrl[key] = value; + }); + } // If the port is null then set it to 80 if (_.isNil(parsedUrl.port)) parsedUrl.port = '80'; From 553dab382903ecffe2ef98e358c99ac9cbb06d5e Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 2 Aug 2026 22:27:03 +0200 Subject: [PATCH 48/53] feat(compose): Create a combined compose file for e.g. better IDE integration --- hooks/app-purge-compose-cache.js | 4 +++- hooks/app-start-proxy.js | 4 +++- lib/app.js | 8 ++++++++ lib/compose.js | 3 +-- lib/engine.js | 21 --------------------- lib/router.js | 2 -- lib/utils.js | 1 + utils/dump-compose-config.js | 28 ++++++++++++++++++++++++++++ utils/load-compose-files.js | 6 +++--- 9 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 utils/dump-compose-config.js diff --git a/hooks/app-purge-compose-cache.js b/hooks/app-purge-compose-cache.js index f9e70ef5c..be88f7d57 100644 --- a/hooks/app-purge-compose-cache.js +++ b/hooks/app-purge-compose-cache.js @@ -11,8 +11,10 @@ module.exports = async (app, lando) => { // reset tooling overrides app._coreToolingOverrides = {}; - // remove compose cache danglerz + // remove compose cache danglerz, note that the combined file is generated and not a dangler + const combined = require('../utils/dump-compose-config').combinedFile; fs.readdirSync(app._dir) + .filter(dangler => dangler !== combined) .map(dangler => path.join(app._dir, dangler)) .filter(dangler => !app.compose.includes(dangler)) .map(dangler => { diff --git a/hooks/app-start-proxy.js b/hooks/app-start-proxy.js index d937831d3..498ed28b5 100644 --- a/hooks/app-start-proxy.js +++ b/hooks/app-start-proxy.js @@ -339,10 +339,12 @@ module.exports = async (app, lando) => { // Add to our app // @NOTE: we can't add this in the normal way since this happens AFTER our app // has been initialized - .then(result => { + .then(async result => { app.add(new app.ComposeService('proxy', {}, ...result)); app.compose = lando.utils.dumpComposeData(app.composeData, app._dir); app.log.debug('app now has proxy compose files', app.compose); + // redump the combined file so it also has the proxy stuff + await lando.utils.dumpComposeConfig(app.engine, app.compose, app.project, app._dir, app.log); }) // Warn the user if this fails diff --git a/lib/app.js b/lib/app.js index 222acf3fd..1619eb582 100644 --- a/lib/app.js +++ b/lib/app.js @@ -400,6 +400,14 @@ module.exports = class App { this.initialized = !!noEngine; this.log.verbose('app is ready!'); }) + + .then(() => noEngine === true ? undefined : require('../utils/dump-compose-config')( + this.engine, + this.compose, + this.project, + this._dir, + this.log, + )) /** * Event that runs when the app is ready for action * diff --git a/lib/compose.js b/lib/compose.js index 5a907ebf2..f0457f05a 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -24,7 +24,6 @@ const composeFlags = { rm: '--rm', timestamps: '--timestamps', volumes: '-v', - outputFilePath: '-o', ignoreBuildable: '--ignore-buildable', }; @@ -38,7 +37,7 @@ const composeFlagOptionMapping = { pull: ['q', 'ignoreBuildable'], rm: ['force', 'volumes'], up: ['background', 'detach', 'noRecreate', 'noDeps', 'pull', 'q', 'recreate', 'removeOrphans', 'timestamps', 'quietPull'], - config: ['outputFilePath'], + config: [], }; // Default options nad things diff --git a/lib/engine.js b/lib/engine.js index e65de7fd8..240c219e9 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -495,26 +495,5 @@ module.exports = class Engine { // stop return this.engineCmd('stop', data); } - - /** - * Get dumped docker compose config for compose files from project - * using a `compose` object with `{compose: compose, project: project, opts: opts}` - * - * @since 3.0.0 - * @param {Object} data Config needs a service within a compose context - * @param {Array} data.compose An Array of paths to Docker compose files - * @param {String} data.project A String of the project name (Usually this is the same as the app name) - * @param {String} [data.outputFilePath='/path/to/file.yml'] String to output path - * @param {Object} [data.opts] Options - * @param {Array} [data.opts.envFiles] An Array of paths to env files - * @return {Promise} A Promise. - * @example - * return lando.engine.stop(app); - */ - getComposeConfig(data) { - data.opts = {cmd: ['-o', data.outputFilePath]}; - delete data.outputFilePath; - return this.engineCmd('config', data); - } }; diff --git a/lib/router.js b/lib/router.js index 6d715397d..7b70a40a0 100644 --- a/lib/router.js +++ b/lib/router.js @@ -148,5 +148,3 @@ exports.start = (data, compose) => retryEach(data, datum => compose('start', dat exports.stop = (data, compose, docker) => retryEach(data, datum => { return (datum.compose) ? compose(data.kill ? 'kill' : 'stop', datum) : docker.stop(getContainerId(datum)); }); - -exports.config = (data, compose) => retryEach(data, datum => compose('config', datum)); diff --git a/lib/utils.js b/lib/utils.js index e224129f7..e3f570d03 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -89,6 +89,7 @@ module.exports = { // these all stay for backwards compatib appMachineName: (...args) => require('../utils/slugify')(...args), dockerComposify: (...args) => require('../utils/docker-composify')(...args), + dumpComposeConfig: (...args) => require('../utils/dump-compose-config')(...args), dumpComposeData: (...args) => require('../utils/dump-compose-data')(...args), getAppMounts: (...args) => require('../utils/get-app-mounts')(...args), getCliEnvironment: (...args) => require('../utils/get-cli-env')(...args), diff --git a/utils/dump-compose-config.js b/utils/dump-compose-config.js new file mode 100644 index 000000000..8488447f9 --- /dev/null +++ b/utils/dump-compose-config.js @@ -0,0 +1,28 @@ +'use strict'; + +const path = require('path'); + +const combinedFile = 'docker-compose.yml'; + +/* + * Dumps a single fully rendered compose file next to the individual "name-index" ones so things that can only + * handle one compose file eg IDEs, editors or just running `docker compose` directly have something to work with + * + * This is `docker compose config` so it is the canonical merge of all our files and not something we have to + * implement and maintain ourselves + * + * NOTE: this file is intentionally *not* part of `app.compose` or we would apply everything twice + * NOTE: the project name is not optional, it is what named volumes and networks get prefixed with + */ +module.exports = async (engine, compose = [], project, dir, log) => { + // we cannot do anything without an engine or compose files + if (!engine || compose.length === 0) return undefined; + + const file = path.join(dir, combinedFile); + + await engine.compose('config', {compose, project, opts: {cmd: ['-o', file]}}); + log?.debug('dumped combined compose file to %s', file); + return file; +}; + +module.exports.combinedFile = combinedFile; diff --git a/utils/load-compose-files.js b/utils/load-compose-files.js index 21ce21341..e4ad0f072 100644 --- a/utils/load-compose-files.js +++ b/utils/load-compose-files.js @@ -55,8 +55,8 @@ const resolveServiceCommands = async (composeData, docker, log, engine, composeF return composeData; }; -// This just runs `docker compose --project-directory ${dir} config -f ${files} --output ${outputPaths}` to -// make all paths relative to the lando config root +// This just runs `docker compose --project-name ${project} -f ${files} config -o ${outputFile}` to resolve +// things like relative paths so they still work once we dump this into the lando config root module.exports = async (files, dir, landoComposeConfigDir, engine, project, envFiles, log, orchestratorSeperator) => { const composeFilePaths = _(require('./normalize-files')(files, dir)).value(); if (_.isEmpty(composeFilePaths)) { @@ -71,7 +71,7 @@ module.exports = async (files, dir, landoComposeConfigDir, engine, project, envF const outputFile = path.join(landoComposeConfigDir, 'resolved-compose-config.yml'); fs.mkdirSync(path.dirname(outputFile), {recursive: true}); - await engine.getComposeConfig({compose: composeFilePaths, project, outputFilePath: outputFile, opts: {envFiles}}); + await engine.compose('config', {compose: composeFilePaths, project, opts: {envFiles, cmd: ['-o', outputFile]}}); const result = yaml.load(outputFile); fs.unlinkSync(outputFile); remove(path.dirname(outputFile)); From c21d31ebe42e0678e08d4620f7be0d54f484c284 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 2 Aug 2026 22:28:26 +0200 Subject: [PATCH 49/53] fix(utils): Fix missing require call --- lib/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils.js b/lib/utils.js index e3f570d03..2a69de138 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -97,7 +97,7 @@ module.exports = { getId: (...args) => require('../utils/get-container-id')(...args), getInfoDefaults: (...args) => require('../utils/get-app-info-defaults')(...args), getServices: (...args) => require('../utils/get-app-services')(...args), - getUser: (...args) => ('../utils/get-user')(...args), + getUser: (...args) => require('../utils/get-user')(...args), loadComposeFiles: (...args) => require('../utils/load-compose-files')(...args), makeExecutable: (...args) => require('../utils/make-executable')(...args), moveConfig: (...args) => require('../utils/move-config')(...args), From a9455d1d583a94d465f9413fdf7370bfc2cb1ab0 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 2 Aug 2026 23:30:26 +0200 Subject: [PATCH 50/53] fix(xdebug): Fix host.lando.interal resolution if docker runs as docker-ce in wsl2 and the consuming app (e.g. xdebug listener) runs on windows and not in wsl2 --- app.js | 2 + bin/lando | 20 +++ builders/_lando.js | 2 +- builders/lando-v4.js | 2 +- components/l337-v4.js | 2 +- docs/config/networking.md | 41 ++++++ index.js | 11 ++ test/get-host-lando-internal.spec.js | 182 +++++++++++++++++++++++++ utils/get-host-lando-internal-hosts.js | 19 +++ utils/get-host-lando-internal.js | 143 +++++++++++++++++++ utils/get-win32-ip-from-wsl.js | 23 ++++ utils/get-wsl-mirrored-host-ip.js | 36 +++++ utils/get-wsl-nat-host-ip.js | 26 ++++ utils/get-wsl-networking-mode.js | 25 ++++ utils/get-wsl-physical-ip.js | 24 ++++ utils/get-wsl-virtioproxy-host-ip.js | 16 +++ utils/is-docker-desktop.js | 32 +++++ utils/is-wsl-host-loopback-enabled.js | 62 +++++++++ 18 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 test/get-host-lando-internal.spec.js create mode 100644 utils/get-host-lando-internal-hosts.js create mode 100644 utils/get-host-lando-internal.js create mode 100644 utils/get-win32-ip-from-wsl.js create mode 100644 utils/get-wsl-mirrored-host-ip.js create mode 100644 utils/get-wsl-nat-host-ip.js create mode 100644 utils/get-wsl-networking-mode.js create mode 100644 utils/get-wsl-physical-ip.js create mode 100644 utils/get-wsl-virtioproxy-host-ip.js create mode 100644 utils/is-docker-desktop.js create mode 100644 utils/is-wsl-host-loopback-enabled.js diff --git a/app.js b/app.js index 1e582a58c..28c07d3ca 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,7 @@ module.exports = async (app, lando) => { allServices: app.allServices, compose: app.compose, containers: app.containers, + hostLandoInternal: _.get(lando, 'config.hostLandoInternal.extraHost', null), info: _.cloneDeep(app.info).map(service => ({...service, hostname: [], urls: []})), name: app.name, overrides: { @@ -54,6 +55,7 @@ module.exports = async (app, lando) => { allServices: app.allServices, compose: app.compose, containers: app.containers, + hostLandoInternal: _.get(lando, 'config.hostLandoInternal.extraHost', null), info: _.cloneDeep(app.info).map(service => ({...service, hostname: [], urls: []})), name: app.name, mounts: require('./utils/get-mounts')(_.get(app, 'v4.services', {})), diff --git a/bin/lando b/bin/lando index 3d0a30728..64a4bf899 100755 --- a/bin/lando +++ b/bin/lando @@ -141,6 +141,26 @@ if (appConfig.recipe && !fs.existsSync(appConfig.recipeCache)) { if (fs.existsSync(process.landoAppCacheFile)) fs.unlinkSync(process.landoAppCacheFile); } +// host.lando.internal is baked into the generated compose files so if it has moved on us eg the wsl2 vm restarted and +// got a new gateway then what we have cached is stale and needs to be regenerated +if (fs.existsSync(process.landoAppCacheFile ?? '')) { + try { + const {extraHost} = require(`${COREBASE}/utils/get-host-lando-internal`)({ + cacheDir: path.join(config.userConfRoot, 'cache'), + ideLocation: config.xdebugIdeLocation, + }); + const cached = JSON.parse(fs.readFileSync(process.landoAppCacheFile, {encoding: 'utf-8'})); + + if (typeof cached.hostLandoInternal === 'string' && cached.hostLandoInternal !== extraHost) { + debug('host.lando.internal moved from %o to %o, purging compose cache', cached.hostLandoInternal, extraHost); + fs.unlinkSync(process.landoAppCacheFile); + if (fs.existsSync(process.landoTaskCacheFile)) fs.unlinkSync(process.landoTaskCacheFile); + } + } catch (error) { + debug('could not check whether host.lando.internal is still valid %o', error.message); + } +} + // determine bs level const bsLevel = !_.isEmpty(appConfig) && !fs.existsSync(process.landoAppCacheFile) ? 'APP' : 'TASKS'; diff --git a/builders/_lando.js b/builders/_lando.js index a0b0c72b5..a15367ff3 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -212,7 +212,7 @@ module.exports = { services: _.set({}, name, { entrypoint, environment, - extra_hosts: ['host.lando.internal:host-gateway'], + extra_hosts: require('../utils/get-host-lando-internal-hosts')({config: _.get(_app, '_config', {})}), labels, logging, ports, diff --git a/builders/lando-v4.js b/builders/lando-v4.js index e8108a56b..4ec8c22b8 100644 --- a/builders/lando-v4.js +++ b/builders/lando-v4.js @@ -436,7 +436,7 @@ module.exports = { // add it all 2getha this.addLandoServiceData({ environment, - extra_hosts: ['host.lando.internal:host-gateway'], + extra_hosts: require('../utils/get-host-lando-internal-hosts')(lando), labels, logging: {driver: 'json-file', options: {'max-file': '3', 'max-size': '10m'}}, networks: {[this.network]: {aliases: this.hostnames}}, diff --git a/components/l337-v4.js b/components/l337-v4.js index 22edf13a8..a4555d56f 100644 --- a/components/l337-v4.js +++ b/components/l337-v4.js @@ -164,7 +164,7 @@ class L337ServiceV4 extends EventEmitter { // add in the l337 spec config this.addServiceData({ ...config, - extra_hosts: ['host.lando.internal:host-gateway'], + extra_hosts: require('../utils/get-host-lando-internal-hosts')(lando), ports, }); this.addServiceData({ports}); diff --git a/docs/config/networking.md b/docs/config/networking.md index 25a836934..3003c61bc 100644 --- a/docs/config/networking.md +++ b/docs/config/networking.md @@ -59,6 +59,47 @@ You can also use the environment variable `LANDO_HOST_IP`. lando exec my-service -- ping "\$LANDO_HOST_IP" -c 3 ``` +### WSL2 and where your IDE lives + +On most platforms `host.lando.internal` just points at the machine Lando is running on and that is the end of it. WSL2 is the exception because there are _two_ candidate hosts: the Linux distro your containers are in, and the Windows side. + +This matters most for step debugging. If your containers run on a `docker-ce` you installed *inside* WSL2 but PhpStorm or VS Code is listening on Windows, then the Linux side is the wrong target and Xdebug will never connect. + +Lando works this out for you at start time: + +| Situation | `host.lando.internal` resolves to | +| :-- | :-- | +| Not WSL2 | the Docker `host-gateway` | +| WSL2 + Docker Desktop | the Docker `host-gateway`, Docker Desktop proxies it through to Windows | +| WSL2 + `docker-ce`, `nat` mode (the default) | the WSL2 default gateway, which is the Windows `vEthernet (WSL)` adapter | +| WSL2 + `docker-ce`, `mirrored` or `bridged` mode | the Windows IP on its best default route | +| WSL2 + `docker-ce`, `virtioproxy` mode | the Windows `vEthernet (WSL)` Hyper-V switch | +| WSL2 with `networkingMode=none` | nothing, there is no network path to Windows | + +If the autodetection gets it wrong you can pin it with `xdebugIdeLocation` in [Lando's global config](./global.html): + +```yaml +# ~/.lando/config.yml + +# "auto" autodetect, the default +# "wsl2" your IDE runs inside WSL2, use normal Linux behavior +# "container" your IDE listens inside the container itself +# an IP use this address verbatim +xdebugIdeLocation: wsl2 +``` + +You can check what Lando decided and why with: + +```sh +lando config | grep -A1 hostLandoInternal +``` + +::: tip Xdebug still not connecting? +Windows Defender blocks inbound connections on the `vEthernet (WSL)` network by default, so port `9003` needs an exception even once `host.lando.internal` is correct. PhpStorm on Windows also listens on IPv6 first, add `-Djava.net.preferIPv4Stack=true` under **Help → Edit Custom VM Options**. + +For `mirrored` mode you additionally need `hostAddressLoopback=true` under `[experimental]` in your Windows `.wslconfig`. +::: + ## Network Limits By default Docker has a limit of 32 networks. If you're running a large number of sites, you'll see a message `Lando has detected you are at Docker's network limit`, after which Lando will attempt to clean up unused networks to put you below the network limit. diff --git a/index.js b/index.js index acb1be0fe..f1ce0ae48 100644 --- a/index.js +++ b/index.js @@ -40,6 +40,9 @@ const defaults = { proxyHttpFallbacks: ['8000', '8080', '8888', '8008'], proxyHttpsFallbacks: ['444', '4433', '4444', '4443'], proxyPassThru: true, + // where the users ide/debugger is listening, this governs what host.lando.internal points at + // one of: "auto" (default), "wsl2", "container" or an explicit ip address + xdebugIdeLocation: 'auto', }, }; @@ -74,6 +77,13 @@ module.exports = async lando => { const platform = lando.config.os.landoPlatform; + // work out what host.lando.internal needs to resolve to, this is mostly a wsl2 concern eg the ide is over on + // windows but the containers are in wsl2 so "host-gateway" only ever gets us as far as the linux side + const hostLandoInternal = require('./utils/get-host-lando-internal')({ + cacheDir: path.join(lando.config.userConfRoot, 'cache'), + ideLocation: lando.config.xdebugIdeLocation, + }); + // ensure some dirs exist before we start _.forEach([binDir, caDir, sshDir], dir => fs.mkdirSync(dir, {recursive: true})); @@ -165,6 +175,7 @@ module.exports = async lando => { caCert, caDomain, caKey, + hostLandoInternal, maxKeyWarning: 10, networkBridge: 'lando_bridge_network', networkLimit: 32, diff --git a/test/get-host-lando-internal.spec.js b/test/get-host-lando-internal.spec.js new file mode 100644 index 000000000..1e6c222be --- /dev/null +++ b/test/get-host-lando-internal.spec.js @@ -0,0 +1,182 @@ +/* + * Tests for get-host-lando-internal. + * @file get-host-lando-internal.spec.js + */ + +'use strict'; + +const chai = require('chai'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const expect = chai.expect; +chai.should(); + +const MODULE = '../utils/get-host-lando-internal'; + +// the sub utils we need to fake in order to exercise the wsl2 branches +const STUBBABLE = { + isDockerDesktop: '../utils/is-docker-desktop', + mode: '../utils/get-wsl-networking-mode', + natIP: '../utils/get-wsl-nat-host-ip', + mirroredIP: '../utils/get-wsl-mirrored-host-ip', + virtioIP: '../utils/get-wsl-virtioproxy-host-ip', + physicalIP: '../utils/get-wsl-physical-ip', + loopback: '../utils/is-wsl-host-loopback-enabled', +}; + +const originalRelease = os.release; + +// stuffs a fake module into the require cache, the resolver requires these lazily so this works +const stub = (id, value) => { + const resolved = require.resolve(id); + require.cache[resolved] = {id: resolved, filename: resolved, loaded: true, exports: () => value}; +}; + +const unstub = id => delete require.cache[require.resolve(id)]; + +// each test gets a throwaway cachedir so the on disk boot-id cache cannot leak between them +const getCacheDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hli-')); + +const getHostLandoInternal = opts => require(MODULE)({cacheDir: getCacheDir(), refresh: true, ...opts}); + +describe('get-host-lando-internal', () => { + afterEach(() => { + os.release = originalRelease; + Object.values(STUBBABLE).forEach(unstub); + }); + + describe('explicit ideLocation', () => { + it('should use an explicit ip address as-is', () => { + const result = getHostLandoInternal({ideLocation: '192.168.1.50'}); + expect(result.extraHost).to.equal('192.168.1.50'); + expect(result.ip).to.equal('192.168.1.50'); + }); + + it('should use loopback when the ide is in the container', () => { + const result = getHostLandoInternal({ideLocation: 'container'}); + expect(result.extraHost).to.equal('127.0.0.1'); + expect(result.ip).to.equal('127.0.0.1'); + }); + }); + + describe('not wsl2', () => { + it('should use host-gateway', () => { + os.release = () => '23.5.0'; + expect(getHostLandoInternal().extraHost).to.equal('host-gateway'); + }); + }); + + describe('wsl2', () => { + beforeEach(() => { + os.release = () => '5.15.153.1-microsoft-standard-WSL2'; + }); + + it('should use host-gateway when the ide is also in wsl2', () => { + const result = getHostLandoInternal({ideLocation: 'wsl2'}); + expect(result.extraHost).to.equal('host-gateway'); + }); + + it('should use host-gateway when docker desktop is in play', () => { + stub(STUBBABLE.isDockerDesktop, true); + expect(getHostLandoInternal().extraHost).to.equal('host-gateway'); + }); + + it('should use the default gateway in nat mode', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'nat'); + stub(STUBBABLE.natIP, '172.28.128.1'); + + const result = getHostLandoInternal(); + expect(result.extraHost).to.equal('172.28.128.1'); + expect(result.mode).to.equal('nat'); + }); + + it('should assume nat when wslinfo is unavailable', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, undefined); + stub(STUBBABLE.natIP, '172.28.128.1'); + + expect(getHostLandoInternal().extraHost).to.equal('172.28.128.1'); + }); + + it('should ask windows for a reachable ip in mirrored mode', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'mirrored'); + stub(STUBBABLE.mirroredIP, '10.1.2.3'); + stub(STUBBABLE.loopback, true); + + const result = getHostLandoInternal(); + expect(result.extraHost).to.equal('10.1.2.3'); + expect(result.message).to.not.contain('hostAddressLoopback'); + }); + + it('should call out a missing hostAddressLoopback in mirrored mode', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'mirrored'); + stub(STUBBABLE.mirroredIP, '10.1.2.3'); + stub(STUBBABLE.loopback, false); + + expect(getHostLandoInternal().message).to.contain('hostAddressLoopback'); + }); + + it('should use the windows reachable ip in bridged mode', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'bridged'); + stub(STUBBABLE.mirroredIP, '10.1.2.3'); + + expect(getHostLandoInternal().extraHost).to.equal('10.1.2.3'); + }); + + it('should use the hyper-v switch in virtioproxy mode', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'virtioproxy'); + stub(STUBBABLE.virtioIP, '172.20.0.1'); + + expect(getHostLandoInternal().extraHost).to.equal('172.20.0.1'); + }); + + it('should fall back to the wsl2 ip when there is no hyper-v switch', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'virtioproxy'); + stub(STUBBABLE.virtioIP, undefined); + stub(STUBBABLE.physicalIP, '10.0.0.42'); + + const result = getHostLandoInternal(); + expect(result.extraHost).to.equal('10.0.0.42'); + expect(result.message).to.contain('xdebugIdeLocation: wsl2'); + }); + + it('should still fall back to host-gateway when networking is disabled', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'none'); + + const result = getHostLandoInternal(); + expect(result.extraHost).to.equal('host-gateway'); + expect(result.mode).to.equal('none'); + }); + + it('should give up when we cannot determine the windows ip', () => { + stub(STUBBABLE.isDockerDesktop, false); + stub(STUBBABLE.mode, 'nat'); + stub(STUBBABLE.natIP, undefined); + + expect(getHostLandoInternal().extraHost).to.be.undefined; + }); + }); +}); + +describe('get-host-lando-internal-hosts', () => { + const getHosts = require('../utils/get-host-lando-internal-hosts'); + + it('should build an extra_hosts entry from the resolved value', () => { + const lando = {config: {hostLandoInternal: {extraHost: '172.28.128.1'}}}; + expect(getHosts(lando)).to.deep.equal(['host.lando.internal:172.28.128.1']); + }); + + it('should build an entry even when there is no resolved extraHost', () => { + const lando = {config: {hostLandoInternal: {message: 'nope'}}}; + expect(getHosts(lando)).to.deep.equal(['host.lando.internal:undefined']); + }); +}); diff --git a/utils/get-host-lando-internal-hosts.js b/utils/get-host-lando-internal-hosts.js new file mode 100644 index 000000000..8c9de4cce --- /dev/null +++ b/utils/get-host-lando-internal-hosts.js @@ -0,0 +1,19 @@ +'use strict'; + +const path = require('path'); + +/* + * Returns the docker compose "extra_hosts" list that maps host.lando.internal at whatever the users machine actually + * lives at, returns an empty list if we could not work that out eg wsl2 with networking disabled + * + * Prefers the value resolved at bootstrap but falls back to resolving it directly, get-host-lando-internal memoizes + * so the fallback is cheap + */ +module.exports = lando => { + const resolved = lando?.config?.hostLandoInternal ?? require('./get-host-lando-internal')({ + cacheDir: lando?.config?.userConfRoot ? path.join(lando.config.userConfRoot, 'cache') : undefined, + ideLocation: lando?.config?.xdebugIdeLocation, + }); + + return [`host.lando.internal:${resolved.extraHost}`]; +}; diff --git a/utils/get-host-lando-internal.js b/utils/get-host-lando-internal.js new file mode 100644 index 000000000..3390f2f22 --- /dev/null +++ b/utils/get-host-lando-internal.js @@ -0,0 +1,143 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const {isIP} = require('net'); + +const Cache = require('../lib/cache'); +const debug = require('debug')('@lando/host-lando-internal'); + +// the cache is invalidated on this so we only pay for the expensive powershell lookups once per wsl boot +const getBootID = () => { + try { + return fs.readFileSync('/proc/sys/kernel/random/boot_id', {encoding: 'utf-8'}).trim(); + } catch { + return 'unknown'; + } +}; + +/* + * Works out what "host.lando.internal" needs to point at so that things inside a container can reach things that are + * listening on the users machine eg an xdebug listener in their ide + * + * This is a port of how ddev handles the same problem + * @see https://github.com/ddev/ddev/blob/master/pkg/dockerutil/host_docker_internal.go + * + * @param {String} ideLocation one of "wsl2", "container", an ip address or falsy for autodetection + * @return {Object} {extraHost, ip, mode, message} where extraHost is what to feed docker compose extra_hosts + */ +const resolve = ideLocation => { + // 1. the user has told us exactly where to go + if (isIP(ideLocation)) { + return {extraHost: ideLocation, ip: ideLocation, message: `ideLocation is the ip ${ideLocation}`}; + } + + // 2. the ide is listening inside the container itself eg a vscode language server style setup + if (ideLocation === 'container') { + return {extraHost: '127.0.0.1', ip: '127.0.0.1', message: 'ideLocation is "container"'}; + } + + // 3. everywhere that is not wsl2, host-gateway does the right thing on linux, docker desktop and colima + if (!os.release().toLowerCase().includes('microsoft')) { + return {extraHost: 'host-gateway', message: 'not wsl2 so host-gateway is fine'}; + } + + // 4. the ide is running inside wsl2 as well, that is just normal linux behavior + if (ideLocation === 'wsl2') { + return {extraHost: 'host-gateway', mode: 'wsl2', message: 'ideLocation is "wsl2" so host-gateway is fine'}; + } + + // 5. docker desktop proxies host-gateway through to the windows host for us so there is nothing to do + if (require('./is-docker-desktop')('wsl')) { + return {extraHost: 'host-gateway', mode: 'wsl2', message: 'wsl2 with docker desktop so host-gateway is fine'}; + } + + // from here on out we assume docker is running _inside_ wsl2 and the ide is on windows, host-gateway would only + // ever get us as far as the linux side of the fence so we need to find the windows host ourselves + + // @NOTE: wslinfo does not exist on older wsl releases and nat was the only mode back then + const mode = require('./get-wsl-networking-mode')() ?? 'nat'; + + switch (mode) { + // no network bridge at all, there is no internet and no path to windows, nothing we can do here + case 'none': + return {extraHost: 'host-gateway', mode, message: 'wsl2 networkingMode=none, there is no network path to the windows host'}; + + // wsl2 shares the windows network namespace so ask windows for its own reachable address + // @NOTE: we also send bridged down this path, in bridged mode the default gateway is the physical router rather + // than windows so the nat lookup below would be flat out wrong + case 'mirrored': + case 'bridged': { + const ip = require('./get-wsl-mirrored-host-ip')(); + if (!ip) { + return { + extraHost: 'host-gateway', + mode, + message: `wsl2 networkingMode=${mode} but we could not determine the windows host ip`, + }; + } + + const loopback = mode === 'mirrored' && !require('./is-wsl-host-loopback-enabled')(); + const caveat = loopback ? ', note that hostAddressLoopback=true is NOT set in .wslconfig' : ''; + return {extraHost: ip, ip, mode, message: `wsl2 networkingMode=${mode} windows host is ${ip}${caveat}`}; + } + + // the default gateway is the network router so we need the hyper-v virtual switch that joins windows to wsl2 + case 'virtioproxy': { + const ip = require('./get-wsl-virtioproxy-host-ip')(); + if (ip) return {extraHost: ip, ip, mode, message: `wsl2 networkingMode=virtioproxy windows host is ${ip}`}; + + // no hyper-v virtual switch, common on arm64 windows. fall back to wsl2s own ip so that host.lando.internal at + // least resolves, that gets an ide running inside wsl2 working but never one on windows + const fallback = require('./get-wsl-physical-ip')(); + const message = 'wsl2 networkingMode=virtioproxy but there is no "vEthernet (WSL)" hyper-v switch, a windows ' + + 'side ide cannot be reached. run your ide inside wsl2 instead and set xdebugIdeLocation: wsl2'; + return fallback ? {extraHost: fallback, ip: fallback, mode, message} : {mode, message}; + } + + // nat, the default. the wsl2 default gateway _is_ the windows host eg the "vEthernet (WSL)" adapter + default: { + const ip = require('./get-wsl-nat-host-ip')(); + if (!ip) return {mode, message: 'wsl2 networkingMode=nat but we could not determine the windows host ip'}; + return {extraHost: ip, ip, mode, message: `wsl2 networkingMode=nat windows host is ${ip}`}; + } + } +}; + +/* + * @param {Object} [opts] + * @param {Object} [opts.cache] a lando cache eg lando.cache, one is created from cacheDir if not passed in + * @param {String} [opts.cacheDir] where to put the file cache if we need to make our own cache + * @param {String} [opts.ideLocation] one of "wsl2", "container", an ip address or falsy for autodetection + * @param {Boolean} [opts.refresh] ignore whatever is cached and resolve again + * @return {Object} see resolve() above + */ +module.exports = ({cache, cacheDir = path.join(os.homedir(), '.lando', 'cache'), ideLocation, refresh = false} = {}) => { + // normalize a few falsy-ish things the user might put in their config + if (!ideLocation || ideLocation === 'auto') ideLocation = undefined; + + // resolution is free outside of wsl2 so there is nothing worth caching + if (!os.release().toLowerCase().includes('microsoft') || isIP(ideLocation) || ideLocation === 'container') { + return resolve(ideLocation); + } + + // otherwise we cache because we would otherwise pay for a powershell spawn on every single lando command + cache = cache ?? new Cache({cacheDir}); + const key = `_.host-lando-internal.${ideLocation ?? 'auto'}`; + + // the cache is invalidated by boot id because the windows host ip can only really change when the wsl2 vm restarts + const bootID = getBootID(); + const cached = refresh ? undefined : cache.get(key); + if (cached && cached.bootID === bootID) { + debug('using cached %o', cached.result); + return cached.result; + } + + const result = resolve(ideLocation); + debug('resolved host.lando.internal to %o because %s', result.extraHost, result.message); + cache.set(key, {bootID, result}, {persist: true}); + + return result; +}; diff --git a/utils/get-win32-ip-from-wsl.js b/utils/get-win32-ip-from-wsl.js new file mode 100644 index 000000000..ea5182ede --- /dev/null +++ b/utils/get-win32-ip-from-wsl.js @@ -0,0 +1,23 @@ +'use strict'; + +const {isIP} = require('net'); + +const stringer = require('./spawn-sync-stringer'); + +/* + * Runs a powershell script that is expected to print a single ipv4 address and returns it + * + * Requires wsl interop eg the ability to invoke powershell.exe from inside the distro + */ +module.exports = script => { + try { + const args = ['-NoProfile', '-NonInteractive', '-Command', script]; + const {status, stdout} = stringer('powershell.exe', args, {encoding: 'utf-8'}); + if (status !== 0) return undefined; + + const ip = stdout.split('\n')[0].trim(); + return isIP(ip) === 4 ? ip : undefined; + } catch { + return undefined; + } +}; diff --git a/utils/get-wsl-mirrored-host-ip.js b/utils/get-wsl-mirrored-host-ip.js new file mode 100644 index 000000000..f8eb196ec --- /dev/null +++ b/utils/get-wsl-mirrored-host-ip.js @@ -0,0 +1,36 @@ +'use strict'; + +const getWinIP = require('./get-win32-ip-from-wsl'); + +/* + * In mirrored mode the wsl2 vm shares the windows network namespace so there is no gateway that points at windows, + * instead we ask windows which of its own addresses is the one a container should be able to reach + * + * Rather than scanning every interface we + * + * 1. find the best default route (0.0.0.0/0) by RouteMetric/InterfaceMetric + * 2. take that routes InterfaceIndex + * 3. return the first non link local, non loopback ipv4 on that interface + * + * @NOTE: this also requires hostAddressLoopback=true in the users .wslconfig, see is-wsl-host-loopback-enabled + */ +const SCRIPT = ` +$bestRoute = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | + Where-Object { $_.NextHop -ne '0.0.0.0' } | + Sort-Object -Property RouteMetric, InterfaceMetric | + Select-Object -First 1 + +if (-not $bestRoute) { + return +} + +Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex $bestRoute.InterfaceIndex -ErrorAction SilentlyContinue | + Where-Object { + $_.IPAddress -and + $_.IPAddress -notlike '169.254*' -and + $_.IPAddress -ne '127.0.0.1' + } | + Select-Object -First 1 -ExpandProperty IPAddress +`; + +module.exports = () => getWinIP(SCRIPT); diff --git a/utils/get-wsl-nat-host-ip.js b/utils/get-wsl-nat-host-ip.js new file mode 100644 index 000000000..0659f40da --- /dev/null +++ b/utils/get-wsl-nat-host-ip.js @@ -0,0 +1,26 @@ +'use strict'; + +const {isIP} = require('net'); + +const stringer = require('./spawn-sync-stringer'); + +/* + * Returns the windows host ip as seen from inside a wsl2 vm running in "nat" networking mode + * + * In nat mode the default gateway of the wsl2 vm _is_ the windows host eg the ip of the "vEthernet (WSL)" adapter + * on the windows side, this is the address a container needs in order to reach something listening on windows + * + * @see https://learn.microsoft.com/en-us/windows/wsl/networking#accessing-windows-networking-apps-from-linux-host-ip + */ +module.exports = () => { + try { + const {status, stdout} = stringer('ip', ['-4', 'route', 'show', 'default'], {encoding: 'utf-8'}); + if (status !== 0) return undefined; + + // output looks like "default via 172.28.128.1 dev eth0 proto kernel" + const ip = stdout.split(/\s+/)[2]; + return isIP(ip) === 4 ? ip : undefined; + } catch { + return undefined; + } +}; diff --git a/utils/get-wsl-networking-mode.js b/utils/get-wsl-networking-mode.js new file mode 100644 index 000000000..f97f25392 --- /dev/null +++ b/utils/get-wsl-networking-mode.js @@ -0,0 +1,25 @@ +'use strict'; + +const stringer = require('./spawn-sync-stringer'); + +// the modes wsl currently knows about +// @see https://learn.microsoft.com/en-us/windows/wsl/wsl-config#configuration-settings-for-wslconfig +const MODES = ['nat', 'mirrored', 'virtioproxy', 'none', 'bridged']; + +/* + * Returns the networking mode of the wsl2 vm we are running in eg nat|mirrored|virtioproxy|none|bridged + * + * Returns undefined if we cannot determine it, note that `wslinfo` only exists on newer wsl releases so on older + * ones the caller should assume "nat" because that was the only option back then + */ +module.exports = () => { + try { + const {status, stdout} = stringer('wslinfo', ['--networking-mode'], {encoding: 'utf-8'}); + if (status !== 0) return undefined; + + const mode = stdout.toLowerCase(); + return MODES.includes(mode) ? mode : undefined; + } catch { + return undefined; + } +}; diff --git a/utils/get-wsl-physical-ip.js b/utils/get-wsl-physical-ip.js new file mode 100644 index 000000000..ec1f5ac9d --- /dev/null +++ b/utils/get-wsl-physical-ip.js @@ -0,0 +1,24 @@ +'use strict'; + +const {isIP} = require('net'); + +const stringer = require('./spawn-sync-stringer'); + +/* + * Returns the wsl2 vms _own_ physical ip eg the source address it uses for outbound traffic + * + * This is only useful as a fallback, it gets containers to the wsl2 side of things but never to windows + */ +module.exports = () => { + try { + const {status, stdout} = stringer('ip', ['-4', 'route', 'get', '1.1.1.1'], {encoding: 'utf-8'}); + if (status !== 0) return undefined; + + // output looks like "1.1.1.1 via 10.0.0.1 dev eth0 src 10.0.0.42 uid 1000" + const parts = stdout.split(/\s+/); + const ip = parts[parts.indexOf('src') + 1]; + return isIP(ip) === 4 ? ip : undefined; + } catch { + return undefined; + } +}; diff --git a/utils/get-wsl-virtioproxy-host-ip.js b/utils/get-wsl-virtioproxy-host-ip.js new file mode 100644 index 000000000..39f3417bc --- /dev/null +++ b/utils/get-wsl-virtioproxy-host-ip.js @@ -0,0 +1,16 @@ +'use strict'; + +const getWinIP = require('./get-win32-ip-from-wsl'); + +/* + * In virtioproxy mode the default gateway is the actual network router and not the windows host so we need the ip of + * the "vEthernet (WSL)" hyper-v virtual switch instead + * + * @NOTE: this switch does not exist on every machine eg arm64 windows, in that case this returns undefined and a + * windows side ide simply is not reachable + */ +const SCRIPT = `Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.InterfaceAlias -like 'vEthernet (WSL*' } | + Select-Object -First 1 -ExpandProperty IPAddress`; + +module.exports = () => getWinIP(SCRIPT); diff --git a/utils/is-docker-desktop.js b/utils/is-docker-desktop.js new file mode 100644 index 000000000..84ef9477f --- /dev/null +++ b/utils/is-docker-desktop.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require('fs'); + +const stringer = require('./spawn-sync-stringer'); + +// docker desktops wsl2 integration always mounts itself here, if this is missing we definitely are not on it +const DD_WSL_MOUNT = '/mnt/wsl/docker-desktop'; + +/* + * Returns true if the build engine we are talking to is docker desktop + * + * This matters on wsl2 because docker desktop proxies "host-gateway" all the way through to the windows host while a + * docker-ce running inside the distro only ever gets you to the linux side + */ +module.exports = (platform = process.landoPlatform ?? process.platform) => { + // cheap and definitive negative on wsl + if (platform === 'wsl' && !fs.existsSync(DD_WSL_MOUNT)) return false; + + // otherwise just ask the daemon + try { + const docker = require('./get-docker-x')(); + if (!docker) return false; + + const {status, stdout} = stringer(docker, ['info', '--format', '{{.OperatingSystem}}'], {encoding: 'utf-8'}); + if (status !== 0) return false; + + return stdout.toLowerCase().includes('docker desktop'); + } catch { + return false; + } +}; diff --git a/utils/is-wsl-host-loopback-enabled.js b/utils/is-wsl-host-loopback-enabled.js new file mode 100644 index 000000000..a68b7feb6 --- /dev/null +++ b/utils/is-wsl-host-loopback-enabled.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs'); + +const getWinEnvar = require('./get-win32-envvar-from-wsl'); +const wslpath = require('./winpath-2-wslpath'); + +/* + * Locates the users windows side .wslconfig, returns undefined if we cannot find it + */ +const getWslConfigPath = () => { + try { + const userProfile = process.env.USERPROFILE ?? getWinEnvar('USERPROFILE'); + if (!userProfile) return undefined; + + const configPath = `${wslpath(userProfile)}/.wslconfig`; + return fs.existsSync(configPath) ? configPath : undefined; + } catch { + return undefined; + } +}; + +/* + * Returns true if hostAddressLoopback=true is set under [experimental] in the users .wslconfig + * + * Mirrored networking mode needs this in order for anything inside wsl2 to be able to connect back to a listener on + * the windows host + */ +module.exports = () => { + const configPath = getWslConfigPath(); + if (!configPath) return false; + + let contents; + try { + contents = fs.readFileSync(configPath, {encoding: 'utf-8'}); + } catch { + return false; + } + + let experimental = false; + + for (const raw of contents.split('\n')) { + const line = raw.trim().replace(/\r$/, ''); + + // skip comments and empties + if (line === '' || line.startsWith('#') || line.startsWith(';')) continue; + + // track sections + if (line.startsWith('[') && line.endsWith(']')) { + experimental = line.slice(1, -1).trim().toLowerCase() === 'experimental'; + continue; + } + + if (!experimental) continue; + + const [key, value] = line.split('='); + if (typeof value !== 'string') continue; + if (key.trim().toLowerCase() === 'hostaddressloopback' && value.trim().toLowerCase() === 'true') return true; + } + + return false; +}; From 9d362bdaa5dbac41300a6a11c3f56ba5c2fad7ce Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Sun, 2 Aug 2026 23:52:31 +0200 Subject: [PATCH 51/53] feat(database): Add db shortcut and import/export commands to the core as the scripts reside here anyway and this is also useful for non-recipe using apps --- app.js | 5 ++ docs/.vitepress/config.mjs | 3 + docs/cli/db-export.md | 50 +++++++++++++++++ docs/cli/db-import.md | 52 +++++++++++++++++ docs/cli/db.md | 44 +++++++++++++++ hooks/app-add-db-tooling.js | 23 ++++++++ hooks/app-purge-compose-cache.js | 3 + lib/app.js | 1 + scripts/sql-cli.sh | 66 ++++++++++++++++++++++ test/app-add-db-tooling.spec.js | 68 +++++++++++++++++++++++ test/get-core-tooling-additions.spec.js | 40 +++++++++++++ test/get-db-services.spec.js | 69 +++++++++++++++++++++++ test/get-db-tooling-defaults.spec.js | 66 ++++++++++++++++++++++ test/get-service-images.spec.js | 42 ++++++++++++++ test/parse-tooling-config.spec.js | 52 +++++++++++++++++ utils/get-core-tooling-additions.js | 15 +++++ utils/get-db-services.js | 38 +++++++++++++ utils/get-db-tooling-defaults.js | 74 +++++++++++++++++++++++++ utils/get-service-images.js | 16 ++++++ utils/get-tasks.js | 40 +++++++------ utils/parse-tooling-config.js | 14 +++-- 21 files changed, 760 insertions(+), 21 deletions(-) create mode 100644 docs/cli/db-export.md create mode 100644 docs/cli/db-import.md create mode 100644 docs/cli/db.md create mode 100644 hooks/app-add-db-tooling.js create mode 100755 scripts/sql-cli.sh create mode 100644 test/app-add-db-tooling.spec.js create mode 100644 test/get-core-tooling-additions.spec.js create mode 100644 test/get-db-services.spec.js create mode 100644 test/get-db-tooling-defaults.spec.js create mode 100644 test/get-service-images.spec.js create mode 100644 test/parse-tooling-config.spec.js create mode 100644 utils/get-core-tooling-additions.js create mode 100644 utils/get-db-services.js create mode 100644 utils/get-db-tooling-defaults.js create mode 100644 utils/get-service-images.js diff --git a/app.js b/app.js index 28c07d3ca..2d496f45d 100644 --- a/app.js +++ b/app.js @@ -29,6 +29,7 @@ module.exports = async (app, lando) => { hostLandoInternal: _.get(lando, 'config.hostLandoInternal.extraHost', null), info: _.cloneDeep(app.info).map(service => ({...service, hostname: [], urls: []})), name: app.name, + coreTooling: app._coreDbTooling, overrides: { tooling: app._coreToolingOverrides, }, @@ -63,6 +64,7 @@ module.exports = async (app, lando) => { project: app.project, root: app.root, sapis: require('./utils/get-service-apis')(app), + coreTooling: app._coreDbTooling, overrides: { tooling: app._coreToolingOverrides, }, @@ -101,6 +103,9 @@ module.exports = async (app, lando) => { // add in hostname app.events.on('post-init', 1, async () => await require('./hooks/app-add-hostnames')(app, lando)); + // add default database tooling eg db-import, db-export, note that this needs to run before app-add-tooling + app.events.on('post-init', 2, async () => await require('./hooks/app-add-db-tooling')(app, lando)); + // run v3 build steps app.events.on('post-init', async () => await require('./hooks/app-run-v3-build-steps')(app, lando)); diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 4b3aea3d9..6b343164e 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -206,6 +206,9 @@ function cliBar() { collapsed: false, items: [ {text: 'lando config', link: '/cli/config'}, + {text: 'lando db NEW!', link: '/cli/db'}, + {text: 'lando db-export NEW!', link: '/cli/db-export'}, + {text: 'lando db-import NEW!', link: '/cli/db-import'}, {text: 'lando destroy', link: '/cli/destroy'}, {text: 'lando exec NEW!', link: '/cli/exec'}, {text: 'lando info', link: '/cli/info'}, diff --git a/docs/cli/db-export.md b/docs/cli/db-export.md new file mode 100644 index 000000000..f88998ea4 --- /dev/null +++ b/docs/cli/db-export.md @@ -0,0 +1,50 @@ +--- +title: lando db-export +description: lando db-export exports a database from a database service to a file +--- + +# lando db-export + +Exports a database from a database service to a file. + +This command is provided automatically for any app that has a service that looks like a database. That means a +service whose type is a known database eg `mysql`, `mariadb` or `postgres`, or a service literally called +`database`. If your recipe or Landofile already declares its own `db-export` then that one is used instead. + +## Usage + +```sh +lando db-export [file] [--host ] [--stdout] +``` + +## Arguments + +```sh +file The file to dump to, defaults to a timestamped file in the current directory +``` + +### Options + +```sh +--host, -h The database service to use [default: "database"] +--stdout Dump database to stdout +``` + +## Examples + +```sh +# Dump the default database service to a timestamped file +lando db-export + +# Dump to a specific file +lando db-export dump.sql + +# Dump a specific database service to stdout +lando db-export --host mydb --stdout +``` + +::: warning File paths are resolved inside the container! +A relative path is resolved against the working directory *inside* the service, which is normally your app mount +eg `/app`. If a service has its app mount disabled then the dump lands somewhere inside the container and is lost +on the next rebuild, so pass a path that is bind mounted from your host. +::: diff --git a/docs/cli/db-import.md b/docs/cli/db-import.md new file mode 100644 index 000000000..d4ea89592 --- /dev/null +++ b/docs/cli/db-import.md @@ -0,0 +1,52 @@ +--- +title: lando db-import +description: lando db-import imports a dump file into a database service +--- + +# lando db-import + +Imports a dump file into a database service. + +This command is provided automatically for any app that has a service that looks like a database. That means a +service whose type is a known database eg `mysql`, `mariadb` or `postgres`, or a service literally called +`database`. If your recipe or Landofile already declares its own `db-import` then that one is used instead. + +## Usage + +```sh +lando db-import [--host ] [--no-wipe] +``` + +## Arguments + +```sh +file The dump file to import +``` + +### Options + +```sh +--host, -h The database service to use [default: "database"] +--no-wipe Do not destroy the existing database before an import [boolean] +``` + +## Examples + +```sh +# Import a dump into the default database service +lando db-import dump.sql + +# Import a gzipped dump without wiping the database first +lando db-import dump.sql.gz --no-wipe + +# Import into a specific database service +lando db-import dump.sql --host mydb + +# Import from stdin +cat dump.sql | lando db-import +``` + +::: warning File paths are resolved inside the container! +A relative path is resolved against the working directory *inside* the service, which is normally your app mount +eg `/app`. If a service has its app mount disabled then only paths that exist inside that container will work. +::: diff --git a/docs/cli/db.md b/docs/cli/db.md new file mode 100644 index 000000000..6de0dfa5c --- /dev/null +++ b/docs/cli/db.md @@ -0,0 +1,44 @@ +--- +title: lando db +description: lando db drops into a database shell on a database service +--- + +# lando db + +Drops into a database shell on a database service. + +`lando db` works out which client to use at runtime by inspecting the service, so it does the right thing whether +the service is MySQL, MariaDB or PostgreSQL. If Lando can determine the flavor of your database service up front +it will *also* give you the conventional alias for it eg `lando mysql`, `lando mariadb` or `lando psql`. + +This command is provided automatically for any app that has a service that looks like a database. That means a +service whose type is a known database eg `mysql`, `mariadb` or `postgres`, or a service literally called +`database`. If your recipe or Landofile already declares its own then that one is used instead. + +## Usage + +```sh +lando db [--host ] +``` + +### Options + +```sh +--host, -h The database service to use [default: "database"] +``` + +## Examples + +```sh +# Drop into a shell on the default database service +lando db + +# Same thing, if Lando could detect the flavor +lando mysql + +# Drop into a shell on a specific database service +lando db --host mydb + +# Run a single query +lando db -- -e "SELECT 1" +``` diff --git a/hooks/app-add-db-tooling.js b/hooks/app-add-db-tooling.js new file mode 100644 index 000000000..c24304d49 --- /dev/null +++ b/hooks/app-add-db-tooling.js @@ -0,0 +1,23 @@ +'use strict'; + +const _ = require('lodash'); + +module.exports = async app => { + // bail if we cannot find anything that looks like a database + const images = require('../utils/get-service-images')(_.get(app, 'composeData', [])); + const services = require('../utils/get-db-services')(_.get(app, 'info', []), images); + if (_.isEmpty(services)) return; + + // prefer a service actually called "database" to preserve the historical recipe default + const host = _.find(services, {service: 'database'}) ? 'database' : _.first(services).service; + const defaults = require('../utils/get-db-tooling-defaults')(services, host); + + // only add tooling the user or their recipe has not already claimed + const additions = require('../utils/get-core-tooling-additions')(defaults, _.get(app, 'config.tooling', {})); + if (_.isEmpty(additions)) return; + + // stash these so we can persist them into the compose cache and use them on the faster "engine" bootstrap path + app._coreDbTooling = additions; + app.config.tooling = _.merge({}, additions, _.get(app, 'config.tooling', {})); + app.log.verbose('added default database tooling %o', _.keys(additions)); +}; diff --git a/hooks/app-purge-compose-cache.js b/hooks/app-purge-compose-cache.js index be88f7d57..415b2e166 100644 --- a/hooks/app-purge-compose-cache.js +++ b/hooks/app-purge-compose-cache.js @@ -11,6 +11,9 @@ module.exports = async (app, lando) => { // reset tooling overrides app._coreToolingOverrides = {}; + // reset core provided database tooling + app._coreDbTooling = {}; + // remove compose cache danglerz, note that the combined file is generated and not a dangler const combined = require('../utils/dump-compose-config').combinedFile; fs.readdirSync(app._dir) diff --git a/lib/app.js b/lib/app.js index 1619eb582..cd2f0a307 100644 --- a/lib/app.js +++ b/lib/app.js @@ -69,6 +69,7 @@ module.exports = class App { this._lando = lando; this._name = name; this._coreToolingOverrides = {}; + this._coreDbTooling = {}; this.debuggy = lando.debuggy; /** diff --git a/scripts/sql-cli.sh b/scripts/sql-cli.sh new file mode 100755 index 000000000..52452823a --- /dev/null +++ b/scripts/sql-cli.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -e + +# Get the lando logger +. /helpers/log.sh + +# Set the module +LANDO_MODULE="sqlcli" + +# Set generic config +HOST=localhost + +# Get type-specific config +if [[ ${POSTGRES_DB} != '' ]]; then + DATABASE=${POSTGRES_DB:-database} + PORT=${LANDO_DB_CLI_PORT:-5432} + USER=${LANDO_DB_CLI_USER:-${POSTGRES_USER:-postgres}} +else + DATABASE=${MYSQL_DATABASE:-database} + PORT=${LANDO_DB_CLI_PORT:-3306} + USER=${LANDO_DB_CLI_USER:-root} +fi + +# PARSE THE ARGZZ +# The --host option is handled by landos built in dynamic service resolution so we just need to drop it here, +# everything else gets passed straight through to the underlying client +ARGS=() +while (( "$#" )); do + case "$1" in + -h|--host) + shift 2 + ;; + --host=*) + shift + ;; + --) + shift + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +# Build DB specific connection command +if [[ ${POSTGRES_DB} != '' ]]; then + if ! command -v psql >/dev/null 2>&1; then + lando_red "Could not find a psql client on service ${LANDO_SERVICE_NAME}!" + exit 1 + fi + exec psql "postgresql://${USER}@${HOST}:${PORT}/${DATABASE}" ${LANDO_EXTRA_DB_CLI_ARGS} "${ARGS[@]}" +fi + +# Newer MariaDB images have dropped the mysql symlink so prefer the mariadb client if mysql is not around +CLIENT=mysql +if ! command -v mysql >/dev/null 2>&1 && command -v mariadb >/dev/null 2>&1; then + CLIENT=mariadb +fi + +if ! command -v "$CLIENT" >/dev/null 2>&1; then + lando_red "Could not find a mysql or mariadb client on service ${LANDO_SERVICE_NAME}!" + exit 1 +fi + +exec "$CLIENT" -h "$HOST" -P "$PORT" -u "$USER" ${LANDO_EXTRA_DB_CLI_ARGS} "$DATABASE" "${ARGS[@]}" diff --git a/test/app-add-db-tooling.spec.js b/test/app-add-db-tooling.spec.js new file mode 100644 index 000000000..23fbab6e7 --- /dev/null +++ b/test/app-add-db-tooling.spec.js @@ -0,0 +1,68 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const addDbTooling = require('../hooks/app-add-db-tooling'); + +// minimal app stub +const getApp = (info = [], tooling = {}) => ({ + info, + config: {tooling}, + _coreDbTooling: {}, + log: {verbose: () => {}}, +}); + +describe('app-add-db-tooling', function() { + it('should do nothing if there is no database service', async function() { + const app = getApp([{service: 'appserver', type: 'php'}]); + await addDbTooling(app); + expect(app.config.tooling).to.deep.equal({}); + expect(app._coreDbTooling).to.deep.equal({}); + }); + + it('should add default tooling when it finds a database', async function() { + const app = getApp([{service: 'database', type: 'mysql'}]); + await addDbTooling(app); + expect(app.config.tooling).to.have.property('db-import '); + expect(app.config.tooling).to.have.property('db-export [file]'); + expect(app.config.tooling).to.have.property('mysql'); + expect(app._coreDbTooling).to.have.property('db-import '); + }); + + it('should default the host to the found service when it is not called "database"', async function() { + const app = getApp([{service: 'mydb', type: 'postgres'}]); + await addDbTooling(app); + expect(app.config.tooling['db-import '].options.host.default).to.equal('mydb'); + expect(app.config.tooling).to.have.property('psql'); + }); + + it('should prefer a service called "database" as the default host', async function() { + const app = getApp([{service: 'mydb', type: 'mysql'}, {service: 'database', type: 'mysql'}]); + await addDbTooling(app); + expect(app.config.tooling['db-import '].options.host.default).to.equal('database'); + }); + + it('should not clobber tooling the user or recipe already declared', async function() { + const existing = {'db-import ': {service: 'database', cmd: 'my-importer'}}; + const app = getApp([{service: 'database', type: 'mysql'}], existing); + await addDbTooling(app); + expect(app.config.tooling['db-import '].cmd).to.equal('my-importer'); + expect(app._coreDbTooling).to.not.have.property('db-import '); + }); + + it('should match claimed tooling on id and not on key', async function() { + // a user declaring a plain "db-import" should not end up with two of them + const app = getApp([{service: 'database', type: 'mysql'}], {'db-import': {service: 'database', cmd: 'nope'}}); + await addDbTooling(app); + expect(app.config.tooling).to.not.have.property('db-import '); + expect(app.config.tooling).to.have.property('db-import'); + }); + + it('should respect tooling that has been disabled', async function() { + const app = getApp([{service: 'database', type: 'mysql'}], {mysql: false}); + await addDbTooling(app); + expect(app.config.tooling.mysql).to.equal(false); + expect(app._coreDbTooling).to.not.have.property('mysql'); + }); +}); diff --git a/test/get-core-tooling-additions.spec.js b/test/get-core-tooling-additions.spec.js new file mode 100644 index 000000000..d5edbde79 --- /dev/null +++ b/test/get-core-tooling-additions.spec.js @@ -0,0 +1,40 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const getAdditions = require('../utils/get-core-tooling-additions'); + +describe('get-core-tooling-additions', function() { + const core = {'db-import ': {cmd: 'core-importer'}, 'db': {cmd: 'core-shell'}}; + + it('should return everything if nothing is claimed', function() { + expect(getAdditions(core, {})).to.deep.equal(core); + expect(getAdditions(core)).to.deep.equal(core); + }); + + it('should handle being given nothing', function() { + expect(getAdditions()).to.deep.equal({}); + }); + + it('should drop commands claimed under the same key', function() { + const additions = getAdditions(core, {'db-import ': {cmd: 'mine'}}); + expect(additions).to.not.have.property('db-import '); + expect(additions).to.have.property('db'); + }); + + it('should drop commands claimed under a different key but the same id', function() { + const additions = getAdditions(core, {'db-import': {cmd: 'mine'}}); + expect(additions).to.not.have.property('db-import '); + }); + + it('should drop commands the user has disabled', function() { + expect(getAdditions(core, {db: false})).to.not.have.property('db'); + }); + + it('should never deep merge into a claimed command', function() { + const tooling = {'db-import ': {cmd: 'mine'}}; + const merged = Object.assign({}, getAdditions(core, tooling), tooling); + expect(merged['db-import ']).to.deep.equal({cmd: 'mine'}); + }); +}); diff --git a/test/get-db-services.spec.js b/test/get-db-services.spec.js new file mode 100644 index 000000000..0f9931b26 --- /dev/null +++ b/test/get-db-services.spec.js @@ -0,0 +1,69 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const getDbServices = require('../utils/get-db-services'); +const {getFlavor} = getDbServices; + +describe('get-db-services', function() { + it('should return an empty array if there is no info', function() { + expect(getDbServices()).to.deep.equal([]); + expect(getDbServices([])).to.deep.equal([]); + }); + + it('should ignore services that are not databases', function() { + const info = [{service: 'appserver', type: 'php'}, {service: 'cache', type: 'redis'}]; + expect(getDbServices(info)).to.deep.equal([]); + }); + + it('should detect known database types', function() { + const info = [ + {service: 'appserver', type: 'php'}, + {service: 'db1', type: 'mysql'}, + {service: 'db2', type: 'mariadb'}, + {service: 'db3', type: 'postgres'}, + ]; + expect(getDbServices(info).map(service => service.service)).to.deep.equal(['db1', 'db2', 'db3']); + }); + + it('should detect prefixed and versioned database types', function() { + const info = [ + {service: 'db1', type: 'pantheon-mariadb'}, + {service: 'db2', type: 'mysql:8.0'}, + {service: 'db3', type: 'lagoon_postgres'}, + ]; + expect(getDbServices(info).map(service => service.flavor)).to.deep.equal(['mariadb', 'mysql', 'postgres']); + }); + + it('should detect a service called "database" even if its flavor is unknown', function() { + const info = [{service: 'database', type: 'lando-compose'}]; + expect(getDbServices(info)).to.deep.equal([{service: 'database', type: 'lando-compose', flavor: undefined}]); + }); + + it('should fall back to sniffing the service image', function() { + const info = [{service: 'db1', type: 'lando'}, {service: 'cache', type: 'lando'}]; + const images = {db1: 'mariadb:10.4', cache: 'redis:7'}; + expect(getDbServices(info, images)).to.deep.equal([{service: 'db1', type: 'lando', flavor: 'mariadb'}]); + }); + + it('should prefer the type over the image when sniffing', function() { + const info = [{service: 'db1', type: 'postgres'}]; + expect(getDbServices(info, {db1: 'mariadb:10.4'})[0].flavor).to.equal('postgres'); + }); + + it('should handle registries and tags in image names', function() { + expect(getFlavor('bitnami/postgresql:15')).to.equal('postgres'); + expect(getFlavor('mysql:8.0')).to.equal('mysql'); + expect(getFlavor('devwithlando/php:8.3-fpm-2')).to.equal(undefined); + expect(getFlavor('nginx:1.22.1')).to.equal(undefined); + }); + + it('should not confuse similarly named types', function() { + expect(getFlavor('mysql-proxy-thing')).to.equal('mysql'); + expect(getFlavor('mysqlish')).to.equal(undefined); + expect(getFlavor('notmariadb')).to.equal(undefined); + expect(getFlavor(undefined)).to.equal(undefined); + expect(getFlavor(42)).to.equal(undefined); + }); +}); diff --git a/test/get-db-tooling-defaults.spec.js b/test/get-db-tooling-defaults.spec.js new file mode 100644 index 000000000..c5b35afd2 --- /dev/null +++ b/test/get-db-tooling-defaults.spec.js @@ -0,0 +1,66 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const getDbToolingDefaults = require('../utils/get-db-tooling-defaults'); + +describe('get-db-tooling-defaults', function() { + it('should always provide import, export and a generic shell', function() { + const tooling = getDbToolingDefaults(); + expect(tooling).to.have.property('db-import '); + expect(tooling).to.have.property('db-export [file]'); + expect(tooling).to.have.property('db'); + }); + + it('should point at the scripts core mounts into every api 3 service', function() { + const tooling = getDbToolingDefaults(); + expect(tooling['db-import '].cmd).to.equal('/helpers/sql-import.sh'); + expect(tooling['db-export [file]'].cmd).to.equal('/helpers/sql-export.sh'); + expect(tooling['db'].cmd).to.equal('/helpers/sql-cli.sh'); + }); + + it('should run import and export as root', function() { + const tooling = getDbToolingDefaults(); + expect(tooling['db-import '].user).to.equal('root'); + expect(tooling['db-export [file]'].user).to.equal('root'); + }); + + it('should use a dynamic service that defaults to "database"', function() { + const tooling = getDbToolingDefaults(); + expect(tooling['db-import '].service).to.equal(':host'); + expect(tooling['db-import '].options.host.default).to.equal('database'); + }); + + it('should honor a different default host', function() { + const tooling = getDbToolingDefaults([{service: 'mydb', flavor: 'mysql'}], 'mydb'); + expect(tooling['db-import '].options.host.default).to.equal('mydb'); + expect(tooling['db-export [file]'].options.host.default).to.equal('mydb'); + expect(tooling['mysql'].options.host.default).to.equal('mydb'); + }); + + it('should add a flavor specific shell for each detected flavor', function() { + const tooling = getDbToolingDefaults([ + {service: 'db1', flavor: 'mariadb'}, + {service: 'db2', flavor: 'postgres'}, + ]); + expect(tooling).to.have.property('mariadb'); + expect(tooling).to.have.property('psql'); + expect(tooling).to.not.have.property('mysql'); + }); + + it('should not add a shell for undetectable or unsupported flavors', function() { + const tooling = getDbToolingDefaults([{service: 'database'}, {service: 'db2', flavor: 'mssql'}]); + expect(tooling).to.have.property('db'); + expect(tooling).to.not.have.property('mysql'); + expect(tooling).to.not.have.property('mssql'); + }); + + it('should not mutate shared option objects between commands', function() { + const tooling = getDbToolingDefaults([{service: 'database', flavor: 'mysql'}]); + expect(tooling['db-import '].options).to.have.property('no-wipe'); + expect(tooling['db-export [file]'].options).to.not.have.property('no-wipe'); + expect(tooling['db'].options).to.not.have.property('no-wipe'); + expect(tooling['mysql'].options).to.not.have.property('stdout'); + }); +}); diff --git a/test/get-service-images.spec.js b/test/get-service-images.spec.js new file mode 100644 index 000000000..1532642d4 --- /dev/null +++ b/test/get-service-images.spec.js @@ -0,0 +1,42 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const getServiceImages = require('../utils/get-service-images'); + +describe('get-service-images', function() { + it('should return an empty object if there is no compose data', function() { + expect(getServiceImages()).to.deep.equal({}); + expect(getServiceImages([])).to.deep.equal({}); + }); + + it('should collect images from all compose data', function() { + const composeData = [ + {data: [{services: {appserver: {image: 'php:8.3-fpm'}, database: {image: 'mariadb:10.4'}}}]}, + {data: [{services: {cache: {image: 'redis:7'}}}]}, + ]; + expect(getServiceImages(composeData)).to.deep.equal({ + appserver: 'php:8.3-fpm', + database: 'mariadb:10.4', + cache: 'redis:7', + }); + }); + + it('should skip services that have no image', function() { + const composeData = [{data: [{services: {appserver: {build: '.'}, database: {image: 'mysql:8.0'}}}]}]; + expect(getServiceImages(composeData)).to.deep.equal({database: 'mysql:8.0'}); + }); + + it('should let later definitions win', function() { + const composeData = [ + {data: [{services: {database: {image: 'mysql:5.7'}}}]}, + {data: [{services: {database: {image: 'mysql:8.0'}}}]}, + ]; + expect(getServiceImages(composeData)).to.deep.equal({database: 'mysql:8.0'}); + }); + + it('should tolerate malformed compose data', function() { + expect(getServiceImages([{}, {data: []}, {data: [{}]}, {data: [{services: {}}]}])).to.deep.equal({}); + }); +}); diff --git a/test/parse-tooling-config.spec.js b/test/parse-tooling-config.spec.js new file mode 100644 index 000000000..882869937 --- /dev/null +++ b/test/parse-tooling-config.spec.js @@ -0,0 +1,52 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const parseToolingConfig = require('../utils/parse-tooling-config'); + +// options as they would be declared by a dynamic service tooling command +const options = {host: {description: 'The database service to use', default: 'database', alias: ['h']}}; + +describe('parse-tooling-config', function() { + it('should resolve a dynamic service from the answers', function() { + const answers = {host: 'database', h: 'database', _eventArgs: ['node', 'lando', 'db']}; + const config = parseToolingConfig(['/helpers/sql-cli.sh'], ':host', 'db', options, answers, {database: 3}); + expect(config[0].service).to.equal('database'); + expect(config[0].sapi).to.equal(3); + }); + + it('should strip the dynamic service option and its value from the args', function() { + const answers = { + host: 'database', + h: 'database', + _eventArgs: ['node', 'lando', 'mysql', '--host', 'database', '--', '-e', 'SELECT 1'], + }; + const config = parseToolingConfig(['/helpers/sql-cli.sh'], ':host', 'mysql', options, answers, {database: 3}); + expect(config[0].args).to.deep.equal(['--', '-e', 'SELECT 1']); + }); + + it('should not strip the command name when it matches the resolved service', function() { + // "lando db" on a service that is also called "db" used to remove its own command name from argv, which + // meant every arg including the node and lando binaries got passed through to the underlying command + const answers = {host: 'db', h: 'db', _eventArgs: ['node', 'lando', 'db', '--', '-e', 'SELECT 1']}; + const config = parseToolingConfig(['/helpers/sql-cli.sh'], ':host', 'db', options, answers, {db: 3}); + expect(config[0].service).to.equal('db'); + expect(config[0].args).to.deep.equal(['--', '-e', 'SELECT 1']); + expect(config[0].args).to.not.include('node'); + expect(config[0].args).to.not.include('lando'); + }); + + it('should still strip the dynamic service option when the command name matches the service', function() { + const answers = {host: 'db', h: 'db', _eventArgs: ['node', 'lando', 'db', '-h', 'db', '--', '-e', 'SELECT 1']}; + const config = parseToolingConfig(['/helpers/sql-cli.sh'], ':host', 'db', options, answers, {db: 3}); + expect(config[0].args).to.deep.equal(['--', '-e', 'SELECT 1']); + }); + + it('should leave non dynamic services alone', function() { + const answers = {_eventArgs: ['node', 'lando', 'composer', 'install']}; + const config = parseToolingConfig(['composer'], 'appserver', 'composer', {}, answers, {appserver: 3}); + expect(config[0].service).to.equal('appserver'); + expect(config[0].args).to.deep.equal(['install']); + }); +}); diff --git a/utils/get-core-tooling-additions.js b/utils/get-core-tooling-additions.js new file mode 100644 index 000000000..c6ffb03e7 --- /dev/null +++ b/utils/get-core-tooling-additions.js @@ -0,0 +1,15 @@ +'use strict'; + +const _ = require('lodash'); + +/* + * Helper to work out which of the tooling commands lando provides on the users behalf should actually be added + * + * Note that we compare ids eg "db-import" and not keys eg "db-import ". This means a user who declares a + * plain "db-import" _replaces_ our "db-import " instead of ending up with both of them. It also means we + * never deep merge our defaults into a command the user has redefined. + */ +module.exports = (core = {}, tooling = {}) => { + const claimed = _(tooling).keys().map(key => key.split(' ')[0]).value(); + return _.omitBy(core, (task, key) => _.includes(claimed, key.split(' ')[0])); +}; diff --git a/utils/get-db-services.js b/utils/get-db-services.js new file mode 100644 index 000000000..2b4305182 --- /dev/null +++ b/utils/get-db-services.js @@ -0,0 +1,38 @@ +'use strict'; + +const _ = require('lodash'); + +/* + * Helper to get the database "flavor" from a service type or image + * + * We tokenize instead of matching the whole string because recipes routinely prefix their service types + * eg "pantheon-mariadb" or suffix them with a version eg "mysql:8.0", and because images come with registries + * and tags attached eg "bitnami/postgresql:15" + */ +const getFlavor = value => { + if (typeof value !== 'string') return undefined; + const tokens = value.toLowerCase().split(/[-_:/]/); + if (_.includes(tokens, 'mariadb')) return 'mariadb'; + if (_.includes(tokens, 'mysql')) return 'mysql'; + if (!_.isEmpty(_.intersection(tokens, ['postgres', 'postgresql', 'pgsql']))) return 'postgres'; + if (_.includes(tokens, 'mssql')) return 'mssql'; + return undefined; +}; + +/* + * Helper to find any services that look like they could be a database + * + * We consider a service to be a database if its type is a known database eg "mysql", if its image looks like a + * known database eg "mariadb:10.4", or if it is literally called "database". The last two are mostly so services + * that come from an external compose file still get the default database tooling. + */ +module.exports = (info = [], images = {}) => _(info) + .map(service => ({ + service: service.service, + type: service.type, + flavor: getFlavor(service.type) ?? getFlavor(images[service.service]), + })) + .filter(service => service.flavor !== undefined || service.service === 'database') + .value(); + +module.exports.getFlavor = getFlavor; diff --git a/utils/get-db-tooling-defaults.js b/utils/get-db-tooling-defaults.js new file mode 100644 index 000000000..586afc732 --- /dev/null +++ b/utils/get-db-tooling-defaults.js @@ -0,0 +1,74 @@ +'use strict'; + +const _ = require('lodash'); + +// map of database flavors to the shell command we should add for them +const shells = { + mariadb: {command: 'mariadb', description: 'Drops into a MariaDB shell on a database service'}, + mysql: {command: 'mysql', description: 'Drops into a MySQL shell on a database service'}, + postgres: {command: 'psql', description: 'Drops into a PostgreSQL shell on a database service'}, +}; + +/* + * Helper to get the default database tooling + * + * This is the same tooling every recipe has historically had to redeclare for itself. The scripts it points at + * are provided by this plugin and mounted into every api 3 service at /helpers. + */ +module.exports = (services = [], host = 'database') => { + const hostOption = { + host: { + description: 'The database service to use', + default: host, + alias: ['h'], + }, + }; + + const tooling = { + 'db-import ': { + service: ':host', + description: 'Imports a dump file into a database service', + cmd: '/helpers/sql-import.sh', + user: 'root', + options: _.merge({}, hostOption, { + 'no-wipe': { + description: 'Do not destroy the existing database before an import', + boolean: true, + }, + }), + }, + 'db-export [file]': { + service: ':host', + description: 'Exports database from a database service to a file', + cmd: '/helpers/sql-export.sh', + user: 'root', + options: _.merge({}, hostOption, { + stdout: { + description: 'Dump database to stdout', + }, + }), + }, + 'db': { + service: ':host', + description: 'Drops into a database shell on a database service', + cmd: '/helpers/sql-cli.sh', + options: _.merge({}, hostOption), + }, + }; + + // add a flavor specific shell eg "lando mysql" for each flavor we were able to detect + _(services) + .map('flavor') + .filter(flavor => _.has(shells, flavor)) + .uniq() + .forEach(flavor => { + tooling[shells[flavor].command] = { + service: ':host', + description: shells[flavor].description, + cmd: '/helpers/sql-cli.sh', + options: _.merge({}, hostOption), + }; + }); + + return tooling; +}; diff --git a/utils/get-service-images.js b/utils/get-service-images.js new file mode 100644 index 000000000..a83d195d9 --- /dev/null +++ b/utils/get-service-images.js @@ -0,0 +1,16 @@ +'use strict'; + +const _ = require('lodash'); + +/* + * Helper to get a map of service name to image from compose data + * + * Note that services built from an imagefile eg api 4 services will not show up here and that later definitions + * of the same service win, which matches how the compose data is merged downstream. + */ +module.exports = (composeData = []) => _(composeData) + .flatMap(data => _.get(data, 'data', [])) + .flatMap(data => _.map(_.get(data, 'services', {}), (config, service) => ([service, _.get(config, 'image')]))) + .filter(pair => typeof pair[1] === 'string') + .fromPairs() + .value(); diff --git a/utils/get-tasks.js b/utils/get-tasks.js index f354c7f62..6388bd956 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -129,6 +129,27 @@ module.exports = (config = {}, argv = {}, tasks = []) => { } } + // Load the compose cache if we have one, note that we need to do this _before_ we build our tooling tasks + // below so any core provided tooling eg database tooling is included + let composeCache = {}; + if (fs.existsSync(config.composeCache)) { + try { + composeCache = JSON.parse(fs.readFileSync(config.composeCache, {encoding: 'utf-8'})); + } catch (e) { + throw new Error(`There was a problem with parsing ${config.composeCache}. Ensure it is valid JSON! ${e}`); + } + + // add additional items + config.allServices = composeCache.allServices ?? []; + config.info = composeCache.info ?? []; + config.primary = composeCache.primary ?? 'appserver'; + config.sapis = composeCache.sapis ?? {}; + + // mix in tooling core has added on our behalf, note that user and recipe tooling always wins + const additions = require('./get-core-tooling-additions')(composeCache.coreTooling ?? {}, config.tooling ?? {}); + config.tooling = _.merge({}, additions, config.tooling ?? {}); + } + // lets add ids to help match commands with args? _.forEach(config.tooling, (task, command) => { if (_.isObject(task) && typeof command === 'string') task.id = task.id || command.split(' ')[0]; @@ -159,23 +180,8 @@ module.exports = (config = {}, argv = {}, tasks = []) => { // get core tasks const coreTasks = _(loadCacheFile(process.landoTaskCacheFile)).map(t => ([t.command, t])).fromPairs().value(); - // mix in any relevant compose cache things - if (exists(config.composeCache)) { - try { - const composeCache = JSON.parse(fs.readFileSync(config.composeCache, {encoding: 'utf-8'})); - - // merge in additional tooling; - Object.assign(coreTasks, composeCache?.overrides?.tooling ?? {}); - - // add additional items - config.allServices = composeCache.allServices ?? []; - config.info = composeCache.info ?? []; - config.primary = composeCache.primary ?? 'appserver'; - config.sapis = composeCache.sapis ?? {}; - } catch (e) { - throw new Error(`There was a problem with parsing ${config.composeCache}. Ensure it is valid JSON! ${e}`); - } - } + // merge in additional tooling; + Object.assign(coreTasks, composeCache?.overrides?.tooling ?? {}); // and combine return tasks.concat(_.map(coreTasks, task => task)); diff --git a/utils/parse-tooling-config.js b/utils/parse-tooling-config.js index 1f7150355..06b42c592 100644 --- a/utils/parse-tooling-config.js +++ b/utils/parse-tooling-config.js @@ -20,11 +20,17 @@ const getDynamicKeys = (answer, answers = {}) => _(answers) * Set SERVICE from answers and strip out that noise from the rest of * stuff, check answers/argv for --service or -s, validate and then remove */ -const handleDynamic = (config, argv, answers = {}, sapis = {}) => { +const handleDynamic = (config, argv, answers = {}, sapis = {}, name = '') => { if (_.startsWith(config.service, ':')) { const answer = answers[config.service.split(':')[1]]; - // Remove dynamic service option from argv - _.remove(argv, arg => _.includes(getDynamicKeys(answer, answers).concat(answer), arg)); + const noise = getDynamicKeys(answer, answers).concat(answer); + // Remove dynamic service option from argv, note that we only consider things _after_ the command itself + // because the rest of the pipeline does the same. Without this a command whose name happens to match the + // service it resolves to eg "lando db" on a service called "db" would strip out its own command name. + const start = argv.findIndex(value => value === name.split(' ')[0]); + for (let i = argv.length - 1; i > start; i--) { + if (_.includes(noise, argv[i])) argv.splice(i, 1); + } // get the service const service = answers[config.service.split(':')[1]]; // Return updated config @@ -78,7 +84,7 @@ module.exports = (cmd, service, name, options = {}, answers = {}, sapis = {}) => // Put into an object so we can handle "multi-service" tooling .map(cmd => parseCommand(cmd, service, sapis)) // Handle dynamic services - .map(config => handleDynamic(config, answers._eventArgs ?? process.argv, answers, sapis)) + .map(config => handleDynamic(config, answers._eventArgs ?? process.argv, answers, sapis, name)) // Add in any argv extras if they've been passed in .map(config => handleOpts(config, name, answers._eventArgs ?? process.argv, handlePassthruOpts(options, answers))) // Wrap the command in /bin/sh if that makes sense From c1a12f6b14f2b41a0c2c604d3cc83ca658c4a3d7 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Mon, 3 Aug 2026 09:07:09 +0200 Subject: [PATCH 52/53] fix(argv): Make sure that options meant for tooling commands are stripped and are not available in the argv --- bin/lando | 4 +- index.js | 2 +- lib/cli.js | 64 ++++++++++++++++-- test/get-passthrough-command.spec.js | 36 ++++++++++ test/lando-argv.spec.js | 98 ++++++++++++++++++++++++++++ test/strip-passthrough-args.spec.js | 78 ++++++++++++++++++++++ utils/get-passthrough-command.js | 15 +++++ utils/get-tasks.js | 5 ++ utils/strip-passthrough-args.js | 44 +++++++++++++ 9 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 test/get-passthrough-command.spec.js create mode 100644 test/lando-argv.spec.js create mode 100644 test/strip-passthrough-args.spec.js create mode 100644 utils/get-passthrough-command.js create mode 100644 utils/strip-passthrough-args.js diff --git a/bin/lando b/bin/lando index 64a4bf899..b4eda8fb0 100755 --- a/bin/lando +++ b/bin/lando @@ -52,6 +52,8 @@ if (process.env.LANDO_DEBUG) { // } // and finally prefer --debug +// @NOTE: --debug is a boolean flag so only an explicit "--debug=" sets the scope. without this +// "lando --debug start" would enable the "start" namespace and print nothing at all if (argv.hasOption('--debug')) { require('debug').enable(argv.getOption('--debug', {defaultValue: 'lando*'})); } @@ -68,7 +70,7 @@ debug('starting %o version %o runtime selector...', id, pjson.version); // allow envvars to override a few core things // @NOTE: we've kept these around for backwards compatibility, you probably shouldnt use them though -const LOGLEVELCONSOLE = process.env.LANDO_CORE_LOGLEVELCONSOLE || debug.enabled ? 4 : undefined; +const LOGLEVELCONSOLE = process.env.LANDO_CORE_LOGLEVELCONSOLE || (debug.enabled ? 4 : undefined); const ENVPREFIX = process.env.LANDO_CORE_ENVPREFIX; const USERCONFROOT = process.env.LANDO_CORE_USERCONFROOT; const RUNTIME = process.env.LANDO_CORE_RUNTIME; diff --git a/index.js b/index.js index f1ce0ae48..312e72bd9 100644 --- a/index.js +++ b/index.js @@ -80,7 +80,7 @@ module.exports = async lando => { // work out what host.lando.internal needs to resolve to, this is mostly a wsl2 concern eg the ide is over on // windows but the containers are in wsl2 so "host-gateway" only ever gets us as far as the linux side const hostLandoInternal = require('./utils/get-host-lando-internal')({ - cacheDir: path.join(lando.config.userConfRoot, 'cache'), + cache: lando.cache, ideLocation: lando.config.xdebugIdeLocation, }); diff --git a/lib/cli.js b/lib/cli.js index af9673c3b..661e9b3bf 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -83,6 +83,41 @@ module.exports = class Cli { return require('yargs').help(false).version(false).argv; } + /** + * Returns the parsed global options that belong to lando itself + * + * For passthrough commands eg tooling, everything after the command name belongs to that command and not to + * us. Without this `lando console my:command -vvv` would crank up landos verbosity when the user only wanted + * the underlying command to be verbose. + * + * @since 3.26.5 + * @alias lando.cli.landoArgv + * @return {Object} Yarg parsed options + */ + landoArgv() { + // reuse the last result if nothing has changed, defaultConfig alone asks for this twice + const key = `${this.passthrough}::${process.argv.join(' ')}`; + if (this._landoArgv && this._landoArgvFor === key) return this._landoArgv; + + // if this is not a passthrough command then all global options are ours + if (!this.passthrough) return this.argv(); + + // otherwise only consider the args that come before the command + const args = process.argv.slice(2); + const index = args.indexOf(this.passthrough); + if (index === -1) return this.argv(); + + // note that we need a fresh yargs here because the singleton has demandCommand and middleware on it by now. + // we also drop "choices" because the validation for those is handled manually in run() and we do not want + // a bad value to make yargs bail out from in here + const parser = require('yargs/yargs')(args.slice(0, index)).help(false).version(false); + _.forEach(globalOptions, (config, name) => parser.option(name, _.omit(config, ['choices']))); + + this._landoArgvFor = key; + this._landoArgv = parser.parse(); + return this._landoArgv; + } + /** * Checks to see if lando is running with sudo. If it is it * will exit the process with a stern warning @@ -181,7 +216,7 @@ module.exports = class Cli { landoFile: '.lando.yml', landoFileConfig: appConfig, leia: _.has(process, 'env.LEIA_PARSER_RUNNING'), - logLevelConsole: (this.argv().verbose) ? this.argv().verbose + 1 : this.logLevel, + logLevelConsole: (this.landoArgv().verbose) ? this.landoArgv().verbose + 1 : this.logLevel, logDir: path.join(this.userConfRoot, 'logs'), mode: 'cli', packaged, @@ -241,7 +276,7 @@ module.exports = class Cli { } isDebug() { - const {debug, verbose} = this.argv(); + const {debug, verbose} = this.landoArgv(); return debug ? 1 + verbose : 0 + verbose; } @@ -257,7 +292,8 @@ module.exports = class Cli { * @param {Boolean} yes [yes=this.argv().yes] The auto yes value * @return {Integer} The exit codes */ - handleError(error, handler, verbose = this.argv().verbose, lando = {}, yes = this.argv().yes) { + // note that "yes" stays on argv() because a "-y" after a command is still meant for us + handleError(error, handler, verbose = this.landoArgv().verbose, lando = {}, yes = this.argv().yes) { // Set the verbosity error.verbose = verbose; @@ -395,9 +431,24 @@ module.exports = class Cli { positionals = {}, run = {}, level = 'app', + dynamic = undefined, + passthrough = false, usage = undefined, } = {}, config = {}) { const handler = argv => { + // if this command passes its args through then note that so we only claim global options that show up + // before it, this needs to happen before anything reads our config or verbosity + if (passthrough) { + this.passthrough = command.split(' ')[0]; + // and drop the args that belong to the command so they cannot influence our own option handling + argv = require('../utils/strip-passthrough-args')(argv, { + options, + positionals, + dynamic, + globals: this.landoArgv(), + }); + } + // Immediately build some arg data set opts and interactive options const data = {options: argv, inquiry: formatters.getInteractive(options, argv)}; // Remove legacy secret toggle if still there @@ -515,7 +566,12 @@ module.exports = class Cli { const yargonaut = require('yargonaut'); yargonaut.style('green').errorsStyle('red'); const yargs = require('yargs'); - const {clear, channel, experimental, secretToggle} = yargs.argv; + + // work out whether this command passes its args through before we look at any global option, otherwise + // something like "lando console my:command --clear" would wipe our caches and exit without ever running it + this.passthrough = require('../utils/get-passthrough-command')(tasks, yargs.argv._[0]); + + const {clear, channel, experimental, secretToggle} = this.landoArgv(); // Handle global flag error conditions first if (secretToggle && this.defaultConfig().packaged) { diff --git a/test/get-passthrough-command.spec.js b/test/get-passthrough-command.spec.js new file mode 100644 index 000000000..3a41b56dc --- /dev/null +++ b/test/get-passthrough-command.spec.js @@ -0,0 +1,36 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const getPassthroughCommand = require('../utils/get-passthrough-command'); + +const tasks = [ + {command: 'start', describe: 'Starts your app'}, + {command: 'db-import ', id: 'db-import', passthrough: true}, + {command: 'console', id: 'console', passthrough: true}, +]; + +describe('get-passthrough-command', function() { + it('should return the command when it is a passthrough', function() { + expect(getPassthroughCommand(tasks, 'console')).to.equal('console'); + }); + + it('should match on id and not on the full command string', function() { + expect(getPassthroughCommand(tasks, 'db-import')).to.equal('db-import'); + }); + + it('should return undefined for one of our own commands', function() { + expect(getPassthroughCommand(tasks, 'start')).to.equal(undefined); + }); + + it('should return undefined for an unknown command', function() { + expect(getPassthroughCommand(tasks, 'nope')).to.equal(undefined); + }); + + it('should return undefined when there is no command', function() { + expect(getPassthroughCommand(tasks)).to.equal(undefined); + expect(getPassthroughCommand(tasks, '')).to.equal(undefined); + expect(getPassthroughCommand()).to.equal(undefined); + }); +}); diff --git a/test/lando-argv.spec.js b/test/lando-argv.spec.js new file mode 100644 index 000000000..e34fac77e --- /dev/null +++ b/test/lando-argv.spec.js @@ -0,0 +1,98 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const Cli = require('../lib/cli'); + +// helper to run something with a faked process.argv +const withArgv = (args, fn) => { + const original = process.argv; + process.argv = ['/usr/local/bin/node', '/usr/local/bin/lando', ...args]; + try { + return fn(); + } finally { + process.argv = original; + } +}; + +describe('cli.landoArgv', function() { + it('should fall back to argv() when the command is not a passthrough', function() { + const cli = new Cli(); + withArgv(['info', '-vvv'], () => { + // no passthrough set, so we should get whatever the normal parse gives us + expect(cli.landoArgv()).to.deep.equal(cli.argv()); + }); + }); + + it('should ignore verbosity that comes after a passthrough command', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['console', 'my:command', '-vvv'], () => { + expect(cli.landoArgv().verbose).to.equal(0); + }); + }); + + it('should claim verbosity that comes before a passthrough command', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['-vvv', 'console', 'my:command'], () => { + expect(cli.landoArgv().verbose).to.equal(3); + }); + }); + + it('should count --verbose and -v the same way yargs does', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['--verbose', '-v', 'console', 'my:command'], () => { + expect(cli.landoArgv().verbose).to.equal(2); + }); + }); + + it('should ignore --debug that comes after a passthrough command', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['console', 'my:command', '--debug'], () => { + expect(cli.landoArgv().debug).to.equal(undefined); + }); + withArgv(['--debug', 'console', 'my:command'], () => { + expect(cli.landoArgv().debug).to.equal(true); + }); + }); + + it('should not choke on a valued global option before the command', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['--channel', 'edge', '-vv', 'console', 'my:command', '-vvv'], () => { + expect(cli.landoArgv().verbose).to.equal(2); + }); + }); + + it('should fall back to argv() if the command cannot be found in argv', function() { + const cli = new Cli(); + cli.passthrough = 'nope'; + withArgv(['console', 'my:command'], () => { + expect(cli.landoArgv()).to.deep.equal(cli.argv()); + }); + }); + + it('should ignore globals that follow the command, bare -- or not', function() { + const cli = new Cli(); + cli.passthrough = 'l4env'; + withArgv(['l4env', '--debug', '--', 'env'], () => { + expect(cli.landoArgv().debug).to.equal(undefined); + }); + withArgv(['l4env', '--', 'env', '--debug'], () => { + expect(cli.landoArgv().debug).to.equal(undefined); + }); + }); + + it('should report zero verbosity when nothing precedes the command', function() { + const cli = new Cli(); + cli.passthrough = 'console'; + withArgv(['console'], () => { + expect(cli.landoArgv().verbose).to.equal(0); + expect(cli.landoArgv().debug).to.equal(undefined); + }); + }); +}); diff --git a/test/strip-passthrough-args.spec.js b/test/strip-passthrough-args.spec.js new file mode 100644 index 000000000..7155df547 --- /dev/null +++ b/test/strip-passthrough-args.spec.js @@ -0,0 +1,78 @@ +'use strict'; + +const chai = require('chai'); +const expect = chai.expect; + +const strip = require('../utils/strip-passthrough-args'); + +// a tooling command as lando would describe it, roughly our own db-import +const declared = { + options: { + 'host': {description: 'The database service to use', default: 'database', alias: ['h']}, + 'no-wipe': {description: 'Do not wipe', boolean: true}, + }, + positionals: {file: {describe: 'the file'}}, + dynamic: 'host', +}; + +// what landoArgv() would hand us for "lando -vv db-import ..." +const globals = {_: ['db-import'], $0: 'lando', verbose: 2, debug: undefined}; + +describe('strip-passthrough-args', function() { + it('should keep options the command declared', function() { + const argv = {host: 'db', h: 'db', file: 'dump.sql'}; + const result = strip(argv, {...declared, globals}); + expect(result.host).to.equal('db'); + expect(result.h).to.equal('db'); + expect(result.file).to.equal('dump.sql'); + }); + + it('should keep both the kebab and camel forms yargs sets', function() { + const argv = {'no-wipe': true, 'noWipe': true}; + const result = strip(argv, {...declared, globals}); + expect(result['no-wipe']).to.equal(true); + expect(result.noWipe).to.equal(true); + }); + + it('should drop args the command did not declare', function() { + const argv = {'host': 'db', 'autoRemove': false, 'auto-remove': false, 'deps': true, 'coolFlag': true}; + const result = strip(argv, {...declared, globals}); + expect(result).to.not.have.property('autoRemove'); + expect(result).to.not.have.property('auto-remove'); + expect(result).to.not.have.property('deps'); + expect(result).to.not.have.property('coolFlag'); + expect(result.host).to.equal('db'); + }); + + it('should keep the structural keys', function() { + const app = {root: '/app'}; + const argv = {'_': ['db-import', 'dump.sql'], '--': ['-e', 'x'], '$0': 'lando', '_app': app, '_yargs': {}}; + const result = strip(argv, {...declared, globals}); + expect(result._).to.deep.equal(['db-import', 'dump.sql']); + expect(result['--']).to.deep.equal(['-e', 'x']); + expect(result._app).to.equal(app); + expect(result).to.have.property('_yargs'); + }); + + it('should take our own global options from the pre command region', function() { + // -vvv after the command must not win over the -vv before it + const argv = {verbose: 3, v: 3, host: 'db'}; + const result = strip(argv, {...declared, globals}); + expect(result.verbose).to.equal(2); + }); + + it('should keep unknown flags that came before the command', function() { + // "lando --deps console foo" still means --deps for us + const result = strip({deps: true}, {options: {}, globals: {...globals, deps: true}}); + expect(result.deps).to.equal(true); + }); + + it('should keep the dynamic service key even if it was not declared as an option', function() { + const result = strip({host: 'db'}, {options: {}, dynamic: 'host', globals}); + expect(result.host).to.equal('db'); + }); + + it('should cope with being given nothing', function() { + expect(strip()).to.deep.equal({}); + }); +}); diff --git a/utils/get-passthrough-command.js b/utils/get-passthrough-command.js new file mode 100644 index 000000000..4af36293b --- /dev/null +++ b/utils/get-passthrough-command.js @@ -0,0 +1,15 @@ +'use strict'; + +const _ = require('lodash'); + +/* + * Helper to work out whether the command being run is one that passes its args through + * + * Returns the command id if it does and undefined if it does not, which is exactly what cli.passthrough wants. + */ +module.exports = (tasks = [], command = undefined) => { + if (typeof command !== 'string' || command.length === 0) return undefined; + + const task = _.find(tasks, task => (task.id ?? _.get(task, 'command', '').split(' ')[0]) === command); + return _.get(task, 'passthrough', false) ? command : undefined; +}; diff --git a/utils/get-tasks.js b/utils/get-tasks.js index 6388bd956..df5b5048b 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -169,6 +169,11 @@ module.exports = (config = {}, argv = {}, tasks = []) => { describe: _.get(task, 'description', `Runs ${command} commands`), examples: _.get(task, 'examples', []), level, + // tooling commands pass their args straight through so any global options that show up after the + // command belong to the command and not to us + passthrough: true, + // dynamic services resolve their service from an answer eg ":host" so we need to keep that key around + dynamic: _.startsWith(_.get(task, 'service', ''), ':') ? _.trimStart(task.service, ':') : undefined, options: _.get(task, 'options', {}), positionals: _.get(task, 'positionals', {}), usage: _.get(task, 'usage', command), diff --git a/utils/strip-passthrough-args.js b/utils/strip-passthrough-args.js new file mode 100644 index 000000000..ab0753ef1 --- /dev/null +++ b/utils/strip-passthrough-args.js @@ -0,0 +1,44 @@ +'use strict'; + +const _ = require('lodash'); + +// argv keys that are structural and must always survive +const structural = ['_', '--', '$0', '_app', '_yargs', '_eventArgs']; + +/* + * Helper to expand an option name into every key yargs might set for it + */ +const expand = (name, config = {}) => _([name]) + .concat(_.get(config, 'alias', [])) + .flatten() + .filter(_.isString) + .flatMap(key => [key, _.camelCase(key)]) + .uniq() + .value(); + +/* + * Helper to remove the args that belong to a passthrough command from our own argv + * + * Tooling commands hand everything after the command name to the thing they wrap, but yargs still parses those + * args and we read a few things straight off the result eg "autoRemove" in utils/build-tooling-task.js. That + * means an "--auto-remove" meant for the wrapped command would quietly change how we run the container. + * + * So we keep only what we actually declared for the command plus whatever global options showed up before it. + * The upshot is that "lando console foo --deps" passes --deps to console while "lando --deps console foo" + * still means it for us. + */ +module.exports = (argv = {}, {options = {}, positionals = {}, dynamic = undefined, globals = {}} = {}) => { + // everything the command itself declared, including aliases and their camelCase forms + const declared = _([]) + .concat(_.flatMap(options, (config, name) => expand(name, config))) + .concat(_.flatMap(positionals, (config, name) => expand(name, config))) + .concat(dynamic ? expand(dynamic) : []) + .concat(structural) + .uniq() + .value(); + + // our own options only ever come from the region before the command + const ours = _.omit(globals, ['_', '$0', '--']); + + return {...ours, ..._.pick(argv, declared)}; +}; From de5359f2c83fbb68aa4560b24ee05d51d41621c3 Mon Sep 17 00:00:00 2001 From: Florian Patruck Date: Tue, 30 Dec 2025 13:40:34 +0100 Subject: [PATCH 53/53] chore: flos core package changes and make release possible --- .github/workflows/deploy-npm.yml | 54 +++++---- .github/workflows/dev-release.yml | 84 +++++++------- .github/workflows/pkg-binary.yml | 14 +-- .github/workflows/release.yml | 186 +++++++++++++++--------------- README.md | 53 +-------- examples/plugins/README.md | 6 +- package-lock.json | 8 +- package.json | 15 ++- 8 files changed, 187 insertions(+), 233 deletions(-) diff --git a/.github/workflows/deploy-npm.yml b/.github/workflows/deploy-npm.yml index c40c96ac0..c0315c8ac 100644 --- a/.github/workflows/deploy-npm.yml +++ b/.github/workflows/deploy-npm.yml @@ -32,26 +32,24 @@ jobs: run: npm run lint - name: Run unit tests run: npm run test:unit - - name: Update edge release alias - shell: bash - run: | - if ./scripts/semcompare.sh "${{ github.event.release.tag_name }}" "$(cat ./release-aliases/3-EDGE)"; then - echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-EDGE - fi - - name: Update stable release alias - shell: bash - if: github.event.release.prerelease == false - run: | - if ./scripts/semcompare.sh "${{ github.event.release.tag_name }}" "$(cat ./release-aliases/3-STABLE)"; then - echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-STABLE - fi + #- name: Update edge release alias + # shell: bash + # run: | + # if ./scripts/semcompare.sh "${{ github.event.release.tag_name }}" "$(cat ./release-aliases/3-EDGE)"; then + # echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-EDGE + # fi + #- name: Update stable release alias + # shell: bash + # if: github.event.release.prerelease == false + # run: | + # if ./scripts/semcompare.sh "${{ github.event.release.tag_name }}" "$(cat ./release-aliases/3-STABLE)"; then + # echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-STABLE + # fi - name: Prepare Release uses: lando/prepare-release-action@v3 with: lando-plugin: true - sync-token: ${{ secrets.github-token }} - sync-email: rtfm47@lando.dev - sync-username: rtfm-47 + sync: false - name: Upgrade npm for trusted publishing run: npm install -g "npm@^11.5.1" - name: Publish to npm @@ -62,7 +60,7 @@ jobs: if [ "${{ github.event.release.prerelease }}" == "false" ]; then npm publish --access public --tag latest --dry-run npm publish --access public --tag latest - npm dist-tag add "$PACKAGE@$VERSION" edge + # npm dist-tag add "$PACKAGE@$VERSION" edge # not supported with trusted publishing echo "::notice title=Published $VERSION to $PACKAGE::This is a stable release published to the default 'latest' npm tag" echo "::notice title=Updated latest tag to $VERSION::The stable tag now points to $VERSION" @@ -74,14 +72,14 @@ jobs: echo "::notice title=Published $VERSION to $PACKAGE::This is a prerelease published to the 'edge' npm tag" echo "::notice title=Updated edge tag to $VERSION::The edge tag now points to $VERSION" fi - - name: Update edge release alias on main - if: github.event.release.target_commitish == 'edge' - run: | - git clone https://github.com/lando/core.git core - cd core - git config user.name "rtfm-47" - git config user.email "rtfm47@lando.dev" - echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-EDGE - git add . - git commit -m "Update edge release alias to ${{ github.event.release.tag_name }} triggered by @rtfm-47" - git push https://x-access-token:${{ secrets.github-token }}@github.com/lando/core.git main + #- name: Update edge release alias on main + # if: github.event.release.target_commitish == 'edge' + # run: | + # git clone https://github.com/lando/core.git core + # cd core + # git config user.name "rtfm-47" + # git config user.email "rtfm47@lando.dev" + # echo "${{ github.event.release.tag_name }}" > ./release-aliases/3-EDGE + # git add . + # git commit -m "Update edge release alias to ${{ github.event.release.tag_name }} triggered by @rtfm-47" + # git push https://x-access-token:${{ secrets.github-token }}@github.com/lando/core.git main diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c0e1ffaea..8cd3e2c7a 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -29,41 +29,41 @@ jobs: os: ${{ matrix.os }} version: dev - sign: - permissions: - contents: read - id-token: write - uses: ./.github/workflows/sign-binary.yml - needs: - - package - strategy: - fail-fast: false - matrix: - file: - - lando-linux-arm64-${{ github.sha }} - - lando-macos-arm64-${{ github.sha }} - - lando-win-arm64-${{ github.sha }} + #sign: + # permissions: + # contents: read + # id-token: write + # uses: ./.github/workflows/sign-binary.yml + # needs: + # - package + # strategy: + # fail-fast: false + # matrix: + # file: + # - lando-linux-arm64-${{ github.sha }} + # - lando-macos-arm64-${{ github.sha }} + # - lando-win-arm64-${{ github.sha }} - - lando-linux-x64-${{ github.sha }} - - lando-macos-x64-${{ github.sha }} - - lando-win-x64-${{ github.sha }} + # - lando-linux-x64-${{ github.sha }} + # - lando-macos-x64-${{ github.sha }} + # - lando-win-x64-${{ github.sha }} - with: - download-pattern: packaged-lando-* - file: ${{ matrix.file }} - secrets: - apple-notary-user: ${{ secrets.APPLE_NOTARY_USER }} - apple-notary-password: ${{ secrets.APPLE_NOTARY_PASSWORD }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-data: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_DATA || '' }} - certificate-password: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_PASSWORD || '' }} + #with: + # download-pattern: packaged-lando-* + # file: ${{ matrix.file }} + #secrets: + # apple-notary-user: ${{ secrets.APPLE_NOTARY_USER }} + # apple-notary-password: ${{ secrets.APPLE_NOTARY_PASSWORD }} + # azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + # azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + # certificate-data: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_DATA || '' }} + # certificate-password: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_PASSWORD || '' }} build-release-binary-alias: uses: ./.github/workflows/release-rename-binary.yml needs: - - sign + - package strategy: fail-fast: false matrix: @@ -80,11 +80,11 @@ jobs: with: source: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }} destination: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.alias }} - download-pattern: signed-lando-* + download-pattern: packaged-lando-* build-release-binary-branch: uses: ./.github/workflows/release-rename-binary.yml needs: - - sign + - package strategy: fail-fast: false matrix: @@ -99,7 +99,7 @@ jobs: with: source: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }} destination: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.head_ref || github.ref_name }} - download-pattern: signed-lando-* + download-pattern: packaged-lando-* checksum: uses: ./.github/workflows/generate-checksums.yml @@ -119,16 +119,16 @@ jobs: show: true upload-name: release-checksums-${{ matrix.alias }} - deploy-releases-s3: - uses: ./.github/workflows/deploy-s3.yml - needs: - - checksum - with: - download-pattern: release-* - secrets: - aws-secret-access-key: ${{ secrets.S3_SECRET_ACCESS_KEY }} - aws-access-key-id: ${{ secrets.S3_ACCESS_KEY_ID }} - aws-region: us-east-1 + #deploy-releases-s3: + # uses: ./.github/workflows/deploy-s3.yml + # needs: + # - checksum + # with: + # download-pattern: release-* + # secrets: + # aws-secret-access-key: ${{ secrets.S3_SECRET_ACCESS_KEY }} + # aws-access-key-id: ${{ secrets.S3_ACCESS_KEY_ID }} + # aws-region: us-east-1 deploy-releases-artifacts: uses: ./.github/workflows/deploy-artifacts.yml needs: diff --git a/.github/workflows/pkg-binary.yml b/.github/workflows/pkg-binary.yml index 7d6b4ae3b..9cd7f11ea 100644 --- a/.github/workflows/pkg-binary.yml +++ b/.github/workflows/pkg-binary.yml @@ -51,8 +51,8 @@ jobs: cache: npm - name: Install dependencies run: npm clean-install --prefer-offline --frozen-lockfile --production - - name: Install plugins - run: scripts/install-plugins.sh --lando bin/lando ${{ inputs.edge == true && '--edge' || '' }} + #- name: Install plugins + # run: scripts/install-plugins.sh --lando bin/lando ${{ inputs.edge == true && '--edge' || '' }} - name: Switch to edge channel if: inputs.edge == true run: | @@ -82,8 +82,8 @@ jobs: - name: Ensure channel if: (inputs.os == 'linux' && runner.os == 'Linux') || (inputs.os == 'macos' && runner.os == 'macOS') run: ./dist/${{ inputs.filename }} config --path channel | grep ${{ inputs.edge == true && 'edge' || 'stable' }} - - name: Ensure plugin install - if: ((inputs.os == 'linux' && runner.os == 'Linux') || (inputs.os == 'macos' && runner.os == 'macOS')) - run: | - ./dist/${{ inputs.filename }} config --path fatcore | grep true - ./dist/${{ inputs.filename }} config | grep -q "/snapshot/core/plugins/wordpress" + #- name: Ensure plugin install + # if: ((inputs.os == 'linux' && runner.os == 'Linux') || (inputs.os == 'macos' && runner.os == 'macOS')) + # run: | + # ./dist/${{ inputs.filename }} config --path fatcore | grep true + # ./dist/${{ inputs.filename }} config | grep -q "/snapshot/core/plugins/wordpress" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1ffa208d..3057a7927 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,41 +31,41 @@ jobs: os: ${{ matrix.os }} version: ${{ github.event.release.tag_name }} - sign: - permissions: - contents: read - id-token: write - uses: ./.github/workflows/sign-binary.yml - needs: - - package - strategy: - fail-fast: false - matrix: - file: - - lando-linux-arm64-${{ github.ref_name }} - - lando-macos-arm64-${{ github.ref_name }} - - lando-win-arm64-${{ github.ref_name }} - - - lando-linux-x64-${{ github.ref_name }} - - lando-macos-x64-${{ github.ref_name }} - - lando-win-x64-${{ github.ref_name }} - - with: - download-pattern: packaged-lando-* - file: ${{ matrix.file }} - secrets: - apple-notary-user: ${{ secrets.APPLE_NOTARY_USER }} - apple-notary-password: ${{ secrets.APPLE_NOTARY_PASSWORD }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-data: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_DATA || '' }} - certificate-password: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_PASSWORD || '' }} + #sign: + # permissions: + # contents: read + # id-token: write + # uses: ./.github/workflows/sign-binary.yml + # needs: + # - package + # strategy: + # fail-fast: false + # matrix: + # file: + # - lando-linux-arm64-${{ github.ref_name }} + # - lando-macos-arm64-${{ github.ref_name }} + # - lando-win-arm64-${{ github.ref_name }} + + # - lando-linux-x64-${{ github.ref_name }} + # - lando-macos-x64-${{ github.ref_name }} + # - lando-win-x64-${{ github.ref_name }} + + # with: + # download-pattern: packaged-lando-* + # file: ${{ matrix.file }} + # secrets: + # apple-notary-user: ${{ secrets.APPLE_NOTARY_USER }} + # apple-notary-password: ${{ secrets.APPLE_NOTARY_PASSWORD }} + # azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + # azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + # certificate-data: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_DATA || '' }} + # certificate-password: ${{ contains(matrix.file, 'macos') && secrets.APPLE_CERT_PASSWORD || '' }} build-release-binary-alias: uses: ./.github/workflows/release-rename-binary.yml needs: - - sign + - package strategy: fail-fast: false matrix: @@ -81,11 +81,11 @@ jobs: with: source: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.ref_name }} destination: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.alias }} - download-pattern: signed-lando-* + download-pattern: packaged-lando-* build-release-binary-tag: uses: ./.github/workflows/release-rename-binary.yml needs: - - sign + - package strategy: fail-fast: false matrix: @@ -99,7 +99,7 @@ jobs: with: source: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.ref_name }} destination: lando-${{ matrix.os }}-${{ matrix.arch }}-${{ github.ref_name }} - download-pattern: signed-lando-* + download-pattern: packaged-lando-* checksum: uses: ./.github/workflows/generate-checksums.yml @@ -130,17 +130,17 @@ jobs: show: true upload-name: release-checksums${{ matrix.alias }} - deploy-releases-s3: - uses: ./.github/workflows/deploy-s3.yml - needs: - - checksum - - checksum-s3-aliases - with: - download-pattern: release-* - secrets: - aws-secret-access-key: ${{ secrets.S3_SECRET_ACCESS_KEY }} - aws-access-key-id: ${{ secrets.S3_ACCESS_KEY_ID }} - aws-region: us-east-1 +# deploy-releases-s3: +# uses: ./.github/workflows/deploy-s3.yml +# needs: +# - checksum +# - checksum-s3-aliases +# with: +# download-pattern: release-* +# secrets: +# aws-secret-access-key: ${{ secrets.S3_SECRET_ACCESS_KEY }} +# aws-access-key-id: ${{ secrets.S3_ACCESS_KEY_ID }} +# aws-region: us-east-1 deploy-releases-artifacts: uses: ./.github/workflows/deploy-artifacts.yml needs: @@ -159,51 +159,51 @@ jobs: - checksum secrets: github-token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} - deploy-legacy-notifications: - runs-on: ubuntu-24.04 - needs: - - checksum - env: - TERM: xterm - steps: - - name: Push release to lando/lando - uses: softprops/action-gh-release@v3 - with: - repository: lando/lando - name: ${{ github.event.release.tag_name }} - draft: ${{ github.event.release.draft }} - prerelease: ${{ github.event.release.prerelease }} - tag_name: ${{ github.event.release.tag_name }} - token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} - body: | - **Starting with v3.21.0-beta.18, Lando is no longer distributed via package installers in here in this releases page!** - - To install Lando please visit the [official install docs](https://docs.lando.dev/install). - - ## Changelogs - - Lando now runs as a distributed plugin-based ecosystem so you will want to check the releases/changelogs in - the various [plugins](https://docs.lando.dev/plugins.html) for relevant notes. - - [Click Here](https://github.com/lando/core/releases/tag/${{ github.event.release.tag_name }}) to check out the notes for `@lando/core@${{ github.event.release.tag_name }}`. - - ## Notes - - * We will continue to push releases here for backwards compatibility, posterity, etc - * [Extended release notes](https://lando.dev/blog/2024/01/16/v321-extended.html) - - - name: Push release to lando/cli - uses: softprops/action-gh-release@v3 - with: - repository: lando/legacy-cli - name: ${{ github.event.release.tag_name }} - draft: ${{ github.event.release.draft }} - prerelease: ${{ github.event.release.prerelease }} - tag_name: ${{ github.event.release.tag_name }} - token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} - body: | - **Starting with v3.23.0, Lando CLI binaries are no longer distributed here in these releases!** - - They are now available in the `@lando/core` [releases page](https://github.com/lando/core/releases) including [this ${{ github.event.release.tag_name }} release](https://github.com/lando/core/releases/tag/${{ github.event.release.tag_name }}). - - All that said we don't recommned you use these binaries directly. Instead, to install Lando please visit the [official install docs](https://docs.lando.dev/install). +# deploy-legacy-notifications: +# runs-on: ubuntu-24.04 +# needs: +# - checksum +# env: +# TERM: xterm +# steps: +# - name: Push release to lando/lando +# uses: softprops/action-gh-release@v3 +# with: +# repository: lando/lando +# name: ${{ github.event.release.tag_name }} +# draft: ${{ github.event.release.draft }} +# prerelease: ${{ github.event.release.prerelease }} +# tag_name: ${{ github.event.release.tag_name }} +# token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} +# body: | +# **Starting with v3.21.0-beta.18, Lando is no longer distributed via package installers in here in this releases page!*#* + +# To install Lando please visit the [official install docs](https://docs.lando.dev/install)#. + +# ## Changelogs + +# Lando now runs as a distributed plugin-based ecosystem so you will want to check the releases/changelogs in +# the various [plugins](https://docs.lando.dev/plugins.html) for relevant notes#. + +# [Click Here](https://github.com/lando/core/releases/tag/${{ github.event.release.tag_name }}) to check out the notes for `@lando/core@${{ github.event.release.tag_name }}`#. + +# ## Notes + +# * We will continue to push releases here for backwards compatibility, posterity, etc +# * [Extended release notes](https://lando.dev/blog/2024/01/16/v321-extended.html#) + +# - name: Push release to lando/cli +# uses: softprops/action-gh-release@v3 +# with: +# repository: lando/legacy-cli +# name: ${{ github.event.release.tag_name }} +# draft: ${{ github.event.release.draft }} +# prerelease: ${{ github.event.release.prerelease }} +# tag_name: ${{ github.event.release.tag_name }} +# token: ${{ secrets.RTFM47_COAXIUM_INJECTOR }} +# body: | +# **Starting with v3.23.0, Lando CLI binaries are no longer distributed here in these releases!*#* + +# They are now available in the `@lando/core` [releases page](https://github.com/lando/core/releases) including [this ${{ github.event.release.tag_name }} release](https://github.com/lando/core/releases/tag/${{ github.event.release.tag_name }})#. + +# All that said we don't recommned you use these binaries directly. Instead, to install Lando please visit the [official install docs](https://docs.lando.dev/install). diff --git a/README.md b/README.md index 7fdd3fd5c..05587cfc0 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,5 @@ -# Lando Core +# Flos Lando Core -These are the core libraries that power Lando. They are implemented in [`@lando/cli`] and things like [Pantheon LocalDev](https://pantheon.io/product/localdev) and [WordPress VIP CLI](https://github.com/Automattic/vip-cli/blob/develop/package.json). - -On a high level they serve as: - -**An abstraction layer** Lando vastly reduces the complexity of spinning up containers by exposing only the most relevant config for a given "service" and setting "sane defaults". Lando also provides "recipes" which are common combinations of services and their tooling that satisfy a given development use case - e.g. Drupal, Python, Laravel, Dotnet, etc. - -**A superset** Lando provides ways for developers to run complex commands, build steps and automation on their services without the hassle of custom Dockerfiles or long "docker exec" commands. Think `lando yarn add express`. Think clear my applications cache after I import a database. Think install this core-extension before my appserver starts and then `composer install` after it does. - -**A utility** Lando handles some of the more arduous configuration required for a good Docker Compose setup - e.g. proxying, nice urls, cross-application networking (think Vue.js frontend talking to a separate Laravel backend), host-container file permission handling, file sharing, per-container SSL certificate handling, ssh-key handling, etc. - -## Basic Usage - -```js -const Lando = require('@lando/core'); -const lando = new Lando(config); - -// bootstrap and go -return lando.bootstrap(bsLevel).then(lando => { - lando.getApp().init().then(() => cli.run(getTasks(config, cli.argv()), config)); -}); -const -``` - -For more info you should check out the [docs](https://docs.lando.dev/core/v3): - -## Issues, Questions and Support - -If you have a question or would like some community support we recommend you [join us on Slack](https://launchpass.com/devwithlando). - -If you'd like to report a bug or submit a feature request then please [use the issue queue](https://github.com/lando/core/issues/new/choose) in this repo. - -## Changelog - -We try to log all changes big and small in both [THE CHANGELOG](https://github.com/lando/core/blob/main/CHANGELOG.md) and the [release notes](https://github.com/lando/core/releases). - -## Contributors - - - - - -Made with [contributors-img](https://contrib.rocks).` - -## Other Selected Resources - -* [LICENSE](/LICENSE) -* [TERMS OF USE](https://docs.lando.dev/terms) -* [PRIVACY POLICY](https://docs.lando.dev/privacy) -* [CODE OF CONDUCT](https://docs.lando.dev/coc) +These are the core libraries that power flos version of Lando with seamless compose integration. +Thanks to the upstream [lando-core](https://github.com/lando/core)! diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 30f0f9fac..efb5346ce 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -79,9 +79,9 @@ lando plugin-login --registry "http://localhost:4873" --password "lando-test" -- # Should be able to add and remove a private plugin via a registry string. lando config | grep -qv "plugins/@lando/lando-plugin-test" -lando plugin-add "@lando/lando-plugin-test" -lando config | grep -q "plugins/@lando/lando-plugin-test" -lando plugin-remove "@lando/lando-plugin-test" +#lando plugin-add "@lando/lando-plugin-test" +#lando config | grep -q "plugins/@lando/lando-plugin-test" +#lando plugin-remove "@lando/lando-plugin-test" lando config | grep -qv "plugins/@lando/lando-plugin-test" ``` diff --git a/package-lock.json b/package-lock.json index 13e9dabf4..a22ef9294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@lando/core", - "version": "3.26.9", + "name": "@florianpat/lando-core", + "version": "3.26.10-1florianPat.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@lando/core", - "version": "3.26.9", + "name": "@florianpat/lando-core", + "version": "3.26.10-1florianPat.0", "license": "MIT", "dependencies": { "@lando/argv": "^1.2.0", diff --git a/package.json b/package.json index 72af42d48..df684fb37 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { - "name": "@lando/core", + "name": "@florianpat/lando-core", "description": "The libraries that power all of Lando.", - "version": "3.26.9", - "author": "Mike Pirog @pirog", + "version": "3.26.10-1florianPat.0", + "author": "Florian Patruck @florianPat", "license": "MIT", - "repository": "lando/core", - "bugs": "https://github.com/lando/core/issues/new/choose", - "homepage": "https://github.com/lando/core", + "repository": { + "type": "git", + "url": "git+https://github.com/HDNET/lando-core.git" + }, + "bugs": "https://github.com/HDNET/lando-core/issues/new/choose", + "homepage": "https://github.com/HDNET/lando-core", "keywords": [ "lando", "lando-plugin"