31 Commits

Author SHA1 Message Date
Andras Bacsai f43cd16f6f Merge pull request #64 from coollabsio/coolify-cli-storage-endpoints
feat(storage): add CRUD operations for persistent and file storages
2026-03-23 14:44:30 +01:00
Andras Bacsai e49daeea95 Merge pull request #65 from coollabsio/62-app-env-update
feat(env): allow updating vars by UUID or key identifier
2026-03-23 14:44:19 +01:00
Andras Bacsai ccf578e537 docs(env): reorder parameters in env update commands
Reorganize parameter ordering for consistency across app, database, and
service env update commands. Move string parameters (--key, --value) after
boolean parameters. Simplify --key parameter description.
2026-03-23 14:41:03 +01:00
Andras Bacsai cad379eefb test(service): use assert.True/False helpers for boolean assertions
Replace assert.Equal with more specific boolean assertion helpers
(assert.True and assert.False) for improved readability and
idiomatic Go testing practices.
2026-03-23 14:40:55 +01:00
Andras Bacsai 53ab7b315c feat(storage): require minimum API version 4.0.0-beta.470
Add version check validation across all storage CRUD operations in application,
database, and service commands. This ensures the API client meets the minimum
version requirement before executing storage operations.

Also includes:
- Documentation updates for dockerfile-target-build parameter in llms.txt
- Field alignment formatting fixes in ApplicationCreateRequest structs
2026-03-23 14:38:19 +01:00
Andras Bacsai 8a7d2c20af Merge remote-tracking branch 'origin/v4.x' into coolify-cli-storage-endpoints 2026-03-23 14:32:49 +01:00
Andras Bacsai fcd1a01fb7 Merge remote-tracking branch 'origin/v4.x' into 62-app-env-update 2026-03-23 14:32:48 +01:00
Andras Bacsai f67411de2c feat(storage): add CRUD operations for persistent and file storages
Add comprehensive storage management system for applications, databases, and services:

- Implement storage subcommands (list, create, update, delete) with full API integration
- Add support for both persistent volumes and file-based storage management
- Create Storage model with comprehensive validation for type-specific operations
- Implement ApplicationService, DatabaseService, and ServiceService storage methods
- Add extensive unit tests covering CRUD operations and edge cases
- Integrate storage subcommand into application, database, and service CLI commands
- Add dockerfile-target-build flag support to application creation and update commands

Storage operations support:
- Persistent volumes: create with optional host paths, update mount paths and names
- File storages: create/update with content or file system paths, support directories
- Common features: mount path management, read-only detection, preview suffix toggling
2026-03-23 14:27:21 +01:00
Andras Bacsai 146ce7a7b0 feat(env): allow updating vars by UUID or key identifier
Add support for identifying environment variables by UUID or key name in
update commands, eliminating the requirement to use --key for lookups.
Commands now accept <env_uuid_or_key> as a positional argument to identify
the variable, with --key becoming optional for renaming only. Applied to
app, database, and service env update commands and updated documentation.
2026-03-23 11:16:47 +01:00
Andras Bacsai b661576fc1 Merge pull request #63 from coollabsio/61-expand-application-struct
feat(models): expand Application with extended configuration
2026-03-23 11:07:57 +01:00
Andras Bacsai 0872e48283 feat(models): add ApplicationSettings and expand Application with extended configuration
- Add ApplicationSettings struct for application-level feature flags
- Expand Application model with git, build, health check, and resource limit fields
- Add comprehensive unit tests for Application marshaling/unmarshaling
- Add application.json fixture for testing
2026-03-23 11:03:01 +01:00
Andras Bacsai 303fad333b Merge pull request #61 from Dagnan/fix_format_json_for_database_get_list
fix(database): respect --format flag in database list and get commands
2026-03-23 10:55:28 +01:00
Andras Bacsai 8ee7ec4c0d refactor(docs): use fmt.Fprintf and restrict file permissions to 0600
- Replace sb.WriteString(fmt.Sprintf(...)) with fmt.Fprintf for more idiomatic
  and efficient string formatting
- Tighten output file permissions from 0644 to 0600
- Move pflag to direct dependencies as it's now explicitly used
2026-03-23 10:46:11 +01:00
Andras Bacsai 98f40f03dc Merge pull request #52 from toanalien/feature/add-llms-config
feat: Add llms.txt for AI agent command specification
2026-03-23 10:31:48 +01:00
Andras Bacsai 28521a2ca0 ci: add llms.txt validation workflow job
Adds a new 'llms-txt' job to the test workflow that regenerates llms.txt
and validates it hasn't changed, ensuring the documentation stays in sync
with the CLI implementation.
2026-03-23 10:31:22 +01:00
Andras Bacsai dd4b271faf feat(docs): add llms command to generate machine-readable CLI spec
Add new `docs llms` subcommand that generates a machine-readable llms.txt
file defining all CLI commands and parameters. This enables AI agents to
understand and interact with the CLI programmatically.

- Implement writeLLMsCommand() to recursively document command hierarchy
- Regenerate llms.txt using the new generator for consistency
- Add --output flag to customize the output file path
2026-03-23 10:29:38 +01:00
Andras Bacsai cdc5a1e732 Merge pull request #50 from YaRissi/fix/app-env-sync
fix: app env bulk
2026-03-23 10:28:15 +01:00
Andras Bacsai 7e3639b41a Merge remote-tracking branch 'origin/v4.x' into feature/add-llms-config 2026-03-23 10:24:12 +01:00
Andras Bacsai 6bd783dc8a Merge remote-tracking branch 'origin/v4.x' into v4.x 2026-03-23 10:21:46 +01:00
Andras Bacsai 2ac1d0f869 chore: bump version to v1.5.0 2026-03-23 10:21:28 +01:00
Michel Pigassou f4c4c962ff fix(database): respect --format flag in list and get commands
The database list and get commands were hardcoded to use table format,
ignoring the global --format flag. This prevented users from using
--format=json or --format=pretty output formats.

Changes:
- cmd/database/list.go: read format flag from command
- cmd/database/get.go: read format flag from command
- Both commands now follow the same pattern as other list/get commands
2026-03-22 13:54:34 +01:00
Andras Bacsai 801c2e0b3c Merge pull request #55 from baer95/tablewriter
refactor: improve table output format
2026-03-20 18:14:18 +01:00
Andras Bacsai ea4bec7492 refactor(output): reorganize table formatter and improve test robustness
- Move empty slice check before table writer creation for early exit
- Fix error message typo: "ascii w" → "table"
- Enhance boolean test to dynamically locate data rows instead of hardcoded indices
2026-03-20 18:14:04 +01:00
Andras Bacsai 7e59cd76c3 feat(env): add database environment variable management
Add complete environment variable management for databases with full CRUD
operations, including a new sync command to load variables from .env files.
Support comment field across application, service, and database entities.

