mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-08 07:07:21 +02:00
Also adds a test to kube/kubeclient to defend against the error type returned by the client changing in future. Fixes tailscale/corp#30855 Change-Id: Id11d4295003e66ad5c29a687f1239333c21226a4 Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
228 lines
8.1 KiB
Go
228 lines
8.1 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package kubeclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"tailscale.com/kube/kubeapi"
|
|
"tailscale.com/tstest"
|
|
)
|
|
|
|
func Test_client_Event(t *testing.T) {
|
|
cl := &tstest.Clock{}
|
|
tests := []struct {
|
|
name string
|
|
typ string
|
|
reason string
|
|
msg string
|
|
argSets []args
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "new_event_gets_created",
|
|
typ: "Normal",
|
|
reason: "TestReason",
|
|
msg: "TestMessage",
|
|
argSets: []args{
|
|
{ // request to GET event returns not found
|
|
wantsMethod: "GET",
|
|
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
|
|
setErr: &kubeapi.Status{Code: 404},
|
|
},
|
|
{ // sends POST request to create event
|
|
wantsMethod: "POST",
|
|
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events",
|
|
wantsIn: &kubeapi.Event{
|
|
ObjectMeta: kubeapi.ObjectMeta{
|
|
Name: "test-pod.test-uid.testreason",
|
|
Namespace: "test-ns",
|
|
},
|
|
Type: "Normal",
|
|
Reason: "TestReason",
|
|
Message: "TestMessage",
|
|
Source: kubeapi.EventSource{
|
|
Component: "test-client",
|
|
},
|
|
InvolvedObject: kubeapi.ObjectReference{
|
|
Name: "test-pod",
|
|
UID: "test-uid",
|
|
Namespace: "test-ns",
|
|
APIVersion: "v1",
|
|
Kind: "Pod",
|
|
},
|
|
FirstTimestamp: cl.Now(),
|
|
LastTimestamp: cl.Now(),
|
|
Count: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "existing_event_gets_patched",
|
|
typ: "Warning",
|
|
reason: "TestReason",
|
|
msg: "TestMsg",
|
|
argSets: []args{
|
|
{ // request to GET event does not error - this is enough to assume that event exists
|
|
wantsMethod: "GET",
|
|
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
|
|
setOut: []byte(`{"count":2}`),
|
|
},
|
|
{ // sends PATCH request to update the event
|
|
wantsMethod: "PATCH",
|
|
wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason",
|
|
wantsIn: []JSONPatch{
|
|
{Op: "replace", Path: "/count", Value: int32(3)},
|
|
{Op: "replace", Path: "/lastTimestamp", Value: cl.Now()},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &client{
|
|
cl: cl,
|
|
name: "test-client",
|
|
podName: "test-pod",
|
|
podUID: "test-uid",
|
|
url: "test-apiserver",
|
|
ns: "test-ns",
|
|
kubeAPIRequest: fakeKubeAPIRequest(t, tt.argSets),
|
|
hasEventsPerms: true,
|
|
}
|
|
if err := c.Event(context.Background(), tt.typ, tt.reason, tt.msg); (err != nil) != tt.wantErr {
|
|
t.Errorf("client.Event() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReturnsKubeStatusError ensures HTTP error codes from the Kubernetes API
|
|
// server can always be extracted by casting the error to the *kubeapi.Status
|
|
// type, as lots of calling code relies on this cast succeeding. Note that
|
|
// transport errors are not expected or required to be of type *kubeapi.Status.
|
|
func TestReturnsKubeStatusError(t *testing.T) {
|
|
cl := clientForKubeHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(kubeapi.Status{Code: http.StatusForbidden, Message: "test error"})
|
|
}))
|
|
|
|
_, err := cl.GetSecret(t.Context(), "test-secret")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if st, ok := err.(*kubeapi.Status); !ok || st.Code != http.StatusForbidden {
|
|
t.Fatalf("expected kubeapi.Status with code %d, got %T: %v", http.StatusForbidden, err, err)
|
|
}
|
|
}
|
|
|
|
// clientForKubeHandler creates a client using the externally accessible package
|
|
// API to ensure it's testing behaviour as close to prod as possible. The passed
|
|
// in handler mocks the Kubernetes API server's responses to any HTTP requests
|
|
// made by the client.
|
|
func clientForKubeHandler(t *testing.T, handler http.Handler) Client {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
rootPathForTests = tmpDir
|
|
saDir := filepath.Join(tmpDir, "var", "run", "secrets", "kubernetes.io", "serviceaccount")
|
|
_ = os.MkdirAll(saDir, 0755)
|
|
_ = os.WriteFile(filepath.Join(saDir, "token"), []byte("test-token"), 0600)
|
|
_ = os.WriteFile(filepath.Join(saDir, "namespace"), []byte("test-namespace"), 0600)
|
|
_ = os.WriteFile(filepath.Join(saDir, "ca.crt"), []byte(ca), 0644)
|
|
cl, err := New("test-client")
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
srv := httptest.NewServer(handler)
|
|
t.Cleanup(srv.Close)
|
|
cl.SetURL(srv.URL)
|
|
return cl
|
|
}
|
|
|
|
// args is a set of values for testing a single call to client.kubeAPIRequest.
|
|
type args struct {
|
|
// wantsMethod is the expected value of 'method' arg.
|
|
wantsMethod string
|
|
// wantsURL is the expected value of 'url' arg.
|
|
wantsURL string
|
|
// wantsIn is the expected value of 'in' arg.
|
|
wantsIn any
|
|
// setOut can be set to a byte slice representing valid JSON. If set 'out' arg will get set to the unmarshalled
|
|
// JSON object.
|
|
setOut []byte
|
|
// setErr is the error that kubeAPIRequest will return.
|
|
setErr error
|
|
}
|
|
|
|
// fakeKubeAPIRequest can be used to test that a series of calls to client.kubeAPIRequest gets called with expected
|
|
// values and to set these calls to return preconfigured values. 'argSets' should be set to a slice of expected
|
|
// arguments and should-be return values of a series of kubeAPIRequest calls.
|
|
func fakeKubeAPIRequest(t *testing.T, argSets []args) kubeAPIRequestFunc {
|
|
count := 0
|
|
f := func(ctx context.Context, gotMethod, gotUrl string, gotIn, gotOut any, opts ...func(*http.Request)) error {
|
|
t.Helper()
|
|
if count >= len(argSets) {
|
|
t.Fatalf("unexpected call to client.kubeAPIRequest, expected %d calls, but got a %dth call", len(argSets), count+1)
|
|
}
|
|
a := argSets[count]
|
|
if gotMethod != a.wantsMethod {
|
|
t.Errorf("[%d] got method %q, wants method %q", count, gotMethod, a.wantsMethod)
|
|
}
|
|
if gotUrl != a.wantsURL {
|
|
t.Errorf("[%d] got URL %q, wants URL %q", count, gotUrl, a.wantsURL)
|
|
}
|
|
if d := cmp.Diff(gotIn, a.wantsIn); d != "" {
|
|
t.Errorf("[%d] unexpected payload (-want + got):\n%s", count, d)
|
|
}
|
|
if len(a.setOut) != 0 {
|
|
if err := json.Unmarshal(a.setOut, gotOut); err != nil {
|
|
t.Fatalf("[%d] error unmarshalling output: %v", count, err)
|
|
}
|
|
}
|
|
count++
|
|
return a.setErr
|
|
}
|
|
return f
|
|
}
|
|
|
|
const ca = `-----BEGIN CERTIFICATE-----
|
|
MIIFEDCCA3igAwIBAgIRANf5NdPojIfj70wMfJVYUg8wDQYJKoZIhvcNAQELBQAw
|
|
gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnJv
|
|
bWJlcmdlckBzdGFyZHVzdC5sb2NhbCAoTWljaGFlbCBKLiBGcm9tYmVyZ2VyKTFB
|
|
MD8GA1UEAww4bWtjZXJ0IGZyb21iZXJnZXJAc3RhcmR1c3QubG9jYWwgKE1pY2hh
|
|
ZWwgSi4gRnJvbWJlcmdlcikwHhcNMjMwMjA3MjAzNDE4WhcNMzMwMjA3MjAzNDE4
|
|
WjCBnzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTowOAYDVQQLDDFm
|
|
cm9tYmVyZ2VyQHN0YXJkdXN0LmxvY2FsIChNaWNoYWVsIEouIEZyb21iZXJnZXIp
|
|
MUEwPwYDVQQDDDhta2NlcnQgZnJvbWJlcmdlckBzdGFyZHVzdC5sb2NhbCAoTWlj
|
|
aGFlbCBKLiBGcm9tYmVyZ2VyKTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoC
|
|
ggGBAL5uXNnrZ6dgjcvK0Hc7ZNUIRYEWst9qbO0P9H7le08pJ6d9T2BUWruZtVjk
|
|
Q12msv5/bVWHhVk8dZclI9FLXuMsIrocH8bsoP4wruPMyRyp6EedSKODN51fFSRv
|
|
/jHbS5vzUVAWTYy9qYmd6qL0uhsHCZCCT6gfigamHPUFKM3sHDn5ZHWvySMwcyGl
|
|
AicmPAIkBWqiCZAkB5+WM7+oyRLjmrIalfWIZYxW/rojGLwTfneHv6J5WjVQnpJB
|
|
ayWCzCzaiXukK9MeBWeTOe8UfVN0Engd74/rjLWvjbfC+uZSr6RVkZvs2jANLwPF
|
|
zgzBPHgRPfAhszU1NNAMjnNQ47+OMOTKRt7e6jYzhO5fyO1qVAAvGBqcfpj+JfDk
|
|
cccaUMhUvdiGrhGf1V1tN/PislxvALirzcFipjD01isBKwn0fxRugzvJNrjEo8RA
|
|
RvbcdeKcwex7M0o/Cd0+G2B13gZNOFvR33PmG7iTpp7IUrUKfQg28I83Sp8tMY3s
|
|
ljJSawIDAQABo0UwQzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgwBgEB/wIB
|
|
ADAdBgNVHQ4EFgQU18qto0Fa56kCi/HwfQuC9ECX7cAwDQYJKoZIhvcNAQELBQAD
|
|
ggGBAAzs96LwZVOsRSlBdQqMo8oMAvs7HgnYbXt8SqaACLX3+kJ3cV/vrCE3iJrW
|
|
ma4CiQbxS/HqsiZjota5m4lYeEevRnUDpXhp+7ugZTiz33Flm1RU99c9UYfQ+919
|
|
ANPAKeqNpoPco/HF5Bz0ocepjcfKQrVZZNTj6noLs8o12FHBLO5976AcF9mqlNfh
|
|
8/F0gDJXq6+x7VT5y8u0rY004XKPRe3CklRt8kpeMiP6mhRyyUehOaHeIbNx8ubi
|
|
Pi44ByN/ueAnuRhF9zYtyZVZZOaSLysJge01tuPXF8rBXGruoJIv35xTTBa9BzaP
|
|
YDOGbGn1ZnajdNagHqCba8vjTLDSpqMvgRj3TFrGHdETA2LDQat38uVxX8gxm68K
|
|
va5Tyv7n+6BQ5YTpJjTPnmSJKaXZrrhdLPvG0OU2TxeEsvbcm5LFQofirOOw86Se
|
|
vzF2cQ94mmHRZiEk0Av3NO0jF93ELDrBCuiccVyEKq6TknuvPQlutCXKDOYSEb8I
|
|
MHctBg==
|
|
-----END CERTIFICATE-----`
|