Skip to content

Commit c4f9f55

Browse files
committed
Run fetch and update in separate containers behind isolated_fetch_update
With the experiment on, the CLI clones in one updater container and updates in another. The clone travels between them on a Docker volume mounted at DEPENDABOT_REPO_CONTENTS_PATH in both, so it is made once rather than twice. Each side gets its own proxy on its own network pair, so the update container has no route to the fetch proxy and the two credential sets can diverge later. They are identical for now; the call site marks the seam. `--local` populates the fetch container only. Debug shells, the OpenTelemetry collector and use_case_insensitive_filesystem are rejected rather than silently misbehaving under the split. Combined mode drops the `bin/run fetch_files &&` prefix. That entrypoint used to be a no-op stub, so this is byte-identical to today, and it avoids fetching twice now that dependabot-core makes it do real work. Also fixes container output being truncated: RunCmd piped through io.Copy and prefixer, but Prefixer.WriteTo returns without writing pending data at EOF and io.Copy only calls WriteTo once, so the tail of every command was dropped. Running `bin/run` twice used to mask it, which is what the "unless I echo here the json doesn't output" comment in input.txt was working around.
1 parent d31f8f3 commit c4f9f55

11 files changed

Lines changed: 329 additions & 50 deletions

File tree

docs/debugging.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ Next, clone https://github.com/dependabot/dependabot-core. This project contains
1010

1111
Try opening a terminal and run `script/dependabot update go_modules dependabot/cli --debug` in the `dependabot-core` project directory. This will drop you in an interactive session with the update ready to proceed.
1212

13-
To perform the update, you need to run two commands:
13+
To perform the update, run:
1414

15-
- `bin/run fetch_files`
1615
- `bin/run update_files`
1716

18-
If the problem you are debugging is during the fetch step, the fetch_files command will be all you need to run.
17+
This fetches the dependency files and then updates them.
1918

20-
If the problem is after the fetch step, you can repeatedly run update_files while you're debugging.
19+
If the problem you are debugging is during the fetch step, run `bin/run fetch_files` instead. It needs somewhere to write the fetched file set, so set `DEPENDABOT_HANDOFF_PATH` first, for example `export DEPENDABOT_HANDOFF_PATH=/tmp/handoff.json`. `update_files` reads that file instead of fetching again when the variable is set, so you can repeatedly run `update_files` against a single fetch while you're debugging.
2120

2221
In the example `script/dependabot` command above, try running an update in the container.
2322

@@ -32,7 +31,7 @@ Next, let's try adding a `debugger` statement. Open the `dependabot-core` projec
3231

3332
> **Note** You don't have to restart your CLI session, the changes are automatically synced to the container!
3433
35-
In the interactive debugging session, run `bin/run fetch_files` and `bin/run update_files`. During the update_files command, the Ruby debugger will open. It should look something like this:
34+
In the interactive debugging session, run `bin/run update_files`. During that command, the Ruby debugger will open. It should look something like this:
3635

