add ssh support for build command

Fixes #705: Add support for ssh property in the build command

Signed-off-by: Domenico Salvatore <banditopazzo@gmail.com>
This commit is contained in:
banditopazzo 2024-10-11 17:38:01 +02:00
parent 7090de3bce
commit ab33954f6c
6 changed files with 298 additions and 0 deletions

View File

@ -0,0 +1 @@
Added support for "ssh" property in the build command.

View File

@ -2401,6 +2401,8 @@ async def build_one(compose, args, cnt):
build_args.extend([f"--build-context={additional_ctx}"])
if "target" in build_desc:
build_args.extend(["--target", build_desc["target"]])
for agent_or_key in norm_as_list(build_desc.get("ssh", {})):
build_args.extend(["--ssh", agent_or_key])
container_to_ulimit_build_args(cnt, build_args)
if getattr(args, "no_cache", None):
build_args.append("--no-cache")

View File

@ -0,0 +1,16 @@
# Base image
FROM alpine:latest
# Install OpenSSH client
RUN apk add openssh
# Test the SSH agents during the build
RUN echo -n "default: " >> /result.log
RUN --mount=type=ssh ssh-add -L >> /result.log
RUN echo -n "id1: " >> /result.log
RUN --mount=type=ssh,id=id1 ssh-add -L >> /result.log
RUN echo -n "id2: " >> /result.log
RUN --mount=type=ssh,id=id2 ssh-add -L >> /result.log

View File

@ -0,0 +1,26 @@
version: "3"
services:
test_build_ssh_map:
build:
context: ./context
dockerfile: Dockerfile
ssh:
default:
id1: "./id_ed25519_dummy"
id2: "./agent_dummy.sock"
image: my-alpine-build-ssh-map
command:
- cat
- /result.log
test_build_ssh_array:
build:
context: ./context
dockerfile: Dockerfile
ssh:
- default
- "id1=./id_ed25519_dummy"
- "id2=./agent_dummy.sock"
image: my-alpine-build-ssh-array
command:
- cat
- /result.log

View File

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h
EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg
AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa
pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----

View File

