From 68d5c96e41905be5f181786237ab1e439ccc7666 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 3ef39dc51..fac0d9218 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 @@ -178,9 +182,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 34cc67c18..080d8a738 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -20,6 +20,7 @@ const composeFlags = { rm: '--rm', timestamps: '--timestamps', volumes: '-v', + outputFilePath: '-o', }; // Default options nad things @@ -33,6 +34,7 @@ const defaultOptions = { pull: {}, rm: {force: true, volumes: true}, up: {background: true, noRecreate: true, recreate: false, removeOrphans: true}, + config: {}, }; /* @@ -155,3 +157,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 b86c919b4..9f869fcfa 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 ff81b0751d7a7b7b6a10d14048ebb2ca9768ab6f 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 a1e0d0316e1b499a68b9a0df9a8a7e1447737039 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 9f869fcfa..881f779fc 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 c07dad65f51ab85fc3627a849e40eba210d41a9e 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 fac0d9218..898f0ec6a 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -221,6 +221,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 de6d14aba550fc530156c89fdacc512801b271ef 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 898f0ec6a..69b7cbe07 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -139,7 +139,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 33d4e1e9cacce2fd9f1d4b73191dc1f61775117c 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 39673be2af0906ea3c0f0f0ee5f79bd5dace9750 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 69b7cbe07..96c6118f0 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 8dbda099697cfe5b7b920d8a35f165b2fe12b9f6 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 c768c8c12c1a9646c97c0dae1e073e5f3359b362 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 080d8a738..1f93027e1 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -23,6 +23,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}, @@ -40,7 +53,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 3ae593808..f0b3f2d02 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -53,6 +53,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 1f6fb451c07d98bb868f8baa2996cdca5ce0d5a8 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 b84dd45b64f99a68bd8c662501018f54bbc98e7b 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 e1a448958e9a05f7fae434bb6acb7e07793749a7 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 f0b3f2d02..9e34aed29 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'); /* * Paths to / @@ -41,9 +42,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))); }; /* @@ -131,7 +133,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 b8c34ed15efa2dc075fce059913e3046962e64ab 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 84797b1345b4148c30e65e05092a6557dbe07bf4 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 4427196ff425564449aee3b23a166a62dceb62ec 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 c033aae61c480a6f62a33fc13c62a5d3219ef3f0 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 bc8e424214acbb9b6812af57f3dd8edb0a971cb8 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 d0389378959206d24c53b92f9690783a84520b6b 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 c38325471..ca784562c 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 5e077ffa1347dbcd5586d235b5061d5f4a279115 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 80eb046a227e91addd86050377a2a561749364bd 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 1f93027e1..5702208b1 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -77,21 +77,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 881f779fc..c5be551a4 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 fe537686a3c4a2a8f1a1364627306100452bd97f 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 276406566e0112d9afed118f150579a033eecb9e 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 b26bc3636d2e2d805d470a6e706ba26711a90c87 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 938beff03..a25c351e8 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 d3763324d3e9c899318ba23b02551359d711b7d8 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 | 20 +++++++++++++++----- package.json | 1 + 4 files changed, 43 insertions(+), 5 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 eaa8bef34..fc2e88cda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "figures": "^3.2.0", "fs-extra": "^11.1.1", "glob": "^7.1.3", + "ini": "^5.0.0", "inquirer": "^6.5.2", "inquirer-autocomplete-prompt": "^1.4.0", "is-class": "^0.0.9", @@ -7978,11 +7979,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/inquirer": { "version": "6.5.2", @@ -12413,6 +12416,13 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", diff --git a/package.json b/package.json index 2a02ad501..708eb54f4 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": "^5.0.0", "inquirer": "^6.5.2", "inquirer-autocomplete-prompt": "^1.4.0", "is-class": "^0.0.9", From b23a1a5dbd58944c45fa752fd0e2df7802189765 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 a25c351e8..b55956dbb 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 18d10c144..8683f428a 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.37.2'; case 'linux': + case 'wsl': return '27.5.0'; case 'win32': return '4.37.1'; - case 'wsl': - return '4.37.1'; } }; 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 7e292a148a2597b8b7a97c5cbcc679c2410948e0 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 7929ad1976de042501f92b84dc82b272a6042df8 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 b55956dbb..a25c351e8 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 8683f428a..18d10c144 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.37.2'; case 'linux': - case 'wsl': return '27.5.0'; case 'win32': return '4.37.1'; + case 'wsl': + return '4.37.1'; } }; From 24c11321936ad8c01eecaac7beadc6fe456fce4a 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 96c6118f0..ec96e4366 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`, ]; @@ -137,7 +137,11 @@ module.exports = { volumes.push(`${addCertsScript}:/scripts/000-add-cert`); volumes.push(`${path.join(userConfRoot, 'certs', certname)}:/certs/cert.crt`); volumes.push(`${path.join(userConfRoot, 'certs', keyname)}:/certs/cert.key`); + volumes.push(`${path.join(userConfRoot, 'certs', certname)}:/lando/certs/${certname}`); + volumes.push(`${path.join(userConfRoot, 'certs', keyname)}:/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 b5c80a73b85af2f3e67fe87543266d0f25659129 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 dfed348c849322b14e6d5c99de0a4f172efcb5d6 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 cb9874e659f6f489f6800d64fcbacc1556154386 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 c4ac9eda8927c5611d2dd63976d7434fc3648839 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 5702208b1..32cb4f181 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -21,6 +21,7 @@ const composeFlags = { timestamps: '--timestamps', volumes: '-v', outputFilePath: '-o', + ignoreBuildable: '--ignore-buildable', }; const composeFlagOptionMapping = { @@ -30,7 +31,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'], @@ -44,7 +45,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 1b3fc6705b5485db624b2a5cd67988fe8e91e356 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 d35f8aff213aacba8e53eed3e969e6ece04050e4 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 32cb4f181..652be2570 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -59,15 +59,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] : []; @@ -84,7 +85,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]); }; @@ -93,7 +94,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 063ebd388db0652b98c85c51b5593c2d0fd72597 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 74b3117e93be33e167ec955d307e5f1bf8db6bd8 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 39cf1db1f..b38afded9 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 0795c1af7768c3eb3e1d182a52d695b381d4c5b1 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 2a83e18d835839a9c1fafd72b287db97c7e06854 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 e3aa972f774ca845015b7722340c5a7cbcb689cd 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 652be2570..f7989f50e 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -15,6 +15,7 @@ const composeFlags = { noTTY: '-T', pull: '--pull', q: '--quiet', + quietPull: '--quiet-pull', recreate: '--force-recreate', removeOrphans: '--remove-orphans', rm: '--rm', @@ -33,7 +34,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 ba51a4eb4d67d4040b5f8b7c913927bf2102e8e2 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 | 14 +++++++------- hooks/lando-setup-orchestrator.js | 4 ++-- lib/daemon.js | 2 +- scripts/install-docker-engine.sh | 2 +- utils/get-compose-x.js | 2 +- utils/get-config-defaults.js | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/config.yml b/config.yml index 9d25a6bac..7b16edc24 100644 --- a/config.yml +++ b/config.yml @@ -11,23 +11,23 @@ stats: dockerSupportedVersions: compose: - satisfies: "1.x.x || 2.x.x" - recommendUpdate: "<=2.24.6" - tested: "<=2.32.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.37.99" - recommendUpdate: "<=4.36" + 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 <28" - tested: "<=27.5.99" + satisfies: ">=18 <30" + 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 d01dc7735..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.31.0') => { +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.31.0') => { /* * Helper to get docker compose v2 download destination */ -const getComposeDownloadDest = (base, version = '2.31.0') => { +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 a25c351e8..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.31.0', + orchestratorVersion = '5.1.1', userConfRoot = path.join(os.homedir(), '.lando'), ) { this.cache = cache; diff --git a/scripts/install-docker-engine.sh b/scripts/install-docker-engine.sh index 3aa19f69b..ad8afbc41 100755 --- a/scripts/install-docker-engine.sh +++ b/scripts/install-docker-engine.sh @@ -3,7 +3,7 @@ set -eo pipefail DEBUG=0 INSTALLER="get-docker.sh" -VERSION="27.5.0" +VERSION="29.3.1" OPTS= debug() { diff --git a/utils/get-compose-x.js b/utils/get-compose-x.js index ace77eda6..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.31.0', 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 18d10c144..ec254e5de 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.31.0', + orchestratorVersion: '5.1.1', configSources: [], coreBase: path.resolve(__dirname, '..'), disablePlugins: [], From 8478fc6de1f6a9c3c070f2a5a75c0e9bed544a89 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 98347a1b1de8bb73b3d1ca7518a574162a0d6f53 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 87ed22f6b..853098027 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) @@ -86,9 +85,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 65f312bfc9995406fbe727c4ed900aa56fb86ea0 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 636b5648a501da8d17c58fceb5623f614197be65 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 a49e4acbab33d2b6d24936ef89eb3a4f2341b1ea 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 9820db680..b43addc7c 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 3403e5c08..aa55cf91d 100644 --- a/lib/lando.js +++ b/lib/lando.js @@ -402,7 +402,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 414ebb769493ed517c7c17e2403d0096b6328957 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 1fa345fb50b2ce86cf3afa2c517fcf82cb6351b8 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 ea47bf32a05a4fec517f0f1940afcf1d18b319a2 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 f7989f50e..f8dea8bc4 100644 --- a/lib/compose.js +++ b/lib/compose.js @@ -21,7 +21,6 @@ const composeFlags = { rm: '--rm', timestamps: '--timestamps', volumes: '-v', - outputFilePath: '-o', ignoreBuildable: '--ignore-buildable', }; @@ -35,7 +34,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 c5be551a4..1c7eb33c5 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 9f9a80828..b9caf24d0 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 1d429efff5a881741daae387dcaf67612f21336b 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 b9caf24d0..638673374 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 333bdb14115ee2ee80c5fdca98c191e181c95da1 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 ec96e4366..85f1f328e 100644 --- a/builders/_lando.js +++ b/builders/_lando.js @@ -202,7 +202,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 b43addc7c..8ba13350e 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 962059b0016412b85fa49ace92c3eb1ef9eae396 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 9e34aed29..a116e9110 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -126,6 +126,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]; @@ -156,23 +177,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 (fs.existsSync(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 0efa44afe2371c8fb4370f7963f48adb0eb4c3ee 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 a116e9110..e3b495292 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -166,6 +166,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 bda4f3b74251a704f649f4c5e8397e504844d600 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 50a493ef3..b69fa07de 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -63,9 +63,9 @@ lando plugin-login --registry "https://npm.pkg.github.com" --password "$GITHUB_P # 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 fc2e88cda..14581c2b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@lando/core", - "version": "3.26.7", + "name": "@florianpat/lando-core", + "version": "3.26.8-1florianPat.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@lando/core", - "version": "3.26.7", + "name": "@florianpat/lando-core", + "version": "3.26.8-1florianPat.0", "license": "MIT", "dependencies": { "@lando/argv": "^1.2.0", diff --git a/package.json b/package.json index 708eb54f4..55a0a5f81 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.7", - "author": "Mike Pirog @pirog", + "version": "3.26.8-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"