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..2f679bc 100644 --- a/model/common.go +++ b/model/common.go @@ -33,6 +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 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",