diff --git a/README.md b/README.md index 6150d0f..df53f92 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,12 @@ Commands can use `server` or `servers` interchangeably. - `coolify server domains ` - Get server domains by UUID - `coolify server destinations list ` - List a server's destinations - `coolify server destinations create ` - 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 ` - 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 `, `--item-id `, `--data-center-id `, `--template-id `, `--private-key ` - 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 @@ -479,7 +484,7 @@ Commands can use `server` or `servers` interchangeably. - `coolify destination delete ` - 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 diff --git a/cmd/cloudtoken/cloudtoken.go b/cmd/cloudtoken/cloudtoken.go index 73302c5..b929036 100644 --- a/cmd/cloudtoken/cloudtoken.go +++ b/cmd/cloudtoken/cloudtoken.go @@ -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") @@ -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 diff --git a/cmd/cloudtoken/cloudtoken_test.go b/cmd/cloudtoken/cloudtoken_test.go index 4aff181..444818b 100644 --- a/cmd/cloudtoken/cloudtoken_test.go +++ b/cmd/cloudtoken/cloudtoken_test.go @@ -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") +} diff --git a/cmd/docs.go b/cmd/docs.go index 1d2b9b1..c9c44a4 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -247,6 +247,8 @@ coolify cloud-token create --provider hetzner --name production --provider-token coolify cloud-token validate coolify server hetzner locations coolify server hetzner create --cloud-token --location --server-type --image +coolify server hostinger catalog +coolify server hostinger create --cloud-token --item-id --data-center-id --template-id --private-key ` + "```" + ` ## Common Aliases diff --git a/cmd/server/provider.go b/cmd/server/provider.go index a7f5199..3f7dc6d 100644 --- a/cmd/server/provider.go +++ b/cmd/server/provider.go @@ -1,7 +1,10 @@ package server import ( + "bufio" + "errors" "fmt" + "strings" "github.com/spf13/cobra" @@ -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 } @@ -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 + coolify server hostinger create --cloud-token --item-id hostingercom-vps-kvm1-usd-1m \ + --data-center-id 9 --template-id 1002 --private-key `, + 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 +} diff --git a/cmd/server/provider_hostinger_test.go b/cmd/server/provider_hostinger_test.go new file mode 100644 index 0000000..e0d4fcc --- /dev/null +++ b/cmd/server/provider_hostinger_test.go @@ -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 ` 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") +} diff --git a/cmd/server/provider_test.go b/cmd/server/provider_test.go index 4a5ef25..f9f6501 100644 --- a/cmd/server/provider_test.go +++ b/cmd/server/provider_test.go @@ -9,7 +9,7 @@ import ( func TestServerCommand_RegistersProviderTreesWithoutReplacingAdd(t *testing.T) { cmd := NewServerCommand() - for _, name := range []string{"add", "hetzner", "digitalocean", "vultr", "destinations"} { + for _, name := range []string{"add", "hetzner", "digitalocean", "vultr", "hostinger", "destinations"} { child, _, err := cmd.Find([]string{name}) require.NoError(t, err) assert.Equal(t, name, child.Name()) @@ -19,6 +19,7 @@ func TestServerCommand_RegistersProviderTreesWithoutReplacingAdd(t *testing.T) { "hetzner": {"locations", "server-types", "images", "ssh-keys", "firewalls", "networks", "create"}, "digitalocean": {"regions", "sizes", "images", "ssh-keys", "create"}, "vultr": {"regions", "plans", "os", "ssh-keys", "create"}, + "hostinger": {"data-centers", "catalog", "templates", "ssh-keys", "post-install-scripts", "create"}, } for provider, subcommands := range providerCases { for _, subcommand := range subcommands { @@ -50,4 +51,14 @@ func TestProviderCreateCommands_RegisterSchemaFlags(t *testing.T) { for _, flag := range []string{"cloud-token", "region", "plan", "os-id", "private-key", "enable-ipv6", "disable-public-ipv4", "ssh-key-ids", "cloud-init", "validate"} { assert.NotNil(t, create.Flags().Lookup(flag), flag) } + + hostinger := NewHostingerCommand() + create, _, err = hostinger.Find([]string{"create"}) + require.NoError(t, err) + for _, flag := range []string{"cloud-token", "item-id", "data-center-id", "template-id", "private-key", "name", "enable-backups", "ssh-key-ids", "post-install-script-id", "validate", "force"} { + assert.NotNil(t, create.Flags().Lookup(flag), flag) + } + assert.Equal(t, "false", create.Flags().Lookup("enable-backups").DefValue) + assert.Equal(t, "f", create.Flags().Lookup("force").Shorthand) + assert.Contains(t, create.Long, "real purchase") } diff --git a/cmd/server/server.go b/cmd/server/server.go index bb201aa..ee36789 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -25,6 +25,7 @@ func NewServerCommand() *cobra.Command { cmd.AddCommand(NewHetznerCommand()) cmd.AddCommand(NewDigitalOceanCommand()) cmd.AddCommand(NewVultrCommand()) + cmd.AddCommand(NewHostingerCommand()) cmd.AddCommand(NewDockerCleanupCommand()) cmd.AddCommand(NewLogDrainsCommand()) cmd.AddCommand(NewSentinelCommand()) diff --git a/internal/api/client.go b/internal/api/client.go index 3f01d5c..3c860d2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -61,6 +61,13 @@ func (c *Client) Post(ctx context.Context, path string, body, result interface{} return c.doRequest(ctx, "POST", path, body, result) } +// PostOnce makes a single POST request without retries. Use it for +// non-idempotent requests where a retry could repeat a side effect, such as +// purchasing a cloud server. +func (c *Client) PostOnce(ctx context.Context, path string, body, result interface{}) error { + return c.doRequestOnce(ctx, "POST", path, body, result) +} + // Delete makes a DELETE request to the API func (c *Client) Delete(ctx context.Context, path string) error { return c.doRequest(ctx, "DELETE", path, nil, nil) @@ -196,7 +203,10 @@ func (c *Client) doRequestOnce(ctx context.Context, method, path string, body, r } } - return NewError(resp.StatusCode, path, message) + apiErr := NewError(resp.StatusCode, path, message) + apiErr.Body = respBody + apiErr.RetryAfter = resp.Header.Get("Retry-After") + return apiErr } // Unmarshal response into result diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 676ae0a..fc16068 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -310,6 +310,31 @@ func TestClient_Retry_NoRetryOn4xx(t *testing.T) { assert.True(t, IsBadRequest(err)) } +func TestClient_PostOnce_DoesNotRetryServerErrors(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + assert.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom","vm_id":7}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token", WithRetries(3)) + var result map[string]any + + err := client.PostOnce(context.Background(), "test", map[string]string{"a": "b"}, &result) + + require.Error(t, err) + assert.Equal(t, 1, attempts) + var apiErr *Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "boom", apiErr.Message) + assert.JSONEq(t, `{"message":"boom","vm_id":7}`, string(apiErr.Body)) + assert.Equal(t, "30", apiErr.RetryAfter) +} + func TestClient_ContextCancellation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(100 * time.Millisecond) diff --git a/internal/api/error.go b/internal/api/error.go index 66ada28..a8a94f7 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -10,6 +10,11 @@ type Error struct { StatusCode int Message string Path string + // Body is the raw response body, kept for callers that need structured + // error details beyond Message (for example validation errors). + Body []byte + // RetryAfter is the Retry-After response header, if present. + RetryAfter string } // Error implements the error interface diff --git a/internal/models/infrastructure.go b/internal/models/infrastructure.go index a72b9f7..22fda90 100644 --- a/internal/models/infrastructure.go +++ b/internal/models/infrastructure.go @@ -31,10 +31,11 @@ const ( CloudProviderHetzner CloudProvider = "hetzner" CloudProviderDigitalOcean CloudProvider = "digitalocean" CloudProviderVultr CloudProvider = "vultr" + CloudProviderHostinger CloudProvider = "hostinger" ) func (p CloudProvider) Valid() bool { - return p == CloudProviderHetzner || p == CloudProviderDigitalOcean || p == CloudProviderVultr + return p == CloudProviderHetzner || p == CloudProviderDigitalOcean || p == CloudProviderVultr || p == CloudProviderHostinger } type CloudToken struct { @@ -187,3 +188,135 @@ type VultrServerCreateResponse struct { VultrInstanceID string `json:"vultr_instance_id"` IP string `json:"ip" sensitive:"true"` } + +type HostingerDataCenter struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + City string `json:"city"` + Continent string `json:"continent"` +} + +type HostingerCatalogItem struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Metadata HostingerCatalogMetadata `json:"metadata"` + Prices []HostingerCatalogPrice `json:"prices"` +} + +type HostingerCatalogMetadata struct { + CPUs string `json:"cpus"` + Memory string `json:"memory"` + DiskSpace string `json:"disk_space"` + Bandwidth string `json:"bandwidth"` + Network string `json:"network"` +} + +// HostingerCatalogPrice is a purchasable billing option. Its ID is the +// item_id used when creating a server. Prices are in cents. +type HostingerCatalogPrice struct { + ID string `json:"id"` + Name string `json:"name"` + Currency string `json:"currency"` + Price int `json:"price"` + FirstPeriodPrice int `json:"first_period_price"` + Period int `json:"period"` + PeriodUnit string `json:"period_unit"` +} + +// HostingerPlanPrice is one catalog item + billing option flattened into a +// single row, so every purchasable item_id is visible in table output. +type HostingerPlanPrice struct { + ItemID string `json:"item_id"` + Plan string `json:"plan"` + PlanID string `json:"plan_id" table:"-"` + Category string `json:"category" table:"-"` + CPUs string `json:"cpus"` + Memory string `json:"memory"` + DiskSpace string `json:"disk_space"` + Bandwidth string `json:"bandwidth"` + Network string `json:"network" table:"-"` + Currency string `json:"currency"` + PriceCents int `json:"price_cents"` + FirstPeriodPriceCents int `json:"first_period_price_cents"` + Period int `json:"period"` + PeriodUnit string `json:"period_unit"` +} + +// FlattenHostingerCatalog expands catalog items into one row per price. +func FlattenHostingerCatalog(items []HostingerCatalogItem) []HostingerPlanPrice { + rows := []HostingerPlanPrice{} + for _, item := range items { + for _, price := range item.Prices { + rows = append(rows, HostingerPlanPrice{ + ItemID: price.ID, + Plan: item.Name, + PlanID: item.ID, + Category: item.Category, + CPUs: item.Metadata.CPUs, + Memory: item.Metadata.Memory, + DiskSpace: item.Metadata.DiskSpace, + Bandwidth: item.Metadata.Bandwidth, + Network: item.Metadata.Network, + Currency: price.Currency, + PriceCents: price.Price, + FirstPeriodPriceCents: price.FirstPeriodPrice, + Period: price.Period, + PeriodUnit: price.PeriodUnit, + }) + } + } + return rows +} + +type HostingerTemplate struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Documentation string `json:"documentation" table:"-"` +} + +type HostingerSSHKey struct { + ID int `json:"id"` + Name string `json:"name"` + Key string `json:"key" sensitive:"true"` +} + +type HostingerPostInstallScript struct { + ID int `json:"id"` + Name string `json:"name"` + Content string `json:"content" table:"-"` + CreatedAt string `json:"created_at" table:"-"` + UpdatedAt string `json:"updated_at" table:"-"` +} + +type HostingerServerCreateRequest struct { + CloudProviderTokenUUID string `json:"cloud_provider_token_uuid"` + ItemID string `json:"item_id"` + DataCenterID int `json:"data_center_id"` + TemplateID int `json:"template_id"` + PrivateKeyUUID string `json:"private_key_uuid"` + Name string `json:"name,omitempty"` + EnableBackups bool `json:"enable_backups"` + PublicKeyIDs []int `json:"public_key_ids,omitempty"` + PostInstallScriptID int `json:"post_install_script_id,omitempty"` + InstantValidate bool `json:"instant_validate"` +} + +// HostingerServerCreateResponse covers both the 201 (server created) and the +// 202 (payment still processing, no server created) responses. A 202 carries +// only Message. +type HostingerServerCreateResponse struct { + UUID string `json:"uuid"` + HostingerVirtualMachineID int `json:"hostinger_virtual_machine_id"` + IP string `json:"ip" sensitive:"true"` + Provisioning bool `json:"provisioning"` + Message string `json:"message,omitempty" table:"-"` +} + +// PaymentPending reports whether Hostinger accepted the order but is still +// processing payment, meaning Coolify has not created a server yet. +func (r HostingerServerCreateResponse) PaymentPending() bool { + return r.UUID == "" +} diff --git a/internal/models/infrastructure_test.go b/internal/models/infrastructure_test.go index 902a152..4b7e4ae 100644 --- a/internal/models/infrastructure_test.go +++ b/internal/models/infrastructure_test.go @@ -13,3 +13,46 @@ func TestCloudTokenDecodesPrivilegedSensitiveToken(t *testing.T) { require.NotNil(t, token.Token) require.Equal(t, "secret", *token.Token) } + +func TestCloudProviderValid_AcceptsHostinger(t *testing.T) { + for _, provider := range []CloudProvider{CloudProviderHetzner, CloudProviderDigitalOcean, CloudProviderVultr, CloudProviderHostinger} { + require.True(t, provider.Valid(), provider) + } + require.False(t, CloudProvider("linode").Valid()) + + var token CloudToken + require.NoError(t, json.Unmarshal([]byte(`{"uuid":"token-uuid","name":"prod","provider":"hostinger"}`), &token)) + require.Equal(t, CloudProviderHostinger, token.Provider) +} + +func TestFlattenHostingerCatalog_OneRowPerPrice(t *testing.T) { + items := []HostingerCatalogItem{ + { + ID: "hostingercom-vps-kvm1", + Name: "KVM 1", + Category: "VPS", + Metadata: HostingerCatalogMetadata{CPUs: "1", Memory: "4096", DiskSpace: "51200", Bandwidth: "4096000", Network: "300"}, + Prices: []HostingerCatalogPrice{ + {ID: "hostingercom-vps-kvm1-usd-1m", Currency: "USD", Price: 1399, FirstPeriodPrice: 649, Period: 1, PeriodUnit: "month"}, + {ID: "hostingercom-vps-kvm1-usd-1y", Currency: "USD", Price: 11988, FirstPeriodPrice: 7788, Period: 1, PeriodUnit: "year"}, + }, + }, + {ID: "hostingercom-vps-kvm2", Name: "KVM 2"}, + } + + rows := FlattenHostingerCatalog(items) + + require.Len(t, rows, 2) + require.Equal(t, HostingerPlanPrice{ + ItemID: "hostingercom-vps-kvm1-usd-1m", Plan: "KVM 1", PlanID: "hostingercom-vps-kvm1", Category: "VPS", + CPUs: "1", Memory: "4096", DiskSpace: "51200", Bandwidth: "4096000", Network: "300", + Currency: "USD", PriceCents: 1399, FirstPeriodPriceCents: 649, Period: 1, PeriodUnit: "month", + }, rows[0]) + require.Equal(t, "year", rows[1].PeriodUnit) + require.NotNil(t, FlattenHostingerCatalog(nil)) +} + +func TestHostingerServerCreateResponse_PaymentPending(t *testing.T) { + require.True(t, HostingerServerCreateResponse{Message: "Payment is being processed"}.PaymentPending()) + require.False(t, HostingerServerCreateResponse{UUID: "server-1"}.PaymentPending()) +} diff --git a/internal/service/provider.go b/internal/service/provider.go index e852588..cbb4b18 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -2,7 +2,12 @@ package service import ( "context" + "encoding/json" + "errors" + "fmt" "net/url" + "sort" + "strings" "github.com/coollabsio/coolify-cli/internal/api" "github.com/coollabsio/coolify-cli/internal/models" @@ -105,3 +110,93 @@ func (s *VultrService) Create(ctx context.Context, req models.VultrServerCreateR err := s.client.Post(ctx, "servers/vultr", req, &out) return &out, err } + +// ErrHostingerPaymentPending is returned when Hostinger accepted the order but +// is still processing payment (HTTP 202). No Coolify server exists yet. +var ErrHostingerPaymentPending = errors.New("hostinger payment is still processing") + +type HostingerService struct{ client *api.Client } + +func NewHostingerService(client *api.Client) *HostingerService { + return &HostingerService{client: client} +} +func (s *HostingerService) DataCenters(ctx context.Context, token string) ([]models.HostingerDataCenter, error) { + var out []models.HostingerDataCenter + err := s.client.Get(ctx, providerPath("hostinger/data-centers", token), &out) + return out, err +} +func (s *HostingerService) Catalog(ctx context.Context, token string) ([]models.HostingerCatalogItem, error) { + var out []models.HostingerCatalogItem + err := s.client.Get(ctx, providerPath("hostinger/catalog", token), &out) + return out, err +} +func (s *HostingerService) Templates(ctx context.Context, token string) ([]models.HostingerTemplate, error) { + var out []models.HostingerTemplate + err := s.client.Get(ctx, providerPath("hostinger/templates", token), &out) + return out, err +} +func (s *HostingerService) SSHKeys(ctx context.Context, token string) ([]models.HostingerSSHKey, error) { + var out []models.HostingerSSHKey + err := s.client.Get(ctx, providerPath("hostinger/ssh-keys", token), &out) + return out, err +} +func (s *HostingerService) PostInstallScripts(ctx context.Context, token string) ([]models.HostingerPostInstallScript, error) { + var out []models.HostingerPostInstallScript + err := s.client.Get(ctx, providerPath("hostinger/post-install-scripts", token), &out) + return out, err +} + +// Create purchases a Hostinger VPS and registers it in Coolify. The request is +// sent exactly once: retrying a failed purchase could buy a second server. +// A 202 response returns the response together with ErrHostingerPaymentPending. +func (s *HostingerService) Create(ctx context.Context, req models.HostingerServerCreateRequest) (*models.HostingerServerCreateResponse, error) { + var out models.HostingerServerCreateResponse + if err := s.client.PostOnce(ctx, "servers/hostinger", req, &out); err != nil { + return nil, describeHostingerCreateError(err) + } + if out.PaymentPending() { + message := out.Message + if message == "" { + message = "no server was returned" + } + return &out, fmt.Errorf("%w: %s", ErrHostingerPaymentPending, message) + } + return &out, nil +} + +// describeHostingerCreateError adds validation details, rate-limit timing, +// and possibly-purchased VM IDs from the API error body to the error. +func describeHostingerCreateError(err error) error { + var apiErr *api.Error + if !errors.As(err, &apiErr) { + return err + } + var body struct { + Errors map[string][]string `json:"errors"` + HostingerVirtualMachineID *int `json:"hostinger_virtual_machine_id"` + } + _ = json.Unmarshal(apiErr.Body, &body) + + var details []string + switch { + case apiErr.StatusCode == 422 && len(body.Errors) > 0: + fields := make([]string, 0, len(body.Errors)) + for field := range body.Errors { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + details = append(details, fmt.Sprintf("%s: %s", field, strings.Join(body.Errors[field], " "))) + } + case apiErr.StatusCode == 429: + if apiErr.RetryAfter != "" { + details = append(details, fmt.Sprintf("retry after %s seconds", apiErr.RetryAfter)) + } + case apiErr.StatusCode >= 500 && body.HostingerVirtualMachineID != nil: + details = append(details, fmt.Sprintf("Hostinger virtual machine %d may already have been purchased; check your Hostinger account before retrying", *body.HostingerVirtualMachineID)) + } + if len(details) == 0 { + return err + } + return fmt.Errorf("%w (%s)", err, strings.Join(details, "; ")) +} diff --git a/internal/service/provider_hostinger_test.go b/internal/service/provider_hostinger_test.go new file mode 100644 index 0000000..f35a872 --- /dev/null +++ b/internal/service/provider_hostinger_test.go @@ -0,0 +1,208 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sort" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coollabsio/coolify-cli/internal/api" + "github.com/coollabsio/coolify-cli/internal/models" +) + +func TestHostingerService_OptionsUseTokenQuery(t *testing.T) { + responses := map[string]string{ + "/api/v1/hostinger/data-centers": `[{"id":9,"name":"us-central","location":"us","city":"Boston","continent":"North America"}]`, + "/api/v1/hostinger/catalog": `[{"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":"KVM 1 (monthly)","currency":"USD","price":1399,"first_period_price":649,"period":1,"period_unit":"month"}]}]`, + "/api/v1/hostinger/templates": `[{"id":1002,"name":"Ubuntu 24.04","description":"Plain Ubuntu","documentation":"https://example.com"}]`, + "/api/v1/hostinger/ssh-keys": `[{"id":5,"name":"laptop","key":"ssh-ed25519 AAAA"}]`, + "/api/v1/hostinger/post-install-scripts": `[{"id":3,"name":"setup","content":"#!/bin/sh","created_at":"2026-01-01","updated_at":"2026-01-02"}]`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "token/id", r.URL.Query().Get("cloud_provider_token_uuid")) + body, ok := responses[r.URL.Path] + if !assert.True(t, ok, r.URL.Path) { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + ctx := context.Background() + hostinger := NewHostingerService(api.NewClient(server.URL, "token", api.WithRetries(0))) + + dataCenters, err := hostinger.DataCenters(ctx, "token/id") + require.NoError(t, err) + assert.Equal(t, []models.HostingerDataCenter{{ID: 9, Name: "us-central", Location: "us", City: "Boston", Continent: "North America"}}, dataCenters) + + catalog, err := hostinger.Catalog(ctx, "token/id") + require.NoError(t, err) + require.Len(t, catalog, 1) + assert.Equal(t, "4096", catalog[0].Metadata.Memory) + require.Len(t, catalog[0].Prices, 1) + assert.Equal(t, "hostingercom-vps-kvm1-usd-1m", catalog[0].Prices[0].ID) + assert.Equal(t, 649, catalog[0].Prices[0].FirstPeriodPrice) + + templates, err := hostinger.Templates(ctx, "token/id") + require.NoError(t, err) + assert.Equal(t, 1002, templates[0].ID) + + keys, err := hostinger.SSHKeys(ctx, "token/id") + require.NoError(t, err) + assert.Equal(t, models.HostingerSSHKey{ID: 5, Name: "laptop", Key: "ssh-ed25519 AAAA"}, keys[0]) + + scripts, err := hostinger.PostInstallScripts(ctx, "token/id") + require.NoError(t, err) + assert.Equal(t, "#!/bin/sh", scripts[0].Content) +} + +func TestHostingerService_PostInstallScripts_ForbiddenSurfacesMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Token lacks permission"}`)) + })) + defer server.Close() + + _, err := NewHostingerService(api.NewClient(server.URL, "token")).PostInstallScripts(context.Background(), "t") + + require.Error(t, err) + assert.True(t, api.IsUnauthorized(err)) + assert.Contains(t, err.Error(), "Token lacks permission") +} + +func TestHostingerService_Create_SendsSchemaFieldsOnly(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/servers/hostinger", r.URL.RequestURI()) + 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}`)) + })) + defer server.Close() + + response, err := NewHostingerService(api.NewClient(server.URL, "token")).Create(context.Background(), models.HostingerServerCreateRequest{ + CloudProviderTokenUUID: "token-1", + ItemID: "hostingercom-vps-kvm1-usd-1m", + DataCenterID: 9, + TemplateID: 1002, + PrivateKeyUUID: "key-1", + Name: "web-1", + EnableBackups: false, + PublicKeyIDs: []int{5, 6}, + PostInstallScriptID: 3, + InstantValidate: true, + }) + + require.NoError(t, err) + assert.Equal(t, &models.HostingerServerCreateResponse{UUID: "server-1", HostingerVirtualMachineID: 42, IP: "192.0.2.1", Provisioning: true}, response) + keys := make([]string, 0, len(body)) + for key := range body { + keys = append(keys, key) + } + sort.Strings(keys) + assert.Equal(t, []string{"cloud_provider_token_uuid", "data_center_id", "enable_backups", "instant_validate", "item_id", "name", "post_install_script_id", "private_key_uuid", "public_key_ids", "template_id"}, keys) + assert.Equal(t, "hostingercom-vps-kvm1-usd-1m", body["item_id"]) + assert.InDelta(t, 9, body["data_center_id"], 0) + assert.Equal(t, false, body["enable_backups"]) + assert.Equal(t, []any{float64(5), float64(6)}, body["public_key_ids"]) + assert.InDelta(t, 3, body["post_install_script_id"], 0) +} + +func TestHostingerService_Create_OmitsOptionalFields(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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":false}`)) + })) + defer server.Close() + + _, err := NewHostingerService(api.NewClient(server.URL, "token")).Create(context.Background(), models.HostingerServerCreateRequest{CloudProviderTokenUUID: "t", ItemID: "i", DataCenterID: 1, TemplateID: 2, PrivateKeyUUID: "k", EnableBackups: true}) + + require.NoError(t, err) + assert.NotContains(t, body, "name") + assert.NotContains(t, body, "public_key_ids") + assert.NotContains(t, body, "post_install_script_id") + assert.Equal(t, true, body["enable_backups"]) +} + +func TestHostingerService_Create_ErrorResponses(t *testing.T) { + tests := []struct { + name string + status int + retryAfter string + response string + wantPending bool + wantContains []string + }{ + { + name: "payment processing", + status: http.StatusAccepted, + response: `{"message":"Payment is being processed"}`, + wantPending: true, + wantContains: []string{"hostinger payment is still processing", "Payment is being processed"}, + }, + { + name: "validation error", + status: http.StatusUnprocessableEntity, + response: `{"message":"The given data was invalid.","errors":{"template_id":["The template id field is required."],"item_id":["Invalid item."]}}`, + wantContains: []string{"422", "The given data was invalid.", "item_id: Invalid item.; template_id: The template id field is required."}, + }, + { + name: "rate limited", + status: http.StatusTooManyRequests, + retryAfter: "60", + response: `{"message":"Too Many Attempts."}`, + wantContains: []string{"429", "retry after 60 seconds"}, + }, + { + name: "server error after purchase", + status: http.StatusInternalServerError, + response: `{"message":"Failed to add server","hostinger_virtual_machine_id":42}`, + wantContains: []string{"500", "Failed to add server", "Hostinger virtual machine 42 may already have been purchased"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts.Add(1) + if tt.retryAfter != "" { + w.Header().Set("Retry-After", tt.retryAfter) + } + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + // Default client retries 5xx/429; Create must still send exactly one request. + response, err := NewHostingerService(api.NewClient(server.URL, "token")).Create(context.Background(), models.HostingerServerCreateRequest{CloudProviderTokenUUID: "t", ItemID: "i", DataCenterID: 1, TemplateID: 2, PrivateKeyUUID: "k"}) + + require.Error(t, err) + assert.Equal(t, int32(1), attempts.Load()) + assert.Equal(t, tt.wantPending, errorsIsPending(err)) + if tt.wantPending { + require.NotNil(t, response) + assert.Equal(t, "Payment is being processed", response.Message) + } else { + assert.Nil(t, response) + } + for _, want := range tt.wantContains { + assert.Contains(t, err.Error(), want) + } + }) + } +} + +func errorsIsPending(err error) bool { + return errors.Is(err, ErrHostingerPaymentPending) +} diff --git a/llms-full.txt b/llms-full.txt index f1e1b06..b2030ea 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -77,6 +77,7 @@ Aliases are derived from the CLI command tree: - `coolify server cloudflare-tunnel` | `coolify server cf-tunnel` - `coolify server digitalocean` | `coolify server digital-ocean` | `coolify server do` - `coolify server domains` | `coolify server domain` +- `coolify server hostinger catalog` | `coolify server hostinger plans` - `coolify server` | `coolify servers` - `coolify service application` | `coolify service applications` | `coolify service app` | `coolify service apps` - `coolify service database` | `coolify service databases` | `coolify service db` | `coolify service dbs` @@ -2118,7 +2119,7 @@ Parameters: required: false - name: --provider type: string - description: Cloud provider: hetzner, digitalocean, or vultr + description: Cloud provider: hetzner, digitalocean, vultr, or hostinger required: false - name: --provider-token type: string @@ -3794,6 +3795,80 @@ Command: coolify server hetzner ssh-keys Description: List Hetzner SSH keys Parameters: (None) +Command: coolify server hostinger catalog +Description: List Hostinger VPS plans and prices (item IDs for create) +Parameters: (None) + +Command: coolify server hostinger create +Description: Purchase a Hostinger VPS and add it as a server (paid) +Parameters: + - name: --cloud-token + type: string + description: Cloud provider token UUID + required: false + - name: --data-center-id + type: integer + description: Hostinger data center ID + required: false + default: 0 + - name: --enable-backups + type: boolean + description: Enable weekly Hostinger backups (extra cost; off by default) + required: false + default: false + - name: --force (-f) + type: boolean + description: Skip the purchase confirmation prompt + required: false + default: false + - name: --item-id + type: string + description: Hostinger catalog price ID (plan + billing period, e.g. hostingercom-vps-kvm1-usd-1m) + required: false + - name: --name + type: string + description: Server name (auto-generated if omitted) + required: false + - name: --post-install-script-id + type: integer + description: Hostinger post-install script ID + required: false + default: 0 + - name: --private-key + type: string + description: Coolify private key UUID + required: false + - name: --ssh-key-ids + type: intSlice + description: Additional Hostinger SSH key IDs + required: false + - name: --template-id + type: integer + description: Hostinger OS template ID + required: false + default: 0 + - name: --validate + type: boolean + description: Validate after creation + required: false + default: false + +Command: coolify server hostinger data-centers +Description: List Hostinger data centers +Parameters: (None) + +Command: coolify server hostinger post-install-scripts +Description: List Hostinger post-install scripts +Parameters: (None) + +Command: coolify server hostinger ssh-keys +Description: List Hostinger SSH keys +Parameters: (None) + +Command: coolify server hostinger templates +Description: List Hostinger OS templates +Parameters: (None) + Command: coolify server list Description: List all servers Parameters: (None) diff --git a/llms.txt b/llms.txt index 409df62..1bd83b6 100644 --- a/llms.txt +++ b/llms.txt @@ -140,6 +140,8 @@ coolify cloud-token create --provider hetzner --name production --provider-token coolify cloud-token validate coolify server hetzner locations coolify server hetzner create --cloud-token --location --server-type --image +coolify server hostinger catalog +coolify server hostinger create --cloud-token --item-id --data-center-id --template-id --private-key ``` ## Common Aliases