diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 84ee19d..3a1260e 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -2,8 +2,8 @@ name: Release Chemotion CLI on: push: - branches: - - chemotion-cli + tags: + - "*" jobs: build-release-binary: @@ -12,28 +12,24 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: - go-version: "1.18" + go-version: "1.19" check-latest: true - name: Build Go for all OSes run: | cd chemotion-cli go mod verify - GOOS=linux GOARCH=amd64 go build -o chemotion - GOOS=darwin GOARCH=arm64 go build -o chemotion.arm.osx - GOOS=darwin GOARCH=amd64 go build -o chemotion.amd.osx - GOOS=windows GOARCH=amd64 go build -o chemotion.exe - mv chemotion .. - mv chemotion.arm.osx .. - mv chemotion.amd.osx .. - mv chemotion.exe .. + GOOS=linux GOARCH=amd64 go build -o ../chemotion + GOOS=darwin GOARCH=arm64 go build -o ../chemotion.arm.osx + GOOS=darwin GOARCH=amd64 go build -o ../chemotion.amd.osx + GOOS=windows GOARCH=amd64 go build -o ../chemotion.exe - name: Release Binaries uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest" prerelease: false files: | chemotion chemotion.arm.osx chemotion.amd.osx chemotion.exe + docker-compose.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..fea5035 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Announcement + +Please note that the Chemotion CLI tool project has been renamed [ChemCLI and has a new home](https://github.com/Chemotion/ChemCLI). This repository is no longer maintained. diff --git a/chemotion-cli/CHANGELOG.md b/chemotion-cli/CHANGELOG.md new file mode 100644 index 0000000..c7e3151 --- /dev/null +++ b/chemotion-cli/CHANGELOG.md @@ -0,0 +1,87 @@ +# CHANGELOG of Chemotion CLI tool + +## Version 0.2.0-alpha + +The main changes are as follows: + +- `chemotion instance consoles` command that allows you to enter the console of a running instance. +- `chemotion instance ping` command that checks if the instance is up, running and available at the specified URL. +- `chemotion advanced update` command that updates the CLI tool itself. The tool now also checks (once a day) if an update to itself is available and displays a reminder if this is the case. +- `chemotion instance new` now uses the latest availble `docker-compose.yml` file, not a hard-coded one. +- The tool now copies the entries in the `instances::environment` section to the `./instances//shared/pullin/.env` file whenever an instance is turned on (or restarted). The idea is that a user can edit the `chemotion-cli.yml` file (more) easily. +- Responsive menus: only actions that make sense are displayed e.g. the main menu shows an `off` option if the selected instance is already running. +- `Back` option: It is now (mostly) possible to go to the menu above using a `back` option. The tool exits once **a task** is completed -- this is an intended feature. +- The tool is (milliseconds) slower to start now precisely because i now checks on the status of the instance on launch. +- Bugfixes +- _Changes that effect the user's files are as follows.:_ + +> The first difference is formatting of the `chemotion-cli.yml` file. + +- The global keys that handle state of the tool i.e. `selected`, `quiet` and `debug` have now been moved to `cli_state:selected`, `cli_state:quiet` and `cli_state:debug` respectively. +- The `instances::address` and `instances::protocol` keys have been removed. Instead, we have `instances::accessaddress` which stores the full URL that is used to access the ELN instance. +- A new key called `instances::environment` has been introduced. This is now used to create the `shared/pullin/.env` file **everytime** the instance is (re)started. **Please** move all your `key=value` pairs from this `.env` file to the `chemotion-cli.yml` in `key: value` format as sub-keys of the `instances::environment` key. +- With these changes, the version of this YAML file has been changed from `"1.0"` to `"1.1"`. + +Therefore, if your file looked as follows: + +```yaml +instances: + main: + address: mynotebook.kit.edu + debug: false + kind: Production + name: main-ee5e5424 + port: 4000 + protocol: http + quiet: false + second: + address: localhost + debug: false + kind: Production + name: second-ff6f6535 + port: 4100 + protocol: http + quiet: false +selected: main +version: "1.0" +``` + +It should now look as follows: + +```yaml +cli_state: + debug: false + quiet: false + selected: main +instances: + main: + accessaddress: http://mynotebook.kit.edu + environment: + url_host: ifgs6.ifg.kit.edu + url_protocol: http + kind: Production + name: main-ee5e5044 + port: 4000 + second: + accessaddress: http://localhost:4100 + environment: + url_host: localhost:4100 + url_protocol: http + smtp_port: ...... + kind: Production + name: second-ff6f6535 + port: 4100 +version: "1.1" +``` + +> The second difference is splitting of `docker-compose.yml` file into two files. + +So far dockerized installations of Chemotion have relied on `docker-compose.yml` file from [here](https://github.com/ptrxyz/chemotion). + +The CLI in version 0.1.x-alpha diverged from this by modifying the file to suit the needs of the CLI by + +1. changing the `services:eln:ports` key +2. including this label on `networks`, `services` and `volumes`: `net.chemotion.cli.project: - +3. including names on the `volumes` so that they are named the following: `-_chemotion_`. + +Version 0.2.x onwards, we refrain from modifying the `docker-compose.yml` file, making only one change in it (Change 1. is still done.). Changes 2. and 3. are inlcuded in the configuration by adding a new file called `docker-compose.cli.yml` (that we use in addition to the `docker-compose.yml` file). The `docker compose` tool seamlessly merges the two files when reading them. diff --git a/chemotion-cli/README.md b/chemotion-cli/README.md index b4a8075..2e262cf 100644 --- a/chemotion-cli/README.md +++ b/chemotion-cli/README.md @@ -1,19 +1,21 @@ # Chemotion-CLI -## - Chemotion CLI tool is there to help you manage installation(s) of Chemotion on a machine. The goal is to make installation, maintenance and upgradation of Chemotion as easy as possible. -## Installation +> :information_source: [Link to quick intro video](https://youtu.be/10fk2C6qku0) + +## Download -### Download the binary +### Get the binary -The Chemotion CLI tool is a binary file and needs no installation. The only prerequisite is that you install [Docker Desktop](https://www.docker.com/products/docker-desktop/) (and, on Windows, [WSL](https://docs.microsoft.com/en-us/windows/wsl/install)). Depending on your OS, you can download the lastest release of the CLI from here: +The Chemotion CLI tool is a binary file and needs no installation. The only prerequisite is that you install [Docker Desktop](https://www.docker.com/products/docker-desktop/) (and, on Windows, [WSL](https://docs.microsoft.com/en-us/windows/wsl/install)). Depending on your OS, you can download the lastest release of the CLI from [here](https://github.com/harivyasi/chemotion/releases/latest). Builds for the following systems are available: -- [Linux, amd64](https://github.com/harivyasi/chemotion/releases/download/latest/chemotion) -- [Windows, amd64](https://github.com/harivyasi/chemotion/releases/download/latest/chemotion.exe); remember to turn on [Docker integration with WSL](https://docs.docker.com/desktop/windows/wsl/). -- [macOS, apple-silicon](https://github.com/harivyasi/chemotion/releases/download/latest/chemotion.arm.x) -- [macOS, amd64](https://github.com/harivyasi/chemotion/releases/download/latest/chemotion.amd.x) +- Linux, amd64 +- Windows, amd64; remember to turn on [Docker integration with WSL](https://docs.docker.com/desktop/windows/wsl/) +- macOS, apple-silicon +- macOS, amd64 + +Please be sure that you have both, `docker` and `docker compose` commands. This should be the case if you install Docker Desktop following the instructions [here](https://docs.docker.com/desktop/#download-and-install). If you choose to install only Docker Engine, then please make sure that you _also_ have `docker compose` as a command (as opposed to `docker-compose`). ### Make it an executable @@ -21,72 +23,109 @@ On Linux, make this file executable by doing: `chmod u+x chemotion`. On Windows, the file should be executable by default, i.e. do nothing. -On macOS, make this file executable by doing: `chmod u+x chemotion.amd.x` or `chmod u+x chemotion.arm.x`. If the there is a security pop-up when running the command, please also `Allow` the executable in `System Preferences > Security & Privacy`. +On macOS, make this file executable by doing: `chmod u+x chemotion.amd.osx` or `chmod u+x chemotion.arm.osx`. If the there is a security pop-up when running the command, please also `Allow` the executable in `System Preferences > Security & Privacy`. ### Important Note: -All commands here, and in the documentation, use term `chemotion` to refer to the executable. Depending on your configuration, you may have to use any one of the following: +All commands here, and all the documentation of the tool, use term `chemotion` to refer to the executable. Depending on your configuration, you may have to use any one of the following: - `./chemotion` - `.\chemotion.exe` -- `./chemotion.arm.x` -- `./chemotion.amd.x` +- `./chemotion.arm.osx` +- `./chemotion.amd.osx` -### First run +## First run -#### Make a dedicated folder +### Make a dedicated folder Make a folder where you want to store installation(s) of Chemotion. Ideally this folder should be in the largest drive (in terms of free space) of your system. Remember that Chemotion also uses space via Docker (docker containers, volumes etc.) and therefore you need to make sure that your system partition has abundant free space. -#### Install +### Install To begin with installation, execute: `chemotion install` and follow the prompt. The first installation can take really long time (15-30 minutes depending on your download and processor speeds). This will create the first (production-grade) `instance` of Chemotion on your system. Generally, this is suffice if you want to use Chemotion in a single scientific group/lab. By default - this first instance will be available on port 4000 -- this first instance will be the `chosen` instance (more on this below) +- this first instance will be the `selected` instance. + +> :warning: **chemotion-cli.yml**: Installation also creates a file called `chemotion-cli.yml`. This file is critical as it contains information regarding existing installations. Removing the file will render the CLI clueless about existing installations and it will behave as if Chemotion was never installed. Please do not remove the file. Ideally there should be no need for you to modify it manually. + +### The `selected` instance + +Once you install multiple instances of Chemotion, the actions of CLI will pertain to only one of them i.e. you will be managing only one of them. This instance is referred to as the `selected` instance and it's name is stored in a local file (`chemotion-cli.yml`). You can do `chemotion instance switch` to switch to another instance. + +You can also select an instance _temporarily_ by giving its name to the CLI as a flag when you start it e.g. `chemotion instance status --instance the-other-one`. -#### Start and Stop Chemotion +### Start and Stop Chemotion -To turn on, or off, the `chosen` instance, issue the commands: +To turn on, and off, the `chosen` instance, issue the commands: -- `chemotion on`, please wait for a minute before the instance becomes fully active +- `chemotion on`, and - `chemotion off`. -## Uninstallation +### Upgrading an instance (for versions 1.3 and above) -> Usual warning of "be sure about what you want to do" applies! +As long as you installed an instance of Chemotion using this tool, the upgrade process is quite straightforward: -You can uninstall everything created by the CLI tool by running: `chemotion advanced uninstall`. Last you can simply delete the downloaded binary itself. +- First make sure that you have the latest version of this tool. You can check the version of your chemotion binary by doing `chemotion --version`. If necessary, follow the instructions in the [download](#download) section again. Feel free to replace the existing `chemotion` file. DO NOT remove/replace the `chemotion-cli.yml` file. +- Prepare for update by running `chemotion advanced pull-image`. This will download the latest chemotion image from the internet if not already present on the system. Downloading the image outside of downtime saves you time later on. +- Schedule a downtime of at least 15 minutes; more if you have a lot of data that needs to backed up. During the downtime, run `chemotion instance backup` to backup your data followed by `chemotion instance upgrade` to update the instance. -# Planned concept for CLI +### Updating this CLI tool -Following features are planned/thought of: +Starting from version `0.2.0-alpha`, the tool itself can be updated to the latest version by running `chemotion advanced update`. -- Installation & Deployment: we plan to implement `chemotion instance install` to install a Chemotion instance -- Upgrade: use `chemotion instance upgrade` to upgrade an existing Chemotion instance -- Backups: `chemotion snapshot create|restore` to savely store your data somewhere -- Instance life cycle commands, such as `chemotion instance start|stop|pause|restart|status` -- Manage Settings: `chemotion settings import|export` to import/export you settings and `chemotion instance configure` to run configuration wizards that help you to create configuration stubs -- Frequently asked for features for the Chemotion Administrator: `chemotion user show|add|delete|password-reset`, `chemotion system info|rails-shell|shell` +> :information_source: If you are updating from version `0.1.x-alpha`, please download the version `0.2.x-alpha` and run it in the same place. This will modify the configuration to refect changes described at the bottom of this page. -We plan to follow one of the following layouts, depending on which one proves to be more handy in every day use. +### Uninstallation -``` -general: cli-executable - └─────┬──────┘ └───┬────┘ └───┬───┘ └───┬────┘ └──┬──┘ -example: chemotion instance restart MyInstance --force -``` +> :warning: be sure about what you want to do! +You can uninstall everything created by the CLI tool by running: `chemotion advanced uninstall`. Last you can simply delete the downloaded binary itself. + +## Silent and Debug Use + +Almost all features of the CLI can be used in silent mode i.e. without any input from user as long as all required pieces of information have been provided using flags. In silent mode, most of the output from the CLI (but not that of docker) is logged only in the log file, and not put on screen. + +To use the CLI in silent mode, add the flag `-q`/`--quiet` to your command. The CLI will then use default values and other flags to try and accomplish the action. Examples: + +```bash +./chemotion install -q --name first-instance --address https://myuni.de:3000 +./chemotion instance switch --name switch-to-this-instance -q ``` -general: cli-executable - └─────┬──────┘ └───┬───┘ └───┬────┘ └───┬────┘ └──┬──┘ -example: chemotion restart instance MyInstance --force -``` -# Known limitations and bugs +Similarly, the CLI can be run in Debug mode when you encounter an error. This produces a very detailed log file containing a trace of actions you undertake. Telling us about the error and sending us the log file can help us a lot when it comes to helping you. + +## Known limitations and bugs - The following flags cannot be specified in the configuration (`chemotion-cli.yml`) file: - `--config`: because that creates a circular dependency - `chemotion off`: does not lead to exit of containers with exit code 0. +- Everything happens in the folder (and subfolders) where `chemotion` is executed. All files and folders are expected to be there; otherwise failures can happen. + +# Planned concept for CLI + +The commands have the following general layout: + +``` +general: cli-executable + └─────┬──────┘ └───┬────┘ └───┬───┘ └──┬──┘ +example: chemotion instance restart --force +``` + +Following features are exist: + +- ✔ Installation & Deployment: `chemotion install` installs a production instance that is ready to use. +- ✔ Instance life cycle commands: `chemotion on|off` and `chemotion instance status|stats|list|restart`. +- ✔ Multiple instances: `chemotion instance add|switch|remove` can be used to manage multiple instances. +- ✔ Upgrade: use `chemotion instance upgrade` to upgrade an existing Chemotion instance. +- ✔ Backups: use `chemotion instance backup` to save the data associated with an instance. +- ✔ Shell access: using `chemotion instance console` to access shell/rails/SQL console of an instance + +Following features are planned: + +- Restore backup: `chemotion restore` +- Manage Settings: `chemotion instance settings --import|--export` to import/export settings and to run auto-configuring wizards. +- Features for the Chemotion Administrator: `chemotion user show|add|delete|password-reset` +- Command to manage underlying docker installation i.e. free up space and prune network diff --git a/chemotion-cli/cli/configure.go b/chemotion-cli/cli/configure.go deleted file mode 100644 index 8ca82d6..0000000 --- a/chemotion-cli/cli/configure.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/rs/zerolog" -) - -// Viper is used to load values from config file. Cobra is the basis of our command line interface. -// This function uses Viper to set flags on Cobra. -// (See how cool this sounds, make sure you pick fun project names!) -func initConf() { - zlog.Debug().Msg("Start initConf()") - // check status of the config flag - // if changed and specified file is not found then exit - // otherwise set the value of configFile on viper - // then use the path as determined by viper and set as the value of configFile - if rootCmd.Flag("config-file").Changed && !existingFile(configFile) { - // here configFile should be same as rootCmd.Flag("config-file").Value.String() - zboth.Fatal().Err(fmt.Errorf("specified config file not found")).Msgf("Please ensure that the file you specify using --config/-f flag does exist.") - } - conf.SetConfigFile(configFile) - configFile = conf.ConfigFileUsed() - zlog.Debug().Msg("Attempting to read configuration file") - // if the flag is not changed, check for the posibility of first run - if existingFile(configFile) { - firstRun = false - // Try and read the configuration file, then unmarshal it - if err := conf.ReadInConfig(); err == nil { - if errUnmarshal := conf.UnmarshalKey(selector_key, ¤tState.name); errUnmarshal != nil { - zboth.Fatal().Err(fmt.Errorf("unmarshal failed")).Msgf("Failed to find the mandatory key %s in the file: %s.", selector_key, configFile) - } - if !conf.IsSet(joinKey("instances", currentState.name)) { - zboth.Fatal().Err(fmt.Errorf("unmarshal failed")).Msgf("Failed to find the description for instance `%s` in the file: %s.", currentState.name, configFile) - } - if errUnmarshal := conf.UnmarshalKey(joinKey("instances", currentState.name), ¤tState); err == nil { - zboth.Info().Msgf("Read configuration file: %s.", configFile) - if currentState.debug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) // escalate the debug level if said so by the config file - } // don't do else because flags have the final say! - } else { - zboth.Fatal().Err(errUnmarshal).Msg("Failed to map values from configuration file. ABORT!") - } - } else { - zboth.Fatal().Err(err).Msgf("Failed to read configuration file: %s. ABORT!", configFile) - } - } - zlog.Debug().Msgf("End: initConf(), Config found?: %t, is Inside?: %t", !firstRun, currentState.isInside) -} diff --git a/chemotion-cli/cli/helper-io.go b/chemotion-cli/cli/helper-io.go new file mode 100644 index 0000000..f1d6e6c --- /dev/null +++ b/chemotion-cli/cli/helper-io.go @@ -0,0 +1,128 @@ +package cli + +import ( + "os" + "os/exec" + "strings" + + "github.com/cavaliergopher/grab/v3" + "github.com/chigopher/pathlib" +) + +// debug level logging of where we are running at the moment +func logwhere() { + if isInContainer { + if currentInstance == "" { + zlog.Debug().Msgf("Running inside an unknown container") // TODO: read .version file or get from environment + } else { + zlog.Debug().Msgf("Running inside `%s`", currentInstance) + } + } else { + if currentInstance == "" { + zlog.Debug().Msgf("Running on host machine; no instance selected yet") + } else { + zlog.Debug().Msgf("Running on host machine; selected instance: %s", currentInstance) + } + } + zlog.Debug().Msgf("Called as: %s", strings.Join(os.Args, " ")) +} + +// to rewrite the configuration file +func rewriteConfig() (err error) { + var ( + keysToPreserve = []string{joinKey(stateWord, "quiet"), joinKey(stateWord, "debug")} + preserve = make(map[string]any) + ) + if !firstRun { // backup values + oldConf := parseCompose(conf.ConfigFileUsed()) + for _, key := range keysToPreserve { + preserve[key] = conf.GetBool(key) // backup key into memory + conf.Set(key, oldConf.GetBool(key)) // set conf's key to what is read from existing file + } + } + // write to file + if err = conf.WriteConfig(); err == nil { + zboth.Debug().Msgf("Modified configuration file `%s`.", conf.ConfigFileUsed()) + } else { + zboth.Warn().Err(err).Msgf("Failed to update the configuration file.") + } + if !firstRun { // restore values in conf from memory + for _, key := range keysToPreserve { + conf.Set(key, preserve[key]) + } + } + return +} + +// check if file exists, and is a file (keep it simple, runs before logging starts! +func existingFile(filePath string) (exists bool) { + exists, _ = pathlib.NewPath(filePath).IsFile() + return +} + +// download a file, filepath is respective to current working directory +func downloadFile(fileURL string, downloadLocation string) (filepath pathlib.Path) { + zboth.Debug().Msgf("Trying to download %s to %s", fileURL, downloadLocation) + if resp, err := grab.Get(downloadLocation, fileURL); err == nil { + zboth.Debug().Msgf("Downloaded file saved as: %s", resp.Filename) + filepath = *pathlib.NewPath(resp.Filename) + } else { + zboth.Fatal().Err(err).Msgf("Failed to download file from: %s. Check log. ABORT!", fileURL) + } + return +} + +// to copy a file +func copyfile(source, destination string) (err error) { + file := *pathlib.NewPath(source) + var read []byte + read, err = file.ReadFile() + if err == nil { + err = pathlib.NewPath(destination).WriteFile(read) + } + return +} + +// change directory with logging +func gotoFolder(givenName string) (pwd string) { + var folder string + if givenName == "workdir" { + folder = "../.." + } else { + folder = workDir.Join(instancesWord, getInternalName(givenName)).String() + } + if err := os.Chdir(folder); err == nil { + pwd, _ = os.Getwd() + zboth.Debug().Msgf("Changed working directory to: %s", pwd) + } else { + zboth.Fatal().Msgf("Failed to changed working directory as required.") + } + return +} + +// execute a command in shell +func execShell(command string) (result []byte, err error) { + if result, err = exec.Command(shell, "-c", command).CombinedOutput(); err == nil { + zboth.Debug().Msgf("Sucessfully executed shell command: %s", command) + } else { + zboth.Warn().Err(err).Msgf("Failed execution of command: %s", command) + } + return +} + +// to be called from the folder where file exists +func changeKey(filename string, key string, value string) (err error) { + var where string + where, err = os.Getwd() + if err == nil { + if existingFile(filename) { + if success := callVirtualizer(toSprintf("run --rm -v %s:/workdir mikefarah/yq eval -i .%s=\"%s\" %s", where, key, value, filename)); !success { + err = toError("failed to update %s in %s", key, filename) + return + } + } else { + err = toError("file %s not found", filename) + } + } + return +} diff --git a/chemotion-cli/cli/helper-run.go b/chemotion-cli/cli/helper-run.go new file mode 100644 index 0000000..4ecb12d --- /dev/null +++ b/chemotion-cli/cli/helper-run.go @@ -0,0 +1,162 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// check if the CLI is running interactively; if no and fail, then exit. Wrapper around conf.GetBool(joinKey(stateWord,"quiet")). +func isInteractive(fail bool) (interactive bool) { + interactive = true + if conf.GetBool(joinKey(stateWord, "quiet")) { + if interactive = false; fail { + zboth.Fatal().Err(toError("incomplete in quiet mode")).Msgf("%s is in quiet mode. Give all arguments to specify the desired action; use '--help' flag for more. ABORT!", nameCLI) + } + } + if isInContainer { + if interactive = false; fail { + zboth.Fatal().Err(toError("inside container in interactive mode")).Msgf("%s CLI is not meant to executed interactively from within a container. Use the `-q` flag. ABORT!", nameCLI) + } + } + return +} + +// check if an element is in an array of type(element), if yes, return the 1st index, else -1. +func elementInSlice[T uint64 | int | float64 | string](elem T, slice *[]T) int { + for index, element := range *slice { + if element == elem { + return index + } + } + return -1 +} + +// generate a new UID (of the form xxxxxxxx) as a string +func getNewUniqueID() string { + id, _ := uuid.NewRandom() + return strings.Split(id.String(), "-")[0] +} + +// to manage config files as loaded into Viper +func getSubHeadings(configuration *viper.Viper, key string) (subheadings []string) { + for k := range configuration.GetStringMapString(key) { + subheadings = append(subheadings, k) + } + return +} + +// join keys so as to access them in a viper configuration +func joinKey(s ...string) (result string) { + result = strings.Join(s, ".") + return +} + +// to lower case, same as strings.ToLower +var toLower = strings.ToLower + +// to shorten fmt statements +var toError = fmt.Errorf +var toSprintf = fmt.Sprintf + +// toBool +func toBool(s string) (value bool) { + if toLower(s) == "true" { + value = true + } else if toLower(s) == "false" { + value = false + } else { + err := toError("cannot convert %s to bool", s) + zboth.Fatal().Err(err).Msgf(err.Error()) + } + return +} + +// determine if the command was called on its own (true) or access via a menu (false) +func ownCall(cmd *cobra.Command) bool { + return len(cmd.Commands()) == 0 // a command is accessed on its own if there are no child commands +} + +// to get all existing instances as determined by the configuration file +func allInstances() (instances []string) { + instances = getSubHeadings(&conf, instancesWord) + return +} + +// to get all existing used ports +func allPorts() (ports []uint64) { + existingInstances := allInstances() + for _, instance := range existingInstances { + ports = append(ports, conf.GetUint64(joinKey(instancesWord, instance, "port"))) + } + return +} + +// get internal name for an instance +func getInternalName(givenName string) (name string) { + if err := instanceValidate(givenName); err == nil { + name = conf.GetString(joinKey(instancesWord, givenName, "name")) + } else { + zboth.Fatal().Err(err).Msgf("No such instance: %s", givenName) + } + return +} + +// get column associated with `ps` output for a given instance of chemotion +func getColumn(givenName, column, service string) (values []string) { + name := getInternalName(givenName) + filterStr := toSprintf("--filter \"label=net.chemotion.cli.project=%s\"", name) + if service != "" { + filterStr += toSprintf(" --filter name=%s", service) + } + if res, err := execShell(toSprintf("%s ps -a %s --format \"{{.%s}}\"", toLower(virtualizer), filterStr, column)); err == nil { + values = strings.Split(string(res), "\n") + } else { + values = []string{} + } + return +} + +// get services associated with a given `instance` of Chemotion +func getServices(givenName string) (services []string) { + name, out := getInternalName(givenName), getColumn(givenName, "Names", "") + for _, line := range out { // determine what are the status messages for all associated containers + l := strings.TrimSpace(line) // use only the first word + if len(l) > 0 { + l = strings.TrimPrefix(l, toSprintf("%s-", name)) + l = strings.TrimSuffix(l, toSprintf("-%d", rollNum)) + services = append(services, l) + } + } + return +} + +// get container ID associated with a given `instance` and `service` of Chemotion +func getContainerID(givenName, service string) (id string) { + out := getColumn(givenName, "ID", service) + if len(out) == 2 { + id = out[0] + } else { + id = "not found" + } + return +} + +// split address into subcomponents +func splitAddress(full string) (protocol string, address string, port uint64) { + if err := addressValidate(full); err != nil { + zboth.Fatal().Err(err).Msgf("Given address %s is invalid.", full) + } + protocol, address, _ = strings.Cut(full, ":") + address = strings.TrimPrefix(address, "//") + address, portStr, _ := strings.Cut(address, ":") + if port = 0; portStr != "" { + p, _ := strconv.Atoi(portStr) + port = uint64(p) + } + return +} diff --git a/chemotion-cli/cli/helper.go b/chemotion-cli/cli/helper.go deleted file mode 100644 index 2238767..0000000 --- a/chemotion-cli/cli/helper.go +++ /dev/null @@ -1,293 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "os/exec" - "runtime" - "strconv" - "strings" - "time" - - "github.com/cavaliergopher/grab/v3" - "github.com/chigopher/pathlib" - "github.com/google/uuid" - vercompare "github.com/hashicorp/go-version" - "github.com/schollz/progressbar/v3" - "github.com/spf13/viper" -) - -var versionSuffix string = " --version" - -// check if file exists, and is a file (keep it simple, runs before logging starts! -func existingFile(filePath string) (exists bool) { - exists, _ = pathlib.NewPath(filePath).IsFile() - return -} - -// check if the CLI is running interactively; if not, then exit. Wrapper around currentState.quiet. -func confirmInteractive() { - if currentState.quiet { - zboth.Fatal().Err(fmt.Errorf("incomplete in quiet mode")).Msgf("%s is in quiet mode. Give all arguments to specify the desired action; use '--help' flag for more. ABORT!", nameCLI) - } - if currentState.isInside { - zboth.Fatal().Err(fmt.Errorf("inside container in interactive mode")).Msgf("%s CLI is not meant to executed interactively from within a container. Use the `-q` flag. ABORT!", nameCLI) - } -} - -func confirmInstalled() { - confirmVirtualizer(minimumVirtualizer) - if firstRun { - // Println output so that user is not discouraged by a FATAL error on-screen... especially when beginning with the tool. - msg := fmt.Sprintf("Please install %s by running `%s` before using it.", nameCLI, "chemotion install") - fmt.Println(msg) - zlog.Fatal().Err(fmt.Errorf("chemotion not installed")).Msgf(msg) - } -} - -// check if a string is an array of strings, if yes, return the 1st index, else -1. -func stringInArray(str string, strings *[]string) int { - for index, element := range *strings { - if element == str { - return index - } - } - return -1 -} - -// check if a int is an array of int, if yes, return the 1st index, else -1. -func intInArray(num int, array *[]int) int { - for index, element := range *array { - if element == num { - return index - } - } - return -1 -} - -// execute a command in shell -func execShell(command string) (result []byte, err error) { - if result, err = exec.Command(shell, "-c", command).CombinedOutput(); err == nil { - zlog.Debug().Msgf("Sucessfully executed shell command: %s", command) - } else if !strings.HasSuffix(command, versionSuffix) { - zboth.Warn().Err(err).Msgf("Failed execution of command: %s", command) - } - return -} - -// find version of a given software (using its command) -func findVersion(software string) (version string) { - ver, err := execShell(software + versionSuffix) - version = strings.TrimSpace(strings.Split(strings.TrimPrefix(strings.TrimPrefix(string(ver), "v"), "Docker version "), ",")[0]) // TODO: Regexify! - if err != nil { - zboth.Warn().Err(err).Msgf("Version determination of %s failed", software) - if virtualizer == "Docker" && err.Error() == "exit status 1" && runtime.GOOS == "linux" { - version = "Docker on WSL not running!" - } else if err.Error() == "exit status 127" { - version = "Unknown / not installed or found!" // 127 is software not found - } else { - version = err.Error() - } - } - return -} - -// confirm a minimum version for a given software -func compareSoftwareVersion(min string, software string) error { - var minimum, current *vercompare.Version - if ver, err := vercompare.NewVersion(findVersion(software)); err == nil { - current = ver - } else { - return err - } - if ver, err := vercompare.NewVersion(min); err == nil { - minimum = ver - } else { - return err - } - if current.LessThan(minimum) { - return fmt.Errorf("current version of %s: %s is less than the minimum required: %s", software, current.String(), minimum.String()) - } - return nil -} - -// generate a new UID (of the form xxxxxxxx) as a string -func getNewUniqueID() string { - id, _ := uuid.NewRandom() - return strings.Split(id.String(), "-")[0] -} - -// download a file, filepath is respective to current working directory -func downloadFile(fileURL string, downloadLocation string) (filepath pathlib.Path) { - if resp, err := grab.Get(downloadLocation, fileURL); err == nil { - zboth.Info().Msgf("Downloaded file saved as: %s", resp.Filename) - filepath = *pathlib.NewPath(downloadLocation).Join(resp.Filename) - } else { - zboth.Fatal().Err(err).Msgf("Failed to download file from: %s. Check log. ABORT!", fileURL) - } - return -} - -// show (and then remove) a progress bar that waits on screen for given seconds to lapse -func waitProgressBar(seconds int, message []string) { - bar := progressbar.NewOptions(seconds, - progressbar.OptionSetDescription(strings.Join(message, " ")+"..."), - progressbar.OptionSetPredictTime(false), - progressbar.OptionClearOnFinish(), - ) - for i := 0; i < seconds; i++ { - time.Sleep(1 * time.Second) - bar.Add(1) - } - bar.Finish() -} - -// copy a text file -// func copyTextFile(source *pathlib.Path, target *pathlib.Path) (err error) { -// fmt.Println(source.String(), target.String()) -// if reader, errRead := source.ReadFile(); err == nil { -// if errWrite := target.WriteFile(reader); err != nil { -// err = errWrite -// } -// } else { -// err = errRead -// } -// return -// } - -// to manage config files as loaded into Viper -func getKeysValues(configuration *viper.Viper, key string) (keys, values []string) { - for k, v := range configuration.GetStringMapString(key) { - keys = append(keys, k) - values = append(values, v) - } - return -} - -// join keys so as to access them in a viper configuration -func joinKey(s ...string) (result string) { - result = strings.Join(s, ".") - return -} - -// to lower case, same as strings.ToLower -var toLower = strings.ToLower - -// to get all existing instances as determined by the configuration file -func allInstances() (instances []string) { - instances, _ = getKeysValues(&conf, "instances") - return -} - -// to get all existing used ports -func allPorts() (ports []int) { - existingInstances := allInstances() - for _, instance := range existingInstances { - ports = append(ports, int(conf.GetUint32(joinKey("instances", instance, "port")))) - } - return -} - -// get internal name for an instance -func getInternalName(givenName string) (name string) { - name = conf.GetString(joinKey("instances", givenName, "name")) - if name == "" { - zboth.Fatal().Err(fmt.Errorf("instance not found")).Msgf("No such instance: %s", givenName) - } - return -} - -// get column associated with `ps` output for a given instance of chemotion -func getColumn(givenName, column string) (values []string) { - name := getInternalName(givenName) - if res, err := execShell(fmt.Sprintf("%s ps -a --filter \"label=net.chemotion.cli.project=%s\" --format \"{{.%s}}\"", toLower(virtualizer), name, column)); err == nil { - values = strings.Split(string(res), "\n") - } else { - values = []string{} - } - return -} - -// get services associated with a given `instance` of Chemotion -func getServices(givenName string) (services []string) { - name, out := getInternalName(givenName), getColumn(givenName, "Names") - for _, line := range out { // determine what are the status messages for all associated containers - l := strings.TrimSpace(line) // use only the first word - if len(l) > 0 { - l = strings.TrimPrefix(l, fmt.Sprintf("%s-", name)) - l = strings.TrimSuffix(l, fmt.Sprintf("-%d", rollNum)) - services = append(services, l) - } - } - return -} - -// change directory with logging -func gotoFolder(givenName string) (pwd string) { - var folder string - if givenName == "workdir" { - folder = "../.." - } else { - folder = workDir.Join(instancesFolder, getInternalName(givenName)).String() - } - if err := os.Chdir(folder); err == nil { - pwd, _ = os.Getwd() - zboth.Debug().Msgf("Changed working directory to: %s", pwd) - } else { - zboth.Fatal().Msgf("Failed to changed working directory as required.") - } - return -} - -// split address into subcomponents -func splitAddress(full string) (protocol string, address string, port int) { - if err := addressValidate(full); err != nil { - zboth.Fatal().Err(err).Msgf("Given address %s is invalid.", full) - } - protocol, address, _ = strings.Cut(full, ":") - address = strings.TrimPrefix(address, "//") - address, portStr, _ := strings.Cut(address, ":") - if port = -1; portStr != "" { - port, _ = strconv.Atoi(portStr) - } - return -} - -// TODO - -// Start shell for user -// var shellSystemRootCmd = &cobra.Command{ -// Use: "shell", -// SuggestFor: []string{"she"}, -// Args: cobra.NoArgs, -// Run: func(cmd *cobra.Command, args []string) { -// logWhere() -// confirmInstalled() -// fmt.Println("We are now going to start shell") -// }, -// } - -// Start a rails shell for user -// var railsSystemRootCmd = &cobra.Command{ -// Use: "rails", -// SuggestFor: []string{"rai"}, -// Args: cobra.NoArgs, -// Run: func(cmd *cobra.Command, args []string) { -// logWhere() -// confirmInstalled() -// fmt.Println("We are now going to start Rails shell") -// }, -// } - -// example starter - -// var uninstallAdvancedRootCmd = &cobra.Command{ -// Use: "uninstall", -// Args: cobra.NoArgs, -// Short: fmt.Sprintf("Uninstall %s completely.", nameCLI), -// Run: func(cmd *cobra.Command, args []string) { -// logWhere() -// confirmInstalled() -// confirmInteractive() -// }, -// } diff --git a/chemotion-cli/cli/initialize.go b/chemotion-cli/cli/initialize.go new file mode 100644 index 0000000..cde265d --- /dev/null +++ b/chemotion-cli/cli/initialize.go @@ -0,0 +1,179 @@ +package cli + +import ( + "os" + "time" + + "github.com/chigopher/pathlib" + "github.com/rs/zerolog" + "github.com/spf13/viper" +) + +// Initializes logging. Ignores values in the configuration as configuration is loaded after this initialization. +func initLog() { + // lowest level reading of the debug and quiet flags + // alas, it works only with command line flags, otherwise + // we have to wait for the values to be read in from the config file + // this low-level reading has to be done because logging begins before reading the config file. + if zerolog.SetGlobalLevel(zerolog.InfoLevel); elementInSlice("--debug", &os.Args) > 0 || elementInSlice("-d", &os.Args) > 0 || elementInSlice("-qd", &os.Args) > 0 || elementInSlice("-dq", &os.Args) > 0 { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + // start logging + if logFile, err := workDir.Join(logFilename).OpenFile(os.O_APPEND | os.O_CREATE | os.O_WRONLY); err == nil { + zlog = zerolog.New(logFile).With().Timestamp().Logger() + if elementInSlice("-q", &os.Args) > 0 || elementInSlice("--quiet", &os.Args) > 0 || elementInSlice("-qd", &os.Args) > 0 || elementInSlice("-dq", &os.Args) > 0 { + zboth = zlog // in this case, both the loggers point to the same file and there should be no console output + } else { + console := zerolog.ConsoleWriter{Out: os.Stdout} + console.FormatErrFieldName = func(_ any) string { return "" } // we don't want error to be shown in the console + console.FormatErrFieldValue = func(_ any) string { return "" } // PartsExclude doesn't seem to work! + multi := zerolog.MultiLevelWriter(logFile, console) + zboth = zerolog.New(multi).With().Timestamp().Logger() + } + zlog.Debug().Msgf("%s started. Successfully initialized logging", nameCLI) + } else { + minimalConsoleWriter := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + minimalConsoleWriter.Fatal().Err(err).Msg("Can't write log file. ABORT!") // minimal console writer + } +} + +func initFlags() { + zlog.Debug().Msg("Start: initialize flags") + // flag 1: instance, i.e. name of the instance to operate upon + // terminal overrides config-file, default is read from the config file + rootCmd.PersistentFlags().StringVarP(¤tInstance, "selected-instance", "i", "", toSprintf("select an existing instance of %s when starting", nameCLI)) + // flag 2: config, the configuration file + // config as a flag cannot be read from the configuration file because that creates a circular dependency, default name is hard-coded + rootCmd.PersistentFlags().StringVarP(&configFile, "config-file", "f", defaultConfigFilepath, "path to the configuration file") + // flag 3: quiet, i.e. should the CLI run in interactive mode + // terminal overrides config-file, default is false + rootCmd.PersistentFlags().BoolP("quiet", "q", false, toSprintf("use %s in scripted mode i.e. without an interactive prompt", commandForCLI)) + // flag 4: debug, i.e. should debug messages be logged + // terminal overrides config-file, default is false + rootCmd.PersistentFlags().BoolP("debug", "d", false, "enable logging of debug messages") + zlog.Debug().Msg("End: initialize flags") +} + +func upgradeThisTool(transition string) (success bool) { + switch transition { + case "0.1_to_0.2": + if success = selectYesNo("It seems you are upgrading from version 0.1.x of the tool to 0.2.x. Is this true?", true); success { + newConfig := viper.New() + newConfig.Set("version", versionYAML) + newConfig.Set(joinKey(stateWord, selectorWord), conf.GetString(selectorWord)) + newConfig.Set(joinKey(stateWord, "debug"), false) + newConfig.Set(joinKey(stateWord, "quiet"), false) + newConfig.Set(joinKey(stateWord, "version"), versionCLI) + instances := getSubHeadings(&conf, instancesWord) + newConfig.Set(instancesWord, instances) + for _, givenName := range instances { + name := conf.GetString(joinKey(instancesWord, givenName, "name")) + newConfig.Set(joinKey(instancesWord, givenName, "name"), name) + newConfig.Set(joinKey(instancesWord, givenName, "kind"), conf.GetString(joinKey(instancesWord, givenName, "kind"))) + newConfig.Set(joinKey(instancesWord, givenName, "port"), conf.GetInt(joinKey(instancesWord, givenName, "port"))) + env := viper.New() + env.SetConfigType("env") + env.SetConfigFile(workDir.Join(instancesWord, name, "shared", "pullin", ".env").String()) + if err := env.ReadInConfig(); err == nil { + newConfig.Set(joinKey(instancesWord, givenName, "environment"), env.AllSettings()) + } else { + zboth.Warn().Err(err).Msgf("Failed to read the .env file, using existing information to create reasonable entries. Please check the created file manually!") + } + if env.IsSet("URL_HOST") && env.IsSet("URL_PROTOCOL") { + newConfig.Set(joinKey(instancesWord, givenName, "accessaddress"), env.GetString("URL_PROTOCOL")+"://"+env.GetString("URL_HOST")) + } else { + newConfig.Set(joinKey(instancesWord, givenName, "accessaddress"), conf.GetString(joinKey(instancesWord, givenName, "protocol")+"://"+conf.GetString(joinKey(instancesWord, givenName, "address")))) + } + if !existingFile(workDir.Join(instancesWord, name, extenedComposeFilename).String()) { + extendedCompose := createExtendedCompose(name, workDir.Join(instancesWord, name, defaultComposeFilename).String()) + // write out the extended compose file + if _, err, _ := gotoFolder(givenName), extendedCompose.WriteConfigAs(extenedComposeFilename), gotoFolder("workdir"); err == nil { + zboth.Info().Msgf("Written extended file %s in the above step.", extenedComposeFilename) + } else { + zboth.Fatal().Err(err).Msgf("Failed to write the extended compose file to its repective folder. This is necessary for future use.") + } + } + } + oldConfigPath := pathlib.NewPath(conf.ConfigFileUsed()) + if errWrite := newConfig.WriteConfigAs("new." + defaultConfigFilepath); errWrite == nil { + zboth.Debug().Msgf("New configuration file `%s`.", "new."+defaultConfigFilepath) + if errRenameOld := oldConfigPath.RenameStr(toSprintf("old.%s.%s", time.Now().Format("060102150405"), defaultConfigFilepath)); errRenameOld == nil { + zboth.Debug().Msgf("Renamed old configuration file to %s", oldConfigPath.String()) + if errRenameNew := workDir.Join("new." + defaultConfigFilepath).RenameStr(conf.ConfigFileUsed()); errRenameNew == nil { + zboth.Info().Msgf("Successfully written new configuration file at %s.", conf.ConfigFileUsed()) + oldConfigPath.Remove() + success = true + } else { + zboth.Fatal().Err(errRenameNew).Msgf("Failed to rename the new configuration file. It is available at: %s", configFile+".new") + } + } else { + zboth.Fatal().Err(errRenameOld).Msgf("Failed to rename existing configuration file. New one is available at: %s", configFile+".new") + } + } else { + zboth.Fatal().Err(errWrite).Msgf("Failed to write the new configuration file. Old one is still available at %s for use with version 0.1.x of this tool.", oldConfigPath.String()) + } + } + } + return +} + +// Viper is used to load values from config file. Cobra is the basis of our command line interface. +// This function uses Viper to set flags on Cobra. +// (See how cool this sounds, make sure you pick fun project names!) +func initConf() { + zlog.Debug().Msg("Start: initialize configuration") + // check status of the config flag + // if changed and specified file is not found then exit + // otherwise set the value of configFile on viper + // then use the path as determined by viper and set as the value of configFile + if configFile != defaultConfigFilepath && !existingFile(configFile) { // the flag was set but file is missing + zboth.Fatal().Err(toError("specified config file not found")).Msgf("Please ensure that the file you specify using --config/-f flag does exist.") + } + conf.SetConfigFile(configFile) + zlog.Debug().Msg("Attempting to read configuration file") + // if the flag is not changed, check for the posibility of first run + if existingFile(configFile) { + firstRun = false + // Try and read the configuration file, then unmarshal it + if err := conf.ReadInConfig(); err == nil { + if conf.IsSet(joinKey(stateWord, selectorWord)) && conf.IsSet(instancesWord) { + if currentInstance == "" { // i.e. the flag was not set + if errUnmarshal := conf.UnmarshalKey(joinKey(stateWord, selectorWord), ¤tInstance); errUnmarshal != nil { + zboth.Fatal().Err(toError("unmarshal failed")).Msgf("Failed to unmarshal the mandatory key %s in the file: %s.", joinKey(stateWord, selectorWord), configFile) + } + } + if !conf.IsSet(joinKey(instancesWord, currentInstance)) { + zboth.Fatal().Err(toError("unmarshal failed")).Msgf("Failed to find the description for instance `%s` in the file: %s.", currentInstance, configFile) + } + } else { + if conf.IsSet(joinKey(selectorWord)) { + if upgradeThisTool("0.1_to_0.2") { + zboth.Info().Msgf("Upgrade was successful. Please restart this tool.") + os.Exit(0) + } + } + zboth.Fatal().Err(toError("unmarshal failed")).Msgf("Failed to find the mandatory keys `%s`, `%s` and `%s` in the file: %s.", stateWord, selectorWord, instancesWord, configFile) + } + } else { + zboth.Fatal().Err(err).Msgf("Failed to read configuration file: %s. ABORT!", configFile) + } + } + zlog.Debug().Msgf("End: initialize configuration; Config found?: %t; is inside container?: %t", !firstRun, isInContainer) +} + +// bind the command line flags to the configuration +func bindFlags() { + zlog.Debug().Msg("Start: bind flags") + if err := conf.BindPFlag(joinKey(stateWord, selectorWord), rootCmd.Flag("selected-instance")); err != nil { + zboth.Warn().Err(err).Msgf("Failed to bind flag: %s. Will ignore command line input.", "selected-instance") + } + for _, flag := range []string{"debug", "quiet"} { + if err := conf.BindPFlag(joinKey(stateWord, flag), rootCmd.Flag(flag)); err != nil { + zboth.Warn().Err(err).Msgf("Failed to bind flag: %s. Will ignore command line input.", flag) + } + if !conf.IsSet(joinKey(stateWord, flag)) { + conf.Set(joinKey(stateWord, flag), false) + } + } + zlog.Debug().Msg("End: bind flags") +} diff --git a/chemotion-cli/cli/logger.go b/chemotion-cli/cli/logger.go deleted file mode 100644 index e2d790d..0000000 --- a/chemotion-cli/cli/logger.go +++ /dev/null @@ -1,69 +0,0 @@ -package cli - -import ( - "os" - "strings" - - "github.com/rs/zerolog" -) - -// Initializes logging. Ignores values in the configuration as configuration is loaded after this initialization. -func initLog() { - // lowest level reading of the debug and quiet flags - // alas, it works only with command line flags, otherwise - // we have to wait for the values to be read in from the config file - // this low-level reading has to be done because logging begins before reading the config file. - if stringInArray("--debug", &os.Args) > 0 { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - currentState.debug = true - } else { - zerolog.SetGlobalLevel(zerolog.InfoLevel) - currentState.debug = false - } - if stringInArray("-q", &os.Args) > 0 || stringInArray("--quiet", &os.Args) > 0 { - currentState.quiet = true - } else { - currentState.quiet = false - } - // start logging - if logFile, err := workDir.Join(logFilename).OpenFile(os.O_APPEND | os.O_CREATE | os.O_WRONLY); err == nil { - zlog = zerolog.New(logFile).With().Timestamp().Logger() - if currentState.quiet { - zboth = zlog // in this case, both the loggers point to the same file and there should be no console output - } else { - console := zerolog.ConsoleWriter{Out: os.Stdout} - console.FormatErrFieldName = func(i interface{}) string { return "" } // we don't want error to be shown in the console - console.FormatErrFieldValue = func(i interface{}) string { return "" } // PartsExclude doesn't seem to work! - multi := zerolog.MultiLevelWriter(logFile, console) - zboth = zerolog.New(multi).With().Timestamp().Logger() - } - zlog.Debug().Msgf("%s started. Successfully initialized logging", nameCLI) - logPlatform() - } else { - minimalConsoleWriter := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() - minimalConsoleWriter.Fatal().Err(err).Msg("Can't write log file. ABORT!") // minimal console writer - } -} - -// helpers: logging -func logPlatform() { - if currentState.isInside { - if currentState.name == "" { - zlog.Debug().Msgf("Running inside an unknown container") // TODO: read .version file or get from environment - } else { - zlog.Debug().Msgf("Running inside `%s`", currentState.name) - } - } else { - if currentState.name == "" { - zlog.Debug().Msgf("Running on host machine; no instance selected yet") - } else { - zlog.Debug().Msgf("Running on host machine; selected instance: %s", currentState.name) - } - } -} - -// debug level logging of where we are running at the moment -func logWhere() { - logPlatform() - zlog.Debug().Msgf("Called as: %s", strings.Join(os.Args, " ")) -} diff --git a/chemotion-cli/cli/os-dep_darwin.go b/chemotion-cli/cli/os-dep_darwin.go index 2e7dbf2..81c8aaf 100644 --- a/chemotion-cli/cli/os-dep_darwin.go +++ b/chemotion-cli/cli/os-dep_darwin.go @@ -4,7 +4,6 @@ package cli import ( - "fmt" "math" "runtime" "syscall" @@ -15,19 +14,17 @@ func getDiskSpace() (line string) { if runtime.GOOS == "darwin" { var disk syscall.Statfs_t if err := syscall.Statfs(workDir.String(), &disk); err == nil { - line = fmt.Sprintf("- Disk space:\n - %7.1fGi (total) %7.1fGi (free)\n", float64(disk.Blocks*uint64(disk.Bsize))/math.Pow(2, 30), float64(disk.Bavail*uint64(disk.Bsize))/math.Pow(2, 30)) + line = toSprintf("\n- Disk space:\n - %7.1fGi (total) %7.1fGi (free)", float64(disk.Blocks*uint64(disk.Bsize))/math.Pow(2, 30), float64(disk.Bavail*uint64(disk.Bsize))/math.Pow(2, 30)) } else { zboth.Warn().Err(err).Msgf("Failed to retrieve information about disk space.") } } else { - zboth.Warn().Err(fmt.Errorf("running on %s", runtime.GOOS)).Msgf("Cannot retrieve disk space information for this operating system.") - line = "" + zboth.Warn().Err(toError("running on %s", runtime.GOOS)).Msgf("Cannot retrieve disk space information for this operating system.") } return } // stub function func getMemory() (line string) { - line = "" return } diff --git a/chemotion-cli/cli/os-dep_linux.go b/chemotion-cli/cli/os-dep_linux.go index 14bb76c..d64eec4 100644 --- a/chemotion-cli/cli/os-dep_linux.go +++ b/chemotion-cli/cli/os-dep_linux.go @@ -4,7 +4,6 @@ package cli import ( - "fmt" "math" "runtime" "syscall" @@ -14,13 +13,12 @@ func getDiskSpace() (line string) { if runtime.GOOS == "linux" { var disk syscall.Statfs_t if err := syscall.Statfs(workDir.String(), &disk); err == nil { - line = fmt.Sprintf("- Disk space:\n - %7.1fGi (total) %7.1fGi (free)\n", float64(disk.Blocks*uint64(disk.Bsize))/math.Pow(2, 30), float64(disk.Bavail*uint64(disk.Bsize))/math.Pow(2, 30)) + line = toSprintf("\n- Disk space:\n - %7.1fGi (total) %7.1fGi (free)", float64(disk.Blocks*uint64(disk.Bsize))/math.Pow(2, 30), float64(disk.Bavail*uint64(disk.Bsize))/math.Pow(2, 30)) } else { zboth.Warn().Err(err).Msgf("Failed to retrieve information about disk space.") } } else { - zboth.Warn().Err(fmt.Errorf("running on %s", runtime.GOOS)).Msgf("Cannot retrieve disk space information for this operating system.") - line = "" + zboth.Warn().Err(toError("running on %s", runtime.GOOS)).Msgf("Cannot retrieve disk space information for this operating system.") } return } @@ -29,13 +27,12 @@ func getMemory() (line string) { if runtime.GOOS == "linux" { var mem syscall.Sysinfo_t if err := syscall.Sysinfo(&mem); err == nil { - line = fmt.Sprintf("- Memory:\n - %7.1fGi (total) %7.1fGi (free)\n", float64(mem.Totalram)/math.Pow(2, 30), float64(mem.Freeram)/math.Pow(2, 30)) + line = toSprintf("\n- Memory:\n - %7.1fGi (total) %7.1fGi (free)", float64(mem.Totalram)/math.Pow(2, 30), float64(mem.Freeram)/math.Pow(2, 30)) } else { zboth.Warn().Err(err).Msgf("Failed to retrieve information about memory.") } } else { - zboth.Warn().Err(fmt.Errorf("running on %s", runtime.GOOS)).Msgf("Cannot retrieve memory information for this operating system.") - line = "" + zboth.Warn().Err(toError("running on %s", runtime.GOOS)).Msgf("Cannot retrieve memory information for this operating system.") } return } diff --git a/chemotion-cli/cli/os-dep_windows.go b/chemotion-cli/cli/os-dep_windows.go index 9ac921d..53ae4f4 100644 --- a/chemotion-cli/cli/os-dep_windows.go +++ b/chemotion-cli/cli/os-dep_windows.go @@ -5,12 +5,10 @@ package cli // stub function func getDiskSpace() (line string) { - line = "" return } // stub function func getMemory() (line string) { - line = "" return } diff --git a/chemotion-cli/cli/prompt.go b/chemotion-cli/cli/prompt.go index 30d0753..c4efd9a 100644 --- a/chemotion-cli/cli/prompt.go +++ b/chemotion-cli/cli/prompt.go @@ -1,7 +1,7 @@ package cli import ( - "fmt" + "os" "strconv" "strings" @@ -10,20 +10,31 @@ import ( // Prompt to select a value from a given set of values. // Also displays the currently selected instance. -func selectOpt(acceptedOpts []string) (result string) { +func selectOpt(acceptedOpts []string, msg string) (result string) { + coloredExit := toSprintf("%sexit", string("\033[31m")) + if acceptedOpts[len(acceptedOpts)-1] == "exit" { + acceptedOpts[len(acceptedOpts)-1] = coloredExit + } zlog.Debug().Msgf("Selection prompt with options %s:", acceptedOpts) + if msg == "" { + msg = toSprintf("%s%s%s%s Select one of the following", string("\033[31m"), string("\033[1m"), currentInstance, string("\033[0m")) + } selection := promptui.Select{ - Label: fmt.Sprintf("%s%s%s%s Select one of the following", string("\033[31m"), string("\033[1m"), currentState.name, string("\033[0m")), + Label: msg, Items: acceptedOpts, } _, result, err := selection.Run() if err == nil { - zboth.Debug().Msgf("Selected option: %s", result) + zlog.Debug().Msgf("Selected option: %s", result) } else if err == promptui.ErrInterrupt || err == promptui.ErrEOF { zboth.Fatal().Err(err).Msgf("Selection cancelled!") } else { zboth.Fatal().Err(err).Msgf("Selection failed! Check log. ABORT!") } + if result == coloredExit { + zboth.Debug().Msgf("Chose to exit") + os.Exit(0) + } return } @@ -46,7 +57,7 @@ func selectYesNo(question string, defValue bool) (result bool) { } else if err == promptui.ErrAbort { result = false } else if err == promptui.ErrInterrupt || err == promptui.ErrEOF { - zboth.Fatal().Err(fmt.Errorf("yesno prompt cancelled")).Msgf("Selection cancelled.") + zboth.Fatal().Err(toError("yesno prompt cancelled")).Msgf("Selection cancelled.") } else { zboth.Fatal().Err(err).Msgf("Selection failed! Check log. ABORT!") } @@ -56,9 +67,9 @@ func selectYesNo(question string, defValue bool) (result bool) { func textValidate(input string) (err error) { if len(strings.ReplaceAll(input, " ", "")) == 0 { - err = fmt.Errorf("can not accept empty value") - } else if len(strings.Fields(input)) > 1 { - err = fmt.Errorf("can not have spaces in the input") + err = toError("can not accept empty value") + } else if len(strings.Fields(input)) > 1 || strings.ContainsRune(input, ' ') { + err = toError("can not have spaces in this input") } else { err = nil } @@ -68,40 +79,29 @@ func textValidate(input string) (err error) { func instanceValidate(input string) (err error) { err = textValidate(input) if err == nil { - existingInstances := allInstances() - if stringInArray(input, &existingInstances) > -1 { - err = nil - } else { - err = fmt.Errorf("there is no instance called %s", input) + if len(getSubHeadings(&conf, joinKey(instancesWord, input))) == 0 { + err = toError("there is no instance called %s", input) } } return } func addressValidate(input string) (err error) { - err = textValidate(input) - protocol, address, found := strings.Cut(input, ":") - if found { - address = strings.TrimPrefix(address, "//") - protocol += "://" - } - if err == nil { - if !found || !((protocol == "http://") || (protocol == "https://")) { - err = fmt.Errorf("address must start with protocol i.e. as `http://` or as `https://`") - } - } - address, port, portGiven := strings.Cut(address, ":") - if err == nil { - if err = textValidate(address); err != nil { - if err.Error() == "can not accept empty value" { - err = fmt.Errorf("can not accept empty value for address") + if err = textValidate(input); err == nil { + protocol, address, found := strings.Cut(input, "://") + if found && ((protocol == "http") || (protocol == "https")) { + address, port, portGiven := strings.Cut(address, ":") + if err = textValidate(address); err == nil { + if portGiven { + if p, errConv := strconv.Atoi(port); errConv != nil || p < 1 { + err = toError("port must an integer above 0") + } + } + } else { + err = toError("address cannot be empty") } - } - } - if err == nil && portGiven { - _, err = strconv.Atoi(port) - if err != nil { - err = fmt.Errorf("port must be an integer") + } else { + err = toError("address must start with protocol i.e. as `http://` or as `https://`") } } return @@ -110,9 +110,15 @@ func addressValidate(input string) (err error) { // kind of opposite of instanceValidate func newInstanceValidate(input string) (err error) { err = textValidate(input) + if strings.ContainsRune(input, '.') { + err = toError("cannot have `.` in an instance name") + } + if elementInSlice(input, &reseveredWords) > -1 { + err = toError("this is a reserved word; pick another") + } if err == nil { if exists := instanceValidate(input); exists == nil { - err = fmt.Errorf("this value is alredy taken") + err = toError("this value is already taken") } else { err = nil } @@ -131,7 +137,7 @@ func getString(message string, validator promptui.ValidateFunc) (result string) zlog.Debug().Msgf("Given answer: %s", res) result = res } else if err == promptui.ErrInterrupt || err == promptui.ErrEOF { - zboth.Fatal().Err(fmt.Errorf("prompt cancelled")).Msgf("Prompt cancelled. Can't proceed without. ABORT!") + zboth.Fatal().Err(toError("prompt cancelled")).Msgf("Prompt cancelled. Can't proceed without. ABORT!") } else { zboth.Fatal().Err(err).Msgf("Prompt failed because: %s.", err.Error()) } @@ -140,11 +146,11 @@ func getString(message string, validator promptui.ValidateFunc) (result string) // to select an instance, gives a list to select from when less than 5, else a text input func selectInstance(action string) (instance string) { - existingInstances := allInstances() - if len(existingInstances) < 5 { - fmt.Printf("Please pick the instance to %s:\n", action) - instance = selectOpt(existingInstances) + existingInstances := append(allInstances(), "exit") + if len(existingInstances) < 6 { + instance = selectOpt(existingInstances, toSprintf("Please pick the instance to %s:", action)) } else { + zboth.Info().Msgf(strings.Join(append([]string{"The following instances exist: "}, allInstances()...), "\n")) zlog.Debug().Msgf("String prompt to select instance") instance = getString("Please name the instance to "+action, instanceValidate) } diff --git a/chemotion-cli/cli/root-advanced-info.go b/chemotion-cli/cli/root-advanced-info.go index 80e49d9..828d0d1 100644 --- a/chemotion-cli/cli/root-advanced-info.go +++ b/chemotion-cli/cli/root-advanced-info.go @@ -3,49 +3,55 @@ package cli import ( "fmt" "runtime" - "strings" + "github.com/rs/zerolog" "github.com/spf13/cobra" ) -// helper function that is used by infoAdvancedRootCmd -func systemInfo() (info string) { +// get system information +func getSystemInfo() (info string) { // CPU - info += fmt.Sprintln("- CPU Cores:", runtime.NumCPU()) - if runtime.GOOS == "linux" { - info += getDiskSpace() // Disk Space - info += getMemory() // Memory - } - info += fmt.Sprintln("Used software versions:") - printVersionOf := []string{"docker", "ruby", "passenger", "node", "npm"} - for _, software := range printVersionOf { - info += fmt.Sprintf("- %s: %s\n", strings.ToTitle(software), findVersion(software)) - } + info += toSprintf("\n- CPU Cores: %d", runtime.NumCPU()) + info += getDiskSpace() // Disk Space + info += getMemory() // Memory + // info += fmt.Sprintln("Used software versions:") // TODO: fix this + // printVersionOf := []string{"docker", "ruby", "passenger", "node", "npm"} + // for _, software := range printVersionOf { + // info += toSprintf("- %s: %s\n", strings.ToTitle(software), findVersion(software)) + // } return } -// Show host machine information to the user -// See also, chemotion instance info +// print system info depending on the debug tag +func systemInfo() { + info := getSystemInfo() + if isInteractive(false) { + if conf.GetBool(joinKey(stateWord, "debug")) { + zboth.Info().Msgf("Also writing all information in the log file.") + zboth.Debug().Msgf(info) + } + fmt.Println("This is what we know about the host machine:") + fmt.Println(info) + } else { + if err := workDir.Join("system.info").WriteFile([]byte(info + "\n")); err == nil { + zboth.Info().Msgf("Written system.info containing system information.") + } else { + zboth.Warn().Err(err).Msgf("Failed to write system.info. Writing all information in the log file.") + zerolog.SetGlobalLevel(zerolog.DebugLevel) + zboth.Debug().Msgf(info) + if !conf.GetBool(joinKey(stateWord, "debug")) { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + } + } +} + var infoAdvancedRootCmd = &cobra.Command{ Use: "info", Args: cobra.NoArgs, Short: "get information about the system", - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - info := systemInfo() - if currentState.quiet { - if err := workDir.Join("system.info").WriteFile([]byte(info)); err != nil { - zboth.Debug().Msgf(info) - } - } else { - if currentState.debug { - zboth.Debug().Msgf(info) - } else { - fmt.Println("This is what we know about the host machine:") - fmt.Println(info) - } - } + Run: func(_ *cobra.Command, _ []string) { + systemInfo() }, } diff --git a/chemotion-cli/cli/root-advanced-uninstall.go b/chemotion-cli/cli/root-advanced-uninstall.go index 1fbfe40..c1ff10d 100644 --- a/chemotion-cli/cli/root-advanced-uninstall.go +++ b/chemotion-cli/cli/root-advanced-uninstall.go @@ -1,56 +1,63 @@ package cli import ( - "fmt" + "os" "github.com/rs/zerolog" "github.com/spf13/cobra" ) +func advancedUninstall(removeLogfile bool) { + existingInstances := allInstances() + existingInstances[elementInSlice(currentInstance, &existingInstances)] = existingInstances[len(existingInstances)-1] + existingInstances[len(existingInstances)-1] = currentInstance // move currentInstance to the end of the queue for deletion + for _, inst := range existingInstances { + zboth.Info().Msgf("Removing instance called %s.", inst) + if err := instanceRemove(inst, true); err != nil { + zboth.Warn().Err(err).Msgf(err.Error()) + zboth.Fatal().Err(toError("uninstalled failed")).Msgf("Uninstall failed while trying to remove %s", inst) + } + } + if err := workDir.Join(instancesWord).RemoveAll(); err != nil { + zboth.Warn().Err(err).Msgf("Failed to delete the `%s` folder.", instancesWord) + } + if err := workDir.Join(conf.ConfigFileUsed()).Remove(); err != nil { + zboth.Warn().Err(err).Msgf("Failed to delete the configuration file: %s.", conf.ConfigFileUsed()) + } + zboth.Info().Msgf("%s was successfully uninstalled.", nameCLI) + if removeLogfile { + if err := workDir.Join(logFilename).Remove(); err != nil { + zboth.Warn().Err(err).Msgf("Failed to delete the log file: %s.", logFilename) + } + } +} + var uninstallAdvancedRootCmd = &cobra.Command{ - Use: "uninstall (accepts no flags)", + Use: "uninstall", Args: cobra.NoArgs, - Short: fmt.Sprintf("uninstall %s completely", nameCLI), - Run: func(cmd *cobra.Command, args []string) { - if currentState.quiet { - fmt.Println("For security reasons, this command will not run in silent mode.") - zboth.Fatal().Msgf("For security reasons, this command will not run in silent mode.") - } - zerolog.SetGlobalLevel(zerolog.DebugLevel) // uninstall operates in debug mode - fmt.Println("Uninstall operates in debug mode!") - logWhere() - confirmInstalled() - confirmInteractive() - if selectYesNo("Are you sure you want to uninstall "+nameCLI, false) { - chosen := conf.GetString(selector_key) - instances := append(allInstances(), chosen) - skip := true - for _, inst := range instances { - if inst == chosen && skip { // contraption to make sure that the chosen instance is deleted last - skip = false - continue + Short: toSprintf("uninstall %s completely", nameCLI), + Run: func(_ *cobra.Command, _ []string) { + if isInteractive(false) { + zerolog.SetGlobalLevel(zerolog.DebugLevel) // uninstall operates in debug mode + zboth.Debug().Msgf("Uninstall operates in debug mode!") + if selectYesNo("Are you sure you want to uninstall "+nameCLI, false) { + switch selectOpt([]string{"yes", "no", "exit"}, "Do you want to keep the log file after successful uninstallation") { + case "exit": + // ideally this case is handled in the selectOpt function, here as a safety precaution + os.Exit(0) + case "yes": + advancedUninstall(false) + case "no": + advancedUninstall(true) } - zboth.Info().Msgf("Removing instance called %s.", inst) - _root_instance_remove_force_ = true - if !instanceRemove(inst) { - zboth.Fatal().Err(fmt.Errorf("uninstalled failed")).Msgf("Uninstall failed while trying to remove %s", inst) - break - } - } - if err := workDir.Join(instancesFolder).RemoveAll(); err != nil { - zboth.Warn().Err(err).Msgf("Failed to delete the `%s` folder.", instancesFolder) - } - if err := workDir.Join(conf.ConfigFileUsed()).Remove(); err != nil { - zboth.Warn().Err(err).Msgf("Failed to delete the configuration file: %s.", conf.ConfigFileUsed()) - } - zboth.Info().Msgf("%s was successfully uninstalled.", nameCLI) - if selectYesNo("Do you want to remove the log file as well", false) { - if err := workDir.Join(logFilename).Remove(); err != nil { - zboth.Warn().Err(err).Msgf("Failed to delete the log file: %s.", logFilename) + } else { + zboth.Info().Msgf("Nothing was done.") + if !conf.GetBool(joinKey(stateWord, "debug")) { + zerolog.SetGlobalLevel(zerolog.InfoLevel) } } } else { - zboth.Info().Msgf("Nothing was done.") + zboth.Fatal().Err(toError("uninstall in silent mode")).Msgf("For security reasons, this command will not run in silent mode.") } }, } diff --git a/chemotion-cli/cli/root-advanced-update.go b/chemotion-cli/cli/root-advanced-update.go new file mode 100644 index 0000000..2833c64 --- /dev/null +++ b/chemotion-cli/cli/root-advanced-update.go @@ -0,0 +1,106 @@ +package cli + +import ( + "net/http" + "strings" + "time" + + "github.com/chigopher/pathlib" + vercompare "github.com/hashicorp/go-version" + "github.com/spf13/cobra" +) + +func getLatestReleaseURL() (url string, err error) { + client := http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + if resp, errGet := client.Get(releaseUnresolvedURL); errGet == nil { + if loc, errLoc := resp.Location(); errLoc == nil { + url = loc.String() + } else { + err = errLoc + } + } else { + err = errGet + } + return strings.Replace(url, "tag", "download", -1), err +} + +func getLatestVersion() (version string) { + if url, err := getLatestReleaseURL(); err == nil { + urlInParts := strings.Split(url, "/") + version = urlInParts[len(urlInParts)-1] + zboth.Debug().Msgf("Latest version of CLI is %s, installed version is %s.", version, versionCLI) + } else { + zboth.Fatal().Err(err).Msgf("Could not resolve the version of latest release.") + } + return +} + +func updateRequired() (required bool) { + verKey, timeKey := joinKey(stateWord, "version"), joinKey(stateWord, "version_checked_on") + // update version in conf if required + confVersion := conf.GetString(verKey) + if confVersion == "" || confVersion != versionCLI { + conf.Set(verKey, versionCLI) + rewriteConfig() + } + checkedOn := conf.GetTime(timeKey) + if checkedOn.IsZero() || (time.Since(checkedOn).Hours() > 24) { // check every 24 hours + existingVer, _ := vercompare.NewVersion(versionCLI) + newVer, _ := vercompare.NewVersion(getLatestVersion()) + required = newVer.GreaterThan(existingVer) + conf.Set(timeKey, time.Now()) + rewriteConfig() + } + return +} + +func selfUpdate() { + var url string + if u, err := getLatestReleaseURL(); err == nil { + url = u + } else { + zboth.Fatal().Err(err).Msgf("Could not determine address of latest executable.") + } + oldVersion := pathlib.NewPath(commandForCLI) + stat, _ := oldVersion.Stat() + cliFileName := oldVersion.Name() + url = toSprintf("%s/%s", url, cliFileName) + newVersion := downloadFile(url, workDir.Join(toSprintf("%s.new", cliFileName)).String()) + if err := newVersion.Chmod(stat.Mode() | 100); err != nil { // make sure that it remains executable for the ErrUseLastResponse + zboth.Warn().Err(err).Msgf("Could not grant executable permission to the downloaded file. Please do it yourself.") + } + if errOld := oldVersion.RenameStr(toSprintf("%s.old", cliFileName)); errOld == nil { + if errNew := newVersion.RenameStr(cliFileName); errNew == nil { + zboth.Info().Msgf("Successfully downloaded the new version. Old version is available as %s and is safe to remove.", oldVersion.Name()) + } else { + zboth.Warn().Err(errNew).Msgf("Successfully downloaded the new version. Please rename it to %s for further use. The old version is available as %s and is safe to remove.", cliFileName, oldVersion.Name()) + } + } else { + zboth.Warn().Err(errOld).Msgf("Successfully downloaded the new version but failed to rename the old one. The new version is called %s, please rename it %s. The old version is safe to remove.", newVersion.Name(), cliFileName) + } +} + +var updateSelfAdvancedRootCmd = &cobra.Command{ + Use: "update", + Short: "Update this tool itself", + Run: func(cmd *cobra.Command, _ []string) { + update := updateRequired() + if !update && ownCall(cmd) { + update = toBool(cmd.Flag("force").Value.String()) + } + if update { + selfUpdate() + } else { + zboth.Info().Msgf("You are already on the latest version of %s CLI tool.", nameCLI) + } + }, +} + +func init() { + advancedRootCmd.AddCommand(updateSelfAdvancedRootCmd) + updateSelfAdvancedRootCmd.Flags().Bool("force", false, toSprintf("Force update the %s CLI.", nameCLI)) +} diff --git a/chemotion-cli/cli/root-advanced.go b/chemotion-cli/cli/root-advanced.go index 7f1e9cd..35397c1 100644 --- a/chemotion-cli/cli/root-advanced.go +++ b/chemotion-cli/cli/root-advanced.go @@ -2,26 +2,39 @@ package cli import ( "github.com/spf13/cobra" + "golang.org/x/exp/maps" ) +var advancedCmdTable = make(cmdTable) + // Backbone for system-related commands var advancedRootCmd = &cobra.Command{ - Use: "advanced {info|uninstall}", - Short: "Perform advanced actions related to system and " + nameCLI, + Use: "advanced", + Short: "Perform advanced actions related to system and " + nameCLI, + Args: cobra.NoArgs, + ValidArgs: maps.Keys(advancedCmdTable), + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + if cmd.Flag("selected-instance").Changed { + zboth.Warn().Msgf("The `-i` flag is not supported for the `advanced` command and its subcommands.") + } + }, Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - confirmInteractive() - acceptedOpts := []string{"info", "uninstall", "exit"} - selected := selectOpt(acceptedOpts) - switch selected { - case "info": - infoAdvancedRootCmd.Run(cmd, args) - case "uninstall": - uninstallAdvancedRootCmd.Run(cmd, args) - case "exit": - zlog.Debug().Msg("Chose to exit") + isInteractive(true) + acceptedOpts := []string{"info"} + advancedCmdTable["info"] = infoAdvancedRootCmd.Run + if updateRequired() { + acceptedOpts = append(acceptedOpts, "update cli") + advancedCmdTable["update cli"] = updateSelfAdvancedRootCmd.Run + } + if cmd.Use == cmd.CalledAs() { // || elementInSlice(cmd.CalledAs(), &cmd.Aliases) > -1 { { // there are no aliases at the moment + acceptedOpts = append(acceptedOpts, []string{"uninstall", "exit"}...) + advancedCmdTable["uninstall"] = uninstallAdvancedRootCmd.Run + } else { + acceptedOpts = append(acceptedOpts, []string{"uninstall", "back", "exit"}...) + advancedCmdTable["uninstall"] = uninstallAdvancedRootCmd.Run + advancedCmdTable["back"] = cmd.Run } + advancedCmdTable[selectOpt(acceptedOpts, "")](cmd, args) }, } diff --git a/chemotion-cli/cli/root-install.go b/chemotion-cli/cli/root-install.go index 3476bb9..08a8d4d 100644 --- a/chemotion-cli/cli/root-install.go +++ b/chemotion-cli/cli/root-install.go @@ -1,38 +1,40 @@ package cli import ( - "fmt" - "github.com/spf13/cobra" ) // command to install a new container of Chemotion var installRootCmd = &cobra.Command{ - Use: "install", - Args: cobra.NoArgs, - Short: "Initialize the configuration file and install the first instance of " + nameCLI, - Hidden: !firstRun, - Run: func(cmd *cobra.Command, args []string) { - logWhere() + Use: "install", + Args: cobra.NoArgs, + Short: "Initialize the configuration file and install the first instance of " + nameCLI, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + if cmd.Flag("selected-instance").Changed { + zboth.Warn().Msgf("The `-i` flag is not supported for the `install` command.") + } + }, + Run: func(cmd *cobra.Command, _ []string) { if firstRun { - if currentState.quiet || newInstanceInteraction() { - if currentState.quiet { + details := make(map[string]string) + create := processInstallAndInstanceCreateCmd(cmd, details) + if create { + if !isInteractive(false) { zboth.Info().Msgf("You chose do first run of %s in quiet mode. Will go ahead and install it!", nameCLI) } - if success := instanceCreate(_root_instance_new_name_, _root_instance_new_use_, "Production", _root_instance_new_address_); success { - zboth.Info().Msgf("All done! Now you can do `%s on` and `%s off` to start/stop %s.", rootCmd.Name(), rootCmd.Name(), nameCLI) + if success := instanceCreateProduction(details); success { + zboth.Info().Msgf("All done! Now you can do `%s on` and `%s off` to start/stop %s.", commandForCLI, commandForCLI, nameCLI) } } } else { - zboth.Fatal().Err(fmt.Errorf("config file found")).Msgf("This option `%s` is only available for initial installation. Use `%s %s %s` if you wish to create more instances of %s.", cmd.Name(), rootCmd.Name(), instanceRootCmd.Name(), newInstanceRootCmd.Name(), nameCLI) + zboth.Fatal().Err(toError("config file found")).Msgf("This option `%s` is only available for initial installation. Use `%s %s %s` if you wish to create more instances of %s.", cmd.Name(), rootCmd.Name(), instanceRootCmd.Name(), newInstanceRootCmd.Name(), nameCLI) } }, } func init() { rootCmd.AddCommand(installRootCmd) - installRootCmd.Flags().StringVar(&_root_instance_new_name_, "name", instanceDefault, "Name of the first instance to create") - installRootCmd.Flags().StringVar(&_root_instance_new_use_, "use", composeURL, "URL or filepath to use for creating the instance") - installRootCmd.Flags().StringVar(&_root_instance_new_address_, "address", addressDefault, "Web-address (or hostname) for accessing the instance") - installRootCmd.Flags().StringVar(&_root_instance_new_env_, "env", "", ".env file for the first instance") + installRootCmd.Flags().StringP("name", "n", instanceDefault, "Name of the first instance to create") + installRootCmd.Flags().String("use", "", "URL or filepath of the compose file to use for creating the first instance") + installRootCmd.Flags().String("address", addressDefault, "Web-address (or hostname) for accessing the first instance") } diff --git a/chemotion-cli/cli/root-instance-backup.go b/chemotion-cli/cli/root-instance-backup.go new file mode 100644 index 0000000..6eb2cc4 --- /dev/null +++ b/chemotion-cli/cli/root-instance-backup.go @@ -0,0 +1,88 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func instanceBackup(givenName, portion string) { + // deliver payload + //TODO: include version check before delivering payload + gotoFolder(givenName) + var err, msg string + status := instanceStatus(givenName) + if successStart := callVirtualizer(composeCall + "start eln"); successStart { + if successCurl := callVirtualizer(composeCall + "exec eln curl https://raw.githubusercontent.com/harivyasi/chemotion/chemotion-cli/chemotion-cli/payload/backup.sh --output /embed/scripts/backup.sh"); successCurl { + if successBackUp := callVirtualizer(composeCall + "exec --env BACKUP_WHAT=" + portion + " eln chemotion backup"); successBackUp { + zboth.Info().Msgf("Backup successful.") + } else { + msg = "Backup process failed." + err = "backup failed" + } + } else { + err = "backup.sh update failed" + msg = "Could not fix the broken `backup.sh`. Can't create backup." + } + if status != "Up" { // if instance was not Up prior to start then stop it now + callVirtualizer(composeCall + "stop") // need to be low-level because only one service is running + } + } else { + err = "starting eln service failed" + msg = "Could not backup unless it starts. Can't create backup." + } + gotoFolder("workdir") + if err != "" { + zboth.Fatal().Err(toError(err)).Msgf(msg) + } +} + +var backupInstanceRootCmd = &cobra.Command{ + Use: "backup", + Short: "Create a backup of the data associated to an instance of " + nameCLI, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + backup, status := true, instanceStatus(currentInstance) + if status == "Up" { + zboth.Warn().Err(toError("instance running")).Msgf("The instance called %s is running. Backing up a running instance is not a good idea.", currentInstance) + if isInteractive(false) { + backup = selectYesNo("Continue", false) + } + } + if status == "Created" { + zboth.Warn().Err(toError("instance never run")).Msgf("The instance called %s was created but never turned on. Backing up such an instance is not a good idea.", currentInstance) + if isInteractive(false) { + backup = selectYesNo("Continue", false) + } + } + if backup { + portion := "both" + if ownCall(cmd) { + if toBool(cmd.Flag("db").Value.String()) && !toBool(cmd.Flag("data").Value.String()) { + portion = "db" + } + if toBool(cmd.Flag("data").Value.String()) && !toBool(cmd.Flag("db").Value.String()) { + portion = "data" + } + } else { + if isInteractive(false) { + switch selectOpt([]string{"database and data", "database", "data", "exit"}, "What would you like to backup?") { + case "database and data": + portion = "both" + case "database": + portion = "db" + case "data": + portion = "data" + } + } + } + instanceBackup(currentInstance, portion) + } else { + zboth.Debug().Msgf("Backup operation cancelled.") + } + }, +} + +func init() { + backupInstanceRootCmd.Flags().Bool("db", false, "backup only database") + backupInstanceRootCmd.Flags().Bool("data", false, "backup only data") + instanceRootCmd.AddCommand(backupInstanceRootCmd) +} diff --git a/chemotion-cli/cli/root-instance-consoles-console.go b/chemotion-cli/cli/root-instance-consoles-console.go new file mode 100644 index 0000000..823f3f1 --- /dev/null +++ b/chemotion-cli/cli/root-instance-consoles-console.go @@ -0,0 +1,65 @@ +package cli + +import ( + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +func dropIntoConsole(givenName string, consoleName string) { + commandExec := exec.Command(toLower(virtualizer), []string{"compose", "exec", "eln", "chemotion", consoleName}...) + commandExec.Stdin, commandExec.Stdout, commandExec.Stderr = os.Stdin, os.Stdout, os.Stderr + switch consoleName { + case "railsc": + consoleName = "rails" + case "psql": + consoleName = "postgreSQL" // use proper name for psql when printing to user + } + if instanceStatus(givenName) == "Up" { + zboth.Info().Msgf("Starting %s console for instance `%s`.", consoleName, givenName) + if _, err, _ := gotoFolder(givenName), commandExec.Run(), gotoFolder("workdir"); err == nil { + zboth.Debug().Msgf("Successfuly closed console for %s in `%s`.", consoleName, givenName) + } else { + zboth.Fatal().Err(err).Msgf("Console ended with exit message: %s.", err.Error()) + } + } else { + zboth.Warn().Err(toError("instance not running")).Msgf("Cannot start a %s console for `%s`. Instance is not running.", consoleName, givenName) + } +} + +var shellConsoleInstanceRootCmd = &cobra.Command{ + Use: "shell", + Aliases: []string{"bash"}, + Short: "Drop into a shell (bash) console", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + dropIntoConsole(currentInstance, "shell") + }, +} + +var railsConsoleInstanceRootCmd = &cobra.Command{ + Use: "rails", + Aliases: []string{"ruby", "railsc"}, + Short: "Drop into a Ruby on Rails console", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + dropIntoConsole(currentInstance, "railsc") + }, +} + +var psqlConsoleInstanceRootCmd = &cobra.Command{ + Use: "psql", + Aliases: []string{"postgresql", "sql", "postgres", "postgreSQL"}, + Short: "Drop into a PostgreSQL console", + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + dropIntoConsole(currentInstance, "psql") + }, +} + +func init() { + consoleInstanceRootCmd.AddCommand(shellConsoleInstanceRootCmd) + consoleInstanceRootCmd.AddCommand(railsConsoleInstanceRootCmd) + consoleInstanceRootCmd.AddCommand(psqlConsoleInstanceRootCmd) +} diff --git a/chemotion-cli/cli/root-instance-consoles.go b/chemotion-cli/cli/root-instance-consoles.go new file mode 100644 index 0000000..2a560a1 --- /dev/null +++ b/chemotion-cli/cli/root-instance-consoles.go @@ -0,0 +1,33 @@ +package cli + +import ( + "github.com/spf13/cobra" + "golang.org/x/exp/maps" +) + +var consoleInstanceCmdTable = make(cmdTable) + +var consoleInstanceRootCmd = &cobra.Command{ + Use: "console", + Aliases: []string{"consoles"}, + Short: "Allow users to interact with an instance's command line interface", + ValidArgs: maps.Keys(consoleInstanceCmdTable), + Run: func(cmd *cobra.Command, args []string) { + isInteractive(true) + acceptedOpts := []string{"shell", "ruby on rails", "postgresSQL"} + consoleInstanceCmdTable["shell"] = shellConsoleInstanceRootCmd.Run + consoleInstanceCmdTable["ruby on rails"] = railsConsoleInstanceRootCmd.Run + consoleInstanceCmdTable["postgresSQL"] = psqlConsoleInstanceRootCmd.Run + if cmd.Use == cmd.CalledAs() || elementInSlice(cmd.CalledAs(), &cmd.Aliases) > -1 { + acceptedOpts = append(acceptedOpts, "exit") + } else { + acceptedOpts = append(acceptedOpts, []string{"back", "exit"}...) + consoleInstanceCmdTable["back"] = cmd.Run + } + consoleInstanceCmdTable[selectOpt(acceptedOpts, "")](cmd, args) + }, +} + +func init() { + instanceRootCmd.AddCommand(consoleInstanceRootCmd) +} diff --git a/chemotion-cli/cli/root-instance-list.go b/chemotion-cli/cli/root-instance-list.go index 8edeca1..17ebbe4 100644 --- a/chemotion-cli/cli/root-instance-list.go +++ b/chemotion-cli/cli/root-instance-list.go @@ -8,13 +8,13 @@ import ( ) func instanceList() { - if currentState.debug { - zboth.Debug().Msgf("Currently existing instances are :", strings.Join(allInstances(), " ")) + allInstances := allInstances() + if conf.GetBool(joinKey(stateWord, "debug")) { + zboth.Debug().Msgf("Currently existing instances are :", strings.Join(allInstances, " ")) } - if !currentState.quiet { - confirmInstalled() + if isInteractive(false) { fmt.Printf("The following instances of %s exist:\n", nameCLI) - for _, inst := range allInstances() { + for _, inst := range allInstances { fmt.Println(inst) } } @@ -24,8 +24,7 @@ var listInstanceRootCmd = &cobra.Command{ Use: "list", Args: cobra.NoArgs, Short: "Get a list of all instances of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() + Run: func(_ *cobra.Command, _ []string) { instanceList() }, } diff --git a/chemotion-cli/cli/root-instance-log.go b/chemotion-cli/cli/root-instance-log.go index c089a7f..1408fbd 100644 --- a/chemotion-cli/cli/root-instance-log.go +++ b/chemotion-cli/cli/root-instance-log.go @@ -6,53 +6,15 @@ import ( "github.com/spf13/cobra" ) -var ( - _root_instance_log_all_ bool - _root_instance_log_service_ string - _root_instance_log_defaultService_ = "eln" // the service that is used as the default for printing log" - _root_instance_log_v_details_ bool - _root_instance_log_v_follow_ bool - _root_instance_log_v_timestamps_ bool - _root_instance_log_v_since_ string - _root_instance_log_v_until_ string - _root_instance_log_v_tail_ string -) - -func instanceLog(givenName, service string) { - name, services := getInternalName(givenName), getServices(givenName) - var logOf []string - if _root_instance_log_all_ { - logOf = services - } else { - if stringInArray(service, &services) > -1 { - logOf = []string{service} - } else { - zboth.Fatal().Err(fmt.Errorf("named service not found")).Msgf("No service called %s found associated with the instance called %s.", service, givenName) - } - } - for _, service := range logOf { +func instanceLog(givenName, args string, logOf *[]string, follow bool) { + name := getInternalName(givenName) + for _, service := range *logOf { zboth.Info().Msgf("Printing logs for the instance-service called %s-%s.", givenName, service) - args := fmt.Sprintf("--tail %s", _root_instance_log_v_tail_) - if _root_instance_log_v_details_ { - args += " --details" - } - if _root_instance_log_v_timestamps_ { - args += " --timestamps" - } - if _root_instance_log_v_since_ != "" { - args += fmt.Sprintf(" --since %s", _root_instance_log_v_until_) - } - if _root_instance_log_v_until_ != "" { - args += fmt.Sprintf(" --until %s", _root_instance_log_v_until_) - } - if _root_instance_log_v_follow_ { - if _root_instance_log_all_ { - zboth.Fatal().Err(fmt.Errorf("illegal operation")).Msgf("Cannot `follow` all the services. Use only one of the `--all` and `--follow` flags.") - } + if follow { args += " --follow" - callVirtualizer(fmt.Sprintf("logs %s %s-%s-%d", args, name, service, rollNum)) + callVirtualizer(toSprintf("logs %s %s-%s-%d", args, name, service, rollNum)) } else { - if res, err := execShell(fmt.Sprintf("%s logs %s %s-%s-%d", toLower(virtualizer), args, name, service, rollNum)); err == nil { + if res, err := execShell(toSprintf("%s logs %s %s-%s-%d", toLower(virtualizer), args, name, service, rollNum)); err == nil { if n, errPrint := fmt.Println(string(res)); errPrint == nil { zboth.Debug().Msgf("Printed logs to screen that were %d lines long", n) } else { @@ -70,30 +32,65 @@ var logInstanceRootCmd = &cobra.Command{ Aliases: []string{"logs"}, Args: cobra.NoArgs, Short: "Get logs of an instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - if currentState.quiet { - zboth.Warn().Err(fmt.Errorf("illegal operation")).Msgf("Logs can't be printed in quiet mode.") - } else { - if _root_instance_log_service_ == "" { - zboth.Info().Msgf("No service specified, printing logs for all services.") - _root_instance_log_all_ = true + Run: func(cmd *cobra.Command, _ []string) { + if isInteractive(false) { + var services, logOf []string = getServices(currentInstance), []string{} + var ( + args string + follow bool + ) + if ownCall(cmd) { + logOf = []string{toLower(cmd.Flag("service").Value.String())} + if cmd.Flag("all").Changed && toBool(cmd.Flag("all").Value.String()) { + logOf = services + } else { + if cmd.Flag("service").Changed { + if cmd.Flag("all").Changed { + zboth.Warn().Msgf("You used the `--service` and `--all` flags. Ignoring the `--service` flag.") + } + if elementInSlice(logOf[0], &services) == -1 { + zboth.Fatal().Err(toError("service not found")).Msgf("No service called %s found associated with the instance called %s.", logOf[0], currentInstance) + } + } else { + logOf = []string{selectOpt(services, "Please select the service whose logs you want")} + } + } + args := toSprintf("--tail %s", cmd.Flag("tail").Value.String()) + if toBool(cmd.Flag("details").Value.String()) { + args += " --details" + } + if toBool(cmd.Flag("timestamps").Value.String()) { + args += " --timestamps" + } + if cmd.Flag("since").Changed { + args += toSprintf(" --since %s", cmd.Flag("since").Value.String()) + } + if cmd.Flag("until").Changed { + args += toSprintf(" --until %s", cmd.Flag("until").Value.String()) + } + follow = toBool(cmd.Flag("follow").Value.String()) + if !cmd.Flag("all").Changed && args == "--tail all" && !cmd.Flag("service").Changed { + zboth.Info().Msgf("This command can be used with a number of flags. Check them with the `--help` option.") + } + } else { + zboth.Info().Msgf("This command can be used as with a number of flags. Check `%s %s --help` for more.", commandForCLI, "instance logs") + logOf = []string{selectOpt(services, "Please select the service whose logs you want")} } - _root_instance_log_service_ = toLower(_root_instance_log_service_) - instanceLog(currentState.name, _root_instance_log_service_) + instanceLog(currentInstance, args, &logOf, follow) + } else { + zboth.Fatal().Err(toError("illegal operation")).Msgf("Logs can't be printed in quiet mode.") } }, } func init() { instanceRootCmd.AddCommand(logInstanceRootCmd) - logInstanceRootCmd.Flags().StringVar(&_root_instance_log_service_, "service", _root_instance_log_defaultService_, "show the log of a given service") - logInstanceRootCmd.Flags().BoolVar(&_root_instance_log_all_, "all", false, "show the logs of all services") - logInstanceRootCmd.Flags().BoolVar(&_root_instance_log_v_details_, "details", false, fmt.Sprintf("--details flag as received by %s logs command", virtualizer)) - logInstanceRootCmd.Flags().BoolVar(&_root_instance_log_v_follow_, "follow", false, fmt.Sprintf("--follow flag as received by %s logs command", virtualizer)) - logInstanceRootCmd.Flags().BoolVarP(&_root_instance_log_v_timestamps_, "timestamps", "t", false, fmt.Sprintf("--timestamps flag as received by %s logs command", virtualizer)) - logInstanceRootCmd.Flags().StringVar(&_root_instance_log_v_since_, "since", "", fmt.Sprintf("--since flag as received by %s logs command", virtualizer)) - logInstanceRootCmd.Flags().StringVar(&_root_instance_log_v_until_, "until", "", fmt.Sprintf("--until flag as received by %s logs command", virtualizer)) - logInstanceRootCmd.Flags().StringVarP(&_root_instance_log_v_tail_, "tail", "n", "all", fmt.Sprintf("--tail flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().String("service", primaryService, "show the log of a given service") + logInstanceRootCmd.Flags().Bool("all", false, "show the logs of all services") + logInstanceRootCmd.Flags().Bool("details", false, toSprintf("--details flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().Bool("follow", false, toSprintf("--follow flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().BoolP("timestamps", "t", false, toSprintf("--timestamps flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().String("since", "", toSprintf("--since flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().String("until", "", toSprintf("--until flag as received by %s logs command", virtualizer)) + logInstanceRootCmd.Flags().StringP("tail", "n", "all", toSprintf("--tail flag as received by %s logs command", virtualizer)) } diff --git a/chemotion-cli/cli/root-instance-new.go b/chemotion-cli/cli/root-instance-new.go index fca6637..be4eb05 100644 --- a/chemotion-cli/cli/root-instance-new.go +++ b/chemotion-cli/cli/root-instance-new.go @@ -1,7 +1,6 @@ package cli import ( - "fmt" "net/url" "os" "strconv" @@ -12,233 +11,250 @@ import ( "github.com/spf13/viper" ) -var ( - _root_instance_new_name_ string - _root_instance_new_use_ string - _root_instance_new_development_ bool - _root_instance_new_address_ string - _root_instance_new_env_ string -) +// helper to get a compose file +func parseCompose(use string) (compose viper.Viper) { + var ( + composeFilepath pathlib.Path + isUrl bool + ) + // TODO: check on the version of the compose file + if existingFile(use) { + composeFilepath = *pathlib.NewPath(use) + } else if _, err := url.ParseRequestURI(use); err == nil { + isUrl = true + composeFilepath = downloadFile(use, pathlib.NewPath(".").Join(toSprintf("%s.%s", getNewUniqueID(), defaultComposeFilename)).String()) // downloads to where-ever it is called from + } else { + if isUrl { + zboth.Fatal().Err(err).Msgf("Failed to download the file from URL: %s.", use) + } else { + zboth.Fatal().Err(err).Msgf("Failed %s for compose not found.", use) + } + } + // parse the compose file + compose = *viper.New() + compose.SetConfigFile(composeFilepath.String()) + err := compose.ReadInConfig() + if isUrl { + composeFilepath.Remove() + } + if err != nil { + zboth.Fatal().Err(err).Msgf("Invalid formatting for a compose file.") + } + return +} -// helper function to get a fresh (unassigned port) -func getFreshPort(kind string) (port int) { +// helper to get a fresh (unassigned port) +func getFreshPort(kind string) (port uint64) { if firstRun { port = firstPort } else { existingPorts := allPorts() if kind == "Production" { - for i := firstPort + 100; i <= maxInstancesOfKind+(firstPort+100); i++ { - if intInArray(i, &existingPorts) == -1 { + for i := firstPort + 101; i <= maxInstancesOfKind+(firstPort+101); i++ { + if elementInSlice(i, &existingPorts) == -1 { port = i break } } } else if kind == "Development" { - for i := firstPort + 200; i <= maxInstancesOfKind+(firstPort+200); i++ { - if intInArray(i, &existingPorts) == -1 { + for i := firstPort + 201; i <= maxInstancesOfKind+(firstPort+201); i++ { + if elementInSlice(i, &existingPorts) == -1 { port = i break } } } - if port == (firstPort+100)+maxInstancesOfKind || port == (firstPort+200)+maxInstancesOfKind { - zboth.Fatal().Err(fmt.Errorf("max instances")).Msgf("A maximum of %d instances of %s are allowed. Please contact us if you hit this limit.", maxInstancesOfKind, nameCLI) + if port == (firstPort+101)+maxInstancesOfKind || port == (firstPort+201)+maxInstancesOfKind { + zboth.Fatal().Err(toError("max instances")).Msgf("A maximum of %d instances of %s are allowed. Please contact us if you hit this limit.", maxInstancesOfKind, nameCLI) } } return } -func instanceCreate(givenName string, use string, kind string, givenAddress string) (success bool) { - if err := newInstanceValidate(givenName); err != nil { - zboth.Fatal().Err(err).Msgf("Given instance name is invalid because %s.", err.Error()) - } - var ( - port int - protocol string - address string - env *viper.Viper - ) - env = viper.New() - env.SetConfigType("env") - if _root_instance_new_env_ != "" { - env.SetConfigFile(_root_instance_new_env_) - if err := env.ReadInConfig(); err != nil { - zboth.Fatal().Err(err).Msgf("Failed to parse the supplied .env file.") +// to create a development instance +func instanceCreateDevelopment(details map[string]string) (success bool) { + zboth.Fatal().Err(toError("not implemented")).Msgf("This feature is currently under development.") + return false +} + +// interaction when creating a new instance +func processInstallAndInstanceCreateCmd(cmd *cobra.Command, details map[string]string) (create bool) { + askName, askAddress, askDevelopment := true, true, true + create = true + details["givenName"] = instanceDefault + details["accessAddress"] = addressDefault + details["kind"] = "Production" + details["use"] = getLatestComposeURL() + if ownCall(cmd) { + if cmd.Flag("name").Changed { + details["givenName"] = cmd.Flag("name").Value.String() + if err := newInstanceValidate(details["givenName"]); err != nil { + zboth.Fatal().Err(err).Msgf("Cannot create new instance with name %s: %s", details["givenName"], err.Error()) + } + askName = false } - if env.InConfig("URL_PROTOCOL") && env.InConfig("URL_HOST") { - if givenAddress == addressDefault { - givenAddress = env.GetString("URL_PROTOCOL") + "://" + env.GetString("URL_HOST") - } else { - zboth.Warn().Msgf("It seems you have `address` set in .env file as well as via the --address flag. The value in the given .env file will be overwritten.") + if cmd.Flag("address").Changed { + details["accessAddress"] = cmd.Flag("address").Value.String() + if err := addressValidate(details["accessAddress"]); err != nil { + zboth.Fatal().Err(err).Msgf("Cannot accept the address %s: %s", details["accessAddress"], err.Error()) } + askAddress = false } - } - protocol, address, port = splitAddress(givenAddress) - if address != "localhost" && (protocol == "http" && port == 443) || (protocol == "https" && port == 80) { - zboth.Warn().Err(fmt.Errorf("port mismatch")).Msgf("You have chosen port %d for protocol %s. This is generally a very bad idea.", port, protocol) - if !currentState.quiet { - if !selectYesNo("Continue still", false) { - zboth.Info().Msgf("Operation cancelled") - os.Exit(2) + if cmd.Flag("use").Changed { + details["use"] = cmd.Flag("use").Value.String() + } + if cmd.Flag("development") != nil { + if toBool(cmd.Flag("development").Value.String()) { + details["kind"] = "Development" } + askDevelopment = !cmd.Flag("use").Changed } } - if port == -1 { // i.e. a port was not suggested by the user - if address == "localhost" { - port = getFreshPort(kind) - givenAddress += ":" + strconv.Itoa(port) - } else { - if protocol == "http" { - port = 80 - } else { - port = 443 + if isInteractive(false) { + if firstRun || !ownCall(cmd) { // don't ask if the command is run directly i.e. without the menu + { + create = selectYesNo("Installation process may download containers (of multiple GBs) and can take some time. Continue", true) } } - } else { - if address == "localhost" { - zboth.Warn().Err(fmt.Errorf("localhost && port suggested")).Msgf("You suggested a port while running on localhost. We strongly recommend that you use the default schema i.e. do not assign a specific port.") - if !currentState.quiet { - if !selectYesNo("Continue still", false) { - zboth.Info().Msgf("Operation cancelled") - os.Exit(2) + if create { + if askName { + details["givenName"] = getString("Please enter the name of the instance you want to create", newInstanceValidate) + } + if askAddress { + if selectYesNo("Is this instance having its own web-address (e.g. https://chemotion.uni.de or http://chemotion.uni.de:4100)?", false) { + details["accessAddress"] = getString("Please enter the web-address", addressValidate) + } + } + if askDevelopment && !firstRun { + if !selectYesNo("Do you want a Production instance", true) { + details["kind"] = "Development" } } } } // create new unique name for the instance - name := fmt.Sprintf("%s-%s", givenName, getNewUniqueID()) - // store values in the conf, the conf file is modified only later - if firstRun { - conf.SetConfigFile(workDir.Join(defaultConfigFilepath).String()) - conf.Set("version", versionYAML) - conf.Set(selector_key, givenName) - } - conf.Set(joinKey("instances", givenName, "name"), name) - conf.Set(joinKey("instances", givenName, "kind"), kind) - conf.Set(joinKey("instances", givenName, "quiet"), false) - conf.Set(joinKey("instances", givenName, "debug"), kind == "Development") - conf.Set(joinKey("instances", givenName, "protocol"), protocol) - conf.Set(joinKey("instances", givenName, "address"), address) - conf.Set(joinKey("instances", givenName, "port"), port) - // get the compose file for the instance - var composeFilepath pathlib.Path // TODO: check on the version of the compose file - var isUrl bool = false - if existingFile(use) { - composeFilepath = *pathlib.NewPath(use) - } else if _, err := url.ParseRequestURI(use); err == nil { - isUrl = true - composeFilepath = downloadFile(use, workDir.String()) // downloads to the working directory - } else { - zboth.Fatal().Err(err).Msgf("Failed to parse the URL/file: %s.", use) - } - // parse the compose file - compose.SetConfigFile(composeFilepath.String()) - if err := compose.ReadInConfig(); err == nil { - if isUrl { - composeFilepath.Remove() - } - } else { - if isUrl { - composeFilepath.Remove() - } - zboth.Fatal().Err(err).Msgf("Invalid formatting for a compose file.") - } - // set labels in the compose file + details["name"] = toSprintf("%s-%s", details["givenName"], getNewUniqueID()) + return +} + +func createExtendedCompose(name, use string) (extendedCompose viper.Viper) { + extendedCompose = *viper.New() + compose := parseCompose(use) sections := []string{"services", "volumes", "networks"} + // set labels on services, volumes and networks for future identification for _, section := range sections { - subheadings, _ := getKeysValues(&compose, section) // subheadings are the names of the services, volumes and networks + subheadings := getSubHeadings(&compose, section) // subheadings are the names of the services, volumes and networks for _, k := range subheadings { - compose.Set(joinKey(section, k, "labels"), map[string]string{"net.chemotion.cli.project": name}) + extendedCompose.Set(joinKey(section, k, "labels"), map[string]string{"net.chemotion.cli.project": name}) } } // set unique name for volumes in the compose file - volumes, _ := getKeysValues(&compose, "volumes") + volumes := getSubHeadings(&compose, "volumes") for _, volume := range volumes { n := compose.GetString(joinKey("volumes", volume, "name")) - compose.Set(joinKey("volumes", volume, "name"), name+"_"+n) - } - // set the port in the compose file - compose.Set(joinKey("services", "eln", "ports"), []string{fmt.Sprintf("%d:4000", port)}) - zboth.Info().Msgf("Creating a new instance of %s called %s.", nameCLI, name) - // make folder - if err := workDir.Join(instancesFolder, name).MkdirAll(); err != nil { - zboth.Fatal().Err(err).Msgf("Unable to create folder to store instances of %s.", nameCLI) + if n == "" && volume == "spectra" { + n = "chemotion_spectra" + } // because the spectra volume has no name + if strings.HasPrefix(n, name) { // for compatibility with upgradeThisTool("0.1_to_0.2") + extendedCompose.Set(joinKey("volumes", volume, "name"), n) + } else { + extendedCompose.Set(joinKey("volumes", volume, "name"), name+"_"+n) + } } - if _, err, _ := gotoFolder(givenName), compose.WriteConfigAs(composeFilename), gotoFolder("workdir"); err == nil { - zboth.Info().Msgf("Written compose file %s in the above step.", compose.ConfigFileUsed()) - commandStr := fmt.Sprintf("compose -f %s up --no-start", composeFilename) - zboth.Info().Msgf("Starting %s with command: %s", virtualizer, commandStr) - if _, worked, _ := gotoFolder(givenName), callVirtualizer(commandStr), gotoFolder("workdir"); !worked { - success = worked - zboth.Fatal().Err(fmt.Errorf("%s failed", commandStr)).Msgf("Failed to setup %s. Check log. ABORT!", nameCLI) + return +} + +func instanceCreateProduction(details map[string]string) (success bool) { + pro, add, port := splitAddress(details["accessAddress"]) + details["protocol"], details["address"] = pro, add + if port == 0 { + port = getFreshPort(details["kind"]) + if details["address"] == "localhost" { + details["accessAddress"] += toSprintf(":%d", port) } } else { - success = false - zboth.Fatal().Err(err).Msgf("Failed to write the compose file to its repective folder. This is necessary for future use.") - } - // write env file into the container - envFile := workDir.Join(instancesFolder, name, ".env") - env.SetConfigFile(envFile.String()) - env.Set("URL_HOST", strings.TrimPrefix(givenAddress, protocol+"://")) - env.Set("URL_PROTOCOL", protocol) - if err := env.WriteConfig(); err == nil { - if worked := modifyContainer(givenName, []string{"cp", ".env", "shared/pullin"}); !worked { - success = worked - zboth.Warn().Msgf("Failed to write .env file in `%s/shared/pullin`", name) + if details["address"] == "localhost" { + zboth.Warn().Err(toError("localhost && port suggested")).Msgf("You suggested a port while running on localhost. We strongly recommend that you use the default schema i.e. do not assign a specific port.") + if isInteractive(false) { + if !selectYesNo("Continue still", false) { + zboth.Info().Msgf("Operation cancelled") + os.Exit(2) + } + } + } + } + details["port"] = strconv.FormatUint(port, 10) + // download and modify the compose file + var composeFile pathlib.Path + if existingFile(details["use"]) { + if err := copyfile(details["use"], toSprintf("%s.%s", getNewUniqueID(), defaultComposeFilename)); err == nil { + composeFile = *pathlib.NewPath(toSprintf("%s.%s", getNewUniqueID(), defaultComposeFilename)) + } else { + zboth.Fatal().Err(err).Msgf("Failed to copy the suggested compose file: %s. This is necessary for future use.") } } else { - zboth.Warn().Err(err).Msgf("Failed to write .env file") + composeFile = downloadFile(details["use"], toSprintf("%s.%s", getNewUniqueID(), defaultComposeFilename)) + } + if err := changeKey(composeFile.String(), joinKey("services", "eln", "ports[0]"), toSprintf("%s:4000", details["port"])); err != nil { + zboth.Fatal().Err(err).Msgf("Failed to update the downloaded compose file. This is necessary for future use.") + } + extendedCompose := createExtendedCompose(details["name"], composeFile.String()) + // store values in the conf, the conf file is modified only later + if firstRun { + conf.SetConfigFile(workDir.Join(configFile).String()) + conf.Set("version", versionYAML) + conf.Set(joinKey(stateWord, selectorWord), details["givenName"]) + conf.Set(joinKey(stateWord, "quiet"), false) + conf.Set(joinKey(stateWord, "debug"), false) + conf.Set(joinKey(stateWord, "version"), versionCLI) + } + conf.Set(joinKey(instancesWord, details["givenName"], "port"), port) + for _, key := range []string{"name", "kind", "accessAddress"} { + conf.Set(joinKey(instancesWord, details["givenName"], key), details[key]) } - envFile.Remove() - zboth.Info().Msgf("Successfully created container the container. New %s port available at %d.", nameCLI, port) - // now modify the config file - if err := conf.WriteConfig(); err == nil { - zboth.Info().Msgf("Written config file: %s.", conf.ConfigFileUsed()) + // make folder and move the compose file into it + zboth.Info().Msgf("Creating a new instance of %s called %s.", nameCLI, details["name"]) + if err := workDir.Join(instancesWord, details["name"]).MkdirAll(); err == nil { + composeFile.Rename(workDir.Join(instancesWord, details["name"], defaultComposeFilename)) } else { - zboth.Fatal().Err(err).Msg("Failed to write config file. Check log. ABORT!") + zboth.Fatal().Err(err).Msgf("Unable to create folder to store instances of %s.", nameCLI) } - return success -} - -func newInstanceInteraction() (create bool) { - create = selectYesNo("Installation process may download containers (of multiple GBs) and can take some time. Continue", true) - if create { - if _root_instance_new_name_ == instanceDefault { // i.e user has not changed it by passing an argument - _root_instance_new_name_ = getString("Please enter the name of the instance you want to create", newInstanceValidate) - } - if _root_instance_new_env_ == "" && _root_instance_new_address_ == addressDefault && selectYesNo("Is this instance running on a web-server?", false) { // i.e user has not changed it by passing an argument - _root_instance_new_address_ = getString("Please enter the web-address e.g. https://chemotion.uni.de:125", addressValidate) - } + // write out the extended compose file + if _, err, _ := gotoFolder(details["givenName"]), extendedCompose.WriteConfigAs(extenedComposeFilename), gotoFolder("workdir"); err == nil { + zboth.Info().Msgf("Written compose files %s and %s in the above steps.", defaultComposeFilename, extenedComposeFilename) } else { - zboth.Info().Msgf("Installation cancelled.") + zboth.Fatal().Err(err).Msgf("Failed to write the extended compose file to its repective folder. This is necessary for future use.") } - return + if _, success, _ = gotoFolder(details["givenName"]), callVirtualizer(composeCall+"up --no-start"), gotoFolder("workdir"); !success { + zboth.Fatal().Err(toError("compose up failed")).Msgf("Failed to setup %s. Check log. ABORT!", nameCLI) + } + // initialize the env file + conf.Set(joinKey(instancesWord, details["givenName"], "environment", "URL_HOST"), strings.TrimPrefix(details["accessAddress"], pro+"://")) + conf.Set(joinKey(instancesWord, details["givenName"], "environment", "URL_PROTOCOL"), pro) + if err := rewriteConfig(); err != nil { + zboth.Fatal().Err(err).Msg("Failed to write config file. Check log. ABORT!") // we want a fatal error in this case, `rewriteConfig()` does a Warn error + } + return success } -// command to install a new container of Chemotion +// command to install a new instance of Chemotion var newInstanceRootCmd = &cobra.Command{ Use: "new", Args: cobra.NoArgs, Short: "Create a new instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - create := true - kind := "Production" - if _root_instance_new_development_ { - kind = "Development" - } - if !currentState.quiet { - confirmInteractive() - create = newInstanceInteraction() - if create && !_root_instance_new_development_ { // i.e. the flag was not set - fmt.Println("What kind of instance do you want?") - kind = selectOpt([]string{"Production", "Development"}) - } - } + Run: func(cmd *cobra.Command, _ []string) { + details := make(map[string]string) + create := processInstallAndInstanceCreateCmd(cmd, details) if create { - if success := instanceCreate(_root_instance_new_name_, _root_instance_new_use_, kind, _root_instance_new_address_); success { - zboth.Info().Msg("Successfully created the new instance") + switch details["kind"] { + case "Production": + if success := instanceCreateProduction(details); success { + zboth.Info().Msgf("Successfully created a new production instance. Once switched on, it can be found at: %s", details["accessAddress"]) + } + case "Development": + if success := instanceCreateDevelopment(details); success { + zboth.Info().Msgf("Successfully created a new development instance.") + } } } }, @@ -246,9 +262,8 @@ var newInstanceRootCmd = &cobra.Command{ func init() { instanceRootCmd.AddCommand(newInstanceRootCmd) - newInstanceRootCmd.Flags().StringVar(&_root_instance_new_name_, "name", instanceDefault, "Name for the new instance") - newInstanceRootCmd.Flags().StringVar(&_root_instance_new_use_, "use", composeURL, "URL or filepath to use for creating the instance") - newInstanceRootCmd.Flags().StringVar(&_root_instance_new_address_, "address", addressDefault, "Web-address (or hostname) for accessing the instance") - newInstanceRootCmd.Flags().StringVar(&_root_instance_new_env_, "env", "", ".env file for the new instance") - newInstanceRootCmd.Flags().BoolVar(&_root_instance_new_development_, "development", false, "Create a development instance") + newInstanceRootCmd.Flags().StringP("name", "n", instanceDefault, "Name for the new instance") + newInstanceRootCmd.Flags().String("use", "", "URL or filepath of the compose file to use for creating the instance") + newInstanceRootCmd.Flags().String("address", addressDefault, "Web-address (or hostname) for accessing the instance") + newInstanceRootCmd.Flags().Bool("development", false, "Create a development instance") } diff --git a/chemotion-cli/cli/root-instance-ping.go b/chemotion-cli/cli/root-instance-ping.go new file mode 100644 index 0000000..b7ebd72 --- /dev/null +++ b/chemotion-cli/cli/root-instance-ping.go @@ -0,0 +1,40 @@ +package cli + +import ( + "net/http" + "time" + + "github.com/spf13/cobra" +) + +func instancePing(givenName string) (response string) { + url := conf.GetString(joinKey(instancesWord, currentInstance, "accessAddress")) + client := http.Client{Timeout: 2 * time.Second} + if req, err := http.NewRequest("HEAD", url, nil); err == nil { + if resp, err := client.Do(req); err == nil { + resp.Body.Close() + response = resp.Status + } else { + response = err.Error() + } + } + return +} + +// PingCmd represents the ping command +var pingInstanceRootCmd = &cobra.Command{ + Use: "ping", + Args: cobra.NoArgs, + Short: "Ping an instance of " + nameCLI, + Run: func(cmd *cobra.Command, _ []string) { + if response := instancePing(currentInstance); response == "200 OK" { + zboth.Info().Msgf("Success, received: %s.", response) + } else { + zboth.Warn().Msgf("Failed with response: %s.", response) + } + }, +} + +func init() { + instanceRootCmd.AddCommand(pingInstanceRootCmd) +} diff --git a/chemotion-cli/cli/root-instance-remove.go b/chemotion-cli/cli/root-instance-remove.go index 07a46dc..bb542c2 100644 --- a/chemotion-cli/cli/root-instance-remove.go +++ b/chemotion-cli/cli/root-instance-remove.go @@ -1,66 +1,45 @@ package cli import ( - "fmt" - "github.com/spf13/cobra" ) -var ( - _root_instance_remove_name_ string - _root_instance_remove_force_ bool -) - -func instanceRemove(givenName string) (success bool) { +func instanceRemove(givenName string, force bool) (err error) { name := getInternalName(givenName) - if !_root_instance_remove_force_ { - if instanceStatus(givenName) == "Up" { - zboth.Fatal().Err(fmt.Errorf("illegal operation")).Msgf("Cannot delete an instance that is currently running. Please use `chemotion -i %s stop` to stop the instance.") - } - if givenName == currentState.name { - zboth.Fatal().Err(fmt.Errorf("illegal operation")).Msgf("Cannot delete the currently selected instance. Use `chemotion switch` to switch selection to another instance before proceeding.") - } - if len(allInstances()) == 1 { - zboth.Fatal().Err(fmt.Errorf("illegal operation")).Msgf("Cannot delete the only instance. Use `chemotion advanced uninstall` remove %s entirely", nameCLI) - } - } - if _root_instance_remove_force_ && !(instanceStatus(givenName) == "Exited" || instanceStatus(givenName) == "Created") { - if _, worked, _ := gotoFolder(givenName), callVirtualizer("compose kill"), gotoFolder("workdir"); worked { - success = worked + // stop and delete instance + if force { + if _, success, _ := gotoFolder(givenName), callVirtualizer(composeCall+"kill"), gotoFolder("workdir"); success { + zboth.Debug().Msgf("Successfully killed container of instance called %s.", givenName) } else { - success = worked - zboth.Warn().Msgf("Failed to kill the containers associated with instance %s", givenName) + err = toError("failed to kill the containers associated with instance %s", givenName) + return } - } else { - success = true - } - if success { - _, success, _ = gotoFolder(givenName), callVirtualizer("compose down --remove-orphans --volumes"), gotoFolder("workdir") - } - // delete folder - if success { - zboth.Info().Msgf("Successfully removed container of instance called %s.", givenName) - zboth.Info().Msgf("Removing `shared` folder associated with %s.", givenName) - success = modifyContainer(givenName, []string{"rm -rf", "shared"}) } - if success { - if err := workDir.Join(instancesFolder, name).RemoveAll(); err != nil { - zboth.Warn().Err(err).Msgf("Failed to delete associated folder `%s` in `%s`.", name, instancesFolder) - } + if _, success, _ := gotoFolder(givenName), callVirtualizer(composeCall+"down --remove-orphans --volumes"), gotoFolder("workdir"); success { + zboth.Debug().Msgf("Successfully removed container of instance called %s.", givenName) + } else { + err = toError("failed to remove the containers associated with instance %s", givenName) + return } - // delete entry in config - if success { - configMap := conf.GetStringMap("instances") - delete(configMap, givenName) - conf.Set("instances", configMap) - if err := conf.WriteConfig(); err == nil { - zboth.Info().Msgf("Modified configuration file `%s` to remove entry for `%s`.", conf.ConfigFileUsed(), givenName) + // delete folders: shared folder and then named instance folder + if deleteShared := modifyContainer(givenName, "rm -rf", "shared", ""); deleteShared { + zboth.Debug().Msgf("Successfully removed `shared` folder associated with %s.", givenName) + if deleteFolder := workDir.Join(instancesWord, name).RemoveAll(); deleteFolder == nil { + zboth.Debug().Msgf("Successfully removed named instance folder associated with %s.", givenName) } else { - zboth.Fatal().Err(err).Msgf("Failed to update the configuration file.") + err = toError("failed to delete associated folder `%s` in `%s`", name, instancesWord) + return } + } else { + err = toError("failed to remove the `shared` associated with instance %s; you may require admin priviledges to remove it", givenName) + return } - if !success { - zboth.Info().Msgf("Clean deletion of %s failed. Check log to see what went wrong.", givenName) + // delete entry in config + configMap := conf.GetStringMap(instancesWord) + delete(configMap, givenName) + conf.Set(instancesWord, configMap) + if err = rewriteConfig(); err != nil { + err = toError("fail to rewrite configuration file") } return } @@ -69,19 +48,58 @@ var removeInstanceRootCmd = &cobra.Command{ Use: "remove", Args: cobra.NoArgs, Short: "Remove an existing instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - if _root_instance_remove_name_ == "" { - confirmInteractive() - _root_instance_remove_name_ = selectInstance("remove") + Run: func(cmd *cobra.Command, _ []string) { + if len(allInstances()) == 1 { + zboth.Fatal().Err(toError("only one instance")).Msgf("Cannot delete the only instance. Use `%s %s %s` remove %s entirely", commandForCLI, advancedRootCmd.Use, uninstallAdvancedRootCmd.Use, commandForCLI) + } + var ( + givenName string + force bool + ) + if ownCall(cmd) { + if cmd.Flag("name").Changed { + givenName = cmd.Flag("name").Value.String() + if err := instanceValidate(givenName); err != nil { + zboth.Fatal().Err(err).Msgf(err.Error()) + } + } else { + isInteractive(true) + givenName = selectInstance("remove") + } + } else { + if isInteractive(false) { + givenName = selectInstance("remove") + } else { + zboth.Fatal().Err(toError("unexpected operation")).Msgf("Please repeat your actions with the `--debug` flag and report this error.") + } + } + if givenName == currentInstance { + zboth.Fatal().Err(toError("illegal operation")).Msgf("Cannot delete the currently selected instance. Use `%s instance %s` to switch selection to another instance before proceeding.", commandForCLI, switchInstanceRootCmd.Use) + } + status := instanceStatus(givenName) + if elementInSlice(status, &[]string{"Exited", "Created"}) == -1 { + zboth.Warn().Msgf("The instance %s is %s.", givenName, status) + if ownCall(cmd) { + force = toBool(cmd.Flag("force").Value.String()) + if !force && isInteractive(true) { + force = selectYesNo("Force remove", false) + } + } else { + if isInteractive(false) { + force = selectYesNo("Force remove", false) + } else { + zboth.Fatal().Err(toError("unexpected operation")).Msgf("Please repeat your actions with the `--debug` flag and report this error.") + } + } + } + if err := instanceRemove(givenName, force); err != nil { + zboth.Warn().Err(err).Msgf(err.Error()) } - instanceRemove(_root_instance_remove_name_) }, } func init() { instanceRootCmd.AddCommand(removeInstanceRootCmd) - removeInstanceRootCmd.Flags().StringVar(&_root_instance_remove_name_, "name", "", "name of the instance to remove") - removeInstanceRootCmd.Flags().BoolVar(&_root_instance_remove_force_, "force", false, "force remove an instance (very risky)") + removeInstanceRootCmd.Flags().StringP("name", "n", "", "name of the instance to remove") + removeInstanceRootCmd.Flags().Bool("force", false, "force remove an instance (very risky)") } diff --git a/chemotion-cli/cli/root-instance-restart.go b/chemotion-cli/cli/root-instance-restart.go deleted file mode 100644 index c4dd988..0000000 --- a/chemotion-cli/cli/root-instance-restart.go +++ /dev/null @@ -1,24 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func instanceRestart(givenName string) { - instanceStop(givenName) - instanceStart(givenName) -} - -var restartInstanceRootCmd = &cobra.Command{ - Use: "restart", - Args: cobra.NoArgs, - Short: "Restart (the selected) instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - instanceRestart(currentState.name) - }, - // TODO: add a force restart flag -} - -func init() { - instanceRootCmd.AddCommand(restartInstanceRootCmd) -} diff --git a/chemotion-cli/cli/root-instance-stat.go b/chemotion-cli/cli/root-instance-stat.go index 8f35270..8c451aa 100644 --- a/chemotion-cli/cli/root-instance-stat.go +++ b/chemotion-cli/cli/root-instance-stat.go @@ -1,57 +1,58 @@ package cli import ( - "fmt" - "os" "strings" - "text/tabwriter" "github.com/spf13/cobra" ) -var ( - _root_instance_stat_all_ bool - _root_instance_stat_service_ string -) - -func instanceStat(givenName, service string) { - name, services, out, statOf := getInternalName(givenName), getServices(givenName), []string{""}, []string{} - if _root_instance_stat_all_ { - statOf = services - zboth.Info().Msgf("Printing stats for the instance called %s.", givenName) - } else { - if stringInArray(service, &services) > -1 { - statOf = []string{service} - zboth.Info().Msgf("Printing stats for the instance-service called %s-%s.", givenName, service) - } else { - zboth.Fatal().Err(fmt.Errorf("named service not found")).Msgf("No service called %s found associated with the instance called %s.", service, givenName) +func instanceStatus(givenName string) (status string) { + out := getColumn(givenName, "Status", "") + var statuses []string + for _, line := range out { // determine what are the status messages for all associated containers + l := strings.Split(line, " ") // use only the first word + if len(l) > 0 { + status := l[0] // use only the first word + if l[len(l)-1] == "(Paused)" { // use this if the last word is Paused + status = "Paused" + } + if elementInSlice(status, &statuses) == -1 && len(status) != 0 { + statuses = append(statuses, status) + } } } - zboth.Info().Msgf("The status of %s is: %s.", givenName, instanceStatus(givenName)) - if res, err := execShell(fmt.Sprintf("%s stats --all --no-stream --no-trunc --format \"{{ .Name }} {{ .MemUsage }} {{ .MemPerc }} {{ .CPUPerc }}\"", toLower(virtualizer))); err == nil { + if len(statuses) == 0 { + status = "Instance not found" + } else if len(statuses) == 1 { + status = statuses[0] + } else if len(statuses) > 1 { + status = strings.Join(statuses, " and ") + } + return +} + +func instanceStat(givenName string) { + name, services, out := getInternalName(givenName), getServices(givenName), []string{""} + zboth.Info().Msgf("The status of %s is: %s.\n\nIts stats are:", givenName, instanceStatus(givenName)) + if res, err := execShell(toSprintf("%s stats --all --no-stream --no-trunc --format \"{{ .Name }} {{ .ID }} {{ .MemUsage }} {{ .MemPerc }} {{ .CPUPerc }}\"", toLower(virtualizer))); err == nil { out[0] = string(res) out = strings.Split(out[0], "\n") - w := new(tabwriter.Writer) - w.Init(os.Stdout, 12, 8, 0, '\t', 0) - fmt.Fprintf(w, "\n %s\t%s\t%s\t%s", "Name", " Memory ", "Mem %", "CPU %") - fmt.Fprintf(w, "\n %s\t%s\t%s\t%s", "----", "--------", "-----", "-----") - for _, service := range statOf { + zboth.Info().Msgf("%10s %10s %10s %10s %10s", "Name", "ID", " Memory", "Mem %", "CPU %") + zboth.Info().Msgf("---------- ---------- ---------- ---------- ----------") + for _, service := range services { found := false for _, line := range out { - zlog.Log().Msgf("stats for %s-%s: %s", name, service, line) l := strings.Split(line, " ") - if l[0] == fmt.Sprintf("%s-%s-%d", name, service, rollNum) { - fmt.Fprintf(w, "\n %s\t%s\t%s\t%s", service, l[1], l[4], l[5]) + if l[0] == toSprintf("%s-%s-%d", name, service, rollNum) { + zboth.Info().Msgf("%10s %10s %10s %10s %10s", service, l[1][:10], l[2], l[5], l[6]) found = true break } } if !found { - zboth.Warn().Err(fmt.Errorf("stats not found")).Msgf("Error while parsing stats for the instance-container called %s-%s-%d.", name, service, rollNum) + zboth.Warn().Err(toError("stats not found")).Msgf("Error while parsing stats for the instance-container called %s-%s-%d.", name, service, rollNum) } } - fmt.Fprintf(w, "\n") - w.Flush() } else { zboth.Fatal().Err(err).Msgf("Failed to get stats from %s.", virtualizer) } @@ -59,27 +60,28 @@ func instanceStat(givenName, service string) { var statInstanceRootCmd = &cobra.Command{ Use: "stat", - Aliases: []string{"stats"}, + Aliases: []string{"stats", "status"}, Args: cobra.NoArgs, - Short: "Get stats of an instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - if currentState.quiet { - zboth.Warn().Err(fmt.Errorf("illegal operation")).Msgf("Stats can't be printed in quiet mode.") - } else { - if _root_instance_stat_service_ == "" { - zboth.Debug().Msgf("No service specified, printing stats for all services.") - _root_instance_stat_all_ = true + Short: "Get status and status of an instance of " + nameCLI, + Run: func(cmd *cobra.Command, _ []string) { + if ownCall(cmd) && toBool(cmd.Flag("all").Value.String()) { + existingInstances := allInstances() + if len(existingInstances) == 1 { + zboth.Info().Msgf("You have only one instance of %s. Ignoring the `--all` flag.", nameCLI) + instanceStat(currentInstance) + } else { + for _, instance := range existingInstances { + status := instanceStatus(instance) + zboth.Info().Msgf("The status of %s is: %s.", instance, status) + } } - _root_instance_stat_service_ = toLower(_root_instance_stat_service_) - instanceStat(currentState.name, _root_instance_stat_service_) + } else { + instanceStat(currentInstance) } }, } func init() { instanceRootCmd.AddCommand(statInstanceRootCmd) - statInstanceRootCmd.Flags().StringVar(&_root_instance_stat_service_, "service", "", "show the stats of a given service") - statInstanceRootCmd.Flags().BoolVar(&_root_instance_stat_all_, "all", false, "show the stats for all services of an instance") + statInstanceRootCmd.Flags().Bool("all", false, "show the status of all instances") } diff --git a/chemotion-cli/cli/root-instance-status.go b/chemotion-cli/cli/root-instance-status.go deleted file mode 100644 index d85b468..0000000 --- a/chemotion-cli/cli/root-instance-status.go +++ /dev/null @@ -1,58 +0,0 @@ -package cli - -import ( - "strings" - - "github.com/spf13/cobra" -) - -var _root_instance_status_all_ bool - -func instanceStatus(givenName string) (status string) { - out := getColumn(givenName, "Status") - var statuses []string - for _, line := range out { // determine what are the status messages for all associated containers - l := strings.Split(line, " ") // use only the first word - if len(l) > 0 { - status := l[0] // use only the first word - if l[len(l)-1] == "(Paused)" { - status = "Paused" - } - if stringInArray(status, &statuses) == -1 && len(status) != 0 { - statuses = append(statuses, status) - } - } - } - if len(statuses) == 0 { - status = "Instance not found" - } else if len(statuses) == 1 { - status = statuses[0] - } else if len(statuses) > 1 { - status = strings.Join(statuses, " and ") - } - return -} - -var statusInstanceRootCmd = &cobra.Command{ - Use: "status", - Args: cobra.NoArgs, - Short: "Get status of an instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - if !_root_instance_status_all_ { - status := instanceStatus(currentState.name) - zboth.Info().Msgf("The status of %s is: %s.", currentState.name, status) - } else { - for _, instance := range allInstances() { - status := instanceStatus(instance) - zboth.Info().Msgf("The status of %s is: %s.", instance, status) - } - } - }, -} - -func init() { - instanceRootCmd.AddCommand(statusInstanceRootCmd) - statusInstanceRootCmd.Flags().BoolVar(&_root_instance_status_all_, "all", false, "show status of all instances") -} diff --git a/chemotion-cli/cli/root-instance-switch.go b/chemotion-cli/cli/root-instance-switch.go index c24a17f..995f1bb 100644 --- a/chemotion-cli/cli/root-instance-switch.go +++ b/chemotion-cli/cli/root-instance-switch.go @@ -5,9 +5,10 @@ import ( ) func instanceSwitch(givenName string) { - conf.Set(selector_key, givenName) - if err := conf.WriteConfig(); err == nil { - zboth.Info().Msgf("Modified configuration file %s.", conf.ConfigFileUsed()) + conf.Set(joinKey(stateWord, selectorWord), givenName) + if err := rewriteConfig(); err == nil { + currentInstance = givenName + zboth.Info().Msgf("Instance being managed switched to %s%s%s%s.", string("\033[31m"), string("\033[1m"), currentInstance, string("\033[0m")) } else { zboth.Fatal().Err(err).Msgf("Failed to update the selected instance.") } @@ -16,22 +17,27 @@ func instanceSwitch(givenName string) { var switchInstanceRootCmd = &cobra.Command{ Use: "switch", Short: "Switch to an instance of " + nameCLI, - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - if currentState.quiet { - if cmd.Flags().Lookup("selected-instance").Changed { // this implies a non-interactive run - instanceSwitch(currentState.name) + Run: func(cmd *cobra.Command, _ []string) { + if len(allInstances()) == 1 { + zboth.Fatal().Err(toError("only one instance")).Msgf("You cannot switch because you only have one instance.") + + } + if ownCall(cmd) { + if cmd.Flag("name").Changed { + givenName := cmd.Flag("name").Value.String() + if err := instanceValidate(givenName); err != nil { + zboth.Fatal().Err(err).Msgf(err.Error()) + } + instanceSwitch(givenName) + } else { + isInteractive(true) + instanceSwitch(selectInstance("switch to")) } } else { - confirmInteractive() - if cmd.Flags().Lookup("selected-instance").Changed { - if selectYesNo("Confirm switching selected instance to "+currentState.name, false) { - instanceSwitch(currentState.name) - } + if isInteractive(false) { + instanceSwitch(selectInstance("switch to")) } else { - currentState.name = selectInstance("switch to") - instanceSwitch(currentState.name) + zboth.Fatal().Err(toError("unexpected operation")).Msgf("Please repeat your actions with the `--debug` flag and report this error.") } } }, @@ -39,4 +45,5 @@ var switchInstanceRootCmd = &cobra.Command{ func init() { instanceRootCmd.AddCommand(switchInstanceRootCmd) + switchInstanceRootCmd.Flags().StringP("name", "n", "", "Name of instance to switch to.") } diff --git a/chemotion-cli/cli/root-instance-upgrade.go b/chemotion-cli/cli/root-instance-upgrade.go new file mode 100644 index 0000000..669aa12 --- /dev/null +++ b/chemotion-cli/cli/root-instance-upgrade.go @@ -0,0 +1,124 @@ +package cli + +import ( + "strings" + "time" + + "github.com/spf13/cobra" +) + +func pullImages(use string) { + tempCompose := parseCompose(use) + services := getSubHeadings(&tempCompose, "services") + if len(services) == 0 { + zboth.Warn().Err(toError("no services found")).Msgf("Please check that %s is a valid compose file with named services.", tempCompose.ConfigFileUsed()) + } + for _, service := range services { + zboth.Info().Msgf("Pulling image for the service called %s", service) + if success := callVirtualizer(toSprintf("pull %s", tempCompose.GetString(joinKey("services", service, "image")))); !success { + zboth.Warn().Err(toError("pull failed")).Msgf("Failed to pull image for the service called %s", service) + } + } +} + +func instanceUpgrade(givenName, use string) { + var success bool = true + name := getInternalName(givenName) + // download the new compose (in the working directory) + newComposeFile := downloadFile(getLatestComposeURL(), workDir.String()) + // get port from old compose + oldComposeFile := workDir.Join(instancesWord, name, defaultComposeFilename) + oldCompose := parseCompose(oldComposeFile.String()) + if err := changeKey(newComposeFile.String(), joinKey("services", "eln", "ports[0]"), oldCompose.GetStringSlice(joinKey("services", "eln", "ports"))[0]); err != nil { + newComposeFile.Remove() + zboth.Fatal().Err(err).Msgf("Failed to update the downloaded compose file. This is necessary for future use. The file was removed.") + } + // backup the old compose file + if err := oldComposeFile.Rename(workDir.Join(instancesWord, name, toSprintf("old.%s.%s", time.Now().Format("060102150405"), defaultComposeFilename))); err == nil { + zboth.Info().Msgf("The old compose file is now called %s:", oldComposeFile.String()) + } else { + newComposeFile.Remove() + zboth.Fatal().Err(err).Msgf("Failed to remove the new compose file. Check log. ABORT!") + } + if err := newComposeFile.Rename(workDir.Join(instancesWord, name, defaultComposeFilename)); err != nil { + zboth.Fatal().Err(err).Msgf("Failed to rename the new compose file: %s. Check log. ABORT!", newComposeFile.String()) + } + // shutdown existing instance's docker + if _, success, _ = gotoFolder(givenName), callVirtualizer(composeCall+"down --remove-orphans"), gotoFolder("workdir"); !success { + zboth.Fatal().Err(toError("compose down failed")).Msgf("Failed to stop %s. Check log. ABORT!", givenName) + } + if success { + if _, success, _ = gotoFolder(givenName), callVirtualizer(toSprintf("volume rm %s_chemotion_app", name)), gotoFolder("workdir"); !success { + zboth.Fatal().Err(toError("volume removal failed")).Msgf("Failed to remove old app volume. Check log. ABORT!") + } + } + if success { + commandStr := toSprintf(composeCall + "up --no-start") + zboth.Info().Msgf("Starting %s with command: %s", virtualizer, commandStr) + if _, success, _ = gotoFolder(givenName), callVirtualizer(commandStr), gotoFolder("workdir"); !success { + zboth.Fatal().Err(toError("%s failed", commandStr)).Msgf("Failed to initialize upgraded %s. Check log. ABORT!", givenName) + } + zboth.Info().Msgf("Instance upgraded successfully!") + } +} + +func getLatestComposeURL() (url string) { + var err error + if url, err = getLatestReleaseURL(); err == nil { + url = strings.Join([]string{url, defaultComposeFilename}, "/") + } else { + zboth.Warn().Err(err).Msgf("Could not determine the address of the latest compose file, using this one: %s.", composeURL) + url = composeURL + } + return +} + +var upgradeInstanceRootCmd = &cobra.Command{ + Use: "upgrade", + Args: cobra.NoArgs, + Short: "Upgrade (the selected) instance of " + nameCLI, + Run: func(cmd *cobra.Command, _ []string) { + var pull, backup, upgrade bool = false, false, true + var use string = "" + if ownCall(cmd) { + if cmd.Flag("use").Changed { + use = cmd.Flag("use").Value.String() + } + pull = toBool(cmd.Flag("pull-only").Value.String()) + upgrade = !pull + } + if !pull && isInteractive(false) { + switch selectOpt([]string{"all actions: pull image, backup and upgrade", "preparation: pull image and backup", "upgrade only (if already prepared)", "pull image only", "exit"}, "What do you want to do") { + case "all actions: pull image, backup and upgrade": + pull, backup, upgrade = true, true, true + case "preparation: pull image and backup": + pull, backup, upgrade = true, true, false + case "upgrade only (if already prepared)": + pull, backup, upgrade = false, false, true + case "pull image only": + pull, backup, upgrade = true, false, false + } + } + if use == "" { + use = getLatestComposeURL() + } + if pull { + pullImages(use) + } + if backup { + instanceBackup(currentInstance, "both") + } + if upgrade { + if instanceStatus(currentInstance) == "Up" { + zboth.Fatal().Err(toError("upgrade fail; instance is up")).Msgf("Cannot upgrade an instance that is currently running. Please turn it off before continuing.") + } + instanceUpgrade(currentInstance, use) + } + }, +} + +func init() { + upgradeInstanceRootCmd.Flags().String("use", composeURL, "URL or filepath of the compose file to use for upgrading") + upgradeInstanceRootCmd.Flags().Bool("pull-only", false, "Pull image for use in upgrade, don't do the upgrade") + instanceRootCmd.AddCommand(upgradeInstanceRootCmd) +} diff --git a/chemotion-cli/cli/root-instance.go b/chemotion-cli/cli/root-instance.go index 79072e8..6ee3bea 100644 --- a/chemotion-cli/cli/root-instance.go +++ b/chemotion-cli/cli/root-instance.go @@ -2,33 +2,51 @@ package cli import ( "github.com/spf13/cobra" + "golang.org/x/exp/maps" ) +var instanceCmdTable = make(cmdTable) + var instanceRootCmd = &cobra.Command{ - Use: "instance {status|switch|restart|new|remove}", - Args: cobra.NoArgs, - Short: "Manipulate instances of " + nameCLI, + Use: "instance", + Aliases: []string{"i"}, + ValidArgs: maps.Keys(instanceCmdTable), + Args: cobra.NoArgs, + Short: "Manipulate instances of " + nameCLI, Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - confirmInteractive() - acceptedOpts := []string{"status", "switch", "list", "restart", "new", "remove", "exit"} //, "status", "upgrade", "switch", "start", "pause", "stop", "restart", "delete"} - switch selectOpt(acceptedOpts) { - case "status": - statusInstanceRootCmd.Run(cmd, args) - case "switch": - switchInstanceRootCmd.Run(cmd, args) - case "list": - listInstanceRootCmd.Run(&cobra.Command{}, []string{}) - case "restart": - restartInstanceRootCmd.Run(cmd, args) - case "new": - newInstanceRootCmd.Run(cmd, args) - case "remove": - removeInstanceRootCmd.Run(cmd, args) - case "exit": - zlog.Debug().Msg("Chose to exit") + isInteractive(true) + var acceptedOpts []string + if elementInSlice(instanceStatus(currentInstance), &[]string{"Exited", "Created"}) == -1 { // checks if the instance is running + acceptedOpts = []string{"stats", "ping", "logs", "consoles"} + instanceCmdTable["stats"] = statInstanceRootCmd.Run + instanceCmdTable["ping"] = pingInstanceRootCmd.Run + instanceCmdTable["consoles"] = consoleInstanceRootCmd.Run + instanceCmdTable["logs"] = logInstanceRootCmd.Run + } else { + acceptedOpts = []string{"logs"} + instanceCmdTable["logs"] = logInstanceRootCmd.Run + } + if len(allInstances()) > 1 { + acceptedOpts = append(acceptedOpts, []string{"switch", "backup", "upgrade", "list", "new", "remove"}...) + instanceCmdTable["switch"] = switchInstanceRootCmd.Run + instanceCmdTable["backup"] = backupInstanceRootCmd.Run + instanceCmdTable["upgrade"] = upgradeInstanceRootCmd.Run + instanceCmdTable["list"] = listInstanceRootCmd.Run + instanceCmdTable["remove"] = removeInstanceRootCmd.Run + instanceCmdTable["new"] = newInstanceRootCmd.Run + } else { + acceptedOpts = append(acceptedOpts, []string{"backup", "upgrade", "new"}...) + instanceCmdTable["backup"] = backupInstanceRootCmd.Run + instanceCmdTable["upgrade"] = upgradeInstanceRootCmd.Run + instanceCmdTable["new"] = newInstanceRootCmd.Run + } + if cmd.Use == cmd.CalledAs() || elementInSlice(cmd.CalledAs(), &cmd.Aliases) > -1 { + acceptedOpts = append(acceptedOpts, "exit") + } else { + acceptedOpts = append(acceptedOpts, []string{"back", "exit"}...) + instanceCmdTable["back"] = cmd.Run } + instanceCmdTable[selectOpt(acceptedOpts, "")](cmd, args) }, } diff --git a/chemotion-cli/cli/root-onoff.go b/chemotion-cli/cli/root-onoff.go deleted file mode 100644 index 67bd0bf..0000000 --- a/chemotion-cli/cli/root-onoff.go +++ /dev/null @@ -1,70 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func instanceStart(givenName string) { - status := instanceStatus(givenName) - if status == "Up" { - zboth.Warn().Msgf("The instance called %s is already running.", givenName) - } else { - if _, success, _ := gotoFolder(givenName), callVirtualizer("compose up -d"), gotoFolder("workdir"); success { - var ( - seconds int - ft string - ) - if seconds = 20; status == "Created" { - seconds = 60 - } - if firstRun { - ft = " for the first time" - } - zboth.Info().Msgf("Starting instance called %s. Please give it %d seconds to initialize%s.", givenName, seconds, ft) - waitProgressBar(seconds, []string{"Starting", givenName}) - zboth.Info().Msgf("Successfully started instance called %s.", givenName) - } else { - zboth.Fatal().Msgf("Failed to start instance called %s.", givenName) - } - } -} - -func instanceStop(givenName string) { - status := instanceStatus(givenName) - if status == "Up" { - if _, success, _ := gotoFolder(givenName), callVirtualizer("compose stop"), gotoFolder("workdir"); success { - zboth.Info().Msgf("Successfully stopped instance called %s.", givenName) - } else { - zboth.Fatal().Msgf("Failed to stop instance called %s.", givenName) - } - } else { - zboth.Warn().Msgf("Cannot stop instance %s. It seems to be %s.", givenName, status) - } -} - -var onRootCmd = &cobra.Command{ - Use: "on", - Args: cobra.NoArgs, - Short: "Start (the selected instance of) chemotion", - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - instanceStart(currentState.name) - }, -} - -var offRootCmd = &cobra.Command{ - Use: "off", - Args: cobra.NoArgs, - Short: "Stop (the selected instance of) chemotion", - Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - instanceStop(currentState.name) - }, -} - -func init() { - rootCmd.AddCommand(onRootCmd) - rootCmd.AddCommand(offRootCmd) -} diff --git a/chemotion-cli/cli/root-onoffrestart.go b/chemotion-cli/cli/root-onoffrestart.go new file mode 100644 index 0000000..a171650 --- /dev/null +++ b/chemotion-cli/cli/root-onoffrestart.go @@ -0,0 +1,122 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/schollz/progressbar/v3" + "github.com/spf13/cobra" +) + +// show (and then remove) a progress bar that waits for an instance to start +func waitStartSpinner(seconds int, givenName string) (waitTime int) { + bar := progressbar.NewOptions( + -1, + progressbar.OptionSetDescription(toSprintf("Starting %s...", givenName)), + progressbar.OptionSetPredictTime(false), + progressbar.OptionClearOnFinish(), + progressbar.OptionSetRenderBlankState(true), + progressbar.OptionSetVisibility(true), + progressbar.OptionSpinnerType(51), + ) + for i := 0; i < seconds; i++ { + if instancePing(givenName) == "200 OK" { + bar.Finish() + fmt.Println() + waitTime = i + return + } + bar.Add(1) + time.Sleep(1 * time.Second) + } + bar.Finish() + waitTime = -1 + fmt.Println() + return +} + +func instanceStart(givenName string) { + status := instanceStatus(givenName) + if status == "Up" { + zboth.Warn().Msgf("The instance called %s is already running.", givenName) + } else { + env := conf.Sub(joinKey(instancesWord, givenName, "environment")) + env.SetConfigType("env") + if _, errWrite, _ := gotoFolder(givenName), env.WriteConfigAs(".env"), gotoFolder("workdir"); errWrite != nil { + zboth.Fatal().Err(errWrite).Msgf("Failed to write .env file for the container.") + } + if errCreateFolder := modifyContainer(givenName, "mkdir -p", "shared/pullin", ""); !errCreateFolder { + zboth.Fatal().Err(toError("create shared/pullin failed")).Msgf("Failed to create folder inside the respective container.") + } + if errMove := modifyContainer(givenName, "mv", ".env", "shared/pullin"); !errMove { + zboth.Fatal().Err(toError("move .env failed")).Msgf("Failed to move .env file into the respecitive container.") + } + if _, success, _ := gotoFolder(givenName), callVirtualizer(composeCall+"up -d"), gotoFolder("workdir"); success { + waitFor := 120 // in seconds + if status == "Exited" { + waitFor = 30 + } + zlog.Info().Msgf("Starting instance called %s.", givenName) // because user sees the spinner + waitTime := waitStartSpinner(waitFor, givenName) + if waitTime >= 0 { + zboth.Info().Msgf("Successfully started instance called %s in %d seconds at %s.", givenName, waitTime, conf.GetString(joinKey(instancesWord, givenName, "accessAddress"))) + } else { + zboth.Fatal().Err(toError("ping timeout after %d seconds", waitTime)).Msgf("Failed to start instance called %s. Please check logs using `%s instance %s`.", givenName, commandForCLI, logInstanceRootCmd.Use) + } + } else { + zboth.Fatal().Msgf("Failed to start instance called %s.", givenName) + } + } +} + +func instanceStop(givenName string) { + status := instanceStatus(givenName) + if status == "Up" { + if _, success, _ := gotoFolder(givenName), callVirtualizer(composeCall+"stop"), gotoFolder("workdir"); success { + zboth.Info().Msgf("Successfully stopped instance called %s.", givenName) + } else { + zboth.Fatal().Msgf("Failed to stop instance called %s.", givenName) + } + } else { + zboth.Warn().Msgf("Cannot stop instance %s. It seems to be %s.", givenName, status) + } +} + +func instanceRestart(givenName string) { + instanceStop(givenName) + instanceStart(givenName) +} + +var restartRootCmd = &cobra.Command{ + Use: "restart [-i ]", + Args: cobra.NoArgs, + Short: "Restart the selected instance of " + nameCLI, + Run: func(_ *cobra.Command, _ []string) { + instanceRestart(currentInstance) + }, + // TODO: add a force restart flag +} + +var onRootCmd = &cobra.Command{ + Use: "on [-i ]", + Args: cobra.NoArgs, + Short: "Start the selected instance of " + nameCLI, + Run: func(_ *cobra.Command, _ []string) { + instanceStart(currentInstance) + }, +} + +var offRootCmd = &cobra.Command{ + Use: "off [-i ]", + Args: cobra.NoArgs, + Short: "Stop the selected instance of " + nameCLI, + Run: func(_ *cobra.Command, _ []string) { + instanceStop(currentInstance) + }, +} + +func init() { + rootCmd.AddCommand(onRootCmd) + rootCmd.AddCommand(offRootCmd) + rootCmd.AddCommand(restartRootCmd) +} diff --git a/chemotion-cli/cli/root.go b/chemotion-cli/cli/root.go index 1ed1359..9ad3898 100644 --- a/chemotion-cli/cli/root.go +++ b/chemotion-cli/cli/root.go @@ -33,6 +33,7 @@ package cli import ( "fmt" + "os" "github.com/chigopher/pathlib" "github.com/rs/zerolog" @@ -41,74 +42,107 @@ import ( ) const ( - versionCLI = "0.1" - versionYAML = "1.0" - nameCLI = "Chemotion" - defaultConfigFilepath = "chemotion-cli.yml" - logFilename = "chemotion-cli.log" - instanceDefault = "initial" - addressDefault = "http://localhost" - selector_key = "selected" // key that is expected in the configFile to figure out the selected instance - stateFile = "./version" - instancesFolder = "instances" // the folder in which chemotion expects to find all the instances - virtualizer = "Docker" - shell = "bash" // should work with linux (ubuntu, windows < WSL runs when running in powershell >, and macOS) - minimumVirtualizer = "20.10.2" // so as to support docker compose files version 3.5 and avoid this: https://github.com/docker/for-mac/issues/4975 by forcing Docker Desktop >= 3.0.4 - composeFilename = "docker-compose.yml" - maxInstancesOfKind = 64 - firstPort = 4000 - composeURL = "https://raw.githubusercontent.com/ptrxyz/chemotion/release-112/release/1.1.2p220401/docker-compose.yml" - rollNum = 1 // the default index number assigned by virtualizer to every container + versionCLI = "0.2.0-alpha" + versionYAML = "1.1" + nameCLI = "Chemotion" + defaultConfigFilepath = "chemotion-cli.yml" + logFilename = "chemotion-cli.log" + instanceDefault = "initial" + addressDefault = "http://localhost" + insideFile = "/.version" + stateWord = "cli_state" + selectorWord = "selected" // key that is expected in the configFile to figure out the selected instance + instancesWord = "instances" // the folder/key in which chemotion expects to find all the instances + virtualizer = "Docker" + shell = "bash" // should work with linux (ubuntu, windows < WSL runs when running in powershell >, and macOS) + minimumVirtualizer = "20.10.2" // so as to support docker compose files version 3.5 and avoid this: https://github.com/docker/for-mac/issues/4975 by forcing Docker Desktop >= 3.0.4 + defaultComposeFilename = "docker-compose.yml" + extenedComposeFilename = "docker-compose.cli.yml" + maxInstancesOfKind = 63 + firstPort uint64 = 4000 + composeURL = "https://raw.githubusercontent.com/harivyasi/chemotion/chemotion-cli/docker-compose.yml" + releaseUnresolvedURL = "https://github.com/harivyasi/chemotion/releases/latest" + rollNum = 1 // the default index number assigned by virtualizer to every container + primaryService = "eln" ) +// data type that maps a string to corresponding cobra command +type cmdTable map[string]func(*cobra.Command, []string) + var ( - // configuration - currentState state - configFile string - firstRun bool = true // switches to false when configFile is found/given - conf viper.Viper = *viper.New() - compose viper.Viper = *viper.New() - // logging - zlog zerolog.Logger - zboth zerolog.Logger + // configuration and logging + currentInstance string + configFile string + firstRun bool = true // switches to false when configFile is found/given + isInContainer bool = existingFile(insideFile) // switches to true when insideFile is found/given + conf viper.Viper = *viper.New() + zlog zerolog.Logger + zboth zerolog.Logger // path of the working directory workDir pathlib.Path = *pathlib.NewPath(".") // it is expected that all files and folders are relative to this path, unless specified otherwise by the user + // how the executable was called + commandForCLI string = os.Args[0] + // others + reseveredWords = []string{"instance", "advanced", "back", "exit"} + composeCall = toSprintf("compose -f %s -f %s ", defaultComposeFilename, extenedComposeFilename) // extra space at end is on purpose ) -// struct to store information about the currently selected instance, which has implications for the current state of this tool -type state struct { - debug bool - quiet bool - name string - isInside bool -} +var rootCmdTable = make(cmdTable) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "chemotion {on|off|instance|advanced}", + Use: toSprintf("%s [command]", commandForCLI), Short: "CLI for Chemotion ELN", Long: "Chemotion ELN is an Electronic Lab Notebook solution.\nDeveloped for researchers, the software aims to work for you.\nSee, https://www.chemotion.net.", Version: versionCLI, + Args: cobra.NoArgs, // The following lines are the action associated with a bare application run i.e. without any arguments + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + if zerolog.SetGlobalLevel(zerolog.InfoLevel); conf.GetBool(joinKey(stateWord, "debug")) { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + logwhere() + } + confirmVirtualizer(minimumVirtualizer) + if firstRun && cmd.CalledAs() != "install" { + // Println output so that user is not discouraged by a FATAL error on-screen... especially when beginning with the tool. + msg := toSprintf("Please install %s by running `%s install` before using it.", nameCLI, commandForCLI) + fmt.Println(msg) + zlog.Fatal().Err(toError("chemotion not installed")).Msgf(msg) // zlog i.e. don't print on screen. + } + zboth.Info().Msgf("Welcome to %s! You are on a host machine.", nameCLI) + if !firstRun { + if updateRequired() { + zboth.Info().Msgf("The version of %s - the CLI tool - you are using is outdated. Please update it by using `%s advanced update` command.", nameCLI, commandForCLI) + } + if cmd.Flag("selected-instance").Changed { + if err := instanceValidate(cmd.Flag("selected-instance").Value.String()); err != nil { + zboth.Fatal().Err(err).Msgf(err.Error()) + } + } + zboth.Info().Msgf("The instance you are currently managing is %s%s%s%s.", string("\033[31m"), string("\033[1m"), currentInstance, string("\033[0m")) + } + }, Run: func(cmd *cobra.Command, args []string) { - logWhere() - confirmInstalled() - confirmInteractive() - fmt.Printf("Welcome to %s! You are on a host machine. The instance you are currently managing is %s%s%s%s.\n", nameCLI, string("\033[31m"), string("\033[1m"), currentState.name, string("\033[0m")) - acceptedOpts := []string{"on", "off", "instance", "advanced", "exit"} - selected := selectOpt(acceptedOpts) - switch selected { - case "on": - onRootCmd.Run(cmd, args) - case "off": - offRootCmd.Run(cmd, args) - case "instance": - instanceRootCmd.Run(cmd, args) - case "advanced": - advancedRootCmd.Run(cmd, args) - case "exit": - zlog.Debug().Msg("Chose to exit") + isInteractive(true) + var acceptedOpts []string + status := instanceStatus(currentInstance) + if status == "Up" { + acceptedOpts = []string{"off", "restart"} + rootCmdTable["off"] = offRootCmd.Run + rootCmdTable["restart"] = restartRootCmd.Run + } else if status == "Created" || status == "Exited" { + acceptedOpts = []string{"on"} + rootCmdTable["on"] = onRootCmd.Run + } else { + acceptedOpts = []string{"on", "off", "restart"} + rootCmdTable["on"] = onRootCmd.Run + rootCmdTable["off"] = offRootCmd.Run + rootCmdTable["restart"] = restartRootCmd.Run } + acceptedOpts = append(acceptedOpts, []string{"instance", "advanced", "exit"}...) + rootCmdTable["instance"] = instanceRootCmd.Run + rootCmdTable["advanced"] = advancedRootCmd.Run + rootCmdTable[selectOpt(acceptedOpts, "")](cmd, args) }, } @@ -117,44 +151,13 @@ func Execute() { if err := rootCmd.Execute(); err == nil { zlog.Debug().Msgf("%s exited gracefully", nameCLI) } else { - zboth.Fatal().Err(fmt.Errorf("unexplained")).Msgf("%s exited abruptly, check log file if necessary. ABORT!", nameCLI) + zboth.Fatal().Err(toError("unexplained")).Msgf("%s exited abruptly, check log file if necessary. ABORT!", nameCLI) } } func init() { - // flag 0: isInside, determined automatically whenever CLI runs - currentState.isInside = existingFile(stateFile) - // begin by setting up logging - initLog() // in logger.go - // initialize flags - zlog.Debug().Msg("Start: init(): initialize flags") - // flag 1: instance, i.e. name of the instance to operate upon - // terminal overrides config-file, default is `default` - rootCmd.PersistentFlags().StringVarP(¤tState.name, "selected-instance", "i", "", fmt.Sprintf("select an existing instance of %s when starting", nameCLI)) - // flag 2: config, the configuration file - // config as a flag cannot be read from the configuration file because that creates a circular dependency, default name is hard-coded - rootCmd.PersistentFlags().StringVarP(&configFile, "config-file", "f", defaultConfigFilepath, "path to the configuration file") - // flag 3: quiet, i.e. should the CLI run in interactive mode - // terminal overrides config-file, default is false - rootCmd.PersistentFlags().BoolVarP(¤tState.quiet, "quiet", "q", false, fmt.Sprintf("use %s in scripted mode i.e. without an interactive prompt", nameCLI)) - // flag 4: debug, i.e. should debug messages be logged - // terminal overrides config-file, default is false - rootCmd.PersistentFlags().BoolVarP(¤tState.debug, "debug", "d", false, "enable logging of debug messages") - zlog.Debug().Msg("End: init(): initialize flags") - // viper bindings, one for each value in the struct called currentState - zlog.Debug().Msg("Start: init(): bind flags") - if err := conf.BindPFlag(selector_key, rootCmd.PersistentFlags().Lookup("selected-instance")); err != nil { - zboth.Warn().Err(err).Msgf("Failed to bind flag: %s. Will ignore command line input.", "selected-instance") - } - if currentState.name != "" { // i.e. create these entries on "instance" only once an instance has been selected - if err := conf.BindPFlag(joinKey("instances", currentState.name, "quiet"), rootCmd.PersistentFlags().Lookup("quiet")); err != nil { - zboth.Warn().Err(err).Msgf("Failed to bind flag: %s. Will ignore command line input.", "quiet") - } - if err := conf.BindPFlag(joinKey("instances", currentState.name, "debug"), rootCmd.PersistentFlags().Lookup("debug")); err != nil { - zboth.Warn().Err(err).Msgf("Failed to bind flag: %s. Will ignore command line input.", "debug") - } - } - zlog.Debug().Msg("End: init(): bind flags") - // initialize viper (runs last, i.e. when rootCmd.Execute runs) - cobra.OnInitialize(initConf) // in configure.go + initLog() // initialize logging + initFlags() // initialize flags + cobra.OnInitialize(initConf, bindFlags) // intitialize configuration // bind the flag + rootCmd.SetVersionTemplate(fmt.Sprintln("Chemotion CLI version", versionCLI)) } diff --git a/chemotion-cli/cli/virtualize.go b/chemotion-cli/cli/virtualize.go index 741d879..fe8f873 100644 --- a/chemotion-cli/cli/virtualize.go +++ b/chemotion-cli/cli/virtualize.go @@ -2,20 +2,24 @@ package cli import ( "bytes" - "fmt" "io" "os" "os/exec" + "runtime" "strings" + + vercompare "github.com/hashicorp/go-version" ) // modify file system in container -func modifyContainer(givenName string, args []string) (success bool) { - commandStr := fmt.Sprintf("run --rm -v %s:/mountedFolder --name chemotion-helper-safe-to-remove busybox %s", gotoFolder(givenName), args[0]) - zboth.Debug().Msgf("Executing `%s` in the container of %s", strings.Join(args, " "), givenName) - for i := 1; i < len(args); i++ { - commandStr += fmt.Sprintf(" /mountedFolder/%s", args[i]) +func modifyContainer(givenName, command, source, target string) (success bool) { + if target != "" { + command = toSprintf("%s /mountedFolder/%s /mountedFolder/%s", command, source, target) + } else { + command = toSprintf("%s /mountedFolder/%s", command, source) } + zboth.Debug().Msgf("Executing `%s` in the container of %s", command, givenName) + commandStr := toSprintf("run --rm -v %s:/mountedFolder busybox %s", gotoFolder(givenName), command) success = callVirtualizer(commandStr) gotoFolder("workdir") return @@ -23,25 +27,49 @@ func modifyContainer(givenName string, args []string) (success bool) { // confirm that virtualizer is the required minimum version func confirmVirtualizer(minimum string) { - if version := findVersion(toLower(virtualizer)); version == "docker on WSL not running!" { - zboth.Fatal().Err(fmt.Errorf(version)).Msgf("Docker is not running in your WSL environment. Hint: Turn on WSL integration setting in Docker Desktop.") - } else if version == "Unknown / not installed or found!" { - zboth.Fatal().Err(fmt.Errorf(version)).Msgf("%s is necessary to run %s", virtualizer, nameCLI) + if ver, err := execShell(toLower(virtualizer) + " --version"); err == nil { + version, errConvert := vercompare.NewVersion(strings.TrimPrefix(strings.Split(string(ver), ",")[0], "Docker version ")) + if errConvert == nil { + if errCompare := compareSoftwareVersion(minimum, version.String()); errCompare == nil { + zboth.Debug().Msgf("Running version %s of %s", version.String(), virtualizer) + } else { + zboth.Fatal().Err(err).Msgf("%s is out of date. Please update it before proceeding.", virtualizer) + } + } else { + zboth.Fatal().Err(toError("failed to convert version string")).Msgf("Failed to understand the following output upon executing `%s --version`: ", ver, toLower(virtualizer)) + } } else { - zboth.Debug().Msgf("%s version %s is installed", virtualizer, version) + if err.Error() == "exit status 1" && runtime.GOOS == "linux" { + zboth.Fatal().Err(toError("%s on WSL not running", virtualizer)).Msgf("%s is not running in your WSL environment. Hint: Turn on WSL integration setting in %s Desktop.", virtualizer, virtualizer) + } else if err.Error() == "exit status 127" { // 127 is software not found + zboth.Fatal().Err(toError("%s not found", virtualizer)).Msgf("%s is necessary to run %s", virtualizer, nameCLI) + } else { + zboth.Fatal().Err(err).Msgf(err.Error()) + } } - if err := compareSoftwareVersion(minimum, toLower(virtualizer)); err != nil { - zboth.Fatal().Err(err).Msgf(err.Error()) +} + +// confirm a minimum version for a given software +func compareSoftwareVersion(required, current string) (err error) { + var req, curr *vercompare.Version + if req, err = vercompare.NewVersion(required); err == nil { + if curr, err = vercompare.NewVersion(current); err == nil { + if curr.LessThan(req) { + return toError("current version: %s is less than the minimum required: %s", curr.String(), req.String()) + } + } } + return } -// call to virtualizer (this must not end in fatal error) +// call to virtualizer (this must not end in fatal error, i.e. must return `var success bool`) func callVirtualizer(args string) (success bool) { - if strings.Contains(args, "busybox") { - zboth.Debug().Msgf("%s will now fork the execution with command `%s %s` sent to shell.", nameCLI, toLower(virtualizer), args) + if strings.Contains(args, "busybox") || strings.Contains(args, "mikefarah/yq") { + zboth.Debug().Msgf("%s will now start the execution with command `%s %s` sent to shell.", nameCLI, toLower(virtualizer), args) } else { - zboth.Info().Msgf("%s will now fork the execution with command `%s %s` sent to shell.", nameCLI, toLower(virtualizer), args) + zboth.Info().Msgf("%s will now start the execution with command `%s %s` sent to shell.", nameCLI, toLower(virtualizer), args) } + args = strings.TrimSpace(args) commandArgs := strings.Split(args, " ") commandExec := exec.Command(toLower(virtualizer), commandArgs...) // see https://blog.kowalczyk.info/article/wOYk/advanced-command-execution-in-go-with-osexec.html#:~:text=Capture%20output%20but%20also%20show%20progress%20%233 diff --git a/chemotion-cli/go.mod b/chemotion-cli/go.mod index 9196c98..2253632 100644 --- a/chemotion-cli/go.mod +++ b/chemotion-cli/go.mod @@ -1,6 +1,6 @@ module chemotion-cli -go 1.18 +go 1.19 require ( github.com/cavaliergopher/grab/v3 v3.0.1 @@ -11,10 +11,11 @@ require ( github.com/rs/zerolog v1.27.0 github.com/spf13/cobra v1.5.0 github.com/spf13/viper v1.12.0 + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e ) require ( - github.com/chzyer/readline v1.5.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -26,16 +27,16 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.2 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/schollz/progressbar/v3 v3.8.6 - github.com/spf13/afero v1.8.2 // indirect + github.com/rivo/uniseg v0.3.1 // indirect + github.com/schollz/progressbar/v3 v3.9.0 + github.com/spf13/afero v1.9.2 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.0 // indirect - golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect - golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect - golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect + golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 // indirect + golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/ini.v1 v1.66.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/chemotion-cli/go.sum b/chemotion-cli/go.sum index fa5071d..b58f7a5 100644 --- a/chemotion-cli/go.sum +++ b/chemotion-cli/go.sum @@ -44,14 +44,14 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/chigopher/pathlib v0.12.0 h1:1GM7fN/IwXXmOHbd1jkMqHD2wUhYqUvafgxTwmLT/q8= github.com/chigopher/pathlib v0.12.0/go.mod h1:EJ5UtJ/sK8Nt6q3VWN+EwZLZ3g0afJiG8NegYiQQ/gQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.0 h1:+eqR0HfOetur4tgnC8ftU5imRnhi4te+BadWS95c5AM= -github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.0 h1:lSwwFrbNviGePhkewF1az4oLmcwqCZijQ2/Wi3BGHAI= -github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v0.0.0-20210722231415-061457976a23 h1:dZ0/VyGgQdVGAss6Ju0dt5P0QltE0SFY5Woh6hbIfiQ= -github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -176,19 +176,20 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk= +github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/schollz/progressbar/v3 v3.8.6 h1:QruMUdzZ1TbEP++S1m73OqRJk20ON11m6Wqv4EoGg8c= -github.com/schollz/progressbar/v3 v3.8.6/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY= +github.com/schollz/progressbar/v3 v3.9.0 h1:k9SRNQ8KZyibz1UZOaKxnkUE3iGtmGSDt1YY9KlCYQk= +github.com/schollz/progressbar/v3 v3.9.0/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY= github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo= -github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= @@ -231,8 +232,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -243,6 +244,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -358,12 +361,14 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8= -golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220731174439-a90be440212d h1:Sv5ogFZatcgIMMtBSTTAgMYsicp25MXBubjXNDKwm80= +golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704 h1:Y7NOhdqIOU8kYI7BxsgL38d0ot0raxvcW+EMQU2QrT4= +golang.org/x/sys v0.0.0-20220803195053-6e608f9ce704/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/chemotion-cli/payload/backup.sh b/chemotion-cli/payload/backup.sh new file mode 100644 index 0000000..b54821b --- /dev/null +++ b/chemotion-cli/payload/backup.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +[[ -d /backup ]] || { + log "Backup skipped. [/backup] is not mounted." + exit 1 +} + +source /embed/lib/dbenv + +stamp=$(date +'%y%m%d-%H%M%S') + +if [[ $BACKUP_WHAT == "both" || $BACKUP_WHAT == "data" ]]; then + tar cvzH posix -f "/backup/backup-${stamp}.data.tar.gz" --directory=/chemotion/data/ . --directory=/ .version || { + log "Could not backup user data!" + exit 2 + } +fi + +if [[ $BACKUP_WHAT == "both" || "$BACKUP_WHAT" = "db" ]]; then + pg_dump --no-owner --clean --if-exists | gzip -c > "/backup/backup-${stamp}.sql.gz" || { + log "Could not backup database!" + exit 3 + } +fi + +if [[ ! -e "/backup/backup.data.tar.gz" && ! -e "/backup/backup.sql.gz" ]] || \ + [[ -L "/backup/backup.data.tar.gz" && -L "/backup/backup.sql.gz" ]] ; then + if [[ $BACKUP_WHAT == "both" || $BACKUP_WHAT == "data" ]]; then + log "Creating symlink to latest data backup." + rm -f /backup/backup.data.tar.gz + ln -s "backup-${stamp}.data.tar.gz" "/backup/backup.data.tar.gz" + fi + if [[ $BACKUP_WHAT == "both" || "$BACKUP_WHAT" == "db" ]]; then + log "Creating symlink to latest database backup." + rm -f /backup/backup.sql.gz + ln -s "backup-${stamp}.sql.gz" "/backup/backup.sql.gz" + fi +fi + +log "Backup finished successfully. Timestamp: ${stamp}" diff --git a/developer-assets/.gitignore b/developer-assets/.gitignore new file mode 100644 index 0000000..d7e46af --- /dev/null +++ b/developer-assets/.gitignore @@ -0,0 +1 @@ +cargo-1*/ \ No newline at end of file diff --git a/developer-assets/dev.docker-compose.yml b/developer-assets/dev.docker-compose.yml new file mode 100644 index 0000000..a28c566 --- /dev/null +++ b/developer-assets/dev.docker-compose.yml @@ -0,0 +1,60 @@ +version: "3.5" +services: + hull: + image: chemotion-cli/hull-122 # to change + build: + dockerfile: hull.Dockerfile + args: # to change + - TZ=Europe/Berlin + - LANG=en_US + - VERSION_PANDOC=2.10.1 + - VERSION_ASDF=v0.10.0 + - VERSION_NODE=14.16.0 + - VERSION_RUBY=latest:2.6 + - VERSION_BUNDLER=1.17.3 + entrypoint: ["/bin/tini", "--"] + command: ["/bin/bash"] + cargo: + image: chemotion-cli/hull-122 # to change + environment: # to change + - BRANCH=f57052c4d3771e95b93ad3a65db9caba9d53e3ed + - RAILS_ENV=development + - NODE_ENV=development + - UID=1000 + - GID=1000 + - CHEMOTION_DIR=/cargo/chemotion + - GEM_HOME=/cargo/cache/gems + - NODE_MODULES=/cargo/chemotion/node_modules + - YARN_CACHE=/cargo/cache/yarn + - THOR_SILENCE_DEPRECATION=1 + volumes: + - ./cargo-122:/cargo + - ./scripts:/scripts:ro + entrypoint: ["/bin/tini", "--"] + # command: ["/bin/bash"] + command: ["bash", "/scripts/create_cargo.sh"] + db: + image: postgres:13 + restart: unless-stopped + hostname: db + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + volumes: + - chemotion_db:/var/lib/postgresql/data/ + networks: + - chemotion + sail: + image: chemotion-cli/hull-122 + volumes: + - ./cargo-122:/cargo + entrypoint: ["/bin/tini", "--"] + command: ["/bin/bash"] + +volumes: + chemotion_db: + name: chemotion_db + +networks: + chemotion: + name: cargo-122 diff --git a/developer-assets/hull.Dockerfile b/developer-assets/hull.Dockerfile new file mode 100644 index 0000000..f0721c7 --- /dev/null +++ b/developer-assets/hull.Dockerfile @@ -0,0 +1,113 @@ +#################################################################################################### +# BASE IMAGE: TIMEZONE, LOCALES, YQ, TINI +#################################################################################################### +FROM ubuntu:focal +ARG DEBIAN_FRONTEND=noninteractive + +# all ARGS are set as meaningless specifically because we want them to specified by user +ARG TZ=region/city +ARG LANG=language_territory +ARG VERSION_PANDOC=0 +ARG VERSION_ASDF=0 +ARG VERSION_NODE=0 +ARG VERSION_RUBY=0 +ARG VERSION_BUNDLER=0 + +# set timezone +RUN ln -s /usr/share/zoneinfo/${TZ} /etc/localtime + +# set locale +ENV LANG=${LANG}.UTF-8 +ENV LANGUAGE=${LANG} +ENV LC_ALL=${LANG} +RUN echo -e "LANG=${LANG}\nLC_ALL=${LANG}" > /etc/locale.conf && \ + echo "${LANG} UTF-8" > /etc/locale.gen + + +# install basic packages +RUN apt-get -y update && apt-get -y upgrade && \ + apt-get install locales + +# locale +RUN locale-gen ${LANG} + +# include binaries and fontfix +ADD https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 /bin/yq +ADD https://github.com/krallin/tini/releases/latest/download/tini /bin/tini +ADD https://github.com/jgm/pandoc/releases/download/${VERSION_PANDOC}/pandoc-${VERSION_PANDOC}-1-amd64.deb /tmp/pandoc.deb +ADD https://gist.githubusercontent.com/ptrxyz/d32479c72fa73fcc1b86ed3219d41b63/raw/aee4a46ce84d0e18ac958feb84015c779c3664aa/fontfix.conf /etc/fonts/conf.d/99-chemotion-fontfix.conf +RUN chmod +x /bin/tini /bin/yq + +#################################################################################################### +# INSTALL PACKAGES +#################################################################################################### + +# X-mas tree of packages we need +RUN apt-get -y --autoremove --fix-missing install \ + `# --> utilitites` \ + vim \ + wget \ + bash \ + nano \ + sudo \ + iproute2 \ + `# ---------> for asdf` \ + git \ + curl \ + `# ------------> for ruby` \ + libssl-dev \ + zlib1g-dev \ + libzmq3-dev \ + libreadline-dev \ + build-essential \ + `# ------------------> for gems` \ + swig \ + cmake \ + libpq-dev \ + python3-dev \ + libeigen3-dev \ + libsqlite3-dev \ + libmagickcore-dev \ + libboost-system-dev \ + libboost-iostreams-dev \ + libboost-serialization-dev \ + `# -----------------------------> for chemotion` \ + imagemagick \ + libboost-iostreams1.71.0 \ + libboost-serialization1.71.0 \ + curl `# for pandoc` \ + inkscape `# adds python3` \ + postgresql-client `# adds pg_isready` + +RUN dpkg -i /tmp/pandoc.deb && rm /tmp/pandoc.deb + +#################################################################################################### +# INSTALL ASDF + RUBY + NODE +#################################################################################################### + +# ASDF +# make asdf and its shims available everywhere i.e. add to PATH and to /etc/environment +ENV ASDF_DIR=/asdf +ENV ASDF_DATA_DIR=/asdf +ENV ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=/asdf/tool-versions +ENV PATH=${ASDF_DIR}/shims:${ASDF_DIR}/bin:${PATH} + +RUN git clone https://github.com/asdf-vm/asdf.git ${ASDF_DIR} --branch ${VERSION_ASDF} +RUN chmod a+rw ${ASDF_DIR} && \ + sed -i -E 's#(PATH=)("?)(.*)("?)#\1\2'${ASDF_DIR}/shims:${ASDF_DIR}/bin:'\3\4#' /etc/environment && \ + env | grep ^ASDF_ >> /etc/environment + +# NodeJS +RUN asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git && \ + asdf install nodejs ${VERSION_NODE} && \ + asdf global nodejs $(asdf list nodejs) && \ + npm install -g yarn + +# Ruby +RUN asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git && \ + asdf install ruby ${VERSION_RUBY} && \ + asdf global ruby $(asdf list ruby) && \ + gem install bundler -v ${VERSION_BUNDLER} + +SHELL ["/bin/bash", "-c"] +CMD ["/bin/bash"] diff --git a/developer-assets/scripts/create_cargo.sh b/developer-assets/scripts/create_cargo.sh new file mode 100644 index 0000000..b0de6ca --- /dev/null +++ b/developer-assets/scripts/create_cargo.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +chown -R 0:0 /cargo +mkdir -p $GEM_HOME + +# get to the specified commit +[ ! -d $CHEMOTION_DIR ] && git clone https://github.com/ComPlat/chemotion_ELN.git $CHEMOTION_DIR +cd $CHEMOTION_DIR +git stash +git fetch --all +git -c advice.detachedHead=false checkout $BRANCH + +# make node modules +yarn install --production=true --modules-folder $NODE_MODULES --cache-folder $YARN_CACHE + +# install ruby +gem install solargraph +bundle install --jobs=$(getconf _NPROCESSORS_ONLN) +if [[ $(grep -L passenger Gemfile) ]]; then bundle add passenger; fi + +chown -R $UID:$GID /cargo \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4111f40..20a0143 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,78 +1,79 @@ -version: "3.5" +version: '3.5' services: - db: - image: postgres:13 - restart: unless-stopped - hostname: db - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - volumes: - - chemotion_db:/var/lib/postgresql/data/ - networks: - - chemotion + db: + image: postgres:13 + restart: unless-stopped + hostname: db + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + volumes: + - chemotion_db:/var/lib/postgresql/data/ + networks: + - chemotion msconvert: - image: ptrxyz/chemotion:msconvert-1.1.2p220401 - restart: unless-stopped - hostname: msconvert - volumes: - - spectra:/shared:rw - networks: - - chemotion + image: ptrxyz/chemotion:msconvert-1.3.1p220712 + restart: unless-stopped + hostname: msconvert + volumes: + - spectra:/shared:rw + networks: + - chemotion spectra: - image: ptrxyz/chemotion:spectra-1.1.2p220401 - restart: unless-stopped - hostname: spectra - volumes: - - spectra:/shared:rw - depends_on: - - msconvert - networks: - - chemotion + image: ptrxyz/chemotion:spectra-1.3.1p220712 + restart: unless-stopped + hostname: spectra + volumes: + - spectra:/shared:rw + depends_on: + - msconvert + networks: + - chemotion worker: - image: ptrxyz/chemotion:eln-1.1.2p220401 - restart: unless-stopped - environment: - - CONFIG_ROLE=worker - depends_on: - - db - - eln - - spectra - volumes: - - chemotion_data:/chemotion/data/ - - chemotion:/chemotion/app - networks: - - chemotion + image: ptrxyz/chemotion:eln-1.3.1p220712 + restart: unless-stopped + environment: + - CONFIG_ROLE=worker + depends_on: + - db + - eln + - spectra + volumes: + - chemotion_data:/chemotion/data/ + - chemotion:/chemotion/app + networks: + - chemotion eln: - image: ptrxyz/chemotion:eln-1.1.2p220401 - restart: unless-stopped - environment: - - CONFIG_ROLE=eln - depends_on: - - db - - spectra - volumes: - - ./shared/pullin:/shared - - ./shared/backup:/backup - - chemotion_data:/chemotion/data/ - - chemotion:/chemotion/app - ports: - - 4000:4000 - networks: - - chemotion + image: ptrxyz/chemotion:eln-1.3.1p220712 + restart: unless-stopped + environment: + - CONFIG_ROLE=eln + depends_on: + - db + - spectra + volumes: + - ./shared/pullin:/shared + - ./shared/backup:/backup + - chemotion_data:/chemotion/data/ + - chemotion:/chemotion/app + ports: + - 4000:4000 + networks: + - chemotion volumes: - chemotion: - name: chemotion_app - chemotion_data: - name: chemotion_data - chemotion_db: - name: chemotion_db - spectra: + chemotion: + name: chemotion_app + chemotion_data: + name: chemotion_data + chemotion_db: + name: chemotion_db + spectra: + name: chemotion_spectra networks: - chemotion: + chemotion: diff --git a/version b/version index 91c4222..753155e 100644 --- a/version +++ b/version @@ -1 +1 @@ -1.1.2p220401 +1.3.0p220705