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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ Commands can use `server` or `servers` interchangeably.
- `coolify server domains <uuid>` - Get server domains by UUID
- `coolify server destinations list <server_uuid>` - List a server's destinations
- `coolify server destinations create <server_uuid>` - Create a server destination
- `coolify server hetzner|digitalocean|vultr` - List provider options and provision a server
- `coolify server hetzner|digitalocean|vultr|hostinger` - List provider options and provision a server
- `coolify server hostinger data-centers|catalog|templates|ssh-keys|post-install-scripts <cloud_token_uuid>` - List Hostinger options (`catalog` shows one row per plan and billing period; its `item_id` is used for `--item-id`)
- `coolify server hostinger create` - Purchase a Hostinger VPS and add it as a server. This is a paid, recurring (monthly/yearly) purchase and asks for confirmation.
- `--cloud-token <uuid>`, `--item-id <price_id>`, `--data-center-id <id>`, `--template-id <id>`, `--private-key <uuid>` - Required
- `--name`, `--enable-backups` (default: false; backups cost extra), `--ssh-key-ids`, `--post-install-script-id`, `--validate` - Optional
- `-f, --force` - Skip the purchase confirmation prompt

### Projects
- `coolify projects list` - List all projects
Expand Down Expand Up @@ -479,7 +484,7 @@ Commands can use `server` or `servers` interchangeably.
- `coolify destination delete <uuid>` - Delete an unused destination

### Cloud Provider Tokens
- `coolify cloud-token list|get|create|update|delete|validate` - Manage Hetzner, DigitalOcean, and Vultr API tokens
- `coolify cloud-token list|get|create|update|delete|validate` - Manage Hetzner, DigitalOcean, Vultr, and Hostinger API tokens
- Token values are redacted by default. `--show-sensitive` can reveal them only when the Coolify API token has sensitive-data permission.

