Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for ssh property in the build command #1058

Merged
merged 1 commit into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions newsfragments/build-ssh.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for "ssh" property in the build command.
2 changes: 2 additions & 0 deletions podman_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/build_ssh/context/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions tests/integration/build_ssh/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions tests/integration/build_ssh/id_ed25519_dummy
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h
EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg
AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa
pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----
246 changes: 246 additions & 0 deletions tests/integration/build_ssh/test_build_ssh.py
Original file line number Diff line number Diff line change
@@ -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",
])

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make sense to produce some output that shows that information was really acquired from the agent and then assert it in the test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean not having the mock agent at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, got it, I found a solution to check the key

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for not being explicit, but your solution was pretty similar to what I was thinking about.

A more simplier solution would be to do:

RUN echo "No id:" >> /result.log 
RUN --mount=type=ssh ssh-add -L >> /result.log

RUN echo "id1:" >> /result.log 
RUN --mount=type=ssh,id=id1 ssh-add -L >> /result.log

RUN echo "id2:" >> /result.log 
RUN --mount=type=ssh,id=id2 ssh-add -L >> /result.log

Then in docker compose set command to cat /result.log and then check the logs in the test via podman compose logs (there are a few tests that do this to use for reference).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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)