Created
February 22, 2026 11:25
-
-
Save rxbynerd/892159cee656906ad3bd7c6060bc72a7 to your computer and use it in GitHub Desktop.
podman api over ssh in ruby (claude 4.6)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://github.com/blazzy/podman-rest-client