Updating hosting to break up single file into lots of independent commands - this was an attempt to improve syntax. Adding in single image handling, as well as full support for watch directory.

This commit is contained in:
mike12345567 2022-09-28 19:11:22 +01:00
parent 3bb4b3ed3a
commit a464aceffb
12 changed files with 332 additions and 220 deletions

View File

@ -11,7 +11,6 @@ exports.CommandWords = {
exports.InitTypes = { exports.InitTypes = {
QUICK: "quick", QUICK: "quick",
DIGITAL_OCEAN: "do", DIGITAL_OCEAN: "do",
SINGLE: "single",
} }
exports.AnalyticsEvents = { exports.AnalyticsEvents = {

View File

@ -1,211 +1,11 @@
const Command = require("../structures/Command") const Command = require("../structures/Command")
const { CommandWords, InitTypes, AnalyticsEvents } = require("../constants") const { CommandWords } = require("../constants")
const { lookpath } = require("lookpath") const { init } = require("./init")
const { resolve } = require("path") const { start } = require("./start")
const { const { stop } = require("./stop")
downloadFile, const { status } = require("./status")
logErrorToFile, const { update } = require("./update")
success, const { watchPlugins } = require("./watch")
info,
error,
parseEnv,
} = require("../utils")
const { confirmation } = require("../questions")
const fs = require("fs")
const compose = require("docker-compose")
const makeFiles = require("./makeFiles")
const axios = require("axios")
const { captureEvent } = require("../events")
const yaml = require("yaml")
const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"]
const ERROR_FILE = "docker-error.log"
const FILE_URLS = [
"https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml",
]
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
async function downloadFiles() {
const promises = []
for (let url of FILE_URLS) {
const fileName = url.split("/").slice(-1)[0]
promises.push(downloadFile(url, `./${fileName}`))
}
await Promise.all(promises)
}
async function checkDockerConfigured() {
const error =
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
throw error
}
}
function checkInitComplete() {
if (!fs.existsSync(makeFiles.filePath)) {
throw "Please run the hosting --init command before any other hosting command."
}
}
async function handleError(func) {
try {
await func()
} catch (err) {
if (err && err.err) {
logErrorToFile(ERROR_FILE, err.err)
}
throw `Failed to start - logs written to file: ${ERROR_FILE}`
}
}
async function init(type) {
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
await checkDockerConfigured()
if (!isQuick) {
const shouldContinue = await confirmation(
"This will create multiple files in current directory, should continue?"
)
if (!shouldContinue) {
console.log("Stopping.")
return
}
}
captureEvent(AnalyticsEvents.SelfHostInit, {
type,
})
await downloadFiles()
const config = isQuick ? makeFiles.QUICK_CONFIG : {}
if (type === InitTypes.DIGITAL_OCEAN) {
try {
const output = await axios.get(DO_USER_DATA_URL)
const response = parseEnv(output.data)
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
if (response[key]) {
config[value] = response[key]
}
}
} catch (err) {
// don't need to handle error, just don't do anything
}
}
await makeFiles.makeEnv(config)
}
async function start() {
await checkDockerConfigured()
checkInitComplete()
console.log(
info(
"Starting services, this may take a moment - first time this may take a few minutes to download images."
)
)
const port = makeFiles.getEnvProperty("MAIN_PORT")
await handleError(async () => {
// need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true })
})
console.log(
success(
`Services started, please go to http://localhost:${port} for next steps.`
)
)
}
async function status() {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Budibase status"))
await handleError(async () => {
const response = await compose.ps()
console.log(response.out)
})
}
async function stop() {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Stopping services, this may take a moment."))
await handleError(async () => {
await compose.stop()
})
console.log(success("Services have been stopped successfully."))
}
async function update() {
await checkDockerConfigured()
checkInitComplete()
if (await confirmation("Do you wish to update you docker-compose.yaml?")) {
await downloadFiles()
}
await handleError(async () => {
const status = await compose.ps()
const parts = status.out.split("\n")
const isUp = parts[2] && parts[2].indexOf("Up") !== -1
if (isUp) {
console.log(info("Stopping services, this may take a moment."))
await compose.stop()
}
console.log(info("Beginning update, this may take a few minutes."))
await compose.pullMany(BUDIBASE_SERVICES, { log: true })
if (isUp) {
console.log(success("Update complete, restarting services..."))
await start()
}
})
}
async function watchPlugins(pluginPath) {
const PLUGIN_PATH = "/plugins"
// get absolute path
pluginPath = resolve(pluginPath)
if (!fs.existsSync(pluginPath)) {
console.log(
error(
`The directory "${pluginPath}" does not exist, please create and then try again.`
)
)
return
}
const opts = ["docker-compose.yaml", "docker-compose.yml"]
let dockerFilePath = opts.find(name => fs.existsSync(name))
if (!dockerFilePath) {
console.log(error("Unable to locate docker-compose YAML."))
return
}
const dockerYaml = fs.readFileSync(dockerFilePath, "utf8")
const parsedYaml = yaml.parse(dockerYaml)
let service,
serviceList = Object.keys(parsedYaml.services)
if (parsedYaml.services["app-service"]) {
service = parsedYaml.services["app-service"]
} else if (serviceList.length === 1) {
service = parsedYaml.services[serviceList[0]]
}
if (!service) {
console.log(
error(
"Unable to locate service within compose file, is it a valid Budibase configuration?"
)
)
return
}
// set environment variable
service.environment["PLUGINS_DIR"] = PLUGIN_PATH
// add volumes to parsed yaml
if (!service.volumes) {
service.volumes = []
}
const found = service.volumes.find(vol => vol.includes(PLUGIN_PATH))
if (found) {
service.volumes.splice(service.volumes.indexOf(found), 1)
}
service.volumes.push(`${pluginPath}:${PLUGIN_PATH}`)
fs.writeFileSync(dockerFilePath, yaml.stringify(parsedYaml))
console.log(success("Docker compose configuration has been updated!"))
}
const command = new Command(`${CommandWords.HOSTING}`) const command = new Command(`${CommandWords.HOSTING}`)
.addHelp("Controls self hosting on the Budibase platform.") .addHelp("Controls self hosting on the Budibase platform.")
@ -239,6 +39,6 @@ const command = new Command(`${CommandWords.HOSTING}`)
"Add plugin directory watching to a Budibase install.", "Add plugin directory watching to a Budibase install.",
watchPlugins watchPlugins
) )
.addSubOption("--dev", "") .addSubOption("--single", "Specify this with init to use the single image.")
exports.command = command exports.command = command

View File

@ -0,0 +1,63 @@
const { InitTypes, AnalyticsEvents } = require("../constants")
const { confirmation } = require("../questions")
const { captureEvent } = require("../events")
const makeFiles = require("./makeFiles")
const axios = require("axios")
const { parseEnv } = require("../utils")
const { checkDockerConfigured, downloadFiles } = require("./utils")
const { watchPlugins } = require("./watch")
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
async function getInitConfig(type, isQuick) {
const config = isQuick ? makeFiles.QUICK_CONFIG : {}
if (type === InitTypes.DIGITAL_OCEAN) {
try {
const output = await axios.get(DO_USER_DATA_URL)
const response = parseEnv(output.data)
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
if (response[key]) {
config[value] = response[key]
}
}
} catch (err) {
// don't need to handle error, just don't do anything
}
}
return config
}
exports.init = async opts => {
let type, isSingle, watchDir
if (typeof opts === "string") {
type = opts
} else {
type = opts.init
isSingle = opts.single
watchDir = opts.watchPluginDir
}
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
await checkDockerConfigured()
if (!isQuick) {
const shouldContinue = await confirmation(
"This will create multiple files in current directory, should continue?"
)
if (!shouldContinue) {
console.log("Stopping.")
return
}
}
captureEvent(AnalyticsEvents.SelfHostInit, {
type,
})
const config = await getInitConfig(type, isQuick)
if (!isSingle) {
await downloadFiles()
await makeFiles.makeEnv(config)
} else {
await makeFiles.makeSingleCompose(config)
if (watchDir) {
await watchPlugins(watchDir)
}
}
}

View File

