Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,120 @@ _PVECONTROL_COMPLETE=zsh_source pvecontrol > "${HOME}/.zsh/completions/_pvecontr
_PVECONTROL_COMPLETE=fish_source pvecontrol > {$HOME}/.config/fish/completions/pvecontrol.fish
```

---

# Proxmox Backup Server Control

`pbscontrol` is a companion CLI tool to manage Proxmox Backup Server (PBS) instances via their API. It follows the same conventions and configuration style as `pvecontrol`.

## Installation

`pbscontrol` is bundled in the same package as `pvecontrol` and installed alongside it:

```shell
pipx install pvecontrol
```

## Configuration

`pbscontrol` reads its configuration from `$HOME/.config/pbscontrol/config.yaml`. The structure mirrors `pvecontrol`'s configuration, with a `servers` list instead of `clusters`.

```yaml
servers:
- name: my-pbs
host: pbs.example.com
user: root@pam
password: my.password.is.weak
ssl_verify: false
```

### API tokens

Authentication via API tokens is also supported:

```yaml
servers:
- name: my-pbs
host: pbs.example.com
user: pbscontrol@pbs
token_name: mytoken
token_value: randomtokenvalue
```

### Better security

As with `pvecontrol`, the shell command substitution syntax `$(...)` is supported in the `user`, `password`, `token_name`, and `token_value` fields:

```yaml
servers:
- name: my-pbs
host: pbs.example.com
user: root@pam
password: $(pass show pbs/my-pbs)
```

### Connection options

| Option | Default | Description |
|---|---|---|
| `port` | `8007` | PBS API port |
| `timeout` | `60` | Request timeout in seconds |
| `ssl_verify` | `false` | Verify TLS certificate |

## Usage

```shell
$ pbscontrol --help
Usage: pbscontrol [OPTIONS] COMMAND [ARGS]...

Proxmox Backup Server control CLI, version: x.y.z

Options:
-d, --debug
-o, --output [text|json|csv|yaml|md]
[default: text]
-s, --server NAME Proxmox Backup Server name as defined in
configuration [required]
--unicode / --no-unicode Use unicode characters for output
--color / --no-color Use colorized output
--help Show this message and exit.

Commands:
status Show Proxmox Backup Server status

Made with love by Enix.io
```

The simplest command to verify that `pbscontrol` is correctly configured is `status`:

```shell
$ pbscontrol --server my-pbs status

