221 lines
6.7 KiB
Go
221 lines
6.7 KiB
Go
package clicmd
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/iceBear67/simplepages/internal/client"
|
|
"github.com/iceBear67/simplepages/internal/cliutil"
|
|
)
|
|
|
|
// stringList is a flag that may be given more than once.
|
|
type stringList []string
|
|
|
|
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
|
|
|
func (s *stringList) Set(v string) error {
|
|
*s = append(*s, v)
|
|
return nil
|
|
}
|
|
|
|
func deployCmd(g *Globals) *cliutil.Command {
|
|
var (
|
|
activate = true
|
|
meta stringList
|
|
include stringList
|
|
exclude stringList
|
|
concurrency int
|
|
retries int
|
|
follow bool
|
|
dryRun bool
|
|
)
|
|
return &cliutil.Command{
|
|
Name: "deploy",
|
|
Args: "<dir>",
|
|
Short: "Upload a directory and switch the site to it",
|
|
Long: "Hashes the directory, uploads only the files the server does not\n" +
|
|
"already have, then switches the project over in one step — a visitor\n" +
|
|
"sees the old site or the new one, never a mixture.\n\n" +
|
|
"Interrupting a deploy is safe: the blobs that made it are kept, so\n" +
|
|
"running the same command again uploads only what is still missing.\n\n" +
|
|
"Commit metadata is read from the CI environment (GitHub Actions,\n" +
|
|
"GitLab CI) and can be set or overridden with --meta.",
|
|
Flags: func(fs *flag.FlagSet) {
|
|
fs.BoolVar(&activate, "activate", true, "switch the project to this deployment once it is uploaded")
|
|
fs.Var(&meta, "meta", "`key=value` recorded with the deployment; repeatable")
|
|
fs.Var(&include, "include", "only upload files matching `glob`; repeatable")
|
|
fs.Var(&exclude, "exclude", "skip files and directories matching `glob`; repeatable")
|
|
fs.IntVar(&concurrency, "concurrency", 8, "`number` of blobs uploaded at once")
|
|
fs.IntVar(&retries, "retries", 4, "`number` of extra attempts per blob on a transient failure")
|
|
fs.BoolVar(&follow, "follow-symlinks", false, "upload what symlinks point at instead of refusing them")
|
|
fs.BoolVar(&dryRun, "dry-run", false, "scan and report without contacting the server")
|
|
},
|
|
Exec: func(ctx context.Context, args []string) error {
|
|
if err := exactArgs(args, 1, "one directory"); err != nil {
|
|
return err
|
|
}
|
|
dir := args[0]
|
|
|
|
metaMap, err := parseMeta(meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p, err := g.Printer()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
src, err := client.Scan(ctx, dir, client.ScanOptions{
|
|
Include: include,
|
|
Exclude: exclude,
|
|
FollowSymlinks: follow,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
|
|
if dryRun {
|
|
// Deliberately offline: negotiating the manifest to find out what is
|
|
// missing would create a deployment on the server, which is exactly
|
|
// what --dry-run promises not to do.
|
|
return p.Print(dryRunResult{
|
|
Dir: src.Dir,
|
|
FileCount: len(src.Files),
|
|
TotalBytes: src.TotalBytes,
|
|
UniqueBlobs: src.UniqueBlobs(),
|
|
Files: src.Files,
|
|
}, func() *cliutil.Table {
|
|
t := cliutil.NewTable("PATH", "SIZE", "DIGEST")
|
|
for _, f := range src.Files {
|
|
t.Row(f.Path, cliutil.Bytes(f.Size), f.Digest[:12])
|
|
}
|
|
t.Row("", "", "")
|
|
t.Row(fmt.Sprintf("%d files", len(src.Files)),
|
|
cliutil.Bytes(src.TotalBytes),
|
|
fmt.Sprintf("%d unique", src.UniqueBlobs()))
|
|
return t
|
|
})
|
|
}
|
|
|
|
project, err := g.ProjectName()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := g.Client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
res, err := c.Deploy(ctx, client.DeployOptions{
|
|
Project: project,
|
|
Source: src,
|
|
Meta: metaMap,
|
|
Activate: activate,
|
|
Concurrency: concurrency,
|
|
Retries: retries,
|
|
// Progress goes to stderr so it stays out of a piped -o json
|
|
// document, and is shown even without --verbose: the deduplication
|
|
// win is the reason this tool exists, and a CI log should record it.
|
|
Progress: func(msg string) { fmt.Fprintln(g.Err, msg) },
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return p.Print(res, func() *cliutil.Table {
|
|
t := cliutil.NewTable()
|
|
t.Row("deployment", res.Deployment.ID)
|
|
t.Row("project", project)
|
|
t.Row("state", res.Deployment.State)
|
|
t.Row("files", strconv.Itoa(res.FileCount))
|
|
t.Row("total_bytes", cliutil.Bytes(res.TotalBytes))
|
|
t.Row("uploaded", fmt.Sprintf("%s, %s",
|
|
cliutil.Plural(res.Uploaded, "blob"), cliutil.Bytes(res.UploadedBytes)))
|
|
t.Row("reused", strconv.Itoa(res.Deduplicated))
|
|
t.Row("activated", cliutil.Bool(res.Activated))
|
|
t.Row("url", cliutil.Str(res.URL))
|
|
return t
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// dryRunResult is what --dry-run reports: everything decided locally, and
|
|
// nothing that would need the server.
|
|
type dryRunResult struct {
|
|
Dir string `json:"dir"`
|
|
FileCount int `json:"file_count"`
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
UniqueBlobs int `json:"unique_blobs"`
|
|
Files []client.LocalFile `json:"files"`
|
|
}
|
|
|
|
// parseMeta folds --meta over whatever the CI environment reveals, so an
|
|
// explicit flag always wins over a guessed value.
|
|
func parseMeta(pairs []string) (map[string]string, error) {
|
|
out := detectCIMeta()
|
|
for _, p := range pairs {
|
|
k, v, ok := strings.Cut(p, "=")
|
|
if !ok {
|
|
return nil, cliutil.UsageErrorf("--meta %q: expected key=value", p)
|
|
}
|
|
k = strings.TrimSpace(k)
|
|
if k == "" {
|
|
return nil, cliutil.UsageErrorf("--meta %q: empty key", p)
|
|
}
|
|
if out == nil {
|
|
out = make(map[string]string, len(pairs))
|
|
}
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// detectCIMeta reads the commit metadata the common CI systems export, so a
|
|
// deployment can be traced back to what produced it without every pipeline
|
|
// having to spell out the same four --meta flags.
|
|
func detectCIMeta() map[string]string {
|
|
out := make(map[string]string, 4)
|
|
set := func(key string, envs ...string) {
|
|
for _, e := range envs {
|
|
if v := strings.TrimSpace(os.Getenv(e)); v != "" {
|
|
out[key] = v
|
|
return
|
|
}
|
|
}
|
|
}
|
|
set("git_sha", "GITHUB_SHA", "CI_COMMIT_SHA", "GIT_COMMIT")
|
|
set("git_ref", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "GIT_BRANCH")
|
|
set("ci_run", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "BUILD_NUMBER")
|
|
set("actor", "GITHUB_ACTOR", "GITLAB_USER_LOGIN")
|
|
|
|
if url := ciRunURL(); url != "" {
|
|
out["ci_url"] = url
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ciRunURL reconstructs a link back to the job. GitHub does not export one
|
|
// directly; GitLab does.
|
|
func ciRunURL() string {
|
|
if v := strings.TrimSpace(os.Getenv("CI_PIPELINE_URL")); v != "" {
|
|
return v
|
|
}
|
|
server := strings.TrimRight(os.Getenv("GITHUB_SERVER_URL"), "/")
|
|
repo := os.Getenv("GITHUB_REPOSITORY")
|
|
run := os.Getenv("GITHUB_RUN_ID")
|
|
if server != "" && repo != "" && run != "" {
|
|
return server + "/" + repo + "/actions/runs/" + run
|
|
}
|
|
return ""
|
|
}
|