init
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user