### Teams
Expand Down
4 changes: 2 additions & 2 deletions cmd/cloudtoken/cloudtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func newCreate() *cobra.Command {
cmd := &cobra.Command{Use: "create", Short: "Create a cloud provider token", RunE: func(cmd *cobra.Command, _ []string) error {
cloudProvider := models.CloudProvider(provider)
if !cloudProvider.Valid() {
return fmt.Errorf("--provider must be hetzner, digitalocean, or vultr")
return fmt.Errorf("--provider must be hetzner, digitalocean, vultr, or hostinger")
}
if token == "" || name == "" {
return fmt.Errorf("--name and --provider-token are required")
Expand All @@ -117,7 +117,7 @@ func newCreate() *cobra.Command {
}
return format(cmd, response)
}}
cmd.Flags().StringVar(&provider, "provider", "", "Cloud provider: hetzner, digitalocean, or vultr")
cmd.Flags().StringVar(&provider, "provider", "", "Cloud provider: hetzner, digitalocean, vultr, or hostinger")
cmd.Flags().StringVar(&name, "name", "", "Friendly token name")
cmd.Flags().StringVar(&token, "provider-token", "", "Provider API token (sensitive; never included in output)")
return cmd
Expand Down
10 changes: 10 additions & 0 deletions cmd/cloudtoken/cloudtoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,13 @@ func TestPrepareOutput_RedactsCloudTokensWithoutMutatingResponse(t *testing.T) {
assert.Equal(t, secret, *token.Token)
assert.Same(t, token, prepareOutput(token, true))
}

func TestCreateCommand_ListsHostingerProvider(t *testing.T) {
cmd := newCreate()
assert.Contains(t, cmd.Flags().Lookup("provider").Usage, "hostinger")

require.NoError(t, cmd.Flags().Set("provider", "linode"))
err := cmd.RunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "hostinger")
}
2 changes: 2 additions & 0 deletions cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ coolify cloud-token create --provider hetzner --name production --provider-token
coolify cloud-token validate <uuid>
coolify server hetzner locations <cloud-token-uuid>
coolify server hetzner create --cloud-token <uuid> --location <location> --server-type <type> --image <id>
coolify server hostinger catalog <cloud-token-uuid>
coolify server hostinger create --cloud-token <uuid> --item-id <price-id> --data-center-id <id> --template-id <id> --private-key <uuid>
` + "```" + `

## Common Aliases
Expand Down
111 changes: 110 additions & 1 deletion cmd/server/provider.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package server

import (
"bufio"
"errors"
"fmt"
"strings"

"github.com/spf13/cobra"

Expand All @@ -14,7 +17,7 @@ import (
func providerOutput(cmd *cobra.Command, value any) error {
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{ShowSensitive: showSensitive})
formatter, err := output.NewFormatter(format, output.Options{ShowSensitive: showSensitive, Writer: cmd.OutOrStdout()})
if err != nil {
return err
}
Expand Down Expand Up @@ -220,3 +223,109 @@ func newVultrCreateCommand() *cobra.Command {
cmd.Flags().BoolVar(&req.InstantValidate, "validate", false, "Validate after creation")
return cmd
}

func NewHostingerCommand() *cobra.Command {
cmd := &cobra.Command{Use: "hostinger", Short: "Provision servers with Hostinger (paid VPS purchase)"}
with := func(call func(*service.HostingerService, *cobra.Command, string) (any, error)) func(*cobra.Command, string) (any, error) {
return func(cmd *cobra.Command, token string) (any, error) {
client, err := cli.GetAPIClient(cmd)
if err != nil {
return nil, fmt.Errorf("failed to get API client: %w", err)
}
return call(service.NewHostingerService(client), cmd, token)
}
}
catalog := providerOptionCommand("catalog", "List Hostinger VPS plans and prices (item IDs for create)", with(func(s *service.HostingerService, cmd *cobra.Command, token string) (any, error) {
items, err := s.Catalog(cmd.Context(), token)
if err != nil {
return nil, err
}
return models.FlattenHostingerCatalog(items), nil
}))
catalog.Aliases = []string{"plans"}
catalog.Long = "List Hostinger VPS plans with one row per billing option. Use the item_id column as --item-id when creating a server. Prices are in cents; first_period_price_cents is charged for the first billing period."
cmd.AddCommand(
providerOptionCommand("data-centers", "List Hostinger data centers", with(func(s *service.HostingerService, cmd *cobra.Command, token string) (any, error) {
return s.DataCenters(cmd.Context(), token)
})),
catalog,
providerOptionCommand("templates", "List Hostinger OS templates", with(func(s *service.HostingerService, cmd *cobra.Command, token string) (any, error) {
return s.Templates(cmd.Context(), token)
})),
providerOptionCommand("ssh-keys", "List Hostinger SSH keys", with(func(s *service.HostingerService, cmd *cobra.Command, token string) (any, error) {
return s.SSHKeys(cmd.Context(), token)
})),
providerOptionCommand("post-install-scripts", "List Hostinger post-install scripts", with(func(s *service.HostingerService, cmd *cobra.Command, token string) (any, error) {
return s.PostInstallScripts(cmd.Context(), token)
})),
newHostingerCreateCommand(),
)
return cmd
}

func newHostingerCreateCommand() *cobra.Command {
var req models.HostingerServerCreateRequest
var force bool
cmd := &cobra.Command{
Use: "create",
Short: "Purchase a Hostinger VPS and add it as a server (paid)",
Long: `Purchase a Hostinger VPS and add it to Coolify as a server.

This is a real purchase: Hostinger charges your account for the selected plan
and billing period (--item-id, see "coolify server hostinger catalog"), and the
subscription renews monthly or yearly until you cancel it in Hostinger.
You are asked to confirm unless --force is given.`,
Example: ` coolify server hostinger catalog <cloud_token_uuid>
coolify server hostinger create --cloud-token <uuid> --item-id hostingercom-vps-kvm1-usd-1m \
--data-center-id 9 --template-id 1002 --private-key <key_uuid>`,
RunE: func(cmd *cobra.Command, _ []string) error {
if req.CloudProviderTokenUUID == "" || req.ItemID == "" || req.DataCenterID == 0 || req.TemplateID == 0 || req.PrivateKeyUUID == "" {
return fmt.Errorf("--cloud-token, --item-id, --data-center-id, --template-id, and --private-key are required")
}
if !force {
confirmed, err := confirmHostingerPurchase(cmd, req.ItemID)
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(cmd.ErrOrStderr(), "Server creation cancelled.")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
response, err := service.NewHostingerService(client).Create(cmd.Context(), req)
if errors.Is(err, service.ErrHostingerPaymentPending) {
return fmt.Errorf("%w. No server was added to Coolify. Check the order in your Hostinger account before retrying to avoid a duplicate purchase", err)
}
if err != nil {
return fmt.Errorf("failed to create Hostinger server: %w", err)
}
return providerOutput(cmd, response)
},
}
cmd.Flags().StringVar(&req.CloudProviderTokenUUID, "cloud-token", "", "Cloud provider token UUID")
cmd.Flags().StringVar(&req.ItemID, "item-id", "", "Hostinger catalog price ID (plan + billing period, e.g. hostingercom-vps-kvm1-usd-1m)")
cmd.Flags().IntVar(&req.DataCenterID, "data-center-id", 0, "Hostinger data center ID")
cmd.Flags().IntVar(&req.TemplateID, "template-id", 0, "Hostinger OS template ID")
cmd.Flags().StringVar(&req.PrivateKeyUUID, "private-key", "", "Coolify private key UUID")
cmd.Flags().StringVar(&req.Name, "name", "", "Server name (auto-generated if omitted)")
cmd.Flags().BoolVar(&req.EnableBackups, "enable-backups", false, "Enable weekly Hostinger backups (extra cost; off by default)")
cmd.Flags().IntSliceVar(&req.PublicKeyIDs, "ssh-key-ids", nil, "Additional Hostinger SSH key IDs")
cmd.Flags().IntVar(&req.PostInstallScriptID, "post-install-script-id", 0, "Hostinger post-install script ID")
cmd.Flags().BoolVar(&req.InstantValidate, "validate", false, "Validate after creation")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip the purchase confirmation prompt")
return cmd
}

func confirmHostingerPurchase(cmd *cobra.Command, itemID string) (bool, error) {
fmt.Fprintf(cmd.ErrOrStderr(), "This purchases a Hostinger VPS (%s) billed to your Hostinger account and renewed until cancelled. Continue? (yes/no): ", itemID)
response, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
if err != nil && response == "" {
return false, fmt.Errorf("failed to read input: %w", err)
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "yes" || response == "y", nil
}
182 changes: 182 additions & 0 deletions cmd/server/provider_hostinger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package server

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coollabsio/coolify-cli/internal/models"
)

var hostingerCreateArgs = []string{
"hostinger", "create",
"--cloud-token", "token-1",
"--item-id", "hostingercom-vps-kvm1-usd-1m",
"--data-center-id", "9",
"--template-id", "1002",
"--private-key", "key-1",
}

// runHostingerCommand executes `server <args>` against a local mock Coolify API
// using a temporary config file, never a real API.
func runHostingerCommand(t *testing.T, handler http.HandlerFunc, stdin string, args ...string) (string, string, error) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)

home := t.TempDir()
t.Setenv("HOME", home)
configDir := filepath.Join(home, ".config", "coolify")
require.NoError(t, os.MkdirAll(configDir, 0o700))
config := `{"instances":[{"name":"test","fqdn":"` + server.URL + `","token":"api-token","default":true}]}`
require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.json"), []byte(config), 0o600))

root := &cobra.Command{Use: "coolify", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().String("token", "", "")
root.PersistentFlags().String("context", "", "")
root.PersistentFlags().String("format", "table", "")
root.PersistentFlags().BoolP("show-sensitive", "s", false, "")
root.PersistentFlags().Bool("debug", false, "")
root.AddCommand(NewServerCommand())

var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(append([]string{"server"}, args...))
err := root.Execute()
return stdout.String(), stderr.String(), err
}

func TestHostingerCatalogCommand_FlattensPricesIntoRows(t *testing.T) {
stdout, _, err := runHostingerCommand(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/hostinger/catalog", r.URL.Path)
assert.Equal(t, "token-1", r.URL.Query().Get("cloud_provider_token_uuid"))
assert.Equal(t, "Bearer api-token", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`[{"id":"hostingercom-vps-kvm1","name":"KVM 1","category":"VPS","metadata":{"cpus":"1","memory":"4096","disk_space":"51200","bandwidth":"4096000","network":"300"},"prices":[{"id":"hostingercom-vps-kvm1-usd-1m","name":"monthly","currency":"USD","price":1399,"first_period_price":649,"period":1,"period_unit":"month"},{"id":"hostingercom-vps-kvm1-usd-1y","name":"yearly","currency":"USD","price":11988,"first_period_price":7788,"period":1,"period_unit":"year"}]}]`))
}, "", "hostinger", "catalog", "token-1", "--format", "json")

require.NoError(t, err)
var rows []models.HostingerPlanPrice
require.NoError(t, json.Unmarshal([]byte(stdout), &rows))
require.Len(t, rows, 2)
assert.Equal(t, "hostingercom-vps-kvm1-usd-1m", rows[0].ItemID)
assert.Equal(t, "KVM 1", rows[0].Plan)
assert.Equal(t, 649, rows[0].FirstPeriodPriceCents)
assert.Equal(t, "hostingercom-vps-kvm1-usd-1y", rows[1].ItemID)
}

func TestHostingerTableOutput_HidesSSHKeyByDefault(t *testing.T) {
stdout, _, err := runHostingerCommand(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":5,"name":"laptop","key":"ssh-ed25519 AAAAsecret"}]`))
}, "", "hostinger", "ssh-keys", "token-1")

require.NoError(t, err)
assert.Contains(t, stdout, "laptop")
assert.NotContains(t, stdout, "AAAAsecret")
}

