diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 5970b0034..63649dd60 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -2,7 +2,7 @@ name: Bug Report description: Report a bug to help fix your issue title: "[Bug]: " labels: - - kind/bug + - bug body: - type: checkboxes id: preflight diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index a14068e06..447583dda 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -2,14 +2,14 @@ name: Feature Request description: Suggest an idea or improvement title: "[Feature]: " labels: - - kind/feature + - enhancement body: - type: checkboxes id: preflight attributes: label: Before submitting options: - - label: I have searched existing issues and discussions to confirm this hasn't been requested + - label: I have searched existing feature requests and discussions to confirm this has not been requested required: true - type: textarea @@ -25,7 +25,7 @@ body: id: solution attributes: label: Proposed solution - description: Describe the solution you'd like. Be as specific as possible. + description: Describe the solution you would like. Be as specific as possible. validations: required: true diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 7c66c1eca..37cda2893 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -221,7 +221,7 @@ jobs: GH_ACCESS_TOKEN: ${{ github.token }} GH_CREDENTIAL_USERNAME: x-access-token run: | - ./e2e.test -test.v -ginkgo.v -test.timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" + ./e2e.test -test.v -ginkgo.v -test.timeout 1500s -ginkgo.fail-on-empty --ginkgo.label-filter="${{ matrix.label }}" integration-tests: name: Test ${{ matrix.label }}${{ matrix.install-podman && format(' ({0})', matrix.install-podman) || '' }} on ${{ matrix.runner }} @@ -255,6 +255,12 @@ jobs: install-kind: false requires-secret: false + - label: mcp + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + - label: provider runner: ubuntu-latest free-disk-space: true @@ -291,7 +297,7 @@ jobs: install-kind: false requires-secret: false - - label: features + - label: feature runner: ubuntu-latest free-disk-space: false install-kind: false @@ -333,7 +339,7 @@ jobs: install-kind: false requires-secret: false - - label: setup + - label: config-apply runner: ubuntu-latest free-disk-space: false install-kind: false @@ -383,19 +389,85 @@ jobs: install-kind: true requires-secret: false - - label: up-provider-podman + - label: up-provider-podman-rootless-basic + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootless + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootless-lifecycle + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootless + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootless-config + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootless + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootless-features runner: ubuntu-latest free-disk-space: true install-kind: false requires-secret: false install-podman: rootless + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 - - label: up-provider-podman + - label: up-provider-podman-rootful-basic runner: ubuntu-latest free-disk-space: true install-kind: false requires-secret: false install-podman: rootful + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootful-lifecycle + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootful + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootful-config + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootful + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 + + - label: up-provider-podman-rootful-features + runner: ubuntu-latest + free-disk-space: true + install-kind: false + requires-secret: false + install-podman: rootful + test-timeout: 600s + job-timeout-minutes: 20 + flake-attempts: 2 - label: up-provider-docker runner: ubuntu-latest @@ -456,9 +528,10 @@ jobs: requires-secret: false runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: ${{ matrix.job-timeout-minutes || 45 }} steps: - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + background: true if: matrix.free-disk-space == true && runner.os == 'Linux' && (matrix.requires-secret == false || needs.can-read-secret.outputs.secret-set == 'true') - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -502,7 +575,6 @@ jobs: name: e2e-test-${{ steps.os.outputs.runner_os }} path: ./e2e/ - # e2e expects executable to have name defined in e2e/framework/framework.go - name: setup executable if: matrix.requires-secret == false || needs.can-read-secret.outputs.secret-set == 'true' shell: bash @@ -624,24 +696,32 @@ jobs: path: ${{ runner.temp }}/apt-archives key: apt-podman-runc-${{ runner.os }}-v1 + - name: cache apt packages (podman/runc) + if: (matrix.install-podman == 'rootless' || matrix.install-podman == 'rootful') && runner.os == 'Linux' + run: | + for i in $(seq 1 60); do + if sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; then + echo "waiting for dpkg lock to be released ($i/60)" + sleep 2 + else + break + fi + done + mkdir -p "${{ runner.temp }}/apt-archives" + sudo apt-get -o Dir::Cache::Archives="${{ runner.temp }}/apt-archives" update + sudo apt-get -o Dir::Cache::Archives="${{ runner.temp }}/apt-archives" install -y podman runc + + sudo rm -f "${{ runner.temp }}/apt-archives/lock" + sudo rm -rf "${{ runner.temp }}/apt-archives/partial" + + sudo mkdir -p /etc/containers + printf '[engine]\nruntime = "runc"\n' | sudo tee /etc/containers/containers.conf + - name: Install Podman (Linux rootless) if: matrix.install-podman == 'rootless' && runner.os == 'Linux' env: APT_CACHE_DIR: ${{ runner.temp }}/apt-archives run: | - # Cache into a runner-owned dir: /var/cache/apt/archives is root-owned - # and actions/cache (running unprivileged) can't write into it on restore. - mkdir -p "$APT_CACHE_DIR" - sudo apt-get -o Dir::Cache::Archives="$APT_CACHE_DIR" update - sudo apt-get -o Dir::Cache::Archives="$APT_CACHE_DIR" install -y podman runc - - # podman defaults to crun, which has been observed to fail with - # "OCI runtime error: crun: unknown version specified" on this - # runner image. Force runc, which is known-good, and fail fast - # with diagnostics if it isn't. - mkdir -p ~/.config/containers - printf '[engine]\nruntime = "runc"\n' > ~/.config/containers/containers.conf - podman info podman run --rm busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d echo "podman runtime preflight OK" @@ -650,17 +730,6 @@ jobs: env: APT_CACHE_DIR: ${{ runner.temp }}/apt-archives run: | - # Cache into a runner-owned dir: /var/cache/apt/archives is root-owned - # and actions/cache (running unprivileged) can't write into it on restore. - mkdir -p "$APT_CACHE_DIR" - sudo apt-get -o Dir::Cache::Archives="$APT_CACHE_DIR" update - sudo apt-get -o Dir::Cache::Archives="$APT_CACHE_DIR" install -y podman runc - - # See the rootless step above: force runc over the default crun, - # which has been observed to fail on this runner image. - sudo mkdir -p /etc/containers - printf '[engine]\nruntime = "runc"\n' | sudo tee /etc/containers/containers.conf - sudo systemctl enable --now podman.socket if ! timeout 30 bash -c 'until sudo podman info >/dev/null 2>&1; do sleep 1; done'; then echo "::error::podman.socket did not become ready within 30s" @@ -709,19 +778,6 @@ jobs: sudo apt-get remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo rm -rf /var/lib/docker /var/lib/containerd /usr/bin/docker /usr/bin/dockerd - # The snapshot e2e suite pushes/pulls against a local registry:2 - # fixture at host.docker.internal:15500 (see e2e/tests/snapshot/helper.go), - # reachable both from the host CLI and from inside the workspace - # container. Plain-HTTP is only safe here because that address is - # always local to this runner. - # - # host.docker.internal is Docker Desktop/OrbStack magic; plain Linux - # (this runner) resolves it for neither the host CLI process nor - # containers by default. The container side gets it via the fixture - # devcontainer.json's "--add-host=host.docker.internal:host-gateway" - # runArgs; the host side (the devsy CLI's own push-permission checks, - # which run before any container exists) needs an explicit /etc/hosts - # entry, since there's no running container to attach a gateway to yet. - name: configure insecure registry for snapshot tests (Linux) if: matrix.configure-insecure-registry == true && runner.os == 'Linux' run: | @@ -751,6 +807,8 @@ jobs: GH_USERNAME: ${{ github.repository_owner }} GH_ACCESS_TOKEN: ${{ steps.app-token.outputs.token || github.token }} GH_CREDENTIAL_USERNAME: x-access-token + TEST_TIMEOUT: ${{ matrix.test-timeout || '1500s' }} + FLAKE_ATTEMPTS: ${{ matrix.flake-attempts || '1' }} run: | if [ "${{ runner.os }}" == "Linux" ]; then sudo \ @@ -760,7 +818,7 @@ jobs: KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config}" \ ${DOCKER_HOST:+DOCKER_HOST="${DOCKER_HOST}"} \ PATH="${PATH}" \ - ./e2e.test -test.v -ginkgo.v -test.timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" + ./e2e.test -test.v -ginkgo.v -test.timeout "${TEST_TIMEOUT}" -ginkgo.fail-on-empty -ginkgo.flake-attempts="${FLAKE_ATTEMPTS}" --ginkgo.label-filter="${{ matrix.label }}" else GH_USERNAME="${GH_USERNAME}" \ GH_ACCESS_TOKEN="${GH_ACCESS_TOKEN}" \ @@ -768,7 +826,7 @@ jobs: KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config}" \ PATH="${PATH}" \ DOCKER_HOST="npipe:////./pipe/podman-machine-default" \ - ./e2e.test.exe -test.v -ginkgo.v -test.timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" + ./e2e.test.exe -test.v -ginkgo.v -test.timeout "${TEST_TIMEOUT}" -ginkgo.fail-on-empty -ginkgo.flake-attempts="${FLAKE_ATTEMPTS}" --ginkgo.label-filter="${{ matrix.label }}" fi - name: verify docker is installed diff --git a/.golangci-version b/.golangci-version new file mode 100644 index 000000000..6f9cc441d --- /dev/null +++ b/.golangci-version @@ -0,0 +1 @@ +v2.12.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c40c519b3..55368d5b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,6 +37,11 @@ repos: hooks: - id: actionlint files: '^\.github/workflows/' + # actionlint v1.7.12 does not yet recognize the background/wait/ + # wait-all/cancel/parallel step keywords GitHub Actions shipped + # 2026-06-25 (https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/). + # Tracked upstream: https://github.com/rhysd/actionlint/issues/693 + args: ["-ignore", "unexpected key \"background\" for step"] - repo: https://github.com/biomejs/pre-commit rev: v2.5.6 hooks: diff --git a/Taskfile.yml b/Taskfile.yml index 3f2cc33b7..ffc365440 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -23,11 +23,14 @@ tasks: cli:lint: desc: lint go code using golangci-lint + deps: [cli:lint:version] cmd: golangci-lint run ./... cli:lint:ci: - desc: lint exactly as CI does (golangci-lint-action only-new-issues -> new-from-patch vs the merge-base with origin/main) + desc: lint using golangci-lint with CI parity + deps: [cli:lint:version] cmds: + - golangci-lint config verify - git fetch --quiet origin main - | patch="$(mktemp)" @@ -35,12 +38,25 @@ tasks: git diff "$(git merge-base HEAD origin/main)" > "$patch" golangci-lint run --new-from-patch="$patch" --new=false ./... + cli:lint:version: + desc: assert the local golangci-lint matches .golangci-version (the version CI uses) + cmd: | + want="$(sed 's/^v//' .golangci-version | tr -d '[:space:]')" + cur="$(golangci-lint --version 2>&1 | sed -n 's/.*version \([0-9][0-9A-Za-z.+-]*\).*/\1/p')" + if [ "$cur" != "$want" ]; then + echo "golangci-lint version mismatch: local=${cur:-}, expected=${want}. Install golangci-lint v${want}." >&2 + exit 1 + fi + silent: true + cli:lint:fix: desc: fix lint issues using golangci-lint + deps: [cli:lint:version] cmd: golangci-lint run --fix ./... cli:format: desc: format go code using golangci-lint formatters + deps: [cli:lint:version] cmd: golangci-lint fmt ./... cli:act:lint: diff --git a/cmd/internal/agent_daemon.go b/cmd/internal/agent_daemon.go index 5dad5ac47..1f18a205a 100644 --- a/cmd/internal/agent_daemon.go +++ b/cmd/internal/agent_daemon.go @@ -229,8 +229,7 @@ func (cmd *DaemonCmd) runShutdownCommand( log.Infof("running shutdown command for workspace %s: %s", workspace.Workspace.ID, shutdown) var stdout, stderr bytes.Buffer - err = clientimplementation.RunCommand(clientimplementation.RunCommandOptions{ - Ctx: ctx, + err = clientimplementation.RunCommand(ctx, clientimplementation.RunCommandOptions{ Command: workspace.Agent.Exec.Shutdown, Environ: environ, Stdout: &stdout, diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go index 6553eabe3..075d6d618 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -3,17 +3,18 @@ package cmdinternal import ( "bytes" "context" + "fmt" "io" "os" "os/signal" "syscall" + "time" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/cmd/internal/agentworkspace" "github.com/devsy-org/devsy/pkg/agent" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer" - "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/encoding" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" @@ -23,7 +24,19 @@ import ( "github.com/spf13/cobra" ) -// ContainerTunnelCmd holds the ws-tunnel cmd flags. +const containerStatusRunning = "running" + +// containerRootUser is the user to use when running commands inside the container that +// require root privileges. +const containerRootUser = "root" + +// sighupExitGrace is the grace period after SIGHUP before os.Exit(0) is +// called, to avoid orphaning any processes that ignore context cancellation. +const sighupExitGrace = 5 * time.Second + +var findDevContainerTimeout = 30 * time.Second + +// ContainerTunnelCmd holds the container-tunnel cmd flags. type ContainerTunnelCmd struct { *flags.GlobalFlags @@ -31,10 +44,12 @@ type ContainerTunnelCmd struct { User string } -// NewContainerTunnelCmd creates a new command. -func NewContainerTunnelCmd(flags *flags.GlobalFlags) *cobra.Command { +// NewContainerTunnelCmd creates the container-tunnel command, which brings a +// workspace's devcontainer up if needed and then bridges an SSH tunnel into +// it over the calling process's stdio. +func NewContainerTunnelCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &ContainerTunnelCmd{ - GlobalFlags: flags, + GlobalFlags: globalFlags, } containerTunnelCmd := &cobra.Command{ Use: "container-tunnel", @@ -53,42 +68,37 @@ func NewContainerTunnelCmd(flags *flags.GlobalFlags) *cobra.Command { return containerTunnelCmd } -// Run runs the command logic. -func (cmd *ContainerTunnelCmd) Run(ctx context.Context) error { - // write workspace info +// Run brings the workspace's devcontainer to a running state and then +// blocks bridging an SSH tunnel into it, until the tunnel closes or cobraCtx +// is cancelled. +func (cmd *ContainerTunnelCmd) Run(cobraCtx context.Context) error { + ctx, cancel := context.WithCancel(cobraCtx) + defer cancel() + + stopSighupWatch := watchForSighup(cancel) + defer stopSighupWatch() + shouldExit, workspaceInfo, err := agent.WriteWorkspaceInfo(cmd.WorkspaceInfo) if err != nil { - return err - } else if shouldExit { + return fmt.Errorf("write workspace info: %w", err) + } + if shouldExit { return nil } - // make sure content folder exists - _, err = agentworkspace.InitContentFolder(ctx, workspaceInfo) - if err != nil { - return err + if _, err := agentworkspace.InitContentFolder(ctx, workspaceInfo); err != nil { + return fmt.Errorf("init content folder: %w", err) } - // create runner runner, err := agentworkspace.CreateRunner(ctx, workspaceInfo) if err != nil { - return err + return fmt.Errorf("create runner: %w", err) } - // wait until devcontainer is started - err = startDevContainer(ctx, workspaceInfo, runner) - if err != nil { + if err := startDevContainer(ctx, workspaceInfo, runner); err != nil { return err } - // handle SIGHUP - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGHUP) - go func() { - <-sigs - os.Exit(0) - }() - return agent.Tunnel(ctx, agent.TunnelOptions{ Exec: func( ctx context.Context, @@ -112,61 +122,96 @@ func (cmd *ContainerTunnelCmd) Run(ctx context.Context) error { }) } +// watchForSighup cancels on SIGHUP (the SSH channel driving this process +// closed) so in-flight commands are killed instead of orphaned, then falls +// back to os.Exit(0) if something still ignores cancellation. +func watchForSighup(cancel context.CancelFunc) (stop func()) { + done := make(chan struct{}) + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP) + + go func() { + select { + case <-done: + return + case <-sigs: + cancel() + } + select { + case <-done: + case <-time.After(sighupExitGrace): + os.Exit(0) + } + }() + + return func() { + signal.Stop(sigs) + close(done) + } +} + +// startDevContainer ensures workspaceConfig's devcontainer is up and, for +// legacy UIDs, that its result file is readable, (re)starting the container +// when either check fails. func startDevContainer( ctx context.Context, workspaceConfig *provider2.AgentWorkspaceInfo, runner devcontainer.Runner, ) error { - containerDetails, err := runner.Find(ctx) + findCtx, cancel := context.WithTimeout(ctx, findDevContainerTimeout) + defer cancel() + + containerDetails, err := runner.Find(findCtx) if err != nil { - return err + return fmt.Errorf("find devcontainer: %w", err) } - // start container if it is missing or not running - if containerDetails == nil || containerDetails.State.Status != "running" { - _, err = StartContainer(ctx, runner, workspaceConfig) - return err + if containerDetails == nil || containerDetails.State.Status != containerStatusRunning { + if err := startContainer(ctx, runner, workspaceConfig); err != nil { + return fmt.Errorf("start container: %w", err) + } + return nil } - // for legacy UIDs, ensure the workspace result is present in the container, - // restarting it when the result is missing if encoding.IsLegacyUID(workspaceConfig.Workspace.UID) && !hasDevContainerResult(ctx, runner) { - _, err = StartContainer(ctx, runner, workspaceConfig) - return err + if err := startContainer(ctx, runner, workspaceConfig); err != nil { + return fmt.Errorf("restart container after missing devcontainer result: %w", err) + } } return nil } -// hasDevContainerResult reports whether the devcontainer result file is readable -// inside the running container. +// hasDevContainerResult reports whether the devcontainer result file is +// readable inside the running container. func hasDevContainerResult(ctx context.Context, runner devcontainer.Runner) bool { - buf := &bytes.Buffer{} + var buf bytes.Buffer err := runner.Command(ctx, devcontainer.CommandParams{ - User: "root", + User: containerRootUser, Command: "cat " + pkgconfig.DevContainerResultPath, - Stdout: buf, - Stderr: buf, + Stdout: &buf, + Stderr: &buf, }) return err == nil } -func StartContainer( +// startContainer runs the devcontainer's full up workflow, skipping the +// image build, to bring the container to a running state. +func startContainer( ctx context.Context, runner devcontainer.Runner, workspaceConfig *provider2.AgentWorkspaceInfo, -) (*config.Result, error) { - log.Debugf("starting Devsy container") - result, err := runner.Up( +) error { + log.Debug("starting container") + if _, err := runner.Up( ctx, devcontainer.UpOptions{NoBuild: true}, workspaceConfig.InjectTimeout, status.Nop(), - ) - if err != nil { - return result, err + ); err != nil { + return fmt.Errorf("up devcontainer: %w", err) } - log.Debugf("started Devsy container") - return result, err + log.Debug("started container") + return nil } diff --git a/cmd/internal/container_tunnel_test.go b/cmd/internal/container_tunnel_test.go new file mode 100644 index 000000000..3fa9259ae --- /dev/null +++ b/cmd/internal/container_tunnel_test.go @@ -0,0 +1,162 @@ +package cmdinternal + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/devcontainer" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" +) + +type blockingFindRunner struct{} + +func (blockingFindRunner) Up( + context.Context, devcontainer.UpOptions, time.Duration, status.Reporter, +) (*config.Result, error) { + panic("not implemented") +} + +func (blockingFindRunner) Build(context.Context, provider2.BuildOptions) (string, error) { + panic("not implemented") +} + +func (blockingFindRunner) Find(ctx context.Context) (*config.ContainerDetails, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func (blockingFindRunner) Command(context.Context, devcontainer.CommandParams) error { + panic("not implemented") +} + +func (blockingFindRunner) Stop(context.Context) error { + panic("not implemented") +} + +func (blockingFindRunner) Delete(context.Context, devcontainer.DeleteOptions) error { + panic("not implemented") +} + +func (blockingFindRunner) Logs(context.Context, io.Writer) error { + panic("not implemented") +} + +func TestStartDevContainer_FindIsBoundedByTimeout(t *testing.T) { + original := findDevContainerTimeout + findDevContainerTimeout = 50 * time.Millisecond + defer func() { findDevContainerTimeout = original }() + + ctx := context.Background() + + start := time.Now() + err := startDevContainer(ctx, &provider2.AgentWorkspaceInfo{}, blockingFindRunner{}) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("startDevContainer must return an error when Find never returns on its own") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected wrapped context.DeadlineExceeded, got %v", err) + } + if elapsed > time.Second { + t.Fatalf("startDevContainer took %s, want bounded by findDevContainerTimeout", elapsed) + } +} + +type stubRunner struct { + findResult *config.ContainerDetails + findErr error + upErr error + commandErr error +} + +func (s stubRunner) Up( + context.Context, devcontainer.UpOptions, time.Duration, status.Reporter, +) (*config.Result, error) { + return nil, s.upErr +} + +func (stubRunner) Build(context.Context, provider2.BuildOptions) (string, error) { + panic("not implemented") +} + +func (s stubRunner) Find(context.Context) (*config.ContainerDetails, error) { + return s.findResult, s.findErr +} + +func (s stubRunner) Command(context.Context, devcontainer.CommandParams) error { + return s.commandErr +} + +func (stubRunner) Stop(context.Context) error { + panic("not implemented") +} + +func (stubRunner) Delete(context.Context, devcontainer.DeleteOptions) error { + panic("not implemented") +} + +func (stubRunner) Logs(context.Context, io.Writer) error { + panic("not implemented") +} + +func TestStartDevContainer_MissingContainerStartsItAndWrapsUpError(t *testing.T) { + upErr := errors.New("boom") + runner := stubRunner{findResult: nil, upErr: upErr} + + err := startDevContainer(context.Background(), &provider2.AgentWorkspaceInfo{}, runner) + + if !errors.Is(err, upErr) { + t.Fatalf("expected wrapped %v, got %v", upErr, err) + } + const wantPrefix = "start container:" + if got := err.Error(); len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix { + t.Fatalf("error %q does not start with %q", got, wantPrefix) + } +} + +func TestStartDevContainer_RunningContainerWithoutResultRestarts(t *testing.T) { + upErr := errors.New("boom") + runner := stubRunner{ + findResult: &config.ContainerDetails{ + State: config.ContainerDetailsState{Status: containerStatusRunning}, + }, + commandErr: errors.New("cat: no such file"), // hasDevContainerResult -> false + upErr: upErr, + } + workspaceConfig := &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{UID: "a"}, // legacy (non-UUID) UID + } + + err := startDevContainer(context.Background(), workspaceConfig, runner) + + if !errors.Is(err, upErr) { + t.Fatalf("expected wrapped %v, got %v", upErr, err) + } + const wantPrefix = "restart container after missing devcontainer result:" + if got := err.Error(); len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix { + t.Fatalf("error %q does not start with %q", got, wantPrefix) + } +} + +func TestStartDevContainer_RunningContainerWithResultIsNoOp(t *testing.T) { + runner := stubRunner{ + findResult: &config.ContainerDetails{ + State: config.ContainerDetailsState{Status: containerStatusRunning}, + }, + commandErr: nil, // hasDevContainerResult -> true + upErr: errors.New("Up must not be called on the happy path"), + } + workspaceConfig := &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{UID: "a"}, + } + + if err := startDevContainer(context.Background(), workspaceConfig, runner); err != nil { + t.Fatalf("startDevContainer returned unexpected error: %v", err) + } +} diff --git a/cmd/internal/sh.go b/cmd/internal/sh.go index 4992bf330..b23b489fd 100644 --- a/cmd/internal/sh.go +++ b/cmd/internal/sh.go @@ -46,15 +46,20 @@ func (cmd *ShellCommand) Run(ctx context.Context, args []string) error { return fmt.Errorf("only a single script file can be used") } - // load command from file if len(args) > 0 { - content, err := os.ReadFile(args[0]) + fileContent, err := os.ReadFile(args[0]) if err != nil { return err } - cmd.Command = string(content) + cmd.Command = string(fileContent) } - return shell.RunEmulatedShell(ctx, cmd.Command, os.Stdin, os.Stdout, os.Stderr, os.Environ()) + return shell.RunEmulatedShell(ctx, &shell.CommandRunner{ + Command: cmd.Command, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + Environ: os.Environ(), + }) } diff --git a/cmd/machine/ssh.go b/cmd/machine/ssh.go index 7b50191f8..b9d927aad 100644 --- a/cmd/machine/ssh.go +++ b/cmd/machine/ssh.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "strings" "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/cmd/flags" @@ -144,15 +145,13 @@ func (cmd *SSHCmd) Run(ctx context.Context, args []string) error { InstallTerminfo: cmd.InstallTerminfo, }, Exec: func(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { - command := fmt.Sprintf( - "'%s' internal ssh-server --stdio", - machineClient.AgentPath(), - ) + c := strings.Builder{} + _, _ = fmt.Fprintf(&c, "'%s' internal ssh-server --stdio", machineClient.AgentPath()) if cmd.Debug { - command += " --debug" + _, _ = c.WriteString(" --debug") } - return devagent.InjectAgent(&devagent.InjectOptions{ - Ctx: ctx, + command := c.String() + return devagent.InjectAgent(ctx, &devagent.InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return machineClient.Command(ctx, client.CommandOptions{ Command: command, diff --git a/cmd/pro/cluster/list.go b/cmd/pro/cluster/list.go index 80ed5ca49..23fa33b03 100644 --- a/cmd/pro/cluster/list.go +++ b/cmd/pro/cluster/list.go @@ -1,7 +1,6 @@ package cluster import ( - "bytes" "context" "encoding/json" "fmt" @@ -9,13 +8,12 @@ import ( managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -66,42 +64,29 @@ func (cmd *ListClustersCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - opts := devsyConfig.ProviderOptions(provider.Name) - opts[platform.ProjectEnv] = config.OptionValue{Value: cmd.Project} - - var buf bytes.Buffer - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.List.Clusters, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: provider, - Stdout: &buf, - }) - if err != nil { - return fmt.Errorf("list clusters with provider %q: %w", provider.Name, err) - } - headers := []string{proutil.HeaderName, proutil.HeaderDisplayName, "Online"} - if buf.Len() == 0 { - table.Print(headers, nil) - return nil - } - clusters := managementv1.ProjectClusters{} - if err := json.Unmarshal(buf.Bytes(), &clusters); err != nil { - return fmt.Errorf("parse clusters output: %w", err) - } - - rows := make([][]string, 0, len(clusters.Clusters)) - for _, c := range clusters.Clusters { - rows = append(rows, []string{ - c.GetName(), - c.Spec.DisplayName, - fmt.Sprintf("%t", c.Status.Online), - }) - } - table.Print(headers, rows) + return proxycmd.RunAndPrintTable(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.List.Clusters, + DevsyConfig: devsyConfig, + Provider: provider, + ExtraOptions: map[string]config.OptionValue{ + platform.ProjectEnv: {Value: cmd.Project}, + }, + }, headers, func(payload []byte) ([][]string, error) { + clusters := managementv1.ProjectClusters{} + if err := json.Unmarshal(payload, &clusters); err != nil { + return nil, err + } - return nil + rows := make([][]string, 0, len(clusters.Clusters)) + for _, c := range clusters.Clusters { + rows = append(rows, []string{ + c.GetName(), + c.Spec.DisplayName, + fmt.Sprintf("%t", c.Status.Online), + }) + } + return rows, nil + }) } diff --git a/cmd/pro/health.go b/cmd/pro/health.go index 32d044a14..d8468e6eb 100644 --- a/cmd/pro/health.go +++ b/cmd/pro/health.go @@ -1,17 +1,14 @@ package pro import ( - "bytes" "context" - "fmt" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" - "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/spf13/cobra" @@ -73,22 +70,9 @@ func (cmd *HealthCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.Health, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: &buf, - Stderr: log.Writer(log.LevelError), + return proxycmd.RunAndPrint(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.Health, + DevsyConfig: devsyConfig, + Provider: provider, }) - if err != nil { - return fmt.Errorf("check health with provider %q: %w", provider.Name, err) - } - - fmt.Println(buf.String()) - - return nil } diff --git a/cmd/pro/project/list.go b/cmd/pro/project/list.go index 2d6f6bd1e..df80b9f00 100644 --- a/cmd/pro/project/list.go +++ b/cmd/pro/project/list.go @@ -1,20 +1,17 @@ package project import ( - "bytes" "context" "encoding/json" - "fmt" managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -61,40 +58,26 @@ func (cmd *ListProjectsCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.List.Projects, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: &buf, - }) - if err != nil { - return fmt.Errorf("watch workspaces with provider %q: %w", provider.Name, err) - } - headers := []string{proutil.HeaderName, proutil.HeaderDisplayName, "Description"} - if buf.Len() == 0 { - table.Print(headers, nil) - return nil - } - projects := []managementv1.Project{} - if err := json.Unmarshal(buf.Bytes(), &projects); err != nil { - return fmt.Errorf("parse projects output: %w", err) - } - - rows := make([][]string, 0, len(projects)) - for _, p := range projects { - rows = append(rows, []string{ - p.GetName(), - p.Spec.DisplayName, - p.Spec.Description, - }) - } - table.Print(headers, rows) + return proxycmd.RunAndPrintTable(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.List.Projects, + DevsyConfig: devsyConfig, + Provider: provider, + }, headers, func(payload []byte) ([][]string, error) { + projects := []managementv1.Project{} + if err := json.Unmarshal(payload, &projects); err != nil { + return nil, err + } - return nil + rows := make([][]string, 0, len(projects)) + for _, p := range projects { + rows = append(rows, []string{ + p.GetName(), + p.Spec.DisplayName, + p.Spec.Description, + }) + } + return rows, nil + }) } diff --git a/cmd/pro/self.go b/cmd/pro/self.go index ae85b28bc..3a51ad82b 100644 --- a/cmd/pro/self.go +++ b/cmd/pro/self.go @@ -1,13 +1,11 @@ package pro import ( - "bytes" "context" - "fmt" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" @@ -58,21 +56,9 @@ func (cmd *SelfCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.Get.Self, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: &buf, + return proxycmd.RunAndPrint(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.Get.Self, + DevsyConfig: devsyConfig, + Provider: provider, }) - if err != nil { - return fmt.Errorf("get self: %w", err) - } - - fmt.Println(buf.String()) - - return nil } diff --git a/cmd/pro/template/list.go b/cmd/pro/template/list.go index 1783e8ee8..20d996a98 100644 --- a/cmd/pro/template/list.go +++ b/cmd/pro/template/list.go @@ -1,21 +1,18 @@ package template import ( - "bytes" "context" "encoding/json" - "fmt" managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -66,42 +63,29 @@ func (cmd *ListTemplatesCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - opts := devsyConfig.ProviderOptions(provider.Name) - opts[platform.ProjectEnv] = config.OptionValue{Value: cmd.Project} - - var buf bytes.Buffer - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.List.Templates, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: provider, - Stdout: &buf, - }) - if err != nil { - return fmt.Errorf("list templates with provider %q: %w", provider.Name, err) - } - headers := []string{proutil.HeaderName, proutil.HeaderDisplayName, "Description"} - if buf.Len() == 0 { - table.Print(headers, nil) - return nil - } - templates := managementv1.ProjectTemplates{} - if err := json.Unmarshal(buf.Bytes(), &templates); err != nil { - return fmt.Errorf("parse templates output: %w", err) - } - - rows := make([][]string, 0, len(templates.DevsyWorkspaceTemplates)) - for _, t := range templates.DevsyWorkspaceTemplates { - rows = append(rows, []string{ - t.GetName(), - t.Spec.DisplayName, - t.Spec.Description, - }) - } - table.Print(headers, rows) + return proxycmd.RunAndPrintTable(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.List.Templates, + DevsyConfig: devsyConfig, + Provider: provider, + ExtraOptions: map[string]config.OptionValue{ + platform.ProjectEnv: {Value: cmd.Project}, + }, + }, headers, func(payload []byte) ([][]string, error) { + templates := managementv1.ProjectTemplates{} + if err := json.Unmarshal(payload, &templates); err != nil { + return nil, err + } - return nil + rows := make([][]string, 0, len(templates.DevsyWorkspaceTemplates)) + for _, t := range templates.DevsyWorkspaceTemplates { + rows = append(rows, []string{ + t.GetName(), + t.Spec.DisplayName, + t.Spec.Description, + }) + } + return rows, nil + }) } diff --git a/cmd/pro/version.go b/cmd/pro/version.go index 9a94b6837..5b5468188 100644 --- a/cmd/pro/version.go +++ b/cmd/pro/version.go @@ -1,13 +1,11 @@ package pro import ( - "bytes" "context" - "fmt" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" @@ -58,25 +56,13 @@ func (cmd *VersionCmd) Run( devsyConfig *config.Config, providerConfig *provider.ProviderConfig, ) error { - opts := devsyConfig.ProviderOptions(providerConfig.Name) - opts[config.EnvProviderID] = config.OptionValue{Value: providerConfig.Name} - opts[config.EnvProviderContext] = config.OptionValue{Value: cmd.Context} - - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: providerConfig.Exec.Proxy.Get.Version, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: providerConfig, - Stdout: &buf, + return proxycmd.RunAndPrint(ctx, proxycmd.Options{ + Command: providerConfig.Exec.Proxy.Get.Version, + DevsyConfig: devsyConfig, + Provider: providerConfig, + ExtraOptions: map[string]config.OptionValue{ + config.EnvProviderID: {Value: providerConfig.Name}, + config.EnvProviderContext: {Value: cmd.Context}, + }, }) - if err != nil { - return fmt.Errorf("get version: %w", err) - } - - fmt.Print(buf.String()) - - return nil } diff --git a/cmd/pro/workspace/create.go b/cmd/pro/workspace/create.go index c224d5e26..590f859c8 100644 --- a/cmd/pro/workspace/create.go +++ b/cmd/pro/workspace/create.go @@ -1,17 +1,15 @@ +//nolint:dupl // structurally similar to update.go; intentional sibling command package workspace import ( - "bytes" "context" - "fmt" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" - "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/provider" "github.com/spf13/cobra" @@ -26,8 +24,6 @@ type CreateWorkspaceCmd struct { } // NewCreateCmd creates a new command. -// -//nolint:dupl // structurally similar to NewUpdateCmd; intentional sibling factory func NewCreateCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &CreateWorkspaceCmd{ GlobalFlags: globalFlags, @@ -68,25 +64,12 @@ func (cmd *CreateWorkspaceCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - opts := devsyConfig.ProviderOptions(provider.Name) - opts[platform.WorkspaceInstanceEnv] = config.OptionValue{Value: cmd.Instance} - - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.Create.Workspace, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: provider, - Stdout: &buf, - Stderr: log.Writer(log.LevelError), + return proxycmd.RunAndPrint(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.Create.Workspace, + DevsyConfig: devsyConfig, + Provider: provider, + ExtraOptions: map[string]config.OptionValue{ + platform.WorkspaceInstanceEnv: {Value: cmd.Instance}, + }, }) - if err != nil { - return fmt.Errorf("create workspace: %w", err) - } - - fmt.Println(buf.String()) - - return nil } diff --git a/cmd/pro/workspace/list.go b/cmd/pro/workspace/list.go index 2d1199dac..a982d9d9f 100644 --- a/cmd/pro/workspace/list.go +++ b/cmd/pro/workspace/list.go @@ -1,21 +1,18 @@ package workspace import ( - "bytes" "context" "encoding/json" - "fmt" "time" managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -62,49 +59,35 @@ func (cmd *ListWorkspacesCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.List.Workspaces, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: &buf, - }) - if err != nil { - return fmt.Errorf("list workspaces: %w", err) - } - headers := []string{proutil.HeaderName, proutil.HeaderDisplayName, "Project", "Age"} - if buf.Len() == 0 { - table.Print(headers, nil) - return nil - } - - instances := []managementv1.DevsyWorkspaceInstance{} - if err := json.Unmarshal(buf.Bytes(), &instances); err != nil { - return fmt.Errorf("parse workspaces output: %w", err) - } - rows := make([][]string, 0, len(instances)) - for _, inst := range instances { - project := "" - if inst.GetLabels() != nil { - project = inst.GetLabels()["devsy.sh/project"] + return proxycmd.RunAndPrintTable(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.List.Workspaces, + DevsyConfig: devsyConfig, + Provider: provider, + }, headers, func(payload []byte) ([][]string, error) { + instances := []managementv1.DevsyWorkspaceInstance{} + if err := json.Unmarshal(payload, &instances); err != nil { + return nil, err } - age := "" - if !inst.CreationTimestamp.IsZero() { - age = time.Since(inst.CreationTimestamp.Time).Round(time.Second).String() - } - rows = append(rows, []string{ - inst.GetName(), - inst.Spec.DisplayName, - project, - age, - }) - } - table.Print(headers, rows) - return nil + rows := make([][]string, 0, len(instances)) + for _, inst := range instances { + project := "" + if inst.GetLabels() != nil { + project = inst.GetLabels()["devsy.sh/project"] + } + age := "" + if !inst.CreationTimestamp.IsZero() { + age = time.Since(inst.CreationTimestamp.Time).Round(time.Second).String() + } + rows = append(rows, []string{ + inst.GetName(), + inst.Spec.DisplayName, + project, + age, + }) + } + return rows, nil + }) } diff --git a/cmd/pro/workspace/update.go b/cmd/pro/workspace/update.go index a3d11640d..60bb523c6 100644 --- a/cmd/pro/workspace/update.go +++ b/cmd/pro/workspace/update.go @@ -1,17 +1,15 @@ +//nolint:dupl // structurally similar to create.go; intentional sibling command package workspace import ( - "bytes" "context" - "fmt" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/cmd/pro/proutil" - "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/client/proxycmd" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" - "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/provider" "github.com/spf13/cobra" @@ -26,8 +24,6 @@ type UpdateWorkspaceCmd struct { } // NewUpdateCmd creates a new command. -// -//nolint:dupl // structurally similar to NewCreateCmd; intentional sibling factory func NewUpdateCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &UpdateWorkspaceCmd{ GlobalFlags: globalFlags, @@ -68,25 +64,12 @@ func (cmd *UpdateWorkspaceCmd) Run( devsyConfig *config.Config, provider *provider.ProviderConfig, ) error { - opts := devsyConfig.ProviderOptions(provider.Name) - opts[platform.WorkspaceInstanceEnv] = config.OptionValue{Value: cmd.Instance} - - var buf bytes.Buffer - - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Proxy.Update.Workspace, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: provider, - Stdout: &buf, - Stderr: log.Writer(log.LevelError), + return proxycmd.RunAndPrint(ctx, proxycmd.Options{ + Command: provider.Exec.Proxy.Update.Workspace, + DevsyConfig: devsyConfig, + Provider: provider, + ExtraOptions: map[string]config.OptionValue{ + platform.WorkspaceInstanceEnv: {Value: cmd.Instance}, + }, }) - if err != nil { - return fmt.Errorf("update workspace with provider %q: %w", provider.Name, err) - } - - fmt.Println(buf.String()) - - return nil } diff --git a/cmd/pro/workspace/watch.go b/cmd/pro/workspace/watch.go index ed3d92537..5ba04543c 100644 --- a/cmd/pro/workspace/watch.go +++ b/cmd/pro/workspace/watch.go @@ -92,15 +92,17 @@ func (cmd *WatchWorkspacesCmd) Run( cancel() }() - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: cancelCtx, - Command: providerConfig.Exec.Proxy.Watch.Workspaces, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: providerConfig, - Stdout: os.Stdout, - Stderr: log.Writer(log.LevelError), - }) + err := clientimplementation.RunCommandWithBinaries( + cancelCtx, + clientimplementation.WorkspaceCommandConfig{ + Command: providerConfig.Exec.Proxy.Watch.Workspaces, + WorkspaceContextName: devsyConfig.DefaultContext, + Options: opts, + Config: providerConfig, + Stdout: os.Stdout, + Stderr: log.Writer(log.LevelError), + }, + ) if err != nil { return fmt.Errorf("watch workspaces with provider %q: %w", providerConfig.Name, err) } diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index ae633d683..d0a05b85a 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -203,15 +203,17 @@ func initProvider( return fmt.Errorf("save init state: %w", err) } - runErr := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: provider.Exec.Init, - Context: devsyConfig.DefaultContext, - Options: devsyConfig.ProviderOptions(provider.Name), - Config: provider, - Stdout: io2.stdout, - Stderr: io2.stderr, - }) + runErr := clientimplementation.RunCommandWithBinaries( + ctx, + clientimplementation.WorkspaceCommandConfig{ + Command: provider.Exec.Init, + WorkspaceContextName: devsyConfig.DefaultContext, + Options: devsyConfig.ProviderOptions(provider.Name), + Config: provider, + Stdout: io2.stdout, + Stderr: io2.stderr, + }, + ) if runErr != nil { entry.InitError = truncateInitError(runErr.Error()) if saveErr := config.SaveConfig(devsyConfig); saveErr != nil { diff --git a/cmd/workspace/logs.go b/cmd/workspace/logs.go index 3121d124d..e5fd3c658 100644 --- a/cmd/workspace/logs.go +++ b/cmd/workspace/logs.go @@ -121,8 +121,7 @@ func injectLogsAgent(ctx context.Context, params injectLogsAgentParams) error { stderr := log.Writer(log.LevelDebug) defer func() { _ = stderr.Close() }() - return agent.InjectAgent(&agent.InjectOptions{ - Ctx: ctx, + return agent.InjectAgent(ctx, &agent.InjectOptions{ Exec: func(ctx context.Context, command string, stdinR io.Reader, stdoutW io.Writer, stderrW io.Writer) error { return params.client.Command(ctx, clientpkg.CommandOptions{ Command: command, diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index cbb603931..b463e88bd 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -244,11 +244,12 @@ func newAgentInjectFunc( wInfo *provider2.AgentWorkspaceInfo, ) sshtunnel.AgentInjectFunc { return func( - cancelCtx context.Context, sshCmd string, sshTunnelStdinReader, sshTunnelStdoutWriter *os.File, + cancelCtx context.Context, + sshCmd string, + sshTunnelStdinReader, sshTunnelStdoutWriter *os.File, writer io.WriteCloser, ) error { - return agent.InjectAgent(&agent.InjectOptions{ - Ctx: cancelCtx, + return agent.InjectAgent(cancelCtx, &agent.InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return client.Command(ctx, client2.CommandOptions{ Command: command, diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 1f5a995cf..0898131c1 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -15,6 +15,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/context" _ "github.com/devsy-org/devsy/e2e/tests/delivery" _ "github.com/devsy-org/devsy/e2e/tests/dockerinstall" + _ "github.com/devsy-org/devsy/e2e/tests/down" _ "github.com/devsy-org/devsy/e2e/tests/exec" _ "github.com/devsy-org/devsy/e2e/tests/extends" _ "github.com/devsy-org/devsy/e2e/tests/extends-up" diff --git a/e2e/framework/exec.go b/e2e/framework/exec.go index 19741e8a8..761ee8e51 100644 --- a/e2e/framework/exec.go +++ b/e2e/framework/exec.go @@ -9,6 +9,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/devsy-org/devsy/pkg/docker" ) // ExecCommand executes the command string with the devsy test binary. @@ -16,6 +18,7 @@ func (f *Framework) ExecCommandOutput(ctx context.Context, args []string) (strin var execOut bytes.Buffer cmd := exec.CommandContext(ctx, filepath.Join(f.DevsyBinDir, f.DevsyBinName), args...) + docker.PrepareForGroupCancellation(cmd) cmd.Stdout = io.MultiWriter(os.Stdout, &execOut) cmd.Stderr = os.Stderr @@ -29,6 +32,7 @@ func (f *Framework) ExecCommandOutput(ctx context.Context, args []string) (strin // ExecCommandStdout executes the command string with the devsy test binary. func (f *Framework) ExecCommandStdout(ctx context.Context, args []string) error { cmd := exec.CommandContext(ctx, filepath.Join(f.DevsyBinDir, f.DevsyBinName), args...) + docker.PrepareForGroupCancellation(cmd) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -48,6 +52,7 @@ func (f *Framework) ExecCommand( var execOut bytes.Buffer cmd := exec.CommandContext(ctx, filepath.Join(f.DevsyBinDir, f.DevsyBinName), args...) + docker.PrepareForGroupCancellation(cmd) cmd.Stdout = io.MultiWriter(os.Stdout, &execOut) cmd.Stderr = os.Stderr @@ -72,6 +77,7 @@ func (f *Framework) ExecCommandCapture(ctx context.Context, args []string) (stri var execErr bytes.Buffer cmd := exec.CommandContext(ctx, filepath.Join(f.DevsyBinDir, f.DevsyBinName), args...) + docker.PrepareForGroupCancellation(cmd) cmd.Stdout = io.MultiWriter(os.Stdout, &execOut) cmd.Stderr = io.MultiWriter(os.Stderr, &execErr) diff --git a/e2e/tests/down/down.go b/e2e/tests/down/down.go new file mode 100644 index 000000000..6a4495a90 --- /dev/null +++ b/e2e/tests/down/down.go @@ -0,0 +1,142 @@ +package down + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + pkgconfig "github.com/devsy-org/devsy/pkg/config" + docker "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing workspace delete command", + ginkgo.Label("down"), + func() { + var dockerHelper *docker.DockerHelper + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + + dockerHelper = &docker.DockerHelper{DockerCommand: "docker"} + }) + + ginkgo.It("workspace delete stops and deletes workspace", func(ctx context.Context) { + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + + tempDir, err := framework.CopyToTempDir("tests/down/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + status, err := f.DevsyStatus(ctx, tempDir) + framework.ExpectNoError(err) + gomega.Expect(strings.ToUpper(status.State)).To(gomega.Equal("RUNNING")) + + workspace, err := f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + containerIDs, err := dockerHelper.FindContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(containerIDs). + NotTo(gomega.BeEmpty(), "container should exist before delete") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + + containerIDs, err = dockerHelper.FindContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(containerIDs). + To(gomega.BeEmpty(), "container should be deleted after delete") + + _, err = f.FindWorkspace(ctx, tempDir) + gomega.Expect(err). + To(gomega.HaveOccurred(), "workspace should not be in list after delete") + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + + ginkgo.It( + "workspace delete removes workspace with restrictive folder permissions", + func(ctx context.Context) { + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + + name := "vscode-remote-try-python" + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, name) + + err = f.DevsyUp(ctx, "https://github.com/microsoft/vscode-remote-try-python.git") + framework.ExpectNoError(err) + + workspace, err := f.FindWorkspace(ctx, name) + framework.ExpectNoError(err) + + if workspace.Source.LocalFolder != "" { + folder := workspace.Source.LocalFolder + err = os.Chmod(folder, 0o500) //nolint:gosec + framework.ExpectNoError(err) + } + + err = f.DevsyWorkspaceDelete(ctx, name) + framework.ExpectNoError(err) + + _, err = f.FindWorkspace(ctx, name) + gomega.Expect(err). + To(gomega.HaveOccurred(), "workspace should not be in list after delete") + }, + ginkgo.SpecTimeout(framework.TimeoutModerate()), + ) + + ginkgo.It("stop only stops and does not delete workspace", func(ctx context.Context) { + f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + + tempDir, err := framework.CopyToTempDir("tests/down/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + status, err := f.DevsyStatus(ctx, tempDir) + framework.ExpectNoError(err) + gomega.Expect(strings.ToUpper(status.State)).To(gomega.Equal("RUNNING")) + + workspace, err := f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyStop(ctx, tempDir) + framework.ExpectNoError(err) + + status, err = f.DevsyStatus(ctx, tempDir) + framework.ExpectNoError(err) + gomega.Expect(strings.ToUpper(status.State)).To(gomega.Equal("STOPPED")) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + containerIDs, err := dockerHelper.FindContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(containerIDs).NotTo( + gomega.BeEmpty(), + "container should still exist after stop (only stopped, not deleted)", + ) + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + }, +) diff --git a/e2e/tests/down/testdata/docker/.devcontainer.json b/e2e/tests/down/testdata/docker/.devcontainer.json new file mode 100644 index 000000000..25e8ab286 --- /dev/null +++ b/e2e/tests/down/testdata/docker/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Go", + "image": "ghcr.io/devsy-org/test-images/go:1" +} diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 869e8dc9e..71b49518f 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -380,7 +380,7 @@ var _ = ginkgo.Describe( lines = strings.Count(strings.TrimSpace(out), "\n") + 1 gomega.Expect(lines).To(gomega.Equal(2), "postStartCommand should have run again after restart") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) ginkgo.It("waitFor defers postCreateCommand to background", func(ctx context.Context) { tempDir, err := setupWorkspace( @@ -459,21 +459,22 @@ var _ = ginkgo.Describe( gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("postStartDone"), "postStartCommand should have completed before devsy up returned") - // postAttachCommand should NOT have completed yet (it sleeps 15s) _, err = dtc.execSSH(ctx, tempDir, "cat $HOME/post-attach.out") gomega.Expect(err).To(gomega.HaveOccurred(), - "postAttachCommand should still be running when devsy up returns") + "postAttachCommand must still be blocked on the release marker") + + _, err = dtc.execSSH(ctx, tempDir, "touch $HOME/release-post-attach") + framework.ExpectNoError(err) - // Wait for postAttachCommand to finish and verify it does complete gomega.Eventually(func() string { out, err := dtc.execSSH(ctx, tempDir, "cat $HOME/post-attach.out 2>/dev/null") if err != nil { return "" } return strings.TrimSpace(out) - }).WithTimeout(30*time.Second).WithPolling(2*time.Second).Should( + }).WithTimeout(15*time.Second).WithPolling(500*time.Millisecond).Should( gomega.Equal("postAttachDone"), - "postAttachCommand should eventually complete in the background", + "postAttachCommand should complete promptly once released", ) }, ginkgo.SpecTimeout(framework.TimeoutShort())) @@ -514,7 +515,7 @@ var _ = ginkgo.Describe( gomega.Equal("2"), "postAttachCommand should run again on second attach", ) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) ginkgo.It( "initializeCommand with object syntax runs named sub-commands in parallel", diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go deleted file mode 100644 index 67a537b6f..000000000 --- a/e2e/tests/up/provider_podman.go +++ /dev/null @@ -1,1342 +0,0 @@ -package up - -import ( - "context" - "os" - "os/exec" - "path" - "path/filepath" - "strings" - "time" - - "github.com/devsy-org/devsy/e2e/framework" - "github.com/devsy-org/devsy/pkg/flags/names" - "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe( - "testing up command for podman provider", - ginkgo.Label("up-provider-podman"), - func() { - var initialDir string - - ginkgo.BeforeEach(func() { - var err error - initialDir, err = os.Getwd() - framework.ExpectNoError(err) - }) - - ginkgo.Context("with rootless podman", func() { - var f *framework.Framework - - ginkgo.BeforeEach(func(ctx context.Context) { - var err error - f, err = setupDockerProvider(initialDir+"/bin", "podman") - framework.ExpectNoError(err) - }) - - ginkgo.Context("basic", func() { - ginkgo.It( - "should start a new workspace with existing image", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - // Regression: rootless podman bind-mounts the workspace folder - // root-owned, so a non-root remoteUser couldn't chdir into it - // and the in-container SSH server failed with "fork/exec - // /usr/bin/bash: permission denied". - ginkgo.It( - "should ssh into a workspace with a non-root remoteUser", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-nonroot-user", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - whoami, err := f.DevsySSH(ctx, tempDir, "whoami") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(whoami)).To(gomega.Equal("devsyuser")) - - // Assert ownership, not just chdir success: a - // world-searchable bind source would let chdir pass even - // without the chown. - owner, err := f.DevsySSH(ctx, tempDir, `stat -c %U "$PWD"`) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), - "workspace folder should be chowned to the remote user") - }, - ginkgo.SpecTimeout(framework.TimeoutLong()), - ) - }) - - ginkgo.Context("build", func() { - ginkgo.It( - "should start a workspace with a multistage Dockerfile build", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-with-multi-stage-build", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutLong()), - ) - - ginkgo.It( - "should build and respect overrideCommand false", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-override-command-false", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("lifecycle commands", func() { - ginkgo.It( - "should run postCreateCommand with object syntax", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-postcreate-parallel", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - one, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-one.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(one)).To(gomega.Equal("postCreateOne")) - - two, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-two.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(two)).To(gomega.Equal("postCreateTwo")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It("should run postStartCommand after restart", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-start-restart", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") - framework.ExpectNoError(err) - lines := strings.Count(strings.TrimSpace(out), "\n") + 1 - gomega.Expect(lines).To(gomega.Equal(1), - "postStartCommand should have run once after initial up") - - err = f.DevsyWorkspaceStop(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") - framework.ExpectNoError(err) - lines = strings.Count(strings.TrimSpace(out), "\n") + 1 - gomega.Expect(lines).To(gomega.Equal(2), - "postStartCommand should have run again after restart") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It( - "should defer postCreateCommand to background with waitFor", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-waitfor", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/on-create.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("onCreateDone")) - - out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/update-content.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("updateContentDone")) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/deferred.marker 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postCreateDone"), - ) - - envPath, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/deferred-env-path.out", - ) - framework.ExpectNoError(err) - gomega.Expect(envPath).To( - gomega.ContainSubstring("/usr/local/bin"), - ) - gomega.Expect(envPath).NotTo(gomega.ContainSubstring("${containerEnv:")) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, - tempDir, - "cat $HOME/post-start-deferred.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postStartDone"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should make IDE accessible before postAttachCommand completes", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-attach-nonblocking", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("postStartDone")) - - _, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-attach.out") - gomega.Expect(err).To(gomega.HaveOccurred()) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/post-attach.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postAttachDone"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should run postAttachCommand on every attach", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-attach-every-time", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( - gomega.Equal("1"), - ) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( - gomega.Equal("2"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should run initializeCommand with object syntax", - func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-initcmd-parallel", - initialDir, - f, - ) - framework.ExpectNoError(err) - - one, err := os.ReadFile( //nolint:gosec // G304 - filepath.Join(tempDir, "init-cmd-one.out"), - ) - framework.ExpectNoError(err) - gomega.Expect(string(one)).To(gomega.Equal("initCmdOne")) - - two, err := os.ReadFile( //nolint:gosec // G304 - filepath.Join(tempDir, "init-cmd-two.out"), - ) - framework.ExpectNoError(err) - gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should inject secrets-file env into lifecycle commands", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-secrets-file", - initialDir, - f, - ) - framework.ExpectNoError(err) - - secretsDir, err := framework.CreateTempDir() - framework.ExpectNoError(err) - ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - - secretsFile := filepath.Join(secretsDir, "secrets.json") - err = os.WriteFile( - secretsFile, - []byte( - `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, - ), - 0o600, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir, "--secrets-file", secretsFile) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/secret-check.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)). - To(gomega.Equal("test-value-12345")) - - out, err = f.DevsySSH( - ctx, tempDir, "cat /tmp/another-secret-check.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)). - To(gomega.Equal("second-secret-42")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("agent delivery", func() { - ginkgo.It( - "should deliver the agent binary and execute SSH commands", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsySSHEchoTestString(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("exec", func() { - ginkgo.It( - "should execute commands inside the container via SSH", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "echo -n hello-podman") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "hello-podman") - - out, err = f.DevsySSH(ctx, tempDir, "pwd") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).NotTo(gomega.BeEmpty()) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("cleanup", func() { - ginkgo.It( - "should delete workspace and clean up resources", - func(ctx context.Context) { - tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") - framework.ExpectNoError(err) - ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - _, err = f.FindWorkspace(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - - _, err = f.FindWorkspace(ctx, tempDir) - framework.ExpectError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("configuration", func() { //nolint:dupl - ginkgo.It("should substitute variables", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-variables", - initialDir, - f, - "--init-env", "CUSTOM_VAR=custom_value", - "--init-env", "CUSTOM_IMAGE=ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - devContainerID, err := f.DevsySSH( - ctx, - tempDir, - "cat $HOME/dev-container-id.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(devContainerID)).NotTo(gomega.BeEmpty()) - - containerEnvPath, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-env-path.out", - ) - framework.ExpectNoError(err) - gomega.Expect(containerEnvPath).To(gomega.ContainSubstring("/usr/local/bin")) - - localEnvHome, err := f.DevsySSH(ctx, tempDir, "cat $HOME/local-env-home.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(localEnvHome)). - To(gomega.Equal(os.Getenv("HOME"))) - - localWorkspaceFolder, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/local-workspace-folder.out", - ) - framework.ExpectNoError(err) - gomega.Expect( - framework.CleanString(strings.TrimSpace(localWorkspaceFolder)), - ).To(gomega.Equal(framework.CleanString(tempDir))) - - localWorkspaceFolderBasename, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/local-workspace-folder-basename.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(localWorkspaceFolderBasename)). - To(gomega.Equal(filepath.Base(tempDir))) - - containerWorkspaceFolder, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-workspace-folder.out", - ) - framework.ExpectNoError(err) - gomega.Expect( - framework.CleanString(strings.TrimSpace(containerWorkspaceFolder)), - ).To(gomega.Equal( - framework.CleanString(path.Join("/workspaces", filepath.Base(tempDir))), - )) - - containerWorkspaceFolderBasename, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-workspace-folder-basename.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(containerWorkspaceFolderBasename)). - To(gomega.Equal(filepath.Base(tempDir))) - - customVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-var.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(customVar)).To(gomega.Equal("custom_value")) - - customImage, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-image.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(customImage)). - To(gomega.Equal("ghcr.io/devsy-org/test-images/base:alpine")) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should substitute variables with defaults", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-variables-defaults", - initialDir, - f, - ) - framework.ExpectNoError(err) - - withDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/with-default.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(withDefault)). - To(gomega.Equal("my_default_value")) - - colonDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/colon-default.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(colonDefault)). - To(gomega.Equal("http://proxy:8080")) - - setVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/set-var.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(setVar)).To(gomega.Equal(os.Getenv("HOME"))) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should merge extra devcontainer config", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-extra-devcontainer", - initialDir, - f, - ) - framework.ExpectNoError(err) - - extraPath := path.Join(tempDir, "extra.json") - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainerOverlay), - extraPath, - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $BASE_VAR'") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "base_value") - - out, err = f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $EXTRA_VAR'") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "extra_value") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It( - "should override with extra devcontainer config", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-extra-override", - initialDir, - f, - ) - framework.ExpectNoError(err) - - extraPath := path.Join(tempDir, "override.json") - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainerOverlay), - extraPath, - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/test-var.out") - framework.ExpectNoError(err) - framework.ExpectEqual(strings.TrimSpace(out), "overridden_value") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It("should select from multiple devcontainers", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-multi-devcontainer", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainer), - "id:python", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH( - ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", - ) - framework.ExpectNoError(err) - framework.ExpectEqual(out, "python") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir, names.Flag(names.DevContainer), "id:go") - framework.ExpectNoError(err) - - out, err = f.DevsySSH( - ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", - ) - framework.ExpectNoError(err) - framework.ExpectEqual(out, "go") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - }) - - ginkgo.Context("features", func() { - ginkgo.It("should mount volumes", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-mounts", - initialDir, - f, - "--debug", - ) - framework.ExpectNoError(err) - - foo, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt1/foo.txt") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(foo)).To(gomega.Equal("BAR")) - - bar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt2/bar.txt") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(bar)).To(gomega.Equal("FOO")) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should use custom image", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker", - initialDir, - f, - "--devcontainer-image", - "ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "ID=alpine\n") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should skip build with custom image", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-with-multi-stage-build", - initialDir, - f, - "--devcontainer-image", - "ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "ID=alpine\n") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - }) - }) - - ginkgo.Context("with rootful podman", func() { - var f *framework.Framework - - ginkgo.BeforeEach(func(ctx context.Context) { - wrapper, err := os.Create(initialDir + "/bin/podman-rootful") - framework.ExpectNoError(err) - - _, err = wrapper.WriteString("#!/bin/sh\nsudo podman \"$@\"\n") - if err != nil { - _ = wrapper.Close() - framework.ExpectNoError(err) - } - - err = wrapper.Close() - framework.ExpectNoError(err) - - // #nosec G302 -- wrapper script needs execute permission - err = os.Chmod(initialDir+"/bin/podman-rootful", 0o755) - framework.ExpectNoError(err) - - err = exec.Command(initialDir+"/bin/podman-rootful", "ps").Run() - framework.ExpectNoError(err) - - ginkgo.DeferCleanup(func() { - _ = os.Remove(initialDir + "/bin/podman-rootful") - }) - - f, err = setupDockerProvider(initialDir+"/bin", initialDir+"/bin/podman-rootful") - framework.ExpectNoError(err) - }) - - ginkgo.Context("basic", func() { - ginkgo.It( - "should start a new workspace with existing image", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("build", func() { - ginkgo.It( - "should start a workspace with a multistage Dockerfile build", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-with-multi-stage-build", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutLong()), - ) - - ginkgo.It( - "should build and respect overrideCommand false", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-override-command-false", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("lifecycle commands", func() { - ginkgo.It( - "should run postCreateCommand with object syntax", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-postcreate-parallel", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - one, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-one.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(one)).To(gomega.Equal("postCreateOne")) - - two, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-two.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(two)).To(gomega.Equal("postCreateTwo")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It("should run postStartCommand after restart", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-start-restart", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") - framework.ExpectNoError(err) - lines := strings.Count(strings.TrimSpace(out), "\n") + 1 - gomega.Expect(lines).To(gomega.Equal(1), - "postStartCommand should have run once after initial up") - - err = f.DevsyWorkspaceStop(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") - framework.ExpectNoError(err) - lines = strings.Count(strings.TrimSpace(out), "\n") + 1 - gomega.Expect(lines).To(gomega.Equal(2), - "postStartCommand should have run again after restart") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It( - "should defer postCreateCommand to background with waitFor", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-waitfor", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/on-create.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("onCreateDone")) - - out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/update-content.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("updateContentDone")) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/deferred.marker 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postCreateDone"), - ) - - envPath, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/deferred-env-path.out", - ) - framework.ExpectNoError(err) - gomega.Expect(envPath).To( - gomega.ContainSubstring("/usr/local/bin"), - ) - gomega.Expect(envPath).NotTo(gomega.ContainSubstring("${containerEnv:")) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, - tempDir, - "cat $HOME/post-start-deferred.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postStartDone"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should make IDE accessible before postAttachCommand completes", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-attach-nonblocking", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("postStartDone")) - - _, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-attach.out") - gomega.Expect(err).To(gomega.HaveOccurred()) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/post-attach.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( - gomega.Equal("postAttachDone"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should run postAttachCommand on every attach", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-post-attach-every-time", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( - gomega.Equal("1"), - ) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - gomega.Eventually(func() string { - out, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", - ) - if err != nil { - return "" - } - return strings.TrimSpace(out) - }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( - gomega.Equal("2"), - ) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should run initializeCommand with object syntax", - func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-initcmd-parallel", - initialDir, - f, - ) - framework.ExpectNoError(err) - - one, err := os.ReadFile( //nolint:gosec // G304 - filepath.Join(tempDir, "init-cmd-one.out"), - ) - framework.ExpectNoError(err) - gomega.Expect(string(one)).To(gomega.Equal("initCmdOne")) - - two, err := os.ReadFile( //nolint:gosec // G304 - filepath.Join(tempDir, "init-cmd-two.out"), - ) - framework.ExpectNoError(err) - gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It( - "should inject secrets-file env into lifecycle commands", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-secrets-file", - initialDir, - f, - ) - framework.ExpectNoError(err) - - secretsDir, err := framework.CreateTempDir() - framework.ExpectNoError(err) - ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - - secretsFile := filepath.Join(secretsDir, "secrets.json") - err = os.WriteFile( - secretsFile, - []byte( - `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, - ), - 0o600, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir, "--secrets-file", secretsFile) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/secret-check.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)). - To(gomega.Equal("test-value-12345")) - - out, err = f.DevsySSH( - ctx, tempDir, "cat /tmp/another-secret-check.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)). - To(gomega.Equal("second-secret-42")) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("agent delivery", func() { - ginkgo.It( - "should deliver the agent binary and execute SSH commands", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsySSHEchoTestString(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("exec", func() { - ginkgo.It( - "should execute commands inside the container via SSH", - func(ctx context.Context) { - tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "echo -n hello-podman") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "hello-podman") - - out, err = f.DevsySSH(ctx, tempDir, "pwd") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(out)).NotTo(gomega.BeEmpty()) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("cleanup", func() { - ginkgo.It( - "should delete workspace and clean up resources", - func(ctx context.Context) { - tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") - framework.ExpectNoError(err) - ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) - - err = f.DevsyUp(ctx, tempDir) - framework.ExpectNoError(err) - - _, err = f.FindWorkspace(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - - _, err = f.FindWorkspace(ctx, tempDir) - framework.ExpectError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - }) - - ginkgo.Context("configuration", func() { //nolint:dupl - ginkgo.It("should substitute variables", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-variables", - initialDir, - f, - "--init-env", "CUSTOM_VAR=custom_value", - "--init-env", "CUSTOM_IMAGE=ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - devContainerID, err := f.DevsySSH( - ctx, - tempDir, - "cat $HOME/dev-container-id.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(devContainerID)).NotTo(gomega.BeEmpty()) - - containerEnvPath, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-env-path.out", - ) - framework.ExpectNoError(err) - gomega.Expect(containerEnvPath).To(gomega.ContainSubstring("/usr/local/bin")) - - localEnvHome, err := f.DevsySSH(ctx, tempDir, "cat $HOME/local-env-home.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(localEnvHome)). - To(gomega.Equal(os.Getenv("HOME"))) - - localWorkspaceFolder, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/local-workspace-folder.out", - ) - framework.ExpectNoError(err) - gomega.Expect( - framework.CleanString(strings.TrimSpace(localWorkspaceFolder)), - ).To(gomega.Equal(framework.CleanString(tempDir))) - - localWorkspaceFolderBasename, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/local-workspace-folder-basename.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(localWorkspaceFolderBasename)). - To(gomega.Equal(filepath.Base(tempDir))) - - containerWorkspaceFolder, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-workspace-folder.out", - ) - framework.ExpectNoError(err) - gomega.Expect( - framework.CleanString(strings.TrimSpace(containerWorkspaceFolder)), - ).To(gomega.Equal( - framework.CleanString(path.Join("/workspaces", filepath.Base(tempDir))), - )) - - containerWorkspaceFolderBasename, err := f.DevsySSH( - ctx, tempDir, "cat $HOME/container-workspace-folder-basename.out", - ) - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(containerWorkspaceFolderBasename)). - To(gomega.Equal(filepath.Base(tempDir))) - - customVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-var.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(customVar)).To(gomega.Equal("custom_value")) - - customImage, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-image.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(customImage)). - To(gomega.Equal("ghcr.io/devsy-org/test-images/base:alpine")) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should substitute variables with defaults", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-variables-defaults", - initialDir, - f, - ) - framework.ExpectNoError(err) - - withDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/with-default.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(withDefault)). - To(gomega.Equal("my_default_value")) - - colonDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/colon-default.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(colonDefault)). - To(gomega.Equal("http://proxy:8080")) - - setVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/set-var.out") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(setVar)).To(gomega.Equal(os.Getenv("HOME"))) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should merge extra devcontainer config", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-extra-devcontainer", - initialDir, - f, - ) - framework.ExpectNoError(err) - - extraPath := path.Join(tempDir, "extra.json") - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainerOverlay), - extraPath, - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $BASE_VAR'") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "base_value") - - out, err = f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $EXTRA_VAR'") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "extra_value") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It( - "should override with extra devcontainer config", - func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-extra-override", - initialDir, - f, - ) - framework.ExpectNoError(err) - - extraPath := path.Join(tempDir, "override.json") - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainerOverlay), - extraPath, - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/test-var.out") - framework.ExpectNoError(err) - framework.ExpectEqual(strings.TrimSpace(out), "overridden_value") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, - ginkgo.SpecTimeout(framework.TimeoutShort()), - ) - - ginkgo.It("should select from multiple devcontainers", func(ctx context.Context) { - tempDir, err := setupWorkspace( - "tests/up/testdata/docker-multi-devcontainer", - initialDir, - f, - ) - framework.ExpectNoError(err) - - err = f.DevsyUp( - ctx, - tempDir, - names.Flag(names.DevContainer), - "id:python", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH( - ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", - ) - framework.ExpectNoError(err) - framework.ExpectEqual(out, "python") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - - err = f.DevsyUp(ctx, tempDir, names.Flag(names.DevContainer), "id:go") - framework.ExpectNoError(err) - - out, err = f.DevsySSH( - ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", - ) - framework.ExpectNoError(err) - framework.ExpectEqual(out, "go") - - err = f.DevsyWorkspaceDelete(ctx, tempDir) - framework.ExpectNoError(err) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - }) - - ginkgo.Context("features", func() { - ginkgo.It("should mount volumes", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-mounts", - initialDir, - f, - "--debug", - ) - framework.ExpectNoError(err) - - foo, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt1/foo.txt") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(foo)).To(gomega.Equal("BAR")) - - bar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt2/bar.txt") - framework.ExpectNoError(err) - gomega.Expect(strings.TrimSpace(bar)).To(gomega.Equal("FOO")) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should use custom image", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker", - initialDir, - f, - "--devcontainer-image", - "ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "ID=alpine\n") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("should skip build with custom image", func(ctx context.Context) { - tempDir, err := setupWorkspaceAndUp( - ctx, - "tests/up/testdata/docker-with-multi-stage-build", - initialDir, - f, - "--devcontainer-image", - "ghcr.io/devsy-org/test-images/base:alpine", - ) - framework.ExpectNoError(err) - - out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") - framework.ExpectNoError(err) - framework.ExpectEqual(out, "ID=alpine\n") - }, ginkgo.SpecTimeout(framework.TimeoutShort())) - }) - }) - }, -) diff --git a/e2e/tests/up/provider_podman_rootful_basic.go b/e2e/tests/up/provider_podman_rootful_basic.go new file mode 100644 index 000000000..5e61fc859 --- /dev/null +++ b/e2e/tests/up/provider_podman_rootful_basic.go @@ -0,0 +1,181 @@ +package up + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootful-basic"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootful podman", func() { + var f *framework.Framework + + //nolint:dupl // shared rootful podman wrapper setup across split files + ginkgo.BeforeEach(func(ctx context.Context) { + wrapper, err := os.Create( //nolint:gosec // G304: test-controlled path + initialDir + "/bin/podman-rootful", + ) + framework.ExpectNoError(err) + + _, err = wrapper.WriteString("#!/bin/sh\nsudo podman \"$@\"\n") + if err != nil { + _ = wrapper.Close() + framework.ExpectNoError(err) + } + + err = wrapper.Close() + framework.ExpectNoError(err) + + // #nosec G302 -- wrapper script needs execute permission + err = os.Chmod(initialDir+"/bin/podman-rootful", 0o755) + framework.ExpectNoError(err) + + cmd := exec.CommandContext( //nolint:gosec // G204: test-controlled path + ctx, initialDir+"/bin/podman-rootful", "ps", + ) + docker.PrepareForGroupCancellation(cmd) + out, err := cmd.CombinedOutput() + framework.ExpectNoError(err, string(out)) + + ginkgo.DeferCleanup(func() { + _ = os.Remove(initialDir + "/bin/podman-rootful") + }) + + f, err = setupDockerProvider( + initialDir+"/bin", + initialDir+"/bin/podman-rootful", + ) + framework.ExpectNoError(err) + }, + ) + + ginkgo.Context("basic", func() { + ginkgo.It( + "should start a new workspace with existing image", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("build", func() { + ginkgo.It( + "should start a workspace with a multistage Dockerfile build", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-with-multi-stage-build", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + + ginkgo.It( + "should build and respect overrideCommand false", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-override-command-false", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("agent delivery", func() { + ginkgo.It( + "should deliver the agent binary and execute SSH commands", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsySSHEchoTestString(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("exec", func() { + ginkgo.It( + "should execute commands inside the container via SSH", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "echo -n hello-podman") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "hello-podman") + + out, err = f.DevsySSH(ctx, tempDir, "pwd") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).NotTo(gomega.BeEmpty()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("cleanup", func() { + ginkgo.It( + "should delete workspace and clean up resources", + func(ctx context.Context) { + tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootful_config.go b/e2e/tests/up/provider_podman_rootful_config.go new file mode 100644 index 000000000..b497a1220 --- /dev/null +++ b/e2e/tests/up/provider_podman_rootful_config.go @@ -0,0 +1,267 @@ +package up + +import ( + "context" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootful-config"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootful podman", func() { + var f *framework.Framework + + //nolint:dupl // shared rootful podman wrapper setup across split files + ginkgo.BeforeEach(func(ctx context.Context) { + wrapper, err := os.Create( //nolint:gosec // G304: test-controlled path + initialDir + "/bin/podman-rootful", + ) + framework.ExpectNoError(err) + + _, err = wrapper.WriteString("#!/bin/sh\nsudo podman \"$@\"\n") + if err != nil { + _ = wrapper.Close() + framework.ExpectNoError(err) + } + + err = wrapper.Close() + framework.ExpectNoError(err) + + // #nosec G302 -- wrapper script needs execute permission + err = os.Chmod(initialDir+"/bin/podman-rootful", 0o755) + framework.ExpectNoError(err) + + cmd := exec.CommandContext( //nolint:gosec // G204: test-controlled path + ctx, initialDir+"/bin/podman-rootful", "ps", + ) + docker.PrepareForGroupCancellation(cmd) + out, err := cmd.CombinedOutput() + framework.ExpectNoError(err, string(out)) + + ginkgo.DeferCleanup(func() { + _ = os.Remove(initialDir + "/bin/podman-rootful") + }) + + f, err = setupDockerProvider( + initialDir+"/bin", + initialDir+"/bin/podman-rootful", + ) + framework.ExpectNoError(err) + }, + ) + + ginkgo.Context("configuration", func() { //nolint:dupl + ginkgo.It("should substitute variables", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-variables", + initialDir, + f, + "--init-env", "CUSTOM_VAR=custom_value", + "--init-env", "CUSTOM_IMAGE=ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + devContainerID, err := f.DevsySSH( + ctx, + tempDir, + "cat $HOME/dev-container-id.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(devContainerID)).NotTo(gomega.BeEmpty()) + + containerEnvPath, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-env-path.out", + ) + framework.ExpectNoError(err) + gomega.Expect(containerEnvPath).To(gomega.ContainSubstring("/usr/local/bin")) + + localEnvHome, err := f.DevsySSH(ctx, tempDir, "cat $HOME/local-env-home.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(localEnvHome)). + To(gomega.Equal(os.Getenv("HOME"))) + + localWorkspaceFolder, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/local-workspace-folder.out", + ) + framework.ExpectNoError(err) + gomega.Expect( + framework.CleanString(strings.TrimSpace(localWorkspaceFolder)), + ).To(gomega.Equal(framework.CleanString(tempDir))) + + localWorkspaceFolderBasename, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/local-workspace-folder-basename.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(localWorkspaceFolderBasename)). + To(gomega.Equal(filepath.Base(tempDir))) + + containerWorkspaceFolder, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-workspace-folder.out", + ) + framework.ExpectNoError(err) + gomega.Expect( + framework.CleanString(strings.TrimSpace(containerWorkspaceFolder)), + ).To(gomega.Equal( + framework.CleanString(path.Join("/workspaces", filepath.Base(tempDir))), + )) + + containerWorkspaceFolderBasename, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-workspace-folder-basename.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(containerWorkspaceFolderBasename)). + To(gomega.Equal(filepath.Base(tempDir))) + + customVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-var.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(customVar)).To(gomega.Equal("custom_value")) + + customImage, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-image.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(customImage)). + To(gomega.Equal("ghcr.io/devsy-org/test-images/base:alpine")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should substitute variables with defaults", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-variables-defaults", + initialDir, + f, + ) + framework.ExpectNoError(err) + + withDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/with-default.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(withDefault)). + To(gomega.Equal("my_default_value")) + + colonDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/colon-default.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(colonDefault)). + To(gomega.Equal("http://proxy:8080")) + + setVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/set-var.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(setVar)).To(gomega.Equal(os.Getenv("HOME"))) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should merge extra devcontainer config", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-extra-devcontainer", + initialDir, + f, + ) + framework.ExpectNoError(err) + + extraPath := path.Join(tempDir, "extra.json") + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainerOverlay), + extraPath, + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $BASE_VAR'") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "base_value") + + out, err = f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $EXTRA_VAR'") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "extra_value") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "should override with extra devcontainer config", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-extra-override", + initialDir, + f, + ) + framework.ExpectNoError(err) + + extraPath := path.Join(tempDir, "override.json") + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainerOverlay), + extraPath, + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/test-var.out") + framework.ExpectNoError(err) + framework.ExpectEqual(strings.TrimSpace(out), "overridden_value") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It("should select from multiple devcontainers", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-multi-devcontainer", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainer), + "id:python", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH( + ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", + ) + framework.ExpectNoError(err) + framework.ExpectEqual(out, "python") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir, names.Flag(names.DevContainer), "id:go") + framework.ExpectNoError(err) + + out, err = f.DevsySSH( + ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", + ) + framework.ExpectNoError(err) + framework.ExpectEqual(out, "go") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootful_features.go b/e2e/tests/up/provider_podman_rootful_features.go new file mode 100644 index 000000000..2a9f8884b --- /dev/null +++ b/e2e/tests/up/provider_podman_rootful_features.go @@ -0,0 +1,123 @@ +package up + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootful-features"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootful podman", func() { + var f *framework.Framework + + //nolint:dupl // shared rootful podman wrapper setup across split files + ginkgo.BeforeEach(func(ctx context.Context) { + wrapper, err := os.Create( //nolint:gosec // G304: test-controlled path + initialDir + "/bin/podman-rootful", + ) + framework.ExpectNoError(err) + + _, err = wrapper.WriteString("#!/bin/sh\nsudo podman \"$@\"\n") + if err != nil { + _ = wrapper.Close() + framework.ExpectNoError(err) + } + + err = wrapper.Close() + framework.ExpectNoError(err) + + // #nosec G302 -- wrapper script needs execute permission + err = os.Chmod(initialDir+"/bin/podman-rootful", 0o755) + framework.ExpectNoError(err) + + cmd := exec.CommandContext( //nolint:gosec // G204: test-controlled path + ctx, initialDir+"/bin/podman-rootful", "ps", + ) + docker.PrepareForGroupCancellation(cmd) + out, err := cmd.CombinedOutput() + framework.ExpectNoError(err, string(out)) + + ginkgo.DeferCleanup(func() { + _ = os.Remove(initialDir + "/bin/podman-rootful") + }) + + f, err = setupDockerProvider( + initialDir+"/bin", + initialDir+"/bin/podman-rootful", + ) + framework.ExpectNoError(err) + }, + ) + + ginkgo.Context("features", func() { //nolint:dupl + ginkgo.It("should mount volumes", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-mounts", + initialDir, + f, + "--debug", + ) + framework.ExpectNoError(err) + + foo, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt1/foo.txt") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(foo)).To(gomega.Equal("BAR")) + + bar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt2/bar.txt") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(bar)).To(gomega.Equal("FOO")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should use custom image", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker", + initialDir, + f, + "--devcontainer-image", + "ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "ID=alpine\n") + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should skip build with custom image", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-with-multi-stage-build", + initialDir, + f, + "--devcontainer-image", + "ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "ID=alpine\n") + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootful_lifecycle.go b/e2e/tests/up/provider_podman_rootful_lifecycle.go new file mode 100644 index 000000000..f050289b2 --- /dev/null +++ b/e2e/tests/up/provider_podman_rootful_lifecycle.go @@ -0,0 +1,339 @@ +package up + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootful-lifecycle"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootful podman", func() { + var f *framework.Framework + + //nolint:dupl // shared rootful podman wrapper setup across split files + ginkgo.BeforeEach(func(ctx context.Context) { + wrapper, err := os.Create( //nolint:gosec // G304: test-controlled path + initialDir + "/bin/podman-rootful", + ) + framework.ExpectNoError(err) + + _, err = wrapper.WriteString("#!/bin/sh\nsudo podman \"$@\"\n") + if err != nil { + _ = wrapper.Close() + framework.ExpectNoError(err) + } + + err = wrapper.Close() + framework.ExpectNoError(err) + + // #nosec G302 -- wrapper script needs execute permission + err = os.Chmod(initialDir+"/bin/podman-rootful", 0o755) + framework.ExpectNoError(err) + + cmd := exec.CommandContext( //nolint:gosec // G204: test-controlled path + ctx, initialDir+"/bin/podman-rootful", "ps", + ) + docker.PrepareForGroupCancellation(cmd) + out, err := cmd.CombinedOutput() + framework.ExpectNoError(err, string(out)) + + ginkgo.DeferCleanup(func() { + _ = os.Remove(initialDir + "/bin/podman-rootful") + }) + + f, err = setupDockerProvider( + initialDir+"/bin", + initialDir+"/bin/podman-rootful", + ) + framework.ExpectNoError(err) + }, + ) + + ginkgo.Context("lifecycle commands", func() { //nolint:dupl + ginkgo.It( + "should run postCreateCommand with object syntax", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-postcreate-parallel", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + one, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-one.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(one)).To(gomega.Equal("postCreateOne")) + + two, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-two.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(two)).To(gomega.Equal("postCreateTwo")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It("should run postStartCommand after restart", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-start-restart", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") + framework.ExpectNoError(err) + lines := strings.Count(strings.TrimSpace(out), "\n") + 1 + gomega.Expect(lines).To(gomega.Equal(1), + "postStartCommand should have run once after initial up") + + err = f.DevsyWorkspaceStop(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") + framework.ExpectNoError(err) + lines = strings.Count(strings.TrimSpace(out), "\n") + 1 + gomega.Expect(lines).To(gomega.Equal(2), + "postStartCommand should have run again after restart") + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + + ginkgo.It( + "should defer postCreateCommand to background with waitFor", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-waitfor", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/on-create.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("onCreateDone")) + + out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/update-content.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("updateContentDone")) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/deferred.marker 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( + gomega.Equal("postCreateDone"), + ) + + envPath, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/deferred-env-path.out", + ) + framework.ExpectNoError(err) + gomega.Expect(envPath).To( + gomega.ContainSubstring("/usr/local/bin"), + ) + gomega.Expect(envPath).NotTo(gomega.ContainSubstring("${containerEnv:")) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, + tempDir, + "cat $HOME/post-start-deferred.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( + gomega.Equal("postStartDone"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should make IDE accessible before postAttachCommand completes", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-attach-nonblocking", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("postStartDone")) + + _, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-attach.out") + gomega.Expect(err).To(gomega.HaveOccurred(), + "postAttachCommand must still be blocked on the release marker") + + _, err = f.DevsySSH(ctx, tempDir, "touch $HOME/release-post-attach") + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/post-attach.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(500 * time.Millisecond).Should( + gomega.Equal("postAttachDone"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should run postAttachCommand on every attach", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-attach-every-time", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( + gomega.Equal("1"), + ) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( + gomega.Equal("2"), + ) + }, + ginkgo.SpecTimeout( + framework.TimeoutModerate(), + ), + ) + + ginkgo.It( + "should run initializeCommand with object syntax", + func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-initcmd-parallel", + initialDir, + f, + ) + framework.ExpectNoError(err) + + one, err := os.ReadFile( //nolint:gosec // G304 + filepath.Join(tempDir, "init-cmd-one.out"), + ) + framework.ExpectNoError(err) + gomega.Expect(string(one)).To(gomega.Equal("initCmdOne")) + + two, err := os.ReadFile( //nolint:gosec // G304 + filepath.Join(tempDir, "init-cmd-two.out"), + ) + framework.ExpectNoError(err) + gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should inject secrets-file env into lifecycle commands", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-secrets-file", + initialDir, + f, + ) + framework.ExpectNoError(err) + + secretsDir, err := framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) + + secretsFile := filepath.Join(secretsDir, "secrets.json") + err = os.WriteFile( + secretsFile, + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), + 0o600, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir, "--secrets-file", secretsFile) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal("test-value-12345")) + + out, err = f.DevsySSH( + ctx, tempDir, "cat /tmp/another-secret-check.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal("second-secret-42")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootless_basic.go b/e2e/tests/up/provider_podman_rootless_basic.go new file mode 100644 index 000000000..9ed047aca --- /dev/null +++ b/e2e/tests/up/provider_podman_rootless_basic.go @@ -0,0 +1,175 @@ +package up + +import ( + "context" + "os" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootless-basic"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootless podman", func() { + var f *framework.Framework + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + f, err = setupDockerProvider(initialDir+"/bin", "podman") + framework.ExpectNoError(err) + }) + + ginkgo.Context("basic", func() { + ginkgo.It( + "should start a new workspace with existing image", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + // Regression: rootless podman bind-mounts the workspace folder + // root-owned, so a non-root remoteUser couldn't chdir into it + // and the in-container SSH server failed with "fork/exec + // /usr/bin/bash: permission denied". + ginkgo.It( + "should ssh into a workspace with a non-root remoteUser", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-nonroot-user", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + whoami, err := f.DevsySSH(ctx, tempDir, "whoami") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(whoami)).To(gomega.Equal("devsyuser")) + + owner, err := f.DevsySSH(ctx, tempDir, `stat -c %U "$PWD"`) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), + "workspace folder should be chowned to the remote user") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + }) + + ginkgo.Context("build", func() { + ginkgo.It( + "should start a workspace with a multistage Dockerfile build", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-with-multi-stage-build", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + + ginkgo.It( + "should build and respect overrideCommand false", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-override-command-false", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("agent delivery", func() { + ginkgo.It( + "should deliver the agent binary and execute SSH commands", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsySSHEchoTestString(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("exec", func() { + ginkgo.It( + "should execute commands inside the container via SSH", + func(ctx context.Context) { + tempDir, err := setupWorkspace("tests/up/testdata/docker", initialDir, f) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "echo -n hello-podman") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "hello-podman") + + out, err = f.DevsySSH(ctx, tempDir, "pwd") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).NotTo(gomega.BeEmpty()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + + ginkgo.Context("cleanup", func() { + ginkgo.It( + "should delete workspace and clean up resources", + func(ctx context.Context) { + tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + + _, err = f.FindWorkspace(ctx, tempDir) + framework.ExpectError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootless_config.go b/e2e/tests/up/provider_podman_rootless_config.go new file mode 100644 index 000000000..3c46534b3 --- /dev/null +++ b/e2e/tests/up/provider_podman_rootless_config.go @@ -0,0 +1,232 @@ +package up + +import ( + "context" + "os" + "path" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootless-config"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootless podman", func() { + var f *framework.Framework + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + f, err = setupDockerProvider(initialDir+"/bin", "podman") + framework.ExpectNoError(err) + }) + + ginkgo.Context("configuration", func() { //nolint:dupl + ginkgo.It("should substitute variables", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-variables", + initialDir, + f, + "--init-env", "CUSTOM_VAR=custom_value", + "--init-env", "CUSTOM_IMAGE=ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + devContainerID, err := f.DevsySSH( + ctx, + tempDir, + "cat $HOME/dev-container-id.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(devContainerID)).NotTo(gomega.BeEmpty()) + + containerEnvPath, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-env-path.out", + ) + framework.ExpectNoError(err) + gomega.Expect(containerEnvPath).To(gomega.ContainSubstring("/usr/local/bin")) + + localEnvHome, err := f.DevsySSH(ctx, tempDir, "cat $HOME/local-env-home.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(localEnvHome)). + To(gomega.Equal(os.Getenv("HOME"))) + + localWorkspaceFolder, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/local-workspace-folder.out", + ) + framework.ExpectNoError(err) + gomega.Expect( + framework.CleanString(strings.TrimSpace(localWorkspaceFolder)), + ).To(gomega.Equal(framework.CleanString(tempDir))) + + localWorkspaceFolderBasename, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/local-workspace-folder-basename.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(localWorkspaceFolderBasename)). + To(gomega.Equal(filepath.Base(tempDir))) + + containerWorkspaceFolder, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-workspace-folder.out", + ) + framework.ExpectNoError(err) + gomega.Expect( + framework.CleanString(strings.TrimSpace(containerWorkspaceFolder)), + ).To(gomega.Equal( + framework.CleanString(path.Join("/workspaces", filepath.Base(tempDir))), + )) + + containerWorkspaceFolderBasename, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/container-workspace-folder-basename.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(containerWorkspaceFolderBasename)). + To(gomega.Equal(filepath.Base(tempDir))) + + customVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-var.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(customVar)).To(gomega.Equal("custom_value")) + + customImage, err := f.DevsySSH(ctx, tempDir, "cat $HOME/custom-image.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(customImage)). + To(gomega.Equal("ghcr.io/devsy-org/test-images/base:alpine")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should substitute variables with defaults", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-variables-defaults", + initialDir, + f, + ) + framework.ExpectNoError(err) + + withDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/with-default.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(withDefault)). + To(gomega.Equal("my_default_value")) + + colonDefault, err := f.DevsySSH(ctx, tempDir, "cat $HOME/colon-default.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(colonDefault)). + To(gomega.Equal("http://proxy:8080")) + + setVar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/set-var.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(setVar)).To(gomega.Equal(os.Getenv("HOME"))) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should merge extra devcontainer config", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-extra-devcontainer", + initialDir, + f, + ) + framework.ExpectNoError(err) + + extraPath := path.Join(tempDir, "extra.json") + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainerOverlay), + extraPath, + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $BASE_VAR'") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "base_value") + + out, err = f.DevsySSH(ctx, tempDir, "bash -l -c 'echo -n $EXTRA_VAR'") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "extra_value") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "should override with extra devcontainer config", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-extra-override", + initialDir, + f, + ) + framework.ExpectNoError(err) + + extraPath := path.Join(tempDir, "override.json") + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainerOverlay), + extraPath, + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/test-var.out") + framework.ExpectNoError(err) + framework.ExpectEqual(strings.TrimSpace(out), "overridden_value") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It("should select from multiple devcontainers", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-multi-devcontainer", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp( + ctx, + tempDir, + names.Flag(names.DevContainer), + "id:python", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH( + ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", + ) + framework.ExpectNoError(err) + framework.ExpectEqual(out, "python") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir, names.Flag(names.DevContainer), "id:go") + framework.ExpectNoError(err) + + out, err = f.DevsySSH( + ctx, tempDir, "bash -l -c 'echo -n $DEVCONTAINER_TYPE'", + ) + framework.ExpectNoError(err) + framework.ExpectEqual(out, "go") + + err = f.DevsyWorkspaceDelete(ctx, tempDir) + framework.ExpectNoError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootless_features.go b/e2e/tests/up/provider_podman_rootless_features.go new file mode 100644 index 000000000..cc252231d --- /dev/null +++ b/e2e/tests/up/provider_podman_rootless_features.go @@ -0,0 +1,88 @@ +package up + +import ( + "context" + "os" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootless-features"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootless podman", func() { + var f *framework.Framework + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + f, err = setupDockerProvider(initialDir+"/bin", "podman") + framework.ExpectNoError(err) + }) + + ginkgo.Context("features", func() { //nolint:dupl + ginkgo.It("should mount volumes", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-mounts", + initialDir, + f, + "--debug", + ) + framework.ExpectNoError(err) + + foo, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt1/foo.txt") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(foo)).To(gomega.Equal("BAR")) + + bar, err := f.DevsySSH(ctx, tempDir, "cat $HOME/mnt2/bar.txt") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(bar)).To(gomega.Equal("FOO")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should use custom image", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker", + initialDir, + f, + "--devcontainer-image", + "ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "ID=alpine\n") + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should skip build with custom image", func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-with-multi-stage-build", + initialDir, + f, + "--devcontainer-image", + "ghcr.io/devsy-org/test-images/base:alpine", + ) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "grep ^ID= /etc/os-release") + framework.ExpectNoError(err) + framework.ExpectEqual(out, "ID=alpine\n") + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + }) + }, +) diff --git a/e2e/tests/up/provider_podman_rootless_lifecycle.go b/e2e/tests/up/provider_podman_rootless_lifecycle.go new file mode 100644 index 000000000..1f483d560 --- /dev/null +++ b/e2e/tests/up/provider_podman_rootless_lifecycle.go @@ -0,0 +1,304 @@ +package up + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "testing up command for podman provider", + ginkgo.Label("up-provider-podman-rootless-lifecycle"), + func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Context("with rootless podman", func() { + var f *framework.Framework + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + f, err = setupDockerProvider(initialDir+"/bin", "podman") + framework.ExpectNoError(err) + }) + + ginkgo.Context("lifecycle commands", func() { //nolint:dupl + ginkgo.It( + "should run postCreateCommand with object syntax", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-postcreate-parallel", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + one, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-one.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(one)).To(gomega.Equal("postCreateOne")) + + two, err := f.DevsySSH(ctx, tempDir, "cat /tmp/post-create-two.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(two)).To(gomega.Equal("postCreateTwo")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It("should run postStartCommand after restart", func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-start-restart", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") + framework.ExpectNoError(err) + lines := strings.Count(strings.TrimSpace(out), "\n") + 1 + gomega.Expect(lines).To(gomega.Equal(1), + "postStartCommand should have run once after initial up") + + err = f.DevsyWorkspaceStop(ctx, tempDir) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-start-count.log") + framework.ExpectNoError(err) + lines = strings.Count(strings.TrimSpace(out), "\n") + 1 + gomega.Expect(lines).To(gomega.Equal(2), + "postStartCommand should have run again after restart") + }, ginkgo.SpecTimeout(framework.TimeoutModerate())) + + ginkgo.It( + "should defer postCreateCommand to background with waitFor", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-waitfor", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/on-create.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("onCreateDone")) + + out, err = f.DevsySSH(ctx, tempDir, "cat $HOME/update-content.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("updateContentDone")) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/deferred.marker 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( + gomega.Equal("postCreateDone"), + ) + + envPath, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/deferred-env-path.out", + ) + framework.ExpectNoError(err) + gomega.Expect(envPath).To( + gomega.ContainSubstring("/usr/local/bin"), + ) + gomega.Expect(envPath).NotTo(gomega.ContainSubstring("${containerEnv:")) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, + tempDir, + "cat $HOME/post-start-deferred.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(30 * time.Second).WithPolling(2 * time.Second).Should( + gomega.Equal("postStartDone"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should make IDE accessible before postAttachCommand completes", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-attach-nonblocking", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat $HOME/post-start.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("postStartDone")) + + _, err = f.DevsySSH(ctx, tempDir, "cat $HOME/post-attach.out") + gomega.Expect(err).To(gomega.HaveOccurred(), + "postAttachCommand must still be blocked on the release marker") + + _, err = f.DevsySSH(ctx, tempDir, "touch $HOME/release-post-attach") + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/post-attach.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(500 * time.Millisecond).Should( + gomega.Equal("postAttachDone"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should run postAttachCommand on every attach", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-post-attach-every-time", + initialDir, + f, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( + gomega.Equal("1"), + ) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Eventually(func() string { + out, err := f.DevsySSH( + ctx, tempDir, "cat $HOME/attach-count.out 2>/dev/null", + ) + if err != nil { + return "" + } + return strings.TrimSpace(out) + }).WithTimeout(15 * time.Second).WithPolling(1 * time.Second).Should( + gomega.Equal("2"), + ) + }, + ginkgo.SpecTimeout( + framework.TimeoutModerate(), + ), + ) + + ginkgo.It( + "should run initializeCommand with object syntax", + func(ctx context.Context) { + tempDir, err := setupWorkspaceAndUp( + ctx, + "tests/up/testdata/docker-initcmd-parallel", + initialDir, + f, + ) + framework.ExpectNoError(err) + + one, err := os.ReadFile( //nolint:gosec // G304 + filepath.Join(tempDir, "init-cmd-one.out"), + ) + framework.ExpectNoError(err) + gomega.Expect(string(one)).To(gomega.Equal("initCmdOne")) + + two, err := os.ReadFile( //nolint:gosec // G304 + filepath.Join(tempDir, "init-cmd-two.out"), + ) + framework.ExpectNoError(err) + gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "should inject secrets-file env into lifecycle commands", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-secrets-file", + initialDir, + f, + ) + framework.ExpectNoError(err) + + secretsDir, err := framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) + + secretsFile := filepath.Join(secretsDir, "secrets.json") + err = os.WriteFile( + secretsFile, + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), + 0o600, + ) + framework.ExpectNoError(err) + + err = f.DevsyUp(ctx, tempDir, "--secrets-file", secretsFile) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "cat /tmp/secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal("test-value-12345")) + + out, err = f.DevsySSH( + ctx, tempDir, "cat /tmp/another-secret-check.out", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)). + To(gomega.Equal("second-secret-42")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }) + }) + }, +) diff --git a/e2e/tests/up/testdata/docker-post-attach-nonblocking/.devcontainer.json b/e2e/tests/up/testdata/docker-post-attach-nonblocking/.devcontainer.json index 04dc2ca52..cfcdfbd3a 100644 --- a/e2e/tests/up/testdata/docker-post-attach-nonblocking/.devcontainer.json +++ b/e2e/tests/up/testdata/docker-post-attach-nonblocking/.devcontainer.json @@ -1,5 +1,5 @@ { "image": "ghcr.io/devsy-org/test-images/go:1", "postStartCommand": "echo postStartDone > $HOME/post-start.out", - "postAttachCommand": "sleep 15 && echo postAttachDone > $HOME/post-attach.out" + "postAttachCommand": "i=0; while [ ! -f $HOME/release-post-attach ] && [ $i -lt 300 ]; do sleep 1; i=$((i+1)); done; echo postAttachDone > $HOME/post-attach.out" } diff --git a/go.mod b/go.mod index aae2f397e..ec32c5d35 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/u-root/u-root v0.16.0 github.com/zalando/go-keyring v0.2.8 go.uber.org/atomic v1.11.0 + go.uber.org/goleak v1.3.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.54.0 golang.org/x/mod v0.38.0 @@ -89,51 +90,110 @@ require ( cel.dev/expr v0.25.2 // indirect charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - code.gitea.io/sdk/gitea v0.23.2 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/kms v1.31.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect + cloud.google.com/go/monitoring v1.25.0 // indirect + cloud.google.com/go/storage v1.62.2 // indirect + code.gitea.io/sdk/gitea v0.25.1 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect filippo.io/hpke v0.4.0 // indirect github.com/42wim/httpsig v1.2.4 // indirect + github.com/AlekSi/pointer v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.30 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect + github.com/Azure/go-autorest/logger v0.2.2 // indirect + github.com/Azure/go-autorest/tracing v0.6.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/anchore/go-macholibre v0.0.0-20250826193721-3cd206ca93aa // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atc0005/go-teams-notify/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect - github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.56.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.11 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect github.com/aws/smithy-go v1.27.4 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blacktop/go-dwarf v1.0.14 // indirect + github.com/blacktop/go-macho v1.1.263 // indirect + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/caarlos0/env/v11 v11.4.1 // indirect + github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect + github.com/caarlos0/go-version v0.2.2 // indirect + github.com/caarlos0/log v0.6.2 // indirect + github.com/carlmjohnson/versioninfo v0.22.5 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cavaliergopher/cpio v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/fang v1.0.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd/api v1.11.1 // indirect @@ -146,28 +206,53 @@ require ( github.com/containerd/ttrpc v1.2.9 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect github.com/containers/storage v1.59.1 // indirect + github.com/coreos/go-oidc/v3 v3.20.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/creachadair/msync v0.8.1 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/devsy-org/admin-apis v1.2.0 // indirect + github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb // indirect + github.com/dghubble/oauth1 v0.7.3 // indirect + github.com/dghubble/sling v1.4.0 // indirect + github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gaissmai/bart v0.26.1 // indirect + github.com/github/smimesign v0.2.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/runtime v0.32.4 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/strfmt v0.26.4 // indirect github.com/go-openapi/swag v0.26.1 // indirect github.com/go-openapi/swag/cmdutils v0.26.1 // indirect github.com/go-openapi/swag/conv v0.27.0 // indirect @@ -180,37 +265,78 @@ require ( github.com/go-openapi/swag/stringutils v0.26.1 // indirect github.com/go-openapi/swag/typeutils v0.27.0 // indirect github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/validate v0.26.0 // indirect + github.com/go-restruct/restruct v1.2.0-alpha // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.27.0 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-github/v86 v86.0.0 // indirect + github.com/google/go-github/v89 v89.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/ko v0.19.1 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/rpmpack v0.7.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/wire v0.7.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/goreleaser/chglog v0.7.4 // indirect + github.com/goreleaser/fileglob v1.4.0 // indirect + github.com/goreleaser/go-shellwords v1.0.13 // indirect + github.com/goreleaser/goreleaser/v2 v2.17.1 // indirect + github.com/goreleaser/nfpm/v2 v2.47.0 // indirect + github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/ipfs/bbloom v0.0.4 // indirect + github.com/ipfs/go-block-format v0.2.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect + github.com/ipfs/go-ipfs-util v0.0.3 // indirect + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect + github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipfs/go-metrics-interface v0.0.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect + github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect github.com/jsimonetti/rtnetlink v1.4.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect @@ -218,35 +344,59 @@ require ( github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.23 // indirect + github.com/mattn/go-mastodon v0.0.13 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/user v0.4.1 // indirect + github.com/modelcontextprotocol/registry v1.8.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/morikuni/aec v1.1.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.2.0 // indirect + github.com/muesli/mango-cobra v1.3.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -254,35 +404,69 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/safchain/ethtool v0.3.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect - github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v3 v3.1.1 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/sigstore-go v1.2.2 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/slack-go/slack v0.27.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/wireguard-go v0.0.0-20260730222847-4affce44577c // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect - github.com/ulikunitz/xz v0.5.15 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/ulikunitz/xz v0.5.16 // indirect + github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect + github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect + go.digitalxero.dev/go-msix v0.3.1 // indirect go.etcd.io/etcd/api/v3 v3.6.8 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect go.etcd.io/etcd/client/v3 v3.6.8 // indirect + go.mozilla.org/pkcs7 v0.9.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect @@ -291,27 +475,37 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + gocloud.dev v0.46.0 // indirect golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463 // indirect golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/api v0.283.0 // indirect + google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect k8s.io/apiextensions-apiserver v0.36.2 // indirect k8s.io/apiserver v0.36.3 // indirect @@ -319,10 +513,13 @@ require ( k8s.io/component-base v0.36.3 // indirect k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821 // indirect k8s.io/metrics v0.36.3 // indirect + lukechampine.com/blake3 v1.4.1 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kind v0.32.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect ) replace tailscale.com => github.com/devsy-org/tailscale v1.102.2 diff --git a/go.sum b/go.sum index 19348bea8..74a5851dc 100644 --- a/go.sum +++ b/go.sum @@ -14,10 +14,32 @@ charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= -code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/logging v1.14.0 h1:xpPpY8cVT6n9DgIRgrWyE+YEsGlO/994pWnbc7o5Eh4= +cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= +cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= +cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ= +cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg= +code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= +code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= @@ -26,12 +48,20 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= +github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= @@ -40,18 +70,68 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDH github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= +github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= +github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= +github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 h1:Q9R3utmFg9K1B4OYtAZ7ZUUvIUdzQt7G2MN5Hi/d670= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7/go.mod h1:bVrAueELJ0CKLBpUHDIvD516TwmHmzqwCpvONWRsw3s= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/date v0.3.1 h1:o9Z8Jyt+VJJTCZ/UORishuHOusBwolhjokt9s5k8I4w= +github.com/Azure/go-autorest/autorest/date v0.3.1/go.mod h1:Dz/RDmXlfiFFS/eW+b/xMUSFs1tboPVy6UjgADToWDM= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.2 h1:hYqBsEBywrrOSW24kkOCXRcKfKhK76OzLTfF+MYDE2o= +github.com/Azure/go-autorest/logger v0.2.2/go.mod h1:I5fg9K52o+iuydlWfa9T5K6WFos9XYr9dYTFzpqgibw= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsful9L/2nyR0= +github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= +github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.3-0.20260107145400-75610162e7da h1:8ozZKftQ98rltuZgYeEjlKXRRA5fr/fmxf4IOQinE84= github.com/Microsoft/go-winio v0.6.3-0.20260107145400-75610162e7da/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/Microsoft/hcsshim v0.15.0-rc.1 h1:FbbwtQmiD+BVHynGkx5S65JkLyhkEiiTP8nrpmg2SZw= @@ -60,52 +140,87 @@ github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cq github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= +github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= +github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anchore/go-macholibre v0.0.0-20250826193721-3cd206ca93aa h1:KPEP8f3enFJeus3Wo51I+riVuCvlf4OEYl2B4IfycbQ= +github.com/anchore/go-macholibre v0.0.0-20250826193721-3cd206ca93aa/go.mod h1:7YJA6tAfRm4SzIF93b32pR4xnbf8g2nJIeQnp+2vzzI= github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= +github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= +github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1 h1:1hWFp+52Vq8Fevy/KUhbW/1MEApMz7uitCF/PQXRJpk= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1/go.mod h1:sIec8j802/rCkCKgZV678HFR0s7lhQUYXT77tIvlaa4= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 h1:7ZFdtE1XEHH+58GZU4Mbhq6SO/UbColleDApOtlv3vo= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5/go.mod h1:M/qt7xBBXqilBwNmrO1yiu0cywZwQx5aqtKpdJN9J2A= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= -github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3 h1:RtGctYMmkTerGClvdY6bHXdtly4FeYw9wz/NPz62LF8= -github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3/go.mod h1:vBfBu24Ka3/5UZtepbTV0gnc9VPLT8ok+0oDDaYAzn4= -github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10 h1:1A/sI3LNMi3fhRI5TFLMwwo7ALAALSFVCSGvFlr1Iys= -github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10/go.mod h1:Diyyyz0b43X13pdi1mVMqlTwDjOmRbJMvDsqnduUYWM= +github.com/aws/aws-sdk-go-v2/service/ecr v1.56.0 h1:XxNya31nOtsClGghvQ2VkhIB2S/rggb64x5vkHl4xZQ= +github.com/aws/aws-sdk-go-v2/service/ecr v1.56.0/go.mod h1:T+Tz2Xp1gnvtlgvP7OyRHlr84KtI3fZW5Ax/e+s9b64= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.11 h1:2T9NCuNzzBh6RUrwYZBFl1D9lLJ2r2CCbg7w383DjQE= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.11/go.mod h1:FkD34cqOmnqfAEiNHeqOT50SoXqHEgdDsa8BrMw9t+w= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= -github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk= -github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 h1:bBoWhx8lsFLTXintRX64ZBXcmFZbGqUmaPUrjXECqIc= +github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3/go.mod h1:rcRkKbUJ2437WuXdq9fbj+MjTudYWzY9Ct8kiBbN8a8= github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= @@ -120,20 +235,57 @@ github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blacktop/go-dwarf v1.0.14 h1:OjmzfSgg/qAKckn2tWFebcgKgJ7HOqCj7bS+CiE1lrY= +github.com/blacktop/go-dwarf v1.0.14/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl9N6VQ9DSTo= +github.com/blacktop/go-macho v1.1.263 h1:wzcLdip1PD2UwOZ5liKijF4YlYbFFF7XdqjLp3VQ1d0= +github.com/blacktop/go-macho v1.1.263/go.mod h1:Hc5E2Lvt/U1VT+jOxr1O5l/LNFJeMYK4eAmDfazTiGc= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 h1:927VIkxPFKpfJKVDtCNgSQtlhksARaLvsLxppR2FukM= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043/go.mod h1:dXjdzg6bhg1JKnKuf6EBJTtcxtfHYBFEe9btxX5YeAE= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/go-reddit/v3 v3.0.1 h1:w8ugvsrHhaE/m4ez0BO/sTBOBWI9WZTjG7VTecHnql4= +github.com/caarlos0/go-reddit/v3 v3.0.1/go.mod h1:QlwgmG5SAqxMeQvg/A2dD1x9cIZCO56BMnMdjXLoisI= +github.com/caarlos0/go-version v0.2.2 h1:5r+nlrg4H2wOVwWjqRqRRIRbZ7ytRmjC9xoMIP0a5kQ= +github.com/caarlos0/go-version v0.2.2/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= +github.com/caarlos0/log v0.6.2 h1:ZeP1TBAEiF0XF6VoJze/KNKeRqsQ0frHuDLhtNDvRys= +github.com/caarlos0/log v0.6.2/go.mod h1:y47Oq2WDdjL12pPSjdCZlIKtgF6SOMss88kSWTxXZBc= +github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= +github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= +github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= @@ -142,6 +294,8 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4 github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= @@ -156,6 +310,8 @@ github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= @@ -164,6 +320,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -208,10 +366,13 @@ github.com/containers/storage v1.59.1 h1:11Zu68MXsEQGBBd+GadPrHPpWeqjKS8hJDGiAHg github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM= github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= @@ -224,6 +385,10 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw= github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -246,10 +411,27 @@ github.com/devsy-org/ssh v1.2.2 h1:Ylr62ag6nc1U5T6soj0OecPzoLJqPJODUbnJhpaDYSY= github.com/devsy-org/ssh v1.2.2/go.mod h1:x5NsT8LXW/SBykm115/l5j2jynZAFwIALDcbpi4MlU0= github.com/devsy-org/tailscale v1.102.2 h1:9SB6htvO+HmG8alal8WGCshHapfHR7dUFRSMYiFIzIM= github.com/devsy-org/tailscale v1.102.2/go.mod h1:kQUA0lYb/bqCJJZzShx+gqLSxggEFgXB4VDVZDzxWc8= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb h1:7ENzkH+O3juL+yj2undESLTaAeRllHwCs/b8z6aWSfc= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb/go.mod h1:qhZBgV9e4WyB1JNjHpcXVkUe3knWUwYuAPB1hITdm50= +github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= +github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= +github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= +github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i0Ee9EP1TlTZ9w+UncBY6HSmsFZa0= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0= +github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= @@ -274,34 +456,66 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= +github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -314,10 +528,24 @@ github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= +github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= +github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= @@ -348,18 +576,29 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zW github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= +github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= +github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -368,6 +607,11 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -378,8 +622,11 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -390,10 +637,17 @@ github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-2026073117084 github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20260731170845-2ea098f4b134/go.mod h1:ASRF0lXXsE0/lBOY9cXj3UKYOpQLGKxn7mG18mh2/g4= github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= github.com/google/go-github/v90 v90.0.0 h1:EnX9HvTfqvuJbUSWu1/jLrYH6JJLMz0w0qfQVbTxPzE= github.com/google/go-github/v90 v90.0.0/go.mod h1:pLzt1FZURZyoTHT5/Z1UQY3b9fYyrbXH6aj7X+qgID4= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -401,14 +655,47 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/ko v0.19.1 h1:FggFTOgqz3sCdac8DvF2uU7FjphG3zNvZ9QMLOuVhZ8= +github.com/google/ko v0.19.1/go.mod h1:QPXOKWfYyhm1AJFo318SKif5fMPjXRdwj1YudZ5YXQA= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= +github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/goreleaser/chglog v0.7.4 h1:3pnNt/XCrUcAOq+KC91Azlgp5CRv4GHo1nl8Aws7OzI= +github.com/goreleaser/chglog v0.7.4/go.mod h1:dTVoZZagTz7hHdWaZ9OshHntKiF44HbWIHWxYJQ/h0Y= +github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs= +github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8= +github.com/goreleaser/go-shellwords v1.0.13 h1:ivvhC/RvUyud74c0urb1ZGYWPYGibY5QiFzcwHCege4= +github.com/goreleaser/go-shellwords v1.0.13/go.mod h1:UtDFSSvW7wQL/4jmyzZbuP6HfI6R+oSm0v63cs61oDw= +github.com/goreleaser/goreleaser/v2 v2.17.1 h1:7nWdnNZSeiutF2PKAKmCEdEnLO+n0BWDoj4AqNiMX1E= +github.com/goreleaser/goreleaser/v2 v2.17.1/go.mod h1:0yFW5JOLYuNncutj9ebbj8Ok8NXcKg7t0bEN+Q0HuWs= +github.com/goreleaser/nfpm/v2 v2.47.0 h1:0bioJAjWaMPntgDqynP4ze0Wt4zYqYSFJ5/BBy9XIGI= +github.com/goreleaser/nfpm/v2 v2.47.0/go.mod h1:EhVWY2GwWB0Zf7FDDVqpDDCtvIzeqcUsAinpHSE8wUo= +github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321 h1:/O7X5L3FuwXHT055Aar5b53A3pwUtL4z/1W8EqicJHQ= +github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321/go.mod h1:uDK4XtTWv1aLqYzwZUCxCPY/tBSuUasZmu5+lLuQHoM= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -417,26 +704,52 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8= github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= @@ -451,10 +764,46 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 h1:kD8PseueGeYiid/Mmcv17Q0Qqicc4F46jcX22L/e/Hs= github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= -github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= -github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= +github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= +github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= +github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= +github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= +github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= +github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= +github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= +github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= +github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= +github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= +github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= +github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= +github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= +github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= +github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= +github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= +github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= @@ -465,24 +814,37 @@ github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNa github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -491,12 +853,17 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-mastodon v0.0.13 h1:ZQaij7lw7N81KuqbYJeTMSfsO53GZETpi1mXcxsuYIQ= +github.com/mattn/go-mastodon v0.0.13/go.mod h1:9ljK/rR6veDDzO3z2IdUYDBpATgi0cXotDacI3yK+jM= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= @@ -516,6 +883,10 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -524,12 +895,20 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/buildkit v0.32.2 h1:Sfy7+u6dUv/2yuBc9KCoK70Re8atuV8aPZ5UOC068Vc= github.com/moby/buildkit v0.32.2/go.mod h1:0GB/EJ1d+4VIVqIAgy3asaoGkVXy7IrDfVy7mPhOvg8= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/policy-helpers v0.0.0-20260722051018-856be88baec4 h1:3uAZ4pSEOHW0dCkXOxb50ux0YE69lNCwr5MnT2Vf0O8= @@ -554,6 +933,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/registry v1.8.0 h1:x/seX0ji4iqRUpSovmkBcGbxfRiZZ7dgPwBcpCJrSTM= +github.com/modelcontextprotocol/registry v1.8.0/go.mod h1:G6AUpTpZSekQvcLl5griUjijEE8vedARE/TyCaHEFdo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -562,12 +943,36 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= +github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= +github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= @@ -576,19 +981,33 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.3.6 h1:SLGIymCtsk80iNPWgbc8dtjI30r+5mTVV+4dN8/17Sk= +github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4= github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= +github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= @@ -598,6 +1017,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= github.com/posthog/posthog-go v1.22.0 h1:VNy+sMJ9MMnENr9dMSxfQt/5bB4UhwRdZfasOAghMMg= github.com/posthog/posthog-go v1.22.0/go.mod h1://M430hNH3e8CDv4i8SJesb26816Mpa6GIZaiP4pNQU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -608,53 +1029,128 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= +github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= -github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sigstore/cosign/v3 v3.1.1 h1:vYdLHfelistuiexNAM8rXAL5aru55g6p8KxzTOR8MO8= +github.com/sigstore/cosign/v3 v3.1.1/go.mod h1:xBOOidl1V2sGVbAiLtNzwiHnb4QvwQojCVC4x/O/QJI= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0= github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/slack-go/slack v0.27.0 h1:VWOpUzOK6UAPCCQlFxl79jhv8a/b+GOSJMnWziDJ8B8= +github.com/slack-go/slack v0.27.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= @@ -679,6 +1175,10 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -687,12 +1187,24 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA= +github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY= github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 h1:uppBiK+tE8tYG6fc0N8VnsC7FMZcWxnXIyaQ9GcUIU8= github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4= github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE= @@ -701,22 +1213,43 @@ github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/ github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk= github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw= github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g= github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY= github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8= github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= +github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c h1:Jmc9fHbd0LKFmS5CkLgczNUyW36UbiyvbHCG9xCTyiw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -727,15 +1260,30 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= +go.digitalxero.dev/go-msix v0.3.1 h1:V5E8PuFkA3Fr3VFYX6pTUutriogYC9sgxIWhzf9sSKw= +go.digitalxero.dev/go-msix v0.3.1/go.mod h1:QbUpFs0AUd1zk7e9fy17suiqEAF90TR3jZY+LCI2K+c= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= @@ -750,10 +1298,18 @@ go.etcd.io/etcd/server/v3 v3.6.8 h1:U2strdSEy1U8qcSzRIdkYpvOPtBy/9i/IfaaCI9flZ4= go.etcd.io/etcd/server/v3 v3.6.8/go.mod h1:88dCtwUnSirkUoJbflQxxWXqtBSZa6lSG0Kuej+dois= go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk= @@ -762,45 +1318,84 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= -go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= @@ -809,17 +1404,26 @@ golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9 golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463 h1:56+vNxUDjzdwAJjglL/DMnp31nd8UeytlPRLbZfz55Q= golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -829,46 +1433,75 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= @@ -879,24 +1512,44 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek= +google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= @@ -905,6 +1558,7 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk= honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= @@ -937,19 +1591,25 @@ k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w= k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kind v0.32.0 h1:p9hscbj98u/qyrjVpjId86LI70nQmbSsipV7wCG10Xk= +sigs.k8s.io/kind v0.32.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= -software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= -software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/hack/licenses/overrides.ndjson b/hack/licenses/overrides.ndjson index aa8cbaeaa..17a7db80f 100644 --- a/hack/licenses/overrides.ndjson +++ b/hack/licenses/overrides.ndjson @@ -1 +1,2 @@ {"name":"github.com/grpc-ecosystem/go-grpc-middleware/v2","licenceFile":"LICENSE","licenceType":"Apache-2.0"} +{"name":"github.com/segmentio/asm","licenceFile":"LICENSE","licenceType":"MIT-0"} diff --git a/hack/licenses/rules.json b/hack/licenses/rules.json index 7e95f25b2..5f80549e7 100644 --- a/hack/licenses/rules.json +++ b/hack/licenses/rules.json @@ -11,6 +11,7 @@ "CC0-1.0", "Unlicense", "0BSD", + "MIT-0", "Zlib" ] } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 3c30ed296..3bc20f1d5 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -432,8 +432,7 @@ type TunnelOptions struct { } func Tunnel(ctx context.Context, opts TunnelOptions) error { - if err := InjectAgent(&InjectOptions{ - Ctx: ctx, + if err := InjectAgent(ctx, &InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return opts.Exec(ctx, "root", command, stdin, stdout, stderr) }, diff --git a/pkg/agent/delivery/legacy_shell.go b/pkg/agent/delivery/legacy_shell.go index d0cb41be4..2e2d6d2a9 100644 --- a/pkg/agent/delivery/legacy_shell.go +++ b/pkg/agent/delivery/legacy_shell.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" "github.com/devsy-org/devsy/pkg/agent" pkgconfig "github.com/devsy-org/devsy/pkg/config" @@ -11,47 +12,33 @@ import ( "github.com/devsy-org/devsy/pkg/log" ) -// Deprecated: LegacyShellDelivery is deprecated. Platform-native AgentDelivery implementations -// (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. type LegacyShellDelivery struct { - ExecFunc inject.ExecFunc //nolint:staticcheck // deprecated injection path + ExecFunc inject.ExecFunc //nolint:staticcheck DownloadURL string - Timeout func() *agent.InjectOptions + Timeout func() time.Duration } -// Deprecated: Phase is part of LegacyShellDelivery which is deprecated. func (d *LegacyShellDelivery) Phase() DeliveryPhase { return PhasePostStart } -// Deprecated: DeliverPreStart is part of LegacyShellDelivery which is deprecated. func (d *LegacyShellDelivery) DeliverPreStart(_ context.Context, _ PreStartOptions) error { return fmt.Errorf("LegacyShellDelivery does not support pre-start delivery") } -// Deprecated: DeliverPostStart is part of LegacyShellDelivery which is deprecated. func (d *LegacyShellDelivery) DeliverPostStart(ctx context.Context, opts PostStartOptions) error { if d.ExecFunc == nil { return fmt.Errorf("exec function is required for legacy shell delivery") } - injectOpts := &agent.InjectOptions{ - Ctx: ctx, + if err := agent.InjectAgent(ctx, &agent.InjectOptions{ Exec: d.ExecFunc, IsLocal: false, RemoteAgentPath: pkgconfig.ContainerDevsyHelperLocation, DownloadURL: d.downloadURL(), PreferDownloadFromRemoteUrl: new(false), - } - - if d.Timeout != nil { - overrides := d.Timeout() - if overrides != nil && overrides.Timeout > 0 { - injectOpts.Timeout = overrides.Timeout - } - } - - if err := agent.InjectAgent(injectOpts); err != nil { + Timeout: d.timeout(), + }); err != nil { return fmt.Errorf("legacy shell inject: %w", err) } @@ -59,11 +46,17 @@ func (d *LegacyShellDelivery) DeliverPostStart(ctx context.Context, opts PostSta return nil } -// Deprecated: Cleanup is part of LegacyShellDelivery which is deprecated. func (d *LegacyShellDelivery) Cleanup(_ context.Context, _ string) error { return nil } +func (d *LegacyShellDelivery) timeout() time.Duration { + if d.Timeout == nil { + return 0 + } + return d.Timeout() +} + func (d *LegacyShellDelivery) downloadURL() string { if d.DownloadURL != "" { return d.DownloadURL @@ -71,12 +64,6 @@ func (d *LegacyShellDelivery) downloadURL() string { return pkgconfig.DefaultAgentDownloadURL() } -// ExecFuncFromDriver creates an inject.ExecFunc that routes commands through -// the provided driver command function. This adapts the driver's -// CommandDevContainer signature for use with the legacy injection path. -// -// Deprecated: Platform-native AgentDelivery implementations -// (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. func ExecFuncFromDriver( cmdFn func(ctx context.Context, user, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error, user string, diff --git a/pkg/agent/delivery/local_docker.go b/pkg/agent/delivery/local_docker.go index 24951fb6b..fd29a1388 100644 --- a/pkg/agent/delivery/local_docker.go +++ b/pkg/agent/delivery/local_docker.go @@ -226,6 +226,9 @@ func (d *LocalDockerDelivery) populateVolumeWithHelper( return nil } +// populateVolumeDirectCopy writes the agent binary to a volume mountpoint the +// host can write directly (docker, or rootful podman). Podman rootless +// volumes are not directly writable by the host. func (d *LocalDockerDelivery) populateVolumeDirectCopy( ctx context.Context, volumeName string, @@ -238,19 +241,23 @@ func (d *LocalDockerDelivery) populateVolumeDirectCopy( destPath := filepath.Join(mountpoint, binaryName()) - if d.isRootlessPodman(ctx) { - return d.populateVolumeViaUnshare(ctx, destPath, data) + if err := writeBinaryDirect(destPath, data); err == nil { + return nil + } else if !d.isPodman() || !errors.Is(err, os.ErrPermission) { + return fmt.Errorf("write binary to volume: %w", err) } + return d.populateVolumeViaUnshare(ctx, destPath, data) +} + +// writeBinaryDirect writes the agent binary to a volume mountpoint the host +// can write directly (docker, or rootful podman). +func writeBinaryDirect(destPath string, data []byte) error { if err := os.WriteFile(destPath, data, 0o600); err != nil { return fmt.Errorf("write binary to volume: %w", err) } - // #nosec G302 -- agent binary must be executable - if err := os.Chmod(destPath, 0o755); err != nil { - return fmt.Errorf("chmod binary: %w", err) - } - return nil + return os.Chmod(destPath, 0o755) // #nosec G302 -- agent binary must be executable } func (d *LocalDockerDelivery) populateVolumeViaUnshare( @@ -272,21 +279,6 @@ func (d *LocalDockerDelivery) isPodman() bool { return filepath.Base(d.dockerCommand()) == podmanCmd } -// isRootlessPodman reports whether the configured podman is running rootless. -// Only rootless podman requires "podman unshare" to write into a volume's -// mountpoint; rootful podman's mountpoint is directly writable like docker's. -func (d *LocalDockerDelivery) isRootlessPodman(ctx context.Context) bool { - if !d.isPodman() { - return false - } - out, err := d.cmd(ctx, "info", "--format", "{{.Host.Security.Rootless}}").Output() - if err != nil { - log.Debugf("failed to detect podman rootless mode, assuming rootful: %v", err) - return false - } - return strings.TrimSpace(string(out)) == "true" -} - func (d *LocalDockerDelivery) volumeMountpoint( ctx context.Context, volumeName string, diff --git a/pkg/agent/delivery/local_docker_test.go b/pkg/agent/delivery/local_docker_test.go index 74323c462..e42f82f1e 100644 --- a/pkg/agent/delivery/local_docker_test.go +++ b/pkg/agent/delivery/local_docker_test.go @@ -154,7 +154,7 @@ func TestIsPodman(t *testing.T) { } } -func TestPopulateVolumeDirectCopy_PodmanUsesUnshare(t *testing.T) { +func TestPopulateVolumeDirectCopy_PodmanWritesDirectlyWhenWritable(t *testing.T) { tmpDir := t.TempDir() mountDir := filepath.Join(tmpDir, "mount") require.NoError(t, os.MkdirAll(mountDir, 0o750)) @@ -165,7 +165,7 @@ func TestPopulateVolumeDirectCopy_PodmanUsesUnshare(t *testing.T) { scriptPath := filepath.Join(tmpDir, "podman") script := "#!/bin/sh\n" + "case \"$1\" in\n" + - " unshare) shift; exec \"$@\" ;;\n" + + " unshare) echo \"unexpected unshare\" >&2; exit 1 ;;\n" + " volume) echo \"" + mountDir + "\" ;;\n" + " *) exit 1 ;;\n" + "esac\n" @@ -186,6 +186,45 @@ func TestPopulateVolumeDirectCopy_PodmanUsesUnshare(t *testing.T) { assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) } +func TestPopulateVolumeDirectCopy_PodmanFallsBackToUnshareOnPermission(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions; EACCES fallback cannot be exercised") + } + tmpDir := t.TempDir() + mountDir := filepath.Join(tmpDir, "mount") + require.NoError(t, os.MkdirAll(mountDir, 0o750)) + require.NoError( + t, + os.Chmod(mountDir, 0o550), + ) // #nosec G302 -- test sets restrictive permissions to trigger EACCES + t.Cleanup(func() { _ = os.Chmod(mountDir, 0o750) }) // #nosec G302 -- restore for cleanup + + destPath := filepath.Join(mountDir, binaryName()) + binaryContent := []byte("fake-agent-binary-content") + scriptPath := filepath.Join(tmpDir, "podman") + require.NoError(t, os.WriteFile(scriptPath, []byte( + "#!/bin/sh\n"+ + "case \"$1\" in\n"+ + " unshare) chmod 755 \""+mountDir+"\" 2>/dev/null; shift; exec \"$@\" ;;\n"+ + " volume) echo \""+mountDir+"\" ;;\n"+ + " *) exit 1 ;;\n"+ + "esac\n"), 0o600)) + // #nosec G302 -- test script must be executable + require.NoError(t, os.Chmod(scriptPath, 0o755)) + + d := &LocalDockerDelivery{DockerCommand: scriptPath} + err := d.populateVolumeDirectCopy(context.Background(), "test-vol", binaryContent) + require.NoError(t, err) + + data, err := os.ReadFile(destPath) //nolint:gosec // test reads from a temp directory we control + require.NoError(t, err) + assert.Equal(t, binaryContent, data) + + info, err := os.Stat(destPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) +} + func TestPopulateVolumeDirectCopy_DockerUsesDirectWrite(t *testing.T) { tmpDir := t.TempDir() mountDir := filepath.Join(tmpDir, "mount") diff --git a/pkg/agent/delivery/workspace_seed.go b/pkg/agent/delivery/workspace_seed.go index 8e1c9ffda..8ae081f3d 100644 --- a/pkg/agent/delivery/workspace_seed.go +++ b/pkg/agent/delivery/workspace_seed.go @@ -147,7 +147,9 @@ func (d *LocalDockerDelivery) copyDirIntoVolume( var script strings.Builder script.WriteString("cd /source && tar -c") for _, artifact := range config.BuildArtifactExcludes() { - script.WriteString(" --exclude='" + artifact + "'") + script.WriteString(" --exclude='") + script.WriteString(artifact) + script.WriteString("'") } script.WriteString(" . | tar -x -C /target") args := []string{ diff --git a/pkg/agent/inject.go b/pkg/agent/inject.go index 60bed5dc3..fd8f702c9 100644 --- a/pkg/agent/inject.go +++ b/pkg/agent/inject.go @@ -28,39 +28,31 @@ var ( ) const ( - osLinux = "linux" + osLinux = "linux" + statusDone = "done" ) -var waitForInstanceConnectionTimeout = time.Minute * 5 +var ( + waitForInstanceConnectionTimeout = time.Minute * 5 + versionCheckTimeout = time.Second * 30 +) // InjectOptions defines the parameters for injecting the Devsy agent into a remote environment. type InjectOptions struct { - // Ctx is the context for the injection operation. Required. - Ctx context.Context // Exec is the function used to execute commands on the remote machine. Required. - //nolint:staticcheck // SA1019: legacy shell injection path, retained until callers migrate to AgentDelivery + // nolint:staticcheck Exec inject.ExecFunc - // IsLocal indicates if the injection target is the local machine. IsLocal bool // RemoteAgentPath is the path where the agent binary should be placed on the remote machine. - // Defaults to config.RemoteDevsyHelperLocation. RemoteAgentPath string - // DownloadURL is the base URL to download the agent binary from. Defaults to config.DefaultAgentDownloadURL(). + // DownloadURL is the base URL to download the agent binary from. DownloadURL string // PreferDownloadFromRemoteUrl forces downloading the agent even if a local binary is available. // Defaults to true for release versions, false for dev versions. PreferDownloadFromRemoteUrl *bool // Timeout is the maximum duration to wait for the injection to complete. Defaults to 5 minutes. Timeout time.Duration - - // Command is the command to execute after successful injection. - Command string - - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - // LocalVersion is the version of the local Devsy binary. // Defaults to version.GetVersion(). LocalVersion string @@ -70,6 +62,11 @@ type InjectOptions struct { // SkipVersionCheck disables the validation of the remote agent's version. // Defaults to false, unless DEVSY_AGENT_URL is set. SkipVersionCheck bool + // Command is the command to execute upon successful injection. + Command string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer } func (o *InjectOptions) ApplyDefaults() { @@ -78,15 +75,7 @@ func (o *InjectOptions) ApplyDefaults() { o.applyPreferDownloadDefaults() } -//go:fix inline -func Bool(b bool) *bool { - return new(b) -} - func (o *InjectOptions) Validate() error { - if o.Ctx == nil { - return fmt.Errorf("context is required") - } if o.Exec == nil { return fmt.Errorf("exec function is required") } @@ -163,14 +152,14 @@ func (o *InjectOptions) applyEnvPreference(preferDownloadEnv string) { o.SkipVersionCheck = true } -func InjectAgent(opts *InjectOptions) error { +func InjectAgent(ctx context.Context, opts *InjectOptions) error { opts.ApplyDefaults() if err := opts.Validate(); err != nil { return err } if opts.IsLocal { - return injectLocally(opts) + return injectLocally(ctx, opts) } vc := newVersionChecker(opts) @@ -189,7 +178,7 @@ func InjectAgent(opts *InjectOptions) error { log.Debug("starting agent injection") return retry.OnError(backoff, func(err error) bool { - if opts.Ctx.Err() != nil { + if ctx.Err() != nil { return false } if errors.Is(err, docker.ErrContainerTerminal) { @@ -199,7 +188,7 @@ func InjectAgent(opts *InjectOptions) error { log.Debugf("retrying injection: %v", err) return true }, func() error { - return injectAgent(&injectContext{ + return injectAgent(ctx, &injectConfig{ opts: opts, bm: bm, vc: vc, @@ -207,44 +196,43 @@ func InjectAgent(opts *InjectOptions) error { }) } -func injectLocally(opts *InjectOptions) error { +func injectLocally(ctx context.Context, opts *InjectOptions) error { if opts.Command == "" { return nil } log.Debug("execute command locally") - return shell.RunEmulatedShell(opts.Ctx, opts.Command, opts.Stdin, opts.Stdout, opts.Stderr, nil) + return shell.RunEmulatedShell(ctx, &shell.CommandRunner{ + Command: opts.Command, + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + Environ: nil, + }) } -type injectContext struct { +type injectConfig struct { opts *InjectOptions bm *BinaryManager vc *versionChecker } -func injectAgent(ctx *injectContext) error { - opts := ctx.opts - +func injectAgent(ctx context.Context, cfg *injectConfig) error { buf := &bytes.Buffer{} - stderr := setupStderr(opts, buf) - binaryLoader := createBinaryLoader(ctx) - scriptParams := buildScriptParams(ctx) - - //nolint:staticcheck // SA1019: legacy shell injection path, retained until callers migrate to AgentDelivery - wasExecuted, err := inject.Inject(inject.InjectOptions{ - Ctx: opts.Ctx, - Exec: opts.Exec, - LocalFile: binaryLoader, - ScriptParams: scriptParams, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: stderr, - Timeout: opts.Timeout, + //nolint:staticcheck + wasExecuted, err := inject.Inject(ctx, inject.InjectOptions{ + Exec: cfg.opts.Exec, + LocalFile: createBinaryLoader(ctx, cfg), + ScriptParams: buildScriptParams(cfg), + Stdin: cfg.opts.Stdin, + Stdout: cfg.opts.Stdout, + Stderr: setupStderr(cfg.opts, buf), + Timeout: cfg.opts.Timeout, }) if err != nil { return handleInjectError(err, wasExecuted, buf) } - return performVersionCheck(ctx) + return performVersionCheck(ctx, cfg) } func setupStderr(opts *InjectOptions, buf *bytes.Buffer) io.Writer { @@ -254,23 +242,23 @@ func setupStderr(opts *InjectOptions, buf *bytes.Buffer) io.Writer { return buf } -func createBinaryLoader(ctx *injectContext) func(bool) (io.ReadCloser, error) { +func createBinaryLoader(ctx context.Context, cfg *injectConfig) func(bool) (io.ReadCloser, error) { return func(arm bool) (io.ReadCloser, error) { arch := "amd64" if arm { arch = "arm64" } - return ctx.bm.AcquireBinary(ctx.opts.Ctx, arch) + return cfg.bm.AcquireBinary(ctx, arch) } } -func buildScriptParams(ctx *injectContext) *inject.Params { - opts := ctx.opts +func buildScriptParams(cfg *injectConfig) *inject.Params { + opts := cfg.opts return &inject.Params{ Command: opts.Command, AgentRemotePath: opts.RemoteAgentPath, DownloadURLs: inject.NewDownloadURLs(opts.DownloadURL), - ExistsCheck: ctx.vc.buildExistsCheck(opts.RemoteAgentPath), + ExistsCheck: cfg.vc.buildExistsCheck(opts.RemoteAgentPath), PreferAgentDownload: *opts.PreferDownloadFromRemoteUrl, ShouldChmodPath: true, } @@ -286,11 +274,11 @@ func handleInjectError(err error, wasExecuted bool, buf *bytes.Buffer) error { return &InjectError{Stage: InjectStageInject, Cause: err} } -func performVersionCheck(ctx *injectContext) error { - opts := ctx.opts +func performVersionCheck(ctx context.Context, cfg *injectConfig) error { + opts := cfg.opts - detectedVersion, err := ctx.vc.detectRemoteAgentVersion( - opts.Ctx, + detectedVersion, err := cfg.vc.detectRemoteAgentVersion( + ctx, opts.Exec, opts.RemoteAgentPath, ) @@ -354,7 +342,7 @@ func (vc *versionChecker) buildExistsCheck(agentPath string) string { agentPath, agentPath, vc.remoteVersion) } -//nolint:staticcheck // SA1019: legacy shell injection path, retained until callers migrate +// session encapsulates the state and resources for an injection session. func (vc *versionChecker) detectRemoteAgentVersion( ctx context.Context, exec inject.ExecFunc, @@ -362,8 +350,14 @@ func (vc *versionChecker) detectRemoteAgentVersion( ) (string, error) { buf := &bytes.Buffer{} versionCmd := fmt.Sprintf("%s --version", agentPath) - err := exec(ctx, versionCmd, nil, buf, io.Discard) + + checkCtx, cancel := context.WithTimeout(ctx, versionCheckTimeout) + defer cancel() + err := exec(checkCtx, versionCmd, nil, buf, io.Discard) if err != nil { + if errors.Is(checkCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil { + return "", fmt.Errorf("get remote agent version timed out: %w", err) + } return "", fmt.Errorf("failed to get remote agent version: %w", err) } diff --git a/pkg/agent/inject_test.go b/pkg/agent/inject_test.go index 33947eb12..c194c3936 100644 --- a/pkg/agent/inject_test.go +++ b/pkg/agent/inject_test.go @@ -4,6 +4,7 @@ import ( "context" "io" "testing" + "time" "github.com/stretchr/testify/suite" ) @@ -23,7 +24,6 @@ func (s *InjectTestSuite) SetupTest() { func (s *InjectTestSuite) TestLocalInjection() { opts := &InjectOptions{ - Ctx: s.ctx, Exec: (&MockExecFunc{}).Exec, IsLocal: true, Command: "echo hello", @@ -69,6 +69,36 @@ func (s *InjectTestSuite) TestVersionChecker() { }) } +func (s *InjectTestSuite) TestVersionChecker_BoundedByVersionCheckTimeout() { + original := versionCheckTimeout + versionCheckTimeout = 50 * time.Millisecond + defer func() { versionCheckTimeout = original }() + + vc := &versionChecker{remoteVersion: "v2.0.0"} + hangingExec := func( + ctx context.Context, + _ string, + _ io.Reader, + _ io.Writer, + _ io.Writer, + ) error { + <-ctx.Done() + return ctx.Err() + } + + start := time.Now() + detected, err := vc.detectRemoteAgentVersion(context.Background(), hangingExec, "/path") + elapsed := time.Since(start) + + s.Empty(detected) + s.Error(err) + s.Less( + elapsed, 2*time.Second, + "detectRemoteAgentVersion took %s, want bounded by versionCheckTimeout", elapsed, + ) + s.ErrorContains(err, "timed out") +} + // MockExecFunc is a helper for testing. type MockExecFunc struct { CapturedCmd string diff --git a/pkg/client/clientimplementation/machine_client.go b/pkg/client/clientimplementation/machine_client.go index f678b7392..037add70b 100644 --- a/pkg/client/clientimplementation/machine_client.go +++ b/pkg/client/clientimplementation/machine_client.go @@ -76,17 +76,16 @@ func (e *machineExecutor) execute(ctx context.Context, cfg execConfig) error { log.Infof(cfg.startMsg) } - err := RunCommandWithBinaries(CommandOptions{ - Ctx: ctx, - Command: cfg.command, - Context: e.client.machine.Context, - Machine: e.client.machine, - Options: e.client.devsyConfig.ProviderOptions(e.client.config.Name), - Config: e.client.config, - Stdout: cfg.stdout, - Stderr: cfg.stderr, - ExtraEnv: cfg.extraEnv, - Stdin: cfg.stdin, + err := RunCommandWithBinaries(ctx, WorkspaceCommandConfig{ + Command: cfg.command, + WorkspaceContextName: e.client.machine.Context, + Machine: e.client.machine, + Options: e.client.devsyConfig.ProviderOptions(e.client.config.Name), + Config: e.client.config, + Stdout: cfg.stdout, + Stderr: cfg.stderr, + ExtraEnv: cfg.extraEnv, + Stdin: cfg.stdin, }) if err != nil { return err diff --git a/pkg/client/clientimplementation/proxy_client.go b/pkg/client/clientimplementation/proxy_client.go index 1c054d048..4e6d0fe74 100644 --- a/pkg/client/clientimplementation/proxy_client.go +++ b/pkg/client/clientimplementation/proxy_client.go @@ -72,17 +72,16 @@ type execParams struct { // execute runs a proxy command with common settings. func (e *proxyExecutor) execute(ctx context.Context, params execParams) error { - return RunCommandWithBinaries(CommandOptions{ - Ctx: ctx, - Command: params.command, - Context: e.client.workspace.Context, - Workspace: e.client.workspace, - Options: e.client.devsyConfig.ProviderOptions(e.client.config.Name), - Config: e.client.config, - ExtraEnv: params.extraEnv, - Stdin: params.stdin, - Stdout: params.stdout, - Stderr: params.stderr, + return RunCommandWithBinaries(ctx, WorkspaceCommandConfig{ + Command: params.command, + WorkspaceContextName: e.client.workspace.Context, + Workspace: e.client.workspace, + Options: e.client.devsyConfig.ProviderOptions(e.client.config.Name), + Config: e.client.config, + ExtraEnv: params.extraEnv, + Stdin: params.stdin, + Stdout: params.stdout, + Stderr: params.stderr, }) } @@ -314,18 +313,17 @@ func (s *proxyClient) Status( stdout := &bytes.Buffer{} buf := &bytes.Buffer{} - err := RunCommandWithBinaries(CommandOptions{ - Ctx: ctx, - Command: s.config.Exec.Proxy.Status, - Context: s.workspace.Context, - Workspace: s.workspace, - Machine: nil, - Options: s.devsyConfig.ProviderOptions(s.config.Name), - Config: s.config, - ExtraEnv: EncodeOptions(options, config.EnvFlagsStatus), - Stdin: nil, - Stdout: io.MultiWriter(stdout, buf), - Stderr: buf, + err := RunCommandWithBinaries(ctx, WorkspaceCommandConfig{ + Command: s.config.Exec.Proxy.Status, + WorkspaceContextName: s.workspace.Context, + Workspace: s.workspace, + Machine: nil, + Options: s.devsyConfig.ProviderOptions(s.config.Name), + Config: s.config, + ExtraEnv: EncodeOptions(options, config.EnvFlagsStatus), + Stdin: nil, + Stdout: io.MultiWriter(stdout, buf), + Stderr: buf, }) if err != nil { return client.StatusNotFound, fmt.Errorf( diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 4525a4f02..96b3ce48c 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "time" @@ -32,22 +33,22 @@ import ( func NewWorkspaceClient( devsyConfig *config.Config, - prov *provider.ProviderConfig, + providerConfig *provider.ProviderConfig, workspace *provider.Workspace, machine *provider.Machine, ) (client.WorkspaceClient, error) { if workspace.Machine.ID != "" && machine == nil { return nil, errors.New("machine not found") } - if prov.IsMachineProvider() && workspace.Machine.ID == "" { + if providerConfig.IsMachineProvider() && workspace.Machine.ID == "" { return nil, errors.New("machine id empty for machine provider") } return &workspaceClient{ - devsyConfig: devsyConfig, - config: prov, - workspace: workspace, - machine: machine, + devsyConfig: devsyConfig, + providerConfig: providerConfig, + providerWorkspace: workspace, + providerMachine: machine, }, nil } @@ -59,32 +60,32 @@ type workspaceClient struct { workspaceLock *flock.Flock machineLock *flock.Flock - devsyConfig *config.Config - config *provider.ProviderConfig - workspace *provider.Workspace - machine *provider.Machine + devsyConfig *config.Config + providerConfig *provider.ProviderConfig + providerWorkspace *provider.Workspace + providerMachine *provider.Machine } func (s *workspaceClient) Provider() string { - return s.config.Name + return s.providerConfig.Name } func (s *workspaceClient) Context() string { - return s.workspace.Context + return s.providerWorkspace.Context } func (s *workspaceClient) Workspace() string { s.m.Lock() defer s.m.Unlock() - return s.workspace.ID + return s.providerWorkspace.ID } func (s *workspaceClient) WorkspaceConfig() *provider.Workspace { s.m.Lock() defer s.m.Unlock() - return provider.CloneWorkspace(s.workspace) + return provider.CloneWorkspace(s.providerWorkspace) } func (s *workspaceClient) AgentLocal() bool { @@ -198,11 +199,11 @@ func (s *workspaceClient) Create(ctx context.Context) error { if !s.isMachineProvider() { return nil } - if s.machine == nil { + if s.providerMachine == nil { return errors.New("machine not defined") } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return err } @@ -230,10 +231,10 @@ func (s *workspaceClient) Delete(ctx context.Context, opt client.DeleteOptions) } return DeleteWorkspaceFolder(DeleteWorkspaceFolderParams{ - Context: s.workspace.Context, - WorkspaceID: s.workspace.ID, - SSHConfigPath: s.workspace.SSHConfigPath, - SSHConfigIncludePath: s.workspace.SSHConfigIncludePath, + Context: s.providerWorkspace.Context, + WorkspaceID: s.providerWorkspace.ID, + SSHConfigPath: s.providerWorkspace.SSHConfigPath, + SSHConfigIncludePath: s.providerWorkspace.SSHConfigIncludePath, }) } @@ -241,11 +242,11 @@ func (s *workspaceClient) Start(ctx context.Context) error { s.m.Lock() defer s.m.Unlock() - if !s.isMachineProvider() || s.machine == nil { + if !s.isMachineProvider() || s.providerMachine == nil { return nil } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return err } @@ -257,11 +258,11 @@ func (s *workspaceClient) Stop(ctx context.Context, opt client.StopOptions) erro s.m.Lock() defer s.m.Unlock() - if !s.isMachineProvider() || !s.workspace.Machine.AutoDelete { + if !s.isMachineProvider() || !s.providerWorkspace.Machine.AutoDelete { return s.stopContainer(ctx) } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return err } @@ -275,9 +276,8 @@ func (s *workspaceClient) Command(ctx context.Context, opt client.CommandOptions return err } - return RunCommand(RunCommandOptions{ - Ctx: ctx, - Command: s.config.Exec.Command, + return RunCommand(ctx, RunCommandOptions{ + Command: s.providerConfig.Exec.Command, Environ: environ, Stdin: opt.Stdin, Stdout: opt.Stdout, @@ -297,7 +297,7 @@ func (s *workspaceClient) Status( err error ) switch { - case s.isMachineProvider() && len(s.config.Exec.Status) > 0: + case s.isMachineProvider() && len(s.providerConfig.Exec.Status) > 0: result, err = s.machineStatus(ctx, opt) case opt.ContainerStatus: result, err = s.getContainerStatus(ctx) @@ -318,14 +318,14 @@ func (s *workspaceClient) Describe(ctx context.Context) (string, error) { s.m.Lock() defer s.m.Unlock() - if !s.isMachineProvider() || len(s.config.Exec.Describe) == 0 { + if !s.isMachineProvider() || len(s.providerConfig.Exec.Describe) == 0 { return client.DescriptionNotFound, nil } - if s.machine == nil { + if s.providerMachine == nil { return client.DescriptionNotFound, nil } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return client.DescriptionNotFound, err } @@ -363,7 +363,7 @@ func (s *workspaceClient) latestUpTask() *task.State { return nil } - latest := newestUpTask(states, s.workspace.ID) + latest := newestUpTask(states, s.providerWorkspace.ID) if latest == nil { return nil } @@ -385,7 +385,12 @@ func newestUpTask(states []*task.State, workspaceID string) *task.State { } func (s *workspaceClient) agentConfig() provider.ProviderAgentConfig { - return options.ResolveAgentConfig(s.devsyConfig, s.config, s.workspace, s.machine) + return options.ResolveAgentConfig( + s.devsyConfig, + s.providerConfig, + s.providerWorkspace, + s.providerMachine, + ) } func (s *workspaceClient) agentLocal() bool { @@ -393,29 +398,29 @@ func (s *workspaceClient) agentLocal() bool { } func (s *workspaceClient) isMachineProvider() bool { - return len(s.config.Exec.Create) > 0 + return len(s.providerConfig.Exec.Create) > 0 } func (s *workspaceClient) refreshMachineOptions( ctx context.Context, userOptions map[string]string, ) error { - if s.machine == nil { + if s.providerMachine == nil { return nil } machine, err := options.ResolveAndSaveOptionsMachine( ctx, s.devsyConfig, - s.config, - s.machine, + s.providerConfig, + s.providerMachine, userOptions, ) if err != nil { return err } - s.machine = machine + s.providerMachine = machine return nil } @@ -426,8 +431,8 @@ func (s *workspaceClient) refreshWorkspaceOptions( workspace, err := options.ResolveAndSaveOptionsWorkspace( ctx, s.devsyConfig, - s.config, - s.workspace, + s.providerConfig, + s.providerWorkspace, userOptions, ) if err != nil { @@ -439,8 +444,8 @@ func (s *workspaceClient) refreshWorkspaceOptions( return nil } - s.workspace = workspace - log.Debugf("refreshed workspace options: workspaceId=%s", s.workspace.ID) + s.providerWorkspace = workspace + log.Debugf("refreshed workspace options: workspaceId=%s", s.providerWorkspace.ID) return nil } @@ -465,8 +470,8 @@ func (s *workspaceClient) compressedAgentInfo( func (s *workspaceClient) agentInfo(cliOptions provider.CLIOptions) *provider.AgentWorkspaceInfo { agentInfo := &provider.AgentWorkspaceInfo{ WorkspaceOrigin: s.workspaceOrigin(), - Workspace: s.workspace, - Machine: s.machine, + Workspace: s.providerWorkspace, + Machine: s.providerMachine, LastDevContainerConfig: s.lastDevContainerConfig(), CLIOptions: cliOptions, Agent: s.agentConfig(), @@ -492,18 +497,18 @@ func (s *workspaceClient) agentInfo(cliOptions provider.CLIOptions) *provider.Ag } func (s *workspaceClient) workspaceOrigin() string { - if s.workspace == nil { + if s.providerWorkspace == nil { return "" } - return s.workspace.Origin + return s.providerWorkspace.Origin } func (s *workspaceClient) lastDevContainerConfig() *config2.DevContainerConfigWithPath { - if s.workspace == nil { + if s.providerWorkspace == nil { return nil } - result, err := provider.LoadWorkspaceResult(s.workspace.Context, s.workspace.ID) + result, err := provider.LoadWorkspaceResult(s.providerWorkspace.Context, s.providerWorkspace.ID) if err != nil { log.Debugf("load workspace result: %v", err) return nil @@ -532,11 +537,12 @@ func (s *workspaceClient) deleteContext( } func (s *workspaceClient) deleteInstance(ctx context.Context, opt client.DeleteOptions) error { - if !s.isMachineProvider() || !s.workspace.Machine.AutoDelete { + if !s.isMachineProvider() || !s.providerWorkspace.Machine.AutoDelete { return s.deleteContainer(ctx, opt) } - if s.machine != nil && s.workspace.Machine.ID != "" && len(s.config.Exec.Delete) > 0 { + if s.providerMachine != nil && s.providerWorkspace.Machine.ID != "" && + len(s.providerConfig.Exec.Delete) > 0 { return s.deleteMachine(ctx, opt) } @@ -581,7 +587,7 @@ func handleForceError(err error, force bool) error { } func (s *workspaceClient) deleteMachine(ctx context.Context, opt client.DeleteOptions) error { - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { if !opt.Force { return err @@ -615,7 +621,7 @@ func (s *workspaceClient) isMachineRunning(ctx context.Context) (bool, error) { return true, nil } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return false, err } @@ -632,11 +638,11 @@ func (s *workspaceClient) machineStatus( ctx context.Context, opt client.StatusOptions, ) (client.Status, error) { - if s.machine == nil { + if s.providerMachine == nil { return client.StatusNotFound, nil } - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + machineClient, err := NewMachineClient(s.devsyConfig, s.providerConfig, s.providerMachine) if err != nil { return client.StatusNotFound, err } @@ -654,7 +660,10 @@ func (s *workspaceClient) machineStatus( } func (s *workspaceClient) workspaceFolderStatus() (client.Status, error) { - workspaceFolder, err := provider.GetWorkspaceDir(s.workspace.Context, s.workspace.ID) + workspaceFolder, err := provider.GetWorkspaceDir( + s.providerWorkspace.Context, + s.providerWorkspace.ID, + ) if err != nil { return "", err } @@ -720,17 +729,16 @@ func (s *workspaceClient) runProviderCommand( command string, stdout, stderr io.Writer, ) error { - return RunCommandWithBinaries(CommandOptions{ - Ctx: ctx, - Command: s.config.Exec.Command, - Context: s.workspace.Context, - Workspace: s.workspace, - Machine: s.machine, - Options: s.devsyConfig.ProviderOptions(s.config.Name), - Config: s.config, - ExtraEnv: map[string]string{provider.CommandEnv: command}, - Stdout: stdout, - Stderr: stderr, + return RunCommandWithBinaries(ctx, WorkspaceCommandConfig{ + Command: s.providerConfig.Exec.Command, + WorkspaceContextName: s.providerWorkspace.Context, + Workspace: s.providerWorkspace, + Machine: s.providerMachine, + Options: s.devsyConfig.ProviderOptions(s.providerConfig.Name), + Config: s.providerConfig, + ExtraEnv: map[string]string{provider.CommandEnv: command}, + Stdout: stdout, + Stderr: stderr, }) } @@ -739,11 +747,11 @@ func (s *workspaceClient) buildEnvironment(command string) ([]string, error) { defer s.m.Unlock() return provider.ToEnvironmentWithBinaries(provider.EnvironmentOptions{ - Context: s.workspace.Context, - Workspace: s.workspace, - Machine: s.machine, - Options: s.devsyConfig.ProviderOptions(s.config.Name), - Config: s.config, + Context: s.providerWorkspace.Context, + Workspace: s.providerWorkspace, + Machine: s.providerMachine, + Options: s.devsyConfig.ProviderOptions(s.providerConfig.Name), + Config: s.providerConfig, ExtraEnv: map[string]string{provider.CommandEnv: command}, }) } @@ -753,7 +761,7 @@ func (s *workspaceClient) initLock() error { s.m.Lock() defer s.m.Unlock() - workspaceLocksDir, err := provider.GetLocksDir(s.workspace.Context) + workspaceLocksDir, err := provider.GetLocksDir(s.providerWorkspace.Context) if err != nil { s.workspaceLockErr = fmt.Errorf("get workspaces dir: %w", err) return @@ -764,12 +772,12 @@ func (s *workspaceClient) initLock() error { } s.workspaceLock = flock.New( - filepath.Join(workspaceLocksDir, s.workspace.ID+".workspace.lock"), + filepath.Join(workspaceLocksDir, s.providerWorkspace.ID+".workspace.lock"), ) - if s.machine != nil { + if s.providerMachine != nil { s.machineLock = flock.New( - filepath.Join(workspaceLocksDir, s.machine.ID+".machine.lock"), + filepath.Join(workspaceLocksDir, s.providerMachine.ID+".machine.lock"), ) } }) @@ -787,45 +795,42 @@ func stripProviderOptions(info *provider.AgentWorkspaceInfo) { } } -type CommandOptions struct { - Ctx context.Context - Command types.StrArray - Context string - Workspace *provider.Workspace - Machine *provider.Machine - Options map[string]config.OptionValue - Config *provider.ProviderConfig - ExtraEnv map[string]string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer +type WorkspaceCommandConfig struct { + WorkspaceContextName string + ExtraEnv map[string]string + Options map[string]config.OptionValue + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Command types.StrArray + Workspace *provider.Workspace + Machine *provider.Machine + Config *provider.ProviderConfig } -func RunCommandWithBinaries(opts CommandOptions) error { +func RunCommandWithBinaries(ctx context.Context, cfg WorkspaceCommandConfig) error { environ, err := provider.ToEnvironmentWithBinaries(provider.EnvironmentOptions{ - Context: opts.Context, - Workspace: opts.Workspace, - Machine: opts.Machine, - Options: opts.Options, - Config: opts.Config, - ExtraEnv: opts.ExtraEnv, + Context: cfg.WorkspaceContextName, + Workspace: cfg.Workspace, + Machine: cfg.Machine, + Options: cfg.Options, + Config: cfg.Config, + ExtraEnv: cfg.ExtraEnv, }) if err != nil { return err } - return RunCommand(RunCommandOptions{ - Ctx: opts.Ctx, - Command: opts.Command, + return RunCommand(ctx, RunCommandOptions{ + Command: cfg.Command, Environ: environ, - Stdin: opts.Stdin, - Stdout: opts.Stdout, - Stderr: opts.Stderr, + Stdin: cfg.Stdin, + Stdout: cfg.Stdout, + Stderr: cfg.Stderr, }) } type RunCommandOptions struct { - Ctx context.Context Command types.StrArray Environ []string Stdin io.Reader @@ -833,7 +838,7 @@ type RunCommandOptions struct { Stderr io.Writer } -func RunCommand(opts RunCommandOptions) error { +func RunCommand(ctx context.Context, opts RunCommandOptions) error { if len(opts.Command) == 0 { return nil } @@ -843,22 +848,45 @@ func RunCommand(opts RunCommandOptions) error { } if len(opts.Command) == 1 { - return shell.RunEmulatedShell( - opts.Ctx, - opts.Command[0], - opts.Stdin, - opts.Stdout, - opts.Stderr, - opts.Environ, - ) + if err := shell.RunEmulatedShell(ctx, &shell.CommandRunner{ + Command: opts.Command[0], + Stdin: opts.Stdin, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + Environ: opts.Environ, + }); err != nil { + return fmt.Errorf( + "run provider command %q (%s): %w", + opts.Command[0], + providerAction(opts.Environ), + err, + ) + } + return nil } - cmd := exec.CommandContext(opts.Ctx, opts.Command[0], opts.Command[1:]...) // #nosec G204 + cmd := exec.CommandContext(ctx, opts.Command[0], opts.Command[1:]...) // #nosec G204 cmd.Stdin = opts.Stdin cmd.Stdout = opts.Stdout cmd.Stderr = opts.Stderr cmd.Env = opts.Environ - return cmd.Run() + if err := cmd.Run(); err != nil { + return fmt.Errorf( + "run provider command %q (%s): %w", + strings.Join(opts.Command, " "), providerAction(opts.Environ), err, + ) + } + return nil +} + +// providerAction extracts the provider action from the environment variables. +func providerAction(environ []string) string { + for _, kv := range environ { + if rest, ok := strings.CutPrefix(kv, provider.CommandEnv+"="); ok { + return rest + } + } + return "" } func DeleteMachineFolder(context, machineID string) error { @@ -1040,8 +1068,7 @@ func BuildAgentClient(ctx context.Context, opts BuildAgentClientOptions) (*confi defer cancel() command := buildAgentCommand(opts.WorkspaceClient, opts.AgentCommand, workspaceInfo) - errChan := runAgentInjection(agentInjectionOptions{ - ctx: cancelCtx, + errChan := runAgentInjection(cancelCtx, workspaceInjectionConfig{ workspaceClient: opts.WorkspaceClient, command: command, stdin: pipes.stdinReader, @@ -1074,14 +1101,14 @@ func buildAgentCommand( return command } -type agentPipes struct { +type processIOPipes struct { stdoutReader *os.File stdoutWriter *os.File stdinReader *os.File stdinWriter *os.File } -func newAgentPipes() (*agentPipes, error) { +func newAgentPipes() (*processIOPipes, error) { stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { return nil, err @@ -1094,7 +1121,7 @@ func newAgentPipes() (*agentPipes, error) { return nil, err } - return &agentPipes{ + return &processIOPipes{ stdoutReader: stdoutReader, stdoutWriter: stdoutWriter, stdinReader: stdinReader, @@ -1102,15 +1129,14 @@ func newAgentPipes() (*agentPipes, error) { }, nil } -func (p *agentPipes) close() { +func (p *processIOPipes) close() { _ = p.stdoutWriter.Close() _ = p.stdoutReader.Close() _ = p.stdinReader.Close() _ = p.stdinWriter.Close() } -type agentInjectionOptions struct { - ctx context.Context +type workspaceInjectionConfig struct { workspaceClient client.WorkspaceClient command string stdin *os.File @@ -1119,33 +1145,33 @@ type agentInjectionOptions struct { cancel context.CancelFunc } -func runAgentInjection(opts agentInjectionOptions) chan error { +func runAgentInjection(ctx context.Context, cfg workspaceInjectionConfig) chan error { errChan := make(chan error, 1) go func() { - defer log.Debugf("up command completed") - defer opts.cancel() - writer := log.Writer(log.LevelInfo) - defer func() { _ = writer.Close() }() + defer func() { + log.Debugf("up command completed") + cfg.cancel() + _ = writer.Close() + }() - errChan <- agent.InjectAgent(&agent.InjectOptions{ - Ctx: opts.ctx, + errChan <- agent.InjectAgent(ctx, &agent.InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { - return opts.workspaceClient.Command(ctx, client.CommandOptions{ + return cfg.workspaceClient.Command(ctx, client.CommandOptions{ Command: command, Stdin: stdin, Stdout: stdout, Stderr: stderr, }) }, - IsLocal: opts.workspaceClient.AgentLocal(), - RemoteAgentPath: opts.workspaceClient.AgentPath(), - DownloadURL: opts.workspaceClient.AgentURL(), - Command: opts.command, - Stdin: opts.stdin, - Stdout: opts.stdout, + IsLocal: cfg.workspaceClient.AgentLocal(), + RemoteAgentPath: cfg.workspaceClient.AgentPath(), + DownloadURL: cfg.workspaceClient.AgentURL(), + Command: cfg.command, + Stdin: cfg.stdin, + Stdout: cfg.stdout, Stderr: writer, - Timeout: opts.timeout, + Timeout: cfg.timeout, }) }() return errChan diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index a6ec94303..1ccf478b1 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -44,7 +44,7 @@ func assertOverride(t *testing.T, s *workspaceClient, wantOK bool, wantStatus cl func TestTaskStatusOverride_NoTasks(t *testing.T) { useTempTaskDir(t) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -55,7 +55,7 @@ func TestTaskStatusOverride_ActiveUpTaskReportsProvisioning(t *testing.T) { _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) } @@ -67,7 +67,7 @@ func TestTaskStatusOverride_FailedTaskReportsFailed(t *testing.T) { require.NoError(t, err) require.NoError(t, tsk.Fail(errors.New("build failed"))) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusFailed) } @@ -79,7 +79,7 @@ func TestTaskStatusOverride_SucceededTaskDefersToContainerStatus(t *testing.T) { require.NoError(t, err) require.NoError(t, tsk.Succeed(nil)) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -90,7 +90,7 @@ func TestTaskStatusOverride_IgnoresOtherWorkspaces(t *testing.T) { _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "other-ws"}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -101,7 +101,7 @@ func TestTaskStatusOverride_IgnoresNonUpCommands(t *testing.T) { _, err = store.Create(task.CreateOptions{Command: "delete", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -118,7 +118,7 @@ func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) } @@ -133,7 +133,7 @@ func TestTaskStatusOverride_AbandonedTaskReportsFailed(t *testing.T) { require.NoError(t, tk.HoldWorkerLock()) require.NoError(t, tk.ReleaseWorkerLockForTest()) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusFailed) // Persisted, so `task list` agrees with what Status just reported. @@ -153,6 +153,6 @@ func TestTaskStatusOverride_LiveWorkerStillReportsProvisioning(t *testing.T) { // file can block TempDir removal on some platforms. t.Cleanup(func() { require.NoError(t, tk.ReleaseWorkerLockForTest()) }) - s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + s := &workspaceClient{providerWorkspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) } diff --git a/pkg/client/clientimplementation/workspace_client_test.go b/pkg/client/clientimplementation/workspace_client_test.go index 903672658..10ce3ebdd 100644 --- a/pkg/client/clientimplementation/workspace_client_test.go +++ b/pkg/client/clientimplementation/workspace_client_test.go @@ -87,24 +87,23 @@ func TestRemoveAll(t *testing.T) { func TestRunCommand(t *testing.T) { t.Run("empty command is a no-op", func(t *testing.T) { - assert.NoError(t, RunCommand(RunCommandOptions{Ctx: context.Background()})) + assert.NoError(t, RunCommand(context.Background(), RunCommandOptions{})) }) t.Run("multi-arg command runs via exec", func(t *testing.T) { stdout := &bytes.Buffer{} - err := RunCommand(RunCommandOptions{ - Ctx: context.Background(), - Command: types.StrArray{"echo", "hello"}, - Stdout: stdout, - }) + err := RunCommand(context.Background(), + RunCommandOptions{ + Command: types.StrArray{"echo", "hello"}, + Stdout: stdout, + }) require.NoError(t, err) assert.Equal(t, "hello", strings.TrimSpace(stdout.String())) }) t.Run("single command runs via emulated shell", func(t *testing.T) { stdout := &bytes.Buffer{} - err := RunCommand(RunCommandOptions{ - Ctx: context.Background(), + err := RunCommand(context.Background(), RunCommandOptions{ Command: types.StrArray{"echo emulated"}, Stdout: stdout, }) diff --git a/pkg/client/proxycmd/proxycmd.go b/pkg/client/proxycmd/proxycmd.go new file mode 100644 index 000000000..b5f27d4a2 --- /dev/null +++ b/pkg/client/proxycmd/proxycmd.go @@ -0,0 +1,108 @@ +// Proxycmd provides a interface for running provider binaries with the +// correct environment and error handling. +package proxycmd + +import ( + "bytes" + "context" + "fmt" + "io" + "maps" + + "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/table" + "github.com/devsy-org/devsy/pkg/types" +) + +// Options configures a single proxy-command invocation: which provider +// binary command to exec, the options environment it runs with, and how +// errors and stderr are reported. +type Options struct { + DevsyConfig *config.Config + Provider *provider.ProviderConfig + + // Command is the command to run, as a list of arguments. The first + // argument is the binary to exec, and the rest are its arguments. + Command types.StrArray + + // ExtraOptions are additional options to pass to the provider binary. They + // are merged with the provider options from the devsy config, and take + // precedence over them. + ExtraOptions map[string]config.OptionValue + + // Stderr is the writer to which the provider binary's stderr is written. If + // nil, it defaults to the logger's error writer. + Stderr io.Writer +} + +// Run runs a proxy command and returns its raw stdout. +func Run(ctx context.Context, cfg Options) ([]byte, error) { + opts := cfg.DevsyConfig.ProviderOptions(cfg.Provider.Name) + maps.Copy(opts, cfg.ExtraOptions) + + stderr := cfg.Stderr + if stderr == nil { + stderr = log.Writer(log.LevelError) + } + + var stdout bytes.Buffer + err := clientimplementation.RunCommandWithBinaries( + ctx, + clientimplementation.WorkspaceCommandConfig{ + Command: cfg.Command, + WorkspaceContextName: cfg.DevsyConfig.DefaultContext, + Options: opts, + Config: cfg.Provider, + Stdout: &stdout, + Stderr: stderr, + }, + ) + if err != nil { + return nil, fmt.Errorf("with provider %q: %w", cfg.Provider.Name, err) + } + return stdout.Bytes(), nil +} + +// RunAndPrint runs a proxy command and prints its stdout to the logger's info writer. +func RunAndPrint(ctx context.Context, cfg Options) error { + out, err := Run(ctx, cfg) + if err != nil { + return err + } + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output + return nil +} + +func RunAndPrintTable( + ctx context.Context, + cfg Options, + headers []string, + buildRows func(payload []byte) ([][]string, error), +) error { + out, err := Run(ctx, cfg) + if err != nil { + return err + } + return printTable(headers, out, buildRows) +} + +func printTable( + headers []string, + payload []byte, + buildRows func(payload []byte) ([][]string, error), +) error { + if len(payload) == 0 { + table.Print(headers, nil) + return nil + } + + rows, err := buildRows(payload) + if err != nil { + return fmt.Errorf("parse output: %w", err) + } + table.Print(headers, rows) + return nil +} diff --git a/pkg/client/proxycmd/proxycmd_test.go b/pkg/client/proxycmd/proxycmd_test.go new file mode 100644 index 000000000..ec3b7a842 --- /dev/null +++ b/pkg/client/proxycmd/proxycmd_test.go @@ -0,0 +1,59 @@ +package proxycmd + +import ( + "errors" + "strings" + "testing" +) + +var testHeaders = []string{"Name"} + +func TestPrintTable_EmptyPayloadSkipsBuildRows(t *testing.T) { + called := false + err := printTable(testHeaders, nil, func([]byte) ([][]string, error) { + called = true + return nil, nil + }) + if err != nil { + t.Fatalf("printTable(empty) returned error: %v", err) + } + if called { + t.Fatal("printTable must not call buildRows for an empty payload") + } +} + +func TestPrintTable_NonEmptyPayloadCallsBuildRows(t *testing.T) { + payload := []byte(`[{"name":"a"}]`) + var got []byte + err := printTable(testHeaders, payload, func(p []byte) ([][]string, error) { + got = p + return [][]string{{"a"}}, nil + }) + if err != nil { + t.Fatalf("printTable(non-empty) returned error: %v", err) + } + if string(got) != string(payload) { + t.Fatalf("buildRows got payload %q, want %q", got, payload) + } +} + +func TestPrintTable_BuildRowsErrorIsWrappedWithAction(t *testing.T) { + wantCause := errors.New("bad json") + err := printTable( + testHeaders, + []byte(`not json`), + func([]byte) ([][]string, error) { + return nil, wantCause + }, + ) + if err == nil { + t.Fatal("printTable must return an error when buildRows fails") + } + if !errors.Is(err, wantCause) { + t.Fatalf("printTable error does not wrap the buildRows cause: %v", err) + } + const wantSubstr = "parse output" + if got := err.Error(); !strings.Contains(got, wantSubstr) { + t.Fatalf("printTable error %q does not mention %q", got, wantSubstr) + } +} diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index 8e3506386..6de66a03d 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -94,6 +94,7 @@ func (r *runner) extendImage( ImageName: imageBase, RegistryCache: options.RegistryCache, Tags: options.Tag, + BuiltLocally: false, // devcontainer.json "image:" reference }, nil } @@ -402,6 +403,7 @@ func (r *runner) findPrebuildImage( PrebuildHash: params.prebuildHash, RegistryCache: options.RegistryCache, Tags: options.Tag, + BuiltLocally: false, // prebuild repository image, may need a real pull }, nil } @@ -612,6 +614,8 @@ func (r *runner) composeBuildInfo( PrebuildHash: imageTag, RegistryCache: options.RegistryCache, Tags: options.Tag, + BuiltLocally: extendResult.buildImageName != "" && + currentImageName == extendResult.buildImageName, }, nil } diff --git a/pkg/devcontainer/buildkit/remote.go b/pkg/devcontainer/buildkit/remote.go index fc50522d6..51eb9249a 100644 --- a/pkg/devcontainer/buildkit/remote.go +++ b/pkg/devcontainer/buildkit/remote.go @@ -124,6 +124,7 @@ func buildFinalResult( PrebuildHash: opts.PrebuildHash, RegistryCache: opts.Options.RegistryCache, Tags: opts.Options.Tag, + BuiltLocally: false, // built remotely and pushed to a registry, not local }, nil } @@ -242,6 +243,7 @@ func checkExistingImage(params checkExistingImageParams) *config.BuildInfo { PrebuildHash: params.Opts.PrebuildHash, RegistryCache: params.Opts.Options.RegistryCache, Tags: params.Opts.Options.Tag, + BuiltLocally: false, // found in the registry, not local } } diff --git a/pkg/devcontainer/config/build.go b/pkg/devcontainer/config/build.go index 30bb952f8..4df5972d2 100644 --- a/pkg/devcontainer/config/build.go +++ b/pkg/devcontainer/config/build.go @@ -41,14 +41,14 @@ func ValidateIDLabels(labels []string) error { } type BuildInfo struct { - ImageDetails *ImageDetails - ImageMetadata *ImageMetadataConfig + BuiltLocally bool ImageName string PrebuildHash string RegistryCache string + ImageDetails *ImageDetails + ImageMetadata *ImageMetadataConfig + Dockerless *BuildInfoDockerless Tags []string - - Dockerless *BuildInfoDockerless } type BuildInfoDockerless struct { diff --git a/pkg/devcontainer/config/envfile.go b/pkg/devcontainer/config/envfile.go index 1c1a88743..6933e6fd8 100644 --- a/pkg/devcontainer/config/envfile.go +++ b/pkg/devcontainer/config/envfile.go @@ -29,6 +29,9 @@ func ParseKeyValueFile(filename string) ([]string, error) { keyValuePairs = append(keyValuePairs, line) } } + if err := scanner.Err(); err != nil { + return nil, err + } return keyValuePairs, nil } diff --git a/pkg/devcontainer/config/host_requirements_system.go b/pkg/devcontainer/config/host_requirements_system.go index e0d29a13c..89a5600a8 100644 --- a/pkg/devcontainer/config/host_requirements_system.go +++ b/pkg/devcontainer/config/host_requirements_system.go @@ -56,5 +56,8 @@ func readMemTotalFromProc() (uint64, error) { } return kb * 1024, nil } + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("scan /proc/meminfo: %w", err) + } return 0, fmt.Errorf("MemTotal not found in /proc/meminfo") } diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 9a96102f7..d38e9839c 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -226,8 +226,7 @@ func (r *runner) newBinarySource() (delivery.BinarySourceFunc, error) { } func (r *runner) legacyInject(ctx context.Context, timeout time.Duration) error { - err := agent.InjectAgent(&agent.InjectOptions{ - Ctx: ctx, + err := agent.InjectAgent(ctx, &agent.InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return r.driver.CommandDevContainer(ctx, &driver.CommandParams{ WorkspaceID: r.id, diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index ce1767bde..84993849e 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -833,6 +833,7 @@ func (r *runner) getRunOptions( return &driver.RunOptions{ UID: r.workspaceUID(), Image: buildInfo.ImageName, + ImageBuilt: buildInfo.BuiltLocally, User: user, Entrypoint: entrypoint, Cmd: cmd, diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 9819669e1..4442a055a 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/exec" + "runtime" "strings" "sync/atomic" "time" @@ -165,6 +166,70 @@ func (r *DockerHelper) StartPodmanMachine(ctx context.Context) error { return nil } +// PodmanMachineExists reports whether a Podman machine exists. +func (r *DockerHelper) PodmanMachineExists(ctx context.Context) (bool, error) { + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := r.buildCmd(cctx, "machine", "list", "--format", "{{.Name}}").Output() + if err != nil { + return false, err + } + return anyPodmanMachine(out), nil +} + +// anyPodmanMachine reports whether `podman machine list --format {{.Name}}` +// output names at least one machine. +func anyPodmanMachine(stdout []byte) bool { + for line := range strings.SplitSeq(string(stdout), "\n") { + if strings.TrimSpace(line) != "" { + return true + } + } + return false +} + +// StartRootlessPodmanSocket starts the podman.socket systemd unit for rootless +// Podman. +func (r *DockerHelper) StartRootlessPodmanSocket(ctx context.Context) error { + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + if runtime.GOOS == "linux" && isSystemdRunning(cctx) { + cmd := exec.CommandContext(cctx, "systemctl", "--user", "start", "podman.socket") + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%s: %w", msg, err) + } + return err + } + } + return nil +} + +func isSystemdRunning(ctx context.Context) bool { + cmd := exec.CommandContext(ctx, "systemctl", "is-system-running") + out, _ := cmd.CombinedOutput() + return systemdStateIsUsable(string(out)) +} + +const ( + systemdStateRunning = "running" + systemdStateDegraded = "degraded" +) + +// systemdStateIsUsable reports whether the output of `systemctl is-system-running` +// indicates that user-session units are usable. +func systemdStateIsUsable(output string) bool { + switch strings.TrimSpace(output) { + case systemdStateRunning, systemdStateDegraded: + return true + default: + return false + } +} + func (r *DockerHelper) FindDevContainer( ctx context.Context, labels []string, @@ -238,11 +303,16 @@ type PullOptions struct { Stderr io.Writer } +// runCmd runs the given command, wiring up stdin/stdout/stderr and handling context cancellation. func runCmd(ctx context.Context, cmd *exec.Cmd) error { + if cmd.Stdin == os.Stdin { + cmd.SysProcAttr = nil + } + var cancelledByCtx atomic.Bool cmd.Cancel = func() error { cancelledByCtx.Store(true) - return cmd.Process.Kill() + return killProcessGroup(cmd) } err := cmd.Run() @@ -646,5 +716,15 @@ func (r *DockerHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd { if r.Environment != nil { cmd.Env = append(os.Environ(), r.Environment...) } + PrepareForGroupCancellation(cmd) return cmd } + +// PrepareForGroupCancellation sets the Cancel function of the given exec.Cmd +// to kill the entire process group, allowing for cleanup of child processes. +// This is necessary because exec.Cmd does not automatically kill child processes +// when the context is canceled. +func PrepareForGroupCancellation(cmd *exec.Cmd) { + setProcessGroupAttrs(cmd) + cmd.Cancel = func() error { return killProcessGroup(cmd) } +} diff --git a/pkg/docker/helper_test.go b/pkg/docker/helper_test.go index 41d9e55fd..e581b6b22 100644 --- a/pkg/docker/helper_test.go +++ b/pkg/docker/helper_test.go @@ -438,3 +438,19 @@ esac _, err := os.Stat(removed) assert.NoError(t, err, "volume rm should be invoked for an existing volume") } + +func TestSystemdStateIsUsable(t *testing.T) { + cases := map[string]bool{ + systemdStateRunning: true, + systemdStateDegraded: true, + "starting": false, + "stopping": false, + "maintenance": false, + "offline": false, + "": false, + } + for state, want := range cases { + assert.Equal(t, want, systemdStateIsUsable(state), "state=%q", state) + } + assert.True(t, systemdStateIsUsable(systemdStateDegraded+"\n")) +} diff --git a/pkg/docker/helper_windows_test.go b/pkg/docker/helper_windows_test.go new file mode 100644 index 000000000..95d43ef3e --- /dev/null +++ b/pkg/docker/helper_windows_test.go @@ -0,0 +1,71 @@ +//go:build windows + +package docker + +import ( + "context" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunCmd_WindowsKillsOrphanedGrandchildOnCancel(t *testing.T) { + tmp := t.TempDir() + ready := filepath.Join(tmp, "ready") + childPIDFile := filepath.Join(tmp, "child.pid") + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +sleep 30 & +echo $! > `+childPIDFile+` +touch `+ready+` +wait $! +`) + h := &DockerHelper{DockerCommand: bin} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(ready); err == nil { + break + } + time.Sleep(time.Millisecond) + } + cancel() + }() + + streams := Streams{Stdout: io.Discard, Stderr: io.Discard} + done := make(chan error, 1) + go func() { done <- h.Run(ctx, testExecArgs, streams) }() + + select { + case err := <-done: + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal( + "Run did not return within 5s of cancel; grandchild likely leaked and held the pipe open", + ) + } + + childPIDRaw, err := os.ReadFile(childPIDFile) //nolint:gosec // G304: test-controlled path + require.NoError(t, err) + childPID, err := strconv.Atoi(strings.TrimSpace(string(childPIDRaw))) + require.NoError(t, err) + + assert.Eventually(t, func() bool { + proc, err := os.FindProcess(childPID) + if err != nil { + return true // Process does not exist + } + err = proc.Signal(syscall.Signal(0)) + return err != nil // If error, process is not running + }, time.Second, 10*time.Millisecond, "grandchild process %d was orphaned instead of killed with the group", childPID) +} diff --git a/pkg/docker/linger.go b/pkg/docker/linger.go index 7f75394e8..09d76905f 100644 --- a/pkg/docker/linger.go +++ b/pkg/docker/linger.go @@ -4,31 +4,26 @@ import ( "context" "os" "os/user" - "strings" "time" ) func (r *DockerHelper) LingerWarning(ctx context.Context) string { - if !r.IsPodman() || !r.isRootless(ctx) { + if !r.IsPodman() { return "" } - if lingerEnabled() { - return "" - } - return "rootless Podman without systemd linger: the workspace container will " + - "stop when your login session ends. Run `loginctl enable-linger` to keep " + - "it running after logout (Docker does not require this)." -} -func (r *DockerHelper) isRootless(ctx context.Context) bool { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - out, err := r.buildCmd(ctx, "info", "--format", "{{.Host.Security.Rootless}}").Output() - if err != nil { - return true + rootless, ok := probeRootlessPodman(cctx, r.buildCmd) + if ok && rootless { + if !lingerEnabled() { + return "rootless Podman without systemd linger: the workspace container will " + + "stop when your login session ends. Run `loginctl enable-linger` to keep " + + "it running after logout." + } } - return strings.TrimSpace(string(out)) != "false" + return "" } var lingerDir = "/var/lib/systemd/linger" diff --git a/pkg/docker/linger_test.go b/pkg/docker/linger_test.go index 710a88c97..d8d4677cc 100644 --- a/pkg/docker/linger_test.go +++ b/pkg/docker/linger_test.go @@ -1,28 +1,68 @@ package docker import ( - "os" - "path/filepath" + "context" "testing" + + "github.com/stretchr/testify/assert" ) -func TestUserHasLinger(t *testing.T) { - dir := t.TempDir() - orig := lingerDir - lingerDir = dir - t.Cleanup(func() { lingerDir = orig }) - - if userHasLinger("alice") { - t.Fatal("expected no linger when marker file is absent") - } - - if err := os.WriteFile(filepath.Join(dir, "alice"), nil, 0o600); err != nil { - t.Fatalf("write marker: %v", err) - } - if !userHasLinger("alice") { - t.Fatal("expected linger when marker file exists") - } - if userHasLinger("bob") { - t.Fatal("expected no linger for a different user") - } +func withoutLinger(t *testing.T) { + t.Helper() + original := lingerDir + lingerDir = t.TempDir() + t.Cleanup(func() { lingerDir = original }) +} + +func TestLingerWarning_ProbeFailureDoesNotWarn(t *testing.T) { + withoutLinger(t) + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", "#!/bin/sh\n"+ + "case \"$1\" in\n"+ + " --version) echo \"podman version 4.0.0\" ;;\n"+ + " *) echo oops >&2; exit 1 ;;\n"+ + "esac\n") + + h := &DockerHelper{DockerCommand: bin} + assert.Empty(t, h.LingerWarning(context.Background()), + "a failed rootless probe must not be treated as a confirmed rootless daemon") +} + +func TestLingerWarning_RootfulDoesNotWarn(t *testing.T) { + withoutLinger(t) + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", "#!/bin/sh\n"+ + "case \"$1\" in\n"+ + " --version) echo \"podman version 4.0.0\" ;;\n"+ + " *) echo false ;;\n"+ + "esac\n") + + h := &DockerHelper{DockerCommand: bin} + assert.Empty(t, h.LingerWarning(context.Background())) +} + +func TestLingerWarning_RootlessWithoutLingerWarns(t *testing.T) { + withoutLinger(t) + tmp := t.TempDir() + bin := writeScript(t, tmp, "podman-fake", "#!/bin/sh\n"+ + "case \"$1\" in\n"+ + " --version) echo \"podman version 4.0.0\" ;;\n"+ + " *) echo true ;;\n"+ + "esac\n") + + h := &DockerHelper{DockerCommand: bin} + assert.NotEmpty(t, h.LingerWarning(context.Background())) +} + +func TestLingerWarning_NonPodmanDoesNotWarn(t *testing.T) { + withoutLinger(t) + tmp := t.TempDir() + bin := writeScript(t, tmp, "docker-fake", "#!/bin/sh\n"+ + "case \"$1\" in\n"+ + " --version) echo \"Docker version 27.0.0\" ;;\n"+ + " *) echo true ;;\n"+ + "esac\n") + + h := &DockerHelper{DockerCommand: bin} + assert.Empty(t, h.LingerWarning(context.Background())) } diff --git a/pkg/docker/procgroup_unix.go b/pkg/docker/procgroup_unix.go new file mode 100644 index 000000000..7644e7171 --- /dev/null +++ b/pkg/docker/procgroup_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package docker + +import ( + "os/exec" + "syscall" +) + +// setProcessGroupAttrs puts cmd in its own process group so +// killProcessGroup can terminate the whole tree it spawns. +func setProcessGroupAttrs(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +// killProcessGroup sends SIGKILL to cmd's entire process group. +func killProcessGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid { + if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil { + if killErr := syscall.Kill(-pgid, syscall.SIGKILL); killErr == nil { + return nil + } + } + } + return cmd.Process.Kill() +} diff --git a/pkg/docker/procgroup_unix_test.go b/pkg/docker/procgroup_unix_test.go new file mode 100644 index 000000000..d66e6373f --- /dev/null +++ b/pkg/docker/procgroup_unix_test.go @@ -0,0 +1,113 @@ +//go:build !windows + +package docker + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRunCmd_CancelKillsProcessGroup(t *testing.T) { + tmp := t.TempDir() + ready := filepath.Join(tmp, "ready") + pidFile := filepath.Join(tmp, "child.pid") + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +sleep 30 & +echo $! > `+pidFile+` +touch `+ready+` +wait +`) + h := &DockerHelper{DockerCommand: bin} + + ctx, cancel := context.WithCancel(context.Background()) + var childPID int + go func() { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(ready); err == nil { + break + } + time.Sleep(time.Millisecond) + } + //nolint:gosec + pidBytes, err := os.ReadFile(pidFile) + if err == nil { + childPID, _ = strconv.Atoi(strings.TrimSpace(string(pidBytes))) + } + cancel() + }() + + streams := Streams{Stdout: io.Discard, Stderr: io.Discard} + start := time.Now() + _ = h.Run(ctx, testExecArgs, streams) + elapsed := time.Since(start) + + require.NotZero(t, childPID, "grandchild PID must have been captured before cancellation") + require.Less(t, elapsed, 5*time.Second, + "Run must return once its process group is killed, not block on an orphaned "+ + "grandchild still holding the output pipes open") + require.Eventually(t, func() bool { + return syscall.Kill(childPID, syscall.Signal(0)) != nil + }, 5*time.Second, 10*time.Millisecond, + "grandchild process %d must be killed along with its process group", childPID) +} + +func TestRunCmd_InteractiveStdinKeepsSharedProcessGroup(t *testing.T) { + tmp := t.TempDir() + pgidFile := filepath.Join(tmp, "pgid") + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +ps -o pgid= -p $$ | tr -d ' ' > `+pgidFile+` +`) + h := &DockerHelper{DockerCommand: bin} + + require.NoError(t, h.RunWithDir(context.Background(), "", testExecArgs, Streams{ + Stdin: os.Stdin, + Stdout: io.Discard, + Stderr: io.Discard, + }), "buildCmd must route this real call site (cmd/workspace/exec.go's interactive "+ + "attach) through RunWithDir, not a hand-built exec.Cmd bypassing it") + + //nolint:gosec // path is test-controlled + got, err := os.ReadFile(pgidFile) + require.NoError(t, err) + gotPgid, err := strconv.Atoi(strings.TrimSpace(string(got))) + require.NoError(t, err) + + ownPgid, err := syscall.Getpgid(os.Getpid()) + require.NoError(t, err) + require.Equal(t, ownPgid, gotPgid, + "a command sharing the controlling terminal via os.Stdin must stay in the "+ + "caller's process group, or job control backgrounds it and its first "+ + "keystroke read earns it a SIGTTIN hang") +} + +func TestKillCmd_NoGroupFallsBackToSingleProcess(t *testing.T) { + bin := writeScript(t, t.TempDir(), "docker-fake", "#!/bin/sh\nexec sleep 30\n") + //nolint:gosec + cmd := exec.Command(bin) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + require.NoError(t, cmd.Start()) + waitErr := make(chan error, 1) + go func() { waitErr <- cmd.Wait() }() + + require.Nil(t, cmd.SysProcAttr, "precondition: no process group was requested") + require.NoError(t, killProcessGroup(cmd)) + + select { + case err := <-waitErr: + require.Error(t, err, "killProcessGroup must still terminate the single process") + case <-time.After(5 * time.Second): + t.Fatal("killProcessGroup did not terminate the single process") + } +} diff --git a/pkg/docker/procgroup_windows.go b/pkg/docker/procgroup_windows.go new file mode 100644 index 000000000..3428f30ea --- /dev/null +++ b/pkg/docker/procgroup_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package docker + +import "os/exec" + +// setProcessGroupAttrs is a no-op on Windows. +func setProcessGroupAttrs(_ *exec.Cmd) {} + +// killProcessGroup is a no-op on Windows, since Windows does not support process groups +// like Unix systems. It kills the single process. +func killProcessGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} diff --git a/pkg/docker/rootless.go b/pkg/docker/rootless.go new file mode 100644 index 000000000..20df82eb7 --- /dev/null +++ b/pkg/docker/rootless.go @@ -0,0 +1,21 @@ +package docker + +import ( + "context" + "os/exec" + "strings" +) + +// probeRootlessPodman reports whether the podman daemon reachable via build is +// running rootless. If the probe succeeds, ok is true and rootless indicates +// whether the daemon is rootless. If the probe fails, ok is false and rootless is false. +func probeRootlessPodman( + ctx context.Context, + build func(context.Context, ...string) *exec.Cmd, +) (rootless bool, ok bool) { + out, err := build(ctx, "info", "--format", "{{.Host.Security.Rootless}}").Output() + if err != nil { + return false, false + } + return strings.TrimSpace(string(out)) == "true", true +} diff --git a/pkg/driver/apple/build.go b/pkg/driver/apple/build.go index 417ae6c83..78fa8123d 100644 --- a/pkg/driver/apple/build.go +++ b/pkg/driver/apple/build.go @@ -65,6 +65,7 @@ func (d *appleDriver) BuildDevContainer( PrebuildHash: req.PrebuildHash, RegistryCache: req.Options.RegistryCache, Tags: req.Options.Tag, + BuiltLocally: true, }, nil } @@ -90,6 +91,7 @@ func (d *appleDriver) resolveExistingImage( PrebuildHash: req.PrebuildHash, RegistryCache: req.Options.RegistryCache, Tags: req.Options.Tag, + BuiltLocally: true, }, true } diff --git a/pkg/driver/custom/custom.go b/pkg/driver/custom/custom.go index 52b1edee9..41cb08e3d 100644 --- a/pkg/driver/custom/custom.go +++ b/pkg/driver/custom/custom.go @@ -41,32 +41,24 @@ func (c *customDriver) FindDevContainer( writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - // run command stdout := &bytes.Buffer{} - err := c.runCommand( - ctx, - workspaceId, - "findDevContainer", - c.workspaceInfo.Agent.Custom.FindDevContainer, - nil, - stdout, - writer, - nil, - ) - if err != nil { - return nil, fmt.Errorf("error finding dev container: %s%w", stdout.String(), err) + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "findDevContainer", + command: c.workspaceInfo.Agent.Custom.FindDevContainer, + stdout: stdout, + stderr: writer, + }); err != nil { + return nil, fmt.Errorf("error finding devcontainer: %s%w", stdout.String(), err) } else if len(stdout.Bytes()) == 0 { return nil, nil } - - // parse stdout - containerDetails := &config.ContainerDetails{} - err = json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), containerDetails) - if err != nil { - return nil, fmt.Errorf("error parsing container details %s: %w", stdout.String(), err) + var containerDetails config.ContainerDetails + if err := json.Unmarshal(stdout.Bytes(), &containerDetails); err != nil { + return nil, fmt.Errorf("error parsing devcontainer details: %s%w", stdout.String(), err) } - return containerDetails, nil + return &containerDetails, nil } // CommandDevContainer runs the given command inside the devcontainer. @@ -74,22 +66,19 @@ func (c *customDriver) CommandDevContainer( ctx context.Context, params *driver.CommandParams, ) error { - // run command - err := c.runCommand( - ctx, - params.WorkspaceID, - "commandDevContainer", - c.workspaceInfo.Agent.Custom.CommandDevContainer, - params.Stdin, - params.Stdout, - params.Stderr, - []string{ + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: params.WorkspaceID, + name: "commandDevContainer", + command: c.workspaceInfo.Agent.Custom.CommandDevContainer, + stdin: params.Stdin, + stdout: params.Stdout, + stderr: params.Stderr, + extraEnv: []string{ "DEVCONTAINER_USER=" + params.User, "DEVCONTAINER_COMMAND=" + params.Command, }, - ) - if err != nil { - return err + }); err != nil { + return fmt.Errorf("error running command in devcontainer: %w", err) } return nil @@ -100,23 +89,17 @@ func (c *customDriver) TargetArchitecture(ctx context.Context, workspaceId strin writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - // run command stdout := &bytes.Buffer{} - err := c.runCommand( - ctx, - workspaceId, - "getTargetArchitecture", - c.workspaceInfo.Agent.Custom.TargetArchitecture, - nil, - stdout, - writer, - nil, - ) - if err != nil { + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "getTargetArchitecture", + command: c.workspaceInfo.Agent.Custom.TargetArchitecture, + stdout: stdout, + stderr: writer, + }); err != nil { return "", fmt.Errorf("error getting target architecture: %s%w", stdout.String(), err) } - // parse stdout targetArchitecture := strings.ToLower(strings.TrimSpace(stdout.String())) if targetArchitecture != "amd64" && targetArchitecture != "arm64" { return "", fmt.Errorf( @@ -133,21 +116,17 @@ func (c *customDriver) DeleteDevContainer(ctx context.Context, workspaceId strin writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - // run command - err := c.runCommand( - ctx, - workspaceId, - "deleteDevContainer", - c.workspaceInfo.Agent.Custom.DeleteDevContainer, - nil, - writer, - writer, - nil, - ) - if err != nil { + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "deleteDevContainer", + command: c.workspaceInfo.Agent.Custom.DeleteDevContainer, + stdin: nil, + stdout: writer, + stderr: writer, + extraEnv: nil, + }); err != nil { return fmt.Errorf("error deleting devcontainer: %w", err) } - return nil } @@ -156,18 +135,13 @@ func (c *customDriver) StartDevContainer(ctx context.Context, workspaceId string writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - // run command - err := c.runCommand( - ctx, - workspaceId, - "startDevContainer", - c.workspaceInfo.Agent.Custom.StartDevContainer, - nil, - writer, - writer, - nil, - ) - if err != nil { + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "startDevContainer", + command: c.workspaceInfo.Agent.Custom.StartDevContainer, + stdout: writer, + stderr: writer, + }); err != nil { return fmt.Errorf("error starting devcontainer: %w", err) } @@ -179,18 +153,15 @@ func (c *customDriver) StopDevContainer(ctx context.Context, workspaceId string) writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - // run command - err := c.runCommand( - ctx, - workspaceId, - "stopDevContainer", - c.workspaceInfo.Agent.Custom.StopDevContainer, - nil, - writer, - writer, - nil, - ) - if err != nil { + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "stopDevContainer", + command: c.workspaceInfo.Agent.Custom.StopDevContainer, + stdin: nil, + stdout: writer, + stderr: writer, + extraEnv: nil, + }); err != nil { return fmt.Errorf("error stopping devcontainer: %w", err) } @@ -219,26 +190,21 @@ func (c *customDriver) RunDevContainer( done <- struct{}{} }() - // run command - err = c.runCommand( - ctx, - workspaceId, - "runDevContainer", - c.workspaceInfo.Agent.Custom.RunDevContainer, - nil, - writer, - writer, - []string{ + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceId, + name: "runDevContainer", + command: c.workspaceInfo.Agent.Custom.RunDevContainer, + stdin: nil, + stdout: writer, + stderr: writer, + extraEnv: []string{ "DEVCONTAINER_RUN_OPTIONS=" + string(out), }, - ) - if err != nil { - // close writer, wait for logging to flush and shut down - _ = writer.Close() + }); err != nil { + _ = writer.Close() // close writer to unblock logging goroutine select { case <-done: - // forcibly shut down after 1 second - case <-time.After(1 * time.Second): + case <-time.After(1 * time.Second): // timeout to avoid hanging if logging goroutine is blocked } return fmt.Errorf("error running devcontainer: %w", err) } @@ -252,18 +218,13 @@ func (c *customDriver) GetDevContainerLogs( stdout io.Writer, stderr io.Writer, ) error { - // run command - err := c.runCommand( - ctx, - workspaceID, - "getDevContainerLogs", - c.workspaceInfo.Agent.Custom.GetDevContainerLogs, - nil, - stdout, - stderr, - nil, - ) - if err != nil { + if err := c.runCommand(ctx, runCommandOptions{ + workspaceId: workspaceID, + name: "getDevContainerLogs", + command: c.workspaceInfo.Agent.Custom.GetDevContainerLogs, + stdout: stdout, + stderr: stderr, + }); err != nil { return fmt.Errorf("error getting devcontainer logs: %w", err) } @@ -276,44 +237,42 @@ func (c *customDriver) CanReprovision() bool { return c.workspaceInfo.Agent.Custom.CanReprovision == pkgconfig.BoolTrue } +type runCommandOptions struct { + workspaceId string + name string + command types.StrArray + stdin io.Reader + stdout io.Writer + stderr io.Writer + extraEnv []string +} + func (c *customDriver) runCommand( ctx context.Context, - workspaceId string, - name string, - command types.StrArray, - stdin io.Reader, - stdout io.Writer, - stderr io.Writer, - extraEnv []string, + runOpts runCommandOptions, ) error { - if len(command) == 0 { + if len(runOpts.command) == 0 { return nil } - // log - log.Debugf("Run %s driver command: %s", name, strings.Join(command, " ")) - - // get environ + log.Debugf("run %s driver command: %s", runOpts.name, strings.Join(runOpts.command, " ")) environ, err := ToEnvironWithBinaries(ctx, c.workspaceInfo) if err != nil { return err } - environ = append(environ, pkgconfig.EnvDevcontainerID+"="+workspaceId) - environ = append(environ, extraEnv...) + environ = append(environ, pkgconfig.EnvDevcontainerID+"="+runOpts.workspaceId) + environ = append(environ, runOpts.extraEnv...) - // set debug level if log.Underlying().Core().Enabled(zapcore.DebugLevel) { environ = append(environ, pkgconfig.EnvDebug+"="+pkgconfig.BoolTrue) } - // run the command - return clientimplementation.RunCommand(clientimplementation.RunCommandOptions{ - Ctx: ctx, - Command: command, + return clientimplementation.RunCommand(ctx, clientimplementation.RunCommandOptions{ + Command: runOpts.command, Environ: environ, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, + Stdin: runOpts.stdin, + Stdout: runOpts.stdout, + Stderr: runOpts.stderr, }) } @@ -330,7 +289,6 @@ func ToEnvironWithBinaries( ) } - // download binaries agentBinaries, err := provider.DownloadBinaries(ctx, workspace.Agent.Binaries, binariesDir) if err != nil { return nil, fmt.Errorf( @@ -340,7 +298,6 @@ func ToEnvironWithBinaries( ) } - // get environ environ := provider.ToEnvironment( workspace.Workspace, workspace.Machine, diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 4750d5522..f68703bd4 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -323,6 +323,7 @@ func (r *imageResolver) tryResolve( PrebuildHash: req.prebuildHash, RegistryCache: req.options.RegistryCache, Tags: req.options.Tag, + BuiltLocally: true, }, true } @@ -437,5 +438,6 @@ func (d *dockerDriver) createBuildInfo( PrebuildHash: req.PrebuildHash, RegistryCache: req.Options.RegistryCache, Tags: req.Options.Tag, + BuiltLocally: !buildOptions.Push, }, nil } diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index f081135ae..ac9e539be 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -3,8 +3,10 @@ package docker import ( "context" "fmt" + "os" "os/exec" "runtime" + "strings" "github.com/devsy-org/devsy/pkg/compose" "github.com/devsy-org/devsy/pkg/devcontainer/config" @@ -15,10 +17,9 @@ import ( ) const ( - // dockerExec is the docker subcommand for running a command in a container. dockerExec = "exec" - // rootUser is the conventional root account name/uid-0 owner. - rootUser = "root" + rootUser = "root" + osLinux = "linux" ) func makeEnvironment(env map[string]string) []string { @@ -108,25 +109,78 @@ var ( // Preflight checks the runtime binary is installed and its daemon reachable, // starting a stopped Podman machine unless auto-start is disabled. func (d *dockerDriver) Preflight(ctx context.Context, opts driver.PreflightOptions) error { - return runPreflight(ctx, opts, dockerProbe{ + probe := dockerProbe{ command: d.Docker.DockerCommand, runtime: d.Docker.GetRuntime().Name(), lookPath: exec.LookPath, ping: d.Docker.Ping, start: d.Docker.StartPodmanMachine, - }) + } + if podmanMachineApplicable { + probe.machineExists = d.Docker.PodmanMachineExists + } else { + probe.rootless = isRootlessDockerHost(dockerHostValue(d.Docker.Environment)) + probe.startSocket = d.Docker.StartRootlessPodmanSocket + } + return runPreflight(ctx, opts, probe) +} + +// dockerHostValue returns the DOCKER_HOST value from env (KEY=VALUE entries, +// as configured on DockerHelper.Environment), falling back to the process +// environment when env does not override it. +func dockerHostValue(env []string) string { + for _, kv := range env { + if rest, ok := strings.CutPrefix(kv, "DOCKER_HOST="); ok { + return rest + } + } + return os.Getenv("DOCKER_HOST") +} + +// isRootlessDockerHost reports whether a DOCKER_HOST value targets a rootless +// Podman socket. A rootless socket lives under /run/user//... (e.g. +// $XDG_RUNTIME_DIR/podman/podman.sock), while the rootful socket is at +// /run/podman/podman.sock. +func isRootlessDockerHost(host string) bool { + if host == "" { + return os.Geteuid() != 0 + } + return strings.Contains(host, "/run/user/") } // dockerProbe bundles the operations runPreflight depends on so the branching // can be exercised with fakes, without abstracting the whole DockerHelper. type dockerProbe struct { - command string - runtime docker.RuntimeName + // command is the runtime binary name (docker or podman) for lookPath and error messages. + command string + + // runtime is the runtime name (docker or podman) for error messages. + runtime docker.RuntimeName + + // lookPath is the operation that checks whether the runtime binary is installed. lookPath func(string) (string, error) - ping func(context.Context) error - start func(context.Context) error + + // ping is the operation that checks whether the runtime daemon is reachable. + ping func(context.Context) error + + // start is only set for the Podman case, where a machine may be stopped and needs + // to be started. A nil value assumes a machine is running. + start func(context.Context) error + + // machineExists is only set for the Podman case, where a machine may or may not exist. + // A nil value assumes a machine exists. + machineExists func(context.Context) (bool, error) + + // startSocket is only set for the rootless Linux case, where a Podman machine + // does not exist and the user socket is often not running until first use. + startSocket func(context.Context) error + + // rootless is true when Podman is running rootless (Linux non-root user). + rootless bool } +var podmanMachineApplicable = runtime.GOOS != osLinux + func runPreflight(ctx context.Context, opts driver.PreflightOptions, p dockerProbe) error { runtimeName := string(p.runtime) @@ -142,18 +196,95 @@ func runPreflight(ctx context.Context, opts driver.PreflightOptions, p dockerPro return nil } - if p.runtime == docker.RuntimePodman && !opts.DisableAutoStart { - log.Infof( - "Podman machine is not running, attempting to start it (this may take a while)...", - ) - if startErr := p.start(ctx); startErr != nil { - log.Warnf("failed to start Podman machine: %v", startErr) - } else if err = p.ping(ctx); err == nil { - return nil + if recoverPodman(ctx, opts, p) { + return nil + } + + if p.runtime == docker.RuntimePodman && + p.machineExists != nil { // podman machine may be stopped + if exists, checkErr := p.machineExists( + ctx, + ); checkErr == nil && + !exists { // machine does not exist + return &driver.PreflightError{ + Provider: runtimeName, + Err: fmt.Errorf("%w: podman machine is not running", err), + } } } - return &driver.PreflightError{Provider: runtimeName, Err: err} + return &driver.PreflightError{ + Provider: runtimeName, + Err: fmt.Errorf("%w: %s daemon is not reachable", err, p.runtime), + } +} + +// recoverPodman attempts to bring a stopped Podman backend back up after a ping +// failure. Runtime is recovered if the ping is successful. +func recoverPodman(ctx context.Context, opts driver.PreflightOptions, p dockerProbe) bool { + if p.runtime != docker.RuntimePodman || opts.DisableAutoStart { + return false + } + + if p.machineExists == nil { // nil assumes a machine exists, unless not applicable + return recoverPodmanWithoutMachineList(ctx, p) + } + + exists, err := p.machineExists(ctx) + if err != nil { + log.Warnf("failed to detect podman mode (machine list failed): %v", err) + return false + } + if exists { + return startPodmanMachine(ctx, p) + } + + if !p.rootless { // rootful Podman without a machine is not recoverable + log.Warnf("podman is not reachable and no machine exists, and this is not rootless") + return false + } + + return startPodmanSocket(ctx, p) +} + +// recoverPodmanWithoutMachineList attempts to bring a stopped Podman backend back up after a +// ping failure, when podman machine is not applicable (Linux) or the machine list command is +// not available (rootless Linux). Runtime is recovered if the ping is successful. +func recoverPodmanWithoutMachineList(ctx context.Context, p dockerProbe) bool { + if !podmanMachineApplicable { + if !p.rootless { + return false + } + return startPodmanSocket(ctx, p) + } + return startPodmanMachine(ctx, p) +} + +func startPodmanMachine(ctx context.Context, p dockerProbe) bool { + if p.start == nil { + return false + } + log.Infof("podman machine is not running, attempting to start the machine.") + if startErr := p.start(ctx); startErr != nil { + log.Warnf("failed to start podman machine: %v", startErr) + return false + } + + return p.ping(ctx) == nil +} + +func startPodmanSocket(ctx context.Context, p dockerProbe) bool { + if p.startSocket == nil { // rootless Linux + return false + } + + log.Infof("podman is not reachable, attempting to start the rootless user socket.") + if startErr := p.startSocket(ctx); startErr != nil { + log.Warnf("failed to start rootless podman socket: %v", startErr) + return false + } + + return p.ping(ctx) == nil } func (d *dockerDriver) TargetArchitecture(ctx context.Context, workspaceId string) (string, error) { diff --git a/pkg/driver/docker/docker_test.go b/pkg/driver/docker/docker_test.go index fc4ef28fd..5683273c6 100644 --- a/pkg/driver/docker/docker_test.go +++ b/pkg/driver/docker/docker_test.go @@ -12,7 +12,6 @@ const ( testBindMount = "type=bind,src=/a,dst=/b" testUpdateUIDDefaultOff = "off" testUpdateUIDDefaultOn = "on" - testOSLinux = "linux" testRemoteUser = "vscode" testRunArg = "run" testDockerCmd = "docker" diff --git a/pkg/driver/docker/lifecycle.go b/pkg/driver/docker/lifecycle.go index deafa4985..550182847 100644 --- a/pkg/driver/docker/lifecycle.go +++ b/pkg/driver/docker/lifecycle.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "strings" + "time" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/log" + "k8s.io/apimachinery/pkg/util/wait" ) const containerRestartAttempts = 3 @@ -280,18 +282,29 @@ func (d *dockerDriver) RunImageDevContainer( return d.UpdateContainerUserUID(ctx, params.WorkspaceID, params.ParsedConfig, writer) } +var ( + imageInspectPollInterval = 500 * time.Millisecond + imageInspectPollTimeout = 10 * time.Second +) + func (d *dockerDriver) EnsureImage( ctx context.Context, options *driver.RunOptions, ) error { log.Infof("inspecting image: image=%s", options.Image) - _, err := d.Docker.InspectImage(ctx, options.Image, false) + err := d.inspectImage(ctx, options) if err == nil { return nil } if !errors.Is(err, docker.ErrImageNotFound) { return fmt.Errorf("inspect image %s: %w", options.Image, err) } + if options.ImageBuilt { + // A devsy-built, locally-tagged image can never be resolved by a + // registry pull: no registry has it, and a same-named external tag + // would run an entirely different image than the one that was built. + return fmt.Errorf("inspect image %s: %w", options.Image, err) + } log.Infof("image not found, pulling image: image=%s", options.Image) writer := log.Writer(log.LevelDebug) @@ -305,6 +318,42 @@ func (d *dockerDriver) EnsureImage( }) } +// inspectImage inspects the given image; if options.ImageBuilt is true, it retries while the only failure is +// ErrImageNotFound, otherwise it returns immediately on any error. +func (d *dockerDriver) inspectImage(ctx context.Context, options *driver.RunOptions) error { + if !options.ImageBuilt { + _, err := d.Docker.InspectImage(ctx, options.Image, false) + return err + } + return d.waitForLocalImage(ctx, options.Image) +} + +// waitForLocalImage inspects image, retrying while the only failure is +// ErrImageNotFound. Any other inspect error (e.g. daemon unreachable) stops +// the retry immediately. Returns nil once found, or the last ErrImageNotFound +// once imageInspectPollTimeout elapses without the image appearing. +func (d *dockerDriver) waitForLocalImage(ctx context.Context, image string) error { + var lastErr error + pollErr := wait.PollUntilContextTimeout( + ctx, imageInspectPollInterval, imageInspectPollTimeout, true, + func(ctx context.Context) (bool, error) { + _, err := d.Docker.InspectImage(ctx, image, false) + if err == nil { + return true, nil + } + if errors.Is(err, docker.ErrImageNotFound) { + lastErr = err + return false, nil + } + return false, err + }, + ) + if pollErr != nil && lastErr != nil { + return lastErr + } + return pollErr +} + func (d *dockerDriver) startContainer( ctx context.Context, dir string, diff --git a/pkg/driver/docker/lifecycle_test.go b/pkg/driver/docker/lifecycle_test.go index 53638a9ef..55169e206 100644 --- a/pkg/driver/docker/lifecycle_test.go +++ b/pkg/driver/docker/lifecycle_test.go @@ -15,11 +15,8 @@ import ( "github.com/stretchr/testify/require" ) -// fakeDocker writes an executable shell script that emulates the docker CLI -// subcommands used by ensureContainerRunning. `inspect` reports "exited" until -// the number of prior `start` invocations reaches startsUntilRunning, after -// which it reports "running". A startsUntilRunning of -1 keeps it exited -// forever (a container that never boots). +const testImageRef = "x:latest" + func fakeDocker(t *testing.T, startsUntilRunning int) string { t.Helper() dir := t.TempDir() @@ -80,15 +77,115 @@ esac return path } -func TestEnsureImage_NotFoundPulls(t *testing.T) { +func withShortImageInspectPoll(t *testing.T) { + t.Helper() + origInterval, origTimeout := imageInspectPollInterval, imageInspectPollTimeout + imageInspectPollInterval = time.Millisecond + imageInspectPollTimeout = 20 * time.Millisecond + t.Cleanup(func() { + imageInspectPollInterval, imageInspectPollTimeout = origInterval, origTimeout + }) +} + +func TestEnsureImage_BuiltImageNeverFoundDoesNotPull(t *testing.T) { + withShortImageInspectPoll(t) marker := filepath.Join(t.TempDir(), "pulled") bin := fakeEnsureImageDocker(t, "Error response from daemon: No such image: x", marker) d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin}} - err := d.EnsureImage(context.Background(), &driver.RunOptions{Image: "x:latest"}) + err := d.EnsureImage( + context.Background(), + &driver.RunOptions{Image: testImageRef, ImageBuilt: true}, + ) + + require.Error( + t, + err, + "a devsy-built image that never appears locally must not fall back to pull", + ) + assert.ErrorIs(t, err, docker.ErrImageNotFound) + assert.NoFileExists( + t, + marker, + "a locally-built image can never be resolved by a pull; pulling risks running an unrelated image under the same tag", + ) +} + +func TestEnsureImage_TransientMissRecoversWithoutPulling(t *testing.T) { + withShortImageInspectPoll(t) + imageInspectPollTimeout = time.Second // must outlast a couple of retries + + tmp := t.TempDir() + marker := filepath.Join(tmp, "pulled") + readyAfter := filepath.Join(tmp, "attempts") + bin := filepath.Join(tmp, "docker-fake") + script := `#!/bin/sh +case "$1" in + inspect) + n=$(cat "` + readyAfter + `" 2>/dev/null || echo 0) + n=$((n + 1)) + echo "$n" > "` + readyAfter + `" + if [ "$n" -lt 3 ]; then + echo 'Error response from daemon: No such image: x' >&2 + exit 1 + fi + echo '[{"Id":"sha256:fake"}]' + ;; + pull) + echo pulled > "` + marker + `" + ;; +esac +` + //nolint:gosec // test helper script needs exec bit + require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) + d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin}} + + err := d.EnsureImage( + context.Background(), + &driver.RunOptions{Image: testImageRef, ImageBuilt: true}, + ) + + require.NoError(t, err) + assert.NoFileExists(t, marker, + "a transient miss that resolves on retry must not fall back to pull") +} + +func TestEnsureImage_ExternalImageSkipsRetryAndPullsImmediately(t *testing.T) { + withShortImageInspectPoll(t) + imageInspectPollTimeout = time.Second // would need to elapse if (wrongly) retried + + tmp := t.TempDir() + marker := filepath.Join(tmp, "pulled") + attempts := filepath.Join(tmp, "attempts") + bin := filepath.Join(tmp, "docker-fake") + script := `#!/bin/sh +case "$1" in + inspect) + n=$(cat "` + attempts + `" 2>/dev/null || echo 0) + echo $((n + 1)) > "` + attempts + `" + echo 'Error response from daemon: No such image: x' >&2 + exit 1 + ;; + pull) + echo pulled > "` + marker + `" + ;; +esac +` + //nolint:gosec // test helper script needs exec bit + require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) + d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin}} + + start := time.Now() + err := d.EnsureImage(context.Background(), &driver.RunOptions{Image: testImageRef}) + elapsed := time.Since(start) require.NoError(t, err) - assert.FileExists(t, marker, "a not-found image should trigger a pull") + assert.FileExists(t, marker, "a not-found external image should trigger a pull") + attemptsRaw, readErr := os.ReadFile(attempts) //nolint:gosec // test-controlled path + require.NoError(t, readErr) + assert.Equal(t, "1\n", string(attemptsRaw), "must inspect exactly once, no retries") + assert.Less(t, elapsed, imageInspectPollTimeout, + "must not wait out the retry window for an image it never built") } func TestEnsureImage_RealErrorDoesNotPull(t *testing.T) { @@ -96,7 +193,7 @@ func TestEnsureImage_RealErrorDoesNotPull(t *testing.T) { bin := fakeEnsureImageDocker(t, "Cannot connect to the Docker daemon", marker) d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin}} - err := d.EnsureImage(context.Background(), &driver.RunOptions{Image: "x:latest"}) + err := d.EnsureImage(context.Background(), &driver.RunOptions{Image: testImageRef}) require.Error(t, err) assert.NoFileExists(t, marker, "a genuine daemon failure must not trigger a pull") diff --git a/pkg/driver/docker/preflight_test.go b/pkg/driver/docker/preflight_test.go index e73a3db1c..257476a01 100644 --- a/pkg/driver/docker/preflight_test.go +++ b/pkg/driver/docker/preflight_test.go @@ -3,10 +3,14 @@ package docker import ( "context" "errors" + "os" + "path/filepath" + "strings" "testing" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" + "github.com/stretchr/testify/require" ) func TestDockerPreflightBinaryMissing(t *testing.T) { @@ -39,6 +43,15 @@ func TestDockerPreflightBinaryMissing(t *testing.T) { // installed makes lookPath report the binary as present. func installed(string) (string, error) { return "/usr/bin/probe", nil } +// withPodmanMachineApplicable overrides podmanMachineApplicable for the +// duration of the test, restoring the original value on cleanup. +func withPodmanMachineApplicable(t *testing.T, applicable bool) { + t.Helper() + original := podmanMachineApplicable + podmanMachineApplicable = applicable + t.Cleanup(func() { podmanMachineApplicable = original }) +} + func alwaysErr(error) func(context.Context) error { return func(context.Context) error { return errors.New("boom") } } @@ -68,6 +81,8 @@ func TestRunPreflightDaemonDownNoAutoStart(t *testing.T) { } func TestRunPreflightPodmanAutoStartRecovers(t *testing.T) { + withPodmanMachineApplicable(t, true) + pinged := 0 p := dockerProbe{ runtime: docker.RuntimePodman, @@ -90,6 +105,8 @@ func TestRunPreflightPodmanAutoStartRecovers(t *testing.T) { } func TestRunPreflightPodmanAutoStartFails(t *testing.T) { + withPodmanMachineApplicable(t, true) + down := errors.New("Cannot connect to Podman") p := dockerProbe{ runtime: docker.RuntimePodman, @@ -103,7 +120,145 @@ func TestRunPreflightPodmanAutoStartFails(t *testing.T) { } } +func TestRunPreflightPodmanSkipsStartWhenNoMachine(t *testing.T) { + down := errors.New("Cannot connect to Podman") + started := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return down }, + machineExists: func(context.Context) (bool, error) { return false, nil }, + start: func(context.Context) error { + started = true + return nil + }, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if started { + t.Error("machine start must not run when no machine exists") + } + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } +} + +func TestRunPreflightPodmanRootfulNoMachineNoRecovery(t *testing.T) { + down := errors.New("Cannot connect to Podman") + socketStarted := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return down }, + machineExists: func(context.Context) (bool, error) { return false, nil }, + startSocket: func(context.Context) error { + socketStarted = true + return nil + }, + rootless: false, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if socketStarted { + t.Error("rootless socket start must not run for rootful Podman with no machine") + } + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } +} + +func TestRunPreflightPodmanSocketAutoStartRecovers(t *testing.T) { + pinged := 0 + socketStarted := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + machineExists: func(context.Context) (bool, error) { return false, nil }, + ping: func(context.Context) error { + pinged++ + if pinged == 1 { + return errors.New("Cannot connect to Podman") + } + return nil + }, + startSocket: func(context.Context) error { + socketStarted = true + return nil + }, + rootless: true, + } + if err := runPreflight(context.Background(), driver.PreflightOptions{}, p); err != nil { + t.Fatalf("socket auto-start recovery = %v, want nil", err) + } + if !socketStarted { + t.Error("expected rootless socket start to be attempted") + } + if pinged != 2 { + t.Errorf("expected a re-ping after socket start, ping count = %d", pinged) + } +} + +func TestRunPreflightPodmanSocketAutoStartFails(t *testing.T) { + down := errors.New("Cannot connect to Podman") + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + machineExists: func(context.Context) (bool, error) { return false, nil }, + ping: func(context.Context) error { return down }, + startSocket: alwaysErr(errors.New("systemctl: not found")), + rootless: true, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } + if !strings.Contains(err.Error(), "podman machine is not running") { + t.Fatalf("want rootless socket hint in error, got %v", err) + } +} + +func TestRunPreflightPodmanNoHintIfMachineExists(t *testing.T) { + down := errors.New("Cannot connect to Podman") + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + machineExists: func(context.Context) (bool, error) { return true, nil }, + ping: func(context.Context) error { return down }, + start: alwaysErr(errors.New("machine start failed")), + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if strings.Contains(err.Error(), "podman machine is not running") { + t.Fatalf("want no rootless socket hint when a machine exists, got %v", err) + } + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } +} + +func TestRunPreflightPodmanMachineDetectionFails(t *testing.T) { + down := errors.New("Cannot connect to Podman") + listErr := errors.New("machine list: permission denied") + started := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return down }, + machineExists: func(context.Context) (bool, error) { return false, listErr }, + start: func(context.Context) error { + started = true + return nil + }, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if started { + t.Error("machine start must not run when detection fails") + } + if !errors.Is(err, down) { + t.Fatalf("want original daemon error surfaced, got %v", err) + } +} + func TestRunPreflightPodmanOptOutSkipsStart(t *testing.T) { + withPodmanMachineApplicable(t, true) + started := false p := dockerProbe{ runtime: docker.RuntimePodman, @@ -122,3 +277,78 @@ func TestRunPreflightPodmanOptOutSkipsStart(t *testing.T) { t.Error("auto-start must not run when DisableAutoStart is set") } } + +func TestRunPreflightPodmanSkipsAutoStartWhenMachineNotApplicable(t *testing.T) { + withPodmanMachineApplicable(t, false) + + down := errors.New("Cannot connect to Podman") + started := false + p := dockerProbe{ + runtime: docker.RuntimePodman, + lookPath: installed, + ping: func(context.Context) error { return down }, + start: func(context.Context) error { + started = true + return nil + }, + } + err := runPreflight(context.Background(), driver.PreflightOptions{}, p) + if !errors.Is(err, down) { + t.Fatalf("want original ping error surfaced, got %v", err) + } + if started { + t.Error("auto-start must not run when podman machine is not applicable on this OS") + } +} + +func TestDockerPreflight_PodmanLinuxNeverInvokesMachineSubcommand(t *testing.T) { + withPodmanMachineApplicable(t, false) + + dir := t.TempDir() + sentinel := filepath.Join(dir, "machine-invoked") + bin := filepath.Join(dir, "podman-fake") + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " machine) touch " + sentinel + "; exit 1 ;;\n" + + " info) echo 'Cannot connect to Podman' >&2; exit 1 ;;\n" + + " *) echo \"unexpected args: $*\" >&2; exit 1 ;;\n" + + "esac\n" + //nolint:gosec // test helper script needs exec bit + require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) + + rt, err := docker.RuntimeFromName(string(docker.RuntimePodman)) + require.NoError(t, err) + + d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin, Runtime: rt}} + preflightErr := d.Preflight(context.Background(), driver.PreflightOptions{}) + require.Error(t, preflightErr) + _, statErr := os.Stat(sentinel) + require.True(t, os.IsNotExist(statErr), + "Preflight must not invoke `podman machine ...` on native Linux, where it has no meaning") +} + +func TestIsRootlessDockerHost(t *testing.T) { + cases := map[string]bool{ + // explicit rootless socket path + "unix:///run/user/1001/podman/podman.sock": true, + "unix:///run/user/1000/podman/podman.sock": true, + // rootful socket path + "unix:///run/podman/podman.sock": false, + // docker / tcp / other hosts are not rootless podman + "unix:///var/run/docker.sock": false, + "tcp://1.2.3.4:2376": false, + "npipe:////./pipe/podman-machine-default": false, + } + for host, want := range cases { + if got := isRootlessDockerHost(host); got != want { + t.Errorf("isRootlessDockerHost(%q) = %v, want %v", host, got, want) + } + } +} + +func TestIsRootlessDockerHostEmptyFallsBackToUID(t *testing.T) { + want := os.Geteuid() != 0 + if got := isRootlessDockerHost(""); got != want { + t.Errorf("isRootlessDockerHost(\"\") = %v, want %v (uid=%d)", got, want, os.Geteuid()) + } +} diff --git a/pkg/driver/docker/runargs.go b/pkg/driver/docker/runargs.go index 395a5839f..bcab94574 100644 --- a/pkg/driver/docker/runargs.go +++ b/pkg/driver/docker/runargs.go @@ -218,7 +218,7 @@ func shouldBustBindCache(helper *docker.DockerHelper) bool { if helper.GetRuntime().Name() != docker.RuntimeDocker { return false } - if runtime.GOOS == "linux" { + if runtime.GOOS == osLinux { return false } return dockerMajorAtLeast(helper.ClientVersion(context.Background()), minBindCreateSrcMajor) diff --git a/pkg/driver/docker/useruid.go b/pkg/driver/docker/useruid.go index 299c82c9d..3ef504269 100644 --- a/pkg/driver/docker/useruid.go +++ b/pkg/driver/docker/useruid.go @@ -154,7 +154,7 @@ func shouldSkipUpdate(localUser *user.User, info *user.User) bool { } func (d *dockerDriver) shouldUpdateUserUID(parsedConfig *config.DevContainerConfig) bool { - isLinux := runtime.GOOS == "linux" + isLinux := runtime.GOOS == osLinux hasUser := parsedConfig.ContainerUser != "" || parsedConfig.RemoteUser != "" var shouldUpdate bool diff --git a/pkg/driver/docker/useruid_test.go b/pkg/driver/docker/useruid_test.go index 4db56d73f..f83e7d696 100644 --- a/pkg/driver/docker/useruid_test.go +++ b/pkg/driver/docker/useruid_test.go @@ -69,7 +69,7 @@ func (s *DockerDriverTestSuite) TestShouldUpdateUserUID_DefaultTrue_WhenConfigNi } s.driver.UpdateRemoteUserUIDDefault = "" result := s.driver.shouldUpdateUserUID(cfg) - if runtime.GOOS == testOSLinux { + if runtime.GOOS == osLinux { s.True(result) } } @@ -93,7 +93,7 @@ func (s *DockerDriverTestSuite) TestShouldUpdateUserUID_CLIDefaultOn_WhenConfigN } s.driver.UpdateRemoteUserUIDDefault = testUpdateUIDDefaultOn result := s.driver.shouldUpdateUserUID(cfg) - if runtime.GOOS == testOSLinux { + if runtime.GOOS == osLinux { s.True(result) } } @@ -108,7 +108,7 @@ func (s *DockerDriverTestSuite) TestShouldUpdateUserUID_ConfigTakesPrecedence_Tr } s.driver.UpdateRemoteUserUIDDefault = testUpdateUIDDefaultOff result := s.driver.shouldUpdateUserUID(cfg) - if runtime.GOOS == testOSLinux { + if runtime.GOOS == osLinux { s.True(result, "devcontainer.json true should override CLI default off") } } diff --git a/pkg/driver/types.go b/pkg/driver/types.go index cf4d509cc..e6218ae3c 100644 --- a/pkg/driver/types.go +++ b/pkg/driver/types.go @@ -161,4 +161,8 @@ type RunOptions struct { // Platform is the target platform (os/arch) to run the container under, // e.g. "linux/amd64". Empty means use the host's native platform. Platform string `json:"platform,omitempty"` + + // ImageBuilt is true if the image was built locally and is not expected to be + // pullable from a registry. False for a devcontainer.json "image:" reference. + ImageBuilt bool `json:"imageBuilt,omitempty"` } diff --git a/pkg/ide/codeserver/codeserver.go b/pkg/ide/codeserver/codeserver.go index bcc8139fb..8da6be24a 100644 --- a/pkg/ide/codeserver/codeserver.go +++ b/pkg/ide/codeserver/codeserver.go @@ -50,7 +50,7 @@ var Options = ide.Options{ VersionOption: { Name: VersionOption, Description: "The version for the code-server binary (without the leading v)", - Default: "4.129.0", + Default: "4.132.0", }, DownloadArm64Option: { Name: DownloadArm64Option, diff --git a/pkg/ide/vscodeweb/vscodeweb.go b/pkg/ide/vscodeweb/vscodeweb.go index e3bde6fbd..4bd210b60 100644 --- a/pkg/ide/vscodeweb/vscodeweb.go +++ b/pkg/ide/vscodeweb/vscodeweb.go @@ -49,7 +49,7 @@ var Options = ide.Options{ VersionOption: { Name: VersionOption, Description: "The VS Code version for the serve-web CLI (e.g., 1.129.1 or 'latest')", - Default: "1.129.1", + Default: "1.133.0", // check releases at https://github.com/microsoft/vscode/releases }, DownloadArm64Option: { Name: DownloadArm64Option, diff --git a/pkg/inject/inject.go b/pkg/inject/inject.go index 5af52c0a0..ef65c3481 100644 --- a/pkg/inject/inject.go +++ b/pkg/inject/inject.go @@ -14,20 +14,19 @@ import ( "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/util/iojoin" +) + +const ( + statusDone = "done" + defaultInjectTimeout = 20 * time.Second ) -// Deprecated: Script embeds inject.sh which is deprecated. Platform-native AgentDelivery -// implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. -// //go:embed inject.sh var Script string -// Deprecated: ExecFunc is part of the legacy shell injection path. Platform-native AgentDelivery -// implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. type ExecFunc func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error -// Deprecated: LocalFile is part of the legacy shell injection path. Platform-native AgentDelivery -// implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. type LocalFile func(arm bool) (io.ReadCloser, error) type injectResult struct { @@ -35,10 +34,7 @@ type injectResult struct { err error } -// Deprecated: InjectOptions is part of the legacy shell injection path. Platform-native AgentDelivery -// implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. type InjectOptions struct { - Ctx context.Context Exec ExecFunc LocalFile LocalFile ScriptParams *Params @@ -48,21 +44,24 @@ type InjectOptions struct { Timeout time.Duration } -// Deprecated: Inject is part of the legacy shell injection path. Platform-native AgentDelivery -// implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. -// -//nolint:funlen // legacy shell injection path, frozen pending caller migration to AgentDelivery -func Inject(opts InjectOptions) (bool, error) { - if err := validateInjectOptions(opts); err != nil { - return false, err +// Inject runs the inject.sh handshake and bridges the caller's stdio to the exec's stdio. +// It returns true if the handshake completed and the exec was started, false if the handshake +// failed and the exec was not started. +func Inject(ctx context.Context, opts InjectOptions) (bool, error) { + if opts.Exec == nil { + return false, fmt.Errorf("inject: Exec is required") + } + if opts.ScriptParams == nil { + return false, fmt.Errorf("inject: ScriptParams is required") + } + if opts.Timeout <= 0 { + opts.Timeout = defaultInjectTimeout } - start := time.Now() - log.Infof("injection: start") - defer func() { log.Infof("injection: complete elapsed=%s", time.Since(start)) }() - - logPreferAgentDownload(opts.ScriptParams) + log.Debugf("start inject") + defer func() { log.Debugf("complete elapsed=%s", time.Since(start)) }() + logPreferredAgentDownloadURL(opts.ScriptParams) scriptRawCode, err := GenerateScript(Script, opts.ScriptParams) if err != nil { return true, err @@ -70,396 +69,360 @@ func Inject(opts InjectOptions) (bool, error) { log.Debug("execute inject script") defer log.Debug("done injecting") - - // start script - stdinReader, stdinWriter, err := os.Pipe() + sess, err := newSession(opts, scriptRawCode, start) if err != nil { return true, err } - defer func() { _ = stdinWriter.Close() }() - - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return true, err + defer sess.Close() + select { + case <-ctx.Done(): + return false, ctx.Err() + default: } - // delayed stderr - delayedStderr := newDelayedWriter(opts.Stderr) + execCtx, execCancel := context.WithCancel(ctx) + defer execCancel() - // check if context is done - select { - case <-opts.Ctx.Done(): - return true, context.Canceled - default: + return sess.run(execCtx, opts.Timeout) +} + +type pipeEnds struct { + r *os.File + w *os.File +} + +func (p pipeEnds) Close() { + _ = p.r.Close() + _ = p.w.Close() +} + +type session struct { + opts InjectOptions + script string + delayedStderr *delayedWriter + start time.Time + stdin pipeEnds + stdout pipeEnds +} + +func newSession(opts InjectOptions, script string, start time.Time) (*session, error) { + stdinR, stdinW, err := os.Pipe() + if err != nil { + return nil, err + } + stdin := pipeEnds{r: stdinR, w: stdinW} + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + stdin.Close() // don't leak the first pair on partial open + return nil, err } + stdout := pipeEnds{r: stdoutR, w: stdoutW} - // create cancel context - cancelCtx, cancel := context.WithCancel(opts.Ctx) - defer cancel() + return &session{ + opts: opts, + script: script, + delayedStderr: newDelayedWriter(opts.Stderr), + start: start, + stdin: stdin, + stdout: stdout, + }, nil +} + +func (s *session) Close() { + s.stdin.Close() + s.stdout.Close() +} - // start execution of inject.sh +// run executes the inject.sh handshake and bridges the caller's stdio to the exec's stdio. +func (s *session) run(ctx context.Context, handshakeTimeout time.Duration) (bool, error) { execErrChan := make(chan error, 1) - go runInjectScript(cancelCtx, runInjectScriptParams{ - opts: opts, - script: scriptRawCode, - stdin: stdinReader, - stdout: stdoutWriter, - delayedStderr: delayedStderr, - execErrChan: execErrChan, - }) + go s.runExec(ctx, execErrChan) - // inject file injectChan := make(chan injectResult, 1) go func() { - defer func() { _ = stdinWriter.Close() }() defer log.Debug("done inject") - - wasExecuted, err := inject( - opts.LocalFile, - stdinWriter, - opts.Stdin, - stdoutReader, - opts.Stdout, - delayedStderr, - opts.Timeout, - ) + wasExecuted, err := s.handshakeAndBridge(ctx, handshakeTimeout) injectChan <- injectResult{ wasExecuted: wasExecuted, - err: command.WrapCommandError(delayedStderr.Buffer(), err), + err: command.WrapCommandError(s.delayedStderr.Buffer(), err), } }() - return awaitInjectResult(awaitInjectResultParams{ - opts: opts, - start: start, - execErrChan: execErrChan, - injectChan: injectChan, - delayedStderr: delayedStderr, - }) -} + result, execErr := resolveRunResult(execErrChan, injectChan) + log.Debugf("payload delivered elapsed=%s", time.Since(s.start)) -func validateInjectOptions(opts InjectOptions) error { - if opts.Ctx == nil { - return fmt.Errorf("context is required") - } - if opts.Exec == nil { - return fmt.Errorf("exec function is required") + if result.err != nil { + return result.wasExecuted, result.err } - if opts.ScriptParams == nil { - return fmt.Errorf("script params is required") + if execErr != nil && result.wasExecuted { + return result.wasExecuted, execErr } - return nil + return result.wasExecuted, nil } -func logPreferAgentDownload(params *Params) { - if !params.PreferAgentDownload { - return - } - url := "" - if params.DownloadURLs != nil { - url = params.DownloadURLs.Base +// resolveRunResult waits for execErrChan and/or injectChan and decides which +// to trust. execErrChan's send in runExec always happens-before s.stdout.w's +// deferred Close(), which is what unblocks handshakeAndBridge's pipeStreams +// and lets injectChan fire -- so whenever injectChan's wasExecuted is true, +// execErrChan is already populated by the time injectChan is read here. +// select doesn't prefer whichever channel became ready earlier once both are +// ready, so without the inner non-blocking receive a real exec error could +// be silently dropped in favor of a false "succeeded" result. +func resolveRunResult( + execErrChan <-chan error, + injectChan <-chan injectResult, +) (injectResult, error) { + var result injectResult + var execErr error + select { + case execErr = <-execErrChan: + result = <-injectChan + case result = <-injectChan: + select { + case execErr = <-execErrChan: + default: + } } - log.Debugf("prefer downloading agent from URL: url=%s", url) -} - -type runInjectScriptParams struct { - opts InjectOptions - script string - stdin io.Reader - stdout io.WriteCloser - delayedStderr *delayedWriter - execErrChan chan<- error + return result, execErr } -func runInjectScript(ctx context.Context, p runInjectScriptParams) { - defer func() { _ = p.stdout.Close() }() +func (s *session) runExec(ctx context.Context, execErrChan chan<- error) { + defer func() { _ = s.stdout.w.Close() }() defer log.Debug("done exec") - err := p.opts.Exec(ctx, p.script, p.stdin, p.stdout, p.delayedStderr) + err := s.opts.Exec(ctx, s.script, s.stdin.r, s.stdout.w, s.delayedStderr) if err != nil && !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "signal: ") { - p.execErrChan <- command.WrapCommandError(p.delayedStderr.Buffer(), err) + execErrChan <- command.WrapCommandError(s.delayedStderr.Buffer(), err) } else { - p.execErrChan <- nil - } -} - -type awaitInjectResultParams struct { - opts InjectOptions - start time.Time - execErrChan chan error - injectChan chan injectResult - delayedStderr *delayedWriter -} - -func awaitInjectResult(p awaitInjectResultParams) (bool, error) { - // wait here - var result injectResult - var err error - select { - case err = <-p.execErrChan: - result = <-p.injectChan - case result = <-p.injectChan: - // we don't wait for the command termination here and will just retry on error - } - log.Debugf("injection: payload delivered elapsed=%s", time.Since(p.start)) - - if result.err != nil { - return result.wasExecuted, result.err - } - - // Exec EOF during binary injection is expected: the container entrypoint - // may exec the daemon (killing the docker exec process) before inject.sh - // prints its final response. Only surface exec errors when a command was - // actually executed through the script. - if err != nil && result.wasExecuted { - return result.wasExecuted, err - } - - if result.wasExecuted || p.opts.ScriptParams.Command == "" { - return result.wasExecuted, nil + execErrChan <- nil } - - log.Debugf("Rerun command as binary was injected") - p.delayedStderr.Start() - return true, p.opts.Exec( - p.opts.Ctx, - p.opts.ScriptParams.Command, - p.opts.Stdin, - p.opts.Stdout, - p.delayedStderr, - ) } -func inject( - localFile LocalFile, - stdin io.WriteCloser, - stdinOut io.Reader, - stdout io.ReadCloser, - stdoutOut io.Writer, - delayedStderr *delayedWriter, - timeout time.Duration, +// handshakeAndBridge performs the inject.sh handshake and then bridges the +// caller's stdio to the exec's stdio. +func (s *session) handshakeAndBridge( + ctx context.Context, + handshakeTimeout time.Duration, ) (bool, error) { - injectStart := time.Now() + handshakeCtx, cancel := context.WithTimeout(ctx, handshakeTimeout) + defer cancel() - // wait until we read start - var line string - errChan := make(chan error) - go func() { - var err error - line, err = readLine(stdout) - errChan <- err - }() + injectStart := time.Now() - // wait for line to be read - err := waitForMessage(errChan, timeout) + // inject.sh announces readiness with "ping". + line, err := readLine(handshakeCtx, s.stdout.r) if err != nil { - return false, err + return false, fmt.Errorf("read ping: %w", err) } - - err = performMutualHandshake(line, stdin) - if err != nil { - return false, err + if strings.TrimSpace(line) != "ping" { + return false, fmt.Errorf("unexpected start line %q", line) + } + if _, err := s.stdin.w.Write([]byte("pong\n")); err != nil { + return false, fmt.Errorf("write pong: %w", err) } - log.Debugf("injection: handshake complete elapsed=%s", time.Since(injectStart)) + log.Debugf("handshake complete elapsed=%s", time.Since(injectStart)) - // wait until we read something - line, err = readLine(stdout) + line, err = readLine(handshakeCtx, s.stdout.r) if err != nil { - return false, err + return false, fmt.Errorf("read inject signal: %w", err) } log.Debugf("received line after pong: line=%s", line) - lineStr := strings.TrimSpace(line) - if isInjectingOfBinaryNeeded(lineStr) { - log.Debugf("inject binary") - defer log.Debugf("done injecting binary") - - if err := injectBinaryPayload(localFile, lineStr, stdin, stdout); err != nil { + signal := strings.TrimSpace(line) + if isBinaryRequest(signal) { + log.Debug("inject binary") + if err := s.sendBinary(handshakeCtx, signal); err != nil { return false, err } - // start exec with command - return false, nil - } - if lineStr != "done" { - return false, fmt.Errorf("unexpected message during inject %s", lineStr) + } else if signal != statusDone { + return false, fmt.Errorf("unexpected message during inject %q", signal) } - return pipeStreams(pipeStreamsParams{ - stdin: stdin, - stdinOut: stdinOut, - stdoutOut: stdoutOut, - stdout: stdout, - delayedStderr: delayedStderr, + return pipeStreams(streamPipeConfig{ + stdin: s.stdin.w, + hostStdin: s.opts.Stdin, + hostStdout: s.opts.Stdout, + stdout: s.stdout.r, + delayedStderr: s.delayedStderr, }) } -func injectBinaryPayload( - localFile LocalFile, - lineStr string, - stdin io.WriteCloser, - stdout io.ReadCloser, -) error { - fileReader, err := getFileReader(localFile, lineStr) - if err != nil { - return err +func logPreferredAgentDownloadURL(params *Params) { + if !params.PreferAgentDownload { + return } - defer func() { _ = fileReader.Close() }() - - if err := injectBinary(fileReader, stdin, stdout); err != nil { - return err + url := "" + if params.DownloadURLs != nil { + url = params.DownloadURLs.Base } - _ = stdout.Close() - return nil + log.Debugf("prefer downloading agent from URL: url=%s", url) } -type pipeStreamsParams struct { - stdin io.WriteCloser - stdinOut io.Reader - stdoutOut io.Writer - stdout io.ReadCloser - delayedStderr *delayedWriter +func isBinaryRequest(signal string) bool { + return strings.HasPrefix(signal, "ARM-") } -func pipeStreams(p pipeStreamsParams) (bool, error) { - stdoutOut := p.stdoutOut - stdinOut := p.stdinOut - if stdoutOut == nil { - stdoutOut = io.Discard - } - if stdinOut == nil { - stdinOut = bytes.NewReader(nil) - } - - // now pipe reader into stdout - p.delayedStderr.Start() - return true, pipe( - p.stdin, stdinOut, - stdoutOut, p.stdout, - ) -} - -func isInjectingOfBinaryNeeded(lineStr string) bool { - return strings.HasPrefix(lineStr, "ARM-") +func binaryReaderFor(localFile LocalFile, signal string) (io.ReadCloser, error) { + isArm := strings.TrimPrefix(signal, "ARM-") == config.BoolTrue + return localFile(isArm) } -func getFileReader(localFile LocalFile, lineStr string) (io.ReadCloser, error) { - isArm := strings.TrimPrefix(lineStr, "ARM-") == config.BoolTrue - return localFile(isArm) +// sizer is implemented by *os.File; to detect when the binary reader is a file +// to avoid reading the whole file into memory to get its size. +type sizer interface { + Stat() (os.FileInfo, error) } -func performMutualHandshake(line string, stdin io.WriteCloser) error { - // check for string - if strings.TrimSpace(line) != "ping" { - return fmt.Errorf("unexpected start line %v", line) +// sendBinary transfers the agent binary to inject.sh using length-prefixed +// framing. +func (s *session) sendBinary(ctx context.Context, signal string) error { + r, err := binaryReaderFor(s.opts.LocalFile, signal) + if err != nil { + return fmt.Errorf("open binary: %w", err) } + defer func() { _ = r.Close() }() - // send our response - _, err := stdin.Write([]byte("pong\n")) - if err != nil { - return fmt.Errorf("write to stdin: %w", err) + size, ok := binarySize(r) + if !ok { + buf, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("read binary: %w", err) + } + if err := writeFramed(s.stdin.w, int64(len(buf)), bytes.NewReader(buf)); err != nil { + return err + } + return awaitDone(ctx, s.stdout.r) } - // successful handshake - return nil + if err := writeFramed(s.stdin.w, size, r); err != nil { + return err + } + return awaitDone(ctx, s.stdout.r) } -func injectBinary( - fileReader io.ReadCloser, - stdin io.WriteCloser, - stdout io.ReadCloser, -) error { - // copy into writer - _, err := io.Copy(stdin, fileReader) - if err != nil { - return err +// binarySize returns the size of the binary if r is a *os.File, otherwise false. +func binarySize(r io.Reader) (int64, bool) { + f, ok := r.(sizer) + if !ok { + return 0, false + } + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return 0, false } + return info.Size(), true +} - // close stdin - _ = stdin.Close() +// writeFramed writes a length-prefixed payload to stdin. +func writeFramed(stdin io.Writer, size int64, payload io.Reader) error { + if _, err := fmt.Fprintf(stdin, "%d\n", size); err != nil { + return fmt.Errorf("write binary size: %w", err) + } + if _, err := io.Copy(stdin, payload); err != nil { + return fmt.Errorf("write binary payload: %w", err) + } + return nil +} - // wait for done - line, err := readLine(stdout) +func awaitDone(ctx context.Context, stdout io.ReadCloser) error { + line, err := readLine(ctx, stdout) if err != nil { - return err - } else if strings.TrimSpace(line) != "done" { - return fmt.Errorf("unexpected line during inject %s", line) + return fmt.Errorf("read binary done: %w", err) + } + if strings.TrimSpace(line) != statusDone { + return fmt.Errorf("unexpected line during inject %q", line) } return nil } -func waitForMessage(errChannel chan error, timeout time.Duration) error { - select { - case err := <-errChannel: - return err - case <-time.After(timeout): - return context.DeadlineExceeded +type streamPipeConfig struct { + stdin io.WriteCloser + hostStdin io.Reader + hostStdout io.Writer + stdout io.ReadCloser + delayedStderr *delayedWriter +} + +func pipeStreams(cfg streamPipeConfig) (bool, error) { + hostStdout := cfg.hostStdout + hostStdin := cfg.hostStdin + if hostStdout == nil { + hostStdout = io.Discard + } + if hostStdin == nil { + hostStdin = bytes.NewReader(nil) } + + cfg.delayedStderr.Start() + return true, pipe( + cfg.stdin, hostStdin, + hostStdout, cfg.stdout, + ) } -func readLine(reader io.Reader) (string, error) { - // we always only read a single byte - buf := make([]byte, 1) - str := "" - for { - n, err := reader.Read(buf) - switch { - case err != nil: - return "", err - case n == 0: - continue - case buf[0] == '\n': - return str, nil +// readLine reads a single line from r, returning the line without the trailing +// newline. It is bound to ctx so a hung inject.sh aborts instead of hanging the +// caller. +func readLine(ctx context.Context, r io.ReadCloser) (string, error) { + type result struct { + line string + err error + } + ch := make(chan result, 1) + go func() { + var b strings.Builder + one := make([]byte, 1) + for { + n, err := r.Read(one) + if n > 0 { + if one[0] == '\n' { + ch <- result{b.String(), nil} + return + } + b.WriteByte(one[0]) + } + if err != nil { + ch <- result{b.String(), err} + return + } } + }() - str += string(buf) + select { + case res := <-ch: + return res.line, res.err + case <-ctx.Done(): + _ = r.Close() // Abort: close r so the read goroutine unblocks and exits. + go func() { <-ch }() // nolint:govet // drain goroutine on r's terminal error + return "", ctx.Err() } } const pipeSecondDirTimeout = 5 * time.Second +// pipe runs the two directions of a duplex bridge concurrently, returning once +// both have completed or the slower side is abandoned after timeout. func pipe( toStdin io.WriteCloser, fromStdin io.Reader, toStdout io.Writer, fromStdout io.ReadCloser, ) error { - stdinErr := make(chan error, 1) - stdoutErr := make(chan error, 1) - - go func() { + stdoutSide := func() error { _, err := io.Copy(toStdout, fromStdout) - stdoutErr <- err - }() - go func() { + return err + } + stdinSide := func() error { _, err := io.Copy(toStdin, fromStdin) - stdinErr <- err - }() - - // Wait for whichever direction completes first. - var firstErr error - var otherCh <-chan error - select { - case firstErr = <-stdinErr: - otherCh = stdoutErr - case firstErr = <-stdoutErr: - otherCh = stdinErr - } - - // Give the other direction time to finish naturally so we can - // capture any real error and avoid interrupting data in flight. - // If it doesn't finish in time, close pipes to force completion. - var secondErr error - timer := time.NewTimer(pipeSecondDirTimeout) - defer timer.Stop() - select { - case secondErr = <-otherCh: - case <-timer.C: + return err } + stdinErr, stdoutErr := iojoin.Join(stdinSide, stdoutSide, pipeSecondDirTimeout, nil) + _ = toStdin.Close() _ = fromStdout.Close() - if firstErr != nil { - return firstErr + if stdinErr != nil { + return stdinErr } - return secondErr + return stdoutErr } diff --git a/pkg/inject/inject.sh b/pkg/inject/inject.sh index b94f6d995..07df29c3d 100644 --- a/pkg/inject/inject.sh +++ b/pkg/inject/inject.sh @@ -1,6 +1,4 @@ #!/bin/sh -# Deprecated: inject.sh is deprecated. Platform-native AgentDelivery implementations -# (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. set -e INSTALL_DIR="{{ .InstallDir }}" @@ -43,7 +41,19 @@ inject_binary() { temp_file="$(mktemp "$INSTALL_PATH.XXXXXX" 2>/dev/null || echo "$INSTALL_PATH.$$-$(date +%s)")" - if ! $sh_c "cat > \"$temp_file\""; then + if ! read -r BINARY_SIZE; then + >&2 echo "Error: Failed to read binary size" + return 1 + fi + + case "$BINARY_SIZE" in + '' | *[!0-9]*) + >&2 echo "Error: Invalid binary size: $BINARY_SIZE" + return 1 + ;; + esac + + if ! $sh_c "head -c \"$BINARY_SIZE\" > \"$temp_file\""; then >&2 echo "Error: Failed to write binary to $temp_file" $sh_c "rm -f \"$temp_file\"" 2>/dev/null || true return 1 diff --git a/pkg/inject/inject_test.go b/pkg/inject/inject_test.go index 9875b27ab..5f516f226 100644 --- a/pkg/inject/inject_test.go +++ b/pkg/inject/inject_test.go @@ -7,16 +7,35 @@ import ( "fmt" "io" "os" + "os/exec" + "path/filepath" "runtime" + "strconv" "strings" + "sync" + "sync/atomic" "testing" "time" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/util/goleaktest" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.uber.org/zap/zapcore" ) +// TestMain sets up the test environment and checks for goroutine leaks after tests. +func TestMain(m *testing.M) { + goleaktest.TestMain(m) +} + +const ( + testCommand = "test-command" + testExistsCheck = "true" + testAgentRemotePath = "/tmp/agent" + testPong = "pong\n" +) + // errWriter is an io.Writer that always returns a configured error. type errWriter struct { err error @@ -33,8 +52,6 @@ type nopWriteCloser struct { func (nopWriteCloser) Close() error { return nil } -// --- PipeTestSuite --- - type PipeTestSuite struct { suite.Suite } @@ -193,100 +210,84 @@ func TestPipeSuite(t *testing.T) { suite.Run(t, new(PipeTestSuite)) } -// --- ReadLineTestSuite --- - type ReadLineTestSuite struct { suite.Suite } +// readStringClose wraps a strings.Reader as an io.ReadCloser (no-op Close). +type readStringClose struct { + *strings.Reader +} + +func (readStringClose) Close() error { return nil } + func (s *ReadLineTestSuite) TestReadLine_NormalLine() { - r := strings.NewReader("hello\n") - line, err := readLine(r) + line, err := readLine(context.Background(), readStringClose{strings.NewReader("hello\n")}) s.NoError(err) s.Equal("hello", line) } -func (s *ReadLineTestSuite) TestReadLine_MultipleLines() { - r := strings.NewReader("first\nsecond\n") - line, err := readLine(r) +func (s *ReadLineTestSuite) TestReadLine_MultipleLinesReadsFirstOnly() { + line, err := readLine( + context.Background(), + readStringClose{strings.NewReader("first\nsecond\n")}, + ) s.NoError(err) s.Equal("first", line) } -func (s *ReadLineTestSuite) TestReadLine_EOFBeforeNewline() { - r := strings.NewReader("no newline") - _, err := readLine(r) +func (s *ReadLineTestSuite) TestReadLine_EOFBeforeNewlineReturnsPartialAndErr() { + line, err := readLine(context.Background(), readStringClose{strings.NewReader("no newline")}) s.ErrorIs(err, io.EOF) + s.Equal("no newline", line) } func (s *ReadLineTestSuite) TestReadLine_EmptyReader() { - r := strings.NewReader("") - _, err := readLine(r) + _, err := readLine(context.Background(), readStringClose{strings.NewReader("")}) s.ErrorIs(err, io.EOF) } -func TestReadLineSuite(t *testing.T) { - suite.Run(t, new(ReadLineTestSuite)) -} - -// --- WaitForMessageTestSuite --- - -type WaitForMessageTestSuite struct { - suite.Suite -} - -func (s *WaitForMessageTestSuite) TestWaitForMessage_Success() { - ch := make(chan error, 1) - ch <- nil - err := waitForMessage(ch, time.Second) - s.NoError(err) -} +func (s *ReadLineTestSuite) TestReadLine_ContextCancelAbortsAndClosesReader() { + r, w, err := os.Pipe() + s.Require().NoError(err) -func (s *WaitForMessageTestSuite) TestWaitForMessage_SuccessWithError() { - ch := make(chan error, 1) - expected := errors.New("something failed") - ch <- expected - err := waitForMessage(ch, time.Second) - s.ErrorIs(err, expected) -} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() -func (s *WaitForMessageTestSuite) TestWaitForMessage_Timeout() { - ch := make(chan error) - err := waitForMessage(ch, 10*time.Millisecond) + _, err = readLine(ctx, r) s.ErrorIs(err, context.DeadlineExceeded) -} -func TestWaitForMessageSuite(t *testing.T) { - suite.Run(t, new(WaitForMessageTestSuite)) + _, writeErr := w.Write([]byte("x")) + s.Error(writeErr, "reader should be closed after cancel") } -// --- PerformMutualHandshakeTestSuite --- - -type PerformMutualHandshakeTestSuite struct { +type BinarySizeTestSuite struct { suite.Suite } -func (s *PerformMutualHandshakeTestSuite) TestPerformMutualHandshake_ValidPing() { - buf := &bytes.Buffer{} - wc := nopWriteCloser{buf} - err := performMutualHandshake("ping\n", wc) - s.NoError(err) - s.Equal("pong\n", buf.String()) +func (s *BinarySizeTestSuite) TestBinarySize_OsFileReportsStatSize() { + f, err := os.CreateTemp("", "inject-binary-*") + s.Require().NoError(err) + defer func() { _ = f.Close(); _ = os.Remove(f.Name()) }() + + s.Require().NoError(f.Sync()) + size, ok := binarySize(f) + s.True(ok) + s.Equal(int64(0), size) } -func (s *PerformMutualHandshakeTestSuite) TestPerformMutualHandshake_InvalidInput() { - buf := &bytes.Buffer{} - wc := nopWriteCloser{buf} - err := performMutualHandshake("hello\n", wc) - s.Error(err) - s.Contains(err.Error(), "unexpected start line") +func (s *BinarySizeTestSuite) TestBinarySize_InMemoryReaderIsNotStatable() { + _, ok := binarySize(strings.NewReader("not a file")) + s.False(ok) } -func TestPerformMutualHandshakeSuite(t *testing.T) { - suite.Run(t, new(PerformMutualHandshakeTestSuite)) +func TestReadLineSuite(t *testing.T) { + suite.Run(t, new(ReadLineTestSuite)) } -// --- InjectTimingLogTestSuite --- +func TestBinarySizeSuite(t *testing.T) { + suite.Run(t, new(BinarySizeTestSuite)) +} type InjectTimingLogTestSuite struct { suite.Suite @@ -298,11 +299,9 @@ func (s *InjectTimingLogTestSuite) TestInject_TimingLogs() { ctx := context.Background() execFunc := func(_ context.Context, _ string, stdin io.Reader, stdout io.Writer, _ io.Writer) error { - // Simulate the inject.sh protocol: send ping, read pong, send done. if _, err := stdout.Write([]byte("ping\n")); err != nil { return err } - buf := make([]byte, 64) n, err := stdin.Read(buf) if err != nil { @@ -311,21 +310,17 @@ func (s *InjectTimingLogTestSuite) TestInject_TimingLogs() { if strings.TrimSpace(string(buf[:n])) != "pong" { return fmt.Errorf("expected pong, got %q", string(buf[:n])) } - if _, err := stdout.Write([]byte("done\n")); err != nil { return err } - - // Return immediately so stdout pipe closes and pipe() completes quickly. return nil } - wasExecuted, err := Inject(InjectOptions{ - Ctx: ctx, + wasExecuted, err := Inject(ctx, InjectOptions{ Exec: execFunc, ScriptParams: &Params{ Command: "test-cmd", - AgentRemotePath: "/tmp/agent", + AgentRemotePath: testAgentRemotePath, DownloadURLs: &DownloadURLs{}, }, Stdin: strings.NewReader(""), @@ -342,17 +337,17 @@ func (s *InjectTimingLogTestSuite) TestInject_TimingLogs() { messages = append(messages, entry.Message) } - s.Contains(messages, "injection: start") + s.Contains(messages, "start inject") s.True( - containsPrefix(messages, "injection: payload delivered elapsed="), + containsPrefix(messages, "payload delivered elapsed="), "missing payload log: %v", messages, ) s.True( - containsPrefix(messages, "injection: complete elapsed="), + containsPrefix(messages, "complete elapsed="), "missing complete log: %v", messages, ) s.True( - containsPrefix(messages, "injection: handshake complete elapsed="), + containsPrefix(messages, "handshake complete elapsed="), "missing handshake log: %v", messages, ) } @@ -369,3 +364,552 @@ func containsPrefix(messages []string, prefix string) bool { } return false } + +func fakeInjectShHandshake(stdin io.Reader, stdout io.Writer) error { + if _, err := stdout.Write([]byte("ping\n")); err != nil { + return err + } + pong := make([]byte, 5) + if _, err := io.ReadFull(stdin, pong); err != nil { + return err + } + if string(pong) != testPong { + return fmt.Errorf("expected pong, got %q", pong) + } + if _, err := stdout.Write([]byte("ARM-false\n")); err != nil { + return err + } + return nil +} + +func fakeInjectShReceiveBinary(stdin io.Reader) error { + sizeLine, err := readDelimitedLine(stdin) + if err != nil { + return fmt.Errorf("read size: %w", err) + } + size, err := strconv.Atoi(strings.TrimSpace(sizeLine)) + if err != nil { + return fmt.Errorf("parse size %q: %w", sizeLine, err) + } + binary := make([]byte, size) + if _, err := io.ReadFull(stdin, binary); err != nil { + return fmt.Errorf("read binary: %w", err) + } + if string(binary) != "BINARY-CONTENT" { + return fmt.Errorf("binary mismatch: %q", binary) + } + return nil +} + +func fakeInjectShBinaryTransfer( + stdin io.Reader, + stdout io.Writer, + commandStarted chan<- struct{}, +) error { + if err := fakeInjectShHandshake(stdin, stdout); err != nil { + return err + } + if err := fakeInjectShReceiveBinary(stdin); err != nil { + return err + } + if _, err := stdout.Write([]byte("done\n")); err != nil { + return err + } + commandStarted <- struct{}{} + _, copyErr := io.Copy(stdout, stdin) + return copyErr +} + +func assertBridgeIsLive(t *testing.T, stdinW io.Writer, stdoutR io.Reader) { + t.Helper() + if _, err := stdinW.Write([]byte("hello-bridge")); err != nil { + t.Fatal(err) + } + got := make([]byte, 12) + if _, err := io.ReadFull(stdoutR, got); err != nil { + t.Fatalf("read echoed bridge bytes: %v", err) + } + if string(got) != "hello-bridge" { + t.Fatalf("bridge echo mismatch: %q", got) + } +} + +func TestInject_BinaryInjectSingleExec(t *testing.T) { + t.Parallel() + + var execCalls atomic.Int32 + commandStarted := make(chan struct{}, 1) + execFunc := func(_ context.Context, _ string, stdin io.Reader, stdout io.Writer, _ io.Writer) error { + execCalls.Add(1) + return fakeInjectShBinaryTransfer(stdin, stdout, commandStarted) + } + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + + injectDone := make(chan error, 1) + go func() { + _, err := Inject(context.Background(), InjectOptions{ + Exec: execFunc, + LocalFile: func(_ bool) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("BINARY-CONTENT")), nil + }, + ScriptParams: &Params{ + Command: testCommand, + AgentRemotePath: testAgentRemotePath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + PreferAgentDownload: false, + }, + Stdin: stdinR, + Stdout: stdoutW, + Stderr: io.Discard, + Timeout: 5 * time.Second, + }) + injectDone <- err + }() + + select { + case <-commandStarted: + case <-time.After(2 * time.Second): + t.Fatal("command did not start on the single exec") + } + + if got := execCalls.Load(); got != 1 { + t.Fatalf("Exec called %d times, want 1 (single exec, no rerun)", got) + } + + assertBridgeIsLive(t, stdinW, stdoutR) + + _ = stdinW.Close() + _ = stdoutW.Close() + + select { + case <-injectDone: + case <-time.After(8 * time.Second): + t.Fatal("Inject did not return after teardown") + } +} + +func TestResolveRunResult_ExecErrorSurvivesWhenBothChannelsAreReady(t *testing.T) { + wantErr := errors.New("boom: exec failed after done") + for i := range 200 { + execErrChan := make(chan error, 1) + execErrChan <- wantErr + injectChan := make(chan injectResult, 1) + injectChan <- injectResult{wasExecuted: true, err: nil} + + result, execErr := resolveRunResult(execErrChan, injectChan) + + require.True(t, result.wasExecuted, "iteration %d", i) + require.NoError(t, result.err, "iteration %d", i) + require.ErrorIs(t, execErr, wantErr, "iteration %d: exec error must not be dropped", i) + } +} + +func TestInject_HangsAfterHandshakeAbortsOnTimeout(t *testing.T) { + t.Parallel() + + hanging := func(stdin io.Reader, stdout io.Writer) error { + if _, err := stdout.Write([]byte("ping\n")); err != nil { + return err + } + pong := make([]byte, 5) + if _, err := io.ReadFull(stdin, pong); err != nil { + return err + } + if string(pong) != testPong { + return fmt.Errorf("expected pong, got %q", pong) + } + _, _ = io.Copy(io.Discard, stdin) //nolint:errcheck // test helper + return nil + } + + execFunc := func(_ context.Context, _ string, stdin io.Reader, stdout io.Writer, _ io.Writer) error { + return hanging(stdin, stdout) + } + + const timeout = 400 * time.Millisecond + start := time.Now() + _, err := Inject(context.Background(), InjectOptions{ + Exec: execFunc, + LocalFile: func(bool) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil }, + ScriptParams: &Params{ + Command: testCommand, + AgentRemotePath: testAgentRemotePath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + }, + Stdin: strings.NewReader(""), + Stdout: io.Discard, + Stderr: io.Discard, + Timeout: timeout, + }) + elapsed := time.Since(start) + + require.Error(t, err, "Inject must abort when the handshake stalls") + require.ErrorIs(t, err, context.DeadlineExceeded, + "expected context.DeadlineExceeded, got %v", err) + require.Less(t, int64(elapsed), int64(2*timeout), + "Inject did not abort within timeout: elapsed=%s timeout=%s", elapsed, timeout) +} + +func TestInject_PostHandshakeCommandSurvivesHandshakeTimeout(t *testing.T) { + t.Parallel() + + const handshakeTimeout = 150 * time.Millisecond + const bridgeDuration = 400 * time.Millisecond // longer than handshakeTimeout + + execFunc := func(ctx context.Context, _ string, stdin io.Reader, stdout io.Writer, _ io.Writer) error { + if _, err := stdout.Write([]byte("ping\n")); err != nil { + return err + } + pong := make([]byte, 5) + if _, err := io.ReadFull(stdin, pong); err != nil { + return err + } + if string(pong) != testPong { + return fmt.Errorf("expected pong, got %q", pong) + } + if _, err := stdout.Write([]byte("done\n")); err != nil { + return err + } + select { + case <-time.After(bridgeDuration): + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + + start := time.Now() + _, err := Inject(context.Background(), InjectOptions{ + Exec: execFunc, + LocalFile: func(bool) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil }, + ScriptParams: &Params{ + Command: testCommand, + AgentRemotePath: testAgentRemotePath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + }, + Stdin: strings.NewReader(""), + Stdout: io.Discard, + Stderr: io.Discard, + Timeout: handshakeTimeout, + }) + elapsed := time.Since(start) + + require.NoError(t, err, + "a long-running post-handshake command must not be killed by the handshake timeout") + require.GreaterOrEqual(t, int64(elapsed), int64(bridgeDuration), + "Inject returned before the post-handshake command finished: elapsed=%s bridgeDuration=%s", + elapsed, bridgeDuration) +} + +func TestInject_RequiresExecAndScriptParams(t *testing.T) { + t.Parallel() + + wasExecuted, err := Inject(context.Background(), InjectOptions{ + ScriptParams: &Params{Command: testCommand}, + }) + require.Error(t, err, "Exec is required") + require.False(t, wasExecuted) + + wasExecuted, err = Inject(context.Background(), InjectOptions{ + Exec: func(context.Context, string, io.Reader, io.Writer, io.Writer) error { return nil }, + }) + require.Error(t, err, "ScriptParams is required") + require.False(t, wasExecuted) +} + +func TestInject_AlreadyCancelledContextReturnsAndClosesResources(t *testing.T) { + t.Parallel() + + execRan := make(chan struct{}, 1) + execFunc := func(_ context.Context, _ string, _ io.Reader, stdout io.Writer, _ io.Writer) error { + execRan <- struct{}{} + _, _ = stdout.Write([]byte("ping\n")) //nolint:errcheck + <-time.After(time.Second) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled + + start := time.Now() + _, err := Inject(ctx, InjectOptions{ + Exec: execFunc, + LocalFile: func(bool) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("")), nil }, + ScriptParams: &Params{ + Command: testCommand, + AgentRemotePath: testAgentRemotePath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + }, + Stdin: strings.NewReader(""), + Stdout: io.Discard, + Stderr: io.Discard, + Timeout: 5 * time.Second, + }) + elapsed := time.Since(start) + + require.ErrorIs(t, err, context.Canceled) + require.Less(t, int64(elapsed), int64(time.Second), + "Inject did not return promptly on a pre-cancelled context: %s", elapsed) + + select { + case <-execRan: + t.Fatal("exec ran despite pre-cancelled context (resources not cleaned up before launch)") + default: + } +} + +func readLineFatal(t *testing.T, r io.Reader) string { + t.Helper() + var line []byte + for { + b := make([]byte, 1) + if _, err := io.ReadFull(r, b); err != nil { + t.Fatalf("read line: %v", err) + } + if b[0] == '\n' { + return strings.TrimRight(string(line), "\r") + } + line = append(line, b[0]) + } +} + +func expectLine(t *testing.T, r io.Reader, want string) { + t.Helper() + if got := readLineFatal(t, r); got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +type shProcessIO struct { + stdin io.Reader + stdout io.Writer + stderr *syncBuffer +} + +type shTestPipe struct { + in io.Writer + out io.Reader +} + +func startInjectShProcess( + t *testing.T, + command, installPath string, + procIO shProcessIO, +) (*exec.Cmd, <-chan struct{}) { + t.Helper() + + rendered, err := GenerateScript(Script, &Params{ + Command: command, + AgentRemotePath: installPath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + PreferAgentDownload: false, + ShouldChmodPath: true, + }) + require.NoError(t, err) + + cmd := exec.Command( //nolint:gosec // G204: script content is test-generated, not external input + "sh", + "-c", + rendered, + ) + cmd.Stdin = procIO.stdin + cmd.Stdout = procIO.stdout + cmd.Stderr = procIO.stderr + require.NoError(t, cmd.Start()) + + waitDone := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(waitDone) + }() + return cmd, waitDone +} + +func driveBinaryHandshake(t *testing.T, shPipe shTestPipe, binary []byte) { + t.Helper() + + expectLine(t, shPipe.out, "ping") + requireWrite(t, shPipe.in, testPong) + if got := readLineFatal(t, shPipe.out); !strings.HasPrefix(got, "ARM-") { + t.Fatalf("expected ARM-..., got %q", got) + } + requireWrite(t, shPipe.in, fmt.Sprintf("%d\n", len(binary))) + requireWrite(t, shPipe.in, string(binary)) + expectLine(t, shPipe.out, "done") +} + +func verifyBridgeLiveAndInstall( + t *testing.T, + shPipe shTestPipe, + installPath string, + binary []byte, +) { + t.Helper() + + expectLine(t, shPipe.out, "CMD-START") + requireWrite(t, shPipe.in, "bridge-traffic\n") + if got := readLineFatal(t, shPipe.out); got != "bridge-traffic" { + t.Fatalf("command stdin not live (head -c over-read?): got %q", got) + } + + installed, err := os.ReadFile(installPath) //nolint:gosec // G304: path is test-controlled + require.NoError(t, err) + if !bytes.Equal(installed, binary) { + t.Fatalf("installed binary mismatch: got %d bytes, want %d", len(installed), len(binary)) + } +} + +func TestInjectScript_RealShellBinaryTransfer(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + tmp := t.TempDir() + installPath := filepath.Join(tmp, "bin", "agent") + require.NoError(t, os.MkdirAll(filepath.Dir(installPath), 0o750)) + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + stderr := &syncBuffer{} + command := "printf 'CMD-START\\n' && cat" + cmd, waitDone := startInjectShProcess( + t, + command, + installPath, + shProcessIO{stdin: stdinR, stdout: stdoutW, stderr: stderr}, + ) + t.Cleanup(func() { + _ = stdinW.Close() + _ = stdoutW.Close() + _ = cmd.Process.Kill() + <-waitDone + }) + shPipe := shTestPipe{in: stdinW, out: stdoutR} + + binary := bytes.Repeat([]byte{0x07}, 4096) // arbitrary content incl. a 4KB chunk + binary[0], binary[1], binary[len(binary)-1] = 'D', 'V', 'X' + + driveBinaryHandshake(t, shPipe, binary) + verifyBridgeLiveAndInstall(t, shPipe, installPath, binary) + + _ = stdinW.Close() + _ = stdoutW.Close() + select { + case <-waitDone: + case <-time.After(5 * time.Second): + t.Fatalf("inject.sh did not exit: stderr=%s", stderr.String()) + } +} + +func TestInjectScript_RealShellRejectsInvalidBinarySize(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + tmp := t.TempDir() + installPath := filepath.Join(tmp, "bin", "agent") + require.NoError(t, os.MkdirAll(filepath.Dir(installPath), 0o750)) + + rendered, err := GenerateScript(Script, &Params{ + Command: "echo should-not-run", + AgentRemotePath: installPath, + DownloadURLs: &DownloadURLs{}, + ExistsCheck: testExistsCheck, + PreferAgentDownload: false, + ShouldChmodPath: true, + }) + require.NoError(t, err) + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + stderrR, stderrW := io.Pipe() + cmd := exec.Command( //nolint:gosec // G204: script content is test-generated, not external input + "sh", + "-c", + rendered, + ) + cmd.Stdin = stdinR + cmd.Stdout = stdoutW + cmd.Stderr = stderrW + require.NoError(t, cmd.Start()) + waitDone := make(chan struct{}) + t.Cleanup(func() { + _ = cmd.Process.Kill() + <-waitDone + }) + go func() { + _ = cmd.Wait() + close(waitDone) + }() + + shPipe := shTestPipe{in: stdinW, out: stdoutR} + expectLine(t, shPipe.out, "ping") + requireWrite(t, shPipe.in, testPong) + if got := readLineFatal(t, shPipe.out); !strings.HasPrefix(got, "ARM-") { + t.Fatalf("expected ARM-..., got %q", got) + } + requireWrite(t, shPipe.in, "1K\n") + + if got := readLineFatal(t, stderrR); !strings.Contains(got, "Invalid binary size") { + t.Fatalf("expected an invalid-size rejection on stderr, got %q", got) + } + + _, statErr := os.Stat(installPath) + require.True(t, os.IsNotExist(statErr), "invalid size must not install anything") + + _ = stdinW.Close() + _ = stdoutW.Close() + _ = stderrW.Close() +} + +func requireWrite(t *testing.T, w io.Writer, s string) { + t.Helper() + if _, err := w.Write([]byte(s)); err != nil { + t.Fatalf("write %q: %v", s, err) + } +} + +func readDelimitedLine(r io.Reader) (string, error) { + var b []byte + one := make([]byte, 1) + for { + n, err := r.Read(one) + if n > 0 { + if one[0] == '\n' { + return string(b), nil + } + b = append(b, one[0]) + } + if err != nil { + return string(b), err + } + } +} diff --git a/pkg/options/resolver/sub_options.go b/pkg/options/resolver/sub_options.go index b293bbd13..9b90a4b53 100644 --- a/pkg/options/resolver/sub_options.go +++ b/pkg/options/resolver/sub_options.go @@ -29,7 +29,12 @@ func execOptionCommand( env = append(env, k+"="+v) } - err := shell.RunEmulatedShell(ctx, command, nil, stdout, stderr, env) + err := shell.RunEmulatedShell(ctx, &shell.CommandRunner{ + Stdout: stdout, + Stderr: stderr, + Environ: env, + Command: command, + }) if err != nil { return nil, fmt.Errorf("exec command: %s%s: %w", stdout.String(), stderr.String(), err) } diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 0daf538bc..babf37164 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -18,47 +18,45 @@ import ( "mvdan.cc/sh/v3/syntax" ) +type CommandRunner struct { + Command string + Environ []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + func RunEmulatedShell( ctx context.Context, - command string, - stdin io.Reader, - stdout io.Writer, - stderr io.Writer, - env []string, + runner *CommandRunner, ) error { - command = strings.ReplaceAll(command, "\r", "") - - // Let's parse the complete command - parsed, err := syntax.NewParser().Parse(strings.NewReader(command), "") + runner.Command = strings.ReplaceAll(runner.Command, "\r", "") + parsed, err := syntax.NewParser().Parse(strings.NewReader(runner.Command), "") if err != nil { return fmt.Errorf("parse shell command: %w", err) } - // use system default as environ if unspecified - if env == nil { - env = []string{} - env = append(env, os.Environ()...) + if runner.Environ == nil { + runner.Environ = []string{} + runner.Environ = append(runner.Environ, os.Environ()...) } - // Get current working directory dir, err := os.Getwd() if err != nil { return err } - // Create shell runner - r, err := interp.New(buildRunnerOptions(runnerParams{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - env: env, + r, err := interp.New(buildRunnerOptions(shellRunnerOptions{ + stdin: runner.Stdin, + stdout: runner.Stdout, + stderr: runner.Stderr, + env: runner.Environ, dir: dir, })...) if err != nil { return fmt.Errorf("create shell runner: %w", err) } - // Run command err = r.Run(ctx, parsed) if err != nil { var exitStatus interp.ExitStatus @@ -72,7 +70,7 @@ func RunEmulatedShell( return nil } -type runnerParams struct { +type shellRunnerOptions struct { stdin io.Reader stdout io.Writer stderr io.Writer @@ -80,7 +78,7 @@ type runnerParams struct { dir string } -func buildRunnerOptions(p runnerParams) []interp.RunnerOption { +func buildRunnerOptions(p shellRunnerOptions) []interp.RunnerOption { defaultOpenHandler := interp.DefaultOpenHandler() defaultExecHandler := interp.DefaultExecHandler(2 * time.Second) return []interp.RunnerOption{ @@ -132,21 +130,17 @@ func (devNull) Close() error { } func GetShell(userName string) ([]string, error) { - // try to get a shell if runtime.GOOS != "windows" { - // infere login shell from getent shell, err := getUserShell(userName) if err == nil { return []string{shell}, nil } - // fallback to $SHELL env var shell, ok := os.LookupEnv("SHELL") if ok { return []string{shell}, nil } - // fallback to path discovery _, err = exec.LookPath("bash") if err == nil { return []string{"bash"}, nil @@ -158,7 +152,6 @@ func GetShell(userName string) ([]string, error) { } } - // fallback to our in-built shell executable, err := os.Executable() if err != nil { return nil, err diff --git a/pkg/shell/shell_test.go b/pkg/shell/shell_test.go index 14abce4a0..09c3b35c5 100644 --- a/pkg/shell/shell_test.go +++ b/pkg/shell/shell_test.go @@ -9,15 +9,16 @@ import ( ) func TestRunEmulatedShell_KillExecutesRealBinary(t *testing.T) { - // "kill -0 PID" checks whether a signal can be sent to a process - // without actually sending one. It exits 0 if the process exists. - // This verifies that `kill` invokes the real /bin/kill binary - // rather than being swallowed by the shell interpreter's builtin stub. pid := os.Getpid() cmd := fmt.Sprintf("kill -0 %d", pid) var stdout, stderr bytes.Buffer - err := RunEmulatedShell(context.Background(), cmd, nil, &stdout, &stderr, os.Environ()) + err := RunEmulatedShell(context.Background(), &CommandRunner{ + Command: cmd, + Stdout: &stdout, + Stderr: &stderr, + Environ: os.Environ(), + }) if err != nil { t.Fatalf( "kill -0 on own PID should succeed, got error: %v\nstderr: %s", diff --git a/pkg/tunnel/container.go b/pkg/tunnel/container.go index 37d0a4298..db351efcf 100644 --- a/pkg/tunnel/container.go +++ b/pkg/tunnel/container.go @@ -102,8 +102,7 @@ func (c *ContainerTunnel) runHostTunnel( if log.DebugEnabled() { command += " --debug" } - return agent.InjectAgent(&agent.InjectOptions{ - Ctx: ctx, + return agent.InjectAgent(ctx, &agent.InjectOptions{ Exec: func(ctx context.Context, command string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { return c.client.Command(ctx, client.CommandOptions{ Command: command, @@ -132,21 +131,18 @@ func (c *ContainerTunnel) updateConfig(ctx context.Context, sshClient *ssh.Clien case <-time.After(c.updateConfigInterval): log.Debugf("Start refresh") - // update options err := c.client.RefreshOptions(ctx, nil, false) if err != nil { log.Errorf("Error refreshing workspace options: %v", err) break } - // compress info workspaceInfo, agentInfo, err := c.client.AgentInfo(provider.CLIOptions{}) if err != nil { log.Errorf("Error compressing workspace info: %v", err) break } - // update workspace remotely buf := &bytes.Buffer{} command := fmt.Sprintf( "%q internal agent workspace update-config --workspace-info %q", @@ -208,9 +204,7 @@ func (c *ContainerTunnel) runInContainer( containerClient, err := devssh.StdioClient(pb.StdoutReader, pb.StdinWriter, false) if err != nil { - // StdioClient failed — check if the tunnel goroutine already exited - // with an error. If so, the tunnel error is the root cause. - select { + select { // check if the tunnel goroutine has already returned an error case tunnelErr := <-tunnelDone: if tunnelErr != nil { return tunnelErr diff --git a/pkg/tunnel/pipebridge.go b/pkg/tunnel/pipebridge.go index 2449b0ebe..de5a52692 100644 --- a/pkg/tunnel/pipebridge.go +++ b/pkg/tunnel/pipebridge.go @@ -3,8 +3,17 @@ package tunnel import ( "context" "os" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/util/iojoin" ) +const joinTimeout = 5 * time.Second + +// PipeBridge is a duplex bridge between two concurrent units of work that share +// resources. It is used to implement the bridge between the caller's stdio and +// the exec's stdio. type PipeBridge struct { StdoutReader *os.File StdoutWriter *os.File @@ -12,6 +21,8 @@ type PipeBridge struct { StdinWriter *os.File } +// NewPipeBridge creates a bridge backed by two OS pipes. The caller owns the +// bridge and must Close it (RunPair does not). func NewPipeBridge() (*PipeBridge, error) { stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { @@ -31,6 +42,7 @@ func NewPipeBridge() (*PipeBridge, error) { }, nil } +// Close releases both pipe pairs. It is idempotent. func (pb *PipeBridge) Close() { _ = pb.StdoutReader.Close() _ = pb.StdoutWriter.Close() @@ -39,51 +51,56 @@ func (pb *PipeBridge) Close() { } type ( - TunnelFunc func(ctx context.Context, stdin *os.File, stdout *os.File) error + // TunnelFunc is the "tunnel" side: it consumes the caller's input via stdin + // and produces output via stdout (e.g. an SSH server run over the bridge). + TunnelFunc func(ctx context.Context, stdin *os.File, stdout *os.File) error + // HandlerFunc is the "handler" side: it consumes the tunnel's output via + // stdout and produces input via stdin (e.g. the caller's stdio). HandlerFunc func(ctx context.Context, stdout *os.File, stdin *os.File) error ) +// RunPair runs the tunnel and handler sides of the bridge concurrently and +// returns once both have finished (or the slower side is abandoned after +// joinTimeout). func (pb *PipeBridge) RunPair( ctx context.Context, tunnelFn TunnelFunc, handlerFn HandlerFunc, ) error { - cancelCtx, cancel := context.WithCancel(ctx) + pairCtx, cancel := context.WithCancel(ctx) defer cancel() - tunnelChan := make(chan error, 1) - go func() { - tunnelChan <- tunnelFn(cancelCtx, pb.StdinReader, pb.StdoutWriter) - }() + var stopOnce sync.Once + stop := func() { + stopOnce.Do(func() { + cancel() + _ = pb.StdoutWriter.Close() + _ = pb.StdinWriter.Close() + }) + } + defer stop() - handlerChan := make(chan error, 1) + // If the parent ctx is cancelled before either side returns (e.g. both + // are blocked in a raw file Read that doesn't itself watch ctx), stop + // them directly instead of waiting on iojoin.Join's onFirst, which never + // fires unless one side already completed. + stopped := make(chan struct{}) + defer close(stopped) go func() { - defer cancel() - handlerChan <- handlerFn(cancelCtx, pb.StdoutReader, pb.StdinWriter) + select { + case <-ctx.Done(): + stop() + case <-stopped: + } }() - return awaitPair(cancel, tunnelChan, handlerChan, pb.StdoutWriter, pb.StdinWriter) -} - -func awaitPair( - cancel context.CancelFunc, - tunnelChan, handlerChan <-chan error, - stdoutWriter, stdinWriter *os.File, -) error { - var tunnelErr, handlerErr error - - select { - case handlerErr = <-handlerChan: - cancel() - _ = stdoutWriter.Close() - _ = stdinWriter.Close() - tunnelErr = <-tunnelChan - case tunnelErr = <-tunnelChan: - cancel() - _ = stdoutWriter.Close() - _ = stdinWriter.Close() - handlerErr = <-handlerChan + tunnelSide := func() error { return tunnelFn(pairCtx, pb.StdinReader, pb.StdoutWriter) } + handlerSide := func() error { + return handlerFn(pairCtx, pb.StdoutReader, pb.StdinWriter) } + // Run the two sides concurrently and wait for both to finish (or the slower + // side to be abandoned after joinTimeout). + tunnelErr, handlerErr := iojoin.Join(tunnelSide, handlerSide, joinTimeout, stop) return ClassifyTunnelErrors(tunnelErr, handlerErr) } diff --git a/pkg/tunnel/pipebridge_test.go b/pkg/tunnel/pipebridge_test.go index bf7b12275..f1366ff2a 100644 --- a/pkg/tunnel/pipebridge_test.go +++ b/pkg/tunnel/pipebridge_test.go @@ -5,10 +5,17 @@ import ( "errors" "io" "os" + "sync/atomic" "testing" "time" + + "github.com/devsy-org/devsy/pkg/util/goleaktest" ) +func TestMain(m *testing.M) { + goleaktest.TestMain(m) +} + func TestNewPipeBridge(t *testing.T) { t.Parallel() @@ -246,3 +253,153 @@ func TestRunPairJoinsTunnelWhenHandlerFinishesFirst(t *testing.T) { t.Fatal("tunnel goroutine was orphaned when handler finished first") } } + +func TestRunPairDoesNotHangWhenTunnelIgnoresCancellation(t *testing.T) { + t.Parallel() + + pb, err := NewPipeBridge() + if err != nil { + t.Fatalf("NewPipeBridge() error = %v", err) + } + defer pb.Close() + + release := make(chan struct{}) + tunnelReturned := make(chan struct{}) + tunnel := func(ctx context.Context, _ *os.File, _ *os.File) error { //nolint:unparam + <-ctx.Done() + <-release + close(tunnelReturned) + return nil + } + + handler := func(_ context.Context, _ *os.File, _ *os.File) error { + return nil + } + + done := make(chan error, 1) + go func() { done <- pb.RunPair(context.Background(), tunnel, handler) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("RunPair() error = %v", err) + } + select { + case <-tunnelReturned: + t.Fatal("RunPair waited for the unresponsive tunnel; expected it to give up") + default: + } + case <-time.After(8 * time.Second): + t.Fatal("RunPair hung waiting for a tunnel that ignores cancellation") + } + + // Release the abandoned tunnel goroutine so the test leaks nothing. + close(release) + <-tunnelReturned +} + +func TestRunPairDoesNotHangWhenHandlerIgnoresCancellation(t *testing.T) { + t.Parallel() + + pb, err := NewPipeBridge() + if err != nil { + t.Fatalf("NewPipeBridge() error = %v", err) + } + defer pb.Close() + + release := make(chan struct{}) + handlerReturned := make(chan struct{}) + tunnel := func(_ context.Context, _ *os.File, _ *os.File) error { + return nil + } + handler := func(ctx context.Context, _ *os.File, _ *os.File) error { //nolint:unparam + <-ctx.Done() + <-release + close(handlerReturned) + return nil + } + + done := make(chan error, 1) + go func() { done <- pb.RunPair(context.Background(), tunnel, handler) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("RunPair() error = %v", err) + } + select { + case <-handlerReturned: + t.Fatal("RunPair waited for the unresponsive handler; expected it to give up") + default: + } + case <-time.After(8 * time.Second): + t.Fatal("RunPair hung waiting for a handler that ignores cancellation") + } + + close(release) + <-handlerReturned +} + +func TestRunPairBothSidesAlwaysRun(t *testing.T) { + t.Parallel() + + pb, err := NewPipeBridge() + if err != nil { + t.Fatalf("NewPipeBridge() error = %v", err) + } + defer pb.Close() + + var tunnelRan, handlerRan atomic.Bool + tunnel := func(ctx context.Context, _ *os.File, _ *os.File) error { + tunnelRan.Store(true) + <-ctx.Done() + return nil + } + handler := func(_ context.Context, _ *os.File, _ *os.File) error { + handlerRan.Store(true) + return nil + } + + _ = pb.RunPair(context.Background(), tunnel, handler) + + if !tunnelRan.Load() { + t.Fatal("tunnel side never ran") + } + if !handlerRan.Load() { + t.Fatal("handler side never ran") + } +} + +func TestRunPairStopsBothSidesWhenParentContextCancelledBeforeEitherReturns(t *testing.T) { + t.Parallel() + + pb, err := NewPipeBridge() + if err != nil { + t.Fatalf("NewPipeBridge() error = %v", err) + } + defer pb.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + tunnel := func(_ context.Context, stdin *os.File, _ *os.File) error { + buf := make([]byte, 1) + _, err := stdin.Read(buf) + return err + } + handler := func(_ context.Context, stdout *os.File, _ *os.File) error { + buf := make([]byte, 1) + _, err := stdout.Read(buf) + return err + } + + done := make(chan error, 1) + go func() { done <- pb.RunPair(ctx, tunnel, handler) }() + + time.Sleep(50 * time.Millisecond) // let both sides actually enter Read + cancel() // parent ctx ends while both are still blocked + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("RunPair hung after parent ctx cancellation with both sides blocked in Read") + } +} diff --git a/pkg/util/goleaktest/goleaktest.go b/pkg/util/goleaktest/goleaktest.go new file mode 100644 index 000000000..f5b1177ad --- /dev/null +++ b/pkg/util/goleaktest/goleaktest.go @@ -0,0 +1,44 @@ +// Package goleaktest provides a small helper for wiring go.uber.org/goleak +// into a package's TestMain so goroutine leaks fail the test suite. +// +// Usage in a package's _test.go: +// +// func TestMain(m *testing.M) { +// goleaktest.TestMain(m) +// } +// +// Tests that intentionally leave a goroutine running should clean it up. If +// that is impossible, register an ignore via goleak.IgnoreAnyFunction(...) and +// pass it through Options.Extra. +package goleaktest + +import ( + "testing" + + "go.uber.org/goleak" +) + +// Options holds goleak options for packages that need to ignore known, +// long-lived goroutines. +type Options struct { + // IgnoreCurrent ignores all goroutines that are running at the time of the call. + IgnoreCurrent bool + // Extra appends additional goleak.Option values. + Extra []goleak.Option +} + +// Run wires goleak verification around the package's test suite. It runs the +// tests and, if they pass, asserts no goroutines leaked. +func Run(m *testing.M, opts Options) { + optsSlice := make([]goleak.Option, 0, len(opts.Extra)+1) + if opts.IgnoreCurrent { + optsSlice = append(optsSlice, goleak.IgnoreCurrent()) + } + optsSlice = append(optsSlice, opts.Extra...) + + goleak.VerifyTestMain(m, optsSlice...) +} + +func TestMain(m *testing.M) { + Run(m, Options{}) +} diff --git a/pkg/util/iojoin/iojoin.go b/pkg/util/iojoin/iojoin.go new file mode 100644 index 000000000..686821233 --- /dev/null +++ b/pkg/util/iojoin/iojoin.go @@ -0,0 +1,53 @@ +// iojoin provides a utility for joining two concurrent units of work that share +// resources. It is used to implement the duplex bridge between the caller's stdio +// and the exec's stdio. +package iojoin + +import "time" + +// Side is a concurrent, error-reporting unit of work that can be joined with +// another Side. It is expected to be run in its own goroutine. +type Side func() error + +// Join runs two sides concurrently and returns once both have completed or the +// slower side is abandoned after grace. The first side to complete fires the +// onFirst hook, which is where the caller should tear down shared resources +// (close pipe ends, cancel a context) so the straggler can finish naturally. +// The caller owns the resources and must close them; Join does not close any of +// its arguments. +func Join(a, b Side, grace time.Duration, onFirst func()) (aErr, bErr error) { + aDone := make(chan error, 1) + bDone := make(chan error, 1) + + go func() { aDone <- a() }() + go func() { bDone <- b() }() + + var firstErr error + var other <-chan error + select { + case firstErr = <-aDone: + other = bDone + aErr = firstErr + case firstErr = <-bDone: + other = aDone + bErr = firstErr + } + + if onFirst != nil { + onFirst() + } + + timer := time.NewTimer(grace) + defer timer.Stop() + select { + case secondErr := <-other: + if other == bDone { + bErr = secondErr + } else { + aErr = secondErr + } + case <-timer.C: + } + + return aErr, bErr +} diff --git a/pkg/util/iojoin/iojoin_test.go b/pkg/util/iojoin/iojoin_test.go new file mode 100644 index 000000000..389701f2d --- /dev/null +++ b/pkg/util/iojoin/iojoin_test.go @@ -0,0 +1,117 @@ +package iojoin + +import ( + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/util/goleaktest" +) + +func TestMain(m *testing.M) { + goleaktest.TestMain(m) +} + +func immediateSide(err error) Side { + return func() error { + time.Sleep(5 * time.Millisecond) + return err + } +} + +func TestJoinBothSucceed(t *testing.T) { + t.Parallel() + + aErr, bErr := Join(immediateSide(nil), immediateSide(nil), time.Second, nil) + if aErr != nil || bErr != nil { + t.Fatalf("Join() = (%v, %v), want (nil, nil)", aErr, bErr) + } +} + +func TestJoinBothErrorsReported(t *testing.T) { + t.Parallel() + + errA := errors.New("a failed") + errB := errors.New("b failed") + aErr, bErr := Join(immediateSide(errA), immediateSide(errB), time.Second, nil) + if aErr != errA { + t.Errorf("aErr = %v, want %v", aErr, errA) + } + if bErr != errB { + t.Errorf("bErr = %v, want %v", bErr, errB) + } +} + +func TestJoinOnFirstFiresOnce(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + first := immediateSide(nil) + straggler := func() error { + time.Sleep(20 * time.Millisecond) + return nil + } + + _, _ = Join(first, straggler, time.Second, func() { + calls.Add(1) + }) + + if got := calls.Load(); got != 1 { + t.Fatalf("onFirst called %d times, want 1", got) + } +} + +func TestJoinStragglerAbandonedAfterGrace(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + straggler := func() error { + <-release + return nil + } + defer close(release) + + grace := 15 * time.Millisecond + start := time.Now() + aErr, bErr := Join(immediateSide(nil), straggler, grace, nil) + elapsed := time.Since(start) + + if aErr != nil { + t.Errorf("aErr = %v, want nil", aErr) + } + if bErr != nil { + t.Errorf("bErr = %v, want nil (straggler abandoned)", bErr) + } + if elapsed < grace { + t.Errorf("elapsed = %v, want >= grace %v", elapsed, grace) + } + if elapsed > grace+500*time.Millisecond { + t.Errorf("elapsed = %v, Join did not bound the straggler to grace", elapsed) + } +} + +func TestJoinOnFirstUnblocksStraggler(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + straggler := func() error { + <-release + return errors.New("straggler done after teardown") + } + + var bErr error + aErr, bErr := Join( + immediateSide(nil), + straggler, + time.Second, + func() { close(release) }, // eager teardown signals the straggler + ) + + if aErr != nil { + t.Errorf("aErr = %v, want nil", aErr) + } + if bErr == nil { + t.Fatal("bErr = nil, want straggler's error (finished within grace via onFirst)") + } +} diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index cfcdc6954..2adb3fffb 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -411,15 +411,17 @@ func listInstancesProxyProvider( var stdout bytes.Buffer var stderr bytes.Buffer - if err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ - Ctx: ctx, - Command: providerConfig.Exec.Proxy.List.Workspaces, - Context: devsyConfig.DefaultContext, - Options: opts, - Config: providerConfig, - Stdout: &stdout, - Stderr: &stderr, - }); err != nil { + if err := clientimplementation.RunCommandWithBinaries( + ctx, + clientimplementation.WorkspaceCommandConfig{ + Command: providerConfig.Exec.Proxy.List.Workspaces, + WorkspaceContextName: devsyConfig.DefaultContext, + Options: opts, + Config: providerConfig, + Stdout: &stdout, + Stderr: &stderr, + }, + ); err != nil { return nil, fmt.Errorf("failed to list pro workspaces: %s: %w", stderr.String(), err) } if stdout.Len() == 0 {