diff --git a/docs/advanced-usage/docker-options.md b/docs/advanced-usage/docker-options.md index ae4039d3a..2fcabcb6e 100644 --- a/docs/advanced-usage/docker-options.md +++ b/docs/advanced-usage/docker-options.md @@ -179,6 +179,25 @@ A machine-readable JSON view is available via `--format json`: dokku docker-options:report node-js-app --format json ``` +The JSON report includes the existing string keys (`build`, `deploy`, `run`, and `deploy.` when configured) plus parallel `-list` keys for the same shorthand keys. Each `-list` value is a JSON array with one element per option as originally stored via `docker-options:add`, which allows export tooling to round-trip options that contain spaces without splitting the legacy space-joined strings. Empty phases emit an empty array (`[]`). The deprecated `docker-options-*` prefixed keys are unchanged and do not gain `-list` companions. + +```json +{ + "build": "", + "build-list": [], + "deploy": "-v /logs:/logs --memory=512m", + "deploy-list": ["-v /logs:/logs", "--memory=512m"], + "run": "", + "run-list": [], + "deploy.web": "-p 8080:5000", + "deploy.web-list": ["-p 8080:5000"], + "docker-options-build": "", + "docker-options-deploy": "-v /logs:/logs --memory=512m", + "docker-options-run": "", + "docker-options-deploy.web": "-p 8080:5000" +} +``` + ### Process-Specific Options > [!IMPORTANT] diff --git a/plugins/docker-options/report.go b/plugins/docker-options/report.go index 5640e7d60..b389fb486 100644 --- a/plugins/docker-options/report.go +++ b/plugins/docker-options/report.go @@ -1,12 +1,15 @@ package dockeroptions import ( + "encoding/json" "fmt" "strings" "github.com/dokku/dokku/plugins/common" ) +const dockerOptionsReportType = "docker-options" + // ReportSingleApp displays the docker options report for a single app. // Default-scope options are reported under fixed keys for each phase. // Process-scoped options surface as dynamic per-process keys, one per @@ -47,8 +50,32 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { } infoFlags := common.CollectReport(appName, infoFlag, flags) + + if format == "" { + format = "stdout" + } + if format != "stdout" && format != "json" { + return fmt.Errorf("Format must be \"stdout\" or \"json\": %q", format) + } + if format == "json" && infoFlag != "" { + return fmt.Errorf("--format flag cannot be specified when specifying an info flag") + } + + if format == "json" { + data, err := buildJSONReportData(appName, infoFlags) + if err != nil { + return err + } + out, err := json.Marshal(data) + if err != nil { + return err + } + common.Log(string(out)) + return nil + } + return common.ReportSingleApp(common.ReportSingleAppInput{ - ReportType: "docker-options", + ReportType: dockerOptionsReportType, AppName: appName, InfoFlag: infoFlag, InfoFlags: infoFlags, @@ -60,6 +87,63 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { }) } +// listReportKey returns the JSON key used for the structured list companion to +// a shorthand report key (e.g. "deploy" -> "deploy-list"). +func listReportKey(shorthandKey string) string { + return shorthandKey + "-list" +} + +// buildJSONReportData assembles the docker-options JSON report. Existing string +// keys match common.ReportSingleApp output; shorthand keys also gain parallel +// -list keys whose values are the stored option slices. +func buildJSONReportData(appName string, infoFlags map[string]string) (map[string]any, error) { + data := map[string]any{} + pluginPrefix := fmt.Sprintf("--%s-", dockerOptionsReportType) + for key, value := range infoFlags { + legacyKey := strings.TrimPrefix(key, "--") + if strings.HasPrefix(key, pluginPrefix) { + data[strings.TrimPrefix(key, pluginPrefix)] = value + data[legacyKey] = value + } else { + data[legacyKey] = value + } + } + + if appName == "--global" { + return data, nil + } + + phases := []struct { + phase string + shorthandKey string + }{ + {"build", "build"}, + {"deploy", "deploy"}, + {"run", "run"}, + } + for _, phase := range phases { + options, err := GetDockerOptionsForProcessPhase(appName, DefaultProcessType, phase.phase) + if err != nil { + return nil, err + } + data[listReportKey(phase.shorthandKey)] = options + } + + processTypes, err := ListProcessTypesWithOptions(appName) + if err != nil { + return nil, err + } + for _, processType := range processTypes { + options, err := GetDockerOptionsForProcessPhase(appName, processType, "deploy") + if err != nil { + return nil, err + } + data[listReportKey(fmt.Sprintf("deploy.%s", processType))] = options + } + + return data, nil +} + func reportBuildOptions(appName string) string { return joinProcessPhaseOptions(appName, DefaultProcessType, "build") } diff --git a/plugins/docker-options/report_test.go b/plugins/docker-options/report_test.go new file mode 100644 index 000000000..22fab3691 --- /dev/null +++ b/plugins/docker-options/report_test.go @@ -0,0 +1,161 @@ +package dockeroptions + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dokku/dokku/plugins/common" +) + +func setupReportEnv(t *testing.T, appName string) { + t.Helper() + dokkuRoot := setupMigrationEnv(t) + if err := os.MkdirAll(filepath.Join(dokkuRoot, appName), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } +} + +func TestListReportKey(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"build", "build-list"}, + {"deploy", "deploy-list"}, + {"run", "run-list"}, + {"deploy.web", "deploy.web-list"}, + } + for _, tc := range cases { + if got := listReportKey(tc.in); got != tc.want { + t.Errorf("listReportKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestBuildJSONReportData_IncludesListKeysForShorthandOnly(t *testing.T) { + const appName = "testapp" + setupReportEnv(t, appName) + + if err := common.PropertyListWrite("docker-options", appName, "_default_.deploy", []string{"-v /logs:/logs", "--memory=512m"}); err != nil { + t.Fatalf("PropertyListWrite deploy: %v", err) + } + if err := common.PropertyListWrite("docker-options", appName, "web.deploy", []string{"-p 8080:5000"}); err != nil { + t.Fatalf("PropertyListWrite web deploy: %v", err) + } + + infoFlags := map[string]string{ + "--docker-options-build": "", + "--docker-options-deploy": "-v /logs:/logs --memory=512m", + "--docker-options-run": "", + "--docker-options-deploy.web": "-p 8080:5000", + } + + data, err := buildJSONReportData(appName, infoFlags) + if err != nil { + t.Fatalf("buildJSONReportData: %v", err) + } + + assertStringValue(t, data, "deploy", "-v /logs:/logs --memory=512m") + assertStringValue(t, data, "docker-options-deploy", "-v /logs:/logs --memory=512m") + assertStringSliceValue(t, data, "deploy-list", []string{"-v /logs:/logs", "--memory=512m"}) + assertStringSliceValue(t, data, "build-list", []string{}) + assertStringSliceValue(t, data, "run-list", []string{}) + assertStringSliceValue(t, data, "deploy.web-list", []string{"-p 8080:5000"}) + + if _, ok := data["docker-options-deploy-list"]; ok { + t.Fatal("docker-options-deploy-list should not be present") + } +} + +func TestBuildJSONReportData_PreservesOptionsWithSpaces(t *testing.T) { + const appName = "testapp" + setupReportEnv(t, appName) + + option := `--label 'com.example.description=my app'` + if err := common.PropertyListWrite("docker-options", appName, "_default_.deploy", []string{option, "--cpus=2"}); err != nil { + t.Fatalf("PropertyListWrite deploy: %v", err) + } + + infoFlags := map[string]string{ + "--docker-options-build": "", + "--docker-options-deploy": option + " --cpus=2", + "--docker-options-run": "", + } + + data, err := buildJSONReportData(appName, infoFlags) + if err != nil { + t.Fatalf("buildJSONReportData: %v", err) + } + + assertStringSliceValue(t, data, "deploy-list", []string{option, "--cpus=2"}) +} + +func TestBuildJSONReportData_GlobalReportHasNoListKeys(t *testing.T) { + data, err := buildJSONReportData("--global", map[string]string{}) + if err != nil { + t.Fatalf("buildJSONReportData: %v", err) + } + if len(data) != 0 { + t.Fatalf("global report data = %#v, want empty map", data) + } +} + +func TestBuildJSONReportData_MarshalsEmptyListsAsArrays(t *testing.T) { + const appName = "testapp" + setupReportEnv(t, appName) + + infoFlags := map[string]string{ + "--docker-options-build": "", + "--docker-options-deploy": "", + "--docker-options-run": "", + } + + data, err := buildJSONReportData(appName, infoFlags) + if err != nil { + t.Fatalf("buildJSONReportData: %v", err) + } + + out, err := json.Marshal(data) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + jsonText := string(out) + for _, key := range []string{"build-list", "deploy-list", "run-list"} { + if !strings.Contains(jsonText, `"`+key+`":[]`) { + t.Errorf("json output missing empty array for %q: %s", key, jsonText) + } + } +} + +func assertStringValue(t *testing.T, data map[string]any, key, want string) { + t.Helper() + got, ok := data[key] + if !ok { + t.Fatalf("key %q missing from report data", key) + } + gotString, ok := got.(string) + if !ok { + t.Fatalf("key %q = %#v, want string %q", key, got, want) + } + if gotString != want { + t.Errorf("key %q = %q, want %q", key, gotString, want) + } +} + +func assertStringSliceValue(t *testing.T, data map[string]any, key string, want []string) { + t.Helper() + got, ok := data[key] + if !ok { + t.Fatalf("key %q missing from report data", key) + } + gotSlice, ok := got.([]string) + if !ok { + t.Fatalf("key %q = %#v, want []string %#v", key, got, want) + } + if !equalStringSlice(gotSlice, want) { + t.Errorf("key %q = %#v, want %#v", key, gotSlice, want) + } +} diff --git a/tests/unit/docker-options-2.bats b/tests/unit/docker-options-2.bats index 1f9f558b1..620a8a105 100644 --- a/tests/unit/docker-options-2.bats +++ b/tests/unit/docker-options-2.bats @@ -185,10 +185,68 @@ teardown() { assert_success assert_output_contains '"docker-options-deploy.web"' assert_output_contains '"deploy.web"' - assert_output_contains "-p 8080:5000" 2 + assert_output_contains '"deploy.web-list"' + assert_output_contains "-p 8080:5000" 3 assert_output_contains '"docker-options-deploy"' assert_output_contains '"deploy"' - assert_output_contains "-v /logs:/logs" 2 + assert_output_contains '"deploy-list"' + assert_output_contains "-v /logs:/logs" 3 +} + +@test "(docker-options:report) --format json exposes structured -list keys" { + run /bin/bash -c "dokku docker-options:add $TEST_APP build --build-arg X=Y --link foo --link bar" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add --process web $TEST_APP deploy '-p 8080:5000'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '-v /logs:/logs' '--memory=512m'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy --label 'com.example.description=my app'" + echo "output: $output" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"build-list\" | length == 3'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"deploy-list\" | length == 3'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"deploy-list\" | index(\"--memory=512m\") != null'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"deploy-list\"[] | select(test(\"my app\"))'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"deploy.web-list\"[]'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "-p 8080:5000" + + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e 'has(\"docker-options-deploy-list\") | not'" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(docker-options:report) --format json emits empty -list arrays for unset phases" { + run /bin/bash -c "dokku docker-options:report $TEST_APP --format json | jq -e '.\"build-list\" == [] and .\"deploy-list\" == [] and .\"run-list\" == []'" + echo "output: $output" + echo "status: $status" + assert_success } @test "(docker-options) clone copies default and per-process options" {