From 9a673ad2473c546ab25fafb378de27c3a51926f3 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Sat, 26 Sep 2026 10:57:31 -0400 Subject: [PATCH] fix: decode the deployment list when Coolify sends it as an object Coolify filters /deployments server-side, and Laravel serializes a collection with gaps in its keys as an object keyed by index rather than an array. The client only accepted an array, so polling failed with "cannot unmarshal object into Go value of type []coolify.Deployment" and the whole instance showed as unreachable. Both shapes now decode, keeping the server's order. Co-Authored-By: Claude Opus 5.5 --- internal/coolify/client.go | 7 ++++-- internal/coolify/client_test.go | 30 +++++++++++++++++++++++++ internal/coolify/types.go | 39 +++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/coolify/client.go b/internal/coolify/client.go index 6521330..626121c 100644 --- a/internal/coolify/client.go +++ b/internal/coolify/client.go @@ -177,8 +177,11 @@ func (c *Client) Databases(ctx context.Context) ([]Resource, error) { // across every application. This is one call for the whole instance, which is // what makes per-cycle polling cheap. func (c *Client) ActiveDeployments(ctx context.Context) ([]Deployment, error) { - var out []Deployment - return out, c.do(ctx, http.MethodGet, "/deployments", &out) + var out deploymentList + if err := c.do(ctx, http.MethodGet, "/deployments", &out); err != nil { + return nil, err + } + return out, nil } // LatestDeployment returns the most recent deployment for an application, or diff --git a/internal/coolify/client_test.go b/internal/coolify/client_test.go index 8c2c08e..6b9523e 100644 --- a/internal/coolify/client_test.go +++ b/internal/coolify/client_test.go @@ -3,6 +3,7 @@ package coolify import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -296,3 +297,32 @@ func TestApplicationUUIDFromDeploymentURL(t *testing.T) { t.Errorf("unexpected url shape should yield empty uuid, got %q", got) } } + +// Coolify filters its deployment list server-side and Laravel serializes a +// filtered collection with gaps in its keys as an object, not an array. +func TestActiveDeploymentsAcceptsKeyedObject(t *testing.T) { + for name, body := range map[string]string{ + "array": `[{"deployment_uuid":"dep1","status":"in_progress"},{"deployment_uuid":"dep2","status":"queued"}]`, + "object": `{"3":{"deployment_uuid":"dep1","status":"in_progress"},"7":{"deployment_uuid":"dep2","status":"queued"}}`, + "empty": `{}`, + } { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + }) + got, err := c.ActiveDeployments(context.Background()) + if err != nil { + t.Fatalf("%s: ActiveDeployments: %v", name, err) + } + var uuids []string + for _, d := range got { + uuids = append(uuids, d.UUID) + } + want := "[dep1 dep2]" + if name == "empty" { + want = "[]" + } + if fmt.Sprint(uuids) != want { + t.Errorf("%s: got %v, want %s", name, uuids, want) + } + } +} diff --git a/internal/coolify/types.go b/internal/coolify/types.go index fd90984..3ad1bdf 100644 --- a/internal/coolify/types.go +++ b/internal/coolify/types.go @@ -3,6 +3,7 @@ package coolify import ( "encoding/json" "regexp" + "sort" "strings" "time" ) @@ -136,9 +137,43 @@ func (d Deployment) Trigger() string { // deploymentPage is the envelope returned by the per-application deployments // endpoint, which paginates while the instance-wide one returns a bare array. +// deploymentList decodes Coolify's deployment list, which arrives as an array +// normally but as an object keyed by index when the server filtered out some +// entries: Laravel serializes a collection with gaps in its keys that way. +type deploymentList []Deployment + +func (l *deploymentList) UnmarshalJSON(data []byte) error { + var list []Deployment + if err := json.Unmarshal(data, &list); err == nil { + *l = list + return nil + } + var keyed map[string]Deployment + if err := json.Unmarshal(data, &keyed); err != nil { + return err + } + keys := make([]string, 0, len(keyed)) + for k := range keyed { + keys = append(keys, k) + } + // Keep the server's order: keys are indexes, so compare them as numbers. + sort.Slice(keys, func(i, j int) bool { + if len(keys[i]) != len(keys[j]) { + return len(keys[i]) < len(keys[j]) + } + return keys[i] < keys[j] + }) + list = make([]Deployment, 0, len(keys)) + for _, k := range keys { + list = append(list, keyed[k]) + } + *l = list + return nil +} + type deploymentPage struct { - Count int `json:"count"` - Deployments []Deployment `json:"deployments"` + Count int `json:"count"` + Deployments deploymentList `json:"deployments"` } // LogLine is one entry from a deployment's log stream. Coolify stores these as