3736
```ruby
3837
[11, 20] in ~/go_modules/lib/dependabot/go_modules/update_checker.rb
@@ -60,7 +59,7 @@ At this prompt, you can run [debugger commands](https://github.com/ruby/debug) t
6059

6160
If your Dependabot job is hanging and would like to figure out why, the CLI is the perfect tool for the job.
6261

63-
Start by running the update that recreates the hang with `dependabot update <ecosystem> <org/repo>`. Once the hang is reproducible, run with the `--debug` flag and the run the `fetch_files` and `update_files` commands and wait until the job hangs.
62+
Start by running the update that recreates the hang with `dependabot update <ecosystem> <org/repo>`. Once the hang is reproducible, run with the `--debug` flag and then run the `update_files` command and wait until the job hangs.
6463

6564
Once it does hang, hit CTL-C, and you'll get a stack trace leading you to the problematic code.
6665

internal/infra/proxy.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path"
1111
"path/filepath"
1212

13+
"github.com/dependabot/cli/internal/model"
1314
"github.com/docker/docker/api/types/container"
1415
"github.com/docker/docker/api/types/mount"
1516
"github.com/docker/docker/api/types/network"
@@ -32,6 +33,12 @@ type Proxy struct {
3233
}
3334

3435
func NewProxy(ctx context.Context, cli *client.Client, params *RunParams, nets *Networks) (*Proxy, error) {
36+
return newProxyWithCreds(ctx, cli, params, nets, params.Creds)
37+
}
38+
39+
// newProxyWithCreds builds a proxy serving a specific credential set, so the fetch
40+
// and update sides can be given different ones.
41+
func newProxyWithCreds(ctx context.Context, cli *client.Client, params *RunParams, nets *Networks, creds []model.Credential) (*Proxy, error) {
3542
// Generate secrets:
3643
ca, err := GenerateCertificateAuthority()
3744
if err != nil {
@@ -40,7 +47,7 @@ func NewProxy(ctx context.Context, cli *client.Client, params *RunParams, nets *
4047

4148
// Generate and write configuration to disk:
4249
proxyConfig := &Config{
43-
Credentials: params.Creds,
50+
Credentials: creds,
4451
CA: ca,
4552
}
4653

internal/infra/run.go

Lines changed: 157 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"time"
1919

2020
"github.com/docker/docker/api/types/container"
21+
"github.com/docker/docker/api/types/volume"
2122

2223
"github.com/dependabot/cli/internal/model"
2324
"github.com/dependabot/cli/internal/server"
@@ -31,12 +32,16 @@ import (
3132
"gopkg.in/yaml.v3"
3233
)
3334

34-
var runCmds = map[model.RunCommand]string{
35-
model.VersionCommand: "bin/run fetch_files && bin/run update_files",
36-
model.UpdateFilesCommand: "bin/run fetch_files && bin/run update_files",
37-
model.RecreateCommand: "bin/run fetch_files && bin/run update_files",
38-
model.SecurityCommand: "bin/run fetch_files && bin/run update_files",
39-
model.UpdateGraphCommand: "bin/run fetch_files && bin/run update_graph",
35+
// fetchCmd fetches the dependency files. In the combined topology the update
36+
// commands fetch for themselves, so it only runs in the split topology.
37+
const fetchCmd = "bin/run fetch_files"
38+
39+
var updateCmds = map[model.RunCommand]string{
40+
model.VersionCommand: "bin/run update_files",
41+
model.UpdateFilesCommand: "bin/run update_files",
42+
model.RecreateCommand: "bin/run update_files",
43+
model.SecurityCommand: "bin/run update_files",
44+
model.UpdateGraphCommand: "bin/run update_graph",
4045
}
4146

4247
type RunParams struct {
@@ -445,7 +450,16 @@ func runContainers(ctx context.Context, params RunParams) (err error) {
445450
defer collector.Close()
446451
}
447452

448-
updater, err := NewUpdater(ctx, cli, networks, &params, prox, collector)
453+
if params.Job.IsolatedFetchUpdate() {
454+
return runIsolated(ctx, cli, networks, &params, prox, collector)
455+
}
456+
457+
return runCombined(ctx, cli, networks, &params, prox, collector)
458+
}
459+
460+
// runCombined runs fetch and update in a single container sharing a repo clone.
461+
func runCombined(ctx context.Context, cli *client.Client, networks *Networks, params *RunParams, prox *Proxy, collector *Collector) (err error) {
462+
updater, err := NewUpdater(ctx, cli, networks, params, prox, collector, "")
449463
if err != nil {
450464
return err
451465
}
@@ -455,16 +469,8 @@ func runContainers(ctx context.Context, params RunParams) (err error) {
455469
}
456470
}()
457471

458-
// put the clone dir in the updater container to be used by during the update
459-
if params.LocalDir != "" {
460-
containerDir := guestRepoDir
461-
if params.Job.UseCaseInsensitiveFileSystem() {
462-
// since the updater is using the storage container, we need to populate the repo on that device because that's the directory that will be used for the update
463-
containerDir = caseSensitiveRepoContentsPath
464-
}
465-
if err = putCloneDir(ctx, cli, updater, params.LocalDir, containerDir); err != nil {
466-
return err
467-
}
472+
if err = placeCloneDir(ctx, cli, params, updater); err != nil {
473+
return err
468474
}
469475

470476
// update CA certificates as root prior to start debug shell or running dependabot commands
@@ -473,25 +479,145 @@ func runContainers(ctx context.Context, params RunParams) (err error) {
473479
}
474480

475481
if params.Debug {
476-
if err := updater.RunShell(ctx, prox.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables); err != nil {
477-
return err
482+
return updater.RunShell(ctx, prox.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables)
483+
}
484+
485+
// Run dependabot commands as a dependabot user
486+
env := userEnv(prox.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables)
487+
if params.Flamegraph {
488+
env = append(env, "FLAMEGRAPH=1")
489+
}
490+
if err := updater.RunCmd(ctx, updateCmds[params.Job.Command], dependabot, env...); err != nil {
491+
return err
492+
}
493+
if params.Flamegraph {
494+
getFromContainer(ctx, cli, updater.containerID, "/tmp/dependabot-flamegraph.html")
495+
}
496+
497+
return checkExitCode(params, updater)
498+
}
499+
500+
// runIsolated clones in one container and updates in another. The clone travels
501+
// between them on a shared volume rather than being made twice. Each side gets its
502+
// own proxy on its own network, so the update side cannot reach the fetch proxy.
503+
func runIsolated(ctx context.Context, cli *client.Client, networks *Networks, params *RunParams, prox *Proxy, collector *Collector) (err error) {
504+
if params.Debug {
505+
return fmt.Errorf("--debug is not supported with the isolated_fetch_update experiment")
506+
}
507+
if params.Job.UseCaseInsensitiveFileSystem() {
508+
return fmt.Errorf("isolated_fetch_update is not supported with use_case_insensitive_filesystem")
509+
}
510+
if params.CollectorConfigPath != "" {
511+
return fmt.Errorf("the OpenTelemetry collector is not supported with the isolated_fetch_update experiment")
512+
}
513+
514+
repoVolume, err := cli.VolumeCreate(ctx, volume.CreateOptions{Labels: map[string]string{"dependabot-cli": "repo"}})
515+
if err != nil {
516+
return fmt.Errorf("failed to create repo volume: %w", err)
517+
}
518+
defer func() {
519+
if volumeErr := cli.VolumeRemove(context.Background(), repoVolume.Name, true); volumeErr != nil {
520+
err = volumeErr
478521
}
479-
} else {
480-
// Run dependabot commands as a dependabot user
481-
env := userEnv(prox.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables)
482-
if params.Flamegraph {
483-
env = append(env, "FLAMEGRAPH=1")
522+
}()
523+
524+
fetcher, err := NewUpdater(ctx, cli, networks, params, prox, collector, repoVolume.Name)
525+
if err != nil {
526+
return err
527+
}
528+
defer func() {
529+
if fetcherErr := fetcher.Close(); fetcherErr != nil {
530+
err = fetcherErr
484531
}
485-
if err := updater.RunCmd(ctx, runCmds[params.Job.Command], dependabot, env...); err != nil {
486-
return err
532+
}()
533+
534+
if err = placeCloneDir(ctx, cli, params, fetcher); err != nil {
535+
return err
536+
}
537+
538+
if err = fetcher.RunCmd(ctx, "update-ca-certificates", root); err != nil {
539+
return err
540+
}
541+
542+
fetchEnv := userEnv(prox.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables)
543+
if err = fetcher.RunCmd(ctx, fetchCmd, dependabot, fetchEnv...); err != nil {
544+
return err
545+
}
546+
if *fetcher.ExitCode != 0 {
547+
return fmt.Errorf("fetch exited with code %d", *fetcher.ExitCode)
548+
}
549+
550+
// The update side gets its own network and proxy so its credentials can diverge
551+
// from the fetch side's without the two containers being able to swap proxies.
552+
updateNetworks, err := NewNetworks(ctx, cli)
553+
if err != nil {
554+
return fmt.Errorf("failed to create update networks: %w", err)
555+
}
556+
defer func() {
557+
if netErr := updateNetworks.Close(); netErr != nil {
558+
err = netErr
487559
}
488-
if params.Flamegraph {
489-
getFromContainer(ctx, cli, updater.containerID, "/tmp/dependabot-flamegraph.html")
560+
}()
561+
562+
// Same credentials as the fetch side for now. This is the seam where the repo-read
563+
// credential gets dropped, once the update side no longer calls the target repo.
564+
updateProxy, err := newProxyWithCreds(ctx, cli, params, updateNetworks, params.Creds)
565+
if err != nil {
566+
return fmt.Errorf("failed to create update proxy: %w", err)
567+
}
568+
defer func() {
569+
if proxyErr := updateProxy.Close(); proxyErr != nil {
570+
err = proxyErr
490571
}
491-
// If the exit code is non-zero, error when using the `update` subcommand, but not the `test` subcommand.
492-
if params.Expected == nil && *updater.ExitCode != 0 {
493-
return fmt.Errorf("updater exited with code %d", *updater.ExitCode)
572+
}()
573+
go updateProxy.TailLogs(ctx, cli)
574+
575+
updater, err := NewUpdater(ctx, cli, updateNetworks, params, updateProxy, collector, repoVolume.Name)
576+
if err != nil {
577+
return err
578+
}
579+
defer func() {
580+
if updaterErr := updater.Close(); updaterErr != nil {
581+
err = updaterErr
494582
}
583+
}()
584+
585+
if err = updater.RunCmd(ctx, "update-ca-certificates", root); err != nil {
586+
return err
587+
}
588+
589+
env := userEnv(updateProxy.url, params.ApiUrl, params.Job, params.UpdaterEnvironmentVariables)
590+
if params.Flamegraph {
591+
env = append(env, "FLAMEGRAPH=1")
592+
}
593+
if err = updater.RunCmd(ctx, updateCmds[params.Job.Command], dependabot, env...); err != nil {
594+
return err
595+
}
596+
if params.Flamegraph {
597+
getFromContainer(ctx, cli, updater.containerID, "/tmp/dependabot-flamegraph.html")
598+
}
599+
600+
return checkExitCode(params, updater)
601+
}
602+
603+
func placeCloneDir(ctx context.Context, cli *client.Client, params *RunParams, updater *Updater) error {
604+
if params.LocalDir == "" {
605+
return nil
606+
}
607+
608+
containerDir := guestRepoDir
609+
if params.Job.UseCaseInsensitiveFileSystem() {
610+
// since the updater is using the storage container, we need to populate the repo on that device because that's the directory that will be used for the update
611+
containerDir = caseSensitiveRepoContentsPath
612+
}
613+
614+
return putCloneDir(ctx, cli, updater, params.LocalDir, containerDir)
615+
}
616+
617+
// checkExitCode errors when using the `update` subcommand, but not the `test` subcommand.
618+
func checkExitCode(params *RunParams, updater *Updater) error {
619+
if params.Expected == nil && *updater.ExitCode != 0 {
620+
return fmt.Errorf("updater exited with code %d", *updater.ExitCode)
495621
}
496622

497623
return nil

0 commit comments

Comments
 (0)