Update loginrequest/REST.Create to issue client certificates.
Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
parent
613f324a47
commit
8606cc9662
@ -118,32 +118,36 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
|
|||||||
klog.Warningf("webhook authentication failure: %v", err)
|
klog.Warningf("webhook authentication failure: %v", err)
|
||||||
return failureResponse(), nil
|
return failureResponse(), nil
|
||||||
}
|
}
|
||||||
|
if !authenticated || authResponse.User == nil || authResponse.User.GetName() == "" {
|
||||||
var out *placeholderapi.LoginRequest
|
return failureResponse(), nil
|
||||||
if authenticated && authResponse.User != nil && authResponse.User.GetName() != "" {
|
|
||||||
out = successfulResponse(authResponse)
|
|
||||||
} else {
|
|
||||||
out = failureResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
certPEM, keyPEM, err := r.issuer.IssuePEM(
|
||||||
}
|
pkix.Name{
|
||||||
|
CommonName: authResponse.User.GetName(),
|
||||||
|
OrganizationalUnit: authResponse.User.GetGroups(),
|
||||||
|
},
|
||||||
|
[]string{},
|
||||||
|
5*time.Minute,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
klog.Warningf("failed to issue short lived client certificate: %v", err)
|
||||||
|
return failureResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func successfulResponse(authResponse *authenticator.Response) *placeholderapi.LoginRequest {
|
|
||||||
return &placeholderapi.LoginRequest{
|
return &placeholderapi.LoginRequest{
|
||||||
Status: placeholderapi.LoginRequestStatus{
|
Status: placeholderapi.LoginRequestStatus{
|
||||||
Credential: &placeholderapi.LoginRequestCredential{
|
Credential: &placeholderapi.LoginRequestCredential{
|
||||||
ExpirationTimestamp: nil,
|
ExpirationTimestamp: nil,
|
||||||
Token: "snorlax",
|
ClientCertificateData: string(certPEM),
|
||||||
ClientCertificateData: "",
|
ClientKeyData: string(keyPEM),
|
||||||
ClientKeyData: "",
|
|
||||||
},
|
},
|
||||||
User: &placeholderapi.User{
|
User: &placeholderapi.User{
|
||||||
Name: authResponse.User.GetName(),
|
Name: authResponse.User.GetName(),
|
||||||
Groups: authResponse.User.GetGroups(),
|
Groups: authResponse.User.GetGroups(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func failureResponse() *placeholderapi.LoginRequest {
|
func failureResponse() *placeholderapi.LoginRequest {
|
||||||
|
@ -7,11 +7,13 @@ package loginrequest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@ -22,6 +24,7 @@ import (
|
|||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
||||||
|
"github.com/suzerain-io/placeholder-name/internal/mocks/mockcertissuer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey struct{}
|
type contextKey struct{}
|
||||||
@ -113,7 +116,18 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successfulIssuer(ctrl *gomock.Controller) CertIssuer {
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().
|
||||||
|
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return([]byte("test-cert"), []byte("test-key"), nil)
|
||||||
|
return issuer
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *testing.T) {
|
func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: &authenticator.Response{
|
returnResponse: &authenticator.Response{
|
||||||
User: &user.DefaultInfo{
|
User: &user.DefaultInfo{
|
||||||
@ -123,7 +137,17 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
},
|
},
|
||||||
returnUnauthenticated: false,
|
returnUnauthenticated: false,
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().IssuePEM(
|
||||||
|
pkix.Name{
|
||||||
|
CommonName: "test-user",
|
||||||
|
OrganizationalUnit: []string{"test-group-1", "test-group-2"}},
|
||||||
|
[]string{},
|
||||||
|
5*time.Minute,
|
||||||
|
).Return([]byte("test-cert"), []byte("test-key"), nil)
|
||||||
|
|
||||||
|
storage := NewREST(&webhook, issuer)
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
@ -137,9 +161,8 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
},
|
},
|
||||||
Credential: &placeholderapi.LoginRequestCredential{
|
Credential: &placeholderapi.LoginRequestCredential{
|
||||||
ExpirationTimestamp: nil,
|
ExpirationTimestamp: nil,
|
||||||
Token: "snorlax",
|
ClientCertificateData: "test-cert",
|
||||||
ClientCertificateData: "",
|
ClientKeyData: "test-key",
|
||||||
ClientKeyData: "",
|
|
||||||
},
|
},
|
||||||
Message: "",
|
Message: "",
|
||||||
},
|
},
|
||||||
@ -147,11 +170,38 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
|
|||||||
require.Equal(t, requestToken, webhook.calledWithToken)
|
require.Equal(t, requestToken, webhook.calledWithToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateFailsWithValidTokenWhenCertIssuerFails(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
webhook := FakeToken{
|
||||||
|
returnResponse: &authenticator.Response{
|
||||||
|
User: &user.DefaultInfo{
|
||||||
|
Name: "test-user",
|
||||||
|
Groups: []string{"test-group-1", "test-group-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
returnUnauthenticated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
|
||||||
|
issuer.EXPECT().
|
||||||
|
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return(nil, nil, fmt.Errorf("some certificate authority error"))
|
||||||
|
|
||||||
|
storage := NewREST(&webhook, issuer)
|
||||||
|
requestToken := "a token"
|
||||||
|
|
||||||
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
|
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
|
||||||
|
require.Equal(t, requestToken, webhook.calledWithToken)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookDoesNotAuthenticateTheToken(t *testing.T) {
|
func TestCreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookDoesNotAuthenticateTheToken(t *testing.T) {
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnUnauthenticated: true,
|
returnUnauthenticated: true,
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
|
||||||
@ -164,7 +214,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails(t *testing.T)
|
|||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnErr: errors.New("some webhook error"),
|
returnErr: errors.New("some webhook error"),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -175,7 +225,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookDoesNotReturnAnyUse
|
|||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: &authenticator.Response{},
|
returnResponse: &authenticator.Response{},
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -190,7 +240,7 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsern
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, nil)
|
||||||
|
|
||||||
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
response, err := callCreate(context.Background(), storage, validLoginRequest())
|
||||||
|
|
||||||
@ -198,10 +248,13 @@ func TestCreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsern
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
|
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
|
||||||
|
|
||||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||||
@ -212,7 +265,7 @@ func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
||||||
notALoginRequest := runtime.Unknown{}
|
notALoginRequest := runtime.Unknown{}
|
||||||
response, err := NewREST(&FakeToken{}).Create(
|
response, err := NewREST(&FakeToken{}, nil).Create(
|
||||||
genericapirequest.NewContext(),
|
genericapirequest.NewContext(),
|
||||||
¬ALoginRequest,
|
¬ALoginRequest,
|
||||||
rest.ValidateAllObjectFunc,
|
rest.ValidateAllObjectFunc,
|
||||||
@ -222,7 +275,7 @@ func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: placeholderapi.TokenLoginCredentialType,
|
Type: placeholderapi.TokenLoginCredentialType,
|
||||||
Token: nil,
|
Token: nil,
|
||||||
@ -233,7 +286,7 @@ func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: "",
|
Type: "",
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
||||||
@ -244,7 +297,7 @@ func TestCreateFailsWhenTypeInRequestIsMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: "this in an invalid type",
|
Type: "this in an invalid type",
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
||||||
@ -255,7 +308,7 @@ func TestCreateFailsWhenTypeInRequestIsNotLegal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||||
Type: placeholderapi.TokenLoginCredentialType,
|
Type: placeholderapi.TokenLoginCredentialType,
|
||||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: ""},
|
Token: &placeholderapi.LoginRequestTokenCredential{Value: ""},
|
||||||
@ -266,7 +319,7 @@ func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenValidationFails(t *testing.T) {
|
func TestCreateFailsWhenValidationFails(t *testing.T) {
|
||||||
storage := NewREST(&FakeToken{})
|
storage := NewREST(&FakeToken{}, nil)
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
validLoginRequest(),
|
validLoginRequest(),
|
||||||
@ -279,10 +332,13 @@ func TestCreateFailsWhenValidationFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
requestToken := "a token"
|
requestToken := "a token"
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@ -299,10 +355,14 @@ func TestCreateDoesNotAllowValidationFunctionToMutateRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testing.T) {
|
func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
|
||||||
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
validationFunctionWasCalled := false
|
validationFunctionWasCalled := false
|
||||||
var validationFunctionSawTokenValue string
|
var validationFunctionSawTokenValue string
|
||||||
response, err := storage.Create(
|
response, err := storage.Create(
|
||||||
@ -322,7 +382,7 @@ func TestCreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
||||||
response, err := NewREST(&FakeToken{}).Create(
|
response, err := NewREST(&FakeToken{}, nil).Create(
|
||||||
genericapirequest.NewContext(),
|
genericapirequest.NewContext(),
|
||||||
validLoginRequest(),
|
validLoginRequest(),
|
||||||
rest.ValidateAllObjectFunc,
|
rest.ValidateAllObjectFunc,
|
||||||
@ -335,13 +395,16 @@ func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t *testing.T) {
|
func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhookStartedRunningNotificationChan := make(chan bool)
|
webhookStartedRunningNotificationChan := make(chan bool)
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
timeout: time.Second * 2,
|
timeout: time.Second * 2,
|
||||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
c := make(chan bool)
|
c := make(chan bool)
|
||||||
@ -362,13 +425,16 @@ func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself(t *testing.T) {
|
func TestCreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
webhookStartedRunningNotificationChan := make(chan bool)
|
webhookStartedRunningNotificationChan := make(chan bool)
|
||||||
webhook := FakeToken{
|
webhook := FakeToken{
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||||
returnResponse: webhookSuccessResponse(),
|
returnResponse: webhookSuccessResponse(),
|
||||||
}
|
}
|
||||||
storage := NewREST(&webhook)
|
storage := NewREST(&webhook, successfulIssuer(ctrl))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user