Files
pages/internal/clicmd/deploy.go
T
2026-08-15 07:13:00 +00:00

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 ""
}