infisical.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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 addon
  14. import (
  15. "bytes"
  16. "context"
  17. "crypto/rand"
  18. "encoding/base64"
  19. "encoding/hex"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "path/filepath"
  25. "time"
  26. infisicalSdk "github.com/infisical/go-sdk"
  27. . "github.com/onsi/ginkgo/v2"
  28. v1 "k8s.io/api/core/v1"
  29. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  30. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  31. )
  32. const (
  33. infisicalNamespace = "infisical"
  34. infisicalReleaseName = "infisical"
  35. // infisicalServiceName must match infisical.fullnameOverride in
  36. // infisical.values.yaml so we can port-forward and target it by DNS.
  37. infisicalServiceName = "infisical-backend"
  38. infisicalAPIPort = 8080
  39. infisicalChartVer = "1.8.0"
  40. infisicalSecretName = "infisical-secrets"
  41. // Bootstrap identity used to provision the instance. The password meets
  42. // Infisical's complexity policy (length + mixed character classes).
  43. infisicalAdminEmail = "admin@example.com"
  44. infisicalAdminPassword = "E2eAdminPassw0rd!23"
  45. infisicalOrgName = "eso-e2e"
  46. infisicalProjectName = "eso-e2e"
  47. infisicalProjectSlug = "eso-e2e-tests"
  48. )
  49. // Infisical deploys a self-hosted Infisical into the cluster and provisions a
  50. // project plus a Universal Auth machine identity for the e2e suite to use.
  51. type Infisical struct {
  52. chart *HelmChart
  53. Namespace string
  54. portForwarder *PortForward
  55. httpClient *http.Client
  56. cancelRefresh context.CancelFunc
  57. // HostAPI is the in-cluster API URL ESO uses from the SecretStore.
  58. HostAPI string
  59. // localBaseURL is the port-forwarded API URL the test process uses.
  60. localBaseURL string
  61. ClientID string
  62. ClientSecret string
  63. ProjectID string
  64. ProjectSlug string
  65. EnvironmentSlug string
  66. // SDKClient is logged in via Universal Auth and used by the suite to seed
  67. // and remove backend secrets (the provider itself is read-only).
  68. SDKClient infisicalSdk.InfisicalClientInterface
  69. }
  70. func NewInfisical() *Infisical {
  71. repo := "infisical-helm-charts"
  72. return &Infisical{
  73. Namespace: infisicalNamespace,
  74. httpClient: &http.Client{Timeout: 30 * time.Second},
  75. chart: &HelmChart{
  76. Namespace: infisicalNamespace,
  77. ReleaseName: infisicalReleaseName,
  78. Chart: fmt.Sprintf("%s/infisical-standalone", repo),
  79. ChartVersion: infisicalChartVer,
  80. Repo: ChartRepo{
  81. Name: repo,
  82. URL: "https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/",
  83. },
  84. Values: []string{filepath.Join(AssetDir(), "infisical.values.yaml")},
  85. },
  86. }
  87. }
  88. func (l *Infisical) Setup(cfg *Config) error {
  89. return l.chart.Setup(cfg)
  90. }
  91. func (l *Infisical) Install() (err error) {
  92. l.HostAPI = fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", infisicalServiceName, l.Namespace, infisicalAPIPort)
  93. // Arm rollback before the first mutating step so any failure (secret,
  94. // namespace, chart install, or bootstrap) tears down what was created and a
  95. // re-run does not trip over a half-installed instance.
  96. defer func() {
  97. if err != nil {
  98. l.cleanup()
  99. }
  100. }()
  101. if err := l.createInstanceSecret(); err != nil {
  102. return fmt.Errorf("unable to create infisical secret: %w", err)
  103. }
  104. if err := l.chart.Install(); err != nil {
  105. return err
  106. }
  107. pf, err := NewPortForward(l.chart.config.KubeClientSet, l.chart.config.KubeConfig, infisicalServiceName, l.Namespace, infisicalAPIPort)
  108. if err != nil {
  109. return err
  110. }
  111. if err := pf.Start(); err != nil {
  112. return err
  113. }
  114. l.portForwarder = pf
  115. l.localBaseURL = fmt.Sprintf("http://localhost:%d", pf.localPort)
  116. if err := l.waitForAPI(); err != nil {
  117. return fmt.Errorf("infisical API never became ready: %w", err)
  118. }
  119. if err := l.bootstrap(); err != nil {
  120. return fmt.Errorf("unable to bootstrap infisical: %w", err)
  121. }
  122. // Own the SDK client context so its token-refresh goroutine lives for the
  123. // whole suite (not just this BeforeAll node) and is canceled on teardown.
  124. ctx, cancel := context.WithCancel(context.Background())
  125. l.cancelRefresh = cancel
  126. client := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{SiteUrl: l.localBaseURL})
  127. if _, err := client.Auth().UniversalAuthLogin(l.ClientID, l.ClientSecret); err != nil {
  128. return fmt.Errorf("unable to log in with universal auth: %w", err)
  129. }
  130. l.SDKClient = client
  131. return nil
  132. }
  133. // cleanup best-effort removes everything Install created. It runs when a step
  134. // after the chart install fails, so repeated local runs do not collide with a
  135. // leftover release or namespace.
  136. func (l *Infisical) cleanup() {
  137. if l.cancelRefresh != nil {
  138. l.cancelRefresh()
  139. l.cancelRefresh = nil
  140. }
  141. if l.portForwarder != nil {
  142. l.portForwarder.Close()
  143. l.portForwarder = nil
  144. }
  145. _ = l.chart.Uninstall()
  146. _ = l.chart.config.KubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), l.Namespace, metav1.DeleteOptions{})
  147. }
  148. // createInstanceSecret creates the Secret the chart mounts as envFrom. Infisical
  149. // requires a 16-byte hex ENCRYPTION_KEY and a base64 AUTH_SECRET.
  150. func (l *Infisical) createInstanceSecret() error {
  151. encKey := make([]byte, 16)
  152. if _, err := rand.Read(encKey); err != nil {
  153. return err
  154. }
  155. authSecret := make([]byte, 32)
  156. if _, err := rand.Read(authSecret); err != nil {
  157. return err
  158. }
  159. ns := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: l.Namespace}}
  160. if _, err := controllerutil.CreateOrUpdate(GinkgoT().Context(), l.chart.config.CRClient, ns, func() error { return nil }); err != nil {
  161. return err
  162. }
  163. sec := &v1.Secret{
  164. ObjectMeta: metav1.ObjectMeta{
  165. Name: infisicalSecretName,
  166. Namespace: l.Namespace,
  167. },
  168. }
  169. _, err := controllerutil.CreateOrUpdate(GinkgoT().Context(), l.chart.config.CRClient, sec, func() error {
  170. sec.StringData = map[string]string{
  171. "ENCRYPTION_KEY": hex.EncodeToString(encKey),
  172. "AUTH_SECRET": base64.StdEncoding.EncodeToString(authSecret),
  173. "SITE_URL": l.HostAPI,
  174. }
  175. return nil
  176. })
  177. return err
  178. }
  179. func (l *Infisical) waitForAPI() error {
  180. deadline := time.Now().Add(3 * time.Minute)
  181. for time.Now().Before(deadline) {
  182. resp, err := l.httpClient.Get(l.localBaseURL + "/api/status")
  183. if err == nil {
  184. _ = resp.Body.Close()
  185. if resp.StatusCode == http.StatusOK {
  186. return nil
  187. }
  188. }
  189. time.Sleep(3 * time.Second)
  190. }
  191. return fmt.Errorf("timed out waiting for %s/api/status", l.localBaseURL)
  192. }
  193. // bootstrap initializes a fresh instance and provisions everything the suite
  194. // needs: an admin identity, a project, and a Universal Auth machine identity.
  195. func (l *Infisical) bootstrap() error {
  196. var boot struct {
  197. Identity struct {
  198. Credentials struct {
  199. Token string `json:"token"`
  200. } `json:"credentials"`
  201. } `json:"identity"`
  202. Organization struct {
  203. ID string `json:"id"`
  204. } `json:"organization"`
  205. }
  206. if err := l.apiCall(http.MethodPost, "/api/v1/admin/bootstrap", "", map[string]any{
  207. "email": infisicalAdminEmail,
  208. "password": infisicalAdminPassword,
  209. "organization": infisicalOrgName,
  210. }, &boot); err != nil {
  211. return fmt.Errorf("admin bootstrap: %w", err)
  212. }
  213. token := boot.Identity.Credentials.Token
  214. var project struct {
  215. Project struct {
  216. ID string `json:"id"`
  217. Slug string `json:"slug"`
  218. Environments []struct {
  219. Slug string `json:"slug"`
  220. } `json:"environments"`
  221. } `json:"project"`
  222. }
  223. if err := l.apiCall(http.MethodPost, "/api/v1/projects", token, map[string]any{
  224. "projectName": infisicalProjectName,
  225. "slug": infisicalProjectSlug,
  226. "shouldCreateDefaultEnvs": true,
  227. }, &project); err != nil {
  228. return fmt.Errorf("create project: %w", err)
  229. }
  230. l.ProjectID = project.Project.ID
  231. l.ProjectSlug = project.Project.Slug
  232. l.EnvironmentSlug = pickEnvironment(project.Project.Environments)
  233. // Create the machine identity, then grant it admin access to the project.
  234. // The org role alone does not confer project access, so without the
  235. // membership the identity gets a 403 on secret operations.
  236. var identity struct {
  237. Identity struct {
  238. ID string `json:"id"`
  239. } `json:"identity"`
  240. }
  241. if err := l.apiCall(http.MethodPost, "/api/v1/identities", token, map[string]any{
  242. "name": "eso-e2e",
  243. "organizationId": boot.Organization.ID,
  244. "role": "member",
  245. }, &identity); err != nil {
  246. return fmt.Errorf("create identity: %w", err)
  247. }
  248. identityID := identity.Identity.ID
  249. if err := l.apiCall(http.MethodPost, "/api/v2/workspace/"+l.ProjectID+"/identity-memberships/"+identityID, token, map[string]any{
  250. "role": "admin",
  251. }, nil); err != nil {
  252. return fmt.Errorf("add identity to project: %w", err)
  253. }
  254. trustedIPs := []map[string]string{{"ipAddress": "0.0.0.0/0"}, {"ipAddress": "::/0"}}
  255. var ua struct {
  256. IdentityUniversalAuth struct {
  257. ClientID string `json:"clientId"`
  258. } `json:"identityUniversalAuth"`
  259. }
  260. if err := l.apiCall(http.MethodPost, "/api/v1/auth/universal-auth/identities/"+identityID, token, map[string]any{
  261. "accessTokenTTL": 2592000,
  262. "accessTokenMaxTTL": 2592000,
  263. "accessTokenNumUsesLimit": 0,
  264. "clientSecretTrustedIps": trustedIPs,
  265. "accessTokenTrustedIps": trustedIPs,
  266. }, &ua); err != nil {
  267. return fmt.Errorf("attach universal auth: %w", err)
  268. }
  269. l.ClientID = ua.IdentityUniversalAuth.ClientID
  270. var secret struct {
  271. ClientSecret string `json:"clientSecret"`
  272. }
  273. if err := l.apiCall(http.MethodPost, "/api/v1/auth/universal-auth/identities/"+identityID+"/client-secrets", token, map[string]any{
  274. "description": "eso-e2e",
  275. "numUsesLimit": 0,
  276. "ttl": 0,
  277. }, &secret); err != nil {
  278. return fmt.Errorf("create client secret: %w", err)
  279. }
  280. l.ClientSecret = secret.ClientSecret
  281. return nil
  282. }
  283. // pickEnvironment returns the "dev" environment slug when present, otherwise the
  284. // first environment the project was created with.
  285. func pickEnvironment(envs []struct {
  286. Slug string `json:"slug"`
  287. }) string {
  288. for _, e := range envs {
  289. if e.Slug == "dev" {
  290. return e.Slug
  291. }
  292. }
  293. if len(envs) > 0 {
  294. return envs[0].Slug
  295. }
  296. return "dev"
  297. }
  298. // apiCall performs a JSON request against the port-forwarded API, optionally
  299. // bearer-authenticated, and decodes the response into out.
  300. func (l *Infisical) apiCall(method, path, token string, body, out any) error {
  301. var reader io.Reader
  302. if body != nil {
  303. b, err := json.Marshal(body)
  304. if err != nil {
  305. return err
  306. }
  307. reader = bytes.NewReader(b)
  308. }
  309. req, err := http.NewRequestWithContext(GinkgoT().Context(), method, l.localBaseURL+path, reader)
  310. if err != nil {
  311. return err
  312. }
  313. req.Header.Set("Content-Type", "application/json")
  314. if token != "" {
  315. req.Header.Set("Authorization", "Bearer "+token)
  316. }
  317. resp, err := l.httpClient.Do(req)
  318. if err != nil {
  319. return err
  320. }
  321. defer func() { _ = resp.Body.Close() }()
  322. data, err := io.ReadAll(resp.Body)
  323. if err != nil {
  324. return err
  325. }
  326. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  327. return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, string(data))
  328. }
  329. if out == nil {
  330. return nil
  331. }
  332. return json.Unmarshal(data, out)
  333. }
  334. func (l *Infisical) Logs() error {
  335. return l.chart.Logs()
  336. }
  337. func (l *Infisical) Uninstall() error {
  338. if l.cancelRefresh != nil {
  339. l.cancelRefresh()
  340. l.cancelRefresh = nil
  341. }
  342. if l.portForwarder != nil {
  343. l.portForwarder.Close()
  344. l.portForwarder = nil
  345. }
  346. if err := l.chart.Uninstall(); err != nil {
  347. return err
  348. }
  349. return l.chart.config.KubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), l.Namespace, metav1.DeleteOptions{})
  350. }