@ -4,6 +4,7 @@ const fs = require("fs")
const path = require("path") const path = require("path")
const randomString = require("randomstring") const randomString = require("randomstring")
const yaml = require("yaml") const yaml = require("yaml")
const { getAppService } = require("./utils")
const SINGLE_IMAGE = "budibase/budibase:latest" const SINGLE_IMAGE = "budibase/budibase:latest"
const VOL_NAME = "budibase_data" const VOL_NAME = "budibase_data"
@ -16,12 +17,13 @@ function getSecrets() {
"MINIO_ACCESS_KEY", "MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY", "MINIO_SECRET_KEY",
"COUCH_DB_PASSWORD", "COUCH_DB_PASSWORD",
"COUCH_DB_USER",
"REDIS_PASSWORD", "REDIS_PASSWORD",
"INTERNAL_API_KEY", "INTERNAL_API_KEY",
] ]
const obj = {} const obj = {}
secrets.forEach(secret => (obj[secret] = randomString.generate())) secrets.forEach(secret => (obj[secret] = randomString.generate()))
// hard code to admin
obj["COUCH_DB_USER"] = "admin"
return obj return obj
} }
@ -70,10 +72,13 @@ function getEnv(port) {
].join("\n") ].join("\n")
} }
module.exports.filePath = ENV_PATH exports.ENV_PATH = ENV_PATH
exports.COMPOSE_PATH = COMPOSE_PATH
module.exports.ConfigMap = { module.exports.ConfigMap = {
MAIN_PORT: "port", MAIN_PORT: "port",
} }
module.exports.QUICK_CONFIG = { module.exports.QUICK_CONFIG = {
key: "budibase", key: "budibase",
port: 10000, port: 10000,
@ -112,3 +117,14 @@ module.exports.getEnvProperty = property => {
} }
return property.split("=")[1].split("\n")[0] return property.split("=")[1].split("\n")[0]
} }
module.exports.getComposeProperty = property => {
const appService = getAppService(COMPOSE_PATH)
if (property === "port" && Array.isArray(appService.ports)) {
const port = appService.ports[0]
return port.split(":")[0]
} else if (appService.environment) {
return appService.environment[property]
}
return null
}

View File

@ -0,0 +1,34 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info, success } = require("../utils")
const makeFiles = require("./makeFiles")
const compose = require("docker-compose")
const fs = require("fs")
exports.start = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(
info(
"Starting services, this may take a moment - first time this may take a few minutes to download images."
)
)
let port
if (fs.existsSync(makeFiles.ENV_PATH)) {
port = makeFiles.getEnvProperty("MAIN_PORT")
} else {
port = makeFiles.getComposeProperty("port")
}
await handleError(async () => {
// need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true })
})
console.log(
success(
`Services started, please go to http://localhost:${port} for next steps.`
)
)
}

View File

@ -0,0 +1,17 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info } = require("../utils")
const compose = require("docker-compose")
exports.status = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Budibase status"))
await handleError(async () => {
const response = await compose.ps()
console.log(response.out)
})
}

View File

@ -0,0 +1,17 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info, success } = require("../utils")
const compose = require("docker-compose")
exports.stop = async () => {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Stopping services, this may take a moment."))
await handleError(async () => {
await compose.stop()
})
console.log(success("Services have been stopped successfully."))
}

View File

@ -0,0 +1,48 @@
const {
checkDockerConfigured,
checkInitComplete,
downloadFiles,
handleError,
getServices,
} = require("./utils")
const { confirmation } = require("../questions")
const compose = require("docker-compose")
const { COMPOSE_PATH } = require("./makeFiles")
const { info, success } = require("../utils")
const { start } = require("./start")
const BB_COMPOSE_SERVICES = ["app-service", "worker-service", "proxy-service"]
const BB_SINGLE_SERVICE = ["budibase"]
exports.update = async () => {
const isSingle = Object.keys(getServices(COMPOSE_PATH)).length === 1
await checkDockerConfigured()
checkInitComplete()
if (
!isSingle &&
(await confirmation("Do you wish to update you docker-compose.yaml?"))
) {
await downloadFiles()
}
await handleError(async () => {
const status = await compose.ps()
const parts = status.out.split("\n")
const isUp = parts[2] && parts[2].indexOf("Up") !== -1
if (isUp) {
console.log(info("Stopping services, this may take a moment."))
await compose.stop()
}
console.log(info("Beginning update, this may take a few minutes."))
let services
if (isSingle) {
services = BB_SINGLE_SERVICE
} else {
services = BB_COMPOSE_SERVICES
}
await compose.pullMany(services, { log: true })
if (isUp) {
console.log(success("Update complete, restarting services..."))
await start()
}
})
}

View File

