mirror of
https://gitlab.com/psuapp/psu.git
synced 2024-08-30 18:12:34 +00:00
Rewrite project in Go
This commit is contained in:
parent
3326a0fdda
commit
c5b1dfaa82
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
psu
|
||||||
|
psu.exe
|
||||||
|
.idea
|
||||||
|
dist
|
50
.goreleaser.yml
Normal file
50
.goreleaser.yml
Normal 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"
|
41
CHANGELOG.md
41
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.1] - 2019-06-05
|
||||||
### Fixed
|
### Fixed
|
||||||
|
50
Dockerfile
50
Dockerfile
@ -1,35 +1,25 @@
|
|||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
ENV LANG="en_US.UTF-8" \
|
ENV PSU_AUTHENTICATION_PASSWORD="" \
|
||||||
LC_ALL="C.UTF-8" \
|
PSU_AUTHENTICATION_USER="" \
|
||||||
LANGUAGE="en_US.UTF-8" \
|
PSU_CONFIG="" \
|
||||||
TERM="xterm" \
|
PSU_CONNECTION_INSECURE="" \
|
||||||
ACTION="" \
|
PSU_CONNECTION_TIMEOUT="" \
|
||||||
PORTAINER_USER="" \
|
PSU_CONNECTION_URL="" \
|
||||||
PORTAINER_PASSWORD="" \
|
PSU_DEBUG="" \
|
||||||
PORTAINER_URL="" \
|
PSU_ENDPOINT_LIST_FORMAT="" \
|
||||||
PORTAINER_STACK_NAME="" \
|
PSU_STACK_DEPLOY_ENDPOINT="" \
|
||||||
DOCKER_COMPOSE_FILE="" \
|
PSU_STACK_DEPLOY_ENV_FILE="" \
|
||||||
ENVIRONMENT_VARIABLES_FILE="" \
|
PSU_STACK_DEPLOY_REPLACE_ENV="" \
|
||||||
PORTAINER_PRUNE="false" \
|
PSU_STACK_DEPLOY_STACK_FILE="" \
|
||||||
PORTAINER_ENDPOINT="1" \
|
PSU_STACK_LIST_ENDPOINT="" \
|
||||||
HTTPIE_VERIFY_SSL="yes" \
|
PSU_STACK_LIST_FORMAT="" \
|
||||||
VERBOSE_MODE="false" \
|
PSU_STACK_LIST_QUIET="" \
|
||||||
DEBUG_MODE="false" \
|
PSU_STACK_LIST_SWARM="" \
|
||||||
STRICT_MODE="false"
|
PSU_STACK_REMOVE_STRICT="" \
|
||||||
|
PSU_STATUS_FORMAT="" \
|
||||||
|
PSU_VERBOSE=""
|
||||||
|
|
||||||
RUN apk --update add \
|
COPY psu /usr/local/bin/psu
|
||||||
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/*
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/psu"]
|
ENTRYPOINT ["/usr/local/bin/psu"]
|
||||||
|
258
README.md
258
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/)
|
[![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")
|
[![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
|
## 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
|
## 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:
|
You can also install the source code with `go` and build the binaries yourself.
|
||||||
|
|
||||||
```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`
|
|
||||||
|
|
||||||
## How to use
|
## 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.
|
||||||
|
|
||||||
### With envvars
|
**Commands** represent actions, **Args** are things and **Flags** are modifiers for those actions:
|
||||||
|
|
||||||
This is particularly useful for CI/CD pipelines using Docker containers.
|
```text
|
||||||
|
APPNAME COMMAND ARG --FLAG
|
||||||
- `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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
Here are some examples:
|
||||||
export ACTION="undeploy"
|
|
||||||
export PORTAINER_USER="admin"
|
|
||||||
export PORTAINER_PASSWORD="password"
|
|
||||||
export PORTAINER_URL="http://portainer.local"
|
|
||||||
export PORTAINER_STACK_NAME="mystack"
|
|
||||||
|
|
||||||
./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
|
### Configuration
|
||||||
- `-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.
|
|
||||||
|
|
||||||
#### 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
|
```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
|
#### With environment variables
|
||||||
./psu -a undeploy -u admin -p password -l http://portainer.local -n mystack
|
|
||||||
|
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
|
### 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
|
```bash
|
||||||
touch .env
|
touch .env
|
||||||
echo "MYSQL_ROOT_PASSWORD=agoodpassword" >> .env
|
echo "MYSQL_ROOT_PASSWORD=agoodpassword" >> .env
|
||||||
echo "ALLOWED_HOSTS=*" >> .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
|
### Verbose mode
|
||||||
|
|
||||||
In verbose mode the script prints execution steps.
|
In verbose mode the script prints execution steps.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Getting auth token...
|
2019/07/20 19:15:45 [Using config file: /home/johndoe/.psu.yaml]
|
||||||
Getting stack mystack...
|
2019/07/20 19:15:45 [Getting stack mystack...]
|
||||||
Stack mystack not found.
|
2019/07/20 19:15:45 [Getting auth token...]
|
||||||
Getting Docker info...
|
2019/07/20 19:15:45 [Stack mystack not found. Deploying...]
|
||||||
Getting swarm cluster (if any)...
|
2019/07/20 19:15:45 [Swarm cluster found with id qwe123rty456uio789asd123f]
|
||||||
Swarm cluster found.
|
|
||||||
Preparing stack JSON...
|
|
||||||
Creating stack mystack...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### 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.
|
**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).
|
Debug mode can be enabled through the `DEBUG_MODE` [environment variable](#with-environment-variables) and the `debug` [configuration key](#with-configuration-file).
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
28
cmd/completion.go
Normal file
28
cmd/completion.go
Normal 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
15
cmd/endpoint.go
Normal 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
87
cmd/endpointList.go
Normal 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
86
cmd/root.go
Normal 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
15
cmd/stack.go
Normal 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
378
cmd/stackDeploy.go
Normal 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
82
cmd/stackList.go
Normal 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
67
cmd/stackRemove.go
Normal 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
79
cmd/status.go
Normal 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
85
common/authentication.go
Normal 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
22
common/connection.go
Normal 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
26
common/customerrors.go
Normal 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
83
common/printing.go
Normal 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
74
common/types.go
Normal 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
117
common/utils.go
Normal 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
61
common/version.go
Normal 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
7
main.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/greenled/portainer-stack-utils/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
476
psu
476
psu
@ -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 "$@"
|
|
Loading…
Reference in New Issue
Block a user