Skip to content

Commit e33611c

Browse files
authored
Add pre-terminate hook (#1248)
* Add `pre-terminate` hook * Add test for hooks package * Add test for handler package * Document new hook return value
1 parent b4d5032 commit e33611c

File tree

6 files changed

+169
-6
lines changed

6 files changed

+169
-6
lines changed

docs/_advanced-topics/hooks.md

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The table below provides an overview of all available hooks.
3333
| post-receive | No | regularly while data is being transmitted. | logging upload progress, stopping running uploads | Yes |
3434
| pre-finish | Yes | after all upload data has been received but before a response is sent. | sending custom data when an upload is finished | No |
3535
| post-finish | No | after all upload data has been received and after a response is sent. | post-processing of upload, logging of upload end | Yes |
36+
| pre-terminate | Yes | before an upload will be terminated. | checking if an upload should be deleted | No |
3637
| post-terminate | No | after an upload has been terminated. | clean up of allocated resources | Yes |
3738

3839
Users should be aware of following things:
@@ -161,6 +162,13 @@ Below you can find an annotated, JSON-ish encoded example of a hook response:
161162
// to the client.
162163
"RejectUpload": false,
163164

165+
// RejectTermination will cause upload terminations via DELETE requests to be rejected,
166+
// allowing the hook to control whether associated resources are deleted.
167+
// This value is only respected for pre-terminate hooks. For other hooks,
168+
// it is ignored. Use the HTTPResponse field to send details about the rejection
169+
// to the client.
170+
"RejectTermination": false,
171+
164172
// ChangeFileInfo can be set to change selected properties of an upload before
165173
// it has been created.
166174
// Changes are applied on a per-property basis, meaning that specifying just

pkg/handler/config.go

+7
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ type Config struct {
7575
// If the error is non-nil, the error will be forwarded to the client. Furthermore,
7676
// HTTPResponse will be ignored and the error value can contain values for the HTTP response.
7777
PreFinishResponseCallback func(hook HookEvent) (HTTPResponse, error)
78+
// PreUploadTerminateCallback will be invoked on DELETE requests before an upload is terminated,
79+
// giving the application the opportunity to reject the termination. For example, to ensure resources
80+
// used by other services are not deleted.
81+
// If the callback returns no error, optional values from HTTPResponse will be contained in the HTTP response.
82+
// If the error is non-nil, the error will be forwarded to the client. Furthermore,
83+
// HTTPResponse will be ignored and the error value can contain values for the HTTP response.
84+
PreUploadTerminateCallback func(hook HookEvent) (HTTPResponse, error)
7885
// GracefulRequestCompletionTimeout is the timeout for operations to complete after an HTTP
7986
// request has ended (successfully or by error). For example, if an HTTP request is interrupted,
8087
// instead of stopping immediately, the handler and data store will be given some additional

pkg/handler/terminate_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,14 @@ func TestTerminate(t *testing.T) {
5454
composer.UseTerminater(store)
5555
composer.UseLocker(locker)
5656

57+
preTerminateCalled := false
5758
handler, _ := NewHandler(Config{
5859
StoreComposer: composer,
5960
NotifyTerminatedUploads: true,
61+
PreUploadTerminateCallback: func(hook HookEvent) (HTTPResponse, error) {
62+
preTerminateCalled = true
63+
return HTTPResponse{}, nil
64+
},
6065
})
6166

6267
c := make(chan HookEvent, 1)
@@ -81,6 +86,69 @@ func TestTerminate(t *testing.T) {
8186
req := event.HTTPRequest
8287
a.Equal("DELETE", req.Method)
8388
a.Equal("foo", req.URI)
89+
90+
a.True(preTerminateCalled)
91+
})
92+
93+
SubTest(t, "RejectTermination", func(t *testing.T, store *MockFullDataStore, _ *StoreComposer) {
94+
ctrl := gomock.NewController(t)
95+
defer ctrl.Finish()
96+
locker := NewMockFullLocker(ctrl)
97+
lock := NewMockFullLock(ctrl)
98+
upload := NewMockFullUpload(ctrl)
99+
100+
gomock.InOrder(
101+
locker.EXPECT().NewLock("foo").Return(lock, nil),
102+
lock.EXPECT().Lock(gomock.Any(), gomock.Any()).Return(nil),
103+
store.EXPECT().GetUpload(gomock.Any(), "foo").Return(upload, nil),
104+
upload.EXPECT().GetInfo(gomock.Any()).Return(FileInfo{
105+
ID: "foo",
106+
Size: 10,
107+
}, nil),
108+
lock.EXPECT().Unlock().Return(nil),
109+
)
110+
111+
composer := NewStoreComposer()
112+
composer.UseCore(store)
113+
composer.UseTerminater(store)
114+
composer.UseLocker(locker)
115+
116+
a := assert.New(t)
117+
118+
handler, _ := NewHandler(Config{
119+
StoreComposer: composer,
120+
NotifyTerminatedUploads: true,
121+
PreUploadTerminateCallback: func(hook HookEvent) (HTTPResponse, error) {
122+
a.Equal("foo", hook.Upload.ID)
123+
a.Equal(int64(10), hook.Upload.Size)
124+
125+
req := hook.HTTPRequest
126+
a.Equal("DELETE", req.Method)
127+
a.Equal("foo", req.URI)
128+
129+
return HTTPResponse{}, ErrUploadTerminationRejected
130+
},
131+
})
132+
133+
c := make(chan HookEvent, 1)
134+
handler.TerminatedUploads = c
135+
136+
(&httpTest{
137+
Method: "DELETE",
138+
URL: "foo",
139+
ReqHeader: map[string]string{
140+
"Tus-Resumable": "1.0.0",
141+
},
142+
Code: http.StatusBadRequest,
143+
ResBody: "ERR_UPLOAD_TERMINATION_REJECTED: upload termination has been rejected by server\n",
144+
}).Run(handler, t)
145+
146+
select {
147+
case <-c:
148+
a.Fail("Expected termination to be rejected")
149+
default:
150+
// Expected no event
151+
}
84152
})
85153

86154
SubTest(t, "NotProvided", func(t *testing.T, store *MockFullDataStore, _ *StoreComposer) {

pkg/handler/unrouted_handler.go

+16-4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ var (
6060
ErrInvalidUploadDeferLength = NewError("ERR_INVALID_UPLOAD_LENGTH_DEFER", "invalid Upload-Defer-Length header", http.StatusBadRequest)
6161
ErrUploadStoppedByServer = NewError("ERR_UPLOAD_STOPPED", "upload has been stopped by server", http.StatusBadRequest)
6262
ErrUploadRejectedByServer = NewError("ERR_UPLOAD_REJECTED", "upload creation has been rejected by server", http.StatusBadRequest)
63+
ErrUploadTerminationRejected = NewError("ERR_UPLOAD_TERMINATION_REJECTED", "upload termination has been rejected by server", http.StatusBadRequest)
6364
ErrUploadInterrupted = NewError("ERR_UPLOAD_INTERRUPTED", "upload has been interrupted by another request for this upload resource", http.StatusBadRequest)
6465
ErrServerShutdown = NewError("ERR_SERVER_SHUTDOWN", "request has been interrupted because the server is shutting down", http.StatusServiceUnavailable)
6566
ErrOriginNotAllowed = NewError("ERR_ORIGIN_NOT_ALLOWED", "request origin is not allowed", http.StatusForbidden)
@@ -1203,23 +1204,34 @@ func (handler *UnroutedHandler) DelFile(w http.ResponseWriter, r *http.Request)
12031204
}
12041205

12051206
var info FileInfo
1206-
if handler.config.NotifyTerminatedUploads {
1207+
if handler.config.NotifyTerminatedUploads || handler.config.PreUploadTerminateCallback != nil {
12071208
info, err = upload.GetInfo(c)
12081209
if err != nil {
12091210
handler.sendError(c, err)
12101211
return
12111212
}
12121213
}
12131214

1215+
resp := HTTPResponse{
1216+
StatusCode: http.StatusNoContent,
1217+
}
1218+
1219+
if handler.config.PreUploadTerminateCallback != nil {
1220+
resp2, err := handler.config.PreUploadTerminateCallback(newHookEvent(c, info))
1221+
if err != nil {
1222+
handler.sendError(c, err)
1223+
return
1224+
}
1225+
resp = resp.MergeWith(resp2)
1226+
}
1227+
12141228
err = handler.terminateUpload(c, upload, info)
12151229
if err != nil {
12161230
handler.sendError(c, err)
12171231
return
12181232
}
12191233

1220-
handler.sendResp(c, HTTPResponse{
1221-
StatusCode: http.StatusNoContent,
1222-
})
1234+
handler.sendResp(c, resp)
12231235
}
12241236

12251237
// terminateUpload passes a given upload to the DataStore's Terminater,

pkg/hooks/hooks.go

+36-1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ type HookResponse struct {
6868
// to the client.
6969
RejectUpload bool
7070

71+
// RejectTermination will cause the termination of the upload to be rejected, keeping the upload.
72+
// This value is only respected for pre-terminate hooks. For other hooks,
73+
// it is ignored. Use the HTTPResponse field to send details about the rejection
74+
// to the client.
75+
RejectTermination bool
76+
7177
// ChangeFileInfo can be set to change selected properties of an upload before
7278
// it has been created. See the handler.FileInfoChanges type for more details.
7379
// Changes are applied on a per-property basis, meaning that specifying just
@@ -91,10 +97,11 @@ const (
9197
HookPostCreate HookType = "post-create"
9298
HookPreCreate HookType = "pre-create"
9399
HookPreFinish HookType = "pre-finish"
100+
HookPreTerminate HookType = "pre-terminate"
94101
)
95102

96103
// AvailableHooks is a slice of all hooks that are implemented by tusd.
97-
var AvailableHooks []HookType = []HookType{HookPreCreate, HookPostCreate, HookPostReceive, HookPostTerminate, HookPostFinish, HookPreFinish}
104+
var AvailableHooks []HookType = []HookType{HookPreCreate, HookPostCreate, HookPostReceive, HookPreTerminate, HookPostTerminate, HookPostFinish, HookPreFinish}
98105

99106
func preCreateCallback(event handler.HookEvent, hookHandler HookHandler) (handler.HTTPResponse, handler.FileInfoChanges, error) {
100107
ok, hookRes, err := invokeHookSync(HookPreCreate, event, hookHandler)
@@ -128,6 +135,26 @@ func preFinishCallback(event handler.HookEvent, hookHandler HookHandler) (handle
128135
return httpRes, nil
129136
}
130137

138+
func preTerminateCallback(event handler.HookEvent, hookHandler HookHandler) (handler.HTTPResponse, error) {
139+
ok, hookRes, err := invokeHookSync(HookPreTerminate, event, hookHandler)
140+
if !ok || err != nil {
141+
return handler.HTTPResponse{}, err
142+
}
143+
144+
httpRes := hookRes.HTTPResponse
145+
146+
// If the hook response includes the instruction to reject the termination, reuse the error code
147+
// and message from ErrUploadTerminationRejected, but also include custom HTTP response values.
148+
if hookRes.RejectTermination {
149+
err := handler.ErrUploadTerminationRejected
150+
err.HTTPResponse = err.HTTPResponse.MergeWith(httpRes)
151+
152+
return handler.HTTPResponse{}, err
153+
}
154+
155+
return httpRes, nil
156+
}
157+
131158
func postReceiveCallback(event handler.HookEvent, hookHandler HookHandler) {
132159
ok, hookRes, _ := invokeHookSync(HookPostReceive, event, hookHandler)
133160
// invokeHookSync already logs the error, if any occurs. So by checking `ok`, we can ensure
@@ -166,12 +193,14 @@ func SetupHookMetrics() {
166193
MetricsHookErrorsTotal.WithLabelValues(string(HookPostCreate)).Add(0)
167194
MetricsHookErrorsTotal.WithLabelValues(string(HookPreCreate)).Add(0)
168195
MetricsHookErrorsTotal.WithLabelValues(string(HookPreFinish)).Add(0)
196+
MetricsHookErrorsTotal.WithLabelValues(string(HookPreTerminate)).Add(0)
169197
MetricsHookInvocationsTotal.WithLabelValues(string(HookPostFinish)).Add(0)
170198
MetricsHookInvocationsTotal.WithLabelValues(string(HookPostTerminate)).Add(0)
171199
MetricsHookInvocationsTotal.WithLabelValues(string(HookPostReceive)).Add(0)
172200
MetricsHookInvocationsTotal.WithLabelValues(string(HookPostCreate)).Add(0)
173201
MetricsHookInvocationsTotal.WithLabelValues(string(HookPreCreate)).Add(0)
174202
MetricsHookInvocationsTotal.WithLabelValues(string(HookPreFinish)).Add(0)
203+
MetricsHookInvocationsTotal.WithLabelValues(string(HookPreTerminate)).Add(0)
175204
}
176205

177206
func invokeHookAsync(typ HookType, event handler.HookEvent, hookHandler HookHandler) {
@@ -248,6 +277,12 @@ func NewHandlerWithHooks(config *handler.Config, hookHandler HookHandler, enable
248277
}
249278
}
250279

280+
if slices.Contains(enabledHooks, HookPreTerminate) {
281+
config.PreUploadTerminateCallback = func(event handler.HookEvent) (handler.HTTPResponse, error) {
282+
return preTerminateCallback(event, hookHandler)
283+
}
284+
}
285+
251286
// Create handler
252287
handler, err := handler.NewHandler(*config)
253288
if err != nil {

pkg/hooks/hooks_test.go

+34-1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ func TestNewHandlerWithHooks(t *testing.T) {
8989
Type: HookPreFinish,
9090
Event: event,
9191
}).Return(HookResponse{}, error),
92+
hookHandler.EXPECT().InvokeHook(HookRequest{
93+
Type: HookPreTerminate,
94+
Event: event,
95+
}).Return(HookResponse{
96+
HTTPResponse: response,
97+
}, nil),
98+
hookHandler.EXPECT().InvokeHook(HookRequest{
99+
Type: HookPreTerminate,
100+
Event: event,
101+
}).Return(HookResponse{
102+
HTTPResponse: response,
103+
RejectTermination: true,
104+
}, nil),
92105
)
93106

94107
// The hooks are executed asynchronously, so we don't know their execution order.
@@ -112,7 +125,7 @@ func TestNewHandlerWithHooks(t *testing.T) {
112125
Event: event,
113126
})
114127

115-
uploadHandler, err := NewHandlerWithHooks(&config, hookHandler, []HookType{HookPreCreate, HookPostCreate, HookPostReceive, HookPostTerminate, HookPostFinish, HookPreFinish})
128+
uploadHandler, err := NewHandlerWithHooks(&config, hookHandler, []HookType{HookPreCreate, HookPostCreate, HookPostReceive, HookPostTerminate, HookPostFinish, HookPreFinish, HookPreTerminate})
116129
a.NoError(err)
117130

118131
// Successful pre-create hook
@@ -148,6 +161,26 @@ func TestNewHandlerWithHooks(t *testing.T) {
148161
a.Equal(error, err)
149162
a.Equal(handler.HTTPResponse{}, resp_got)
150163

164+
// Successful pre-terminate hook
165+
resp_got, err = config.PreUploadTerminateCallback(event)
166+
a.NoError(err)
167+
a.Equal(response, resp_got)
168+
169+
// Pre-terminate hook with rejection
170+
resp_got, err = config.PreUploadTerminateCallback(event)
171+
a.Equal(handler.Error{
172+
ErrorCode: handler.ErrUploadTerminationRejected.ErrorCode,
173+
Message: handler.ErrUploadTerminationRejected.Message,
174+
HTTPResponse: handler.HTTPResponse{
175+
StatusCode: 200,
176+
Body: "foobar",
177+
Header: handler.HTTPHeader{
178+
"X-Hello": "here",
179+
"Content-Type": "text/plain; charset=utf-8",
180+
},
181+
},
182+
}, err)
183+
151184
// Successful post-* hooks
152185
uploadHandler.CreatedUploads <- event
153186
uploadHandler.UploadProgress <- event

0 commit comments

Comments
 (0)