- Implement database env service layer with create, read, update, delete, list
- Add sync command for bulk loading and updating from .env files
- Add --comment flag to application and service env commands
- Include comprehensive test coverage for database service operations
- Update to Go 1.24.13
2026-03-20 17:31:42 +01:00
Andras Bacsai 81b9e9cdd0 test(application): fix linter warnings in BulkUpdateEnvs tests
Address unused function parameters and unhandled error return values.
2026-03-19 22:26:15 +01:00
Andras Bacsai fe01e8f9b8 test(application): verify BulkUpdateEnvs request serialization and error handling
- Add request body assertions to validate correct serialization in BulkUpdateEnvs test
- Add new test case for API error handling (500 response)
2026-03-19 22:08:26 +01:00
Andras Bacsai daa2a4cdcb Merge remote-tracking branch 'origin/v4.x' into fix/app-env-sync 2026-03-19 22:06:32 +01:00
Andras Bacsai 1703fd2e52 Merge pull request #60 from coollabsio/next
Next
2026-03-19 22:04:08 +01:00
Bernhard Frick 333ff3c504 refactor: improve table output format
replace text/tabwriter with olekukonko/tablewriter for nicer rendering of output formatted as tables
2026-01-26 21:52:03 +01:00
toanalien 0daae657fb feat: Add llms.txt for AI agent command specification
Introduces a machine-readable llms.txt file that defines all CLI commands and their parameters. This file is generated by analyzing the cobra command definitions and is intended to enable AI agents to understand and interact with the CLI.
2025-12-26 16:17:00 +07:00
YaRissi ea3236672b fixing app env sync 2025-12-19 18:09:25 +01:00
54 changed files with 5311 additions and 120 deletions
+20
View File
@@ -34,6 +34,26 @@ jobs:
- name: Run tests
run: go test -v -race -cover ./...
llms-txt:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Regenerate llms.txt
run: go run ./coolify docs llms
- name: Check uncommitted changes
run: git diff --exit-code llms.txt
- if: failure()
run: echo "::error::llms.txt is out of date. Run 'go run ./coolify docs llms' and commit the changes."
go-mod-tidy:
runs-on: ubuntu-latest
steps:
+4 -4
View File
@@ -149,9 +149,9 @@ Commands can use `server` or `servers` interchangeably.
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
- `coolify app env update <app_uuid>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `coolify app env update <app_uuid> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--preview` - Available in preview deployments
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
@@ -239,9 +239,9 @@ Commands can use `server` or `servers` interchangeably.
- `coolify service env get <service_uuid> <env_uuid_or_key>` - Get a specific environment variable
- `coolify service env create <service_uuid>` - Create a new environment variable
- Same flags as application environment variables
- `coolify service env update <service_uuid>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `coolify service env update <service_uuid> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
+14
View File
@@ -5,6 +5,7 @@ import (
"github.com/coollabsio/coolify-cli/cmd/application/create"
"github.com/coollabsio/coolify-cli/cmd/application/env"
"github.com/coollabsio/coolify-cli/cmd/application/storage"
)
// NewAppCommand creates the app parent command
@@ -43,5 +44,18 @@ func NewAppCommand() *cobra.Command {
envCmd.AddCommand(env.NewSyncEnvCommand())
cmd.AddCommand(envCmd)
// Add storage subcommand with its children
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage application storages",
Long: `List and manage persistent volumes and file storages for applications.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+2
View File
@@ -93,6 +93,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -147,6 +148,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -70,6 +70,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -115,6 +116,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -76,6 +76,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -122,6 +123,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -94,6 +94,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -148,6 +149,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -85,6 +85,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -138,6 +139,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+6 -1
View File
@@ -60,6 +60,10 @@ func NewCreateEnvCommand() *cobra.Command {
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.CreateEnv(ctx, appUUID, req)
@@ -67,7 +71,7 @@ func NewCreateEnvCommand() *cobra.Command {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
fmt.Printf("Environment variable '%s' created successfully.\n", key)
fmt.Printf("UUID: %s\n", env.UUID)
return nil
},
@@ -80,5 +84,6 @@ func NewCreateEnvCommand() *cobra.Command {
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+23 -9
View File
@@ -12,13 +12,14 @@ import (
func NewUpdateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Use: "update <app_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. UUID is the application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<app_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -30,12 +31,24 @@ func NewUpdateEnvCommand() *cobra.Command {
return err
}
appSvc := service.NewApplicationService(client)
// Look up the env var to resolve its key
existingEnv, err := appSvc.GetEnv(ctx, appUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.EnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
@@ -60,15 +73,15 @@ func NewUpdateEnvCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil {
return fmt.Errorf("--key is required")
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.UpdateEnv(ctx, appUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
@@ -79,12 +92,13 @@ func NewUpdateEnvCommand() *cobra.Command {
},
}
cmd.Flags().String("key", "", "New environment variable key")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("preview", false, "Available in preview deployments")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+96
View File
@@ -0,0 +1,96 @@
package storage
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/service"
)
// NewCreateCommand returns the storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <app_uuid>",
Short: "Create a storage for an application",
Long: `Create a persistent volume or file storage for an application.
Examples:
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data --host-path /var/data
coolify app storage create <app_uuid> --type file --mount-path /app/config.yml --content "key: value"
coolify app storage create <app_uuid> --type file --mount-path /app/data --is-directory --fs-path /app/data`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
req := &models.StorageCreateRequest{
Type: storageType,
MountPath: mountPath,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <app_uuid> <storage_uuid>",
Short: "Delete a storage from an application",
Long: `Delete a persistent volume or file storage from an application.
Examples:
coolify app storage delete <app_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<app_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <app_uuid>",
Short: "List all storages for an application",
Long: `List all persistent volumes and file storages for a specific application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
storages, err := appSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+117
View File
@@ -0,0 +1,117 @@
package storage
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/service"
)
// NewUpdateCommand returns the storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Short: "Update a storage for an application",
Long: `Update a persistent volume or file storage for an application.
The --uuid and --type flags are required. Use 'coolify app storage list' to find storage UUIDs.
For read-only storages (from docker-compose or services), only --is-preview-suffix-enabled can be updated.
Examples:
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage update <app_uuid> --uuid <storage_uuid> --type file --content "config content" --mount-path /app/config.yml
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+6
View File
@@ -104,6 +104,11 @@ func NewUpdateCommand() *cobra.Command {
req.PortsMappings = &ports
hasUpdates = true
}
if cmd.Flags().Changed("dockerfile-target-build") {
targetBuild, _ := cmd.Flags().GetString("dockerfile-target-build")
req.DockerfileTargetBuild = &targetBuild
hasUpdates = true
}
if cmd.Flags().Changed("health-check-enabled") {
enabled, _ := cmd.Flags().GetBool("health-check-enabled")
req.HealthCheckEnabled = &enabled
@@ -152,6 +157,7 @@ func NewUpdateCommand() *cobra.Command {
cmd.Flags().String("dockerfile", "", "Dockerfile content")
cmd.Flags().String("docker-image", "", "Docker image name")
cmd.Flags().String("docker-tag", "", "Docker image tag")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
cmd.Flags().String("ports-exposes", "", "Exposed ports")
cmd.Flags().String("ports-mappings", "", "Port mappings")
cmd.Flags().Bool("health-check-enabled", false, "Enable health check")
+28
View File
@@ -4,6 +4,8 @@ import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/database/backup"
"github.com/coollabsio/coolify-cli/cmd/database/env"
"github.com/coollabsio/coolify-cli/cmd/database/storage"
)
// NewDatabaseCommand creates the database parent command with all subcommands
@@ -25,6 +27,19 @@ func NewDatabaseCommand() *cobra.Command {
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
// Add env subcommand
envCmd := &cobra.Command{
Use: "env",
Short: "Manage database environment variables",
}
envCmd.AddCommand(env.NewListCommand())
envCmd.AddCommand(env.NewGetCommand())
envCmd.AddCommand(env.NewCreateCommand())
envCmd.AddCommand(env.NewUpdateCommand())
envCmd.AddCommand(env.NewDeleteCommand())
envCmd.AddCommand(env.NewSyncCommand())
cmd.AddCommand(envCmd)
// Add backup subcommand
backupCmd := &cobra.Command{
Use: "backup",
@@ -39,5 +54,18 @@ func NewDatabaseCommand() *cobra.Command {
backupCmd.AddCommand(backup.NewDeleteExecutionCommand())
cmd.AddCommand(backupCmd)
// Add storage subcommand
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage database storages",
Long: `List and manage persistent volumes and file storages for databases.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+79
View File
@@ -0,0 +1,79 @@
package env
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/service"
)
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <database_uuid>",
Short: "Create an environment variable for a database",
Long: `Create a new environment variable for a specific database. Use --key and --value flags to specify the variable.`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
key, _ := cmd.Flags().GetString("key")
value, _ := cmd.Flags().GetString("value")
if key == "" {
return fmt.Errorf("--key is required")
}
if value == "" {
return fmt.Errorf("--value is required")
}
req := &models.DatabaseEnvironmentVariableCreateRequest{
Key: key,
Value: value,
}
if cmd.Flags().Changed("is-literal") {
isLiteral, _ := cmd.Flags().GetBool("is-literal")
req.IsLiteral = &isLiteral
}
if cmd.Flags().Changed("is-multiline") {
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
req.IsMultiline = &isMultiline
}
if cmd.Flags().Changed("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
dbSvc := service.NewDatabaseService(client)
_, err = dbSvc.CreateEnv(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", key)
return nil
},
}
cmd.Flags().String("key", "", "Environment variable key (required)")
cmd.Flags().String("value", "", "Environment variable value (required)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+56
View File
@@ -0,0 +1,56 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <database_uuid> <env_uuid>",
Short: "Delete an environment variable",
Long: `Delete an environment variable from a database. First UUID is the database, second is the specific environment variable to delete.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
force, _ := cmd.Flags().GetBool("force")
// Prompt for confirmation unless --force is used
if !force {
var response string
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
_, _ = fmt.Scanln(&response)
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
dbSvc := service.NewDatabaseService(client)
err = dbSvc.DeleteEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to delete environment variable: %w", err)
}
fmt.Println("Environment variable deleted successfully.")
return nil
},
}
cmd.Flags().Bool("force", false, "Skip confirmation prompt")
return cmd
}
+57
View File
@@ -0,0 +1,57 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <database_uuid> <env_uuid_or_key>",
Short: "Get environment variable details",
Long: `Get detailed information about a specific environment variable. First UUID is the database, second is the environment variable UUID or key name.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbSvc := service.NewDatabaseService(client)
env, err := dbSvc.GetEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to get environment variable: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive value unless --show-sensitive is used
if !showSensitive {
env.Value = "********"
if env.RealValue != nil {
masked := "********"
env.RealValue = &masked
}
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(env)
},
}
}
+58
View File
@@ -0,0 +1,58 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <database_uuid>",
Short: "List all environment variables for a database",
Long: `List all environment variables for a specific database.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbSvc := service.NewDatabaseService(client)
envs, err := dbSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list environment variables: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive values unless --show-sensitive is used
if !showSensitive {
for i := range envs {
envs[i].Value = "********"
if envs[i].RealValue != nil {
masked := "********"
envs[i].RealValue = &masked
}
}
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(envs)
},
}
}
+145
View File
@@ -0,0 +1,145 @@
package env
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/parser"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewSyncCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sync <database_uuid>",
Short: "Sync environment variables from a .env file",
Long: `Sync environment variables from a .env file. This command intelligently:
- Updates existing environment variables with new values
- Creates new environment variables that don't exist yet
- Uses efficient bulk operations where possible
Example: coolify db env sync abc123 --file .env.production`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
filePath, _ := cmd.Flags().GetString("file")
if filePath == "" {
return fmt.Errorf("--file is required")
}
isLiteral, _ := cmd.Flags().GetBool("is-literal")
// Parse the .env file
envVars, err := parser.ParseEnvFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse .env file: %w", err)
}
if len(envVars) == 0 {
fmt.Println("No environment variables found in file.")
return nil
}
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
// Fetch existing environment variables
dbSvc := service.NewDatabaseService(client)
existingEnvs, err := dbSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list existing environment variables: %w", err)
}
// Build a map of existing env vars by key
existingMap := make(map[string]models.DatabaseEnvironmentVariable)
for _, env := range existingEnvs {
existingMap[env.Key] = env
}
// Separate into updates and creates
var toUpdate []models.DatabaseEnvironmentVariableCreateRequest
var toCreate []models.DatabaseEnvironmentVariableCreateRequest
for _, envVar := range envVars {
req := models.DatabaseEnvironmentVariableCreateRequest{
Key: envVar.Key,
Value: envVar.Value,
}
// Apply flags if explicitly provided
if cmd.Flags().Changed("is-literal") {
req.IsLiteral = &isLiteral
}
// Auto-detect multiline values
if strings.Contains(envVar.Value, "\n") {
multiline := true
req.IsMultiline = &multiline
}
if _, exists := existingMap[envVar.Key]; exists {
toUpdate = append(toUpdate, req)
} else {
toCreate = append(toCreate, req)
}
}
updateCount := 0
createCount := 0
failCount := 0
// Perform bulk update if there are vars to update
if len(toUpdate) > 0 {
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
bulkReq := &models.DatabaseEnvBulkUpdateRequest{
Data: toUpdate,
}
_, err := dbSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
if err != nil {
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
failCount += len(toUpdate)
} else {
updateCount = len(toUpdate)
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
}
}
// Create new variables one by one
if len(toCreate) > 0 {
fmt.Printf("Creating %d new variables...\n", len(toCreate))
for _, req := range toCreate {
_, err := dbSvc.CreateEnv(ctx, uuid, &req)
if err != nil {
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
failCount++
} else {
fmt.Printf(" ✓ Created '%s'\n", req.Key)
createCount++
}
}
}
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
if failCount > 0 {
return fmt.Errorf("some environment variables failed to sync")
}
return nil
},
}
cmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
cmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
return cmd
}
+95
View File
@@ -0,0 +1,95 @@
package env
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/service"
)
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <database_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<database_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
// Look up the env var to resolve its key
existingEnv, err := dbSvc.GetEnv(ctx, dbUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.DatabaseEnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
}
if cmd.Flags().Changed("is-literal") {
isLiteral, _ := cmd.Flags().GetBool("is-literal")
req.IsLiteral = &isLiteral
}
if cmd.Flags().Changed("is-multiline") {
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
req.IsMultiline = &isMultiline
}
if cmd.Flags().Changed("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
env, err := dbSvc.UpdateEnv(ctx, dbUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
return nil
},
}
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+12 -2
View File
@@ -32,15 +32,25 @@ func NewGetCommand() *cobra.Command {
return fmt.Errorf("failed to get database: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter("table", output.Options{
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(database)
if err := formatter.Format(database); err != nil {
return err
}
if !showSensitive && format == output.FormatTable {
fmt.Println("\nNote: Use -s to show sensitive information.")
}
return nil
},
}
}
+15 -2
View File
@@ -30,12 +30,25 @@ func NewListCommand() *cobra.Command {
return fmt.Errorf("failed to list databases: %w", err)
}
formatter, err := output.NewFormatter("table", output.Options{})
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(databases)
if err := formatter.Format(databases); err != nil {
return err
}
if !showSensitive && format == output.FormatTable {
fmt.Println("\nNote: Use -s to show sensitive information.")
}
return nil
},
}
}
+94
View File
@@ -0,0 +1,94 @@
package storage
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/service"
)
// NewCreateCommand returns the database storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <db_uuid>",
Short: "Create a storage for a database",
Long: `Create a persistent volume or file storage for a database.
Examples:
coolify db storage create <db_uuid> --type persistent --name my-volume --mount-path /data
coolify db storage create <db_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
req := &models.StorageCreateRequest{
Type: storageType,
MountPath: mountPath,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the database storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <db_uuid> <storage_uuid>",
Short: "Delete a storage from a database",
Long: `Delete a persistent volume or file storage from a database.
Examples:
coolify db storage delete <db_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<db_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the database storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <db_uuid>",
Short: "List all storages for a database",
Long: `List all persistent volumes and file storages for a specific database.`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
storages, err := dbSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+114
View File
@@ -0,0 +1,114 @@
package storage
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/service"
)
// NewUpdateCommand returns the database storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <db_uuid>",
Short: "Update a storage for a database",
Long: `Update a persistent volume or file storage for a database.
The --uuid and --type flags are required. Use 'coolify db storage list' to find storage UUIDs.
Examples:
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --name my-volume
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+144
View File
@@ -4,9 +4,11 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
"github.com/spf13/pflag"
)
var docsCmd = &cobra.Command{
@@ -85,12 +87,154 @@ The markdown files will be written to the specified directory (default: ./docs).
},
}
var llmsCmd = &cobra.Command{
Use: "llms",
Short: "Generate llms.txt for AI agent command specification",
Long: `Generate a machine-readable llms.txt file that defines all CLI commands and their parameters.
This file is intended to enable AI agents to understand and interact with the CLI.
The output file will be written to the specified path (default: ./llms.txt).`,
Example: ` coolify docs llms
coolify docs llms --output=./llms.txt`,
RunE: func(cmd *cobra.Command, _ []string) error {
outputFile, _ := cmd.Flags().GetString("output")
var sb strings.Builder
writeLLMsCommand(&sb, rootCmd, "coolify")
if err := os.WriteFile(outputFile, []byte(sb.String()), 0600); err != nil {
return fmt.Errorf("failed to write llms.txt: %w", err)
}
absPath, _ := filepath.Abs(outputFile)
fmt.Printf("llms.txt generated successfully: %s\n", absPath)
return nil
},
}
// writeLLMsCommand recursively writes command documentation in llms.txt format.
func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
// Build the full command path including args from Use field
commandPath := parentPath
if cmd.HasParent() {
parts := strings.Fields(cmd.Use)
commandPath = parentPath + " " + parts[0]
// Append positional args from the Use field (e.g., "<uuid>", "[optional]")
if len(parts) > 1 {
commandPath += " " + strings.Join(parts[1:], " ")
}
}
// Skip the docs command itself and help command
if cmd.Name() == "docs" || cmd.Name() == "help" {
return
}
// Determine if this command should be written
isRoot := !cmd.HasParent()
isRunnable := cmd.RunE != nil || cmd.Run != nil
hasVisibleChildren := false
for _, child := range cmd.Commands() {
if !child.Hidden && child.Name() != "help" {
hasVisibleChildren = true
break
}
}
// Write the root command, runnable commands, and leaf commands (no children)
if isRoot || isRunnable || !hasVisibleChildren {
// Get description - prefer Long if it's a single clean sentence, otherwise use Short
description := cmd.Short
if cmd.Long != "" {
longLines := strings.Split(strings.TrimSpace(cmd.Long), "\n")
if len(longLines) == 1 && len(longLines[0]) < 200 {
description = longLines[0]
}
}
fmt.Fprintf(sb, "Command: %s\n", commandPath)
fmt.Fprintf(sb, "Description: %s\n", description)
// For root command, show persistent flags; for others, show local flags
var flags []*pflag.Flag
if isRoot {
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
if f.Name == "help" {
return
}
flags = append(flags, f)
})
} else {
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Name == "help" {
return
}
flags = append(flags, f)
})
}
if len(flags) == 0 {
sb.WriteString("Parameters: (None)\n")
} else {
sb.WriteString("Parameters:\n")
for _, f := range flags {
flagType := f.Value.Type()
// Normalize type names
switch flagType {
case "int", "int32", "int64":
flagType = "integer"
case "bool":
flagType = "boolean"
}
// Check if the flag is marked as required via cobra annotation
// or via "(required)" in the usage string
required := isFlagRequired(f)
fmt.Fprintf(sb, " - name: --%s\n", f.Name)
fmt.Fprintf(sb, " type: %s\n", flagType)
fmt.Fprintf(sb, " description: %s\n", f.Usage)
fmt.Fprintf(sb, " required: %t\n", required)
}
}
sb.WriteString("\n")
}
// Recurse into subcommands
for _, child := range cmd.Commands() {
if child.Hidden || child.Name() == "help" {
continue
}
childPath := parentPath
if cmd.HasParent() {
parts := strings.Fields(cmd.Use)
childPath = parentPath + " " + parts[0]
}
writeLLMsCommand(sb, child, childPath)
}
}
// isFlagRequired checks if a flag is required by looking at cobra annotations
// and the "(required)" convention in usage strings.
func isFlagRequired(f *pflag.Flag) bool {
// Check cobra's MarkFlagRequired annotation
if ann, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(ann) > 0 && ann[0] == "true" {
return true
}
// Check for "(required)" in usage string (convention used in this codebase)
return strings.Contains(strings.ToLower(f.Usage), "(required)")
}
func NewDocsCommand() *cobra.Command {
docsCmd.AddCommand(manCmd)
docsCmd.AddCommand(markdownCmd)
docsCmd.AddCommand(llmsCmd)
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
llmsCmd.Flags().StringP("output", "o", "./llms.txt", "Output file path")
return docsCmd
}
+7 -2
View File
@@ -57,14 +57,18 @@ func NewCreateCommand() *cobra.Command {
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
serviceSvc := service.NewService(client)
env, err := serviceSvc.CreateEnv(ctx, uuid, req)
_, err = serviceSvc.CreateEnv(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
fmt.Printf("Environment variable '%s' created successfully.\n", key)
return nil
},
}
@@ -75,6 +79,7 @@ func NewCreateCommand() *cobra.Command {
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+23 -10
View File
@@ -12,13 +12,14 @@ import (
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <service_uuid>",
Use: "update <service_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. UUID is the service.`,
Args: cli.ExactArgs(1, "<service_uuid>"),
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<service_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
serviceUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -30,13 +31,24 @@ func NewUpdateCommand() *cobra.Command {
return err
}
serviceSvc := service.NewService(client)
// Look up the env var to resolve its key
existingEnv, err := serviceSvc.GetEnv(ctx, serviceUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.ServiceEnvironmentVariableUpdateRequest{}
// Only set fields that were provided
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
@@ -57,15 +69,15 @@ func NewUpdateCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil {
return fmt.Errorf("--key is required")
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
serviceSvc := service.NewService(client)
env, err := serviceSvc.UpdateEnv(ctx, serviceUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
@@ -76,12 +88,13 @@ func NewUpdateCommand() *cobra.Command {
},
}
cmd.Flags().String("key", "", "New environment variable key")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+14
View File
@@ -4,6 +4,7 @@ import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/service/env"
"github.com/coollabsio/coolify-cli/cmd/service/storage"
)
// NewServiceCommand creates the service parent command with all subcommands
@@ -37,5 +38,18 @@ func NewServiceCommand() *cobra.Command {
envCmd.AddCommand(env.NewSyncCommand())
cmd.AddCommand(envCmd)
// Add storage subcommand
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage service storages",
Long: `List and manage persistent volumes and file storages for services.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+103
View File
@@ -0,0 +1,103 @@
package storage
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/service"
)
// NewCreateCommand returns the service storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <service_uuid>",
Short: "Create a storage for a service",
Long: `Create a persistent volume or file storage for a service.
The --resource-uuid flag is required to specify which service sub-resource
(application or database) the storage belongs to.
Examples:
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type persistent --name my-volume --mount-path /data
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
resourceUUID, _ := cmd.Flags().GetString("resource-uuid")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
if resourceUUID == "" {
return fmt.Errorf("--resource-uuid is required (UUID of the service sub-resource)")
}
req := &models.ServiceStorageCreateRequest{
Type: storageType,
MountPath: mountPath,
ResourceUUID: resourceUUID,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("resource-uuid", "", "UUID of the service sub-resource (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the service storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <service_uuid> <storage_uuid>",
Short: "Delete a storage from a service",
Long: `Delete a persistent volume or file storage from a service.
Examples:
coolify svc storage delete <service_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<service_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the service storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <service_uuid>",
Short: "List all storages for a service",
Long: `List all persistent volumes and file storages for a specific service.`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
storages, err := svcSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+114
View File
@@ -0,0 +1,114 @@
package storage
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/service"
)
// NewUpdateCommand returns the service storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <service_uuid>",
Short: "Update a storage for a service",
Long: `Update a persistent volume or file storage for a service.
The --uuid and --type flags are required. Use 'coolify svc storage list' to find storage UUIDs.
Examples:
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --name my-volume
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+13 -2
View File
@@ -1,12 +1,14 @@
module github.com/coollabsio/coolify-cli
go 1.24.6
go 1.24.13
require (
github.com/adrg/xdg v0.5.3
github.com/creativeprojects/go-selfupdate v1.5.1
github.com/hashicorp/go-version v1.7.0
github.com/olekukonko/tablewriter v1.1.2
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
@@ -15,9 +17,13 @@ require (
code.gitea.io/sdk/gitea v0.22.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -26,13 +32,18 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/go-gitlab v0.115.0 // indirect
+19
View File
@@ -6,6 +6,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -50,8 +56,19 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg=
github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc=
github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -97,6 +114,8 @@ golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+249 -16
View File
@@ -1,16 +1,116 @@
package models
// ApplicationSettings represents the settings for a Coolify application
type ApplicationSettings struct {
ID int `json:"-" table:"-"`
ApplicationID int `json:"-" table:"-"`
IsStatic *bool `json:"is_static,omitempty"`
IsBuildServerEnabled *bool `json:"is_build_server_enabled,omitempty"`
IsPreserveRepositoryEnabled *bool `json:"is_preserve_repository_enabled,omitempty"`
IsAutoDeployEnabled *bool `json:"is_auto_deploy_enabled,omitempty"`
IsForceHTTPSEnabled *bool `json:"is_force_https_enabled,omitempty"`
IsDebugEnabled *bool `json:"is_debug_enabled,omitempty"`
IsPreviewDeploymentsEnabled *bool `json:"is_preview_deployments_enabled,omitempty"`
IsGitSubmodulesEnabled *bool `json:"is_git_submodules_enabled,omitempty"`
IsGitLFSEnabled *bool `json:"is_git_lfs_enabled,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// Application represents a Coolify application
type Application struct {
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
GitBranch *string `json:"git_branch,omitempty"`
FQDN *string `json:"fqdn,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
// Table-visible fields
UUID string `json:"uuid"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
FQDN *string `json:"fqdn,omitempty"`
GitRepository *string `json:"git_repository,omitempty"`
GitBranch *string `json:"git_branch,omitempty"`
BuildPack *string `json:"build_pack,omitempty"`
PortsExposes *string `json:"ports_exposes,omitempty"`
// Git extended (JSON-only)
GitCommitSHA *string `json:"git_commit_sha,omitempty" table:"-"`
GitFullURL *string `json:"git_full_url,omitempty" table:"-"`
// Build configuration (JSON-only)
InstallCommand *string `json:"install_command,omitempty" table:"-"`
BuildCommand *string `json:"build_command,omitempty" table:"-"`
StartCommand *string `json:"start_command,omitempty" table:"-"`
BaseDirectory *string `json:"base_directory,omitempty" table:"-"`
PublishDirectory *string `json:"publish_directory,omitempty" table:"-"`
StaticImage *string `json:"static_image,omitempty" table:"-"`
// Docker configuration (JSON-only)
Dockerfile *string `json:"dockerfile,omitempty" table:"-"`
DockerfileLocation *string `json:"dockerfile_location,omitempty" table:"-"`
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty" table:"-"`
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty" table:"-"`
DockerCompose *string `json:"docker_compose,omitempty" table:"-"`
DockerComposeRaw *string `json:"docker_compose_raw,omitempty" table:"-"`
DockerComposeLocation *string `json:"docker_compose_location,omitempty" table:"-"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty" table:"-"`
CustomLabels *string `json:"custom_labels,omitempty" table:"-"`
CustomNginxConfiguration *string `json:"custom_nginx_configuration,omitempty" table:"-"`
// Networking (JSON-only)
PortsMappings *string `json:"ports_mappings,omitempty" table:"-"`
Domains *string `json:"domains,omitempty" table:"-"`
Redirect *string `json:"redirect,omitempty" table:"-"`
PreviewURLTemplate *string `json:"preview_url_template,omitempty" table:"-"`
// Health checks (JSON-only)
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty" table:"-"`
HealthCheckPath *string `json:"health_check_path,omitempty" table:"-"`
HealthCheckPort *string `json:"health_check_port,omitempty" table:"-"`
HealthCheckHost *string `json:"health_check_host,omitempty" table:"-"`
HealthCheckMethod *string `json:"health_check_method,omitempty" table:"-"`
HealthCheckScheme *string `json:"health_check_scheme,omitempty" table:"-"`
HealthCheckReturnCode *int `json:"health_check_return_code,omitempty" table:"-"`
HealthCheckResponseText *string `json:"health_check_response_text,omitempty" table:"-"`
HealthCheckInterval *int `json:"health_check_interval,omitempty" table:"-"`
HealthCheckTimeout *int `json:"health_check_timeout,omitempty" table:"-"`
HealthCheckRetries *int `json:"health_check_retries,omitempty" table:"-"`
HealthCheckStartPeriod *int `json:"health_check_start_period,omitempty" table:"-"`
// Resource limits (JSON-only)
LimitsCPUs *string `json:"limits_cpus,omitempty" table:"-"`
LimitsCPUShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
LimitsCPUSet *string `json:"limits_cpuset,omitempty" table:"-"`
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
// Deployment hooks (JSON-only)
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty" table:"-"`
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty" table:"-"`
PostDeploymentCommand *string `json:"post_deployment_command,omitempty" table:"-"`
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty" table:"-"`
// Webhook secrets (JSON-only)
ManualWebhookSecretGitHub *string `json:"manual_webhook_secret_github,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretGitLab *string `json:"manual_webhook_secret_gitlab,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretBitbucket *string `json:"manual_webhook_secret_bitbucket,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretGitea *string `json:"manual_webhook_secret_gitea,omitempty" table:"-" sensitive:"true"`
// Misc (JSON-only)
WatchPaths *string `json:"watch_paths,omitempty" table:"-"`
SwarmReplicas *int `json:"swarm_replicas,omitempty" table:"-"`
ConfigHash *string `json:"config_hash,omitempty" table:"-"`
// Nested settings (JSON-only)
Settings *ApplicationSettings `json:"settings,omitempty" table:"-"`
// Hidden fields (not in JSON or table output)
ID int `json:"-" table:"-"`
EnvironmentID *int `json:"-" table:"-"`
DestinationID *int `json:"-" table:"-"`
SourceID *int `json:"-" table:"-"`
PrivateKeyID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// ApplicationListItem represents a simplified application for list view
@@ -43,6 +143,7 @@ type ApplicationUpdateRequest struct {
// Docker configuration
Dockerfile *string `json:"dockerfile,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty"`
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
@@ -106,6 +207,7 @@ type EnvironmentVariable struct {
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
ApplicationID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
@@ -114,13 +216,14 @@ type EnvironmentVariable struct {
// EnvironmentVariableCreateRequest represents the request to create an environment variable
type EnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
@@ -132,6 +235,131 @@ type EnvironmentVariableUpdateRequest struct {
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// StoragesResponse represents the API response for listing storages
type StoragesResponse struct {
PersistentStorages []PersistentStorage `json:"persistent_storages"`
FileStorages []FileStorage `json:"file_storages"`
}
// PersistentStorage represents a persistent volume for an application
type PersistentStorage struct {
ID int `json:"id" table:"-"`
UUID string `json:"uuid"`
Name string `json:"name"`
MountPath string `json:"mount_path"`
HostPath *string `json:"host_path,omitempty"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
IsReadOnly bool `json:"is_readonly"`
ResourceType string `json:"resource_type" table:"-"`
ResourceID int `json:"resource_id" table:"-"`
}
// FileStorage represents a file storage for an application
type FileStorage struct {
ID int `json:"id"`
UUID string `json:"uuid"`
FsPath string `json:"fs_path"`
MountPath string `json:"mount_path"`
Content *string `json:"content,omitempty"`
IsDirectory bool `json:"is_directory"`
IsBasedOnGit bool `json:"is_based_on_git"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
Chown *string `json:"chown,omitempty"`
Chmod *string `json:"chmod,omitempty"`
ResourceType string `json:"resource_type" table:"-"`
ResourceID int `json:"resource_id" table:"-"`
}
// StorageListItem is a unified view of both storage types for table output
type StorageListItem struct {
ID int `json:"id" table:"-"`
UUID string `json:"uuid"`
Type string `json:"type"`
Name string `json:"name"`
MountPath string `json:"mount_path"`
HostPath string `json:"host_path,omitempty"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
Content string `json:"content,omitempty" table:"-"`
}
// MergeStorages converts a StoragesResponse into a unified list of StorageListItem
func MergeStorages(resp StoragesResponse) []StorageListItem {
var items []StorageListItem
for _, ps := range resp.PersistentStorages {
hostPath := ""
if ps.HostPath != nil {
hostPath = *ps.HostPath
}
items = append(items, StorageListItem{
ID: ps.ID,
UUID: ps.UUID,
Type: "persistent",
Name: ps.Name,
MountPath: ps.MountPath,
HostPath: hostPath,
IsPreviewSuffixEnabled: ps.IsPreviewSuffixEnabled,
})
}
for _, fs := range resp.FileStorages {
content := ""
if fs.Content != nil {
content = *fs.Content
}
items = append(items, StorageListItem{
ID: fs.ID,
UUID: fs.UUID,
Type: "file",
Name: fs.FsPath,
MountPath: fs.MountPath,
IsPreviewSuffixEnabled: fs.IsPreviewSuffixEnabled,
Content: content,
})
}
return items
}
// StorageCreateRequest represents the request to create a storage for applications and databases
type StorageCreateRequest struct {
Type string `json:"type"` // "persistent" or "file"
MountPath string `json:"mount_path"` // required
Name *string `json:"name,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
IsDirectory *bool `json:"is_directory,omitempty"`
FsPath *string `json:"fs_path,omitempty"`
}
// ServiceStorageCreateRequest represents the request to create a storage for services
// Services require resource_uuid to identify which sub-resource the storage belongs to
type ServiceStorageCreateRequest struct {
Type string `json:"type"` // "persistent" or "file"
MountPath string `json:"mount_path"` // required
ResourceUUID string `json:"resource_uuid"` // required for services
Name *string `json:"name,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
IsDirectory *bool `json:"is_directory,omitempty"`
FsPath *string `json:"fs_path,omitempty"`
}
// StorageUpdateRequest represents the request to update a storage
type StorageUpdateRequest struct {
// Required fields
Type string `json:"type"` // "persistent" or "file"
// Identifier (uuid preferred, id deprecated)
UUID *string `json:"uuid,omitempty"`
ID *int `json:"id,omitempty"`
// Optional fields
IsPreviewSuffixEnabled *bool `json:"is_preview_suffix_enabled,omitempty"`
Name *string `json:"name,omitempty"`
MountPath *string `json:"mount_path,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
}
// ApplicationCreatePublicRequest for POST /applications/public
@@ -164,6 +392,7 @@ type ApplicationCreatePublicRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
// Health checks
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
@@ -207,6 +436,7 @@ type ApplicationCreateGitHubAppRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -246,6 +476,7 @@ type ApplicationCreateDeployKeyRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -276,6 +507,7 @@ type ApplicationCreateDockerfileRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -307,6 +539,7 @@ type ApplicationCreateDockerImageRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
+46
View File
@@ -164,6 +164,52 @@ type DatabaseLifecycleResponse struct {
Message string `json:"message"`
}
// DatabaseEnvironmentVariable represents an environment variable for a database
type DatabaseEnvironmentVariable struct {
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Key string `json:"key"`
Value string `json:"value" sensitive:"true"`
IsBuildTime bool `json:"is_buildtime"`
IsLiteralValue bool `json:"is_literal"`
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
DatabaseID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// DatabaseEnvironmentVariableCreateRequest represents the request to create a database environment variable
type DatabaseEnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsShownOnce *bool `json:"is_shown_once,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// DatabaseEnvironmentVariableUpdateRequest represents the request to update a database environment variable
type DatabaseEnvironmentVariableUpdateRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsShownOnce *bool `json:"is_shown_once,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// DatabaseEnvBulkUpdateRequest represents the request to bulk update database environment variables
type DatabaseEnvBulkUpdateRequest struct {
Data []DatabaseEnvironmentVariableCreateRequest `json:"data"`
}
// DatabaseEnvBulkUpdateResponse represents the response from database bulk update
type DatabaseEnvBulkUpdateResponse []DatabaseEnvironmentVariable
// DatabaseBackup represents a scheduled database backup configuration
type DatabaseBackup struct {
ID int `json:"-" table:"-"`
+120
View File
@@ -99,6 +99,126 @@ func TestProject_UnmarshalFromFixture(t *testing.T) {
assert.Equal(t, "running", project.Environments[0].Applications[0].Status)
}
func TestApplication_MarshalUnmarshal(t *testing.T) {
desc := "Test application"
repo := "https://github.com/example/app"
branch := "main"
fqdn := "https://app.example.com"
buildPack := "nixpacks"
ports := "3000"
installCmd := "npm install"
buildCmd := "npm run build"
startCmd := "npm start"
limitsCPUs := "2"
limitsMemory := "512M"
healthPath := "/health"
isAutoDeployEnabled := true
app := Application{
ID: 1,
UUID: "app-uuid",
Name: "My App",
Description: &desc,
Status: "running",
FQDN: &fqdn,
GitRepository: &repo,
GitBranch: &branch,
BuildPack: &buildPack,
PortsExposes: &ports,
InstallCommand: &installCmd,
BuildCommand: &buildCmd,
StartCommand: &startCmd,
LimitsCPUs: &limitsCPUs,
LimitsMemory: &limitsMemory,
HealthCheckPath: &healthPath,
Settings: &ApplicationSettings{
IsAutoDeployEnabled: &isAutoDeployEnabled,
},
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
// Marshal
data, err := json.Marshal(app)
require.NoError(t, err)
// Unmarshal
var unmarshaled Application
err = json.Unmarshal(data, &unmarshaled)
require.NoError(t, err)
assert.Equal(t, app.UUID, unmarshaled.UUID)
assert.Equal(t, app.Name, unmarshaled.Name)
assert.Equal(t, *app.GitRepository, *unmarshaled.GitRepository)
assert.Equal(t, *app.BuildPack, *unmarshaled.BuildPack)
assert.Equal(t, *app.InstallCommand, *unmarshaled.InstallCommand)
assert.Equal(t, *app.LimitsCPUs, *unmarshaled.LimitsCPUs)
assert.NotNil(t, unmarshaled.Settings)
assert.True(t, *unmarshaled.Settings.IsAutoDeployEnabled)
}
func TestApplication_UnmarshalFromFixture(t *testing.T) {
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "application.json"))
require.NoError(t, err)
var app Application
err = json.Unmarshal(fixtureData, &app)
require.NoError(t, err)
assert.Equal(t, "app-fixture-uuid-123", app.UUID)
assert.Equal(t, "My Web Application", app.Name)
assert.Equal(t, "running", app.Status)
assert.Equal(t, "https://app.example.com", *app.FQDN)
assert.Equal(t, "https://github.com/example/app", *app.GitRepository)
assert.Equal(t, "main", *app.GitBranch)
assert.Equal(t, "nixpacks", *app.BuildPack)
assert.Equal(t, "3000", *app.PortsExposes)
assert.Equal(t, "npm install", *app.InstallCommand)
assert.Equal(t, "npm run build", *app.BuildCommand)
assert.Equal(t, "npm start", *app.StartCommand)
assert.Equal(t, "/health", *app.HealthCheckPath)
assert.Equal(t, 200, *app.HealthCheckReturnCode)
assert.Equal(t, "2", *app.LimitsCPUs)
assert.Equal(t, "512M", *app.LimitsMemory)
assert.Equal(t, "npm run migrate", *app.PreDeploymentCommand)
assert.Equal(t, 1, *app.SwarmReplicas)
assert.Equal(t, "abc123", *app.ConfigHash)
// Nested settings
require.NotNil(t, app.Settings)
assert.NotNil(t, app.Settings.IsAutoDeployEnabled)
assert.True(t, *app.Settings.IsAutoDeployEnabled)
assert.True(t, *app.Settings.IsForceHTTPSEnabled)
assert.False(t, *app.Settings.IsStatic)
}
func TestApplication_JSONExcludesHiddenFields(t *testing.T) {
app := Application{
ID: 42,
UUID: "app-uuid",
Name: "Test App",
Status: "running",
EnvironmentID: intPtr(1),
DestinationID: intPtr(2),
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
data, err := json.Marshal(app)
require.NoError(t, err)
jsonStr := string(data)
assert.NotContains(t, jsonStr, `"id"`)
assert.NotContains(t, jsonStr, `"environment_id"`)
assert.NotContains(t, jsonStr, `"destination_id"`)
assert.NotContains(t, jsonStr, `"created_at"`)
assert.NotContains(t, jsonStr, `"updated_at"`)
assert.Contains(t, jsonStr, `"uuid"`)
assert.Contains(t, jsonStr, `"name"`)
}
func intPtr(v int) *int { return &v }
func TestResource_MarshalUnmarshal(t *testing.T) {
resource := Resource{
ID: 1,
+9 -6
View File
@@ -82,6 +82,7 @@ type ServiceEnvironmentVariable struct {
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
ServiceID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
@@ -90,12 +91,13 @@ type ServiceEnvironmentVariable struct {
// ServiceEnvironmentVariableCreateRequest represents the request to create a service environment variable
type ServiceEnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ServiceEnvironmentVariableUpdateRequest represents the request to update a service environment variable
@@ -106,6 +108,7 @@ type ServiceEnvironmentVariableUpdateRequest struct {
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ServiceEnvBulkUpdateRequest represents the request to bulk update service environment variables
+17 -6
View File
@@ -210,12 +210,23 @@ func TestTableFormatter_BooleanValues(t *testing.T) {
output := buf.String()
// Check boolean formatting
lines := strings.Split(output, "\n")
assert.Contains(t, lines[1], "true")
assert.Contains(t, lines[1], "false")
assert.Contains(t, lines[2], "false")
assert.Contains(t, lines[2], "true")
// Check boolean formatting - verify both rows contain expected values
// Row 1: test1, true, false
assert.Contains(t, output, "test1")
assert.Contains(t, output, "test2")
// Find lines containing test data (not headers/borders)
var dataLines []string
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "test1") || strings.Contains(line, "test2") {
dataLines = append(dataLines, line)
}
}
require.Len(t, dataLines, 2, "expected exactly 2 data rows")
assert.Contains(t, dataLines[0], "true")
assert.Contains(t, dataLines[0], "false")
assert.Contains(t, dataLines[1], "true")
assert.Contains(t, dataLines[1], "false")
}
func TestTableFormatter_NilPointer(t *testing.T) {
+34 -39
View File
@@ -4,7 +4,9 @@ import (
"fmt"
"reflect"
"strings"
"text/tabwriter"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
)
// TableFormatter formats output as a table
@@ -18,21 +20,6 @@ func NewTableFormatter(opts Options) *TableFormatter {
}
func (f *TableFormatter) Format(data any) (err error) {
w := tabwriter.NewWriter(f.opts.Writer, 0, 0, 2, ' ', tabwriter.Debug)
defer func() {
if flushErr := w.Flush(); flushErr != nil {
if err == nil {
err = fmt.Errorf("failed to flush table writer: %w", flushErr)
}
}
// Add a final newline nach table output, but only if no error occurred
if err == nil {
if _, nlErr := fmt.Fprintln(f.opts.Writer); nlErr != nil {
err = fmt.Errorf("failed to write trailing newline: %w", nlErr)
}
}
}()
// Handle different data types
val := reflect.ValueOf(data)
@@ -41,6 +28,24 @@ func (f *TableFormatter) Format(data any) (err error) {
val = val.Elem()
}
// Check for empty slice/array before creating the table writer
if (val.Kind() == reflect.Slice || val.Kind() == reflect.Array) && val.Len() == 0 {
if _, writeErr := fmt.Fprintln(f.opts.Writer, "No data"); writeErr != nil {
return fmt.Errorf("failed to write no data message: %w", writeErr)
}
return nil
}
w := tablewriter.NewWriter(f.opts.Writer)
defer func() {
if renderErr := w.Render(); renderErr != nil && err == nil {
err = fmt.Errorf("failed to render table: %w", renderErr)
}
}()
// disable ALL CAPS for column headers
w.Options(tablewriter.WithHeaderAutoFormat(tw.Off))
switch val.Kind() {
case reflect.Slice, reflect.Array:
return f.formatSlice(w, val)
@@ -54,14 +59,7 @@ func (f *TableFormatter) Format(data any) (err error) {
}
// formatSlice formats a slice of structs as a table
func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) error {
if val.Len() == 0 {
if _, err := fmt.Fprintln(w, "No data"); err != nil {
return fmt.Errorf("failed to write no data message: %w", err)
}
return nil
}
func (f *TableFormatter) formatSlice(w *tablewriter.Table, val reflect.Value) error {
// Get the first element to determine columns
firstElem := val.Index(0)
if firstElem.Kind() == reflect.Ptr {
@@ -71,7 +69,9 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
if firstElem.Kind() != reflect.Struct {
// Simple slice (e.g., []string)
for i := 0; i < val.Len(); i++ {
if _, err := fmt.Fprintf(w, "%v\n", val.Index(i).Interface()); err != nil {
elem := val.Index(i)
row := []string{f.formatValue(elem)}
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write slice element: %w", err)
}
}
@@ -82,9 +82,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
headers := f.getHeaders(firstElem.Type())
// Add # as first column header
headersWithNum := append([]string{"#"}, headers...)
if _, err := fmt.Fprintln(w, strings.Join(headersWithNum, "\t")); err != nil {
return fmt.Errorf("failed to write table headers: %w", err)
}
w.Header(headersWithNum)
// Print rows
for i := 0; i < val.Len(); i++ {
@@ -95,7 +93,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
row := f.formatStructRow(elem)
// Add row number (1-indexed) as first column
rowWithNum := append([]string{fmt.Sprintf("%d", i+1)}, row...)
if _, err := fmt.Fprintln(w, strings.Join(rowWithNum, "\t")); err != nil {
if err := w.Append(rowWithNum); err != nil {
return fmt.Errorf("failed to write table row: %w", err)
}
}
@@ -104,16 +102,14 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
}
// formatStruct formats a single struct as a table (horizontal layout with headers)
func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) error {
func (f *TableFormatter) formatStruct(w *tablewriter.Table, val reflect.Value) error {
// Get headers
headers := f.getHeaders(val.Type())
if _, err := fmt.Fprintln(w, strings.Join(headers, "\t")); err != nil {
return fmt.Errorf("failed to write struct headers: %w", err)
}
w.Header(headers)
// Get row data
row := f.formatStructRow(val)
if _, err := fmt.Fprintln(w, strings.Join(row, "\t")); err != nil {
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write struct row: %w", err)
}
@@ -121,16 +117,15 @@ func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) er
}
// formatMap formats a map as a table
func (f *TableFormatter) formatMap(w *tabwriter.Writer, val reflect.Value) error {
if _, err := fmt.Fprintln(w, "Key\tValue"); err != nil {
return fmt.Errorf("failed to write map headers: %w", err)
}
func (f *TableFormatter) formatMap(w *tablewriter.Table, val reflect.Value) error {
w.Header([]string{"Key", "Value"})
iter := val.MapRange()
for iter.Next() {
key := iter.Key()
value := iter.Value()
if _, err := fmt.Fprintf(w, "%v\t%v\n", key.Interface(), f.formatValue(value)); err != nil {
row := []string{f.formatValue(key), f.formatValue(value)}
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write map entry: %w", err)
}
}
+38 -3
View File
@@ -184,9 +184,7 @@ type BulkUpdateEnvsRequest struct {
}
// BulkUpdateEnvsResponse represents the response from bulk update
type BulkUpdateEnvsResponse struct {
Message string `json:"message"`
}
type BulkUpdateEnvsResponse []models.EnvironmentVariable
// BulkUpdateEnvs updates multiple environment variables in a single request
func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
@@ -198,6 +196,43 @@ func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string,
return &response, nil
}
// ListStorages retrieves all storages for an application
func (s *ApplicationService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for application %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for an application
func (s *ApplicationService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for application %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for an application
func (s *ApplicationService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for application %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from an application
func (s *ApplicationService) DeleteStorage(ctx context.Context, appUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/storages/%s", appUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from application %s: %w", storageUUID, appUUID, err)
}
return nil
}
// CreatePublic creates an application from a public git repository
func (s *ApplicationService) CreatePublic(ctx context.Context, req *models.ApplicationCreatePublicRequest) (*models.Application, error) {
var app models.Application
+380 -14
View File
@@ -110,17 +110,41 @@ func TestApplicationService_Get(t *testing.T) {
desc := "Test Application"
branch := "main"
fqdn := "test.example.com"
repo := "https://github.com/example/app"
buildPack := "nixpacks"
ports := "3000"
installCmd := "npm install"
buildCmd := "npm run build"
startCmd := "npm start"
healthPath := "/health"
limitsCPUs := "2"
limitsMemory := "512M"
preDeployCmd := "npm run migrate"
isAutoDeployEnabled := true
application := models.Application{
ID: 1,
UUID: "app-uuid-123",
Name: "Test App",
Description: &desc,
Status: "running",
GitBranch: &branch,
FQDN: &fqdn,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
ID: 1,
UUID: "app-uuid-123",
Name: "Test App",
Description: &desc,
Status: "running",
GitBranch: &branch,
FQDN: &fqdn,
GitRepository: &repo,
BuildPack: &buildPack,
PortsExposes: &ports,
InstallCommand: &installCmd,
BuildCommand: &buildCmd,
StartCommand: &startCmd,
HealthCheckPath: &healthPath,
LimitsCPUs: &limitsCPUs,
LimitsMemory: &limitsMemory,
PreDeploymentCommand: &preDeployCmd,
Settings: &models.ApplicationSettings{
IsAutoDeployEnabled: &isAutoDeployEnabled,
},
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -144,6 +168,18 @@ func TestApplicationService_Get(t *testing.T) {
assert.Equal(t, "running", result.Status)
assert.Equal(t, "main", *result.GitBranch)
assert.Equal(t, "test.example.com", *result.FQDN)
assert.Equal(t, "https://github.com/example/app", *result.GitRepository)
assert.Equal(t, "nixpacks", *result.BuildPack)
assert.Equal(t, "3000", *result.PortsExposes)
assert.Equal(t, "npm install", *result.InstallCommand)
assert.Equal(t, "npm run build", *result.BuildCommand)
assert.Equal(t, "npm start", *result.StartCommand)
assert.Equal(t, "/health", *result.HealthCheckPath)
assert.Equal(t, "2", *result.LimitsCPUs)
assert.Equal(t, "512M", *result.LimitsMemory)
assert.Equal(t, "npm run migrate", *result.PreDeploymentCommand)
require.NotNil(t, result.Settings)
assert.True(t, *result.Settings.IsAutoDeployEnabled)
}
func TestApplicationService_Get_NotFound(t *testing.T) {
@@ -724,10 +760,12 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
comment := "API key for external service"
env := models.EnvironmentVariable{
UUID: "env-uuid-1",
Key: "API_KEY",
Value: "newsecret456",
UUID: "env-uuid-1",
Key: "API_KEY",
Value: "newsecret456",
Comment: &comment,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(env)
@@ -739,9 +777,11 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
newKey := "API_KEY"
newValue := "newsecret456"
newComment := "API key for external service"
req := &models.EnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
Key: &newKey,
Value: &newValue,
Comment: &newComment,
}
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
@@ -749,6 +789,7 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
assert.NotNil(t, result)
assert.Equal(t, "API_KEY", result.Key)
assert.Equal(t, "newsecret456", result.Value)
assert.Equal(t, "API key for external service", *result.Comment)
}
func TestApplicationService_UpdateEnv_Error(t *testing.T) {
@@ -1196,3 +1237,328 @@ func TestApplicationService_CreateDockerImage(t *testing.T) {
assert.NotNil(t, result)
assert.Equal(t, "new-app-uuid", result.UUID)
}
func TestApplicationService_BulkUpdateEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs/bulk", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
// Verify request body was correctly serialized
var reqBody BulkUpdateEnvsRequest
err := json.NewDecoder(r.Body).Decode(&reqBody)
assert.NoError(t, err)
assert.Len(t, reqBody.Data, 1)
assert.Equal(t, "KEY1", reqBody.Data[0].Key)
assert.Equal(t, "VAL1", reqBody.Data[0].Value)
// Return array response as per API
w.Header().Set("Content-Type", "application/json")
resp := []models.EnvironmentVariable{
{
Key: "KEY1",
Value: "VAL1",
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &BulkUpdateEnvsRequest{
Data: []models.EnvironmentVariableCreateRequest{
{Key: "KEY1", Value: "VAL1"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Len(t, *result, 1)
assert.Equal(t, "KEY1", (*result)[0].Key)
}
func TestApplicationService_BulkUpdateEnvs_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &BulkUpdateEnvsRequest{
Data: []models.EnvironmentVariableCreateRequest{
{Key: "KEY1", Value: "VAL1"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to bulk update environment variables")
}
func TestApplicationService_ListStorages(t *testing.T) {
hostPath := "/var/data"
content := "key: value"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: false,
IsReadOnly: false,
ResourceType: "App\\Models\\Application",
ResourceID: 10,
},
},
FileStorages: []models.FileStorage{
{
ID: 2,
UUID: "fs-uuid-1",
FsPath: "/app/config.yml",
MountPath: "/app/config.yml",
Content: &content,
IsDirectory: false,
IsBasedOnGit: false,
IsPreviewSuffixEnabled: true,
ResourceType: "App\\Models\\Application",
ResourceID: 10,
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "ps-uuid-1", result[0].UUID)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.Equal(t, "/data", result[0].MountPath)
assert.Equal(t, "/var/data", result[0].HostPath)
assert.False(t, result[0].IsPreviewSuffixEnabled)
assert.Equal(t, "fs-uuid-1", result[1].UUID)
assert.Equal(t, "file", result[1].Type)
assert.Equal(t, "/app/config.yml", result[1].Name)
assert.Equal(t, "key: value", result[1].Content)
assert.True(t, result[1].IsPreviewSuffixEnabled)
}
func TestApplicationService_ListStorages_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"persistent_storages":[],"file_storages":[]}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.NoError(t, err)
assert.Empty(t, result)
}
func TestApplicationService_ListStorages_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"application not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to list storages")
}
func TestApplicationService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.NotNil(t, req.UUID)
assert.Equal(t, "storage-uuid-1", *req.UUID)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.Name)
assert.Equal(t, "new-name", *req.Name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"message":"Storage updated."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
}
func TestApplicationService_UpdateStorage_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"application not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "non-existent-uuid", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to update storage")
}
func TestApplicationService_UpdateStorage_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to update storage")
}
func TestApplicationService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
var req models.StorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
assert.NotNil(t, req.Name)
assert.Equal(t, "my-volume", *req.Name)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "my-volume"
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
}
func TestApplicationService_CreateStorage_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message":"invalid request"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
}
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to create storage")
}
func TestApplicationService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
func TestApplicationService_DeleteStorage_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"storage not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "nonexistent-uuid")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to delete storage")
}
+102
View File
@@ -173,6 +173,108 @@ func (s *DatabaseService) DeleteBackupExecution(ctx context.Context, dbUUID, bac
return nil
}
// ListEnvs retrieves all environment variables for a database
func (s *DatabaseService) ListEnvs(ctx context.Context, uuid string) ([]models.DatabaseEnvironmentVariable, error) {
var envs []models.DatabaseEnvironmentVariable
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/envs", uuid), &envs)
if err != nil {
return nil, fmt.Errorf("failed to list environment variables for database %s: %w", uuid, err)
}
return envs, nil
}
// GetEnv retrieves a single environment variable by UUID or key
func (s *DatabaseService) GetEnv(ctx context.Context, dbUUID, envIdentifier string) (*models.DatabaseEnvironmentVariable, error) {
envs, err := s.ListEnvs(ctx, dbUUID)
if err != nil {
return nil, err
}
for _, env := range envs {
if env.UUID == envIdentifier || env.Key == envIdentifier {
return &env, nil
}
}
return nil, fmt.Errorf("environment variable '%s' not found in database %s", envIdentifier, dbUUID)
}
// CreateEnv creates a new environment variable for a database
func (s *DatabaseService) CreateEnv(ctx context.Context, uuid string, req *models.DatabaseEnvironmentVariableCreateRequest) (*models.DatabaseEnvironmentVariable, error) {
var env models.DatabaseEnvironmentVariable
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/envs", uuid), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to create environment variable for database %s: %w", uuid, err)
}
return &env, nil
}
// UpdateEnv updates an environment variable for a database
func (s *DatabaseService) UpdateEnv(ctx context.Context, dbUUID string, req *models.DatabaseEnvironmentVariableUpdateRequest) (*models.DatabaseEnvironmentVariable, error) {
var env models.DatabaseEnvironmentVariable
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs", dbUUID), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to update environment variable for database %s: %w", dbUUID, err)
}
return &env, nil
}
// DeleteEnv deletes an environment variable from a database
func (s *DatabaseService) DeleteEnv(ctx context.Context, dbUUID, envUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/envs/%s", dbUUID, envUUID))
if err != nil {
return fmt.Errorf("failed to delete environment variable %s from database %s: %w", envUUID, dbUUID, err)
}
return nil
}
// BulkUpdateEnvs updates multiple environment variables for a database in a single request
func (s *DatabaseService) BulkUpdateEnvs(ctx context.Context, dbUUID string, req *models.DatabaseEnvBulkUpdateRequest) (models.DatabaseEnvBulkUpdateResponse, error) {
var response models.DatabaseEnvBulkUpdateResponse
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs/bulk", dbUUID), req, &response)
if err != nil {
return nil, fmt.Errorf("failed to bulk update environment variables for database %s: %w", dbUUID, err)
}
return response, nil
}
// ListStorages retrieves all storages for a database
func (s *DatabaseService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for database %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for a database
func (s *DatabaseService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for database %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for a database
func (s *DatabaseService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for database %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from a database
func (s *DatabaseService) DeleteStorage(ctx context.Context, dbUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/storages/%s", dbUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from database %s: %w", storageUUID, dbUUID, err)
}
return nil
}
// inferDatabaseType determines the database type from available fields
func inferDatabaseType(db *models.Database) string {
// Check for PostgreSQL
+367
View File
@@ -848,6 +848,252 @@ func TestDatabaseService_DeleteBackupExecution(t *testing.T) {
}
}
// --- Environment Variable Tests ---
func TestDatabaseService_ListEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost", "is_literal": false},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432", "is_literal": true}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
require.NoError(t, err)
assert.Len(t, envs, 2)
assert.Equal(t, "DB_HOST", envs[0].Key)
assert.Equal(t, "DB_PORT", envs[1].Key)
}
func TestDatabaseService_ListEnvs_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"database not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
require.Error(t, err)
assert.Nil(t, envs)
assert.Contains(t, err.Error(), "failed to list environment variables")
}
func TestDatabaseService_GetEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
// Find by UUID
env, err := svc.GetEnv(context.Background(), "db-uuid-123", "env-2")
require.NoError(t, err)
assert.Equal(t, "DB_PORT", env.Key)
// Find by key
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "DB_HOST")
require.NoError(t, err)
assert.Equal(t, "env-1", env.UUID)
// Not found
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "NONEXISTENT")
require.Error(t, err)
assert.Nil(t, env)
assert.Contains(t, err.Error(), "not found")
}
func TestDatabaseService_CreateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.DatabaseEnvironmentVariableCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "NEW_VAR", req.Key)
assert.Equal(t, "new_value", req.Value)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"uuid": "env-new",
"key": "NEW_VAR",
"value": "new_value",
"comment": "test comment"
}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
comment := "test comment"
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
Key: "NEW_VAR",
Value: "new_value",
Comment: &comment,
})
require.NoError(t, err)
assert.Equal(t, "NEW_VAR", env.Key)
assert.Equal(t, "new_value", env.Value)
assert.Equal(t, "test comment", *env.Comment)
}
func TestDatabaseService_CreateEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"validation failed"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
Key: "NEW_VAR",
Value: "new_value",
})
require.Error(t, err)
assert.Nil(t, env)
assert.Contains(t, err.Error(), "failed to create environment variable")
}
func TestDatabaseService_UpdateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
comment := "updated comment"
env := models.DatabaseEnvironmentVariable{
UUID: "env-1",
Key: "DB_HOST",
Value: "newhost",
Comment: &comment,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(env)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
newKey := "DB_HOST"
newValue := "newhost"
newComment := "updated comment"
req := &models.DatabaseEnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
Comment: &newComment,
}
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "DB_HOST", result.Key)
assert.Equal(t, "newhost", result.Value)
assert.Equal(t, "updated comment", *result.Comment)
}
func TestDatabaseService_UpdateEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"environment variable not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
newKey := "DB_HOST"
newValue := "newhost"
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
})
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to update environment variable")
}
func TestDatabaseService_DeleteEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/env-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
require.NoError(t, err)
}
func TestDatabaseService_DeleteEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to delete environment variable")
}
func TestDatabaseService_BulkUpdateEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/bulk", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
req := &models.DatabaseEnvBulkUpdateRequest{
Data: []models.DatabaseEnvironmentVariableCreateRequest{
{Key: "DB_HOST", Value: "localhost"},
{Key: "DB_PORT", Value: "5432"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
assert.Len(t, result, 2)
}
func stringPtr(s string) *string {
return &s
}
@@ -855,3 +1101,124 @@ func stringPtr(s string) *string {
func boolPtr(b bool) *bool {
return &b
}
func TestDatabaseService_ListStorages(t *testing.T) {
hostPath := "/var/data"
content := "key: value"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: true,
},
},
FileStorages: []models.FileStorage{
{
ID: 2,
UUID: "fs-uuid-1",
FsPath: "/app/config.yml",
MountPath: "/app/config.yml",
Content: &content,
IsPreviewSuffixEnabled: false,
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
result, err := svc.ListStorages(context.Background(), "db-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.True(t, result[0].IsPreviewSuffixEnabled)
assert.Equal(t, "file", result[1].Type)
assert.Equal(t, "/app/config.yml", result[1].Name)
}
func TestDatabaseService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.StorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
name := "my-volume"
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
}
func TestDatabaseService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.UUID)
assert.Equal(t, "storage-uuid-1", *req.UUID)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
storageUUID := "storage-uuid-1"
name := "new-name"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
}
func TestDatabaseService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteStorage(context.Background(), "db-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
+37
View File
@@ -156,6 +156,43 @@ func (s *Service) DeleteEnv(ctx context.Context, serviceUUID, envUUID string) er
return nil
}
// ListStorages retrieves all storages for a service
func (s *Service) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("services/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for service %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for a service
func (s *Service) CreateStorage(ctx context.Context, uuid string, req *models.ServiceStorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("services/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for service %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for a service
func (s *Service) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for service %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from a service
func (s *Service) DeleteStorage(ctx context.Context, svcUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("services/%s/storages/%s", svcUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from service %s: %w", storageUUID, svcUUID, err)
}
return nil
}
// BulkUpdateEnvs updates multiple environment variables in a single request
func (s *Service) BulkUpdateEnvs(ctx context.Context, serviceUUID string, req *models.ServiceEnvBulkUpdateRequest) (models.ServiceEnvBulkUpdateResponse, error) {
var response models.ServiceEnvBulkUpdateResponse
+117 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -320,7 +321,8 @@ func TestService_UpdateEnv(t *testing.T) {
"key": "UPDATED_VAR",
"value": "updated_value",
"is_buildtime": true,
"is_preview": false
"is_preview": false,
"comment": "updated comment"
}`))
}))
defer server.Close()
@@ -329,12 +331,15 @@ func TestService_UpdateEnv(t *testing.T) {
svc := NewService(client)
newKey := "UPDATED_VAR"
newComment := "updated comment"
env, err := svc.UpdateEnv(context.Background(), "service-uuid-123", &models.ServiceEnvironmentVariableUpdateRequest{
Key: &newKey,
Key: &newKey,
Comment: &newComment,
})
require.NoError(t, err)
assert.Equal(t, "UPDATED_VAR", env.Key)
assert.Equal(t, "updated comment", *env.Comment)
}
func TestService_DeleteEnv(t *testing.T) {
@@ -435,3 +440,113 @@ func TestService_Create_Error(t *testing.T) {
require.Error(t, err)
}
func TestService_ListStorages(t *testing.T) {
hostPath := "/var/data"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: true,
},
},
FileStorages: []models.FileStorage{},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
result, err := svc.ListStorages(context.Background(), "svc-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.True(t, result[0].IsPreviewSuffixEnabled)
}
func TestService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.ServiceStorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
assert.Equal(t, "sub-resource-uuid", req.ResourceUUID)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
name := "my-volume"
req := &models.ServiceStorageCreateRequest{
Type: "persistent",
MountPath: "/data",
ResourceUUID: "sub-resource-uuid",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "svc-uuid-123", req)
require.NoError(t, err)
}
func TestService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.UUID)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
storageUUID := "storage-uuid-1"
name := "new-name"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "svc-uuid-123", req)
require.NoError(t, err)
}
func TestService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
err := svc.DeleteStorage(context.Background(), "svc-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
// Version variables injected by GoReleaser at build time via ldflags
var (
version = "v1.4.0"
version = "v1.5.0"
)
// GitHubAPIURL is the URL for fetching CLI version tags (exported for testing)
+1948
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
{
"id": 1,
"uuid": "app-fixture-uuid-123",
"name": "My Web Application",
"description": "A comprehensive test fixture",
"status": "running",
"fqdn": "https://app.example.com",
"git_repository": "https://github.com/example/app",
"git_branch": "main",
"git_commit_sha": "abc123def456",
"git_full_url": "https://github.com/example/app.git",
"build_pack": "nixpacks",
"ports_exposes": "3000",
"ports_mappings": "3000:3000",
"domains": "app.example.com",
"redirect": "www",
"install_command": "npm install",
"build_command": "npm run build",
"start_command": "npm start",
"base_directory": "/",
"publish_directory": "/dist",
"dockerfile": null,
"dockerfile_location": "/Dockerfile",
"docker_registry_image_name": null,
"docker_registry_image_tag": null,
"custom_docker_run_options": null,
"custom_labels": null,
"custom_nginx_configuration": null,
"health_check_enabled": true,
"health_check_path": "/health",
"health_check_port": "3000",
"health_check_host": null,
"health_check_method": "GET",
"health_check_scheme": "http",
"health_check_return_code": 200,
"health_check_response_text": null,
"health_check_interval": 30,
"health_check_timeout": 5,
"health_check_retries": 3,
"health_check_start_period": 10,
"limits_cpus": "2",
"limits_memory": "512M",
"limits_memory_swap": "1G",
"limits_memory_swappiness": 60,
"limits_memory_reservation": "256M",
"limits_cpuset": null,
"limits_cpu_shares": 1024,
"pre_deployment_command": "npm run migrate",
"pre_deployment_command_container": null,
"post_deployment_command": null,
"post_deployment_command_container": null,
"watch_paths": null,
"swarm_replicas": 1,
"config_hash": "abc123",
"environment_id": 1,
"destination_id": 2,
"source_id": null,
"private_key_id": null,
"settings": {
"id": 1,
"application_id": 1,
"is_static": false,
"is_build_server_enabled": false,
"is_preserve_repository_enabled": false,
"is_auto_deploy_enabled": true,
"is_force_https_enabled": true,
"is_debug_enabled": false,
"is_preview_deployments_enabled": true,
"is_git_submodules_enabled": false,
"is_git_lfs_enabled": false,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
}
+2 -1
View File
@@ -8,5 +8,6 @@
"is_literal": false,
"is_shown_once": false,
"is_runtime": true,
"is_shared": false
"is_shared": false,
"comment": "Primary database connection string"
}