Compare commits

..

10 Commits

Author SHA1 Message Date
91a440035d refactor: refactoring of code
All checks were successful
Lint / Run on Ubuntu (push) Successful in 20m9s
2024-12-08 13:39:54 +01:00
1a6e2e4ffc feat: add Database source jdbc uri format 2024-12-08 13:33:32 +01:00
99f76eb5d6 Fix grammar issues in azure.go 2024-12-07 23:58:48 +01:00
324c5df69c fix: SSH storage key identitify file 2024-12-07 20:27:09 +01:00
5cca957009 chore: update notification template 2024-12-07 16:50:08 +01:00
Jonas Kaninda
f7989a865d chore: correct pg name 2024-12-07 03:47:55 +01:00
Jonas Kaninda
a9196e6e51 chore: update .env.example 2024-12-07 03:28:12 +01:00
Jonas Kaninda
b8fabf2bd8 chore: update .env.example 2024-12-07 03:26:45 +01:00
Jonas Kaninda
c644aa60d2 chore: update base image 2024-12-07 02:31:45 +01:00
Jonas Kaninda
1eb57044ad refactor: clean up code 2024-12-07 02:28:16 +01:00
20 changed files with 329 additions and 139 deletions

View File

@@ -15,6 +15,7 @@ TZ=Europe/Paris
### Backup restoration
#FILE_NAME=
### AWS S3 Storage
#ACCESS_KEY=
#SECRET_KEY=
@@ -43,19 +44,30 @@ TZ=Europe/Paris
#FTP_USER=
#FTP_PORT=21
#REMOTE_PATH=
## Azure Blob Storage
AZURE_STORAGE_CONTAINER_NAME=
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
#### Backup encryption
#GPG_PUBLIC_KEY=/config/public_key.asc
#GPG_PRIVATE_KEY=/config/private_key.asc
#GPG_PASSPHRASE=Your strong passphrase
## For multiple database backup on Docker or Docker in Swarm mode
#BACKUP_CONFIG_FILE=/config/config.yaml
### Database restoration
#FILE_NAME=
### Notification
#BACKUP_REFERENCE=K8s/Paris cluster
## Telegram
#TG_TOKEN=
#TG_CHAT_ID=
### Email
#MAIL_HOST=
#MAIL_PORT=

View File

@@ -9,7 +9,7 @@ RUN go mod download
# Build
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-X 'github.com/jkaninda/pg-bkup/utils.Version=${appVersion}'" -o /app/pg-bkup
FROM alpine:3.20.3
FROM alpine:3.21.0
ENV TZ=UTC
ARG WORKDIR="/config"
ARG BACKUPDIR="/backup"

View File

@@ -38,6 +38,8 @@ services:
- DB_NAME=database
- DB_USERNAME=username
- DB_PASSWORD=password
# You can also use JDBC format
#- DB_URL=jdbc:postgresql://postgres:5432/database?user=user&password=password
# pg-bkup container must be connected to the same network with your database
networks:
- web
@@ -67,7 +69,7 @@ services:
# for a list of available releases.
image: jkaninda/pg-bkup
container_name: pg-bkup
command: backup -d database --cron-expression "0 1 * * *"
command: backup -d database --cron-expression @midnight
volumes:
- ./backup:/backup
environment:
@@ -76,7 +78,9 @@ services:
- DB_NAME=database
- DB_USERNAME=username
- DB_PASSWORD=password
- BACKUP_CRON_EXPRESSION=0 1 * * *
- BACKUP_CRON_EXPRESSION=@midnight
# You can also use JDBC format
#- DB_URL=jdbc:postgresql://postgres:5432/database?user=user&password=password
#Delete old backup created more than specified days ago
#- BACKUP_RETENTION_DAYS=7
# pg-bkup container must be connected to the same network with your database

View File

