package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// manifest describes the files to copy and how to start and stop the service.
type manifest struct {
// name is populated using the name of the file
name string
// Files to rsync, copy, chmod and chown. By default partial paths will
// be placed relative to your ssh user's home directory, but you can
// override this in the Default section below.
Files map[string]fileOpts `json:"files"`
// Stop the service. This will run after rsync but before anything
// else (mv, chown, start, etc.), so this is a good place to setup
// needed resources, such as a new user account if not using your ssh
// login user.
Stop []string `json:"stop"`
// Start the service using this series of steps.
Start []string `json:"start"`
// Vars defined here will be replaced using the `$substitution` syntax
// from shell. Vars may be used in any of the other keys or values.
Vars map[string]string `json:"vars"`
// Default for generating the script allows you to override cup's
// default settings.
Default struct {
// Remote by default is equivalent to your ssh user's
// `$HOME/$MANIFEST_NAME`. You may want to override this if
// your ssh user will not be the user running your service.
Remote string `json:"remote"`
// User by default is $UP_USER. Override this if the user
// running your service does not match your ssh user.
User string `json:"user"`
// Group by default is the $user. Override this if you want to
// chown files by default to a different group than $user.
Group string `json:"group"`
// SSH command to run. Use $cmd to indicate where the command
// should go, such as:
//
// "ssh $user@$server $command"
//
// The command will be safely quoted for you.
SSH string `json:"ssh"`
// Rsync command to run. The default value of this changes
// depending on the OS. OpenBSD prefers openrsync, whereas
// other operating systems use rsync directly. Use $files to
// indicate where the files should go, such as:
//
// "rsync -chazP --del $files $user@$server"
Rsync string `json:"rsync"`
// Mv command to move files and folders. Generally this is in
// the form of `sudo mv` or `doas cp -R`.
Mv string `json:"mv"`
// Chown command to change ownership of files and folders.
// Generally this in the form of `sudo chown -R`
Chown string `json:"chown"`
// Chmod command to change permission bits of files and
// folders. Generally this in the form of `sudo chmod -R`
Chmod string `json:"chmod"`
// Mkdir command to create a directory and all needed
// subdirectories. Generally this is in the form of
// `sudo mkdir -p`
Mkdir string `json:"mkdir"`
} `json:"default"`
}
type fileOpts struct {
Remote string
Mod string
Own string
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// sshCommand returns an ssh command capable of running as-is.
func sshCommand(base, cmd string) (string, error) {
const replace = "$command"
if !strings.Contains(base, replace) {
return "", fmt.Errorf("missing %s", replace)
}
return strings.Replace(base, replace, cmd, 1), nil
}
// rsync returns an rsync command capable of running as-is. Base is the rsync
// command itself to run, name is the name of the manifest file.
func rsync(base, name string, files []string) (string, error) {
const (
replace1 = "$files"
replace2 = "$app"
)
if !strings.Contains(base, replace1) {
return "", fmt.Errorf("missing %s", replace1)
}
if !strings.Contains(base, replace2) {
return "", fmt.Errorf("missing %s", replace2)
}
for i, f := range files {
files[i] = fmt.Sprintf("%q", f)
}
out := strings.NewReplacer(
replace1, strings.Join(files, " "),
replace2, name,
).Replace(base)
return out, nil
}
func defaultRemote(name string) string {
return filepath.Join("/", "home", "$user", name)
}
func run() error {
name := flag.String("f", "manifest.json", "manifest filepath")
verbose := flag.Bool("v", false, "verbose upfile for debugging")
flag.Parse()
// Parse manifest.json
fi, err := os.Open(*name)
if err != nil {
return fmt.Errorf("open manifest: %w", err)
}
defer fi.Close()
var man manifest
if err := json.NewDecoder(fi).Decode(&man); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
// Set defaults per the documentation
man.name = strings.TrimSuffix(filepath.Base(*name), filepath.Ext(*name))
if man.Default.User == "" {
man.Default.User = "$UP_USER"
}
if man.Default.Group == "" {
man.Default.Group = "$UP_USER"
}
if man.Default.Remote == "" {
man.Default.Remote = defaultRemote(man.name)
}
if man.Default.SSH == "" {
man.Default.SSH = "ssh $user@$server $command"
}
if man.Default.Rsync == "" {
man.Default.Rsync = "rsync -chazP --del --chmod=700 $files $user@$server:$app/"
}
if man.Default.Mv == "" {
// `cp -R` is used in place of `mv` because it dramatically
// speeds up rsync transfers, since the files are left in
// place. This will accumulate "cruft" in your user's remote
// directory which you may want to clear out.
man.Default.Mv = "sudo cp -R"
}
if man.Default.Chown == "" {
man.Default.Chown = "sudo chown -R"
}
if man.Default.Chmod == "" {
man.Default.Chmod = "sudo chmod -R"
}
if man.Default.Mkdir == "" {
man.Default.Mkdir = "sudo mkdir -p"
}
// Allow use of several vars if not already set explicitly in the
// manifest
if man.Vars["user"] == "" {
man.Vars["user"] = man.Default.User
}
if man.Vars["remote"] == "" {
man.Vars["remote"] = man.Default.Remote
}
if man.Vars["manifest"] == "" {
man.Vars["manifest"] = man.name
}
// Add lines to the Upfile to stop the service, sync all files, then
// start it again. We stop before transferring to allow for safely
// using `rsync --del` and to prevent "Text file busy" errors when
// overwriting files which are being used by a running process.
var script string
for _, line := range man.Stop {
addLine(*verbose, &script, line, man.Default.SSH)
}
// Ensure we make each directory if it doesn't exist, but only once
mkdirs := map[string]struct{}{}
for file, opts := range man.Files {
if opts.Remote == "" || !filepath.IsAbs(opts.Remote) {
opts.Remote = filepath.Join(man.Default.Remote,
filepath.Base(file))
}
remoteDir := filepath.Dir(opts.Remote)
if remoteDir == "." {
continue
}
if _, ok := mkdirs[remoteDir]; ok {
continue
}
mkdirs[remoteDir] = struct{}{}
mkdir := fmt.Sprintf("%s %q", man.Default.Mkdir, remoteDir)
addLine(*verbose, &script, mkdir, man.Default.SSH)
}
// Ensure the remote directory is owned by the user
line := fmt.Sprintf("%s $user:$user %q", man.Default.Chown,
man.Default.Remote)
addLine(*verbose, &script, line, man.Default.SSH)
// Generate a script to cp, chmod, chown files. It's very important the
// copying happens first, since we only want to update permissions on
// the remote files.
var files []string
for file, opts := range man.Files {
files = append(files, file)
// Ensure we move the file to the correct location based on our
// default remote location if we haven't specified a remote
// explicitly.
var skipCopy bool
isDefaultRemote := man.Default.Remote == defaultRemote(man.name)
if opts.Remote == "" && isDefaultRemote {
skipCopy = true
}
mvRemote := man.Default.Remote
if opts.Remote != "" {
if filepath.IsAbs(opts.Remote) {
mvRemote = opts.Remote
} else {
mvRemote = filepath.Join(man.Default.Remote,
opts.Remote)
}
}
if !filepath.IsAbs(opts.Remote) {
remoteFile := file
if opts.Remote != "" {
remoteFile = opts.Remote
}
opts.Remote = filepath.Join(man.Default.Remote,
filepath.Base(remoteFile))
}
if opts.Own == "" {
opts.Own = fmt.Sprintf("%s:%s", man.Default.User,
man.Default.User)
}
if !skipCopy {
fileShort := filepath.Join(man.name,
filepath.Base(file))
line := fmt.Sprintf("%s %q %q", man.Default.Mv,
fileShort, mvRemote)
addLine(*verbose, &script, line, man.Default.SSH)
}
if opts.Mod != "" && man.Default.User != "" {
line := fmt.Sprintf("%s %s %q", man.Default.Chmod,
opts.Mod, opts.Remote)
addLine(*verbose, &script, line, man.Default.SSH)
}
line := fmt.Sprintf("%s %s %q", man.Default.Chown, opts.Own,
opts.Remote)
addLine(*verbose, &script, line, man.Default.SSH)
}
var lines []string
if len(files) > 0 {
line, err := rsync(man.Default.Rsync, man.name, files)
if err != nil {
return fmt.Errorf("rsync: %w", err)
}
lines = append(lines, line)
}
for _, line := range man.Start {
addLine(*verbose, &script, line, man.Default.SSH)
}
// Add to upfile a line to perform the above steps using ssh
if script != "" && !*verbose {
// Ensure we escape double-quotes in our SSH commands
cmd := fmt.Sprintf("%q", script)
script = strings.Replace(man.Default.SSH, "$command", cmd, 1)
}
lines = append(lines, script)
script = strings.Join(lines, "\n\t")
upfile := fmt.Sprintf(`%s:
%s`, man.name, script)
// Replace vars. Sort them by longest to shortest to prevent replacing
// the wrong string. e.g. If both "$app" and "$app_dir" are defined,
// then $app_dir must be replaced first.
var i int
sortedVars := make([]string, len(man.Vars))
for k := range man.Vars {
sortedVars[i] = k
i++
}
sort.Slice(sortedVars, func(i, j int) bool {
return len(sortedVars[j]) < len(sortedVars[i])
})
// Now that we have vars sorted by their lengths, we can assemble our
// replacer
i = 0
replacements := make([]string, len(man.Vars)*2)
for _, k := range sortedVars {
replacements[i] = "$" + k
replacements[i+1] = man.Vars[k]
i += 2
}
replacer := strings.NewReplacer(replacements...)
// Recursively replace vars within a reasonable limit
prev := upfile
for i := 0; i < 128; i++ {
upfile = replacer.Replace(upfile)
if prev == upfile {
break
}
prev = upfile
}
fmt.Println(upfile)
return nil
}
func addLine(verbose bool, script *string, line, ssh string) {
if verbose {
// Ensure we escape double-quotes in our SSH commands
line = fmt.Sprintf("%q", line)
line = strings.Replace(ssh, "$command", line, 1)
}
if *script == "" {
*script = line
return
}
if verbose {
*script = fmt.Sprintf("%s\n\t%s", *script, line)
} else {
*script = fmt.Sprintf("%s && %s", *script, line)
}
}