provider.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. // Package webhook exercises the webhook provider against an HTTP backend that
  14. // runs inside this test process. The provider's spec.url accepts any address,
  15. // so the backend needs no vendor account and no repo secrets, which keeps the
  16. // leg fork-safe and runnable on every PR.
  17. //
  18. // The backend is deliberately in-process rather than a deployed image: it
  19. // records every request it receives, so a PushSecret spec can assert the exact
  20. // body and headers the controller sent instead of inferring them from a status
  21. // or scraping pod logs.
  22. //
  23. // IMPORTANT: this only works when the suite itself runs inside the cluster,
  24. // which is what e2e/run.sh does (it launches the suite as a pod). Running the
  25. // suite from a workstation against a kind cluster leaves the Service with an
  26. // endpoint the controller cannot reach; setUpBackend fails early and says so.
  27. package webhook
  28. import (
  29. "encoding/json"
  30. "fmt"
  31. "io"
  32. "net"
  33. "net/http"
  34. "os"
  35. "strings"
  36. "sync"
  37. // nolint
  38. . "github.com/onsi/ginkgo/v2"
  39. // nolint
  40. . "github.com/onsi/gomega"
  41. corev1 "k8s.io/api/core/v1"
  42. discoveryv1 "k8s.io/api/discovery/v1"
  43. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  44. "k8s.io/apimachinery/pkg/util/intstr"
  45. "github.com/external-secrets/external-secrets-e2e/framework"
  46. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  47. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  48. )
  49. const (
  50. // backendBasePort is offset by the ginkgo process index. The suite runs with
  51. // `ginkgo -p`, so several processes share one pod: each needs its own
  52. // listener port, and gets its own in-memory store with it.
  53. backendBasePort = 18080
  54. // serviceName is created per test namespace so it is torn down with the
  55. // namespace rather than leaking for the life of the suite.
  56. serviceName = "webhook-e2e-backend"
  57. portName = "http"
  58. // namespaceFile is how a process running in a pod learns its own namespace.
  59. namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
  60. // The provider reads every Secret through getStoreSecret, which requires
  61. // this label on all of them, the auth ones included.
  62. storeTypeLabel = "external-secrets.io/type"
  63. storeTypeValue = "webhook"
  64. authSecretName = "webhook-e2e-auth"
  65. authSecretKey = "token"
  66. authSecretValue = "e2e-secret-token"
  67. // kvPath serves reads, pushes and deletes.
  68. kvPath = "/kv/"
  69. // The read and the push code paths template spec.url from disjoint variable
  70. // sets: a read exposes .remoteRef.key, while a push and a delete expose
  71. // .remoteRef.remoteKey. text/template runs with missingkey=default, so
  72. // naming the absent one renders the literal "<no value>" rather than an
  73. // empty string. Concatenating both therefore does not work, though a
  74. // fallback does ("{{ or .remoteRef.key .remoteRef.remoteKey }}"). The specs
  75. // use one explicit template per path anyway, so it stays obvious which
  76. // variable set each one exercises.
  77. readKeyTemplate = "{{ .remoteRef.key }}"
  78. pushKeyTemplate = "{{ .remoteRef.remoteKey }}"
  79. )
  80. // recordedRequest is what the backend saw on the wire. Specs assert against
  81. // this, which is the reason the backend lives in-process.
  82. type recordedRequest struct {
  83. Method string
  84. Path string
  85. Header http.Header
  86. Body string
  87. }
  88. // backend is the in-process HTTP store. Its zero value is not usable; use
  89. // newBackend.
  90. type backend struct {
  91. mu sync.Mutex
  92. values map[string]string
  93. requests []recordedRequest
  94. }
  95. func newBackend() *backend {
  96. return &backend{values: map[string]string{}}
  97. }
  98. func (b *backend) set(key, value string) {
  99. b.mu.Lock()
  100. defer b.mu.Unlock()
  101. b.values[key] = value
  102. }
  103. func (b *backend) delete(key string) {
  104. b.mu.Lock()
  105. defer b.mu.Unlock()
  106. delete(b.values, key)
  107. }
  108. func (b *backend) value(key string) (string, bool) {
  109. b.mu.Lock()
  110. defer b.mu.Unlock()
  111. v, ok := b.values[key]
  112. return v, ok
  113. }
  114. // reset clears state between specs. Specs in one process share a backend, so
  115. // without this a key left behind by a failed spec would leak into the next.
  116. func (b *backend) reset() {
  117. b.mu.Lock()
  118. defer b.mu.Unlock()
  119. b.values = map[string]string{}
  120. b.requests = nil
  121. }
  122. func (b *backend) record(r recordedRequest) {
  123. b.mu.Lock()
  124. defer b.mu.Unlock()
  125. b.requests = append(b.requests, r)
  126. }
  127. // requestsFor returns every recorded request whose path ends in key, oldest
  128. // first.
  129. //
  130. // Results are not guaranteed to belong only to the current spec. Namespace
  131. // deletion does not block, so a previous spec's ExternalSecret can still be
  132. // reconciling against the same listener for a few seconds. Match on the last
  133. // entry or filter by method; do not assert an exact count.
  134. func (b *backend) requestsFor(key string) []recordedRequest {
  135. b.mu.Lock()
  136. defer b.mu.Unlock()
  137. var out []recordedRequest
  138. for _, r := range b.requests {
  139. if strings.HasSuffix(r.Path, "/"+key) {
  140. out = append(out, r)
  141. }
  142. }
  143. return out
  144. }
  145. // handler implements the read, push and delete verbs the provider issues.
  146. //
  147. // A read returns {"value": "<stored>"} and the store sets result.jsonPath to
  148. // $.value. That shape serves both provider entry points: GetSecret returns the
  149. // string as-is, and GetSecretMap re-parses a string result as JSON, so
  150. // dataFrom.extract works when the stored value is a JSON object.
  151. //
  152. // A miss must be 404 and nothing else: the provider maps 404 to NoSecretError,
  153. // which is what makes SecretExists report false instead of erroring, and what
  154. // makes a delete of an absent key succeed.
  155. func (b *backend) handler() http.Handler {
  156. mux := http.NewServeMux()
  157. mux.HandleFunc(kvPath, func(w http.ResponseWriter, r *http.Request) {
  158. key := strings.TrimPrefix(r.URL.Path, kvPath)
  159. body, _ := io.ReadAll(r.Body)
  160. b.record(recordedRequest{
  161. Method: r.Method,
  162. Path: r.URL.Path,
  163. Header: r.Header.Clone(),
  164. Body: string(body),
  165. })
  166. switch r.Method {
  167. case http.MethodGet:
  168. value, ok := b.value(key)
  169. if !ok {
  170. w.WriteHeader(http.StatusNotFound)
  171. return
  172. }
  173. w.Header().Set("Content-Type", "application/json")
  174. _ = json.NewEncoder(w).Encode(map[string]string{"value": value})
  175. case http.MethodPost, http.MethodPut:
  176. b.set(key, string(body))
  177. w.WriteHeader(http.StatusOK)
  178. case http.MethodDelete:
  179. if _, ok := b.value(key); !ok {
  180. w.WriteHeader(http.StatusNotFound)
  181. return
  182. }
  183. b.delete(key)
  184. w.WriteHeader(http.StatusNoContent)
  185. default:
  186. w.WriteHeader(http.StatusMethodNotAllowed)
  187. }
  188. })
  189. return mux
  190. }
  191. // addressType picks the EndpointSlice address family from the resolved address,
  192. // so a dual-stack or IPv6-first cluster does not fail API validation.
  193. func addressType(ip string) discoveryv1.AddressType {
  194. if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() == nil {
  195. return discoveryv1.AddressTypeIPv6
  196. }
  197. return discoveryv1.AddressTypeIPv4
  198. }
  199. // The listener is per process, so it is started once and shared by every spec
  200. // that process runs. The Service is per namespace and so is created per spec.
  201. var (
  202. setUpOnce sync.Once
  203. sharedState *backend
  204. sharedPort int
  205. sharedPodIP string
  206. sharedSetUp error
  207. )
  208. // Provider wires the in-process backend to the framework's table tests.
  209. type Provider struct {
  210. framework *framework.Framework
  211. backend *backend
  212. baseURL string
  213. }
  214. func NewProvider(f *framework.Framework) *Provider {
  215. prov := &Provider{framework: f}
  216. // Registered as BeforeEach rather than run here: this constructor executes
  217. // during tree construction in every parallel process, including ones that
  218. // will not run a single webhook spec.
  219. BeforeEach(prov.BeforeEach)
  220. return prov
  221. }
  222. func (p *Provider) BeforeEach() {
  223. setUpOnce.Do(setUpBackend)
  224. Expect(sharedSetUp).ToNot(HaveOccurred())
  225. p.backend = sharedState
  226. p.backend.reset()
  227. p.exposeBackend()
  228. p.CreateAuthSecret(authSecretName, true)
  229. p.CreateStore()
  230. }
  231. // setUpBackend binds this process's port and starts serving. Errors are stored
  232. // rather than asserted so the failure surfaces inside a spec.
  233. func setUpBackend() {
  234. sharedState = newBackend()
  235. // Order matters: the namespace file is the only reliable in-a-pod signal, so
  236. // check it before resolving an address. Hostname resolution succeeds off
  237. // cluster too (to 127.0.1.1 on Debian, to a LAN address on macOS), which
  238. // would otherwise publish an EndpointSlice the controller cannot use and
  239. // leave every spec failing with an opaque connection error.
  240. if err := assertRunningInCluster(); err != nil {
  241. sharedSetUp = err
  242. return
  243. }
  244. ip, err := podIP()
  245. if err != nil {
  246. sharedSetUp = err
  247. return
  248. }
  249. sharedPodIP = ip
  250. sharedPort = backendBasePort + GinkgoParallelProcess()
  251. listener, err := net.Listen("tcp", fmt.Sprintf(":%d", sharedPort))
  252. if err != nil {
  253. sharedSetUp = fmt.Errorf("cannot listen on port %d: %w", sharedPort, err)
  254. return
  255. }
  256. server := &http.Server{Handler: sharedState.handler()}
  257. go func() {
  258. defer GinkgoRecover()
  259. if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
  260. // The suite is tearing down; nothing left to assert against.
  261. _, _ = fmt.Fprintf(GinkgoWriter, "backend stopped: %v\n", err)
  262. }
  263. }()
  264. }
  265. // podIP returns the address the controller will connect back to.
  266. func podIP() (string, error) {
  267. host, err := os.Hostname()
  268. if err != nil {
  269. return "", fmt.Errorf("cannot determine hostname: %w", err)
  270. }
  271. addrs, err := net.LookupHost(host)
  272. if err != nil || len(addrs) == 0 {
  273. return "", fmt.Errorf("cannot resolve own address (%q): %w", host, err)
  274. }
  275. return addrs[0], nil
  276. }
  277. // assertRunningInCluster fails when the suite is not executing inside a pod.
  278. // The projected serviceaccount namespace file is the discriminator; hostname
  279. // resolution is not, because it succeeds off cluster and yields an address the
  280. // controller cannot route to.
  281. func assertRunningInCluster() error {
  282. if _, err := os.Stat(namespaceFile); err != nil {
  283. return fmt.Errorf(
  284. "%s is absent, so this is not running in a pod: the webhook suite "+
  285. "exposes an in-process backend to the controller and must run "+
  286. "in-cluster, the way e2e/run.sh launches it: %w", namespaceFile, err)
  287. }
  288. return nil
  289. }
  290. // exposeBackend publishes this process's listener into the test namespace. The
  291. // Service carries no selector, and the EndpointSlice names the pod address
  292. // explicitly, so it resolves regardless of which namespace the suite pod runs
  293. // in and regardless of the labels run.sh happens to set on it.
  294. func (p *Provider) exposeBackend() {
  295. ns := p.framework.Namespace.Name
  296. port := int32(sharedPort)
  297. svc := &corev1.Service{
  298. ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns},
  299. Spec: corev1.ServiceSpec{
  300. Ports: []corev1.ServicePort{{
  301. Name: portName,
  302. Port: port,
  303. TargetPort: intstr.FromInt32(port),
  304. Protocol: corev1.ProtocolTCP,
  305. }},
  306. },
  307. }
  308. Expect(p.framework.CRClient.Create(GinkgoT().Context(), svc)).To(Succeed())
  309. slice := &discoveryv1.EndpointSlice{
  310. ObjectMeta: metav1.ObjectMeta{
  311. Name: serviceName,
  312. Namespace: ns,
  313. Labels: map[string]string{discoveryv1.LabelServiceName: serviceName},
  314. },
  315. AddressType: addressType(sharedPodIP),
  316. Endpoints: []discoveryv1.Endpoint{{
  317. Addresses: []string{sharedPodIP},
  318. Conditions: discoveryv1.EndpointConditions{Ready: new(true)},
  319. }},
  320. Ports: []discoveryv1.EndpointPort{{
  321. Name: new(portName),
  322. Port: new(port),
  323. Protocol: new(corev1.ProtocolTCP),
  324. }},
  325. }
  326. Expect(p.framework.CRClient.Create(GinkgoT().Context(), slice)).To(Succeed())
  327. p.baseURL = fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s",
  328. serviceName, ns, sharedPort, kvPath)
  329. }
  330. // CreateAuthSecret creates the Secret the store references from spec.secrets.
  331. // Pass labelled=false to build the same Secret without the
  332. // external-secrets.io/type label, which is how the negative specs prove the
  333. // provider refuses it.
  334. func (p *Provider) CreateAuthSecret(name string, labelled bool) {
  335. secret := &corev1.Secret{
  336. ObjectMeta: metav1.ObjectMeta{
  337. Name: name,
  338. Namespace: p.framework.Namespace.Name,
  339. },
  340. Data: map[string][]byte{authSecretKey: []byte(authSecretValue)},
  341. }
  342. if labelled {
  343. secret.Labels = map[string]string{storeTypeLabel: storeTypeValue}
  344. }
  345. Expect(p.framework.CRClient.Create(GinkgoT().Context(), secret)).To(Succeed())
  346. }
  347. // CreateSecret implements framework.SecretStoreProvider by writing straight
  348. // into the backing map, so the shared table in cases/common applies here.
  349. func (p *Provider) CreateSecret(key string, val framework.SecretEntry) {
  350. p.backend.set(key, val.Value)
  351. }
  352. func (p *Provider) DeleteSecret(key string) {
  353. p.backend.delete(key)
  354. }
  355. // CreateStore installs the read-oriented SecretStore the specs sync through.
  356. //
  357. // spec.method is deliberately left unset. It is shared by the read and the push
  358. // path but its default differs per path: GET for a read, POST for a push, with
  359. // a delete always DELETE. Pinning it to GET here would silently turn every
  360. // push into a GET.
  361. func (p *Provider) CreateStore() {
  362. By("creating a webhook secret store")
  363. store := &esv1.SecretStore{
  364. ObjectMeta: metav1.ObjectMeta{
  365. Name: p.framework.Namespace.Name,
  366. Namespace: p.framework.Namespace.Name,
  367. },
  368. Spec: p.storeSpec(authSecretName, readKeyTemplate),
  369. }
  370. Expect(p.framework.CRClient.Create(GinkgoT().Context(), store)).To(Succeed())
  371. }
  372. // storeSpec builds a webhook store whose url and X-Remote-Key header address
  373. // the remote key through keyTemplate, so callers choose between the read and
  374. // the push variable set. The negative specs reuse it to point the same store at
  375. // a differently-built Secret.
  376. func (p *Provider) storeSpec(secretName, keyTemplate string) esv1.SecretStoreSpec {
  377. return esv1.SecretStoreSpec{
  378. Provider: &esv1.SecretStoreProvider{
  379. Webhook: &esv1.WebhookProvider{
  380. URL: p.baseURL + keyTemplate,
  381. Headers: map[string]string{
  382. // Proves spec.secrets values are addressed as
  383. // .<name>.<keyInSecret> in a header template.
  384. "Authorization": "Bearer {{ .creds.token }}",
  385. "X-Remote-Key": keyTemplate,
  386. },
  387. Secrets: []esv1.WebhookSecret{{
  388. Name: "creds",
  389. SecretRef: esmeta.SecretKeySelector{
  390. Name: secretName,
  391. Key: authSecretKey,
  392. },
  393. }},
  394. Result: esv1.WebhookResult{JSONPath: "$.value"},
  395. },
  396. },
  397. }
  398. }