diff --git a/action.yml b/action.yml index f15ca690..2ce53e10 100644 --- a/action.yml +++ b/action.yml @@ -3,19 +3,22 @@ description: Run an API-backed Dependabot job using the CLI's container runner author: Dependabot inputs: api-url: - description: Dependabot API base URL, including any path prefix - required: true + description: Dependabot API base URL, including any path prefix; required unless supplied by a Dependabot dynamic event + required: false + api-docker-url: + description: API base URL for updater and proxy containers; defaults to the dynamic event's Docker URL or api-url + required: false job-id: - description: Dependabot update job ID - required: true + description: Dependabot update job ID; required unless supplied by a Dependabot dynamic event + required: false job-token: - description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN) + description: Job token (alternatively set GITHUB_DEPENDABOT_JOB_TOKEN or use the Dependabot dynamic event) required: false credentials-token: - description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN) + description: Credentials token (alternatively set GITHUB_DEPENDABOT_CRED_TOKEN or use the Dependabot dynamic event) required: false updater-image: - description: Updater image override; otherwise selected from the job's package manager + description: Updater image override; otherwise taken from the dynamic event or selected from the job's package manager required: false proxy-image: description: Proxy image override diff --git a/cmd/dependabot-action/docker_test.go b/cmd/dependabot-action/docker_test.go index 8542a912..fa04316d 100644 --- a/cmd/dependabot-action/docker_test.go +++ b/cmd/dependabot-action/docker_test.go @@ -99,8 +99,16 @@ func TestDockerAction(t *testing.T) { defer server.Close() port := server.Listener.Addr().(*net.TCPAddr).Port - for _, failure := range []bool{false, true} { - t.Run(fmt.Sprintf("update/failure=%v", failure), func(t *testing.T) { + for _, tc := range []struct { + event bool + failure bool + }{ + {event: false, failure: false}, + {event: false, failure: true}, + {event: true, failure: false}, + {event: true, failure: true}, + } { + t.Run(fmt.Sprintf("update/event=%v/failure=%v", tc.event, tc.failure), func(t *testing.T) { dir := t.TempDir() if err := os.Chmod(dir, 0755); err != nil { t.Fatal(err) @@ -118,7 +126,7 @@ func TestDockerAction(t *testing.T) { mu.Lock() requests = nil mu.Unlock() - name := fmt.Sprintf("dependabot-action-test-%s-update-%v", suffix, failure) + name := fmt.Sprintf("dependabot-action-test-%s-event-%v-failure-%v", suffix, tc.event, tc.failure) t.Cleanup(func() { if out, err := exec.Command("docker", "rm", "-f", name).CombinedOutput(); err != nil { t.Logf("Action container cleanup: %v\n%s", err, out) @@ -130,22 +138,32 @@ func TestDockerAction(t *testing.T) { "--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", dir + ":/github/workspace", "--env", "SSL_CERT_FILE=/github/workspace/ca.pem", - "--env", fmt.Sprintf("INPUT_API-URL=https://host.docker.internal:%d/api", port), - "--env", "INPUT_JOB-ID=42", "--env", "GITHUB_DEPENDABOT_JOB_TOKEN=job-secret", "--env", "GITHUB_DEPENDABOT_CRED_TOKEN=cred-secret", - "--env", "INPUT_UPDATER-IMAGE=" + updaterImage, "--env", "INPUT_PROXY-IMAGE=" + proxyImage, "--env", "INPUT_PULL-IMAGES=false", "--env", "INPUT_SKIP-IMAGE-VERIFICATION=true", "--env", "INPUT_TIMEOUT=1m", "--env", "INPUT_VOLUMES=/github/workspace/cache:/cache", - "--env", fmt.Sprintf("INPUT_UPDATER-ENV=CACHE_PATH=/cache\nFAIL=%v", failure), + "--env", fmt.Sprintf("INPUT_UPDATER-ENV=CACHE_PATH=/cache\nFAIL=%v", tc.failure), "--env", "TOKEN=must-not-be-expanded", - actionImage, } + if tc.event { + payload := fmt.Sprintf(`{"inputs":{"dependabotApiUrl":"https://host.docker.internal:%d/api","jobId":"42","updaterImage":%q}}`, port, updaterImage) + if err := os.WriteFile(filepath.Join(dir, "event.json"), []byte(payload), 0644); err != nil { + t.Fatal(err) + } + args = append(args, "--env", "GITHUB_EVENT_NAME=dynamic", "--env", "GITHUB_EVENT_PATH=/github/workspace/event.json") + } else { + args = append(args, + "--env", fmt.Sprintf("INPUT_API-URL=https://host.docker.internal:%d/api", port), + "--env", "INPUT_JOB-ID=42", + "--env", "INPUT_UPDATER-IMAGE="+updaterImage, + ) + } + args = append(args, actionImage) out, err := exec.CommandContext(t.Context(), "docker", args...).CombinedOutput() - if (err != nil) != failure { + if (err != nil) != tc.failure { t.Fatalf("Action exit = %v\n%s", err, out) } data, readErr := os.ReadFile(filepath.Join(cache, "result")) @@ -164,7 +182,7 @@ func TestDockerAction(t *testing.T) { got := strings.Join(requests, ",") mu.Unlock() want := "details,credentials" - if failure { + if tc.failure { want += ",record_update_job_error,mark_as_processed" } if got != want { diff --git a/cmd/dependabot-action/inputs.go b/cmd/dependabot-action/inputs.go index 6df68aa0..2f7a0934 100644 --- a/cmd/dependabot-action/inputs.go +++ b/cmd/dependabot-action/inputs.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "fmt" "net/url" + "os" "strconv" "strings" "time" @@ -10,6 +12,7 @@ import ( type config struct { apiURL string + apiDockerURL string jobID string jobToken string credentialsToken string @@ -22,27 +25,85 @@ type config struct { updaterEnv []string } +type eventInputs struct { + APIURL string `json:"dependabotApiUrl"` + APIDockerURL string `json:"dependabotApiDockerUrl"` + JobID string `json:"jobId"` + JobToken string `json:"jobToken"` + CredentialsToken string `json:"credentialsToken"` + UpdaterImage string `json:"updaterImage"` +} + +func readEventInputs(getenv func(string) string) (eventInputs, error) { + if getenv("GITHUB_EVENT_NAME") != "dynamic" { + return eventInputs{}, nil + } + path := getenv("GITHUB_EVENT_PATH") + if path == "" { + return eventInputs{}, fmt.Errorf("GITHUB_EVENT_PATH is required for Dependabot dynamic events") + } + data, err := os.ReadFile(path) + if err != nil { + return eventInputs{}, fmt.Errorf("cannot read GITHUB_EVENT_PATH") + } + var event struct { + Inputs *eventInputs `json:"inputs"` + } + if err := json.Unmarshal(data, &event); err != nil { + return eventInputs{}, fmt.Errorf("invalid Dependabot event JSON") + } + if event.Inputs == nil { + return eventInputs{}, fmt.Errorf("Dependabot event must contain inputs") + } + return *event.Inputs, nil +} + func readConfig(getenv func(string) string) (config, error) { + event, err := readEventInputs(getenv) + if err != nil { + return config{}, err + } input := func(name string) string { return strings.TrimSpace(getenv("INPUT_" + strings.ToUpper(name))) } + inputOrEvent := func(name, fallback string) string { + if value := input(name); value != "" { + return value + } + return strings.TrimSpace(fallback) + } cfg := config{ - apiURL: strings.TrimRight(input("api-url"), "/"), jobID: input("job-id"), - jobToken: getenv("GITHUB_DEPENDABOT_JOB_TOKEN"), credentialsToken: getenv("GITHUB_DEPENDABOT_CRED_TOKEN"), - updaterImage: input("updater-image"), proxyImage: input("proxy-image"), + apiURL: strings.TrimRight(inputOrEvent("api-url", event.APIURL), "/"), + apiDockerURL: strings.TrimRight(inputOrEvent("api-docker-url", event.APIDockerURL), "/"), + jobID: inputOrEvent("job-id", event.JobID), + jobToken: getenv("GITHUB_DEPENDABOT_JOB_TOKEN"), credentialsToken: getenv("GITHUB_DEPENDABOT_CRED_TOKEN"), + updaterImage: inputOrEvent("updater-image", event.UpdaterImage), proxyImage: input("proxy-image"), pullImages: true, timeout: time.Hour, volumes: inputLines(input("volumes")), updaterEnv: inputLines(input("updater-env")), } + if cfg.apiDockerURL == "" { + cfg.apiDockerURL = cfg.apiURL + } if cfg.jobToken == "" { - cfg.jobToken = input("job-token") + cfg.jobToken = inputOrEvent("job-token", event.JobToken) } if cfg.credentialsToken == "" { - cfg.credentialsToken = input("credentials-token") + cfg.credentialsToken = inputOrEvent("credentials-token", event.CredentialsToken) } if cfg.jobToken == "" || cfg.credentialsToken == "" { - return config{}, fmt.Errorf("job-token and credentials-token are required (or their GITHUB_DEPENDABOT_* environment variables)") + return config{}, fmt.Errorf("job-token and credentials-token are required (or their GITHUB_DEPENDABOT_* environment variables or Dependabot dynamic event inputs)") + } + if cfg.apiURL == "" { + return config{}, fmt.Errorf("api-url is required: set the action input or Dependabot event inputs.dependabotApiUrl") + } + for _, endpoint := range []struct{ name, value string }{ + {"api-url", cfg.apiURL}, {"api-docker-url", cfg.apiDockerURL}, + } { + apiURL, err := url.Parse(endpoint.value) + if err != nil || apiURL.Scheme != "https" || apiURL.Host == "" || apiURL.User != nil || apiURL.RawQuery != "" || apiURL.Fragment != "" { + return config{}, fmt.Errorf("%s must be an HTTPS URL without credentials, query, or fragment", endpoint.name) + } } - apiURL, err := url.Parse(cfg.apiURL) - if err != nil || apiURL.Scheme != "https" || apiURL.Host == "" || apiURL.User != nil || apiURL.RawQuery != "" || apiURL.Fragment != "" { - return config{}, fmt.Errorf("api-url must be an HTTPS URL without credentials, query, or fragment") + if cfg.jobID == "" { + return config{}, fmt.Errorf("job-id is required: set the action input or Dependabot event inputs.jobId") } if id, err := strconv.ParseUint(cfg.jobID, 10, 64); err != nil || id == 0 { return config{}, fmt.Errorf("job-id must be a positive integer") diff --git a/cmd/dependabot-action/inputs_test.go b/cmd/dependabot-action/inputs_test.go index edc36f47..b1fde41c 100644 --- a/cmd/dependabot-action/inputs_test.go +++ b/cmd/dependabot-action/inputs_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "reflect" "strings" "testing" @@ -10,6 +12,141 @@ import ( "github.com/docker/docker/api/types/mount" ) +func writeEventFile(t *testing.T, payload string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "event.json") + if err := os.WriteFile(path, []byte(payload), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestReadConfigDynamicEvent(t *testing.T) { + path := writeEventFile(t, `{"inputs":{ + "dependabotApiUrl":"https://dependabot.example/api/", + "jobId":"42","jobToken":"event-job","credentialsToken":"event-cred", + "updaterImage":"event:updater" + }}`) + dockerPath := writeEventFile(t, `{"inputs":{ + "dependabotApiUrl":"https://dependabot.example/api/", + "dependabotApiDockerUrl":"https://event-docker.example/api/", + "jobId":"42","jobToken":"event-job","credentialsToken":"event-cred", + "updaterImage":"event:updater" + }}`) + for _, tc := range []struct { + name string + overrides map[string]string + want config + }{ + { + name: "event inputs", + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://dependabot.example/api", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + { + name: "explicit inputs", + overrides: map[string]string{ + "INPUT_API-URL": "https://override.example/prefix/", "INPUT_JOB-ID": "99", + "INPUT_JOB-TOKEN": "input-job", "INPUT_CREDENTIALS-TOKEN": "input-cred", + "INPUT_UPDATER-IMAGE": "input:updater", + }, + want: config{ + apiURL: "https://override.example/prefix", apiDockerURL: "https://override.example/prefix", + jobID: "99", jobToken: "input-job", credentialsToken: "input-cred", updaterImage: "input:updater", + }, + }, + { + name: "environment tokens", + overrides: map[string]string{ + "GITHUB_DEPENDABOT_JOB_TOKEN": "env-job", "GITHUB_DEPENDABOT_CRED_TOKEN": "env-cred", + "INPUT_JOB-TOKEN": "input-job", "INPUT_CREDENTIALS-TOKEN": "input-cred", + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://dependabot.example/api", + jobID: "42", jobToken: "env-job", credentialsToken: "env-cred", updaterImage: "event:updater", + }, + }, + { + name: "event Docker URL", + overrides: map[string]string{ + "GITHUB_EVENT_PATH": dockerPath, + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://event-docker.example/api", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + { + name: "explicit Docker URL", + overrides: map[string]string{ + "GITHUB_EVENT_PATH": dockerPath, + "INPUT_API-DOCKER-URL": "https://docker.example/prefix/", + }, + want: config{ + apiURL: "https://dependabot.example/api", apiDockerURL: "https://docker.example/prefix", + jobID: "42", jobToken: "event-job", credentialsToken: "event-cred", updaterImage: "event:updater", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{"GITHUB_EVENT_NAME": "dynamic", "GITHUB_EVENT_PATH": path} + for key, value := range tc.overrides { + env[key] = value + } + got, err := readConfig(func(key string) string { return env[key] }) + tc.want.pullImages = true + tc.want.timeout = time.Hour + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("readConfig() = %#v, %v; want %#v", got, err, tc.want) + } + }) + } +} + +func TestReadConfigEventErrors(t *testing.T) { + for _, tc := range []struct { + name string + payload string + eventName string + overrides map[string]string + want string + }{ + {name: "missing path", eventName: "dynamic", want: "GITHUB_EVENT_PATH is required"}, + {name: "unreadable path", eventName: "dynamic", overrides: map[string]string{"GITHUB_EVENT_PATH": filepath.Join(t.TempDir(), "missing.json")}, want: "cannot read GITHUB_EVENT_PATH"}, + {name: "malformed JSON", eventName: "dynamic", payload: `{"inputs":{"jobToken":"do-not-log-secret"`, want: "invalid Dependabot event JSON"}, + {name: "missing inputs", eventName: "dynamic", payload: `{}`, want: "Dependabot event must contain inputs"}, + {name: "null inputs", eventName: "dynamic", payload: `{"inputs":null}`, want: "Dependabot event must contain inputs"}, + {name: "wrong input type", eventName: "dynamic", payload: `{"inputs":{"jobId":[]}}`, want: "invalid Dependabot event JSON"}, + {name: "missing URL", eventName: "dynamic", payload: `{"inputs":{"jobId":"42"}}`, want: "api-url is required"}, + {name: "missing job ID", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com"}}`, want: "job-id is required"}, + {name: "other event ignored", eventName: "workflow_dispatch", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","jobId":"42"}}`, want: "api-url is required"}, + {name: "invalid explicit URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","jobId":"42"}}`, overrides: map[string]string{"INPUT_API-URL": "http://invalid.example"}, want: "api-url must be an HTTPS URL"}, + {name: "invalid event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://user:do-not-log-secret@example.com","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "query in event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com?token=do-not-log-secret","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "fragment in event URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com#do-not-log-secret","jobId":"42"}}`, want: "api-url must be an HTTPS URL"}, + {name: "invalid Docker URL", eventName: "dynamic", payload: `{"inputs":{"dependabotApiUrl":"https://example.com","dependabotApiDockerUrl":"http://invalid.example","jobId":"42"}}`, want: "api-docker-url must be an HTTPS URL"}, + } { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{ + "GITHUB_EVENT_NAME": tc.eventName, + "GITHUB_DEPENDABOT_JOB_TOKEN": "job", "GITHUB_DEPENDABOT_CRED_TOKEN": "cred", + } + if tc.payload != "" { + env["GITHUB_EVENT_PATH"] = writeEventFile(t, tc.payload) + } + for key, value := range tc.overrides { + env[key] = value + } + _, err := readConfig(func(key string) string { return env[key] }) + if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "do-not-log-secret") { + t.Fatalf("readConfig() error = %v; want %q without secrets", err, tc.want) + } + }) + } +} + func TestReadConfig(t *testing.T) { env := map[string]string{ "INPUT_API-URL": "https://dependabot.example/api/", diff --git a/cmd/dependabot-action/main.go b/cmd/dependabot-action/main.go index eb479962..d2279570 100644 --- a/cmd/dependabot-action/main.go +++ b/cmd/dependabot-action/main.go @@ -52,9 +52,13 @@ func run(ctx context.Context, cfg config, api *jobClient, output io.Writer, runn err = os.Setenv("DEPENDABOT_JOB_ID", cfg.jobID) } if err == nil { + apiURL := cfg.apiDockerURL + if apiURL == "" { + apiURL = cfg.apiURL + } err = runner(ctx, infra.RunParams{ Job: job, Creds: creds, CredentialsResolved: true, - ApiUrl: cfg.apiURL, UpdaterImage: cfg.updaterImage, ProxyImage: cfg.proxyImage, + ApiUrl: apiURL, UpdaterImage: cfg.updaterImage, ProxyImage: cfg.proxyImage, StorageImage: infra.StorageImageName, PullImages: cfg.pullImages, SkipImageVerification: cfg.skipImageVerification, Timeout: cfg.timeout, Volumes: cfg.volumes, UpdaterEnvironmentVariables: cfg.updaterEnv, diff --git a/cmd/dependabot-action/main_test.go b/cmd/dependabot-action/main_test.go index acd661e5..b87d0773 100644 --- a/cmd/dependabot-action/main_test.go +++ b/cmd/dependabot-action/main_test.go @@ -13,7 +13,6 @@ import ( "reflect" "strings" "testing" - "time" "github.com/dependabot/cli/internal/infra" "github.com/dependabot/cli/internal/model" @@ -49,17 +48,25 @@ func TestRunHostedJob(t *testing.T) { } })) defer server.Close() - cfg := config{ - apiURL: server.URL + "/prefix", jobID: "42", jobToken: "job-secret", credentialsToken: "cred-secret", - updaterImage: "custom:job", proxyImage: "custom:proxy", pullImages: false, - skipImageVerification: true, - timeout: 3 * time.Hour, volumes: []string{"/github/workspace/cache:/cache"}, - updaterEnv: []string{"CUSTOM_CACHE_PATH=/cache"}, + eventPath := writeEventFile(t, fmt.Sprintf(`{"inputs":{ + "dependabotApiUrl":%q,"dependabotApiDockerUrl":"https://updater.example/prefix", + "jobId":"42","updaterImage":"custom:job" + }}`, server.URL+"/prefix")) + env := map[string]string{ + "GITHUB_EVENT_NAME": "dynamic", "GITHUB_EVENT_PATH": eventPath, + "GITHUB_DEPENDABOT_JOB_TOKEN": "job-secret", "GITHUB_DEPENDABOT_CRED_TOKEN": "cred-secret", + "INPUT_PROXY-IMAGE": "custom:proxy", "INPUT_PULL-IMAGES": "false", + "INPUT_SKIP-IMAGE-VERIFICATION": "true", "INPUT_TIMEOUT": "3h", + "INPUT_VOLUMES": "/github/workspace/cache:/cache", "INPUT_UPDATER-ENV": "CUSTOM_CACHE_PATH=/cache", + } + cfg, err := readConfig(func(key string) string { return env[key] }) + if err != nil { + t.Fatal(err) } api := newJobClient(cfg) api.http = server.Client() called := false - err := run(context.Background(), cfg, api, &output, func(ctx context.Context, params infra.RunParams) error { + err = run(context.Background(), cfg, api, &output, func(ctx context.Context, params infra.RunParams) error { called = true if len(paths) != 2 { t.Fatal("runner invoked before fetching both responses") @@ -75,7 +82,7 @@ func TestRunHostedJob(t *testing.T) { t.Errorf("%q not masked before execution", secret) } } - if params.ApiUrl != cfg.apiURL || params.UpdaterImage != cfg.updaterImage || params.ProxyImage != cfg.proxyImage || + if params.ApiUrl != cfg.apiDockerURL || params.UpdaterImage != cfg.updaterImage || params.ProxyImage != cfg.proxyImage || params.PullImages || !params.SkipImageVerification || params.Timeout != cfg.timeout || params.StorageImage != infra.StorageImageName || !reflect.DeepEqual(params.Volumes, cfg.volumes) || !reflect.DeepEqual(params.UpdaterEnvironmentVariables, cfg.updaterEnv) { t.Fatalf("incorrect runner options: %+v", params)