From 0b0c0c2b8fe1eaa888eb5acd8486344178571fe3 Mon Sep 17 00:00:00 2001 From: Daryoush Rahseparian Date: Wed, 7 Oct 2026 04:52:59 -0700 Subject: [PATCH] fix: pin recorded clocks during smoke replay --- README.md | 24 +++++- cmd/dependabot/internal/cmd/graph.go | 1 + cmd/dependabot/internal/cmd/test.go | 1 + cmd/dependabot/internal/cmd/update.go | 1 + internal/infra/clock.go | 59 ++++++++++++++ internal/infra/clock/clock.cjs | 17 ++++ internal/infra/clock/clock.rb | 7 ++ internal/infra/clock/clock.sh | 6 ++ internal/infra/run.go | 7 ++ internal/infra/updater.go | 11 ++- internal/model/smoke.go | 4 + testdata/scripts/clock.txt | 110 ++++++++++++++++++++++++++ 12 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 internal/infra/clock.go create mode 100644 internal/infra/clock/clock.cjs create mode 100644 internal/infra/clock/clock.rb create mode 100755 internal/infra/clock/clock.sh create mode 100644 testdata/scripts/clock.txt diff --git a/README.md b/README.md index 52974b80..5b01a8a5 100644 --- a/README.md +++ b/README.md @@ -250,8 +250,8 @@ output: This example smoke test describes the expected behavior for Dependabot to update the base image of a Dockerfile from `ubuntu:17.04` to `ubuntu:22.04`. -* The `input` field consists of a `job` and any `credentials`. - (this is equivalent a [job description file](#job-description-file)). +* The `input` field consists of a `job`, any `credentials`, and an optional + `recorded-at` timestamp (equivalent to a [job description file](#job-description-file)). * The `output` field comprises an array of expectation objects. These correspond to requests made by the updater to the Dependabot API service when performing an update job. @@ -293,6 +293,26 @@ Any cache misses indicate an external request made by the updater, which may cause tests to fail unexpectedly (for example, when a new version of a package is released). +### Recording time + +New recordings made with `update -o` or `graph -o` capture `input.recorded-at` +as an RFC 3339 timestamp. Recording and replay use that instant for Ruby's +`Time.now` and JavaScript's `Date`, including native npm subprocesses. +This keeps release-age cooldowns from expiring between runs against the same +cached registry responses. + +You can also supply `recorded-at` in a job input to reproduce a known historical +instant. The clock has millisecond precision. The override is confined to the +updater container and preserves existing `RUBYOPT` and `NODE_OPTIONS` options. +Timers, monotonic clocks, TLS certificate verification, and other runtimes' +clocks are not overridden. + +Ordinary updates without an output recording or an explicit `recorded-at` +continue to use the current clock. Legacy smoke tests without `recorded-at` +also keep their existing behavior. To make one clock-consistent, create a new +recording with `update -o`; do not infer its original clock from cached HTTP +responses or change expected dependency versions by hand. + ## Debugging with the CLI See the [debugging doc](/docs/debugging.md) for details. diff --git a/cmd/dependabot/internal/cmd/graph.go b/cmd/dependabot/internal/cmd/graph.go index 3b5260b1..9b2c68f9 100644 --- a/cmd/dependabot/internal/cmd/graph.go +++ b/cmd/dependabot/internal/cmd/graph.go @@ -86,6 +86,7 @@ func NewGraphCommand() *cobra.Command { ProxyCertPath: flags.proxyCertPath, ProxyImage: proxyImage, PullImages: flags.pullImages, + RecordedAt: input.RecordedAt, Timeout: flags.timeout, UpdaterImage: updaterImage, Volumes: flags.volumes, diff --git a/cmd/dependabot/internal/cmd/test.go b/cmd/dependabot/internal/cmd/test.go index 3a6bb8a9..a8e35782 100644 --- a/cmd/dependabot/internal/cmd/test.go +++ b/cmd/dependabot/internal/cmd/test.go @@ -50,6 +50,7 @@ func NewTestCommand() *cobra.Command { ProxyCertPath: flags.proxyCertPath, ProxyImage: proxyImage, PullImages: flags.pullImages, + RecordedAt: smokeTest.Input.RecordedAt, StorageImage: storageImage, Timeout: flags.timeout, UpdaterImage: updaterImage, diff --git a/cmd/dependabot/internal/cmd/update.go b/cmd/dependabot/internal/cmd/update.go index a197483f..9c17e6e9 100644 --- a/cmd/dependabot/internal/cmd/update.go +++ b/cmd/dependabot/internal/cmd/update.go @@ -101,6 +101,7 @@ func NewUpdateCommand() *cobra.Command { ProxyCertPath: flags.proxyCertPath, ProxyImage: proxyImage, PullImages: flags.pullImages, + RecordedAt: input.RecordedAt, StorageImage: storageImage, Timeout: flags.timeout, UpdaterImage: updaterImage, diff --git a/internal/infra/clock.go b/internal/infra/clock.go new file mode 100644 index 00000000..015a2381 --- /dev/null +++ b/internal/infra/clock.go @@ -0,0 +1,59 @@ +package infra + +import ( + "archive/tar" + "bytes" + "context" + _ "embed" + "fmt" + "time" + + "github.com/docker/docker/api/types/container" +) + +const guestClockDir = "/opt/dependabot-cli-clock" + +//go:embed clock/clock.sh +var clockShell string + +//go:embed clock/clock.rb +var clockRuby string + +//go:embed clock/clock.cjs +var clockNode string + +func (u *Updater) installClock(ctx context.Context, recordedAt time.Time) error { + var archive bytes.Buffer + writer := tar.NewWriter(&archive) + for _, file := range []struct{ name, content string }{ + {"clock.sh", fmt.Sprintf(clockShell, recordedAt.UnixMilli())}, + {"clock.rb", clockRuby}, + {"clock.cjs", clockNode}, + } { + if err := addFileToArchive(writer, guestClockDir+"/"+file.name, 0555, file.content); err != nil { + return fmt.Errorf("failed to archive replay clock: %w", err) + } + } + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close replay clock archive: %w", err) + } + if err := u.cli.CopyToContainer(ctx, u.containerID, "/", &archive, container.CopyToContainerOptions{}); err != nil { + return fmt.Errorf("failed to copy replay clock to container: %w", err) + } + u.recordedClock = true + return nil +} + +func (u *Updater) command(shell string, args ...string) []string { + offset := 0 + if u.recordedClock { + offset = 1 + } + command := make([]string, len(args)+1+offset) + if u.recordedClock { + command[0] = guestClockDir + "/clock.sh" + } + command[offset] = shell + copy(command[offset+1:], args) + return command +} diff --git a/internal/infra/clock/clock.cjs b/internal/infra/clock/clock.cjs new file mode 100644 index 00000000..29488a85 --- /dev/null +++ b/internal/infra/clock/clock.cjs @@ -0,0 +1,17 @@ +const recordedTime = Number(process.env.DEPENDABOT_RECORDED_AT) +if (!Number.isFinite(recordedTime)) { + throw new Error('Invalid Dependabot replay timestamp') +} + +// Freeze wall-clock dates, not timers, performance.now(), or TLS verification. +const NativeDate = Date +NativeDate.now = () => recordedTime +globalThis.Date = new Proxy(NativeDate, { + apply() { + return new NativeDate(recordedTime).toString() + }, + construct(target, args, newTarget) { + return Reflect.construct(target, args.length ? args : [recordedTime], newTarget) + }, +}) +NativeDate.prototype.constructor = globalThis.Date diff --git a/internal/infra/clock/clock.rb b/internal/infra/clock/clock.rb new file mode 100644 index 00000000..220e2450 --- /dev/null +++ b/internal/infra/clock/clock.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Leave monotonic clocks and OpenSSL's certificate clock untouched. +recorded_time = Rational(Integer(ENV.fetch("DEPENDABOT_RECORDED_AT"), 10), 1000) +Time.define_singleton_method(:now) do |**options| + at(recorded_time, **options) +end diff --git a/internal/infra/clock/clock.sh b/internal/infra/clock/clock.sh new file mode 100755 index 00000000..44397c66 --- /dev/null +++ b/internal/infra/clock/clock.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +export DEPENDABOT_RECORDED_AT=%d +export RUBYOPT="${RUBYOPT:+$RUBYOPT }-r/opt/dependabot-cli-clock/clock.rb" +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require=/opt/dependabot-cli-clock/clock.cjs" +exec "$@" diff --git a/internal/infra/run.go b/internal/infra/run.go index 95ebce9c..cc2aa3a0 100644 --- a/internal/infra/run.go +++ b/internal/infra/run.go @@ -93,6 +93,8 @@ type RunParams struct { ApiUrl string // UpdaterEnvironmentVariables are additional environment variables to set in the update container UpdaterEnvironmentVariables []string + // RecordedAt is the clock captured by a recording, independent of the host clock. + RecordedAt *time.Time } var gitShaRegex = regexp.MustCompile(`^[0-9a-f]{40}$`) @@ -118,6 +120,10 @@ func Run(params RunParams) error { if err := params.Validate(); err != nil { return err } + if params.RecordedAt == nil && params.Output != "" && params.Expected == nil { + recordedAt := time.Now().UTC().Truncate(time.Millisecond) + params.RecordedAt = &recordedAt + } var ctx context.Context var cancel func() @@ -187,6 +193,7 @@ func generateOutput(params RunParams, api *server.API, outFile *os.File) ([]byte params.Job.Source.Commit = api.Actual.Input.Job.Source.Commit } api.Actual.Input.Job = *params.Job + api.Actual.Input.RecordedAt = params.RecordedAt // ignore conditions help make tests reproducible, so they are generated if there aren't any yet if len(api.Actual.Input.Job.IgnoreConditions) == 0 { diff --git a/internal/infra/updater.go b/internal/infra/updater.go index dd3a4955..153c941c 100644 --- a/internal/infra/updater.go +++ b/internal/infra/updater.go @@ -53,6 +53,7 @@ type Updater struct { containerID string storageContainerID string storageVolumes []string + recordedClock bool // ExitCode is set once an Updater command has completed. ExitCode *int @@ -132,6 +133,12 @@ func NewUpdater(ctx context.Context, cli *client.Client, net *Networks, params * updater.Close() return nil, err } + if params.RecordedAt != nil { + if err = updater.installClock(ctx, *params.RecordedAt); err != nil { + updater.Close() + return nil, err + } + } if err = cli.ContainerStart(ctx, updaterContainer.ID, container.StartOptions{}); err != nil { updater.Close() @@ -343,7 +350,7 @@ func (u *Updater) RunShell(ctx context.Context, proxyURL string, apiUrl string, Tty: true, User: dependabot, Env: append(userEnv(proxyURL, apiUrl, job, additionalEnvVars), "DEBUG=1"), - Cmd: []string{"/bin/bash"}, + Cmd: u.command("/bin/bash"), }) if err != nil { return fmt.Errorf("failed to create exec: %w", err) @@ -397,7 +404,7 @@ func (u *Updater) RunCmd(ctx context.Context, cmd, user string, env ...string) e AttachStderr: true, User: user, Env: env, - Cmd: []string{"/bin/sh", "-c", cmd}, + Cmd: u.command("/bin/sh", "-c", cmd), }) if err != nil { return fmt.Errorf("failed to create exec: %w", err) diff --git a/internal/model/smoke.go b/internal/model/smoke.go index 42afc78e..d195f6c4 100644 --- a/internal/model/smoke.go +++ b/internal/model/smoke.go @@ -1,5 +1,7 @@ package model +import "time" + type RunCommand string const ( @@ -24,6 +26,8 @@ type Input struct { Job Job `yaml:"job"` // Credentials is the registry info and tokens to pass to the Proxy Credentials []Credential `yaml:"credentials,omitempty"` + // RecordedAt pins the Ruby and JavaScript wall clocks when replaying a recording. + RecordedAt *time.Time `yaml:"recorded-at,omitempty" json:"recorded-at,omitempty"` } // Output is the expected output given the inputs diff --git a/testdata/scripts/clock.txt b/testdata/scripts/clock.txt new file mode 100644 index 00000000..89367b91 --- /dev/null +++ b/testdata/scripts/clock.txt @@ -0,0 +1,110 @@ +# The updater runs real Ruby and npm. No external registry requests are needed. +exec docker build -qt clock-updater . + +dependabot update -f input.yaml -o recorded.yaml --updater-image clock-updater +stderr 'NPM_CANDIDATE=1.0.0' +stderr 'RUBY_TIME=2026-10-02T06:10:32.000Z' + +# The same metadata admits the new version after its three-day cooldown expires. +dependabot update -f later.yaml --updater-image clock-updater +stderr 'NPM_CANDIDATE=1.1.0' +stderr 'RUBY_TIME=2026-10-07T06:10:32.000Z' + +# Replaying the earlier recording must not inherit the later clock. +dependabot test -f recorded.yaml --updater-image clock-updater +stderr 'NPM_CANDIDATE=1.0.0' +stderr 'RUBY_TIME=2026-10-02T06:10:32.000Z' + +exec docker rmi clock-updater + +-- Dockerfile -- +FROM node:24-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ruby ca-certificates && npm install --global npm@11.19.0 +RUN useradd -m dependabot +USER dependabot +WORKDIR /home/dependabot/dependabot-updater +COPY --chown=dependabot --chmod=755 run bin/run +COPY --chown=dependabot registry.cjs registry.cjs + +-- run -- +#!/bin/sh +set -eu +ruby -rtime -e 'start = Process.clock_gettime(Process::CLOCK_MONOTONIC); sleep 0.01; abort "monotonic clock stopped" unless Process.clock_gettime(Process::CLOCK_MONOTONIC) > start; puts "RUBY_TIME=#{Time.now.utc.iso8601(3)}"' +node registry.cjs + +-- registry.cjs -- +const assert = require('node:assert/strict') +const { execFile } = require('node:child_process') +const fs = require('node:fs') +const http = require('node:http') +const os = require('node:os') +const path = require('node:path') +const { promisify } = require('node:util') + +const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'clock-registry-')) +const server = http.createServer((request, response) => { + if (request.url !== '/clock-dependency') { + response.writeHead(404).end() + return + } + const origin = `http://127.0.0.1:${server.address().port}` + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ + name: 'clock-dependency', + 'dist-tags': { latest: '1.1.0' }, + versions: Object.fromEntries(['1.0.0', '1.1.0'].map(version => [version, { + name: 'clock-dependency', version, + dist: { tarball: `${origin}/clock-dependency-${version}.tgz` }, + }])), + time: { '1.0.0': '2026-01-01T00:00:00Z', '1.1.0': '2026-10-01T13:52:31Z' }, + })) +}) + +server.listen(0, '127.0.0.1', async () => { + try { + const manifest = { name: 'clock-test', version: '1.0.0', dependencies: { 'clock-dependency': '^1.0.0' } } + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)) + fs.writeFileSync(path.join(directory, 'package-lock.json'), JSON.stringify({ + name: 'clock-test', version: '1.0.0', lockfileVersion: 3, + packages: { '': manifest, 'node_modules/clock-dependency': { version: '1.0.0' } }, + })) + const monotonic = process.hrtime.bigint() + await new Promise(resolve => setTimeout(resolve, 10)) + assert(process.hrtime.bigint() > monotonic) + assert.equal(+new Date('2000-01-01T00:00:00Z'), 946684800000) + await promisify(execFile)('npm', [ + 'update', 'clock-dependency', '--package-lock-only', '--ignore-scripts', + '--no-audit', '--no-fund', '--min-release-age=3', + `--registry=http://127.0.0.1:${server.address().port}`, `--cache=${directory}/cache`, + ], { cwd: directory, env: { ...process.env, NO_PROXY: '127.0.0.1', no_proxy: '127.0.0.1' } }) + const lock = JSON.parse(fs.readFileSync(path.join(directory, 'package-lock.json'), 'utf8')) + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')), manifest) + console.log(`NPM_CANDIDATE=${lock.packages['node_modules/clock-dependency'].version}`) + } catch (error) { + console.error(error) + process.exitCode = 1 + } finally { + server.close() + fs.rmSync(directory, { recursive: true, force: true }) + } +}) + +-- input.yaml -- +recorded-at: 2026-10-02T06:10:32Z +job: + package-manager: npm_and_yarn + source: + provider: github + repo: dependabot/smoke-tests + directory: / + commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +-- later.yaml -- +recorded-at: 2026-10-07T06:10:32Z +job: + package-manager: npm_and_yarn + source: + provider: github + repo: dependabot/smoke-tests + directory: / + commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa