Rewrite project in Go

This commit is contained in:
Juan Carlos Mejías Rodríguez 2019-07-20 22:00:04 -04:00
parent 3326a0fdda
commit c5b1dfaa82
23 changed files with 1572 additions and 621 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
psu
psu.exe
.idea
dist

50
.goreleaser.yml Normal file
View File

@ -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"

View File

@ -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

View File

@ -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"]

260
README.md
View File

@ -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

28
cmd/completion.go Normal file
View File

@ -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)
}

15
cmd/endpoint.go Normal file
View File

@ -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)
}

87
cmd/endpointList.go Normal file
View File

@ -0,0 +1,87 @@
/*
Copyright © 2019 Juan Carlos Mejías Rodríguez <juan.mejias@reduc.edu.cu>
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 <http://www.gnu.org/licenses/>.
*/
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"))
}

86
cmd/root.go Normal file
View File

@ -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())
}
}

15
cmd/stack.go Normal file
View File

@ -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)
}

378
cmd/stackDeploy.go Normal file
View File

@ -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"
}

82
cmd/stackList.go Normal file
View File

@ -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"))
}

67
cmd/stackRemove.go Normal file
View File

@ -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"))
}

79
cmd/status.go Normal file
View File

@ -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"))
}

85
common/authentication.go Normal file
View File

@ -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
}

22
common/connection.go Normal file
View File

@ -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"),
}
}

26
common/customerrors.go Normal file
View File

@ -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
}

83
common/printing.go Normal file
View File

@ -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
}

74
common/types.go Normal file
View File

@ -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)
}

117
common/utils.go Normal file
View File

@ -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
}

61
common/version.go Normal file
View File

@ -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)
}

7
main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/greenled/portainer-stack-utils/cmd"
func main() {
cmd.Execute()
}

476
psu
View File

@ -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=<n> 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 "$@"