From 2b8220a71b93730719d553b0e5be1d8394d4d1ae Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 19 Jun 2025 01:43:47 +0800 Subject: [PATCH] feat: added support for outputting bootstrap credentials to k8 secret --- cli/packages/api/api.go | 8 +- cli/packages/api/model.go | 32 +++++ cli/packages/cmd/bootstrap.go | 210 ++++++++++++++++++++++++++++++-- cli/packages/util/constants.go | 9 ++ docs/cli/commands/bootstrap.mdx | 127 ++++++++++++++++++- 5 files changed, 371 insertions(+), 15 deletions(-) diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 83732b64a..15f75a57d 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -631,8 +631,8 @@ func CallGatewayHeartBeatV1(httpClient *resty.Client) error { return nil } -func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRequest) (map[string]interface{}, error) { - var resBody map[string]interface{} +func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRequest) (BootstrapInstanceResponse, error) { + var resBody BootstrapInstanceResponse response, err := httpClient. R(). SetResult(&resBody). @@ -641,11 +641,11 @@ func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRe Post(fmt.Sprintf("%v/v1/admin/bootstrap", request.Domain)) if err != nil { - return nil, NewGenericRequestError(operationCallBootstrapInstance, err) + return BootstrapInstanceResponse{}, NewGenericRequestError(operationCallBootstrapInstance, err) } if response.IsError() { - return nil, NewAPIErrorWithResponse(operationCallBootstrapInstance, response, nil) + return BootstrapInstanceResponse{}, NewAPIErrorWithResponse(operationCallBootstrapInstance, response, nil) } return resBody, nil diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index a7a797a0b..9bf666e44 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -655,3 +655,35 @@ type BootstrapInstanceRequest struct { Organization string `json:"organization"` Domain string `json:"domain"` } + +type BootstrapInstanceResponse struct { + Message string `json:"message"` + Identity BootstrapIdentity `json:"identity"` + Organization BootstrapOrganization `json:"organization"` + User BootstrapUser `json:"user"` +} + +type BootstrapIdentity struct { + ID string `json:"id"` + Name string `json:"name"` + Credentials BootstrapIdentityCredentials `json:"credentials"` +} + +type BootstrapIdentityCredentials struct { + Token string `json:"token"` +} + +type BootstrapOrganization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type BootstrapUser struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + SuperAdmin bool `json:"superAdmin"` +} diff --git a/cli/packages/cmd/bootstrap.go b/cli/packages/cmd/bootstrap.go index 4582cb001..51e8b076e 100644 --- a/cli/packages/cmd/bootstrap.go +++ b/cli/packages/cmd/bootstrap.go @@ -4,9 +4,14 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "os" + "text/template" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" @@ -14,6 +19,133 @@ import ( "github.com/spf13/cobra" ) +// handleK8SecretOutput processes the k8-secret output type by creating a Kubernetes secret +func handleK8SecretOutput(bootstrapResponse api.BootstrapInstanceResponse, k8SecretTemplate, k8SecretName, k8SecretNamespace string) error { + // Read Kubernetes service account credentials from the pod + k8sToken, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH) + if err != nil { + return fmt.Errorf("failed to read Kubernetes service account token: %v", err) + } + + k8sCaCert, err := os.ReadFile(util.KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH) + if err != nil { + return fmt.Errorf("failed to read Kubernetes CA certificate: %v", err) + } + + // Get Kubernetes API server URL from environment variables + k8sHost := os.Getenv(util.KUBERNETES_SERVICE_HOST_ENV_NAME) + k8sPort := os.Getenv(util.KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME) + if k8sHost == "" || k8sPort == "" { + return fmt.Errorf("failed to get Kubernetes API server address from environment variables") + } + + k8sApiUrl := fmt.Sprintf("https://%s:%s", k8sHost, k8sPort) + + // Parse and execute the template to render only the data/stringData section + tmpl, err := template.New("k8-secret-template").Funcs(template.FuncMap{ + "b64enc": func(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) + }, + }).Parse(k8SecretTemplate) + + if err != nil { + return fmt.Errorf("failed to parse output template: %v", err) + } + + var renderedDataSection bytes.Buffer + err = tmpl.Execute(&renderedDataSection, bootstrapResponse) + if err != nil { + return fmt.Errorf("failed to execute output template: %v", err) + } + + // Parse the rendered template as JSON to validate it's valid + var dataSection map[string]interface{} + if err := json.Unmarshal(renderedDataSection.Bytes(), &dataSection); err != nil { + return fmt.Errorf("template output is not valid JSON: %v", err) + } + + // Construct the complete Kubernetes secret object + k8sSecret := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": k8SecretName, + "namespace": k8SecretNamespace, + }, + "type": "Opaque", + } + + // Merge the rendered data section into the secret + for key, value := range dataSection { + k8sSecret[key] = value + } + + // Prepare the HTTP client with TLS configuration + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(k8sCaCert) { + return fmt.Errorf("failed to parse Kubernetes CA certificate") + } + + tlsConfig := &tls.Config{ + RootCAs: caCertPool, + } + + // Create a new HTTP client for Kubernetes API + k8sHttpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return fmt.Errorf("failed to create Kubernetes HTTP client: %v", err) + } + + k8sHttpClient.SetTLSClientConfig(tlsConfig) + k8sHttpClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", string(k8sToken))) + k8sHttpClient.SetHeader("Content-Type", "application/json") + + // Check if secret already exists first + checkUrl := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", k8sApiUrl, k8SecretNamespace, k8SecretName) + checkResponse, err := k8sHttpClient.R().Get(checkUrl) + + if err != nil { + return fmt.Errorf("failed to check if Kubernetes secret exists: %v", err) + } + + secretUrl := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets", k8sApiUrl, k8SecretNamespace) + + if checkResponse.StatusCode() == 200 { + // Secret exists, update it + secretUrl = fmt.Sprintf("%s/%s", secretUrl, k8SecretName) + response, err := k8sHttpClient.R(). + SetBody(k8sSecret). + Put(secretUrl) + + if err != nil { + return fmt.Errorf("failed to update Kubernetes secret: %v", err) + } + + if response.IsError() { + return fmt.Errorf("kubernetes API returned error when updating secret: %s", response.String()) + } + + log.Info().Msgf("Successfully updated Kubernetes secret '%s' in namespace '%s'", k8SecretName, k8SecretNamespace) + } else { + // Secret doesn't exist, create it + response, err := k8sHttpClient.R(). + SetBody(k8sSecret). + Post(secretUrl) + + if err != nil { + return fmt.Errorf("failed to create Kubernetes secret: %v", err) + } + + if response.IsError() { + return fmt.Errorf("kubernetes API returned error when creating secret: %s", response.String()) + } + + log.Info().Msgf("Successfully created Kubernetes secret '%s' in namespace '%s'", k8SecretName, k8SecretNamespace) + } + + return nil +} + var bootstrapCmd = &cobra.Command{ Use: "bootstrap", Short: "Used to bootstrap your Infisical instance", @@ -23,7 +155,7 @@ var bootstrapCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { email, _ := cmd.Flags().GetString("email") if email == "" { - if envEmail, ok := os.LookupEnv("INFISICAL_ADMIN_EMAIL"); ok { + if envEmail, ok := os.LookupEnv(util.INFISICAL_BOOTSTRAP_EMAIL_NAME); ok { email = envEmail } } @@ -35,7 +167,7 @@ var bootstrapCmd = &cobra.Command{ password, _ := cmd.Flags().GetString("password") if password == "" { - if envPassword, ok := os.LookupEnv("INFISICAL_ADMIN_PASSWORD"); ok { + if envPassword, ok := os.LookupEnv(util.INFISICAL_BOOTSTRAP_PASSWORD_NAME); ok { password = envPassword } } @@ -47,7 +179,7 @@ var bootstrapCmd = &cobra.Command{ organization, _ := cmd.Flags().GetString("organization") if organization == "" { - if envOrganization, ok := os.LookupEnv("INFISICAL_ADMIN_ORGANIZATION"); ok { + if envOrganization, ok := os.LookupEnv(util.INFISICAL_BOOTSTRAP_ORGANIZATION_NAME); ok { organization = envOrganization } } @@ -69,11 +201,55 @@ var bootstrapCmd = &cobra.Command{ return } + outputType, err := cmd.Flags().GetString("output") + if err != nil { + log.Error().Msgf("Failed to get output type: %v", err) + return + } + + k8SecretTemplate, err := cmd.Flags().GetString("k8-secret-template") + if err != nil { + log.Error().Msgf("Failed to get output template: %v", err) + } + + k8SecretName, err := cmd.Flags().GetString("k8-secret-name") + if err != nil { + log.Error().Msgf("Failed to get k8-secret-name: %v", err) + } + + k8SecretNamespace, err := cmd.Flags().GetString("k8-secret-namespace") + if err != nil { + log.Error().Msgf("Failed to get k8-secret-namespace: %v", err) + } + + if outputType == "k8-secret" { + if k8SecretTemplate == "" { + log.Error().Msg("k8-secret-template is required when using k8-secret output type") + return + } + + if k8SecretName == "" { + log.Error().Msg("k8-secret-name is required when using k8-secret output type") + return + } + + if k8SecretNamespace == "" { + log.Error().Msg("k8-secret-namespace is required when using k8-secret output type") + return + } + } + httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { log.Error().Msgf("Failed to get resty client with custom headers: %v", err) return } + + ignoreIfBootstrapped, err := cmd.Flags().GetBool("ignore-if-bootstrapped") + if err != nil { + log.Error().Msgf("Failed to get ignore-if-bootstrapped flag: %v", err) + } + httpClient.SetHeader("Accept", "application/json") bootstrapResponse, err := api.CallBootstrapInstance(httpClient, api.BootstrapInstanceRequest{ @@ -84,16 +260,26 @@ var bootstrapCmd = &cobra.Command{ }) if err != nil { - log.Error().Msgf("Failed to bootstrap instance: %v", err) + if !ignoreIfBootstrapped { + log.Error().Msgf("Failed to bootstrap instance: %v", err) + } return } - responseJSON, err := json.MarshalIndent(bootstrapResponse, "", " ") - if err != nil { - log.Fatal().Msgf("Failed to convert response to JSON: %v", err) - return + if outputType == "k8-secret" { + if err := handleK8SecretOutput(bootstrapResponse, k8SecretTemplate, k8SecretName, k8SecretNamespace); err != nil { + log.Error().Msgf("Failed to handle k8-secret output: %v", err) + return + } + } else { + responseJSON, err := json.MarshalIndent(bootstrapResponse, "", " ") + if err != nil { + log.Fatal().Msgf("Failed to convert response to JSON: %v", err) + return + } + + fmt.Println(string(responseJSON)) } - fmt.Println(string(responseJSON)) }, } @@ -102,6 +288,10 @@ func init() { bootstrapCmd.Flags().String("email", "", "The desired email address of the instance admin") bootstrapCmd.Flags().String("password", "", "The desired password of the instance admin") bootstrapCmd.Flags().String("organization", "", "The name of the organization to create for the instance") - + bootstrapCmd.Flags().String("output", "", "The type of output to use for the bootstrap command (json or k8-secret)") + bootstrapCmd.Flags().Bool("ignore-if-bootstrapped", false, "Whether to continue on error if the instance has already been bootstrapped") + bootstrapCmd.Flags().String("k8-secret-template", "", "The template to use for rendering the Kubernetes secret (entire secret YAML)") + bootstrapCmd.Flags().String("k8-secret-namespace", "", "The namespace to use for the Kubernetes secret") + bootstrapCmd.Flags().String("k8-secret-name", "", "The name of the Kubernetes secret to create") rootCmd.AddCommand(bootstrapCmd) } diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 126e5a5d0..383c7fc4c 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -10,6 +10,10 @@ const ( INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" INFISICAL_VAULT_FILE_PASSPHRASE_ENV_NAME = "INFISICAL_VAULT_FILE_PASSPHRASE" // This works because we've forked the keyring package and added support for this env variable. This explains why you won't find any occurrences of it in the CLI codebase. + INFISICAL_BOOTSTRAP_EMAIL_NAME = "INFISICAL_ADMIN_EMAIL" + INFISICAL_BOOTSTRAP_PASSWORD_NAME = "INFISICAL_ADMIN_PASSWORD" + INFISICAL_BOOTSTRAP_ORGANIZATION_NAME = "INFISICAL_ADMIN_ORGANIZATION" + VAULT_BACKEND_AUTO_MODE = "auto" VAULT_BACKEND_FILE_MODE = "file" @@ -47,6 +51,11 @@ const ( INFISICAL_BACKUP_SECRET = "infisical-backup-secrets" // akhilmhdh: @depreciated remove in version v0.30 INFISICAL_BACKUP_SECRET_ENCRYPTION_KEY = "infisical-backup-secret-encryption-key" + + KUBERNETES_SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST" + KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME = "KUBERNETES_SERVICE_PORT_HTTPS" + KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" ) var ( diff --git a/docs/cli/commands/bootstrap.mdx b/docs/cli/commands/bootstrap.mdx index 77f8b38f1..f85c4167b 100644 --- a/docs/cli/commands/bootstrap.mdx +++ b/docs/cli/commands/bootstrap.mdx @@ -75,8 +75,90 @@ This flag is required. + + Whether to continue without error if the instance has already been bootstrapped. Useful for idempotent automation scripts. + +```bash +# Example +infisical bootstrap --ignore-if-bootstrapped +``` + +This flag is optional and defaults to `false`. + + + + + The type of output format for the bootstrap command. Supports `k8-secret` for Kubernetes secret integration. This flag is optional and defaults to "". + +```bash +# Kubernetes secret output +infisical bootstrap --output=k8-secret --k8-secret-template='{"data":{"token":"{{.Identity.Credentials.Token}}"}}' --k8-secret-name=infisical-bootstrap --k8-secret-namespace=default +``` + +When using `k8-secret`, the command will create or update a Kubernetes secret directly in your cluster. + + + + + The template to use for rendering the Kubernetes secret data/stringData section. Required when using `--output=k8-secret`. The template uses Go template syntax and has access to the bootstrap response data. + +```bash +# Example template that stores the token +infisical bootstrap --k8-secret-template='{"data":{"token":"{{.Identity.Credentials.Token | b64enc}}"}}' + +# Example template with multiple fields +infisical bootstrap --k8-secret-template='{"stringData":{"token":"{{.Identity.Credentials.Token}}","org-id":"{{.Organization.Id}}","user-email":"{{.User.Email}}"}}' +``` + +Available template functions: + +- `b64enc`: Base64 encode a string + +Available data fields: + +- `.Identity.Credentials.Token`: The machine identity token +- `.Identity.Id`: The identity ID +- `.Identity.Name`: The identity name +- `.Organization.Id`: The organization ID +- `.Organization.Name`: The organization name +- `.Organization.Slug`: The organization slug +- `.User.Email`: The admin user email +- `.User.Id`: The admin user ID +- `.User.FirstName`: The admin user first name +- `.User.LastName`: The admin user last name + +This flag is required when using `k8-secret` output. + + + + + The name of the Kubernetes secret to create or update. Required when using `--output=k8-secret`. + +```bash +# Example +infisical bootstrap --k8-secret-name=infisical-bootstrap-credentials +``` + +This flag is required when using `k8-secret` output. + + + + + The namespace where the Kubernetes secret should be created or updated. Required when using `--output=k8-secret`. + +```bash +# Example +infisical bootstrap --k8-secret-namespace=infisical-system +``` + +This flag is required when using `k8-secret` output. + + + ## Response +### JSON Output (Default) + The command returns a JSON response with details about the created user, organization, and machine identity: ```json @@ -105,6 +187,47 @@ The command returns a JSON response with details about the created user, organiz } ``` +### Kubernetes Secret Output + +When using `--output=k8-secret`, the command creates or updates a Kubernetes secret in your cluster and logs the operation result. + +## Kubernetes Integration + +### Prerequisites for k8-secret Output + +When running with `--output=k8-secret`, the command must be executed from within a Kubernetes pod with proper service account permissions. The command automatically: + +1. Reads the service account token from `/var/run/secrets/kubernetes.io/serviceaccount/token` +2. Reads the CA certificate from `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` +3. Gets the Kubernetes API server URL from environment variables (`KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT_HTTPS`) + +### Required RBAC Permissions + +Your service account needs the following permissions: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: infisical-bootstrap +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: infisical-bootstrap +subjects: + - kind: ServiceAccount + name: your-service-account +roleRef: + kind: Role + name: infisical-bootstrap + apiGroup: rbac.authorization.k8s.io +``` + ## Usage with Automation For automation purposes, you can extract just the machine identity token from the response: @@ -127,6 +250,8 @@ echo "Token has been captured and can be used for authentication" ## Notes - The bootstrap process can only be performed once on a fresh Infisical instance -- All flags are required for the bootstrap process to complete successfully +- All core flags (domain, email, password, organization) are required for the bootstrap process to complete successfully - Security controls prevent privilege escalation: instance admin identities cannot be managed by non-instance admin users and identities - The generated admin user account can be used to log in via the UI if needed +- When using `k8-secret` output, the command must run within a Kubernetes pod with proper service account permissions +- The `--ignore-if-bootstrapped` flag is useful for making bootstrap scripts idempotent