Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ type ApplyCommand struct {
listTasks bool
startAtTask string
arguments map[string]*Argument

// tasksData caches the recipe bytes read while building the FlagSet (to
// pre-register input flags). Run reuses them instead of reading the
// source a second time, so a --tasks URL is fetched once per
// invocation. tasksDataSource records where those bytes came from; the
// cache is honored only when Run resolves the same source.
tasksData []byte
tasksDataSource string
}

func (c *ApplyCommand) Name() string {
Expand Down Expand Up @@ -72,7 +80,7 @@ func (c *ApplyCommand) ParsedArguments(args []string) (map[string]command.Argume

func (c *ApplyCommand) FlagSet() *flag.FlagSet {
f := c.Meta.FlagSet(c.Name(), command.FlagSetClient)
f.StringVar(&c.tasksFile, "tasks", "", "task file (YAML or JSON5) containing a task list. When omitted, docket probes tasks.yml -> tasks.yaml -> tasks.json in the current directory.")
f.StringVar(&c.tasksFile, "tasks", "", "task file (YAML or JSON5) containing a task list. When omitted, docket probes tasks.yml -> tasks.yaml -> tasks.json in the current directory. An http(s):// URL is fetched over HTTP.")
f.BoolVar(&c.verbose, "verbose", false, "echo the resolved dokku command for each task as a continuation line. Values from inputs declared `sensitive: true` and from task struct fields tagged `sensitive:\"true\"` are masked as `***`. Ignored when --json is set; the JSON output already includes the resolved commands.")
f.BoolVar(&c.json, "json", false, "emit one JSON-lines event per play/task/summary instead of human-readable output. Schema is keyed by `version: 1`; sensitive values mask to `***`.")
f.StringVar(&c.host, "host", "", "remote dokku host as [user@]host[:port]; equivalent to DOKKU_HOST. Routes every dokku invocation through ssh.")
Expand All @@ -87,10 +95,12 @@ func (c *ApplyCommand) FlagSet() *flag.FlagSet {
f.StringVar(&c.startAtTask, "start-at-task", "", "skip every task before the matched name; the matched task and successors run normally. Filter order: --start-at-task -> --tags/--skip-tags -> per-task when: at execution. The name search walks every play in source order, narrowed by --play.")

taskFile, format := resolveTaskFileFromArgs(os.Args)
data, err := os.ReadFile(taskFile)
data, err := readTaskFileData(taskFile)
if err != nil {
return f
}
c.tasksData = data
c.tasksDataSource = taskFile

arguments, err := registerInputFlags(f, data, format)
if err != nil {
Expand Down Expand Up @@ -152,10 +162,15 @@ func (c *ApplyCommand) Run(args []string) int {
c.tasksFile = resolvedPath
c.tasksFormat = resolvedFormat

data, err := os.ReadFile(c.tasksFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("read error: %v", err))
return 1
// Reuse the bytes FlagSet already read for this source (see tasksData);
// for a --tasks URL this avoids a second HTTP fetch of the same recipe.
data := c.tasksData
if data == nil || c.tasksDataSource != c.tasksFile {
data, err = readTaskFileData(c.tasksFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("read error: %v", err))
return 1
}
}

context := make(map[string]interface{})
Expand Down
36 changes: 36 additions & 0 deletions commands/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,42 @@ func TestInitNameFlagSetsAppDefault(t *testing.T) {
}
}

// TestInitNameSetsPlayName: --name names the scaffolded play (not just the
// app input default) so `docket apply --play <name>` resolves after init.
// Every template variant is checked because the play name is what the
// documented `--name` behaviour promises.
func TestInitNameSetsPlayName(t *testing.T) {
cases := []struct {
label string
opts initOptions
format string
context map[string]interface{}
}{
{"yaml-default", initOptions{Name: "web"}, tasks.FormatYAML, map[string]interface{}{"app": "web", "repo": "https://example.com/r.git"}},
{"yaml-minimal", initOptions{Name: "web", Minimal: true}, tasks.FormatYAML, nil},
{"json5-default", initOptions{Name: "web", Format: tasks.FormatNameJSON5}, tasks.FormatNameJSON5, map[string]interface{}{"app": "web", "repo": "https://example.com/r.git"}},
{"json5-minimal", initOptions{Name: "web", Minimal: true, Format: tasks.FormatNameJSON5}, tasks.FormatNameJSON5, nil},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
out, err := renderInit(tc.opts)
if err != nil {
t.Fatalf("renderInit: %v", err)
}
plays, err := tasks.GetPlaysWithFormat(out, tc.format, tc.context, nil)
if err != nil {
t.Fatalf("GetPlaysWithFormat: %v\n%s", err, out)
}
if len(plays) != 1 {
t.Fatalf("plays = %d, want 1\n%s", len(plays), out)
}
if plays[0].Name != "web" {
t.Errorf("play name = %q, want \"web\"\n%s", plays[0].Name, out)
}
})
}
}

func TestInitRepoFlagSetsRepoDefault(t *testing.T) {
out, err := renderInit(initOptions{Name: "demo", Repo: "git@github.com:foo/bar.git"})
if err != nil {
Expand Down
27 changes: 21 additions & 6 deletions commands/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ type PlanCommand struct {
play string
listTasks bool
arguments map[string]*Argument

// tasksData caches the recipe bytes read while building the FlagSet (to
// pre-register input flags). Run reuses them instead of reading the
// source a second time, so a --tasks URL is fetched once per
// invocation. tasksDataSource records where those bytes came from; the
// cache is honored only when Run resolves the same source.
tasksData []byte
tasksDataSource string
}

func (c *PlanCommand) Name() string {
Expand Down Expand Up @@ -71,7 +79,7 @@ func (c *PlanCommand) ParsedArguments(args []string) (map[string]command.Argumen

func (c *PlanCommand) FlagSet() *flag.FlagSet {
f := c.Meta.FlagSet(c.Name(), command.FlagSetClient)
f.StringVar(&c.tasksFile, "tasks", "", "task file (YAML or JSON5) containing a task list. When omitted, docket probes tasks.yml -> tasks.yaml -> tasks.json in the current directory.")
f.StringVar(&c.tasksFile, "tasks", "", "task file (YAML or JSON5) containing a task list. When omitted, docket probes tasks.yml -> tasks.yaml -> tasks.json in the current directory. An http(s):// URL is fetched over HTTP.")
f.BoolVar(&c.json, "json", false, "emit one JSON-lines event per play/task/summary instead of human-readable output. Schema is keyed by `version: 1`; sensitive values mask to `***`.")
f.BoolVar(&c.detailedExitCode, "detailed-exitcode", false, "exit 0 when no drift is detected, 2 when drift is detected, 1 on error. Without this flag plan exits 0 regardless of drift.")
f.StringVar(&c.host, "host", "", "remote dokku host as [user@]host[:port]; equivalent to DOKKU_HOST. Routes every dokku invocation through ssh.")
Expand All @@ -84,10 +92,12 @@ func (c *PlanCommand) FlagSet() *flag.FlagSet {
f.BoolVar(&c.listTasks, "list-tasks", false, "print the resolved task plan and exit without contacting the server. Honors --play / --tags / --skip-tags and shows expanded loop iterations and [skipped] markers for when:-skipped tasks.")

taskFile, format := resolveTaskFileFromArgs(os.Args)
data, err := os.ReadFile(taskFile)
data, err := readTaskFileData(taskFile)
if err != nil {
return f
}
c.tasksData = data
c.tasksDataSource = taskFile

arguments, err := registerInputFlags(f, data, format)
if err != nil {
Expand Down Expand Up @@ -161,10 +171,15 @@ func (c *PlanCommand) Run(args []string) int {
c.tasksFile = resolvedPath
c.tasksFormat = resolvedFormat

data, err := os.ReadFile(c.tasksFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("read error: %v", err))
return 1
// Reuse the bytes FlagSet already read for this source (see tasksData);
// for a --tasks URL this avoids a second HTTP fetch of the same recipe.
data := c.tasksData
if data == nil || c.tasksDataSource != c.tasksFile {
data, err = readTaskFileData(c.tasksFile)
if err != nil {
c.Ui.Error(fmt.Sprintf("read error: %v", err))
return 1
}
}

context := make(map[string]interface{})
Expand Down
70 changes: 67 additions & 3 deletions commands/task_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package commands
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)

// Task file format identifiers used throughout the commands package and
Expand All @@ -27,17 +31,77 @@ var defaultTaskFileCandidates = []string{"tasks.yml", "tasks.yaml", "tasks.json"
// detectTaskFileFormat returns "json5" when path's extension is .json or
// .json5 (case-insensitive), and "yaml" otherwise. Unknown extensions
// default to YAML so explicit paths like `--tasks recipe.txt` keep the
// pre-#218 behaviour. HTTP URLs and other non-filesystem paths flow
// through filepath.Ext just fine because they still carry an extension.
// pre-#218 behaviour. For an http(s) URL the format is taken from the URL
// path component so a trailing query string or fragment
// (`tasks.json?ref=main`) does not get glued onto the extension.
func detectTaskFileFormat(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
ext := filepath.Ext(path)
if isTaskFileURL(path) {
if u, err := url.Parse(path); err == nil {
ext = filepath.Ext(u.Path)
}
}
switch strings.ToLower(ext) {
case ".json", ".json5":
return taskFileFormatJSON5
default:
return taskFileFormatYAML
}
}

// taskFileFetchTimeout bounds a remote recipe fetch so a hung server does
// not stall the whole command.
const taskFileFetchTimeout = 30 * time.Second

// maxTaskFileBytes caps the size of a fetched recipe so a runaway or
// hostile response cannot exhaust memory. Recipes are small; 16 MiB is far
// above any realistic task file.
const maxTaskFileBytes = 16 << 20

// isTaskFileURL reports whether path is an http(s) URL docket should fetch
// over HTTP rather than read from the local filesystem.
func isTaskFileURL(path string) bool {
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
}

// readTaskFileData returns the bytes of the recipe at path. An http(s) URL
// is fetched over HTTP (apply and plan advertise a remote --tasks URL in
// their help); any other value is read from disk so the familiar
// os.ReadFile "no such file" error still surfaces for a mistyped path.
func readTaskFileData(path string) ([]byte, error) {
if isTaskFileURL(path) {
return fetchTaskFileURL(path)
}
return os.ReadFile(path)
}

// fetchTaskFileURL GETs a recipe from an http(s) URL. A transport error, a
// non-2xx response, or a body larger than maxTaskFileBytes is reported as
// an error naming the URL so the read-error message stays actionable.
func fetchTaskFileURL(rawURL string) ([]byte, error) {
client := &http.Client{Timeout: taskFileFetchTimeout}
resp, err := client.Get(rawURL)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", rawURL, err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("fetch %s: unexpected status %s", rawURL, resp.Status)
}

// Read one byte past the cap so an over-limit body is detected rather
// than silently truncated.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTaskFileBytes+1))
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", rawURL, err)
}
if len(data) > maxTaskFileBytes {
return nil, fmt.Errorf("fetch %s: recipe exceeds %d bytes", rawURL, maxTaskFileBytes)
}
return data, nil
}

// hasTaskFileExtension reports whether path carries one of the recipe
// file extensions. Used to spot a positional recipe path in an argv the
// flag parser has not yet processed.
Expand Down
Loading