From 42c412e46bda6cfae727653359c89e1ef7b7da67 Mon Sep 17 00:00:00 2001 From: Giovanni Visciano Date: Mon, 16 Feb 2026 11:24:43 +0100 Subject: [PATCH] Enables multi-key SSH forwarding for run and shell Updates CLI options and parsing to support multiple --ssh specs, allowing users to forward custom SSH keys and agents to builds and shell containers. Improves flexibility for pipeline execution, clarifies help text, and updates shell completions for enhanced UX. --- .github/workflows/ci.yml | 8 +++---- lib/pix/command/help.ex | 11 +++++---- lib/pix/command/run.ex | 2 +- lib/pix/command/shell.ex | 2 +- lib/pix/docker.ex | 49 +++++++++++++++++++++++++++++++------- lib/pix/pipeline.ex | 20 +++++++++++----- lib/pix/user_settings.ex | 4 ++-- shell_completions/pix.fish | 4 ++-- 8 files changed, 72 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288b846..474b800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -77,7 +77,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Docker Buildx id: buildx @@ -109,10 +109,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Download docs artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v7 with: name: docs path: .pipeline/output/doc diff --git a/lib/pix/command/help.ex b/lib/pix/command/help.ex index dadf5b0..679e025 100644 --- a/lib/pix/command/help.ex +++ b/lib/pix/command/help.ex @@ -75,7 +75,7 @@ defmodule Pix.Command.Help do defp help_cmd("run") do """ - #{_cmd("pix run")} [#{_opt("--output")}] [#{_opt("--ssh")}] [#{_opt("--arg ARG")} ...] [#{_opt("--progress PROGRESS")}] [#{_opt("--target TARGET")} [#{_opt("--tag TAG")}]] [#{_opt("--no-cache")}] [#{_opt("--no-cache-filter TARGET")} ...] PIPELINE + #{_cmd("pix run")} [#{_opt("--output")}] [#{_opt("--ssh [SPEC]")} ...] [#{_opt("--arg ARG")} ...] [#{_opt("--progress PROGRESS")}] [#{_opt("--target TARGET")} [#{_opt("--tag TAG")}]] [#{_opt("--no-cache")}] [#{_opt("--no-cache-filter TARGET")} ...] PIPELINE Run PIPELINE. @@ -91,7 +91,8 @@ defmodule Pix.Command.Help do #{_opt("--no-cache-filter")}* Do not cache specified targets #{_opt("--progress")} Set type of progress output - "auto", "plain", "tty", "rawjson" (default "auto") #{_opt("--secret")}* Forward one or more secrets to `buildx build` - #{_opt("--ssh")} Forward SSH agent to `buildx build` + #{_opt("--ssh")}* Forward SSH agent/keys to `buildx build` (format: default[=] | =). + Can be specified multiple times. #{_opt("--tag")} Tag the TARGET's docker image (default: no tag) #{_opt("--target")} Run PIPELINE for a specific TARGET (default: all the PIPELINE targets) """ @@ -99,7 +100,7 @@ defmodule Pix.Command.Help do defp help_cmd("shell") do """ - #{_cmd("pix shell")} [#{_opt("--ssh")}] [#{_opt("--arg ARG")} ...] [#{_opt("--target TARGET")}] [#{_opt("--host")}] PIPELINE [COMMAND] + #{_cmd("pix shell")} [#{_opt("--ssh [SPEC]")} ...] [#{_opt("--arg ARG")} ...] [#{_opt("--target TARGET")}] [#{_opt("--host")}] PIPELINE [COMMAND] Shell into the specified target of the PIPELINE. @@ -113,7 +114,9 @@ defmodule Pix.Command.Help do OPTIONS: #{_opt("--arg")}* Set one or more pipeline ARG (format KEY=value) #{_opt("--secret")}* Forward one or more secrets to `buildx build` - #{_opt("--ssh")} Forward SSH agent to shell container + #{_opt("--ssh")}* Forward SSH agent/keys to the build and shell container (format: default[=] | =). + Custom key files are mounted read-only into the shell container. + Can be specified multiple times. #{_opt("--target")} The shell target """ end diff --git a/lib/pix/command/run.ex b/lib/pix/command/run.ex index 74c1189..b6e8271 100644 --- a/lib/pix/command/run.ex +++ b/lib/pix/command/run.ex @@ -8,7 +8,7 @@ defmodule Pix.Command.Run do output: :boolean, progress: :string, secret: [:string, :keep], - ssh: :boolean, + ssh: [:string, :keep], tag: :string, target: :string ] diff --git a/lib/pix/command/shell.ex b/lib/pix/command/shell.ex index 3a1451f..f923667 100644 --- a/lib/pix/command/shell.ex +++ b/lib/pix/command/shell.ex @@ -5,7 +5,7 @@ defmodule Pix.Command.Shell do arg: [:string, :keep], host: :boolean, secret: [:string, :keep], - ssh: :boolean, + ssh: [:string, :keep], target: :string ] diff --git a/lib/pix/docker.ex b/lib/pix/docker.ex index f02098a..9afe8b8 100644 --- a/lib/pix/docker.ex +++ b/lib/pix/docker.ex @@ -40,9 +40,10 @@ defmodule Pix.Docker do end end - @spec run(image :: String.t(), ssh_fwd? :: boolean(), opts(), cmd_args :: [String.t()]) :: status :: non_neg_integer() - def run(image, ssh_fwd?, opts, cmd_args) do - ssh_opts = if ssh_fwd?, do: run_opts_ssh_forward(), else: [] + @spec run(image :: String.t(), ssh_specs :: [String.t()], opts(), cmd_args :: [String.t()]) :: + status :: non_neg_integer() + def run(image, ssh_specs, opts, cmd_args) do + ssh_opts = if ssh_specs != [], do: run_opts_ssh_forward(ssh_specs), else: [] opts = opts ++ ssh_opts ++ run_opts_docker_outside_of_docker() args = ["run"] ++ opts_encode(opts) ++ Pix.Env.pix_docker_run_opts() ++ [image] ++ cmd_args @@ -73,8 +74,16 @@ defmodule Pix.Docker do :ok end - @spec run_opts_ssh_forward :: opts() - defp run_opts_ssh_forward do + @spec run_opts_ssh_forward([String.t()]) :: opts() + defp run_opts_ssh_forward(ssh_specs) do + agent_opts = if "default" in ssh_specs, do: run_opts_ssh_agent_forward(), else: [] + {volume_opts, key_paths} = run_opts_ssh_key_mounts(ssh_specs) + git_ssh_opts = run_opts_git_ssh_command(key_paths) + agent_opts ++ volume_opts ++ git_ssh_opts + end + + @spec run_opts_ssh_agent_forward :: opts() + defp run_opts_ssh_agent_forward do ssh_sock = cond do :os.type() == {:unix, :darwin} -> @@ -101,6 +110,30 @@ defmodule Pix.Docker do end end + @spec run_opts_ssh_key_mounts([String.t()]) :: {opts(), key_paths :: [String.t()]} + defp run_opts_ssh_key_mounts(ssh_specs) do + Enum.reduce(ssh_specs, {[], []}, fn spec, {vol_acc, key_path_acc} -> + case String.split(spec, "=", parts: 2) do + [_id, path] when path != "" -> + path = Path.expand(path) + container_path = "/root/.ssh/#{Path.basename(path)}" + Pix.Report.internal(">>> mounting SSH key #{inspect(path)} as #{container_path} into shell container\n") + {[{:volume, "#{path}:#{container_path}:ro"} | vol_acc], [container_path | key_path_acc]} + + _ -> + {vol_acc, key_path_acc} + end + end) + end + + @spec run_opts_git_ssh_command([String.t()]) :: opts() + defp run_opts_git_ssh_command([]), do: [] + + defp run_opts_git_ssh_command(key_paths) do + identity_flags = Enum.map_join(key_paths, " ", &"-i #{&1}") + [env: "GIT_SSH_COMMAND=ssh #{identity_flags} -o StrictHostKeyChecking=no"] + end + @spec run_opts_docker_outside_of_docker :: opts() defp run_opts_docker_outside_of_docker do docker_socket = "/var/run/docker.sock" @@ -108,9 +141,9 @@ defmodule Pix.Docker do [volume: "#{docker_socket}:#{docker_socket}"] end - @spec build(ssh_fwd? :: boolean(), opts(), String.t()) :: exit_status :: non_neg_integer() - def build(ssh_fwd?, opts, ctx) do - ssh_opts = if ssh_fwd?, do: [ssh: "default"], else: [] + @spec build(ssh_specs :: [String.t()], opts(), String.t()) :: exit_status :: non_neg_integer() + def build(ssh_specs, opts, ctx) do + ssh_opts = Enum.map(ssh_specs, fn spec -> {:ssh, spec} end) builder_opts = case buildx_builder() do diff --git a/lib/pix/pipeline.ex b/lib/pix/pipeline.ex index e1a680a..19b1d9f 100644 --- a/lib/pix/pipeline.ex +++ b/lib/pix/pipeline.ex @@ -8,7 +8,7 @@ defmodule Pix.Pipeline do | {:output, boolean()} | {:progress, String.t()} | {:secret, String.t()} - | {:ssh, boolean()} + | {:ssh, String.t()} | {:tag, String.t()} | {:target, String.t()} ] @@ -16,7 +16,8 @@ defmodule Pix.Pipeline do @type shell_cli_opts :: [ {:arg, String.t()} | {:host, boolean()} - | {:ssh, boolean()} + | {:secret, String.t()} + | {:ssh, String.t()} | {:target, String.t()} ] @@ -198,7 +199,9 @@ defmodule Pix.Pipeline do Pix.Report.info("\nRunning pipeline (targets: #{inspect(targets)})\n\n") build_opts = build_opts ++ [file: dockerfile_path] - Pix.Docker.build(cli_opts[:ssh], build_opts, ".") |> halt_on_error() + ssh_opts = Keyword.get_values(cli_opts, :ssh) + + Pix.Docker.build(ssh_opts, build_opts, ".") |> halt_on_error() if cli_opts[:output] do Pix.Report.info("\nExported pipeline outputs to #{output_dir()}:\n") @@ -216,7 +219,9 @@ defmodule Pix.Pipeline do build_opts = build_opts ++ [target: cli_opts[:target], file: dockerfile_path] build_opts = add_run_tag_option(build_opts, cli_opts) - Pix.Docker.build(cli_opts[:ssh], build_opts, ".") |> halt_on_error() + ssh_opts = Keyword.get_values(cli_opts, :ssh) + + Pix.Docker.build(ssh_opts, build_opts, ".") |> halt_on_error() :ok end @@ -281,7 +286,9 @@ defmodule Pix.Pipeline do defp execute_shell_build(build_opts, shell_target, cli_opts) do Pix.Report.info("\nBuilding pipeline (target=#{shell_target})\n\n") - Pix.Docker.build(cli_opts[:ssh], build_opts, ".") + ssh_opts = Keyword.get_values(cli_opts, :ssh) + + Pix.Docker.build(ssh_opts, build_opts, ".") |> halt_on_error() :ok @@ -292,9 +299,10 @@ defmodule Pix.Pipeline do Pix.Report.info("\nEntering shell\n") opts = shell_run_options(shell_target, from, cli_opts) + ssh_opts = Keyword.get_values(cli_opts, :ssh) shell_docker_image - |> Pix.Docker.run(cli_opts[:ssh], opts, cmd_args) + |> Pix.Docker.run(ssh_opts, opts, cmd_args) |> halt_on_error() :ok diff --git a/lib/pix/user_settings.ex b/lib/pix/user_settings.ex index 5dac148..1afaaae 100644 --- a/lib/pix/user_settings.ex +++ b/lib/pix/user_settings.ex @@ -16,12 +16,12 @@ defmodule Pix.UserSettings do command: %{ run: %{ cli_opts: [ - ssh: true + ssh: "default" ] }, shell: %{ cli_opts: [ - ssh: true + ssh: "default" ] } } diff --git a/shell_completions/pix.fish b/shell_completions/pix.fish index bd9c4eb..3c9f539 100644 --- a/shell_completions/pix.fish +++ b/shell_completions/pix.fish @@ -70,7 +70,7 @@ complete -c pix -n "__fish_seen_subcommand_from graph" -l "format" -d "Output fo # run command options complete -c pix -n "__fish_seen_subcommand_from run" -a "(__pix_get_pipelines)" -d "Pipeline" complete -c pix -n "__fish_seen_subcommand_from run" -l "output" -d "Output the target artifacts under .pipeline/output directory" -complete -c pix -n "__fish_seen_subcommand_from run" -l "ssh" -d "Forward SSH agent to buildx build" +complete -c pix -n "__fish_seen_subcommand_from run" -l "ssh" -d "Forward SSH agent/keys to buildx build (default, or id=path)" -rf complete -c pix -n "__fish_seen_subcommand_from run" -l "arg" -d "Set one or more pipeline ARG (format KEY=value)" -a "(__pix_get_run_target_args)" complete -c pix -n "__fish_seen_subcommand_from run" -l "progress" -d "Set type of progress output" -a "auto plain tty rawjson" complete -c pix -n "__fish_seen_subcommand_from run" -l "secret" -d "Forward one or more secrets to `buildx build`" @@ -81,7 +81,7 @@ complete -c pix -n "__fish_seen_subcommand_from run" -l "no-cache-filter" -d "Do # shell command options complete -c pix -n "__fish_seen_subcommand_from shell" -a "(__pix_get_pipelines)" -d "Pipeline" -complete -c pix -n "__fish_seen_subcommand_from shell" -l "ssh" -d "Forward SSH agent to shell container" +complete -c pix -n "__fish_seen_subcommand_from shell" -l "ssh" -d "Forward SSH agent/keys to shell container (default, or id=path)" -rf complete -c pix -n "__fish_seen_subcommand_from shell" -l "arg" -d "Set one or more pipeline ARG (format KEY=value)" complete -c pix -n "__fish_seen_subcommand_from shell" -l "secret" -d "Forward one or more secrets to `buildx build`" complete -c pix -n "__fish_seen_subcommand_from shell" -l "target" -d "The shell target"