diff --git a/README.md b/README.md index 039fe98..7994ac7 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,15 @@ Commands can use `server` or `servers` interchangeably. - `coolify cloud-token list|get|create|update|delete|validate` - Manage Hetzner, DigitalOcean, and Vultr API tokens - Token values are redacted by default. `--show-sensitive` can reveal them only when the Coolify API token has sensitive-data permission. +### Secret Managers +- `coolify secret-manager create-token --provider --name --provider-token ` - Add a secret manager integration token + - Infisical also requires `--base-url` and `--client-id` + - Vault also requires `--base-url` and optionally accepts `--namespace` +- `coolify app secret-manager set --integration-token-uuid ` - Configure an application to use the integration token + - Doppler service account tokens use `--project` and `--config` + - Infisical uses `--project-id`, `--environment`, and optionally `--secret-path` + - Vault uses `--mount` and `--path` + ### Teams - `coolify team list` - List all teams - `coolify team get ` - Get team details @@ -559,6 +568,11 @@ coolify app env create --key API_KEY --value secret123 # Sync from .env file (updates existing, creates new, keeps others unchanged) coolify app env sync --file .env coolify app env sync --file .env.production --build-time --preview + +# Create a Doppler integration token and configure an application +coolify secret-manager create-token --provider doppler --name production --provider-token dp.sa.example +coolify app secret-manager set --integration-token-uuid \ + --project website --config production ``` ### Database Management diff --git a/cmd/application/application.go b/cmd/application/application.go index a09e926..dd2b497 100644 --- a/cmd/application/application.go +++ b/cmd/application/application.go @@ -39,6 +39,7 @@ func NewAppCommand() *cobra.Command { cmd.AddCommand(apptask.NewExecuteTaskCommand()) cmd.AddCommand(NewRunStorageBackupCommand()) cmd.AddCommand(tag.NewCommand()) + cmd.AddCommand(NewSecretManagerCommand()) // Add env subcommand with its children envCmd := &cobra.Command{ diff --git a/cmd/application/secretmanager.go b/cmd/application/secretmanager.go new file mode 100644 index 0000000..4efc669 --- /dev/null +++ b/cmd/application/secretmanager.go @@ -0,0 +1,80 @@ +package application + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/coollabsio/coolify-cli/internal/cli" + "github.com/coollabsio/coolify-cli/internal/models" + "github.com/coollabsio/coolify-cli/internal/output" + "github.com/coollabsio/coolify-cli/internal/service" +) + +func NewSecretManagerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "secret-manager", + Short: "Configure an application's secret manager", + } + cmd.AddCommand(newSetSecretManagerCommand()) + return cmd +} + +func newSetSecretManagerCommand() *cobra.Command { + var tokenUUID string + settings := map[string]string{} + cmd := &cobra.Command{ + Use: "set ", + Short: "Configure the secret manager used by an application", + Args: cli.ExactArgs(1, ""), + RunE: func(cmd *cobra.Command, args []string) error { + if tokenUUID == "" { + return fmt.Errorf("--integration-token-uuid is required") + } + for _, key := range []string{"project", "config", "project-id", "environment", "secret-path", "mount", "path"} { + value, _ := cmd.Flags().GetString(key) + if value != "" { + settings[flagToSetting(key)] = value + } + } + + client, err := cli.GetAPIClient(cmd) + if err != nil { + return fmt.Errorf("failed to get API client: %w", err) + } + response, err := service.NewSecretManagerService(client).ConfigureApplication(cmd.Context(), args[0], models.ApplicationSecretManagerRequest{ + IntegrationTokenUUID: tokenUUID, + Settings: settings, + }) + if err != nil { + return fmt.Errorf("failed to configure application secret manager: %w", err) + } + + format, _ := cmd.Flags().GetString("format") + formatter, err := output.NewFormatter(format, output.Options{}) + if err != nil { + return err + } + return formatter.Format(response) + }, + } + cmd.Flags().StringVar(&tokenUUID, "integration-token-uuid", "", "Secret manager integration token UUID") + cmd.Flags().String("project", "", "Doppler project (service account tokens)") + cmd.Flags().String("config", "", "Doppler config (service account tokens)") + cmd.Flags().String("project-id", "", "Infisical project ID") + cmd.Flags().String("environment", "", "Infisical environment slug") + cmd.Flags().String("secret-path", "", "Infisical secret path") + cmd.Flags().String("mount", "", "Vault secrets engine mount") + cmd.Flags().String("path", "", "Vault secret path") + return cmd +} + +func flagToSetting(flag string) string { + if flag == "project-id" { + return "project_id" + } + if flag == "secret-path" { + return "secret_path" + } + return flag +} diff --git a/cmd/application/secretmanager_test.go b/cmd/application/secretmanager_test.go new file mode 100644 index 0000000..a9de77c --- /dev/null +++ b/cmd/application/secretmanager_test.go @@ -0,0 +1,27 @@ +package application + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplicationSecretManagerCommand_RegistersSetCommand(t *testing.T) { + cmd := NewSecretManagerCommand() + set, _, err := cmd.Find([]string{"set"}) + + require.NoError(t, err) + assert.Equal(t, "set", set.Name()) + for _, flag := range []string{"integration-token-uuid", "project", "config", "project-id", "environment", "secret-path", "mount", "path"} { + assert.NotNil(t, set.Flags().Lookup(flag), "missing --%s", flag) + } +} + +func TestApplicationCommand_RegistersSecretManager(t *testing.T) { + cmd := NewAppCommand() + child, _, err := cmd.Find([]string{"secret-manager"}) + + require.NoError(t, err) + assert.Equal(t, "secret-manager", child.Name()) +} diff --git a/cmd/root.go b/cmd/root.go index 0ebb4d7..1a0bd10 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ import ( "github.com/coollabsio/coolify-cli/cmd/project" "github.com/coollabsio/coolify-cli/cmd/resources" "github.com/coollabsio/coolify-cli/cmd/s3" + "github.com/coollabsio/coolify-cli/cmd/secretmanager" "github.com/coollabsio/coolify-cli/cmd/server" "github.com/coollabsio/coolify-cli/cmd/service" settingscmd "github.com/coollabsio/coolify-cli/cmd/settings" @@ -119,6 +120,7 @@ func init() { rootCmd.AddCommand(service.NewServiceCommand()) rootCmd.AddCommand(settingscmd.NewSettingsCommand()) rootCmd.AddCommand(sharedenv.NewSharedEnvCommand()) + rootCmd.AddCommand(secretmanager.NewCommand()) rootCmd.AddCommand(teams.NewTeamsCommand()) rootCmd.AddCommand(tag.NewTagCommand()) rootCmd.AddCommand(update.NewUpdateCommand()) diff --git a/cmd/secretmanager/secretmanager.go b/cmd/secretmanager/secretmanager.go new file mode 100644 index 0000000..52166b6 --- /dev/null +++ b/cmd/secretmanager/secretmanager.go @@ -0,0 +1,74 @@ +package secretmanager + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/coollabsio/coolify-cli/internal/cli" + "github.com/coollabsio/coolify-cli/internal/models" + "github.com/coollabsio/coolify-cli/internal/output" + "github.com/coollabsio/coolify-cli/internal/service" +) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "secret-manager", + Aliases: []string{"secret-managers"}, + Short: "Manage secret manager integrations", + } + cmd.AddCommand(newCreateTokenCommand()) + return cmd +} + +func newCreateTokenCommand() *cobra.Command { + var provider, name, providerToken, baseURL, clientID, namespace string + cmd := &cobra.Command{ + Use: "create-token", + Short: "Create a Doppler, Infisical, or Vault integration token", + RunE: func(cmd *cobra.Command, _ []string) error { + provider = strings.ToLower(provider) + if provider != "doppler" && provider != "infisical" && provider != "vault" { + return fmt.Errorf("--provider must be doppler, infisical, or vault") + } + if name == "" || providerToken == "" { + return fmt.Errorf("--name and --provider-token are required") + } + if provider == "infisical" && (baseURL == "" || clientID == "") { + return fmt.Errorf("--base-url and --client-id are required for Infisical") + } + if provider == "vault" && baseURL == "" { + return fmt.Errorf("--base-url is required for Vault") + } + + client, err := cli.GetAPIClient(cmd) + if err != nil { + return fmt.Errorf("failed to get API client: %w", err) + } + response, err := service.NewSecretManagerService(client).CreateToken(cmd.Context(), models.SecretManagerTokenCreateRequest{ + Provider: provider, + Name: name, + Token: providerToken, + Metadata: models.SecretManagerMetadata{BaseURL: baseURL, ClientID: clientID, Namespace: namespace}, + }) + if err != nil { + return fmt.Errorf("failed to create secret manager token: %w", err) + } + + format, _ := cmd.Flags().GetString("format") + formatter, err := output.NewFormatter(format, output.Options{}) + if err != nil { + return err + } + return formatter.Format(response) + }, + } + cmd.Flags().StringVar(&provider, "provider", "", "Secret manager provider: doppler, infisical, or vault") + cmd.Flags().StringVar(&name, "name", "", "Friendly token name") + cmd.Flags().StringVar(&providerToken, "provider-token", "", "Provider token or client secret (sensitive; never included in output)") + cmd.Flags().StringVar(&baseURL, "base-url", "", "Infisical or Vault base URL") + cmd.Flags().StringVar(&clientID, "client-id", "", "Infisical machine identity client ID") + cmd.Flags().StringVar(&namespace, "namespace", "", "Vault namespace") + return cmd +} diff --git a/cmd/secretmanager/secretmanager_test.go b/cmd/secretmanager/secretmanager_test.go new file mode 100644 index 0000000..c7fa4e5 --- /dev/null +++ b/cmd/secretmanager/secretmanager_test.go @@ -0,0 +1,29 @@ +package secretmanager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecretManagerCommand_RegistersCreateToken(t *testing.T) { + cmd := NewCommand() + create, _, err := cmd.Find([]string{"create-token"}) + + require.NoError(t, err) + assert.Equal(t, "create-token", create.Name()) + for _, flag := range []string{"provider", "name", "provider-token", "base-url", "client-id", "namespace"} { + assert.NotNil(t, create.Flags().Lookup(flag), "missing --%s", flag) + } +} + +func TestCreateTokenCommand_ValidatesProviderSpecificFlags(t *testing.T) { + cmd := newCreateTokenCommand() + cmd.SetArgs([]string{"--provider", "infisical", "--name", "Production", "--provider-token", "secret"}) + + err := cmd.Execute() + + require.Error(t, err) + assert.ErrorContains(t, err, "--base-url and --client-id are required") +} diff --git a/internal/models/secretmanager.go b/internal/models/secretmanager.go new file mode 100644 index 0000000..46b497a --- /dev/null +++ b/internal/models/secretmanager.go @@ -0,0 +1,25 @@ +package models + +type SecretManagerMetadata struct { + BaseURL string `json:"base_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type SecretManagerTokenCreateRequest struct { + Provider string `json:"provider"` + Name string `json:"name"` + Token string `json:"token" sensitive:"true" table:"-"` + Metadata SecretManagerMetadata `json:"metadata,omitempty"` +} + +type ApplicationSecretManagerRequest struct { + IntegrationTokenUUID string `json:"integration_token_uuid"` + Settings map[string]string `json:"settings,omitempty"` +} + +type ApplicationSecretManager struct { + IntegrationTokenUUID string `json:"integration_token_uuid"` + Provider string `json:"provider"` + Settings map[string]string `json:"settings,omitempty" table:"-"` +} diff --git a/internal/service/secretmanager.go b/internal/service/secretmanager.go new file mode 100644 index 0000000..24b72d4 --- /dev/null +++ b/internal/service/secretmanager.go @@ -0,0 +1,29 @@ +package service + +import ( + "context" + "net/url" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +type SecretManagerService struct { + client *api.Client +} + +func NewSecretManagerService(client *api.Client) *SecretManagerService { + return &SecretManagerService{client: client} +} + +func (s *SecretManagerService) CreateToken(ctx context.Context, request models.SecretManagerTokenCreateRequest) (*models.UUID, error) { + var response models.UUID + err := s.client.Post(ctx, "security/integration-tokens", request, &response) + return &response, err +} + +func (s *SecretManagerService) ConfigureApplication(ctx context.Context, applicationUUID string, request models.ApplicationSecretManagerRequest) (*models.ApplicationSecretManager, error) { + var response models.ApplicationSecretManager + err := s.client.Patch(ctx, "applications/"+url.PathEscape(applicationUUID)+"/secret-manager", request, &response) + return &response, err +} diff --git a/internal/service/secretmanager_test.go b/internal/service/secretmanager_test.go new file mode 100644 index 0000000..d666470 --- /dev/null +++ b/internal/service/secretmanager_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +func TestSecretManagerService_CreatesTokenAndConfiguresApplication(t *testing.T) { + type observed struct { + method string + path string + body map[string]any + } + requests := make(chan observed, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + item := observed{method: r.Method, path: r.URL.EscapedPath()} + require.NoError(t, json.NewDecoder(r.Body).Decode(&item.body)) + requests <- item + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"uuid":"token-1"}`)) + return + } + _, _ = w.Write([]byte(`{"integration_token_uuid":"token-1","provider":"doppler","settings":{"project":"web","config":"production"}}`)) + })) + defer server.Close() + + svc := NewSecretManagerService(api.NewClient(server.URL, "token", api.WithRetries(0))) + created, err := svc.CreateToken(context.Background(), models.SecretManagerTokenCreateRequest{ + Provider: "doppler", Name: "Production", Token: "dp.sa.secret", + }) + require.NoError(t, err) + assert.Equal(t, "token-1", created.UUID) + + configured, err := svc.ConfigureApplication(context.Background(), "app/id", models.ApplicationSecretManagerRequest{ + IntegrationTokenUUID: "token-1", + Settings: map[string]string{"project": "web", "config": "production"}, + }) + require.NoError(t, err) + assert.Equal(t, "doppler", configured.Provider) + + createRequest := <-requests + assert.Equal(t, http.MethodPost, createRequest.method) + assert.Equal(t, "/api/v1/security/integration-tokens", createRequest.path) + assert.Equal(t, "dp.sa.secret", createRequest.body["token"]) + + configureRequest := <-requests + assert.Equal(t, http.MethodPatch, configureRequest.method) + assert.Equal(t, "/api/v1/applications/app%2Fid/secret-manager", configureRequest.path) + assert.Equal(t, "token-1", configureRequest.body["integration_token_uuid"]) +}