Merge pull request #19696 from Luap99/api-stream-format

api docs: document stream format
This commit is contained in:
OpenShift Merge Robot 2023-08-28 19:43:24 +02:00 committed by GitHub
commit 8bda49608f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 351 additions and 194 deletions

View File

@ -608,7 +608,7 @@ func (r *ConmonOCIRuntime) HTTPAttach(ctr *Container, req *http.Request, w http.
hijackDone <- true
writeHijackHeader(req, httpBuf)
writeHijackHeader(req, httpBuf, isTerminal)
// Force a flush after the header is written.
if err := httpBuf.Flush(); err != nil {

View File

@ -569,7 +569,7 @@ func attachExecHTTP(c *Container, sessionID string, r *http.Request, w http.Resp
hijackDone <- true
// Write a header to let the client know what happened
writeHijackHeader(r, httpBuf)
writeHijackHeader(r, httpBuf, isTerminal)
// Force a flush after the header is written.
if err := httpBuf.Flush(); err != nil {

View File

@ -15,6 +15,7 @@ import (
"github.com/containers/common/libnetwork/types"
"github.com/containers/common/pkg/config"
"github.com/containers/podman/v4/libpod/define"
"github.com/containers/podman/v4/pkg/api/handlers/utils/apiutil"
spec "github.com/opencontainers/runtime-spec/specs-go"
"github.com/opencontainers/selinux/go-selinux/label"
"github.com/sirupsen/logrus"
@ -182,22 +183,36 @@ func makeHTTPAttachHeader(stream byte, length uint32) []byte {
// writeHijackHeader writes a header appropriate for the type of HTTP Hijack
// that occurred in a hijacked HTTP connection used for attach.
func writeHijackHeader(r *http.Request, conn io.Writer) {
func writeHijackHeader(r *http.Request, conn io.Writer, tty bool) {
// AttachHeader is the literal header sent for upgraded/hijacked connections for
// attach, sourced from Docker at:
// https://raw.githubusercontent.com/moby/moby/b95fad8e51bd064be4f4e58a996924f343846c85/api/server/router/container/container_routes.go
// Using literally to ensure compatibility with existing clients.
// New docker API uses a different header for the non tty case.
// Lets do the same for libpod. Only do this for the new api versions to not break older clients.
header := "application/vnd.docker.raw-stream"
if !tty {
version := "4.7.0"
if !apiutil.IsLibpodRequest(r) {
version = "1.42.0" // docker only used two digest "1.42" but our semver lib needs the extra .0 to work
}
if _, err := apiutil.SupportedVersion(r, ">= "+version); err == nil {
header = "application/vnd.docker.multiplexed-stream"
}
}
c := r.Header.Get("Connection")
proto := r.Header.Get("Upgrade")
if len(proto) == 0 || !strings.EqualFold(c, "Upgrade") {
// OK - can't upgrade if not requested or protocol is not specified
fmt.Fprintf(conn,
"HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n")
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\n\r\n", header)
} else {
// Upgraded
fmt.Fprintf(conn,
"HTTP/1.1 101 UPGRADED\r\nContent-Type: application/vnd.docker.raw-stream\r\nConnection: Upgrade\r\nUpgrade: %s\r\n\r\n",
proto)
"HTTP/1.1 101 UPGRADED\r\nContent-Type: %s\r\nConnection: Upgrade\r\nUpgrade: %s\r\n\r\n",
proto, header)
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/containers/podman/v4/libpod"
"github.com/containers/podman/v4/pkg/api/handlers"
"github.com/containers/podman/v4/pkg/api/handlers/utils"
"github.com/containers/podman/v4/pkg/api/handlers/utils/apiutil"
api "github.com/containers/podman/v4/pkg/api/types"
"github.com/containers/podman/v4/pkg/auth"
"github.com/containers/podman/v4/pkg/channel"
@ -80,7 +81,7 @@ func ManifestCreate(w http.ResponseWriter, r *http.Request) {
}
status := http.StatusOK
if _, err := utils.SupportedVersion(r, "< 4.0.0"); err == utils.ErrVersionNotSupported {
if _, err := utils.SupportedVersion(r, "< 4.0.0"); err == apiutil.ErrVersionNotSupported {
status = http.StatusCreated
}

View File

@ -0,0 +1,69 @@
package apiutil
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/blang/semver/v4"
"github.com/containers/podman/v4/version"
"github.com/gorilla/mux"
)
var (
// ErrVersionNotGiven returned when version not given by client
ErrVersionNotGiven = errors.New("version not given in URL path")
// ErrVersionNotSupported returned when given version is too old
ErrVersionNotSupported = errors.New("given version is not supported")
)
// IsLibpodRequest returns true if the request related to a libpod endpoint
// (e.g., /v2/libpod/...).
func IsLibpodRequest(r *http.Request) bool {
split := strings.Split(r.URL.String(), "/")
return len(split) >= 3 && split[2] == "libpod"
}
// SupportedVersion validates that the version provided by client is included in the given condition
// https://github.com/blang/semver#ranges provides the details for writing conditions
// If a version is not given in URL path, ErrVersionNotGiven is returned
func SupportedVersion(r *http.Request, condition string) (semver.Version, error) {
version := semver.Version{}
val, ok := mux.Vars(r)["version"]
if !ok {
return version, ErrVersionNotGiven
}
safeVal, err := url.PathUnescape(val)
if err != nil {
return version, fmt.Errorf("unable to unescape given API version: %q: %w", val, err)
}
version, err = semver.ParseTolerant(safeVal)
if err != nil {
return version, fmt.Errorf("unable to parse given API version: %q from %q: %w", safeVal, val, err)
}
inRange, err := semver.ParseRange(condition)
if err != nil {
return version, err
}
if inRange(version) {
return version, nil
}
return version, ErrVersionNotSupported
}
// SupportedVersionWithDefaults validates that the version provided by client valid is supported by server
// minimal API version <= client path version <= maximum API version focused on the endpoint tree from URL
func SupportedVersionWithDefaults(r *http.Request) (semver.Version, error) {
tree := version.Compat
if IsLibpodRequest(r) {
tree = version.Libpod
}
return SupportedVersion(r,
fmt.Sprintf(">=%s <=%s", version.APIVersion[tree][version.MinimalAPI].String(),
version.APIVersion[tree][version.CurrentAPI].String()))
}

View File

@ -0,0 +1,140 @@
package apiutil
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/containers/podman/v4/version"
"github.com/gorilla/mux"
)
func TestSupportedVersion(t *testing.T) {
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version.APIVersion[version.Libpod][version.CurrentAPI]),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version.APIVersion[version.Libpod][version.CurrentAPI].String()})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersionWithDefaults(r)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body is what we expect.
expected := `OK`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}
func TestUnsupportedVersion(t *testing.T) {
version := "999.999.999"
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersionWithDefaults(r)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusBadRequest)
}
// Check the response body is what we expect.
expected := ErrVersionNotSupported.Error()
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}
func TestEqualVersion(t *testing.T) {
version := "1.30.0"
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersion(r, "=="+version)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body is what we expect.
expected := http.StatusText(http.StatusOK)
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}

View File

@ -1,79 +1,34 @@
package utils
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"unsafe"
"github.com/blang/semver/v4"
"github.com/containers/podman/v4/version"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
jsoniter "github.com/json-iterator/go"
"github.com/sirupsen/logrus"
"github.com/containers/podman/v4/pkg/api/handlers/utils/apiutil"
api "github.com/containers/podman/v4/pkg/api/types"
)
var (
// ErrVersionNotGiven returned when version not given by client
ErrVersionNotGiven = errors.New("version not given in URL path")
// ErrVersionNotSupported returned when given version is too old
ErrVersionNotSupported = errors.New("given version is not supported")
)
// IsLibpodRequest returns true if the request related to a libpod endpoint
// (e.g., /v2/libpod/...).
func IsLibpodRequest(r *http.Request) bool {
split := strings.Split(r.URL.String(), "/")
return len(split) >= 3 && split[2] == "libpod"
return apiutil.IsLibpodRequest(r)
}
// SupportedVersion validates that the version provided by client is included in the given condition
// https://github.com/blang/semver#ranges provides the details for writing conditions
// If a version is not given in URL path, ErrVersionNotGiven is returned
func SupportedVersion(r *http.Request, condition string) (semver.Version, error) {
version := semver.Version{}
val, ok := mux.Vars(r)["version"]
if !ok {
return version, ErrVersionNotGiven
}
safeVal, err := url.PathUnescape(val)
if err != nil {
return version, fmt.Errorf("unable to unescape given API version: %q: %w", val, err)
}
version, err = semver.ParseTolerant(safeVal)
if err != nil {
return version, fmt.Errorf("unable to parse given API version: %q from %q: %w", safeVal, val, err)
}
inRange, err := semver.ParseRange(condition)
if err != nil {
return version, err
}
if inRange(version) {
return version, nil
}
return version, ErrVersionNotSupported
}
// SupportedVersionWithDefaults validates that the version provided by client valid is supported by server
// minimal API version <= client path version <= maximum API version focused on the endpoint tree from URL
func SupportedVersionWithDefaults(r *http.Request) (semver.Version, error) {
tree := version.Compat
if IsLibpodRequest(r) {
tree = version.Libpod
}
return SupportedVersion(r,
fmt.Sprintf(">=%s <=%s", version.APIVersion[tree][version.MinimalAPI].String(),
version.APIVersion[tree][version.CurrentAPI].String()))
return apiutil.SupportedVersion(r, condition)
}
// WriteResponse encodes the given value as JSON or string and renders it for http client

View File

@ -1,144 +1,9 @@
package utils
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/containers/podman/v4/version"
"github.com/gorilla/mux"
)
func TestSupportedVersion(t *testing.T) {
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version.APIVersion[version.Libpod][version.CurrentAPI]),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version.APIVersion[version.Libpod][version.CurrentAPI].String()})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersionWithDefaults(r)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body is what we expect.
expected := `OK`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}
func TestUnsupportedVersion(t *testing.T) {
version := "999.999.999"
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersionWithDefaults(r)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusBadRequest)
}
// Check the response body is what we expect.
expected := ErrVersionNotSupported.Error()
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}
func TestEqualVersion(t *testing.T) {
version := "1.30.0"
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("/v%s/libpod/testing/versions", version),
nil)
if err != nil {
t.Fatal(err)
}
req = mux.SetURLVars(req, map[string]string{"version": version})
rr := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := SupportedVersion(r, "=="+version)
switch {
case errors.Is(err, ErrVersionNotGiven): // for compat endpoints version optional
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
case errors.Is(err, ErrVersionNotSupported): // version given but not supported
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, err.Error())
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, err.Error())
default: // all good
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}
})
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body is what we expect.
expected := http.StatusText(http.StatusOK)
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %q want %q",
rr.Body.String(), expected)
}
}
func TestErrorEncoderFuncOmit(t *testing.T) {
data, err := json.Marshal(struct {
Err error `json:"err,omitempty"`

View File

@ -526,7 +526,12 @@ func (s *APIServer) registerContainersHandlers(r *mux.Router) error {
// tags:
// - containers (compat)
// summary: Attach to a container
// description: Hijacks the connection to forward the container's standard streams to the client.
// description: |
// Attach to a container to read its output or send it input. You can attach
// to the same container multiple times and you can reattach to containers
// that have been detached.
//
// It uses the same stream format as docker, see the libpod attach endpoint for a description of the format.
// parameters:
// - in: path
// name: name
@ -964,7 +969,10 @@ func (s *APIServer) registerContainersHandlers(r *mux.Router) error {
// tags:
// - containers
// summary: Get container logs
// description: Get stdout and stderr logs from a container.
// description: |
// Get stdout and stderr logs from a container.
//
// The stream format is the same as described in the attach endpoint.
// parameters:
// - in: path
// name: name
@ -1319,7 +1327,94 @@ func (s *APIServer) registerContainersHandlers(r *mux.Router) error {
// tags:
// - containers
// summary: Attach to a container
// description: Hijacks the connection to forward the container's standard streams to the client.
// description: |
// Attach to a container to read its output or send it input. You can attach
// to the same container multiple times and you can reattach to containers
// that have been detached.
//
// ### Hijacking
//
// This endpoint hijacks the HTTP connection to transport `stdin`, `stdout`,
// and `stderr` on the same socket.
//
// This is the response from the service for an attach request:
//
// ```
// HTTP/1.1 200 OK
// Content-Type: application/vnd.docker.raw-stream
//
// [STREAM]
// ```
//
// After the headers and two new lines, the TCP connection can now be used
// for raw, bidirectional communication between the client and server.
//
// To inform potential proxies about connection hijacking, the client
// can also optionally send connection upgrade headers.
//
// For example, the client sends this request to upgrade the connection:
//
// ```
// POST /v4.6.0/libpod/containers/16253994b7c4/attach?stream=1&stdout=1 HTTP/1.1
// Upgrade: tcp
// Connection: Upgrade
// ```
//
// The service will respond with a `101 UPGRADED` response, and will
// similarly follow with the raw stream:
//
// ```
// HTTP/1.1 101 UPGRADED
// Content-Type: application/vnd.docker.raw-stream
// Connection: Upgrade
// Upgrade: tcp
//
// [STREAM]
// ```
//
// ### Stream format
//
// When the TTY setting is disabled for the container,
// the HTTP Content-Type header is set to application/vnd.docker.multiplexed-stream
// (starting with v4.7.0, previously application/vnd.docker.raw-stream was always used)
// and the stream over the hijacked connected is multiplexed to separate out
// `stdout` and `stderr`. The stream consists of a series of frames, each
// containing a header and a payload.
//
// The header contains the information about the output stream type and the size of
// the payload.
// It is encoded on the first eight bytes like this:
//
// ```go
// header := [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4}
// ```
//
// `STREAM_TYPE` can be:
//
// - 0: `stdin` (is written on `stdout`)
// - 1: `stdout`
// - 2: `stderr`
//
// `SIZE1, SIZE2, SIZE3, SIZE4` are the four bytes of the `uint32` size
// encoded as big endian.
//
// Following the header is the payload, which contains the specified number of
// bytes as written in the size.
//
// The simplest way to implement this protocol is the following:
//
// 1. Read 8 bytes.
// 2. Choose `stdout` or `stderr` depending on the first byte.
// 3. Extract the frame size from the last four bytes.
// 4. Read the extracted size and output it on the correct output.
// 5. Goto 1.
//
// ### Stream format when using a TTY
//
// When the TTY setting is enabled for the container,
// the stream is not multiplexed. The data exchanged over the hijacked
// connection is simply the raw data from the process PTY and client's
// `stdin`.
// parameters:
// - in: path
// name: name

View File

@ -254,7 +254,9 @@ func (s *APIServer) registerExecHandlers(r *mux.Router) error {
// tags:
// - exec
// summary: Start an exec instance
// description: Starts a previously set up exec instance. If detach is true, this endpoint returns immediately after starting the command. Otherwise, it sets up an interactive session with the command.
// description: |
// Starts a previously set up exec instance. If detach is true, this endpoint returns immediately after starting the command.
// Otherwise, it sets up an interactive session with the command. The stream format is the same as the attach endpoint.
// parameters:
// - in: path
// name: id

View File

@ -30,6 +30,21 @@ podman run --rm -d --replace --name foo $IMAGE sh -c "echo $mytext;sleep 42"
# Looks like it is missing the required 0 bytes from the message, why?
t POST "containers/foo/attach?logs=true&stream=false" 200 \
$'\001\031'$mytext
# check old docker header
response_headers=$(cat "$WORKDIR/curl.headers.out")
like "$response_headers" ".*Content-Type: application/vnd\.docker\.raw-stream.*" "vnd.docker.raw-stream docker v1.40"
# check new vnd.docker.multiplexed-stream header
t POST "/v1.42/containers/foo/attach?logs=true&stream=false" 200
response_headers=$(cat "$WORKDIR/curl.headers.out")
like "$response_headers" ".*Content-Type: application/vnd\.docker\.multiplexed-stream.*" "vnd.docker.multiplexed-stream docker v1.42"
t POST "/v4.6.0/libpod/containers/foo/attach?logs=true&stream=false" 200
response_headers=$(cat "$WORKDIR/curl.headers.out")
like "$response_headers" ".*Content-Type: application/vnd\.docker\.raw-stream.*" "vnd.docker.raw-stream libpod v4.6.0"
t POST "/v4.7.0/libpod/containers/foo/attach?logs=true&stream=false" 200
response_headers=$(cat "$WORKDIR/curl.headers.out")
like "$response_headers" ".*Content-Type: application/vnd\.docker\.multiplexed-stream.*" "vnd.docker.multiplexed-stream libpod v4.7.0"
t POST "containers/foo/kill" 204
podman run --replace --name=foo -v /tmp:/tmp $IMAGE true