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).
|
||||
|
||||
## [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
|
||||
|
50
Dockerfile
50
Dockerfile
@ -1,35 +1,25 @@
|
||||
FROM alpine
|
||||
|
||||
ENV LANG="en_US.UTF-8" \
|
||||
LC_ALL="C.UTF-8" \
|
||||
LANGUAGE="en_US.UTF-8" \
|
||||
TERM="xterm" \
|
||||
ACTION="" \
|
||||
PORTAINER_USER="" \
|
||||
PORTAINER_PASSWORD="" \
|
||||
PORTAINER_URL="" \
|
||||
PORTAINER_STACK_NAME="" \
|
||||
DOCKER_COMPOSE_FILE="" \
|
||||
ENVIRONMENT_VARIABLES_FILE="" \
|
||||
PORTAINER_PRUNE="false" \
|
||||
PORTAINER_ENDPOINT="1" \
|
||||
HTTPIE_VERIFY_SSL="yes" \
|
||||
VERBOSE_MODE="false" \
|
||||
DEBUG_MODE="false" \
|
||||
STRICT_MODE="false"
|
||||
ENV PSU_AUTHENTICATION_PASSWORD="" \
|
||||
PSU_AUTHENTICATION_USER="" \
|
||||
PSU_CONFIG="" \
|
||||
PSU_CONNECTION_INSECURE="" \
|
||||
PSU_CONNECTION_TIMEOUT="" \
|
||||
PSU_CONNECTION_URL="" \
|
||||
PSU_DEBUG="" \
|
||||
PSU_ENDPOINT_LIST_FORMAT="" \
|
||||
PSU_STACK_DEPLOY_ENDPOINT="" \
|
||||
PSU_STACK_DEPLOY_ENV_FILE="" \
|
||||
PSU_STACK_DEPLOY_REPLACE_ENV="" \
|
||||
PSU_STACK_DEPLOY_STACK_FILE="" \
|
||||
PSU_STACK_LIST_ENDPOINT="" \
|
||||
PSU_STACK_LIST_FORMAT="" \
|
||||
PSU_STACK_LIST_QUIET="" \
|
||||
PSU_STACK_LIST_SWARM="" \
|
||||
PSU_STACK_REMOVE_STRICT="" \
|
||||
PSU_STATUS_FORMAT="" \
|
||||
PSU_VERBOSE=""
|
||||
|
||||
RUN apk --update add \
|
||||
bash \
|
||||
ca-certificates \
|
||||
httpie \
|
||||
jq \
|
||||
gettext \
|
||||
&& \
|
||||
rm -rf /tmp/src && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY psu /usr/local/bin/
|
||||
|
||||
RUN chmod +x /usr/local/bin/*
|
||||
COPY psu /usr/local/bin/psu
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/psu"]
|
||||
|
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/)
|
||||
[![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.
|
||||
|
||||
### 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.
|
||||
|
||||
- `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
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