func TestHostingerCreateCommand_RequiresFlags(t *testing.T) {
var requests atomic.Int32
_, _, err := runHostingerCommand(t, func(http.ResponseWriter, *http.Request) { requests.Add(1) }, "yes\n", "hostinger", "create", "--cloud-token", "token-1", "--force")

require.Error(t, err)
assert.Contains(t, err.Error(), "--item-id")
assert.Equal(t, int32(0), requests.Load())
}

func TestHostingerCreateCommand_DeclinedConfirmationDoesNotPurchase(t *testing.T) {
for _, answer := range []string{"no\n", "\n", ""} {
var requests atomic.Int32
stdout, stderr, err := runHostingerCommand(t, func(http.ResponseWriter, *http.Request) { requests.Add(1) }, answer, hostingerCreateArgs...)

if answer == "" {
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read input")
} else {
require.NoError(t, err)
assert.Contains(t, stderr, "Server creation cancelled.")
}
assert.Contains(t, stderr, "This purchases a Hostinger VPS (hostingercom-vps-kvm1-usd-1m)")
assert.Empty(t, stdout)
assert.Equal(t, int32(0), requests.Load(), "answer %q must not send a request", answer)
}
}

func TestHostingerCreateCommand_ConfirmedCreatesServer(t *testing.T) {
for _, tc := range []struct {
name string
stdin string
extraArgs []string
wantPrompt bool
}{
{name: "confirmed with yes", stdin: "YES\n", wantPrompt: true},
{name: "force skips prompt", extraArgs: []string{"--force"}},
} {
t.Run(tc.name, func(t *testing.T) {
var requests atomic.Int32
var body models.HostingerServerCreateRequest
args := append(append([]string{}, hostingerCreateArgs...), tc.extraArgs...)
args = append(args, "--format", "json", "--enable-backups=false", "--ssh-key-ids", "5,6")
stdout, stderr, err := runHostingerCommand(t, func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/servers/hostinger", r.URL.Path)
assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"uuid":"server-1","hostinger_virtual_machine_id":42,"ip":"192.0.2.1","provisioning":true}`))
}, tc.stdin, args...)

require.NoError(t, err)
assert.Equal(t, int32(1), requests.Load())
assert.Equal(t, tc.wantPrompt, strings.Contains(stderr, "This purchases a Hostinger VPS"))
assert.Equal(t, models.HostingerServerCreateRequest{
CloudProviderTokenUUID: "token-1",
ItemID: "hostingercom-vps-kvm1-usd-1m",
DataCenterID: 9,
TemplateID: 1002,
PrivateKeyUUID: "key-1",
EnableBackups: false,
PublicKeyIDs: []int{5, 6},
}, body)
assert.JSONEq(t, `{"uuid":"server-1","hostinger_virtual_machine_id":42,"ip":"192.0.2.1","provisioning":true}`, stdout)
})
}
}

func TestHostingerCreateCommand_PaymentPendingIsAnError(t *testing.T) {
stdout, _, err := runHostingerCommand(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"message":"Hostinger is still processing the payment."}`))
}, "", append(append([]string{}, hostingerCreateArgs...), "--force")...)

require.Error(t, err)
assert.Contains(t, err.Error(), "Hostinger is still processing the payment.")
assert.Contains(t, err.Error(), "No server was added to Coolify")
assert.Contains(t, err.Error(), "duplicate purchase")
assert.Empty(t, stdout)
}

func TestHostingerCreateCommand_ServerErrorIsNotRetried(t *testing.T) {
var requests atomic.Int32
_, _, err := runHostingerCommand(t, func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"Failed to register server","hostinger_virtual_machine_id":42}`))
}, "", append(append([]string{}, hostingerCreateArgs...), "--force")...)

require.Error(t, err)
assert.Equal(t, int32(1), requests.Load())
assert.Contains(t, err.Error(), "failed to create Hostinger server")
assert.Contains(t, err.Error(), "Hostinger virtual machine 42 may already have been purchased")
}
Loading
Loading