From c5b1dfaa82754617cca5418aba1971f9a5a81a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Carlos=20Mej=C3=ADas=20Rodr=C3=ADguez?= Date: Sat, 20 Jul 2019 22:00:04 -0400 Subject: [PATCH] Rewrite project in Go --- .gitignore | 4 + .goreleaser.yml | 50 ++++ CHANGELOG.md | 41 ++++ Dockerfile | 50 ++-- README.md | 260 +++++++++++---------- cmd/completion.go | 28 +++ cmd/endpoint.go | 15 ++ cmd/endpointList.go | 87 +++++++ cmd/root.go | 86 +++++++ cmd/stack.go | 15 ++ cmd/stackDeploy.go | 378 +++++++++++++++++++++++++++++++ cmd/stackList.go | 82 +++++++ cmd/stackRemove.go | 67 ++++++ cmd/status.go | 79 +++++++ common/authentication.go | 85 +++++++ common/connection.go | 22 ++ common/customerrors.go | 26 +++ common/printing.go | 83 +++++++ common/types.go | 74 ++++++ common/utils.go | 117 ++++++++++ common/version.go | 61 +++++ main.go | 7 + psu | 476 --------------------------------------- 23 files changed, 1572 insertions(+), 621 deletions(-) create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 cmd/completion.go create mode 100644 cmd/endpoint.go create mode 100644 cmd/endpointList.go create mode 100644 cmd/root.go create mode 100644 cmd/stack.go create mode 100644 cmd/stackDeploy.go create mode 100644 cmd/stackList.go create mode 100644 cmd/stackRemove.go create mode 100644 cmd/status.go create mode 100644 common/authentication.go create mode 100644 common/connection.go create mode 100644 common/customerrors.go create mode 100644 common/printing.go create mode 100644 common/types.go create mode 100644 common/utils.go create mode 100644 common/version.go create mode 100644 main.go delete mode 100755 psu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6e64fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +psu +psu.exe +.idea +dist diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..8666d47 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,50 @@ +project_name: psu +before: + hooks: + - go get github.com/spf13/cobra + - go get github.com/joho/godotenv +builds: + - main: main.go + binary: psu + ldflags: + - -X github.com/greenled/portainer-stack-utils/common.commitHash={{ .ShortCommit }} -X github.com/greenled/portainer-stack-utils/common.buildDate={{ .Date }} + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - 386 + - amd64 + - arm + - arm64 + goarm: + - 7 +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip + replacements: + 386: 32bit + amd64: 64bit + arm: ARM + arm64: ARM64 + darwin: macOS + linux: Linux + windows: Windows +dockers: + - image_templates: + - "greenled/portainer-stack-utils:{{ .Major }}" + - "greenled/portainer-stack-utils:{{ .Major }}.{{ .Minor }}" + - "greenled/portainer-stack-utils:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" + goos: linux + goarch: amd64 + binaries: + - psu + dockerfile: Dockerfile +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" diff --git a/CHANGELOG.md b/CHANGELOG.md index e852ddf..6c7ef73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `endpoint list|ls` command to print the endpoints list as a table. +- `stack list|ls` command to print the stacks list as a table. + - `--swarm` flag sets a filter by swarm Id. + - `--endpoint` flag sets a filter by endpoint Id. + -`-q, --quiet`) flag causes stack list to print only stack names. +- `stack deploy|up|create` command to deploy a stack. + - `-c, --stack-file` flag sets the stack file to use. + - `-e, --env-file` flag sets the environment variables file to use. + - `--replace-env` flag causes environment variables to be replaced instead of merged while updating a stack. + - `--endpoint` flag sets the endpoint to use. + - `-p, --prune` flag causes services that are no longer referenced to be removed. +- `stack remove|rm|down` command to remove a stack. + - `--strict` flag causes a failure if the stack does not exist. +- `status` command to show Portainer status as a table. +- `help` command and `-h, --help` global flag to print global help. +- `-h, --help` flags on each command to print its help. +- `-t, --timeout` global flag to set a timeout for requests execution. +- `--config` global flag to set the path to a configuration file. +- `--version` global flag to print the client version. + +### Removed +- `-a` flag, which used to select an action to execute. There is a command now for each action. +- `bash`, `jq` and `httpie` programs in the Docker image. The client doesn't use them anymore. + +### Changed +- Single executable binary instead of a bash script. Project has been rewritten in Go language. +- No external programs (bash, httpie, jq) dependency. The new executable binary is self-sufficient. +- `-u` global flag renamed to `--user`. +- `-p` global flag renamed to `--password`. +- `-l` global flag renamed to `--url`. +- `-s` global flag renamed to `--insecure`. It does not receive a value anymore, it is a boolean flag. +- `-v` global flag renamed to `-v, --verbose`. It does not receive a value anymore, it is a boolean flag. +- `-d` global flag renamed to `-d, --debug`. It does not receive a value anymore, it is a boolean flag. +- `-n` global flag moved as a parameter to the `stack deploy` command. +- `-c` global flag renamed to `-c, --stack-file` and moved to the `stack deploy` command. +- `-g` global flag renamed to `-e, --env-file` and moved to the `stack deploy` command. +- `-r` global flag renamed to `-p, --prune` and moved to the `stack deploy` command. It does not receive a value anymore, it is a boolean flag. +- `-e` global flag renamed to `--endpoint` and moved to the `stack deploy` command. +- `-t` global flag renamed to `--strict` and moved to the `stack remove` command. +- All environment variables prefixed with "PSU_" and renamed to match command and flag names. ## [0.1.1] - 2019-06-05 ### Fixed diff --git a/Dockerfile b/Dockerfile index 4e60329..6cee044 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,25 @@ FROM alpine -ENV LANG="en_US.UTF-8" \ - LC_ALL="C.UTF-8" \ - LANGUAGE="en_US.UTF-8" \ - TERM="xterm" \ - ACTION="" \ - PORTAINER_USER="" \ - PORTAINER_PASSWORD="" \ - PORTAINER_URL="" \ - PORTAINER_STACK_NAME="" \ - DOCKER_COMPOSE_FILE="" \ - ENVIRONMENT_VARIABLES_FILE="" \ - PORTAINER_PRUNE="false" \ - PORTAINER_ENDPOINT="1" \ - HTTPIE_VERIFY_SSL="yes" \ - VERBOSE_MODE="false" \ - DEBUG_MODE="false" \ - STRICT_MODE="false" +ENV PSU_AUTHENTICATION_PASSWORD="" \ + PSU_AUTHENTICATION_USER="" \ + PSU_CONFIG="" \ + PSU_CONNECTION_INSECURE="" \ + PSU_CONNECTION_TIMEOUT="" \ + PSU_CONNECTION_URL="" \ + PSU_DEBUG="" \ + PSU_ENDPOINT_LIST_FORMAT="" \ + PSU_STACK_DEPLOY_ENDPOINT="" \ + PSU_STACK_DEPLOY_ENV_FILE="" \ + PSU_STACK_DEPLOY_REPLACE_ENV="" \ + PSU_STACK_DEPLOY_STACK_FILE="" \ + PSU_STACK_LIST_ENDPOINT="" \ + PSU_STACK_LIST_FORMAT="" \ + PSU_STACK_LIST_QUIET="" \ + PSU_STACK_LIST_SWARM="" \ + PSU_STACK_REMOVE_STRICT="" \ + PSU_STATUS_FORMAT="" \ + PSU_VERBOSE="" -RUN apk --update add \ - bash \ - ca-certificates \ - httpie \ - jq \ - gettext \ - && \ - rm -rf /tmp/src && \ - rm -rf /var/cache/apk/* - -COPY psu /usr/local/bin/ - -RUN chmod +x /usr/local/bin/* +COPY psu /usr/local/bin/psu ENTRYPOINT ["/usr/local/bin/psu"] diff --git a/README.md b/README.md index c1911ac..af1cc0d 100644 --- a/README.md +++ b/README.md @@ -4,156 +4,192 @@ [![Docker Pulls](https://img.shields.io/docker/pulls/greenled/portainer-stack-utils.svg)](https://hub.docker.com/r/greenled/portainer-stack-utils/) [![Microbadger](https://images.microbadger.com/badges/image/greenled/portainer-stack-utils.svg)](http://microbadger.com/images/greenled/portainer-stack-utils "Image size") -Bash script to deploy/update/undeploy stacks in a [Portainer](https://portainer.io/) instance from a [docker-compose](https://docs.docker.com/compose) [yaml file](https://docs.docker.com/compose/compose-file). Based on previous work by [@vladbabii](https://github.com/vladbabii) on [docker-how-to/portainer-bash-scripts](https://github.com/docker-how-to/portainer-bash-scripts). +## Overview + +Portainer Stack Utils is a CLI client for [Portainer](https://portainer.io/) written in Go ## Supported Portainer API -Script was created for the latest Portainer API, which at the time of writing is [1.19.2](https://app.swaggerhub.com/apis/deviantony/Portainer/1.19.2). +This application was created for the latest Portainer API, which at the time of writing is [1.21.0](https://app.swaggerhub.com/apis/deviantony/Portainer/1.21.0). ## How to install -### Standalone +Download the binaries for your platform from [the releases page](https://github.com/greenled/portainer-stack-utils/releases). The binaries have no external dependencies. -Just clone the repo and use the script: - -```bash -git clone https://github.com/greenled/portainer-stack-utils.git -cd portainer-stack-utils -./psu -a deploy ... -``` - -#### Requirements - -You will need these dependecies installed: - -- [bash](https://www.gnu.org/software/bash/) -- [httpie](https://httpie.org/) -- [jq](https://stedolan.github.io/jq/) - -For Debian and similar apt-powered systems: `apt install bash httpie jq`. - -### Docker image - -Use [the published Docker image](https://hub.docker.com/r/greenled/portainer-stack-utils/): - -```bash -docker run -e ACTION=deploy greenled/portainer-stack-utils ... -``` - -See the [With envvars](#with-envvars) section for a list of all supported environment variables. - -#### Tags - -Published images are [tagged](https://hub.docker.com/r/greenled/portainer-stack-utils/tags/) matching [GitHub releases](https://github.com/greenled/portainer-stack-utils/releases): - -- `latest`, `0.1.1` -> `0.1.1` -- `0.1.0` -> `0.1.0` -- ... -- `dev` -> `master` +You can also install the source code with `go` and build the binaries yourself. ## How to use -The provided `psu` script allows to deploy/update/undeploy Portainer stacks. Settings can be passed through envvars and/or flags. Both envvars and flags can be mixed but flags will always overwrite envvar values. When deploying a stack, if it doesn't exist a new one is created, otherwise it's updated (unless strict mode is active). +The application is built on a structure of commands, arguments and flags. + +**Commands** represent actions, **Args** are things and **Flags** are modifiers for those actions: -### With envvars - -This is particularly useful for CI/CD pipelines using Docker containers. - -- `ACTION` ("deploy" or "undeploy", required): Whether to deploy or undeploy the stack -- `PORTAINER_USER` (string, required): Username -- `PORTAINER_PASSWORD` (string, required): Password -- `PORTAINER_URL` (string, required): URL to Portainer -- `PORTAINER_STACK_NAME` (string, required): Stack name -- `DOCKER_COMPOSE_FILE` (string, required if action=deploy): Path to doker-compose file -- `ENVIRONMENT_VARIABLES_FILE` (string, optional, only used when action=deploy or action=update): Path to file with environment variables to be used by the stack. See [stack environment variables](#stack-environment-variables) below. -- `PORTAINER_PRUNE` ("true" or "false", optional): Whether to prune unused containers or not. Defaults to `"false"`. -- `PORTAINER_ENDPOINT` (int, optional): Which endpoint to use. Defaults to `1`. -- `HTTPIE_VERIFY_SSL` ("yes" or "no", optional): Whether to verify SSL certificate or not. Defaults to `"yes"`. -- `VERBOSE_MODE` ("true" or "false", optional): Whether to activate verbose output mode or not. Defaults to `"false"`. See [verbose mode](#verbose-mode) below. -- `DEBUG_MODE` ("true" or "false", optional): Whether to activate debug output mode or not. Defaults to `"false"`. See [debug mode](#debug-mode) below. -- `STRICT_MODE` ("true" or "false", optional): Whether to activate strict mode or not. Defaults to `"false"`. See [strict mode](#strict-mode) below. - -#### Examples - -```bash -export ACTION="deploy" -export PORTAINER_USER="admin" -export PORTAINER_PASSWORD="password" -export PORTAINER_URL="http://portainer.local" -export PORTAINER_STACK_NAME="mystack" -export DOCKER_COMPOSE_FILE="/path/to/docker-compose.yml" -export ENVIRONMENT_VARIABLES_FILE="/path/to/env_vars_file" - -./psu +```text +APPNAME COMMAND ARG --FLAG ``` -```bash -export ACTION="undeploy" -export PORTAINER_USER="admin" -export PORTAINER_PASSWORD="password" -export PORTAINER_URL="http://portainer.local" -export PORTAINER_STACK_NAME="mystack" +Here are some examples: -./psu +```bash +psu help +psu status --help +psu stack ls --quiet --endpoint 5 +psu stack deploy mystack --stack-file docker-compose.yml -e .env --verbose +psu stack rm mystack ``` -### With flags +Commands can have subcommands, like `stack ls` and `stack deploy` in the previous example. They can also have aliases (i.e. `create` and `up` are aliases of `deploy`). -This is more suitable for standalone script usage. +Some flags are global, which means they affect every command (i.e. `--verbose`), while others are local, which mean they only affect the command they belong to (i.e. `--stack-file` flag from `deploy` command). Also, some flags have a short version (i.e `--debug`, `-d`). -- `-a` ("deploy" or "undeploy", required): Whether to deploy or undeploy the stack -- `-u` (string, required): Username -- `-p` (string, required): Password -- `-l` (string, required): URL to Portainer -- `-n` (string, required): Stack name -- `-c` (string, required if action=deploy): Path to doker-compose file -- `-g` (string, optional, only used when action=deploy or action=update): Path to file with environment variables to be used by the stack. See [stack environment variables](#stack-environment-variables) below. -- `-r` ("true" or "false", optional): Whether to prune unused containers or not. Defaults to `"false"`. -- `-e` (int, optional): Which endpoint to use. Defaults to `1`. -- `-s` ("yes" or "no", optional): Whether to verify SSL certificate or not. Defaults to `"yes"`. -- `-v` ("true" or "false", optional): Whether to activate verbose output mode or not. Defaults to `"false"`. See [verbose mode](#verbose-mode) below. -- `-d` ("true" or "false", optional): Whether to activate debug output mode or not. Defaults to `"false"`. See [debug mode](#debug-mode) below. -- `-t` ("true" or "false", optional): Whether to activate strict mode or not. Defaults to `"false"`. See [strict mode](#strict-mode) below. +### Configuration -#### Examples +Each flag can be set inline (i.e. `--debug`), through an environment variable (i.e. `PSU_DEBUG=true`) and through a configuration file ([see below](#with-configuration-file)). All three methods can be combined, but if a flag is set more than once the order of precedence is: + +1. Inline flag +2. Environment variable +3. Configuration file + +#### With inline flags + +Each command has it's own flags. Run `psu [COMMAND [SUBCOMMAND]] --help` to see each command's flag set. ```bash -./psu -a deploy -u admin -p password -l http://portainer.local -n mystack -c /path/to/docker-compose.yml -g /path/to/env_vars_file +psu --help +psu stack --help +psu stack deploy --help ``` -```bash -./psu -a undeploy -u admin -p password -l http://portainer.local -n mystack +#### With environment variables + +This is particularly useful for CI/CD pipelines. + +Environment variables can be bound to flags following the `PSU_[COMMAND_[SUBCOMMAND_]]FLAG` naming pattern: + +| Command and subcommand | Flag | Environment variable | Comment | +| :--------------------- | :--- | :------------------- | :------ | +| | --verbose | PSU_VERBOSE=true | All environment variables are prefixed with "PSU_" | +| stack list | --quiet | PSU_STACK_LIST_QUIET=true | Commands and subcommands are uppercased and joined with "_" | +| stack deploy | --env-file .env | PSU_STACK_DEPLOY_ENV_FILE=.env | Characters "-" in flag name are replaced with "_" | + +#### With configuration file + +Flags can be bound to a configuration file. Use the `--config` flag to specify a configuration file to load flags from. By default the file `$HOME/.psu.yaml` is used if present. + +#### Using Yaml + +If you use a Yaml configuration file: + +```text +[command: + [subcommand:]] + flag: value +``` + +```yaml +verbose: true +url: http://localhost:10000 +insecure: true +stack: + deploy: + stack-file: docker-compose.yml + env-file: .env + list: + quiet: true +``` + +This is valid too: + +```text +[command.[subcommand.]]flag: value +``` + +```yaml +verbose: true +url: http://localhost:10000 +insecure: true +stack.deploy.stack-file: docker-compose.yml +stack.deploy.env-file: .env +stack.list.quiet: true +``` + +#### Using Json + +If you use a Json configuration file: + +```text +{ + ["command": { + ["subcommand": {]] + "flag": value + [}] + [}] +{ +``` + +```json +{ + "verbose": true, + "url": "http://localhost:10000", + "insecure": true, + "stack": { + "deploy": { + "stack-file": "docker-compose.yml", + "env-file": ".env" + }, + "list": { + "quiet": true + } + } +} +``` + +This is valid too: + +```text +{ + "[command.[subcommand.]]flag": value +} +``` + +```json +{ +"verbose": true, +"url": "http://localhost:10000", +"insecure": true, +"stack.deploy.stack-file": "docker-compose.yml", +"stack.deploy.env-file": ".env", +"stack.list.quiet": true +} + ``` ### Stack environment variables -There can be set environment variables for each stack, be it a new deployment or an update. For example: +You will usually want to set some environment variables in your stacks. You can do so with the `--env-file` flag: ```bash touch .env echo "MYSQL_ROOT_PASSWORD=agoodpassword" >> .env echo "ALLOWED_HOSTS=*" >> .env -./psu -a deploy -u admin -p password -l http://portainer.local -n django-stack -c /path/to/docker-compose.yml -g env_vars +psu stack deploy django-stack -c /path/to/docker-compose.yml -e .env ``` -Stack environment variables can be enabled through [ENVIRONMENT_VARIABLES_FILE envvar](#with-envvars) or [-g flag](#with-flags). +As every flag, this one can also be used with the `PSU_STACK_DEPLOY_ENV_FILE` [environment variable](#with-environment-variables) and the `psu.stack.deploy.env-file` [configuration key](#with-configuration-file). ### Verbose mode In verbose mode the script prints execution steps. ```text -Getting auth token... -Getting stack mystack... -Stack mystack not found. -Getting Docker info... -Getting swarm cluster (if any)... -Swarm cluster found. -Preparing stack JSON... -Creating stack mystack... +2019/07/20 19:15:45 [Using config file: /home/johndoe/.psu.yaml] +2019/07/20 19:15:45 [Getting stack mystack...] +2019/07/20 19:15:45 [Getting auth token...] +2019/07/20 19:15:45 [Stack mystack not found. Deploying...] +2019/07/20 19:15:45 [Swarm cluster found with id qwe123rty456uio789asd123f] ``` -Verbose mode can be enabled through [VERBOSE_MODE envvar](#with-envvars) or [-v flag](#with-flags). +Verbose mode can be enabled through the `PSU_VERBOSE` [environment variable](#with-environment-variables) and the `verbose` [configuration key](#with-configuration-file). ### Debug mode @@ -161,13 +197,7 @@ In debug mode the script prints as much information as possible to help diagnosi **WARNING**: Debug mode will print configuration values (with Portainer credentials) and Portainer API responses (with sensitive information like authentication token and stacks environment variables). Avoid using debug mode in CI/CD pipelines, as pipeline logs are usually recorded. -Debug mode can be enabled through [DEBUG_MODE envvar](#with-envvars) or [-d flag](#with-flags). - -### Strict mode - -In strict mode the script never updates an existent stack nor removes an unexistent one, and instead exits with an error. - -Strict mode can be enabled through [STRICT_MODE envvar](#with-envvars) or [-t flag](#with-flags). +Debug mode can be enabled through the `DEBUG_MODE` [environment variable](#with-environment-variables) and the `debug` [configuration key](#with-configuration-file). ## License diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..b68880e --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "os" +) + +// completionCmd represents the completion command +var completionCmd = &cobra.Command{ + Use: "completion", + Short: "Generates bash completion scripts", + Long: `To load completion run + +. <(psu completion) + +To configure your bash shell to load completions for each session add to your bashrc + +# ~/.bashrc or ~/.profile +. <(psu completion) +`, + Run: func(cmd *cobra.Command, args []string) { + rootCmd.GenBashCompletion(os.Stdout) + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/endpoint.go b/cmd/endpoint.go new file mode 100644 index 0000000..3bef4d1 --- /dev/null +++ b/cmd/endpoint.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// endpointCmd represents the endpoint command +var endpointCmd = &cobra.Command{ + Use: "endpoint", + Short: "Manage endpoints", +} + +func init() { + rootCmd.AddCommand(endpointCmd) +} diff --git a/cmd/endpointList.go b/cmd/endpointList.go new file mode 100644 index 0000000..2acb954 --- /dev/null +++ b/cmd/endpointList.go @@ -0,0 +1,87 @@ +/* +Copyright © 2019 Juan Carlos Mejías Rodríguez + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package cmd + +import ( + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/spf13/viper" + "os" + "text/template" + + "github.com/spf13/cobra" +) + +// endpointListCmd represents the list command +var endpointListCmd = &cobra.Command{ + Use: "list", + Short: "List endpoints", + Aliases: []string{"ls"}, + Run: func(cmd *cobra.Command, args []string) { + endpoints, err := common.GetAllEndpoints() + common.CheckError(err) + + if viper.GetString("endpoint.list.format") != "" { + // Print endpoint fields formatted + template, templateParsingErr := template.New("endpointTpl").Parse(viper.GetString("endpoint.list.format")) + common.CheckError(templateParsingErr) + for _, e := range endpoints { + templateExecutionErr := template.Execute(os.Stdout, e) + common.CheckError(templateExecutionErr) + fmt.Println() + } + } else { + // Print all endpoint fields as a table + writer, err := common.NewTabWriter([]string{ + "ENDPOINT ID", + "NAME", + "TYPE", + "URL", + "PUBLIC URL", + "GROUP ID", + }) + common.CheckError(err) + for _, e := range endpoints { + var endpointType string + if e.Type == 1 { + endpointType = "docker" + } else if e.Type == 2 { + endpointType = "agent" + } + _, err := fmt.Fprintln(writer, fmt.Sprintf( + "%v\t%s\t%v\t%s\t%s\t%v", + e.Id, + e.Name, + endpointType, + e.URL, + e.PublicURL, + e.GroupID, + )) + common.CheckError(err) + } + flushErr := writer.Flush() + common.CheckError(flushErr) + } + }, +} + +func init() { + endpointCmd.AddCommand(endpointListCmd) + + endpointListCmd.Flags().String("format", "", "format output using a Go template") + viper.BindPFlag("endpoint.list.format", endpointListCmd.Flags().Lookup("format")) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..d92e04f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "os" + "strings" +) + +var cfgFile string + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "psu", + Short: "A CLI client for Portainer", + Version: "is set on common/version.CurrentVersion", +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.SetVersionTemplate("{{ version }}\n") + cobra.AddTemplateFunc("version", common.BuildVersionString) + + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.psu.yaml)") + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose mode") + rootCmd.PersistentFlags().BoolP("debug", "d", false, "debug mode") + rootCmd.PersistentFlags().Bool("insecure", false, "skip Portainer SSL certificate verification") + rootCmd.PersistentFlags().String("url", "", "Portainer url") + rootCmd.PersistentFlags().String("user", "", "Portainer user") + rootCmd.PersistentFlags().String("password", "", "Portainer password") + rootCmd.PersistentFlags().DurationP("timeout", "t", 0, "waiting time before aborting (like 100ms, 30s, 1h20m)") + viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config")) + viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) + viper.BindPFlag("insecure", rootCmd.PersistentFlags().Lookup("insecure")) + viper.BindPFlag("url", rootCmd.PersistentFlags().Lookup("url")) + viper.BindPFlag("timeout", rootCmd.PersistentFlags().Lookup("timeout")) + viper.BindPFlag("user", rootCmd.PersistentFlags().Lookup("user")) + viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Search config in home directory with name ".psu" (without extension). + viper.AddConfigPath(home) + viper.SetConfigName(".psu") + } + + viper.SetEnvPrefix("PSU") + viper.AutomaticEnv() // read in environment variables that match + + replacer := strings.NewReplacer("-", "_", ".", "_") + viper.SetEnvKeyReplacer(replacer) + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + common.PrintVerbose("Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/cmd/stack.go b/cmd/stack.go new file mode 100644 index 0000000..115fc18 --- /dev/null +++ b/cmd/stack.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// Command2 represents the stack command +var stackCmd = &cobra.Command{ + Use: "stack", + Short: "Manage stacks", +} + +func init() { + rootCmd.AddCommand(stackCmd) +} diff --git a/cmd/stackDeploy.go b/cmd/stackDeploy.go new file mode 100644 index 0000000..d3d5b05 --- /dev/null +++ b/cmd/stackDeploy.go @@ -0,0 +1,378 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/joho/godotenv" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "io/ioutil" + "log" + "net/http" + "net/url" +) + +// stackDeployCmd represents the undeploy command +var stackDeployCmd = &cobra.Command{ + Use: "deploy STACK_NAME", + Short: "Deploy a new stack or update an existing one", + Aliases: []string{"up", "create"}, + Example: "psu stack deploy mystack --stack-file mystack.yml", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var loadedEnvironmentVariables []common.StackEnv + if viper.GetString("stack.deploy.env-file") != "" { + var loadingErr error + loadedEnvironmentVariables, loadingErr = loadEnvironmentVariablesFile(viper.GetString("stack.deploy.env-file")) + common.CheckError(loadingErr) + } + + stackName := args[0] + retrievedStack, stackRetrievalErr := common.GetStackByName(stackName) + switch stackRetrievalErr.(type) { + case nil: + // We are updating an existing stack + common.PrintVerbose(fmt.Sprintf("Stack %s found. Updating...", retrievedStack.Name)) + + var stackFileContent string + if viper.GetString("stack.deploy.stack-file") != "" { + var loadingErr error + stackFileContent, loadingErr = loadStackFile(viper.GetString("stack.deploy.stack-file")) + common.CheckError(loadingErr) + } else { + var stackFileContentRetrievalErr error + stackFileContent, stackFileContentRetrievalErr = getStackFileContent(retrievedStack.Id) + common.CheckError(stackFileContentRetrievalErr) + } + + var newEnvironmentVariables []common.StackEnv + if viper.GetBool("stack.deploy.replace-env") { + newEnvironmentVariables = loadedEnvironmentVariables + } else { + // Merge stack environment variables with the loaded ones + newEnvironmentVariables = retrievedStack.Env + LoadedVariablesLoop: + for _, loadedEnvironmentVariable := range loadedEnvironmentVariables { + for _, newEnvironmentVariable := range newEnvironmentVariables { + if loadedEnvironmentVariable.Name == newEnvironmentVariable.Name { + newEnvironmentVariable.Value = loadedEnvironmentVariable.Value + continue LoadedVariablesLoop + } + } + newEnvironmentVariables = append(newEnvironmentVariables, common.StackEnv{ + Name: loadedEnvironmentVariable.Name, + Value: loadedEnvironmentVariable.Value, + }) + } + } + + updateErr := updateStack(retrievedStack, newEnvironmentVariables, stackFileContent, viper.GetBool("stack.deploy.prune")) + common.CheckError(updateErr) + case *common.StackNotFoundError: + // We are deploying a new stack + common.PrintVerbose(fmt.Sprintf("Stack %s not found. Deploying...", stackName)) + + if viper.GetString("stack.deploy.stack-file") == "" { + log.Fatalln("Specify a docker-compose file with --stack-file") + } + stackFileContent, loadingErr := loadStackFile(viper.GetString("stack.deploy.stack-file")) + common.CheckError(loadingErr) + + swarmClusterId, selectionErr := getSwarmClusterId() + switch selectionErr.(type) { + case nil: + // It's a swarm cluster + common.PrintVerbose(fmt.Sprintf("Swarm cluster found with id %s", swarmClusterId)) + deploymentErr := deploySwarmStack(stackName, loadedEnvironmentVariables, stackFileContent, swarmClusterId) + common.CheckError(deploymentErr) + case *valueNotFoundError: + // It's not a swarm cluster + common.PrintVerbose("Swarm cluster not found") + deploymentErr := deployComposeStack(stackName, loadedEnvironmentVariables, stackFileContent) + common.CheckError(deploymentErr) + default: + // Something else happened + log.Fatalln(selectionErr) + } + default: + // Something else happened + log.Fatalln(stackRetrievalErr) + } + }, +} + +func init() { + stackCmd.AddCommand(stackDeployCmd) + + stackDeployCmd.Flags().StringP("stack-file", "c", "", "path to a file with the content of the stack") + stackDeployCmd.Flags().String("endpoint", "1", "endpoint ID") + stackDeployCmd.Flags().StringP("env-file", "e", "", "path to a file with environment variables used during stack deployment") + stackDeployCmd.Flags().Bool("replace-env", false, "replace environment variables instead of merging them") + stackDeployCmd.Flags().BoolP("prune", "p", false, "prune services that are no longer referenced (only available for Swarm stacks)") + viper.BindPFlag("stack.deploy.stack-file", stackDeployCmd.Flags().Lookup("stack-file")) + viper.BindPFlag("stack.deploy.endpoint", stackDeployCmd.Flags().Lookup("endpoint")) + viper.BindPFlag("stack.deploy.env-file", stackDeployCmd.Flags().Lookup("env-file")) + viper.BindPFlag("stack.deploy.replace-env", stackDeployCmd.Flags().Lookup("replace-env")) + viper.BindPFlag("stack.deploy.prune", stackDeployCmd.Flags().Lookup("prune")) +} + +func deploySwarmStack(stackName string, environmentVariables []common.StackEnv, dockerComposeFileContent string, swarmClusterId string) error { + reqBody := common.StackCreateRequest{ + Name: stackName, + Env: environmentVariables, + SwarmID: swarmClusterId, + StackFileContent: dockerComposeFileContent, + } + + reqBodyBytes, marshalingErr := json.Marshal(reqBody) + if marshalingErr != nil { + return marshalingErr + } + + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/stacks?type=%v&method=%s&endpointId=%s", viper.GetString("url"), 1, "string", viper.GetString("stack.deploy.endpoint"))) + if parsingErr != nil { + return parsingErr + } + + req, newRequestErr := http.NewRequest(http.MethodPost, reqUrl.String(), bytes.NewBuffer(reqBodyBytes)) + if newRequestErr != nil { + return newRequestErr + } + headerErr := common.AddAuthorizationHeader(req) + req.Header.Add("Content-Type", "application/json") + if headerErr != nil { + return headerErr + } + common.PrintDebugRequest("Deploy stack request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + if requestExecutionErr != nil { + return requestExecutionErr + } + common.PrintDebugResponse("Deploy stack response", resp) + + responseErr := common.CheckResponseForErrors(resp) + if responseErr != nil { + return responseErr + } + + return nil +} + +func deployComposeStack(stackName string, environmentVariables []common.StackEnv, stackFileContent string) error { + reqBody := common.StackCreateRequest{ + Name: stackName, + Env: environmentVariables, + StackFileContent: stackFileContent, + } + + reqBodyBytes, marshalingErr := json.Marshal(reqBody) + if marshalingErr != nil { + return marshalingErr + } + + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/stacks?type=%v&method=%s&endpointId=%s", viper.GetString("url"), 2, "string", viper.GetString("stack.deploy.endpoint"))) + if parsingErr != nil { + return parsingErr + } + + req, newRequestErr := http.NewRequest(http.MethodPost, reqUrl.String(), bytes.NewBuffer(reqBodyBytes)) + if newRequestErr != nil { + return newRequestErr + } + headerErr := common.AddAuthorizationHeader(req) + req.Header.Add("Content-Type", "application/json") + if headerErr != nil { + return headerErr + } + common.PrintDebugRequest("Deploy stack request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + if requestExecutionErr != nil { + return requestExecutionErr + } + common.PrintDebugResponse("Deploy stack response", resp) + + responseErr := common.CheckResponseForErrors(resp) + if responseErr != nil { + return responseErr + } + + return nil +} + +func updateStack(stack common.Stack, environmentVariables []common.StackEnv, stackFileContent string, prune bool) error { + reqBody := common.StackUpdateRequest{ + Env: environmentVariables, + StackFileContent: stackFileContent, + Prune: prune, + } + + reqBodyBytes, marshalingErr := json.Marshal(reqBody) + if marshalingErr != nil { + return marshalingErr + } + + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/stacks/%v?endpointId=%s", viper.GetString("url"), stack.Id, viper.GetString("stack.deploy.endpoint"))) + if parsingErr != nil { + return parsingErr + } + + req, newRequestErr := http.NewRequest(http.MethodPut, reqUrl.String(), bytes.NewBuffer(reqBodyBytes)) + if newRequestErr != nil { + return newRequestErr + } + headerErr := common.AddAuthorizationHeader(req) + req.Header.Add("Content-Type", "application/json") + if headerErr != nil { + return headerErr + } + common.PrintDebugRequest("Update stack request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + if requestExecutionErr != nil { + return requestExecutionErr + } + common.PrintDebugResponse("Update stack response", resp) + + responseErr := common.CheckResponseForErrors(resp) + if responseErr != nil { + return responseErr + } + + return nil +} + +func getSwarmClusterId() (string, error) { + // Get docker information for endpoint + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/endpoints/%v/docker/info", viper.GetString("url"), viper.GetString("stack.deploy.endpoint"))) + if parsingErr != nil { + return "", parsingErr + } + + req, newRequestErr := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + if newRequestErr != nil { + return "", newRequestErr + } + headerErr := common.AddAuthorizationHeader(req) + if headerErr != nil { + return "", headerErr + } + common.PrintDebugRequest("Get docker info request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + if requestExecutionErr != nil { + return "", requestExecutionErr + } + common.PrintDebugResponse("Get docker info response", resp) + + responseErr := common.CheckResponseForErrors(resp) + if responseErr != nil { + return "", responseErr + } + + // Get swarm (if any) information for endpoint + var result map[string]interface{} + decodingError := json.NewDecoder(resp.Body).Decode(&result) + if decodingError != nil { + return "", decodingError + } + + swarmClusterId, selectionErr := selectValue(result, []string{"Swarm", "Cluster", "ID"}) + if selectionErr != nil { + return "", selectionErr + } + + return swarmClusterId.(string), nil +} + +func selectValue(jsonMap map[string]interface{}, jsonPath []string) (interface{}, error) { + value := jsonMap[jsonPath[0]] + if value == nil { + return nil, &valueNotFoundError{} + } else if len(jsonPath) > 1 { + return selectValue(value.(map[string]interface{}), jsonPath[1:]) + } else { + return value, nil + } +} + +func loadStackFile(path string) (string, error) { + loadedStackFileContentBytes, readingErr := ioutil.ReadFile(path) + if readingErr != nil { + return "", readingErr + } + return string(loadedStackFileContentBytes), nil +} + +// Load environment variables +func loadEnvironmentVariablesFile(path string) ([]common.StackEnv, error) { + var variables []common.StackEnv + variablesMap, readingErr := godotenv.Read(path) + if readingErr != nil { + return []common.StackEnv{}, readingErr + } + + for key, value := range variablesMap { + variables = append(variables, common.StackEnv{ + Name: key, + Value: value, + }) + } + + return variables, nil +} + +func getStackFileContent(stackId uint32) (string, error) { + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/stacks/%v/file", viper.GetString("url"), stackId)) + if parsingErr != nil { + return "", parsingErr + } + + req, newRequestErr := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + if newRequestErr != nil { + return "", newRequestErr + } + headerErr := common.AddAuthorizationHeader(req) + if headerErr != nil { + return "", headerErr + } + common.PrintDebugRequest("Get stack file content request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + if requestExecutionErr != nil { + return "", requestExecutionErr + } + common.PrintDebugResponse("Get stack file content response", resp) + + responseErr := common.CheckResponseForErrors(resp) + if responseErr != nil { + return "", responseErr + } + + var respBody common.StackFileInspectResponse + decodingErr := json.NewDecoder(resp.Body).Decode(&respBody) + if decodingErr != nil { + return "", decodingErr + } + + return respBody.StackFileContent, nil +} + +type valueNotFoundError struct{} + +func (e *valueNotFoundError) Error() string { + return "Value not found" +} diff --git a/cmd/stackList.go b/cmd/stackList.go new file mode 100644 index 0000000..62023fe --- /dev/null +++ b/cmd/stackList.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "os" + "text/template" +) + +// stackListCmd represents the remove command +var stackListCmd = &cobra.Command{ + Use: "list", + Short: "List stacks", + Aliases: []string{"ls"}, + Example: "psu stack list --endpoint 1", + Run: func(cmd *cobra.Command, args []string) { + stacks, err := common.GetAllStacksFiltered(common.StackListFilter{ + SwarmId: viper.GetString("stack.list.swarm"), + EndpointId: viper.GetUint32("stack.list.endpoint"), + }) + common.CheckError(err) + + if viper.GetBool("stack.list.quiet") { + // Print only stack names + for _, s := range stacks { + _, err := fmt.Println(s.Name) + common.CheckError(err) + } + } else if viper.GetString("stack.list.format") != "" { + // Print stack fields formatted + template, templateParsingErr := template.New("stackTpl").Parse(viper.GetString("stack.list.format")) + common.CheckError(templateParsingErr) + for _, s := range stacks { + templateExecutionErr := template.Execute(os.Stdout, s) + common.CheckError(templateExecutionErr) + fmt.Println() + } + } else { + // Print all stack fields as a table + writer, err := common.NewTabWriter([]string{ + "STACK ID", + "NAME", + "TYPE", + "ENTRY POINT", + "PROJECT PATH", + "ENDPOINT ID", + "SWARM ID", + }) + common.CheckError(err) + for _, s := range stacks { + _, err := fmt.Fprintln(writer, fmt.Sprintf( + "%v\t%s\t%v\t%s\t%s\t%v\t%s", + s.Id, + s.Name, + s.GetTranslatedStackType(), + s.EntryPoint, + s.ProjectPath, + s.EndpointID, + s.SwarmID, + )) + common.CheckError(err) + } + flushErr := writer.Flush() + common.CheckError(flushErr) + } + }, +} + +func init() { + stackCmd.AddCommand(stackListCmd) + + stackListCmd.Flags().String("swarm", "", "filter by swarm ID") + stackListCmd.Flags().String("endpoint", "", "filter by endpoint ID") + stackListCmd.Flags().BoolP("quiet", "q", false, "only display stack names") + stackListCmd.Flags().String("format", "", "format output using a Go template") + viper.BindPFlag("stack.list.swarm", stackListCmd.Flags().Lookup("swarm")) + viper.BindPFlag("stack.list.endpoint", stackListCmd.Flags().Lookup("endpoint")) + viper.BindPFlag("stack.list.quiet", stackListCmd.Flags().Lookup("quiet")) + viper.BindPFlag("stack.list.format", stackListCmd.Flags().Lookup("format")) +} diff --git a/cmd/stackRemove.go b/cmd/stackRemove.go new file mode 100644 index 0000000..5d6450d --- /dev/null +++ b/cmd/stackRemove.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/spf13/viper" + "log" + "net/http" + "net/url" + + "github.com/spf13/cobra" +) + +// stackRemoveCmd represents the remove command +var stackRemoveCmd = &cobra.Command{ + Use: "remove STACK_NAME", + Short: "Remove a stack", + Aliases: []string{"rm", "down"}, + Example: "psu stack rm mystack", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + stackName := args[0] + stack, err := common.GetStackByName(stackName) + + switch err.(type) { + case nil: + // The stack exists + common.PrintVerbose(fmt.Sprintf("Stack %s exists.", stackName)) + + stackId := stack.Id + + common.PrintVerbose(fmt.Sprintf("Removing stack %s...", stackName)) + reqUrl, err := url.Parse(fmt.Sprintf("%s/api/stacks/%d", viper.GetString("url"), stackId)) + common.CheckError(err) + + req, err := http.NewRequest(http.MethodDelete, reqUrl.String(), nil) + common.CheckError(err) + headerErr := common.AddAuthorizationHeader(req) + common.CheckError(headerErr) + common.PrintDebugRequest("Remove stack request", req) + + client := common.NewHttpClient() + + resp, err := client.Do(req) + common.PrintDebugResponse("Remove stack response", resp) + common.CheckError(err) + + common.CheckError(common.CheckResponseForErrors(resp)) + case *common.StackNotFoundError: + // The stack does not exist + common.PrintVerbose(fmt.Sprintf("Stack %s does not exist.", stackName)) + if viper.GetBool("stack.remove.strict") { + log.Fatalln(fmt.Sprintf("Stack %s does not exist.", stackName)) + } + default: + // Something else happened + common.CheckError(err) + } + }, +} + +func init() { + stackCmd.AddCommand(stackRemoveCmd) + + stackRemoveCmd.Flags().Bool("strict", false, "fail if stack does not exist") + viper.BindPFlag("stack.remove.strict", stackRemoveCmd.Flags().Lookup("strict")) +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..735ed31 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "github.com/greenled/portainer-stack-utils/common" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "net/http" + "net/url" + "os" + "text/template" +) + +// statusCmd represents the status command +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Check Portainer status", + Run: func(cmd *cobra.Command, args []string) { + reqUrl, parsingErr := url.Parse(fmt.Sprintf("%s/api/status", viper.GetString("url"))) + common.CheckError(parsingErr) + + req, newRequestErr := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + common.CheckError(newRequestErr) + headerErr := common.AddAuthorizationHeader(req) + common.CheckError(headerErr) + common.PrintDebugRequest("Get status request", req) + + client := common.NewHttpClient() + + resp, requestExecutionErr := client.Do(req) + common.CheckError(requestExecutionErr) + common.PrintDebugResponse("Get status response", resp) + + responseErr := common.CheckResponseForErrors(resp) + common.CheckError(responseErr) + + var respBody common.Status + decodingErr := json.NewDecoder(resp.Body).Decode(&respBody) + common.CheckError(decodingErr) + + if viper.GetString("status.format") != "" { + // Print stack fields formatted + template, templateParsingErr := template.New("statusTpl").Parse(viper.GetString("status.format")) + common.CheckError(templateParsingErr) + templateExecutionErr := template.Execute(os.Stdout, respBody) + common.CheckError(templateExecutionErr) + fmt.Println() + } else { + // Print status fields as a table + writer, newTabWriterErr := common.NewTabWriter([]string{ + "VERSION", + "AUTHENTICATION", + "ENDPOINT MANAGEMENT", + "ANALYTICS", + }) + common.CheckError(newTabWriterErr) + + _, printingErr := fmt.Fprintln(writer, fmt.Sprintf( + "%s\t%v\t%v\t%v", + respBody.Version, + respBody.Authentication, + respBody.EndpointManagement, + respBody.Analytics, + )) + common.CheckError(printingErr) + + flushErr := writer.Flush() + common.CheckError(flushErr) + } + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) + + statusCmd.Flags().String("format", "", "format output using a Go template") + viper.BindPFlag("status.format", statusCmd.Flags().Lookup("format")) +} diff --git a/common/authentication.go b/common/authentication.go new file mode 100644 index 0000000..0f7f5da --- /dev/null +++ b/common/authentication.go @@ -0,0 +1,85 @@ +package common + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/spf13/viper" + "net/http" + "net/url" +) + +var cachedAuthenticationToken string + +func GetAuthenticationToken() (string, error) { + if cachedAuthenticationToken == "" { + var authenticationTokenRetrievalErr error + cachedAuthenticationToken, authenticationTokenRetrievalErr = GetNewAuthenticationToken() + if authenticationTokenRetrievalErr != nil { + return "", authenticationTokenRetrievalErr + } + } + return cachedAuthenticationToken, nil +} + +func GetNewAuthenticationToken() (string, error) { + PrintVerbose("Getting auth token...") + + reqBody := AuthenticateUserRequest{ + Username: viper.GetString("user"), + Password: viper.GetString("password"), + } + + reqBodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + reqUrl, err := url.Parse(fmt.Sprintf("%s/api/auth", viper.GetString("url"))) + if err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, reqUrl.String(), bytes.NewBuffer(reqBodyBytes)) + if err != nil { + return "", err + } + PrintDebugRequest("Get auth token request", req) + + client := NewHttpClient() + + resp, err := client.Do(req) + if err != nil { + return "", err + } + PrintDebugResponse("Get auth token response", resp) + + respErr := CheckResponseForErrors(resp) + if respErr != nil { + return "", err + } + + respBody := AuthenticateUserResponse{} + decodingErr := json.NewDecoder(resp.Body).Decode(&respBody) + CheckError(decodingErr) + PrintDebug(fmt.Sprintf("Auth token: %s", respBody.Jwt)) + return respBody.Jwt, nil +} + +type AuthenticateUserRequest struct { + Username string + Password string +} + +type AuthenticateUserResponse struct { + Jwt string +} + +func AddAuthorizationHeader(request *http.Request) error { + token, err := GetAuthenticationToken() + if err != nil { + return err + } + request.Header.Add("Authorization", "Bearer "+token) + return nil +} diff --git a/common/connection.go b/common/connection.go new file mode 100644 index 0000000..d1e3635 --- /dev/null +++ b/common/connection.go @@ -0,0 +1,22 @@ +package common + +import ( + "crypto/tls" + "github.com/spf13/viper" + "net/http" +) + +func NewHttpClient() http.Client { + // Create HTTP transport + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: viper.GetBool("insecure"), + }, + } + + // Create HTTP client + return http.Client{ + Transport: tr, + Timeout: viper.GetDuration("timeout"), + } +} diff --git a/common/customerrors.go b/common/customerrors.go new file mode 100644 index 0000000..d472600 --- /dev/null +++ b/common/customerrors.go @@ -0,0 +1,26 @@ +package common + +import ( + "encoding/json" + "log" + "net/http" +) + +// CheckError checks if an error occurred (it's not nil) +func CheckError(err error) { + if err != nil { + log.Fatalln(err) + } +} + +func CheckResponseForErrors(resp *http.Response) error { + if 300 <= resp.StatusCode { + respBody := GenericError{} + err := json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + return err + } + return &respBody + } + return nil +} diff --git a/common/printing.go b/common/printing.go new file mode 100644 index 0000000..764c831 --- /dev/null +++ b/common/printing.go @@ -0,0 +1,83 @@ +package common + +import ( + "bytes" + "fmt" + "github.com/spf13/viper" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "text/tabwriter" +) + +func PrintVerbose(a ...interface{}) { + if viper.GetBool("verbose") { + log.Println(a) + } +} + +func PrintDebug(a ...interface{}) { + if viper.GetBool("debug") { + log.Println(a) + } +} + +func NewTabWriter(headers []string) (*tabwriter.Writer, error) { + writer := tabwriter.NewWriter(os.Stdout, 20, 2, 3, ' ', 0) + _, err := fmt.Fprintln(writer, strings.Join(headers, "\t")) + if err != nil { + return &tabwriter.Writer{}, err + } + return writer, nil +} + +func PrintDebugRequest(title string, req *http.Request) error { + if viper.GetBool("debug") { + var bodyString string + if req.Body != nil { + bodyBytes, err := ioutil.ReadAll(req.Body) + defer req.Body.Close() + if err != nil { + return err + } + bodyString = string(bodyBytes) + req.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes)) + } + + PrintDebug(fmt.Sprintf(`%s +--- +Method: %s +URL: %s +Body: +%s +---`, title, req.Method, req.URL.String(), string(bodyString))) + } + + return nil +} + +func PrintDebugResponse(title string, resp *http.Response) error { + if viper.GetBool("debug") { + var bodyString string + if resp.Body != nil { + bodyBytes, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return err + } + bodyString = string(bodyBytes) + resp.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes)) + } + + PrintDebug(fmt.Sprintf(`%s +--- +Status: %s +Body: +%s +---`, title, resp.Status, bodyString)) + } + + return nil +} diff --git a/common/types.go b/common/types.go new file mode 100644 index 0000000..50ad9df --- /dev/null +++ b/common/types.go @@ -0,0 +1,74 @@ +package common + +import "fmt" + +type Stack struct { + // In the API documentation this field is a String, + // but it's returned as a number + Id uint32 + Name string + Type uint8 // 1 for a Swarm stack, 2 for a Compose stack + EndpointID uint + EntryPoint string + SwarmID string + ProjectPath string + Env []StackEnv +} + +func (s *Stack) GetTranslatedStackType() string { + switch s.Type { + case 1: + return "swarm" + case 2: + return "compose" + default: + return "" + } +} + +type StackEnv struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type EndpointSubset struct { + Id uint32 + Name string + Type uint8 + URL string + PublicURL string + GroupID uint32 +} + +type StackCreateRequest struct { + Name string + SwarmID string + StackFileContent string + Env []StackEnv `json:",omitempty"` +} + +type StackUpdateRequest struct { + StackFileContent string + Env []StackEnv `json:",omitempty"` + Prune bool +} + +type StackFileInspectResponse struct { + StackFileContent string +} + +type Status struct { + Authentication bool + EndpointManagement bool + Analytics bool + Version string +} + +type GenericError struct { + Err string + Details string +} + +func (e *GenericError) Error() string { + return fmt.Sprintf("%s: %s", e.Err, e.Details) +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 0000000..8db896a --- /dev/null +++ b/common/utils.go @@ -0,0 +1,117 @@ +package common + +import ( + "encoding/json" + "fmt" + "github.com/spf13/viper" + "net/http" + "net/url" +) + +func GetAllStacks() ([]Stack, error) { + return GetAllStacksFiltered(StackListFilter{}) +} + +func GetAllStacksFiltered(filter StackListFilter) ([]Stack, error) { + PrintVerbose("Getting all stacks...") + + filterJsonBytes, _ := json.Marshal(filter) + filterJsonString := string(filterJsonBytes) + + reqUrl, err := url.Parse(fmt.Sprintf("%s/api/stacks?filters=%s", viper.GetString("url"), filterJsonString)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + if err != nil { + return nil, err + } + headerErr := AddAuthorizationHeader(req) + if headerErr != nil { + return nil, err + } + PrintDebugRequest("Get stacks request", req) + + client := NewHttpClient() + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + PrintDebugResponse("Get stacks response", resp) + + CheckError(CheckResponseForErrors(resp)) + + var respBody []Stack + decodingErr := json.NewDecoder(resp.Body).Decode(&respBody) + CheckError(decodingErr) + + return respBody, nil +} + +func GetStackByName(name string) (Stack, error) { + stacks, err := GetAllStacks() + if err != nil { + return Stack{}, err + } + + PrintVerbose(fmt.Sprintf("Getting stack %s...", name)) + for _, stack := range stacks { + if stack.Name == name { + return stack, nil + } + } + return Stack{}, &StackNotFoundError{ + StackName: name, + } +} + +type StackListFilter struct { + SwarmId string `json:",omitempty"` + EndpointId uint32 `json:",omitempty"` +} + +// Custom customerrors +type StackNotFoundError struct { + StackName string +} + +func (e *StackNotFoundError) Error() string { + return fmt.Sprintf("Stack %s not found", e.StackName) +} + +func GetAllEndpoints() ([]EndpointSubset, error) { + PrintVerbose("Getting all endpoints...") + + reqUrl, err := url.Parse(fmt.Sprintf("%s/api/endpoints", viper.GetString("url"))) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + if err != nil { + return nil, err + } + headerErr := AddAuthorizationHeader(req) + if headerErr != nil { + return nil, err + } + PrintDebugRequest("Get endpoints request", req) + + client := NewHttpClient() + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + PrintDebugResponse("Get endpoints response", resp) + + CheckError(CheckResponseForErrors(resp)) + + var respBody []EndpointSubset + decodingErr := json.NewDecoder(resp.Body).Decode(&respBody) + CheckError(decodingErr) + + return respBody, nil +} diff --git a/common/version.go b/common/version.go new file mode 100644 index 0000000..9a45879 --- /dev/null +++ b/common/version.go @@ -0,0 +1,61 @@ +package common + +import ( + "fmt" + "runtime" + "strings" +) + +var ( + // This is the current version of the client + CurrentVersion = Version{ + Major: 0, + Minor: 1, + Patch: 1, + Suffix: "", + } + + // commitHash contains the current Git revision. Use Go Releaser to make sure this gets set. + commitHash string + + // buildDate contains the date of the current build. + buildDate string +) + +type Version struct { + // Major version + Major uint32 + + // Minor version + Minor uint32 + + // Patch version + Patch uint32 + + // Suffix used in version string + // Will be blank for release versions + Suffix string +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d%s", v.Major, v.Minor, v.Patch, v.Suffix) +} + +func BuildVersionString() string { + program := "Portainer Stack Utils" + + version := "v" + CurrentVersion.String() + + if commitHash != "" { + version += "-" + strings.ToUpper(commitHash) + } + + osArch := runtime.GOOS + "/" + runtime.GOARCH + + date := buildDate + if date == "" { + date = "unknown" + } + + return fmt.Sprintf("%s %s %s BuildDate: %s", program, version, osArch, date) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..93f72e7 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/greenled/portainer-stack-utils/cmd" + +func main() { + cmd.Execute() +} diff --git a/psu b/psu deleted file mode 100755 index ba7bbbc..0000000 --- a/psu +++ /dev/null @@ -1,476 +0,0 @@ -#!/usr/bin/env bash -# -# Deploy/update/undeploy Docker stacks in a Portainer instance. - -########################## -# Main entrypoint # -# Globals: # -# AUTH_TOKEN # -# HTTPIE_VERIFY_SSL # -# PORTAINER_URL # -# PORTAINER_USER # -# PORTAINER_PASSWORD # -# PORTAINER_STACK_NAME # -# STACK # -# ACTION # -# Arguments: # -# None # -# Returns: # -# None # -########################## -main() { - set_globals "$@" - - # Get Portainer auth token. Will be used on every API request. - echo_verbose "Getting auth token..." - AUTH_TOKEN=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - $PORTAINER_URL/api/auth \ - username=$PORTAINER_USER \ - password=$PORTAINER_PASSWORD) - check_for_errors $? "$AUTH_TOKEN" - echo_debug "Get auth token response -> $(echo $AUTH_TOKEN | jq -C .)" - AUTH_TOKEN=$(echo $AUTH_TOKEN | jq -r .jwt) - echo_debug "Auth token -> $AUTH_TOKEN" - - # Get list of all stacks - echo_verbose "Getting stack $PORTAINER_STACK_NAME..." - local stacks - stacks=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - "$PORTAINER_URL/api/stacks" \ - "Authorization: Bearer $AUTH_TOKEN") - check_for_errors $? "$stacks" - echo_debug "Get stacks response -> $(echo $stacks | jq -C .)" - - # Get desired stack from stacks list by it's name - STACK=$(echo "$stacks" \ - | jq --arg PORTAINER_STACK_NAME "$PORTAINER_STACK_NAME" -jc '.[] | select(.Name == $PORTAINER_STACK_NAME)') - echo_debug "Stack ${PORTAINER_STACK_NAME} -> $(echo $STACK | jq -C .)" - - if [ $ACTION == "deploy" ]; then - deploy - exit 0 - fi - - if [ $ACTION == "undeploy" ]; then - undeploy - exit 0 - fi - - echo_error "Error: Unknown action \"$ACTION\"." - exit 1 -} - -################################ -# Set globals # -# Globals: # -# ACTION # -# PORTAINER_USER # -# PORTAINER_PASSWORD # -# PORTAINER_URL # -# PORTAINER_STACK_NAME # -# DOCKER_COMPOSE_FILE # -# ENVIRONMENT_VARIABLES_FILE # -# PORTAINER_ENDPOINT # -# PORTAINER_PRUNE # -# HTTPIE_VERIFY_SSL # -# VERBOSE_MODE # -# DEBUG_MODE # -# STRICT_MODE # -# Arguments: # -# None # -# Returns: # -# None # -################################ -set_globals() { - # Set arguments through envvars - ACTION=${ACTION} - PORTAINER_USER=${PORTAINER_USER} - PORTAINER_PASSWORD=${PORTAINER_PASSWORD} - PORTAINER_URL=${PORTAINER_URL} - PORTAINER_STACK_NAME=${PORTAINER_STACK_NAME} - DOCKER_COMPOSE_FILE=${DOCKER_COMPOSE_FILE} - ENVIRONMENT_VARIABLES_FILE=${ENVIRONMENT_VARIABLES_FILE} - PORTAINER_ENDPOINT=${PORTAINER_ENDPOINT:-"1"} - PORTAINER_PRUNE=${PORTAINER_PRUNE:-"false"} - HTTPIE_VERIFY_SSL=${HTTPIE_VERIFY_SSL:-"yes"} - VERBOSE_MODE=${VERBOSE_MODE:-"false"} - DEBUG_MODE=${DEBUG_MODE:-"false"} - STRICT_MODE=${STRICT_MODE:-"false"} - - # Set arguments through flags (overwrite envvars) - while getopts a:u:p:l:n:c:e:g:rsvdt option; do - case "${option}" in - a) ACTION=${OPTARG} ;; - u) PORTAINER_USER=${OPTARG} ;; - p) PORTAINER_PASSWORD=${OPTARG} ;; - l) PORTAINER_URL=${OPTARG} ;; - n) PORTAINER_STACK_NAME=${OPTARG} ;; - c) DOCKER_COMPOSE_FILE=${OPTARG} ;; - e) PORTAINER_ENDPOINT=${OPTARG} ;; - g) ENVIRONMENT_VARIABLES_FILE=${OPTARG} ;; - r) PORTAINER_PRUNE="true" ;; - s) HTTPIE_VERIFY_SSL="no" ;; - v) VERBOSE_MODE="true" ;; - d) DEBUG_MODE="true" ;; - t) STRICT_MODE="true" ;; - *) - echo_error "Unexpected option ${option}" - exit 1 - ;; - esac - done - - # Print config (only if debug mode is active) - echo_debug "ACTION -> $ACTION" - echo_debug "PORTAINER_USER -> $PORTAINER_USER" - echo_debug "PORTAINER_PASSWORD -> $PORTAINER_PASSWORD" - echo_debug "PORTAINER_URL -> $PORTAINER_URL" - echo_debug "PORTAINER_STACK_NAME -> $PORTAINER_STACK_NAME" - echo_debug "DOCKER_COMPOSE_FILE -> $DOCKER_COMPOSE_FILE" - echo_debug "ENVIRONMENT_VARIABLES_FILE -> $ENVIRONMENT_VARIABLES_FILE" - echo_debug "PORTAINER_ENDPOINT -> $PORTAINER_ENDPOINT" - echo_debug "PORTAINER_PRUNE -> $PORTAINER_PRUNE" - echo_debug "HTTPIE_VERIFY_SSL -> $HTTPIE_VERIFY_SSL" - echo_debug "VERBOSE_MODE -> $VERBOSE_MODE" - echo_debug "DEBUG_MODE -> $DEBUG_MODE" - echo_debug "STRICT_MODE -> $STRICT_MODE" - - # Check required arguments have been provided - check_argument "$ACTION" "action" "ACTION" "a" - check_argument "$PORTAINER_USER" "portainer user" "PORTAINER_USER" "u" - check_argument "$PORTAINER_PASSWORD" "portainer password" "PORTAINER_PASSWORD" "p" - check_argument "$PORTAINER_URL" "portainer url" "PORTAINER_URL" "l" - check_argument "$PORTAINER_STACK_NAME" "portainer stack name" "PORTAINER_STACK_NAME" "n" - if [ $ACTION == "deploy" ]; then - check_argument "$DOCKER_COMPOSE_FILE" "docker compose file" "DOCKER_COMPOSE_FILE" "c" - if [ -n "$ENVIRONMENT_VARIABLES_FILE" ] && [[ ! -f "$ENVIRONMENT_VARIABLES_FILE" ]]; then - echo_error "Error: File path \"$ENVIRONMENT_VARIABLES_FILE\" not found for \"ENVIRONMENT_VARIABLES_FILE\" environment variable or the \"-g\" flag." - exit 1 - fi - fi -} - -############################ -# Print an error to stderr # -# Globals: # -# None # -# Arguments: # -# $1 Error message # -# Returns: # -# None # -############################ -echo_error() { - local error_message="$@" - local red='\033[0;31m' - local nc='\033[0m' - echo -e "${red}[$(date +'%Y-%m-%dT%H:%M:%S%z')]: ${error_message}${nc}" >&2 -} - -####################################### -# Check a parameter has been provided # -# Globals: # -# None # -# Arguments: # -# $1 Argument value # -# $2 Argument name # -# $3 Argument envvar # -# $4 Argument flag # -# Returns: # -# None # -####################################### -check_argument() { - local argument_value=$1 - local argument_name=$2 - local argument_envvar=$3 - local argument_flag=$4 - if [ -z "$argument_value" ]; then - echo_error "Error: Missing argument \"$argument_name\"." - echo_error "Try setting \"$argument_envvar\" environment variable or using the \"-$argument_flag\" flag." - exit 1 - fi -} - -########################################### -# Checks for error exit codes from httpie # -# Globals: # -# None # -# Arguments: # -# $1 Httpie exit code # -# $2 Response returned by Portainer API # -# Returns: # -# None # -########################################### -check_for_errors() { - local exit_code=$1 - local response=$2 - if [ $exit_code -ne 0 ]; then - case $exit_code in - 2) echo_error 'Request timed out!' ;; - 3) echo_error 'Unexpected HTTP 3xx Redirection!' ;; - 4) - echo_error 'HTTP 4xx Client Error!' - echo_error $response - ;; - 5) - echo_error 'HTTP 5xx Server Error!' - echo_error $response - ;; - 6) echo_error 'Exceeded --max-redirects= redirects!' ;; - *) echo_error 'Unholy Error!' ;; - esac - exit 1 - fi -} - -########################################### -# Print message if verbose mode is active # -# Globals: # -# VERBOSE_MODE # -# Arguments: # -# $1 Message # -# Returns: # -# None # -########################################### -echo_verbose() { - local message=$1 - local yellow='\033[1;33m' - local nc='\033[0m' - if [ $VERBOSE_MODE == "true" ]; then - echo -e "${yellow}${message}${nc}" - fi -} - -######################################### -# Print message if debug mode is active # -# Globals: # -# DEBUG_MODE # -# Arguments: # -# $1 Message # -# Returns: # -# None # -######################################### -echo_debug() { - local message=$1 - if [ $DEBUG_MODE == "true" ]; then - echo -e "${message}" - fi -} - -################################ -# Create/update a stack # -# Globals: # -# STACK # -# DOCKER_COMPOSE_FILE # -# PORTAINER_STACK_NAME # -# PORTAINER_URL # -# ENVIRONMENT_VARIABLES_FILE # -# HTTPIE_VERIFY_SSL # -# PORTAINER_ENDPOINT # -# AUTH_TOKEN # -# Arguments: # -# None # -# Returns: # -# None # -################################ -deploy() { - # Read docker-compose file content - local docker_compose_file_content - docker_compose_file_content=$(cat "$DOCKER_COMPOSE_FILE") - - # Remove carriage returns - docker_compose_file_content="${docker_compose_file_content//$'\r'/''}" - - # Escape double quotes - docker_compose_file_content="${docker_compose_file_content//$'"'/'\"'}" - - # Escape newlines - docker_compose_file_content="${docker_compose_file_content//$'\n'/'\n'}" - - # If the stack does not exist - if [ -z "$STACK" ]; then - echo_verbose "Stack $PORTAINER_STACK_NAME does not exist." - - # Get Docker info - echo_verbose "Getting Docker info..." - local docker_info - docker_info=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/info" \ - "Authorization: Bearer $AUTH_TOKEN") - check_for_errors $? "$docker_info" - echo_debug "Docker info -> $(echo $docker_info | jq -C .)" - - # Get Docker swarm ID - echo_verbose "Getting swarm cluster (if any)..." - local swarm_id - swarm_id=$(echo $docker_info | jq -r ".Swarm.Cluster.ID // empty") - echo_debug "Swarm ID -> $swarm_id" - - # If there is no swarm ID - if [ -z "$swarm_id" ];then - echo_verbose "Swarm cluster not found." - - echo_verbose "Preparing stack JSON..." - local stack_envvars - stack_envvars="[]" - if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then - stack_envvars=$(env_file_to_json) - fi - local data_prefix="{\"Name\":\"$PORTAINER_STACK_NAME\",\"StackFileContent\":\"" - local data_suffix="\",\"Env\":"$stack_envvars"}" - echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp - echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" - - # Create stack for single Docker instance - echo_verbose "Creating stack $PORTAINER_STACK_NAME..." - local create - create=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - --timeout=300 \ - "$PORTAINER_URL/api/stacks" \ - "Authorization: Bearer $AUTH_TOKEN" \ - type==2 \ - method==string \ - endpointId==$PORTAINER_ENDPOINT \ - @json.tmp) - check_for_errors $? "$create" - echo_debug "Create action response -> $(echo $create | jq -C .)" - else - echo_verbose "Swarm cluster found." - - echo_verbose "Preparing stack JSON..." - local stack_envvars - stack_envvars="[]" - if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then - stack_envvars=$(env_file_to_json) - fi - local data_prefix="{\"Name\":\"$PORTAINER_STACK_NAME\",\"SwarmID\":\"$swarm_id\",\"StackFileContent\":\"" - local data_suffix="\",\"Env\":"$stack_envvars"}" - echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp - echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" - - # Create stack for Docker swarm - echo_verbose "Creating stack $PORTAINER_STACK_NAME..." - local create - create=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - --timeout=300 \ - "$PORTAINER_URL/api/stacks" \ - "Authorization: Bearer $AUTH_TOKEN" \ - type==1 \ - method==string \ - endpointId==$PORTAINER_ENDPOINT \ - @json.tmp) - check_for_errors $? "$create" - echo_debug "Create action response -> $(echo $create | jq -C .)" - fi - - rm json.tmp - else - if [ $STRICT_MODE == "true" ]; then - echo_error "Error: Stack $PORTAINER_STACK_NAME already exists." - exit 1 - fi - echo_verbose "Stack $PORTAINER_STACK_NAME exists." - - echo_verbose "Preparing stack JSON..." - local stack_id - stack_id="$(echo "$STACK" | jq -j ".Id")" - local stack_envvars - stack_envvars="$(echo -n "$STACK" | jq ".Env" -jc)" - if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then - local new_stack_envvars - new_stack_envvars=$(env_file_to_json) - stack_envvars="$(echo "${new_stack_envvars}${stack_envvars}" | jq -sjc 'add | unique_by(.name)')" - fi - local data_prefix="{\"Id\":\"$stack_id\",\"StackFileContent\":\"" - local data_suffix="\",\"Env\":"$stack_envvars",\"Prune\":$PORTAINER_PRUNE}" - echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp - echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" - - # Update stack - echo_verbose "Updating stack $PORTAINER_STACK_NAME..." - local update - update=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - --timeout=300 \ - PUT "$PORTAINER_URL/api/stacks/$stack_id" \ - "Authorization: Bearer $AUTH_TOKEN" \ - endpointId==$PORTAINER_ENDPOINT \ - @json.tmp) - check_for_errors $? "$update" - echo_debug "Update action response -> $(echo $update | jq -C .)" - - rm json.tmp - fi -} - -########################## -# Remove a stack # -# Globals: # -# STACK # -# PORTAINER_STACK_NAME # -# PORTAINER_URL # -# HTTPIE_VERIFY_SSL # -# AUTH_TOKEN # -# Arguments: # -# None # -# Returns: # -# None # -########################## -undeploy() { - if [ -z "$STACK" ]; then - if [ $STRICT_MODE == "true" ]; then - echo_error "Error: Stack $PORTAINER_STACK_NAME does not exist." - exit 1 - else - echo_verbose "Stack $PORTAINER_STACK_NAME does not exist. No need to undeploy it." - exit 0 - fi - fi - echo_verbose "Stack $PORTAINER_STACK_NAME exists." - - local stack_id - stack_id="$(echo "$STACK" | jq -j ".Id")" - echo_debug "Stack ID -> $stack_id" - - echo_verbose "Deleting stack $PORTAINER_STACK_NAME..." - local delete - delete=$(http \ - --check-status \ - --ignore-stdin \ - --verify=$HTTPIE_VERIFY_SSL \ - DELETE "$PORTAINER_URL/api/stacks/$stack_id" \ - "Authorization: Bearer $AUTH_TOKEN") - check_for_errors $? "$delete" - echo_debug "Delete action response -> $(echo $delete | jq -C .)" -} - -################################################### -# Convert environment variables from file to JSON # -# Globals: # -# ENVIRONMENT_VARIABLES_FILE # -# Arguments: # -# None # -# Returns: # -# JSON string # -################################################### -env_file_to_json() { - echo "$(env -i sh -c "(unset \$(env | sed 's/=.*//'); set -a; . $(readlink -f $ENVIRONMENT_VARIABLES_FILE); set +a; jq -njc 'env | to_entries | map({name: .key, value: .value})')")" -} - -main "$@"