@ -0,0 +1,246 @@
# SPDX-License-Identifier: GPL-2.0
import os
import socket
import struct
import threading
import unittest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from tests.integration.test_podman_compose import podman_compose_path
from tests.integration.test_podman_compose import test_path
from tests.integration.test_utils import RunSubprocessMixin
expected_lines = [
"default: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
"id1: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
"id2: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
]
class TestBuildSsh(unittest.TestCase, RunSubprocessMixin):
def test_build_ssh(self):
"""The build context can contain the ssh authentications that the image builder should
use during image build. They can be either an array or a map.
"""
compose_path = os.path.join(test_path(), "build_ssh/docker-compose.yml")
sock_path = os.path.join(test_path(), "build_ssh/agent_dummy.sock")
private_key_file = os.path.join(test_path(), "build_ssh/id_ed25519_dummy")
agent = MockSSHAgent(private_key_file)
try:
# Set SSH_AUTH_SOCK because `default` expects it
os.environ['SSH_AUTH_SOCK'] = sock_path
# Start a mock SSH agent server
agent.start_agent(sock_path)
self.run_subprocess_assert_returncode([
podman_compose_path(),
"-f",
compose_path,
"build",
"test_build_ssh_map",
"test_build_ssh_array",
])
for test_image in [
"test_build_ssh_map",
"test_build_ssh_array",
]:
out, _ = self.run_subprocess_assert_returncode([
podman_compose_path(),
"-f",
compose_path,
"run",
"--rm",
test_image,
])
out = out.decode('utf-8')
# Check if all lines are contained in the output
self.assertTrue(
all(line in out for line in expected_lines),
f"Incorrect output for image {test_image}",
)
finally:
# Now we send the stop command to gracefully shut down the server
agent.stop_agent()
if os.path.exists(sock_path):
os.remove(sock_path)
self.run_subprocess_assert_returncode([
"podman",
"rmi",
"my-alpine-build-ssh-map",
"my-alpine-build-ssh-array",
])
# SSH agent message types
SSH_AGENTC_REQUEST_IDENTITIES = 11
SSH_AGENT_IDENTITIES_ANSWER = 12
SSH_AGENT_FAILURE = 5
STOP_REQUEST = 0xFF
class MockSSHAgent:
def __init__(self, private_key_path):
self.sock_path = None
self.server_sock = None
self.running = threading.Event()
self.keys = [self._load_ed25519_private_key(private_key_path)]
self.agent_thread = None # Thread to run the agent
def _load_ed25519_private_key(self, private_key_path):
"""Load ED25519 private key from an OpenSSH private key file."""
with open(private_key_path, 'rb') as key_file:
private_key = serialization.load_ssh_private_key(key_file.read(), password=None)
# Ensure it's an Ed25519 key
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("Invalid key type, expected ED25519 private key.")
# Get the public key corresponding to the private key
public_key = private_key.public_key()
# Serialize the public key to the OpenSSH format
public_key_blob = public_key.public_bytes(
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
)
# SSH key type "ssh-ed25519"
key_type = b"ssh-ed25519"
# Build the key blob (public key part for the agent)
key_blob_full = (
struct.pack(">I", len(key_type))
+ key_type # Key type length + type
+ struct.pack(">I", len(public_key_blob))
+ public_key_blob # Public key length + key blob
)
# Comment (empty)
comment = ""
return ("ssh-ed25519", key_blob_full, comment)
def start_agent(self, sock_path):
"""Start the mock SSH agent and create a Unix domain socket."""
self.sock_path = sock_path
if os.path.exists(self.sock_path):
os.remove(self.sock_path)
self.server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.server_sock.bind(self.sock_path)
self.server_sock.listen(5)
os.environ['SSH_AUTH_SOCK'] = self.sock_path
self.running.set() # Set the running event
# Start a thread to accept client connections
self.agent_thread = threading.Thread(target=self._accept_connections, daemon=True)
self.agent_thread.start()
def _accept_connections(self):
"""Accept and handle incoming connections."""
while self.running.is_set():
try:
client_sock, _ = self.server_sock.accept()
self._handle_client(client_sock)
except Exception as e:
print(f"Error accepting connection: {e}")
def _handle_client(self, client_sock):
"""Handle a single client request (like ssh-add)."""
try:
# Read the message length (first 4 bytes)
length_message = client_sock.recv(4)
if not length_message:
raise "no length message received"
msg_len = struct.unpack(">I", length_message)[0]
request_message = client_sock.recv(msg_len)
# Check for STOP_REQUEST
if request_message[0] == STOP_REQUEST:
client_sock.close()
self.running.clear() # Stop accepting connections
return
# Check for SSH_AGENTC_REQUEST_IDENTITIES
if request_message[0] == SSH_AGENTC_REQUEST_IDENTITIES:
response = self._mock_list_keys_response()
client_sock.sendall(response)
else:
print("Message not recognized")
# Send failure if the message type is not recognized
response = struct.pack(">I", 1) + struct.pack(">B", SSH_AGENT_FAILURE)
client_sock.sendall(response)
except socket.error:
print("Client socket error.")
pass # You can handle specific errors here if needed
finally:
client_sock.close() # Ensure the client socket is closed
def _mock_list_keys_response(self):
"""Create a mock response for ssh-add -l, listing keys."""
# Start building the response
response = struct.pack(">B", SSH_AGENT_IDENTITIES_ANSWER) # Message type
# Number of keys
response += struct.pack(">I", len(self.keys))
# For each key, append key blob and comment
for key_type, key_blob, comment in self.keys:
# Key blob length and content
response += struct.pack(">I", len(key_blob)) + key_blob
# Comment length and content
comment_encoded = comment.encode()
response += struct.pack(">I", len(comment_encoded)) + comment_encoded
# Prefix the entire response with the total message length
response = struct.pack(">I", len(response)) + response
return response
def stop_agent(self):
"""Stop the mock SSH agent."""
if self.running.is_set(): # First check if the agent is running
# Create a temporary connection to send the stop command
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_sock:
client_sock.connect(self.sock_path) # Connect to the server
stop_command = struct.pack(
">B", STOP_REQUEST
) # Pack the stop command as a single byte
# Send the message length first
message_length = struct.pack(">I", len(stop_command))
client_sock.sendall(message_length) # Send the length first
client_sock.sendall(stop_command) # Send the stop command
self.running.clear() # Stop accepting new connections
# Wait for the agent thread to finish
if self.agent_thread:
self.agent_thread.join() # Wait for the thread to finish
self.agent_thread = None # Reset thread reference
# Remove the socket file only after the server socket is closed
if self.server_sock: # Check if the server socket exists
self.server_sock.close() # Close the server socket
os.remove(self.sock_path)