Name: my-pbs
Version: 4.1.1
Datastores:
datastore1: 372.53 GiB/931.32 GiB (40.0%), available: 558.79 GiB, gc: ok
datastore2: 1.64 TiB/1.82 TiB (90.0%), available: 186.26 GiB, gc: ok
```

## Environment variables

`pbscontrol` supports the following environment variables:
- `PBSCONTROL_SERVER`: the default server to use when no `-s` or `--server` option is specified.
- `PBSCONTROL_COLOR`: if set to `False`, it will disable all colorized output.
- `PBSCONTROL_UNICODE`: if set to `False`, it will disable all unicode output.

## Shell completion

```shell
# bash
_PBSCONTROL_COMPLETE=bash_source pbscontrol > "${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions/pbscontrol"
# zsh
_PBSCONTROL_COMPLETE=zsh_source pbscontrol > "${HOME}/.zsh/completions/_pbscontrol"
# fish
_PBSCONTROL_COMPLETE=fish_source pbscontrol > {$HOME}/.config/fish/completions/pbscontrol.fish
```

## Development

If you want to tinker with the code, all the required dependencies are listed in `requirements.txt`, and you can install them e.g. with pip:
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ where=src
[options.entry_points]
console_scripts =
pvecontrol = pvecontrol:main
pbscontrol = pbscontrol:main
157 changes: 157 additions & 0 deletions src/pbscontrol/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3

import sys
import logging
import signal

from types import SimpleNamespace
from importlib.metadata import version, PackageNotFoundError

import click
import urllib3

from pbscontrol import actions
from pvecontrol.utils import OutputFormats


def get_leaf_command(cmd, ctx, args):
if len(args) == 0:
return cmd, []

parser = cmd.make_parser(ctx)
_, args_without_options, _ = parser.parse_args(list(args))

if len(args_without_options) == 0:
return cmd, args

name, sub_cmd, sub_args = cmd.resolve_command(ctx, args_without_options)
if isinstance(sub_cmd, click.MultiCommand) and len(sub_args) > 0:
sub_ctx = sub_cmd.make_context(name, sub_args, parent=ctx)
return get_leaf_command(sub_cmd, sub_ctx, sub_args)

return sub_cmd, sub_args


class IgnoreRequiredForHelp(click.Group):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ignoring = False

def _is_defaulting_to_help(self, ctx, args):
try:
leaf_cmd, leaf_args = get_leaf_command(self, ctx, args)

if leaf_cmd is None or leaf_cmd is self:
return False

return (
"--help" in leaf_args
or (isinstance(leaf_cmd, click.MultiCommand) and not leaf_cmd.invoke_without_command)
or (leaf_cmd.no_args_is_help and len(leaf_args) == 0)
)
except click.exceptions.UsageError:
return False

def parse_args(self, ctx, args):
if self._is_defaulting_to_help(ctx, args):
self.ignoring = True
for param in self.params:
param.required = False

return super().parse_args(ctx, args)

def format_commands(self, ctx, formatter) -> None:
commands = []
for subcommand in self.list_commands(ctx):
cmd = self.get_command(ctx, subcommand)
if cmd is None or cmd.hidden:
continue

commands.append((subcommand, cmd))

if len(commands) > 0:
limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)

rows = []
for subcommand, cmd in commands:
if not isinstance(cmd, click.MultiCommand):
cmd_help = cmd.get_short_help_str(limit)
rows.append((subcommand, cmd_help))
continue
for subsubcommand in cmd.list_commands(ctx):
subcmd = cmd.get_command(ctx, subsubcommand)
if subcmd is None or subcmd.hidden:
continue
cmd_help = subcmd.get_short_help_str(limit)
rows.append((f"{subcommand} {subsubcommand}", cmd_help))

if rows:
with formatter.section("Commands"):
formatter.write_dl(rows)


try:
_version = version("pvecontrol")
except PackageNotFoundError:
_version = "unknown"


@click.group(
cls=IgnoreRequiredForHelp,
help=f"Proxmox Backup Server control CLI, version: {_version}",
epilog="Made with love by Enix.io",
)
@click.option("-d", "--debug", is_flag=True)
@click.option(
"-o",
"--output",
type=click.Choice([o.value for o in OutputFormats]),
show_default=True,
default=OutputFormats.TEXT.value,
callback=lambda *v: OutputFormats(v[2]),
)
@click.option(
"-s",
"--server",
metavar="NAME",
envvar="SERVER",
required=True,
help="Proxmox Backup Server name as defined in configuration",
)
@click.option(
"--unicode/--no-unicode",
envvar="UNICODE",
default=True,
help="Use unicode characters for output",
)
@click.option(
"--color/--no-color",
envvar="COLOR",
default=True,
help="Use colorized output",
)
@click.pass_context
def pbscontrol(ctx, debug, output, server, unicode, color):
signal.signal(signal.SIGINT, lambda *_: sys.exit(130))
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

if not ctx.command.ignoring:
args = SimpleNamespace(output=output, server=server, unicode=unicode, color=color)

logging.basicConfig(encoding="utf-8", level=logging.DEBUG if debug else logging.INFO)
logging.debug("Arguments: %s", args)

ctx.ensure_object(dict)
ctx.obj["args"] = args


pbscontrol.add_command(cmd=actions.server.status, name="status")


def main():
# pylint: disable=no-value-for-parameter
pbscontrol(auto_envvar_prefix="PBSCONTROL")


if __name__ == "__main__":
sys.exit(main())
4 changes: 4 additions & 0 deletions src/pbscontrol/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import pbscontrol

if __name__ == "__main__":
pbscontrol.main()
1 change: 1 addition & 0 deletions src/pbscontrol/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from pbscontrol.actions import server
40 changes: 40 additions & 0 deletions src/pbscontrol/actions/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import click

from humanize import naturalsize

from pbscontrol.models.server import PBSServer
from pvecontrol.utils import OutputFormats, render_output


@click.command()
@click.pass_context
def status(ctx):
"""Show Proxmox Backup Server status"""
pbs = PBSServer.create_from_config(ctx.obj["args"].server)
usage = pbs.datastore_usage

if ctx.obj["args"].output == OutputFormats.TEXT:
print(f"""\n\
Name: {pbs.name}
Version: {pbs.version.get('version', 'unknown')}
Datastores:""")
for ds in usage:
total = naturalsize(ds["total"], binary=True, format="%.2f")
used = naturalsize(ds["used"], binary=True, format="%.2f")
avail = naturalsize(ds["avail"], binary=True, format="%.2f")
percent = ds["used"] / ds["total"] * 100 if ds["total"] else 0
print(f" {ds['store']}: {used}/{total} ({percent:.1f}%), available: {avail}, gc: {ds['gc-status']}")
print()
else:
render_table = [
{
"store": ds["store"],
"total": ds["total"],
"used": ds["used"],
"avail": ds["avail"],
"percent": round(ds["used"] / ds["total"] * 100 if ds["total"] else 0, 1),
"gc-status": ds["gc-status"],
}
for ds in usage
]
print(render_output(render_table, output=ctx.obj["args"].output))
50 changes: 50 additions & 0 deletions src/pbscontrol/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import sys
import confuse

configtemplate = {
"servers": confuse.Sequence( # pylint: disable=abstract-class-instantiated
{
"name": str,
"host": str,
"user": str,
"password": confuse.Optional(str, None),
"token_name": confuse.Optional(str, None),
"token_value": confuse.Optional(str, None),
"proxy_certificate": confuse.Optional(
confuse.OneOf(
[
str,
{
"cert": str,
"key": str,
},
]
),
None,
),
"port": confuse.Optional(int, default=8007),
"timeout": confuse.Optional(int, default=60),
"ssl_verify": confuse.Optional(bool, False),
}
),
}


config = confuse.LazyConfig("pbscontrol", __name__)


def set_config(server_name):
validconfig = config.get(configtemplate)
logging.debug("configuration is %s", validconfig)

serverconfig = False
for s in validconfig.servers:
if s.name == server_name:
serverconfig = s
if not serverconfig:
logging.error('No such server "%s"', server_name)
sys.exit(1)
logging.debug("serverconfig is %s", serverconfig)

return serverconfig
Empty file.
Loading
Loading