Skip to content

Commit 6b10bea

Browse files
authored
Merge pull request #28109 from simonbrauner/issue-22105
Make libpod return error status code on failure to pull image
2 parents daadda8 + 76095db commit 6b10bea

File tree

6 files changed

+159
-60
lines changed

6 files changed

+159
-60
lines changed

pkg/api/handlers/libpod/images_pull.go

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import (
2323
"go.podman.io/image/v5/types"
2424
)
2525

26+
// The duration for which we are willing to wait before starting the stream, to be able to decide the HTTP status code more accurately.
27+
const maximalStreamingDelay = 5 * time.Second
28+
2629
// ImagesPull is the v2 libpod endpoint for pulling images. Note that the
2730
// mandatory `reference` must be a reference to a registry (i.e., of docker
2831
// transport or be normalized to one). Other transports are rejected as they
@@ -116,10 +119,12 @@ func ImagesPull(w http.ResponseWriter, r *http.Request) {
116119
// Let's keep thing simple when running in quiet mode and pull directly.
117120
if query.Quiet {
118121
images, err := runtime.LibimageRuntime().Pull(r.Context(), query.Reference, pullPolicy, pullOptions)
119-
var report entities.ImagePullReport
120122
if err != nil {
121-
report.Error = err.Error()
123+
utils.Error(w, utils.HTTPStatusFromRegistryError(err), err)
124+
return
122125
}
126+
127+
var report entities.ImagePullReport
123128
for _, image := range images {
124129
report.Images = append(report.Images, image.ID())
125130
// Pull last ID from list and publish in 'id' stanza. This maintains previous API contract
@@ -138,6 +143,9 @@ func ImagesPull(w http.ResponseWriter, r *http.Request) {
138143
defer writer.Close()
139144
pullOptions.Writer = writer
140145

146+
progress := make(chan types.ProgressProperties)
147+
pullOptions.Progress = progress
148+
141149
var pulledImages []*libimage.Image
142150
var pullError error
143151
runCtx, cancel := context.WithCancel(r.Context())
@@ -152,22 +160,58 @@ func ImagesPull(w http.ResponseWriter, r *http.Request) {
152160
}
153161
}
154162

155-
w.Header().Set("Content-Type", "application/json")
156-
w.WriteHeader(http.StatusOK)
157-
flush()
158-
159163
enc := json.NewEncoder(w)
160164
enc.SetEscapeHTML(true)
165+
166+
streamingStarted := false
167+
var reportBuffer []entities.ImagePullReport
168+
169+
// This timer ensures that streaming is not delayed indefinitely.
170+
streamingDelayTimer := time.NewTimer(maximalStreamingDelay)
171+
172+
// Streaming is delayed to choose the HTTP status code more accurately (e.g. if the image does not exist at all).
173+
// Once a message is streamed, it is no longer possible to change the status code.
174+
startStreaming := func() {
175+
if !streamingStarted {
176+
w.Header().Set("Content-Type", "application/json")
177+
w.WriteHeader(http.StatusOK)
178+
179+
for _, report := range reportBuffer {
180+
if err := enc.Encode(report); err != nil {
181+
logrus.Warnf("Failed to encode json: %v", err)
182+
}
183+
flush()
184+
}
185+
streamingStarted = true
186+
}
187+
}
188+
161189
for {
162-
var report entities.ImagePullReport
163190
select {
191+
case <-progress:
192+
startStreaming() // We are reporting progress working with the image contents, so it presumably exists and it is accessible.
193+
case <-streamingDelayTimer.C:
194+
startStreaming() // At some point, give up on the precise error code and let the caller show an “operation in progress, no data available yet” UI.
164195
case s := <-writer.Chan():
165-
report.Stream = string(s)
166-
if err := enc.Encode(report); err != nil {
167-
logrus.Warnf("Failed to encode json: %v", err)
196+
report := entities.ImagePullReport{
197+
Stream: string(s),
198+
}
199+
if streamingStarted {
200+
if err := enc.Encode(report); err != nil {
201+
logrus.Warnf("Failed to encode json: %v", err)
202+
}
203+
flush()
204+
} else {
205+
reportBuffer = append(reportBuffer, report)
168206
}
169-
flush()
170207
case <-runCtx.Done():
208+
if !streamingStarted && pullError != nil {
209+
utils.Error(w, utils.HTTPStatusFromRegistryError(pullError), pullError)
210+
return
211+
}
212+
213+
startStreaming()
214+
var report entities.ImagePullReport
171215
for _, image := range pulledImages {
172216
report.Images = append(report.Images, image.ID())
173217
// Pull last ID from list and publish in 'id' stanza. This maintains previous API contract

pkg/api/handlers/swagger/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ type artifactBadAuth struct {
4444
Body errorhandling.ErrorModel
4545
}
4646

47+
// Error from registry
48+
// swagger:response
49+
type errorFromRegistry struct {
50+
// in:body
51+
Body errorhandling.ErrorModel
52+
}
53+
4754
// No such network
4855
// swagger:response
4956
type networkNotFound struct {

pkg/api/handlers/swagger/responses.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ type imagesImportResponseLibpod struct {
5959
Body entities.ImageImportReport
6060
}
6161

62-
// Image Pull
62+
// Image Pull. Errors may be detected later even if this returns HTTP status 200, and in that case, the error description will be in the `error` field.
6363
// swagger:response
6464
type imagesPullResponseLibpod struct {
6565
// in:body

pkg/api/handlers/utils/images.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,7 @@ loop: // break out of for/select infinite loop
252252
case pullRes := <-pullResChan:
253253
err := pullRes.err
254254
if err != nil {
255-
var errcd errcode.ErrorCoder
256-
if errors.As(err, &errcd) {
257-
writeStatusCode(errcd.ErrorCode().Descriptor().HTTPStatusCode)
258-
} else {
259-
writeStatusCode(http.StatusInternalServerError)
260-
}
255+
writeStatusCode(HTTPStatusFromRegistryError(err))
261256
msg := err.Error()
262257
report.Error = &jsonstream.Error{
263258
Message: msg,
@@ -305,3 +300,14 @@ loop: // break out of for/select infinite loop
305300
}
306301
}
307302
}
303+
304+
func HTTPStatusFromRegistryError(err error) int {
305+
if err == nil {
306+
return http.StatusOK
307+
}
308+
var errcd errcode.ErrorCoder
309+
if errors.As(err, &errcd) {
310+
return errcd.ErrorCode().Descriptor().HTTPStatusCode
311+
}
312+
return http.StatusInternalServerError
313+
}

pkg/api/server/register_images.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,7 @@ func (s *APIServer) registerImagesHandlers(r *mux.Router) error {
11031103
// tags:
11041104
// - images
11051105
// summary: Pull images
1106-
// description: Pull one or more images from a container registry.
1106+
// description: Pull one or more images from a container registry. Error status codes can come either from the API or from the registry. Errors may be detected later even if the HTTP status 200 is returned, and in that case, the error description will be in the `error` field.
11071107
// parameters:
11081108
// - in: query
11091109
// name: reference
@@ -1157,6 +1157,8 @@ func (s *APIServer) registerImagesHandlers(r *mux.Router) error {
11571157
// $ref: "#/responses/badParamError"
11581158
// 500:
11591159
// $ref: '#/responses/internalError'
1160+
// default:
1161+
// $ref: "#/responses/errorFromRegistry"
11601162
r.Handle(VersionedPath("/libpod/images/pull"), s.APIHandler(libpod.ImagesPull)).Methods(http.MethodPost)
11611163
// swagger:operation POST /libpod/images/prune libpod ImagePruneLibpod
11621164
// ---

test/apiv2/python/rest_api/test_v2_0_0_image.py

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -68,47 +68,87 @@ def test_delete(self):
6868
self.assertEqual(r.status_code, 409, r.text)
6969

7070
def test_pull(self):
71-
r = requests.post(self.uri("/images/pull?reference=alpine"), timeout=15)
72-
self.assertEqual(r.status_code, 200, r.status_code)
73-
text = r.text
74-
keys = {
75-
"error": False,
76-
"id": False,
77-
"images": False,
78-
"stream": False,
79-
}
80-
# Read and record stanza's from pull
81-
for line in str.splitlines(text):
82-
obj = json.loads(line)
83-
key_list = list(obj.keys())
84-
for k in key_list:
85-
keys[k] = True
86-
87-
self.assertFalse(keys["error"], "Expected no errors")
88-
self.assertTrue(keys["id"], "Expected to find id stanza")
89-
self.assertTrue(keys["images"], "Expected to find images stanza")
90-
self.assertTrue(keys["stream"], "Expected to find stream progress stanza's")
91-
92-
r = requests.post(self.uri("/images/pull?reference=alpine&quiet=true"), timeout=15)
93-
self.assertEqual(r.status_code, 200, r.status_code)
94-
text = r.text
95-
keys = {
96-
"error": False,
97-
"id": False,
98-
"images": False,
99-
"stream": False,
100-
}
101-
# Read and record stanza's from pull
102-
for line in str.splitlines(text):
103-
obj = json.loads(line)
104-
key_list = list(obj.keys())
105-
for k in key_list:
106-
keys[k] = True
107-
108-
self.assertFalse(keys["error"], "Expected no errors")
109-
self.assertTrue(keys["id"], "Expected to find id stanza")
110-
self.assertTrue(keys["images"], "Expected to find images stanza")
111-
self.assertFalse(keys["stream"], "Expected to find stream progress stanza's")
71+
def check_response_keys(r, keys_expected):
72+
text = r.text
73+
keys_found = set()
74+
75+
# Read and record stanza's from pull
76+
for line in str.splitlines(text):
77+
obj = json.loads(line)
78+
key_list = list(obj.keys())
79+
for k in key_list:
80+
keys_found.add(k)
81+
82+
for key, expected in keys_expected.items():
83+
if expected:
84+
negation = ""
85+
else:
86+
negation = "not "
87+
self.assertEqual(
88+
key in keys_found,
89+
expected,
90+
f'Expected {negation}to find "{key}" stanza in response',
91+
)
92+
93+
existing_reference = "alpine"
94+
non_existing_reference = "quay.io/f4ee35641334/f6fda4bb"
95+
cases = [
96+
dict(
97+
quiet_postfix="&quiet=True",
98+
reference=existing_reference,
99+
timeout=15,
100+
assert_function=self.assertEqual,
101+
expected_keys={
102+
"error": False,
103+
"id": True,
104+
"images": True,
105+
"stream": False,
106+
},
107+
),
108+
dict(
109+
quiet_postfix="",
110+
reference=existing_reference,
111+
timeout=15,
112+
assert_function=self.assertEqual,
113+
expected_keys={
114+
"error": False,
115+
"id": True,
116+
"images": True,
117+
"stream": True,
118+
},
119+
),
120+
dict(
121+
quiet_postfix="&quiet=True",
122+
reference=non_existing_reference,
123+
timeout=None,
124+
assert_function=self.assertNotEqual,
125+
expected_keys={
126+
"cause": True,
127+
"message": True,
128+
"response": True,
129+
},
130+
),
131+
dict(
132+
quiet_postfix="",
133+
reference=non_existing_reference,
134+
timeout=None,
135+
assert_function=self.assertNotEqual,
136+
expected_keys={
137+
"cause": True,
138+
"message": True,
139+
"response": True,
140+
},
141+
),
142+
]
143+
144+
for case in cases:
145+
with self.subTest(case=case):
146+
r = requests.post(
147+
self.uri(f"/images/pull?reference={case['reference']}{case['quiet_postfix']}"),
148+
timeout=case["timeout"],
149+
)
150+
case["assert_function"](r.status_code, 200, r.status_code)
151+
check_response_keys(r, case["expected_keys"])
112152

113153
def test_create(self):
114154
r = requests.post(

0 commit comments

Comments
 (0)