From 1da31f057e4ec83b09b09331fa6f38039c1f5465 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 9 Nov 2026 21:53:40 +0100 Subject: [PATCH 1/2] feat: decode and report the contract's top-level version Create and update carry the service's version (a key of the provider's versions) as Version, nil when not sent. ServiceStatus.Version reports it back. The library doesn't validate the value: the provider owns its version list. --- README.md | 7 ++++--- model/common.go | 2 ++ provider/interface.go | 4 +++- provider/routes.go | 4 ++++ provider/routes_test.go | 31 +++++++++++++++++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f18b26e..27cfa63 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ the contract's own fields or envelopes yourself: | The contract defines | You define | |---|---| -| `id`, `teamId`, `customSubdomain` — fields on the request struct | `PlanParams` — contents of `plan.parameters` | +| `id`, `teamId`, `customSubdomain`, `version` — fields on the request struct | `PlanParams` — contents of `plan.parameters` | | `pause` on update, and `pause` in the status response | `Config` — contents of `config` | | the `plan: {parameters: …}` wrapper, unwrapped on the way in and re-wrapped on the way out | `Secrets` — contents of `secrets` | | `msId` and `retentionDays` on backup requests | `Details` — read-only status data (hostnames, ports, readiness) | -| the `{plan, config, details, pause, conditions, utilization, maintenance}` status envelope (`ServiceStatus`) | `UpdateParams` — your partial `PATCH` payload | +| the `{plan, config, details, pause, conditions, utilization, maintenance, version}` status envelope (`ServiceStatus`) | `UpdateParams` — your partial `PATCH` payload | | HTTP status codes and error mapping | | `PATCH` bodies are partial, so make `UpdateParams` fields pointers to tell "not sent" from "sent @@ -68,7 +68,8 @@ Build status values with `provider.NewServiceStatus(plan, config, details, pause `health` says whether the service works for its users (`healthy`, `degraded`, `unhealthy`, `unknown`, plus an optional message); pass `nil` if you can't tell. It is dropped while the service is paused. Set `Utilization` on the result for the resource parameters you declare with -`reportsUtilization`. +`reportsUtilization`, and `Version` to the service's key of your `versions`. The library passes +`version` through unchecked; reject unknown ones with `ErrInvalidArgument`. Embed `provider.Base` for the shared dependencies (Kubernetes client, logger) and helpers. diff --git a/model/common.go b/model/common.go index 50ab24d..9e5923a 100644 --- a/model/common.go +++ b/model/common.go @@ -33,6 +33,8 @@ type ServiceStatus[PlanParams, Config, Details any] struct { // A provider that implements provider.Maintenance reports an empty list when // nothing is pending; nil leaves the field out, i.e. maintenance isn't supported. Maintenance []MaintenanceItem `json:"maintenance,omitzero"` + // Version is the service's key of the provider's versions; empty leaves it out. + Version string `json:"version,omitempty"` } // HealthStatus says whether the service currently works for its users, diff --git a/provider/interface.go b/provider/interface.go index c42e217..fc4198b 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -17,6 +17,7 @@ type CreateRequest[PlanParams, Config, Secrets any] struct { ID model.ServiceID TeamID int CustomSubdomain *string + Version *string // nil = not sent Plan PlanParams Config Config Secrets Secrets @@ -29,7 +30,8 @@ type UpdateRequest[UpdateParams any] struct { ID model.ServiceID TeamID int CustomSubdomain *string - Pause *bool // nil = leave as is + Pause *bool // nil = leave as is + Version *string // nil = leave as is Params UpdateParams Backups *model.ServiceBackups // nil = leave backups as is } diff --git a/provider/routes.go b/provider/routes.go index ffb93c6..b4b196f 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -21,6 +21,8 @@ type serviceFields struct { ID model.ServiceID `json:"id" binding:"required"` TeamID int `json:"teamId" binding:"gt=0"` CustomSubdomain *string `json:"customSubdomain"` + // Version is a key of the provider's versions; the provider validates it. + Version *string `json:"version"` } // updateFields are the service-level fields on an update payload. Pause is a @@ -96,6 +98,7 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( ID: body.ID, TeamID: body.TeamID, CustomSubdomain: body.CustomSubdomain, + Version: body.Version, Plan: body.Plan.Parameters, Config: body.Config, Secrets: body.Secrets, @@ -131,6 +134,7 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( TeamID: fields.TeamID, CustomSubdomain: fields.CustomSubdomain, Pause: fields.Pause, + Version: fields.Version, Backups: fields.Backups, Params: args, }); err != nil { diff --git a/provider/routes_test.go b/provider/routes_test.go index d175aeb..872d854 100644 --- a/provider/routes_test.go +++ b/provider/routes_test.go @@ -170,6 +170,15 @@ var _ = Describe("Routes", func() { Expect(p.created[0].Secrets).To(Equal(fakeSecrets{Password: "super-secret-password"})) }) + It("decodes the top-level version apart from the provider's config", func() { + do(http.MethodPost, "/api/v1/fake", `{"id": "svc-1", "teamId": 7, "version": "1.0.0", "config": {"version": "14.2"}}`) + do(http.MethodPost, "/api/v1/fake", body) + + Expect(p.created[0].Version).To(HaveValue(Equal("1.0.0"))) + Expect(p.created[0].Config).To(Equal(fakeConfig{Version: "14.2"})) + Expect(p.created[1].Version).To(BeNil()) + }) + It("rejects a malformed body", func() { w := do(http.MethodPost, "/api/v1/fake", `{"plan": "not-an-object"}`) @@ -294,6 +303,15 @@ var _ = Describe("Routes", func() { Expect(p.updated[1].Pause).To(BeNil()) }) + It("distinguishes a version change from leaving the version alone", func() { + do(http.MethodPatch, "/api/v1/fake/svc-1", `{"id": "svc-1", "teamId": 7, "version": "1.1.0"}`) + do(http.MethodPatch, "/api/v1/fake/svc-1", `{"id": "svc-1", "teamId": 7}`) + + Expect(p.updated).To(HaveLen(2)) + Expect(p.updated[0].Version).To(HaveValue(Equal("1.1.0"))) + Expect(p.updated[1].Version).To(BeNil()) + }) + It("decodes backups.enabled=false as a contract field", func() { w := do(http.MethodPatch, "/api/v1/fake/svc-1", `{"id": "svc-1", "teamId": 7, "backups": {"enabled": false}}`) @@ -434,6 +452,18 @@ var _ = Describe("Routes", func() { Expect(string(got["svc-1"]["utilization"])).To(MatchJSON(`{"storage":512}`)) }) + It("reports the version set by the provider at the top level of the status response", func() { + status := provider.NewServiceStatus(fakeParams{}, fakeConfig{}, fakeDetails{}, nil, nil, nil) + status.Version = "1.0.0" + p.status = map[model.ServiceID]model.ServiceStatus[fakeParams, fakeConfig, fakeDetails]{"svc-1": status} + + w := do(http.MethodGet, "/api/v1/fake?id=svc-1", "") + + var got map[string]map[string]json.RawMessage + Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed()) + Expect(string(got["svc-1"]["version"])).To(MatchJSON(`"1.0.0"`)) + }) + It("omits conditions and utilization when the provider does not set them", func() { p.status = map[model.ServiceID]model.ServiceStatus[fakeParams, fakeConfig, fakeDetails]{ "svc-1": provider.NewServiceStatus(fakeParams{}, fakeConfig{}, fakeDetails{}, nil, nil, nil), @@ -446,6 +476,7 @@ var _ = Describe("Routes", func() { Expect(got["svc-1"]).NotTo(HaveKey("conditions")) Expect(got["svc-1"]).NotTo(HaveKey("utilization")) Expect(got["svc-1"]).NotTo(HaveKey("maintenance")) + Expect(got["svc-1"]).NotTo(HaveKey("version")) }) DescribeTable("reports the maintenance set by the provider in the status response", From e78a6d63a093ecb4234dfd40fc86bbff3b4fb110 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Wed, 11 Nov 2026 11:54:42 +0100 Subject: [PATCH 2/2] chore: drop the Version field comment --- model/common.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/model/common.go b/model/common.go index 9e5923a..2f679bc 100644 --- a/model/common.go +++ b/model/common.go @@ -33,8 +33,7 @@ type ServiceStatus[PlanParams, Config, Details any] struct { // A provider that implements provider.Maintenance reports an empty list when // nothing is pending; nil leaves the field out, i.e. maintenance isn't supported. Maintenance []MaintenanceItem `json:"maintenance,omitzero"` - // Version is the service's key of the provider's versions; empty leaves it out. - Version string `json:"version,omitempty"` + Version string `json:"version,omitempty"` } // HealthStatus says whether the service currently works for its users,