Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <doppler|infisical|vault> --name <name> --provider-token <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 <application_uuid> --integration-token-uuid <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 <team_id>` - Get team details
Expand Down Expand Up @@ -559,6 +568,11 @@ coolify app env create <uuid> --key API_KEY --value secret123
# Sync from .env file (updates existing, creates new, keeps others unchanged)
coolify app env sync <uuid> --file .env
coolify app env sync <uuid> --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 <application_uuid> --integration-token-uuid <token_uuid> \
--project website --config production
```

### Database Management
Expand Down
1 change: 1 addition & 0 deletions cmd/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
80 changes: 80 additions & 0 deletions cmd/application/secretmanager.go
Original file line number Diff line number Diff line change
@@ -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 <uuid>",
Short: "Configure the secret manager used by an application",
Args: cli.ExactArgs(1, "<uuid>"),
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
}
27 changes: 27 additions & 0 deletions cmd/application/secretmanager_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
74 changes: 74 additions & 0 deletions cmd/secretmanager/secretmanager.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions cmd/secretmanager/secretmanager_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
25 changes: 25 additions & 0 deletions internal/models/secretmanager.go
Original file line number Diff line number Diff line change
@@ -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:"-"`
}
29 changes: 29 additions & 0 deletions internal/service/secretmanager.go
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions internal/service/secretmanager_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
Loading