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