@@ -38,12 +38,16 @@ services:
- DB_NAME=database
- DB_USERNAME=username
- DB_PASSWORD=password
# You can also use JDBC format
#- DB_URL=jdbc:postgresql://postgres:5432/database?user=username&password=password
## Target database
- TARGET_DB_HOST=target-postgres
- TARGET_DB_PORT=5432
- TARGET_DB_NAME=dbname
- TARGET_DB_USERNAME=username
- TARGET_DB_PASSWORD=password
# You can also use JDBC format
#- TARGET_DB_URL=jdbc:postgresql://target-postgres:5432/dbname?user=username&password=password
# mysql-bkup container must be connected to the same network with your database
networks:
- web

View File

@@ -33,12 +33,13 @@ Backup, restore and migrate targets, schedule and retention are configured using
## Environment variables
| Name | Requirement | Description |
|------------------------|---------------------------------------------------------------|-----------------------------------------------------------------|
|------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------|
| DB_PORT | Optional, default 5432 | Database port number |
| DB_HOST | Required | Database host |
| DB_NAME | Optional if it was provided from the -d flag | Database name |
| DB_USERNAME | Required | Database user name |
| DB_PASSWORD | Required | Database password |
| DB_URL | Optional | Database URL in JDBC URI format |
| AWS_ACCESS_KEY | Optional, required for S3 storage | AWS S3 Access Key |
| AWS_SECRET_KEY | Optional, required for S3 storage | AWS S3 Secret Key |
| AWS_BUCKET_NAME | Optional, required for S3 storage | AWS S3 Bucket Name |
@@ -65,10 +66,14 @@ Backup, restore and migrate targets, schedule and retention are configured using
| TARGET_DB_PORT | Optional, required for database migration | Target database port |
| TARGET_DB_NAME | Optional, required for database migration | Target database name |
| TARGET_DB_USERNAME | Optional, required for database migration | Target database username |
| TARGET_DB_URL | Optional | Database URL in JDBC URI format |
| TARGET_DB_PASSWORD | Optional, required for database migration | Target database password |
| TG_TOKEN | Optional, required for Telegram notification | Telegram token (`BOT-ID:BOT-TOKEN`) |
| TG_CHAT_ID | Optional, required for Telegram notification | Telegram Chat ID |
| TZ | Optional | Time Zone |
| AZURE_STORAGE_CONTAINER_NAME | Optional, required for Azure Blob Storage storage | Azure storage container name |
| AZURE_STORAGE_ACCOUNT_NAME | Optional, required for Azure Blob Storage storage | Azure storage account name |
| AZURE_STORAGE_ACCOUNT_KEY | Optional, required for Azure Blob Storage storage | Azure storage account key |
---
## Run in Scheduled mode

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.23.2
require (
github.com/go-mail/mail v2.3.1+incompatible
github.com/jkaninda/encryptor v0.0.0-20241013064832-ed4bd6a1b221
github.com/jkaninda/go-storage v0.1.2
github.com/jkaninda/go-storage v0.1.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1

2
go.sum
View File

@@ -46,6 +46,8 @@ github.com/jkaninda/encryptor v0.0.0-20241013064832-ed4bd6a1b221 h1:AwkCf7el1kze
github.com/jkaninda/encryptor v0.0.0-20241013064832-ed4bd6a1b221/go.mod h1:9F8ZJ+ZXE8DZBo77+aneGj8LMjrYXX6eFUCC/uqZOUo=
github.com/jkaninda/go-storage v0.1.2 h1:d7+TRPjmHXdSqO0wne3KAB8zt9ih8lf5D8aL4n7/Dds=
github.com/jkaninda/go-storage v0.1.2/go.mod h1:zVRnLprBk/9AUz2+za6Y03MgoNYrqKLy3edVtjqMaps=
github.com/jkaninda/go-storage v0.1.3 h1:lEpHVgFLKSvjsi/6tAek96Y07za3vxmsXF2/+jiCMZU=
github.com/jkaninda/go-storage v0.1.3/go.mod h1:zVRnLprBk/9AUz2+za6Y03MgoNYrqKLy3edVtjqMaps=
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=

View File

@@ -1,4 +1,3 @@
// Package main /
/*
MIT License
@@ -22,6 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package main
import "github.com/jkaninda/pg-bkup/cmd"

View File

@@ -10,7 +10,7 @@ import (
)
func azureBackup(db *dbConfig, config *BackupConfig) {
utils.Info("Backup database to the remote FTP server")
utils.Info("Backup database to Azure Blob Storage")
startTime = time.Now().Format(utils.TimeFormat())
// Backup database

View File

@@ -80,7 +80,7 @@ type FTPConfig struct {
host string
user string
password string
port string
port int
remotePath string
}
type AzureConfig struct {
@@ -94,7 +94,7 @@ type SSHConfig struct {
user string
password string
hostName string
port string
port int
identifyFile string
}
type AWSConfig struct {
@@ -109,6 +109,14 @@ type AWSConfig struct {
}
func initDbConfig(cmd *cobra.Command) *dbConfig {
jdbcUri := os.Getenv("DB_URL")
if len(jdbcUri) != 0 {
config, err := convertJDBCToDbConfig(jdbcUri)
if err != nil {
utils.Fatal("Error: %v", err.Error())
}
return config
}
// Set env
utils.GetEnv(cmd, "dbname", "DB_NAME")
dConf := dbConfig{}
@@ -149,7 +157,7 @@ func loadSSHConfig() (*SSHConfig, error) {
user: os.Getenv("SSH_USER"),
password: os.Getenv("SSH_PASSWORD"),
hostName: os.Getenv("SSH_HOST"),
port: os.Getenv("SSH_PORT"),
port: utils.GetIntEnv("SSH_PORT"),
identifyFile: os.Getenv("SSH_IDENTIFY_FILE"),
}, nil
}
@@ -159,7 +167,7 @@ func loadFtpConfig() *FTPConfig {
fConfig.host = utils.GetEnvVariable("FTP_HOST", "FTP_HOST_NAME")
fConfig.user = os.Getenv("FTP_USER")
fConfig.password = os.Getenv("FTP_PASSWORD")
fConfig.port = os.Getenv("FTP_PORT")
fConfig.port = utils.GetIntEnv("FTP_PORT")
fConfig.remotePath = os.Getenv("REMOTE_PATH")
err := utils.CheckEnvVars(ftpVars)
if err != nil {
@@ -293,6 +301,20 @@ func initRestoreConfig(cmd *cobra.Command) *RestoreConfig {
return &rConfig
}
func initTargetDbConfig() *targetDbConfig {
jdbcUri := os.Getenv("TARGET_DB_URL")
if len(jdbcUri) != 0 {
config, err := convertJDBCToDbConfig(jdbcUri)
if err != nil {
utils.Fatal("Error: %v", err.Error())
}
return &targetDbConfig{
targetDbHost: config.dbHost,
targetDbPort: config.dbPort,
targetDbName: config.dbName,
targetDbPassword: config.dbPassword,
targetDbUserName: config.dbUserName,
}
}
tdbConfig := targetDbConfig{}
tdbConfig.targetDbHost = os.Getenv("TARGET_DB_HOST")
tdbConfig.targetDbPort = utils.EnvWithDefault("TARGET_DB_PORT", "5432")

View File

@@ -29,6 +29,7 @@ import (
"fmt"
"github.com/jkaninda/pg-bkup/utils"
"gopkg.in/yaml.v3"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -36,7 +37,7 @@ import (
)
func intro() {
fmt.Println("Starting PosgreSQL Backup...")
fmt.Println("Starting PostgresSQL Backup...")
fmt.Printf("Version: %s\n", utils.Version)
fmt.Println("Copyright (c) 2024 Jonas Kaninda")
}
@@ -69,6 +70,9 @@ func deleteTemp() {
func testDatabaseConnection(db *dbConfig) {
utils.Info("Connecting to %s database ...", db.dbName)
// Set database name for notification error
utils.DatabaseName = db.dbName
// Test database connection
query := "SELECT version();"
@@ -193,3 +197,34 @@ func RemoveLastExtension(filename string) string {
}
return filename
}
func convertJDBCToDbConfig(jdbcURI string) (*dbConfig, error) {
// Remove the "jdbc:" prefix
jdbcURI = strings.TrimPrefix(jdbcURI, "jdbc:")
// Parse the URI
u, err := url.Parse(jdbcURI)
if err != nil {
return &dbConfig{}, fmt.Errorf("failed to parse JDBC URI: %v", err)
}
// Extract components
host := u.Hostname()
port := u.Port()
if port == "" {
port = "5432" // Default PostgreSQL port
}
database := strings.TrimPrefix(u.Path, "/")
params, _ := url.ParseQuery(u.RawQuery)
username := params.Get("user")
password := params.Get("password")
// Validate essential fields
if host == "" || database == "" || username == "" {
return &dbConfig{}, fmt.Errorf("incomplete JDBC URI: missing host, database, or username")
}
return &dbConfig{
dbHost: host,
dbPort: port,
dbName: database,
dbUserName: username,
dbPassword: password,
}, nil
}

View File

@@ -56,6 +56,7 @@ func sshBackup(db *dbConfig, config *BackupConfig) {
Port: sshConfig.port,
User: sshConfig.user,
Password: sshConfig.password,
IdentifyFile: sshConfig.identifyFile,
RemotePath: config.remotePath,
LocalPath: tmpPath,
})
@@ -103,6 +104,51 @@ func sshBackup(db *dbConfig, config *BackupConfig) {
utils.Info("Backup completed successfully")
}
func remoteRestore(db *dbConfig, conf *RestoreConfig) {
utils.Info("Restore database from remote server")
sshConfig, err := loadSSHConfig()
if err != nil {
utils.Fatal("Error loading ssh config: %s", err)
}
sshStorage, err := ssh.NewStorage(ssh.Config{
Host: sshConfig.hostName,
Port: sshConfig.port,
User: sshConfig.user,
Password: sshConfig.password,
IdentifyFile: sshConfig.identifyFile,
RemotePath: conf.remotePath,
LocalPath: tmpPath,
})
if err != nil {
utils.Fatal("Error creating SSH storage: %s", err)
}
err = sshStorage.CopyFrom(conf.file)
if err != nil {
utils.Fatal("Error copying backup file: %s", err)
}
RestoreDatabase(db, conf)
}
func ftpRestore(db *dbConfig, conf *RestoreConfig) {
utils.Info("Restore database from FTP server")
ftpConfig := loadFtpConfig()
ftpStorage, err := ftp.NewStorage(ftp.Config{
Host: ftpConfig.host,
Port: ftpConfig.port,
User: ftpConfig.user,
Password: ftpConfig.password,
RemotePath: conf.remotePath,
LocalPath: tmpPath,
})
if err != nil {
utils.Fatal("Error creating SSH storage: %s", err)
}
err = ftpStorage.CopyFrom(conf.file)
if err != nil {
utils.Fatal("Error copying backup file: %s", err)
}
RestoreDatabase(db, conf)
}
func ftpBackup(db *dbConfig, config *BackupConfig) {
utils.Info("Backup database to the remote FTP server")
startTime = time.Now().Format(utils.TimeFormat())
@@ -169,48 +215,3 @@ func ftpBackup(db *dbConfig, config *BackupConfig) {
deleteTemp()
utils.Info("Backup completed successfully")
}
func remoteRestore(db *dbConfig, conf *RestoreConfig) {
utils.Info("Restore database from remote server")
sshConfig, err := loadSSHConfig()
if err != nil {
utils.Fatal("Error loading ssh config: %s", err)
}
sshStorage, err := ssh.NewStorage(ssh.Config{
Host: sshConfig.hostName,
Port: sshConfig.port,
User: sshConfig.user,
Password: sshConfig.password,
IdentifyFile: sshConfig.identifyFile,
RemotePath: conf.remotePath,
LocalPath: tmpPath,
})
if err != nil {
utils.Fatal("Error creating SSH storage: %s", err)
}
err = sshStorage.CopyFrom(conf.file)
if err != nil {
utils.Fatal("Error copying backup file: %s", err)
}
RestoreDatabase(db, conf)
}
func ftpRestore(db *dbConfig, conf *RestoreConfig) {
utils.Info("Restore database from FTP server")
ftpConfig := loadFtpConfig()
ftpStorage, err := ftp.NewStorage(ftp.Config{
Host: ftpConfig.host,
Port: ftpConfig.port,
User: ftpConfig.user,
Password: ftpConfig.password,
RemotePath: conf.remotePath,
LocalPath: tmpPath,
})
if err != nil {
utils.Fatal("Error creating SSH storage: %s", err)
}
err = ftpStorage.CopyFrom(conf.file)
if err != nil {
utils.Fatal("Error copying backup file: %s", err)
}
RestoreDatabase(db, conf)
}

View File

@@ -1,4 +1,3 @@
// Package internal /
/*
MIT License

View File

@@ -1,18 +1,69 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>🔴 Urgent: Database Backup Failure Notification</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔴 Urgent: Database Backup Failure</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f8f9fa;
color: #333;
margin: 0;
padding: 20px;
}
h2 {
color: #d9534f;
}
.details {
background-color: #ffffff;
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
margin-top: 10px;
}
.details ul {
list-style-type: none;
padding: 0;
}
.details li {
margin: 5px 0;
}
a {
color: #0275d8;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
footer {
margin-top: 20px;
font-size: 0.9em;
color: #6c757d;
}
</style>
</head>
<body>
<h2>Hi,</h2>
<p>An error occurred during database backup.</p>
<h2>🔴 Urgent: Database Backup Failure Notification</h2>
<p>Dear Team,</p>
<p>An error occurred during the database backup process. Please review the details below and take the necessary actions:</p>
<div class="details">
<h3>Failure Details:</h3>
<ul>
<li>Error Message: {{.Error}}</li>
<li>Date: {{.EndTime}}</li>
<li>Backup Reference: {{.BackupReference}} </li>
<li><strong>Database Name:</strong> {{.DatabaseName}}</li>
<li><strong>Error Message:</strong> {{.Error}}</li>
<li><strong>Date:</strong> {{.EndTime}}</li>
<li><strong>Backup Reference:</strong> {{.BackupReference}}</li>
</ul>
<p>©2024 <a href="https://github.com/jkaninda/pg-bkup">pg-bkup</a></p>
</div>
<p>We recommend investigating the issue as soon as possible to prevent potential data loss or service disruptions.</p>
<p>For more information, visit the <a href="https://jkaninda.github.io/pg-bkup">pg-bkup documentation</a>.</p>
<footer>
&copy; 2024 <a href="https://github.com/jkaninda/pg-bkup">pg-bkup</a> | Automated Backup System
</footer>
</body>
</html>

View File

@@ -1,23 +1,70 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>✅ Database Backup Notification {{.Database}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>✅ Database Backup Successful {{.Database}}</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f8f9fa;
color: #333;
margin: 0;
padding: 20px;
}
h2 {
color: #5cb85c;
}
.details {
background-color: #ffffff;
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
margin-top: 10px;
}
.details ul {
list-style-type: none;
padding: 0;
}
.details li {
margin: 5px 0;
}
a {
color: #0275d8;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
footer {
margin-top: 20px;
font-size: 0.9em;
color: #6c757d;
}
</style>
</head>
<body>
<h2>Hi,</h2>
<p>Backup of the {{.Database}} database has been successfully completed on {{.EndTime}}.</p>
<h2>✅ Database Backup Successful</h2>
<p>Dear Team,</p>
<p>The backup process for the <strong>{{.Database}}</strong> database was successfully completed. Please find the details below:</p>
<div class="details">
<h3>Backup Details:</h3>
<ul>
<li>Database Name: {{.Database}}</li>
<li>Backup Start Time: {{.StartTime}}</li>
<li>Backup End Time: {{.EndTime}}</li>
<li>Backup Storage: {{.Storage}}</li>
<li>Backup Location: {{.BackupLocation}}</li>
<li>Backup Size: {{.BackupSize}} bytes</li>
<li>Backup Reference: {{.BackupReference}} </li>
<li><strong>Database Name:</strong> {{.Database}}</li>
<li><strong>Backup Start Time:</strong> {{.StartTime}}</li>
<li><strong>Backup End Time:</strong> {{.EndTime}}</li>
<li><strong>Backup Storage:</strong> {{.Storage}}</li>
<li><strong>Backup Location:</strong> {{.BackupLocation}}</li>
<li><strong>Backup Size:</strong> {{.BackupSize}} bytes</li>
<li><strong>Backup Reference:</strong> {{.BackupReference}}</li>
</ul>
<p>Best regards,</p>
<p>©2024 <a href="https://github.com/jkaninda/pg-bkup">pg-bkup</a></p>
</div>
<p>You can access the backup at the specified location if needed. Thank you for using <a href="https://jkaninda.github.io/pg-bkup/">pg-bkup</a>.</p>
<footer>
&copy; 2024 <a href="https://github.com/jkaninda/pg-bkup">pg-bkup</a> | Automated Backup System
</footer>
</body>
</html>

View File

@@ -1,8 +1,11 @@
🔴 Urgent: Database Backup Failure Notification
Hi,
An error occurred during database backup.
Dear Team,
An error occurred during the database backup process.
Please review the details below and take the necessary actions:
Failure Details:
- Database Name: {{.DatabaseName}}
- Date: {{.EndTime}}
- Backup Reference: {{.BackupReference}}
- Error Message: {{.Error}}
We recommend investigating the issue as soon as possible to prevent potential data loss or service disruptions.

View File

@@ -1,6 +1,8 @@
Database Backup Notification {{.Database}}
Hi,
Backup of the {{.Database}} database has been successfully completed on {{.EndTime}}.
✅ Database Backup Successful
Dear Team,
The backup process for the {{.Database}} database was successfully completed.
Please find the details below:
Backup Details:
- Database Name: {{.Database}}
@@ -10,3 +12,5 @@ Backup Details:
- Backup Location: {{.BackupLocation}}
- Backup Size: {{.BackupSize}} bytes
- Backup Reference: {{.BackupReference}}
You can access the backup at the specified location if needed.

View File

@@ -50,6 +50,7 @@ type ErrorMessage struct {
EndTime string
Error string
BackupReference string
DatabaseName string
}
// loadMailConfig gets mail environment variables and returns MailConfig
@@ -81,3 +82,5 @@ func backupReference() string {
}
const templatePath = "/config/templates"
var DatabaseName = ""

View File

@@ -167,6 +167,7 @@ func NotifyError(error string) {
Error: error,
EndTime: time.Now().Format(TimeFormat()),
BackupReference: os.Getenv("BACKUP_REFERENCE"),
DatabaseName: DatabaseName,
}, "email-error.tmpl")
if err != nil {
Error("Could not parse error template: %v", err)
@@ -183,6 +184,7 @@ func NotifyError(error string) {
Error: error,
EndTime: time.Now().Format(TimeFormat()),
BackupReference: os.Getenv("BACKUP_REFERENCE"),
DatabaseName: DatabaseName,
}, "telegram-error.tmpl")
if err != nil {
Error("Could not parse error template: %v", err)

View File

@@ -254,7 +254,3 @@ func CronNextTime(cronExpr string) time.Time {
next := schedule.Next(now)
return next
}
func UsageErrorf(cmd *cobra.Command, message string, args ...interface{}) error {
msg := fmt.Sprintf(message, args...)
return fmt.Errorf("%s\nSee '%s -h' for help and examples", msg, cmd.CommandPath())
}