Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions model/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion provider/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions provider/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions provider/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`)

Expand Down Expand Up @@ -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}}`)

Expand Down Expand Up @@ -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),
Expand All @@ -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",
Expand Down
Loading