@ -0,0 +1,67 @@
const { lookpath } = require("lookpath")
const fs = require("fs")
const makeFiles = require("./makeFiles")
const { logErrorToFile, downloadFile } = require("../utils")
const yaml = require("yaml")
const ERROR_FILE = "docker-error.log"
const FILE_URLS = [
"https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml",
]
exports.downloadFiles = async () => {
const promises = []
for (let url of FILE_URLS) {
const fileName = url.split("/").slice(-1)[0]
promises.push(downloadFile(url, `./${fileName}`))
}
await Promise.all(promises)
}
exports.checkDockerConfigured = async () => {
const error =
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
throw error
}
}
exports.checkInitComplete = () => {
if (
!fs.existsSync(makeFiles.ENV_PATH) &&
!fs.existsSync(makeFiles.COMPOSE_PATH)
) {
throw "Please run the hosting --init command before any other hosting command."
}
}
exports.handleError = async func => {
try {
await func()
} catch (err) {
if (err && err.err) {
logErrorToFile(ERROR_FILE, err.err)
}
throw `Failed to start - logs written to file: ${ERROR_FILE}`
}
}
exports.getServices = path => {
const dockerYaml = fs.readFileSync(path, "utf8")
const parsedYaml = yaml.parse(dockerYaml)
return parsedYaml.services
}
exports.getAppService = path => {
const services = exports.getServices(path),
serviceList = Object.keys(services)
let service
if (services["app-service"]) {
service = services["app-service"]
} else if (serviceList.length === 1) {
service = services[serviceList[0]]
}
return service
}

View File

@ -0,0 +1,49 @@
const { resolve } = require("path")
const fs = require("fs")
const { error, success } = require("../utils")
const yaml = require("yaml")
const { getAppService } = require("./utils")
exports.watchPlugins = async pluginPath => {
const PLUGIN_PATH = "/plugins"
// get absolute path
pluginPath = resolve(pluginPath)
if (!fs.existsSync(pluginPath)) {
console.log(
error(
`The directory "${pluginPath}" does not exist, please create and then try again.`
)
)
return
}
const opts = ["docker-compose.yaml", "docker-compose.yml"]
let dockerFilePath = opts.find(name => fs.existsSync(name))
if (!dockerFilePath) {
console.log(error("Unable to locate docker-compose YAML."))
return
}
const dockerYaml = fs.readFileSync(dockerFilePath, "utf8")
const parsedYaml = yaml.parse(dockerYaml)
const service = getAppService(dockerFilePath)
if (!service) {
console.log(
error(
"Unable to locate service within compose file, is it a valid Budibase configuration?"
)
)
return
}
// set environment variable
service.environment["PLUGINS_DIR"] = PLUGIN_PATH
// add volumes to parsed yaml
if (!service.volumes) {
service.volumes = []
}
const found = service.volumes.find(vol => vol.includes(PLUGIN_PATH))
if (found) {
service.volumes.splice(service.volumes.indexOf(found), 1)
}
service.volumes.push(`${pluginPath}:${PLUGIN_PATH}`)
fs.writeFileSync(dockerFilePath, yaml.stringify(parsedYaml))
console.log(success("Docker compose configuration has been updated!"))
}

View File

@ -39,10 +39,7 @@ class Command {
command = command.description(getHelpDescription(thisCmd.help)) command = command.description(getHelpDescription(thisCmd.help))
} }
for (let opt of thisCmd.opts) { for (let opt of thisCmd.opts) {
command = command.option( command = command.option(opt.command, getSubHelpDescription(opt.help))
`${opt.command}`,
getSubHelpDescription(opt.help)
)
} }
command.helpOption( command.helpOption(
"--help", "--help",
@ -50,19 +47,25 @@ class Command {
) )
command.action(async options => { command.action(async options => {
try { try {
let executed = false let executed = false,
found = false
for (let opt of thisCmd.opts) { for (let opt of thisCmd.opts) {
let lookup = opt.command.split(" ")[0].replace("--", "") let lookup = opt.command.split(" ")[0].replace("--", "")
// need to handle how commander converts watch-plugin-dir to watchPluginDir // need to handle how commander converts watch-plugin-dir to watchPluginDir
lookup = this.convertToCommander(lookup) lookup = this.convertToCommander(lookup)
if (!executed && options[lookup]) { found = !executed && options[lookup]
if (found && opt.func) {
const input = const input =
Object.keys(options).length > 1 ? options : options[lookup] Object.keys(options).length > 1 ? options : options[lookup]
await opt.func(input) await opt.func(input)
executed = true executed = true
} }
} }
if (!executed) { if (found && !executed) {
console.log(
error(`${Object.keys(options)[0]} is an option, not an operation.`)
)
} else if (!executed) {
console.log(error(`Unknown ${this.command} option.`)) console.log(error(`Unknown ${this.command} option.`))
command.help() command.help()
} }

View File

@ -33,11 +33,10 @@ class ConfigManager {
} }
setValue(key, value) { setValue(key, value) {
const updated = { this.config = {
...this.config, ...this.config,
[key]: value, [key]: value,
} }
this.config = updated
} }
removeKey(key) { removeKey(key) {