Skip to content

Instantly share code, notes, and snippets.

@rxbynerd
Created February 22, 2026 11:25
Show Gist options
  • Select an option

  • Save rxbynerd/892159cee656906ad3bd7c6060bc72a7 to your computer and use it in GitHub Desktop.

Select an option

Save rxbynerd/892159cee656906ad3bd7c6060bc72a7 to your computer and use it in GitHub Desktop.
podman api over ssh in ruby (claude 4.6)
#!/usr/bin/env ruby
# frozen_string_literal: true
# Podman REST API client for macOS
#
# On macOS, Podman runs inside a Linux VM. The API socket lives inside that VM
# and is accessed by SSH-tunneling to a Unix socket:
#
# macOS client --SSH--> VM (127.0.0.1:PORT) ---> /run/user/UID/podman/podman.sock
#
# This script discovers the connection details from `podman system connection list`,
# opens an SSH channel to the remote Unix socket, and speaks HTTP over it.
require "json"
require "net/http"
require "net/ssh"
require "uri"
require "socket"
require "stringio"
# --- Connection discovery ---
# Parse `podman system connection list` to find the default connection.
# Returns a hash like:
# { uri: "ssh://core@127.0.0.1:63169/run/user/501/podman/podman.sock",
# identity: "/path/to/ssh/key" }
def discover_connection
raw = `podman system connection list --format json 2>/dev/null`
raise "podman CLI not found or not configured — run `podman machine init && podman machine start`" if raw.empty?
connections = JSON.parse(raw)
default = connections.find { |c| c["Default"] } || connections.first
raise "No podman connections configured" unless default
{ uri: default["URI"], identity: default["Identity"] }
end
# --- SSH-tunneled HTTP transport ---
# Sends a single HTTP request over an SSH direct-streamlocal channel.
# This is equivalent to the Rust client's `channel_open_direct_streamlocal`.
class PodmanClient
API_BASE = "/v5.1.0"
def initialize(uri: nil, identity_file: nil)
if uri.nil?
conn = discover_connection
uri = conn[:uri]
identity_file ||= conn[:identity]
end
parsed = URI.parse(uri)
@user = parsed.user || "core"
@host = parsed.host || "127.0.0.1"
@port = parsed.port || 22
@socket_path = parsed.path # e.g. /run/user/501/podman/podman.sock
@identity = identity_file
ssh_opts = { non_interactive: true }
ssh_opts[:keys] = [@identity] if @identity
@ssh = Net::SSH.start(@host, @user, **ssh_opts.merge(port: @port))
end
def close
@ssh.close
end
# --- Container operations (libpod API) ---
# List containers.
# all: true to include stopped containers
def list_containers(all: false)
params = all ? "?all=true" : ""
get("#{API_BASE}/libpod/containers/json#{params}")
end
# Create a container.
# image: image name (e.g. "docker.io/library/alpine:latest")
# name: optional container name
# command: optional command array (e.g. ["sleep", "3600"])
def create_container(image:, name: nil, command: nil)
body = { image: image }
body[:name] = name if name
body[:command] = command if command
post("#{API_BASE}/libpod/containers/create", body)
end
# Start a container by name or ID.
def start_container(name_or_id)
post("#{API_BASE}/libpod/containers/#{name_or_id}/start", nil)
end
# Stop a container by name or ID.
def stop_container(name_or_id)
post("#{API_BASE}/libpod/containers/#{name_or_id}/stop", nil)
end
# Delete a container by name or ID.
# force: true to kill a running container before deleting
def delete_container(name_or_id, force: false)
params = force ? "?force=true" : ""
delete("#{API_BASE}/libpod/containers/#{name_or_id}#{params}")
end
# Inspect a container by name or ID.
def inspect_container(name_or_id)
get("#{API_BASE}/libpod/containers/#{name_or_id}/json")
end
private
# Open a direct-streamlocal channel to the Podman socket inside the VM,
# send a raw HTTP request, and read the response.
def request(method, path, body = nil)
channel = @ssh.open_channel do |ch|
ch.send_channel_request("direct-streamlocal@openssh.com", :string, @socket_path,
:string, "", :long, 0)
end
@ssh.loop { !channel.active? }
# Build raw HTTP/1.1 request
req_lines = ["#{method} #{path} HTTP/1.1"]
req_lines << "Host: d" # Podman ignores the Host but HTTP requires it
req_lines << "Accept: application/json"
if body
json = body.to_json
req_lines << "Content-Type: application/json"
req_lines << "Content-Length: #{json.bytesize}"
req_lines << "Connection: close"
req_lines << ""
req_lines << json
else
req_lines << "Connection: close"
req_lines << ""
end
raw_request = req_lines.join("\r\n") + "\r\n"
response_buf = +""
channel.on_data { |_ch, data| response_buf << data }
channel.on_eof { channel.close }
channel.send_data(raw_request)
channel.eof!
@ssh.loop { channel.active? }
parse_http_response(response_buf)
end
def get(path) = request("GET", path)
def post(path, body) = request("POST", path, body)
def delete(path) = request("DELETE", path)
# Minimal HTTP response parser — extracts status code and JSON body.
def parse_http_response(raw)
header_end = raw.index("\r\n\r\n")
raise "Malformed HTTP response" unless header_end
header_section = raw[0...header_end]
body_section = raw[(header_end + 4)..]
status_line = header_section.lines.first
status_code = status_line[/HTTP\/\d\.\d (\d+)/, 1].to_i
# Handle chunked transfer encoding
if header_section.downcase.include?("transfer-encoding: chunked")
body_section = decode_chunked(body_section)
end
parsed_body = body_section.strip.empty? ? nil : JSON.parse(body_section.strip)
{ status: status_code, body: parsed_body }
rescue JSON::ParserError
{ status: status_code, body: body_section.strip }
end
def decode_chunked(data)
result = +""
io = StringIO.new(data)
loop do
size_line = io.gets
break unless size_line
size = size_line.strip.to_i(16)
break if size == 0
result << io.read(size)
io.gets # consume trailing \r\n
end
result
end
end
# --- Example usage ---
if __FILE__ == $PROGRAM_NAME
client = PodmanClient.new
puts "=== Listing containers ==="
result = client.list_containers(all: true)
puts JSON.pretty_generate(result[:body] || [])
puts "\n=== Creating a container ==="
result = client.create_container(
image: "docker.io/library/alpine:latest",
name: "ruby-test",
command: ["sleep", "3600"]
)
puts JSON.pretty_generate(result)
container_id = result.dig(:body, "Id")
if container_id
puts "\n=== Starting container ==="
puts client.start_container("ruby-test").inspect
puts "\n=== Inspecting container ==="
info = client.inspect_container("ruby-test")
puts "State: #{info.dig(:body, "State", "Status")}"
puts "\n=== Stopping container ==="
puts client.stop_container("ruby-test").inspect
puts "\n=== Deleting container ==="
puts client.delete_container("ruby-test", force: true).inspect
end
puts "\n=== Final container list ==="
result = client.list_containers(all: true)
puts JSON.pretty_generate(result[:body] || [])
client.close
end
@rxbynerd
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment