|
| 1 | +package server |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "net/http" |
| 6 | + "net/http/httptest" |
| 7 | + "testing" |
| 8 | + |
| 9 | + corev1 "k8s.io/api/core/v1" |
| 10 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 11 | + "k8s.io/apimachinery/pkg/types" |
| 12 | + "k8s.io/client-go/kubernetes" |
| 13 | + "k8s.io/client-go/kubernetes/fake" |
| 14 | + "k8s.io/client-go/rest" |
| 15 | + |
| 16 | + "github.com/skyhook-io/radar/internal/auth" |
| 17 | + "github.com/skyhook-io/radar/internal/k8s" |
| 18 | + "github.com/skyhook-io/radar/pkg/k8score" |
| 19 | +) |
| 20 | + |
| 21 | +const helmSecretTestName = "sh.helm.release.v1.web.v3" |
| 22 | + |
| 23 | +func helmReleaseSecretFixture(payload string) *corev1.Secret { |
| 24 | + return &corev1.Secret{ |
| 25 | + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, |
| 26 | + ObjectMeta: metav1.ObjectMeta{ |
| 27 | + Name: helmSecretTestName, |
| 28 | + Namespace: "default", |
| 29 | + UID: "uid-web-v3", |
| 30 | + Labels: map[string]string{"owner": "helm", "name": "web", "status": "deployed", "version": "3"}, |
| 31 | + }, |
| 32 | + Type: k8score.HelmReleaseSecretType, |
| 33 | + Data: map[string][]byte{"release": []byte(payload)}, |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +type fakeSecretAPI struct { |
| 38 | + url string |
| 39 | + gets int |
| 40 | + impersonate []string // Impersonate-User of each GET |
| 41 | + serveUID string // when set, the live Secret has this UID instead |
| 42 | +} |
| 43 | + |
| 44 | +// useHelmReleaseSecretCluster caches one Helm release Secret and points the |
| 45 | +// live client at a fake API server that answers GETs for it with payload. |
| 46 | +func useHelmReleaseSecretCluster(t *testing.T, payload string) *fakeSecretAPI { |
| 47 | + t.Helper() |
| 48 | + useTestResourceCache(t, fake.NewClientset( |
| 49 | + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}}, |
| 50 | + helmReleaseSecretFixture(payload), |
| 51 | + &corev1.Secret{ |
| 52 | + ObjectMeta: metav1.ObjectMeta{Name: "app-config", Namespace: "default"}, |
| 53 | + Type: corev1.SecretTypeOpaque, |
| 54 | + Data: map[string][]byte{"password": []byte("hunter2")}, |
| 55 | + }, |
| 56 | + )) |
| 57 | + |
| 58 | + fakeAPI := &fakeSecretAPI{} |
| 59 | + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 60 | + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/namespaces/default/secrets/"+helmSecretTestName { |
| 61 | + http.NotFound(w, r) |
| 62 | + return |
| 63 | + } |
| 64 | + fakeAPI.gets++ |
| 65 | + fakeAPI.impersonate = append(fakeAPI.impersonate, r.Header.Get("Impersonate-User")) |
| 66 | + w.Header().Set("Content-Type", "application/json") |
| 67 | + live := helmReleaseSecretFixture(payload) |
| 68 | + if fakeAPI.serveUID != "" { |
| 69 | + live.UID = types.UID(fakeAPI.serveUID) |
| 70 | + } |
| 71 | + _ = json.NewEncoder(w).Encode(live) |
| 72 | + })) |
| 73 | + t.Cleanup(api.Close) |
| 74 | + fakeAPI.url = api.URL |
| 75 | + prev := k8s.SetTestClient(kubernetes.NewForConfigOrDie(&rest.Config{Host: api.URL})) |
| 76 | + t.Cleanup(func() { k8s.SetTestClient(prev) }) |
| 77 | + return fakeAPI |
| 78 | +} |
| 79 | + |
| 80 | +type secretRow struct { |
| 81 | + Metadata struct { |
| 82 | + Name string `json:"name"` |
| 83 | + } `json:"metadata"` |
| 84 | + Data map[string]*string `json:"data"` |
| 85 | +} |
| 86 | + |
| 87 | +// The cache holds a digest in place of each Helm release value; the list |
| 88 | +// must serve the key with no value, for one namespace and for all of them |
| 89 | +// (the default view). |
| 90 | +func TestSecretsList_HelmReleaseServedWithoutValues(t *testing.T) { |
| 91 | + useHelmReleaseSecretCluster(t, "H4sIpayload") |
| 92 | + |
| 93 | + for _, path := range []string{"/api/resources/secrets?namespaces=default", "/api/resources/secrets"} { |
| 94 | + resp := get(t, path) |
| 95 | + if resp.StatusCode != http.StatusOK { |
| 96 | + resp.Body.Close() |
| 97 | + t.Fatalf("%s: expected 200, got %d", path, resp.StatusCode) |
| 98 | + } |
| 99 | + var rows []secretRow |
| 100 | + err := json.NewDecoder(resp.Body).Decode(&rows) |
| 101 | + resp.Body.Close() |
| 102 | + if err != nil { |
| 103 | + t.Fatalf("%s: decode: %v", path, err) |
| 104 | + } |
| 105 | + seen := map[string]secretRow{} |
| 106 | + for _, row := range rows { |
| 107 | + seen[row.Metadata.Name] = row |
| 108 | + } |
| 109 | + |
| 110 | + helm, ok := seen[helmSecretTestName] |
| 111 | + if !ok { |
| 112 | + t.Fatalf("%s: Helm release Secret missing from the list: %v", path, rows) |
| 113 | + } |
| 114 | + value, hasKey := helm.Data["release"] |
| 115 | + if !hasKey { |
| 116 | + t.Fatalf("%s: release key dropped; the Keys column would read 0", path) |
| 117 | + } |
| 118 | + if value != nil { |
| 119 | + t.Fatalf("%s: the list served a value for a Helm release Secret: %q", path, *value) |
| 120 | + } |
| 121 | + if v := seen["app-config"].Data["password"]; v == nil || *v == "" { |
| 122 | + t.Fatalf("%s: an ordinary Secret lost its value: %v", path, seen["app-config"].Data) |
| 123 | + } |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +func TestSecretGet_HelmReleaseServesLivePayload(t *testing.T) { |
| 128 | + fakeAPI := useHelmReleaseSecretCluster(t, "H4sIpayload") |
| 129 | + |
| 130 | + resp := get(t, "/api/resources/secrets/default/"+helmSecretTestName) |
| 131 | + defer resp.Body.Close() |
| 132 | + if resp.StatusCode != http.StatusOK { |
| 133 | + t.Fatalf("expected 200, got %d", resp.StatusCode) |
| 134 | + } |
| 135 | + var body struct { |
| 136 | + Resource secretRow `json:"resource"` |
| 137 | + } |
| 138 | + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| 139 | + t.Fatalf("decode: %v", err) |
| 140 | + } |
| 141 | + // JSON carries []byte as base64. |
| 142 | + if v := body.Resource.Data["release"]; v == nil || *v != "SDRzSXBheWxvYWQ=" { |
| 143 | + t.Fatalf("release value = %v, want the live payload", v) |
| 144 | + } |
| 145 | + if fakeAPI.gets != 1 { |
| 146 | + t.Fatalf("live GETs = %d, want 1", fakeAPI.gets) |
| 147 | + } |
| 148 | + |
| 149 | + // Ordinary Secrets are still answered from the cache. |
| 150 | + resp2 := get(t, "/api/resources/secrets/default/app-config") |
| 151 | + defer resp2.Body.Close() |
| 152 | + if resp2.StatusCode != http.StatusOK { |
| 153 | + t.Fatalf("expected 200, got %d", resp2.StatusCode) |
| 154 | + } |
| 155 | + if fakeAPI.gets != 1 { |
| 156 | + t.Fatalf("an ordinary Secret was read live (GETs = %d)", fakeAPI.gets) |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +// The live read can fail where the cached read would not have: the Secret |
| 161 | +// was deleted since it was cached, or the API server refuses the caller. |
| 162 | +func TestSecretGet_HelmReleaseLiveReadFailures(t *testing.T) { |
| 163 | + for _, tc := range []struct { |
| 164 | + name string |
| 165 | + reason metav1.StatusReason |
| 166 | + code int |
| 167 | + }{ |
| 168 | + {"deleted since cached", metav1.StatusReasonNotFound, http.StatusNotFound}, |
| 169 | + {"forbidden by the API server", metav1.StatusReasonForbidden, http.StatusForbidden}, |
| 170 | + } { |
| 171 | + t.Run(tc.name, func(t *testing.T) { |
| 172 | + useHelmReleaseSecretCluster(t, "H4sIpayload") |
| 173 | + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 174 | + w.Header().Set("Content-Type", "application/json") |
| 175 | + w.WriteHeader(tc.code) |
| 176 | + _ = json.NewEncoder(w).Encode(metav1.Status{ |
| 177 | + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, |
| 178 | + Status: metav1.StatusFailure, Reason: tc.reason, Code: int32(tc.code), |
| 179 | + }) |
| 180 | + })) |
| 181 | + t.Cleanup(failing.Close) |
| 182 | + prev := k8s.SetTestClient(kubernetes.NewForConfigOrDie(&rest.Config{Host: failing.URL})) |
| 183 | + t.Cleanup(func() { k8s.SetTestClient(prev) }) |
| 184 | + |
| 185 | + resp := get(t, "/api/resources/secrets/default/"+helmSecretTestName) |
| 186 | + defer resp.Body.Close() |
| 187 | + if resp.StatusCode != tc.code { |
| 188 | + t.Fatalf("expected %d, got %d", tc.code, resp.StatusCode) |
| 189 | + } |
| 190 | + }) |
| 191 | + } |
| 192 | +} |
| 193 | + |
| 194 | +// With auth on, the live read runs as the caller, never as Radar's own |
| 195 | +// identity, so the API server applies the caller's RBAC. |
| 196 | +func TestSecretGet_HelmReleaseLiveReadImpersonatesCaller(t *testing.T) { |
| 197 | + fakeAPI := useHelmReleaseSecretCluster(t, "H4sIpayload") |
| 198 | + prevConfig := k8s.SetTestConfig(&rest.Config{Host: fakeAPI.url}) |
| 199 | + t.Cleanup(func() { k8s.SetTestConfig(prevConfig) }) |
| 200 | + |
| 201 | + env := newAuthTestServer(t) |
| 202 | + env.srv.permCache.Set("bob", nil, &auth.UserPermissions{AllowedNamespaces: []string{"default"}}) |
| 203 | + seedServerSecretGetCanI(t, env, "bob", []string{"default"}, nil) |
| 204 | + |
| 205 | + resp := env.authGet(t, "/api/resources/secrets/default/"+helmSecretTestName, "bob", "") |
| 206 | + defer resp.Body.Close() |
| 207 | + if resp.StatusCode != http.StatusOK { |
| 208 | + t.Fatalf("expected 200, got %d", resp.StatusCode) |
| 209 | + } |
| 210 | + if len(fakeAPI.impersonate) != 1 || fakeAPI.impersonate[0] != "bob" { |
| 211 | + t.Fatalf("live read impersonated %q, want bob", fakeAPI.impersonate) |
| 212 | + } |
| 213 | +} |
| 214 | + |
| 215 | +// The live read must return the object the cache answered for. Another UID |
| 216 | +// under the same name means the Secret was recreated, or the cluster context |
| 217 | +// switched between the cache lookup and the read; the caller retries. |
| 218 | +func TestSecretGet_HelmReleaseLiveReadOfAnotherObjectIsRetryable(t *testing.T) { |
| 219 | + fakeAPI := useHelmReleaseSecretCluster(t, "H4sIpayload") |
| 220 | + fakeAPI.serveUID = "uid-from-another-cluster" |
| 221 | + |
| 222 | + resp := get(t, "/api/resources/secrets/default/"+helmSecretTestName) |
| 223 | + defer resp.Body.Close() |
| 224 | + if resp.StatusCode != http.StatusServiceUnavailable { |
| 225 | + t.Fatalf("expected 503, got %d", resp.StatusCode) |
| 226 | + } |
| 227 | + var body struct { |
| 228 | + Code string `json:"error_code"` |
| 229 | + } |
| 230 | + _ = json.NewDecoder(resp.Body).Decode(&body) |
| 231 | + if body.Code != "cluster_connecting" { |
| 232 | + t.Fatalf("code = %q, want cluster_connecting so the drawer retries", body.Code) |
| 233 | + } |
| 234 | +} |
0 commit comments