feat: added support for outputting bootstrap credentials to k8 secret

This commit is contained in:
Sheen Capadngan
2025-06-19 01:43:47 +08:00
parent 59ac14380a
commit 2b8220a71b
5 changed files with 371 additions and 15 deletions

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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 (

View File

@@ -75,8 +75,90 @@ This flag is required.
</Accordion>
<Accordion title="--ignore-if-bootstrapped">
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`.
</Accordion>
<Accordion title="--output">
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.
</Accordion>
<Accordion title="--k8-secret-template">
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.
</Accordion>
<Accordion title="--k8-secret-name">
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.
</Accordion>
<Accordion title="--k8-secret-namespace">
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.
</Accordion>
## 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