File size: 9,490 Bytes
3b70664 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | (in-package #:nekod.docker)
;;; Incremental HTTP/1.1 parser for Docker Engine API over Unix socket
(defvar *max-header-size* (* 64 1024))
(defvar *connection-policy* :close)
(defstruct http-response
(version "" :type string)
(status 0 :type integer)
(reason "" :type string)
(headers nil :type list)
(body #() :type (vector (unsigned-byte 8))))
(defstruct http-parser-state
(buffer (make-array 4096 :element-type '(unsigned-byte 8)
:adjustable t :fill-pointer 0))
(header-complete nil :type boolean)
(content-length nil)
(chunked nil :type boolean)
(body-bytes-read 0 :type integer)
(complete nil :type boolean))
;;; --- HTTP request building ---
(defun build-http-request (method path &optional body)
"Build an HTTP/1.1 request string for the Docker API."
(let* ((body-octets (if body
(nekod.docker::string-to-octets body)
#()))
(content-length (length body-octets))
(connection (if (eq *connection-policy* :keep-alive) "keep-alive" "close")))
(format nil "~a ~a HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ~a\r\nConnection: ~a\r\n\r\n~@[~a~]"
method path content-length connection body)))
;;; --- Incremental HTTP response parsing ---
(defun parse-status-line (line)
"Parse 'HTTP/1.1 200 OK' into (values version status reason)."
(let* ((sp1 (position #\Space line))
(sp2 (and sp1 (position #\Space line :start (1+ sp1)))))
(unless (and sp1 sp2)
(error 'nekod:http-protocol-error
:message (format nil "Malformed status line: ~a" line)))
(values (subseq line 0 sp1)
(parse-integer (subseq line (1+ sp1) sp2))
(subseq line (1+ sp2)))))
(defun parse-http-headers (header-text)
"Parse header block into alist of (name . value)."
(let ((headers nil)
(lines (remove "" (mapcar (lambda (l) (string-trim '(#\Return) l))
(uiop:split-string header-text :separator '(#\Newline)))
:test #'string=)))
(dolist (line lines)
(let ((colon (position #\: line)))
(when colon
(push (cons (string-downcase (subseq line 0 colon))
(string-trim '(#\Space) (subseq line (1+ colon))))
headers))))
(nreverse headers)))
(defun header-value (headers name)
"Get header value by lowercase name from alist."
(cdr (assoc name headers :test #'string=)))
(defun decode-chunk-size (line)
"Decode hex chunk size from a chunk header line."
(parse-integer (string-trim '(#\Return #\Newline #\Space) line)
:radix 16 :junk-allowed t))
;;; --- Body reading strategies ---
(defun read-content-length-body (sock content-length)
"Read exactly content-length bytes from socket."
(let ((body (make-array content-length :element-type '(unsigned-byte 8)))
(total-read 0))
(loop while (< total-read content-length)
do (let ((n (socket-read-octets sock body
:start total-read
:end content-length)))
(when (<= n total-read)
(error 'nekod:http-body-truncated
:message "Connection closed before body complete"
:expected content-length
:received total-read))
(setf total-read n)))
body))
(defun read-chunked-body (sock)
"Read chunked transfer-encoding body incrementally."
(let ((result (make-array 0 :element-type '(unsigned-byte 8)
:adjustable t :fill-pointer 0)))
(loop
(let* ((size-line-octets (socket-read-until sock (format nil "~c~c" #\Return #\Newline)))
(size-line (nekod.docker::octets-to-string size-line-octets :external-format :utf-8))
(chunk-size (decode-chunk-size size-line)))
(when (or (null chunk-size) (zerop chunk-size))
(socket-read-until sock (format nil "~c~c" #\Return #\Newline))
(return result))
(let ((chunk (make-array chunk-size :element-type '(unsigned-byte 8)))
(read-so-far 0))
(loop while (< read-so-far chunk-size)
do (let ((n (socket-read-octets sock chunk
:start read-so-far
:end chunk-size)))
(when (<= n read-so-far)
(error 'nekod:http-body-truncated
:message "Chunk truncated"
:expected chunk-size
:received read-so-far))
(setf read-so-far n)))
(loop for b across chunk do (vector-push-extend b result))
(socket-read-until sock (format nil "~c~c" #\Return #\Newline)))))
result))
;;; --- Full response reader ---
(defun read-http-response (sock)
"Read a complete HTTP response from socket. Handles fragmented reads."
(let* ((header-octets (socket-read-until sock (format nil "~c~c~c~c"
#\Return #\Newline
#\Return #\Newline)
:max-bytes *max-header-size*))
(header-text (nekod.docker::octets-to-string header-octets :external-format :utf-8))
(header-end (search (format nil "~c~c~c~c" #\Return #\Newline #\Return #\Newline)
header-text))
(status-end (position #\Newline header-text))
(status-line (subseq header-text 0 (and status-end
(if (and status-end
(> status-end 0)
(char= (char header-text (1- status-end)) #\Return))
(1- status-end)
status-end))))
(header-block (if header-end
(subseq header-text (1+ status-end) header-end)
"")))
(multiple-value-bind (version status reason) (parse-status-line status-line)
(let* ((headers (parse-http-headers header-block))
(cl-val (header-value headers "content-length"))
(te-val (header-value headers "transfer-encoding"))
(content-length (and cl-val (parse-integer cl-val :junk-allowed t)))
(chunked-p (and te-val (search "chunked" te-val)))
(body (cond
((member status '(204 304 101))
(make-array 0 :element-type '(unsigned-byte 8)))
(chunked-p
(read-chunked-body sock))
(content-length
(read-content-length-body sock content-length))
(t
(make-array 0 :element-type '(unsigned-byte 8))))))
(make-http-response :version version
:status status
:reason reason
:headers headers
:body body)))))
;;; --- Convenience API (backward compatible) ---
(defun parse-http-response (raw)
"Legacy: Parse HTTP response string. Extract status code and body."
(let* ((header-end (search (format nil "~c~c~c~c" #\Return #\Newline #\Return #\Newline) raw))
(status-line (subseq raw 0 (position #\Return raw)))
(sp1 (position #\Space status-line))
(sp2 (and sp1 (position #\Space status-line :start (1+ sp1))))
(status-code (and sp1 sp2 (parse-integer (subseq status-line (1+ sp1) sp2)
:junk-allowed t)))
(body (if header-end (subseq raw (+ header-end 4)) "")))
(values (or status-code 0) body)))
(defun docker-request (sock method path &optional body)
"Send HTTP request and read full response via real socket transport.
Returns http-response struct."
(let ((req (build-http-request method path body)))
(socket-write sock req)
(read-http-response sock)))
(defun docker-get (sock path)
"GET request to Docker API. Returns (values status body-string)."
(let ((resp (docker-request sock "GET" path)))
(values (http-response-status resp)
(nekod.docker::octets-to-string (http-response-body resp)
:external-format :utf-8))))
(defun docker-post (sock path &optional body)
"POST request to Docker API. Returns (values status body-string)."
(let ((resp (docker-request sock "POST" path body)))
(values (http-response-status resp)
(nekod.docker::octets-to-string (http-response-body resp)
:external-format :utf-8))))
(defun docker-delete (sock path)
"DELETE request to Docker API. Returns (values status body-string)."
(let ((resp (docker-request sock "DELETE" path)))
(values (http-response-status resp)
(nekod.docker::octets-to-string (http-response-body resp)
:external-format :utf-8))))
|