From 6e33f6df2f1db114d09281c6a787c600cbe63fe7 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 10:51:33 +0200 Subject: [PATCH 1/8] refac!: explicit provider contract --- README.md | 44 ++++-- model/common.go | 35 ----- provider/interface.go | 73 ++++++++-- provider/routes.go | 98 ++++++++----- provider/routes_test.go | 295 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 91 deletions(-) create mode 100644 provider/routes_test.go diff --git a/README.md b/README.md index 0dcb307..db1a2ec 100644 --- a/README.md +++ b/README.md @@ -19,24 +19,48 @@ Go 1.26+. ## Provider interface ```go -type Provider[CreateParams any, Status any, UpdateParams any] interface { - Create(ctx context.Context, params CreateParams) error +type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { + Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + plan PlanParams, config Config, secrets Secrets) error List(ctx context.Context) ([]model.ServiceID, error) - GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]Status, error) - Update(ctx context.Context, id model.ServiceID, args UpdateParams) error + GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]ServiceStatus[PlanParams, Config, Details], error) + Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + args UpdateParams) error Delete(ctx context.Context, id model.ServiceID) error } ``` -Embed `provider.Base` for the shared dependencies (Kubernetes client, logger) and helpers. +The split between the platform and your provider is visible in the signatures — you never declare +the Codesphere-supplied fields or the contract's envelopes yourself: -Backups are an **opt-in capability**, generic over the provider's own request type: +| Codesphere provides | You define | +|---|---| +| `id`, `teamId`, `customSubdomain` — passed as arguments | `PlanParams` — contents of `plan.parameters` | +| the `plan: {parameters: …}` wrapper, unwrapped on the way in and re-wrapped on the way out | `Config` — contents of `config` | +| `msId` on backup requests | `Secrets` — contents of `secrets` | +| the `{plan, config, details}` status envelope (`ServiceStatus`) | `Details` — read-only status data (hostnames, ports, readiness) | +| HTTP status codes and error mapping | `UpdateParams` — your partial `PATCH` payload | + +`PATCH` bodies are partial, so make `UpdateParams` fields pointers to tell "not sent" from "sent +empty". Alias the instantiation once so the five type parameters stay out of your way: + +```go +type MyProvider = provider.Provider[Params, Config, Secrets, Details, UpdateParams] +``` + +Build status values with `provider.NewServiceStatus(plan, config, details)`. Embed +`provider.Base` for the shared dependencies (Kubernetes client, logger) and helpers. + +Backups are an **opt-in capability**, generic over the provider's own backup-store schemas: ```go -type Backups[BackupParams any] interface { - TakeBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error - GetBackupStatus(ctx context.Context, backupID model.BackupId, params BackupParams) (BackupStatus, error) - DeleteBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error +type Backups[BackupConfig, BackupSecrets any] interface { + TakeBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) error + GetBackupStatus(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) (BackupStatus, error) + DeleteBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) error } ``` diff --git a/model/common.go b/model/common.go index 87846d7..4073d2d 100644 --- a/model/common.go +++ b/model/common.go @@ -8,38 +8,3 @@ type ServiceID string // BackupId is a unique identifier for a managed service backup. type BackupId string - -// PlanParameters defines the resource allocation for a managed service. -type PlanParameters struct { - // StorageMiB is the storage size in MiB. - StorageMiB int `json:"storage"` - - // CPUTenths is the CPU allocation in tenths of a core. - CPUTenths int `json:"cpu"` - - // MemoryMiB is the memory allocation in MiB. - MemoryMiB int `json:"memory"` -} - -// Plan wraps the plan parameters. -type Plan struct { - Parameters PlanParameters `json:"parameters"` -} - -// ServiceConfig holds configuration for a managed service. -type ServiceConfig struct { - // Version is the version of the managed service. - Version string `json:"version"` -} - -// ServiceSecrets holds sensitive data for a managed service. -type ServiceSecrets struct { - // SuperuserPassword is the superuser/admin password. - SuperuserPassword string `json:"superuserPassword"` -} - -// ServiceDetails contains common status details for a managed service. -type ServiceDetails struct { - // Ready indicates if the service is ready to accept connections. - Ready bool `json:"ready"` -} diff --git a/provider/interface.go b/provider/interface.go index 170dd56..a229dcd 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -13,39 +13,84 @@ import ( // Each provider (e.g., Postgres, FerretDB) implements this interface // to handle its specific lifecycle operations. // -// Generic parameters: -// - CreateParams: the full managed-service payload accepted on create -// - Status: the per-service status payload returned to the marketplace -// - UpdateParams: the partial update payload accepted on PATCH -type Provider[CreateParams any, Status any, UpdateParams any] interface { +// Whatever Codesphere sends or structures is an explicit parameter; the type +// parameters are the provider's own schemas. Providers therefore never declare +// the id/teamId/customSubdomain fields or the plan.parameters wrapper themselves. +// +// Generic parameters, each the contents of one provider-defined section of the +// REST contract: +// - PlanParams: plan.parameters +// - Config: config +// - Secrets: secrets +// - Details: details, the read-only part of the status response +// - UpdateParams: the provider's partial PATCH payload +// +// Providers are expected to alias the instantiation once: +// +// type MyProvider = provider.Provider[Params, Config, Secrets, Details, UpdateParams] +type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { // Create creates a new managed service. - Create(ctx context.Context, params CreateParams) error + Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + plan PlanParams, config Config, secrets Secrets) error // List returns all service IDs managed by this provider. List(ctx context.Context) ([]model.ServiceID, error) // GetStatus returns the status of the specified services. // Services that don't exist are simply omitted from the result map. - GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]Status, error) + GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]ServiceStatus[PlanParams, Config, Details], error) - // Update updates an existing managed service. - Update(ctx context.Context, id model.ServiceID, args UpdateParams) error + // Update updates an existing managed service. args holds whichever of the + // provider's own fields changed. + Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + args UpdateParams) error // Delete deletes a managed service. Delete(ctx context.Context, id model.ServiceID) error } +// ServiceStatus is the per-service value of the status response. Build it with +// NewServiceStatus, which applies the contract's plan.parameters wrapper. +type ServiceStatus[PlanParams, Config, Details any] struct { + // Plan echoes the service's current plan parameters. + Plan planSpec[PlanParams] `json:"plan"` + + // Config echoes the service's current configuration. + Config Config `json:"config"` + + // Details is read-only provider data (hostnames, ports, readiness, ...). + Details Details `json:"details"` +} + +// NewServiceStatus assembles a ServiceStatus, wrapping plan in the contract's +// plan.parameters envelope. +func NewServiceStatus[PlanParams, Config, Details any]( + plan PlanParams, + config Config, + details Details, +) ServiceStatus[PlanParams, Config, Details] { + return ServiceStatus[PlanParams, Config, Details]{ + Plan: planSpec[PlanParams]{Parameters: plan}, + Config: config, + Details: details, + } +} + // Backups is the optional backup capability, kept separate from Provider so a -// provider opts in by implementing it. -type Backups[BackupParams any] interface { +// provider opts in by implementing it. The type parameters are the provider's own +// backup-store schemas. +type Backups[BackupConfig, BackupSecrets any] interface { // TakeBackup initiates a backup of the managed service. - TakeBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error + TakeBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) error // GetBackupStatus returns the status of a backup. - GetBackupStatus(ctx context.Context, backupID model.BackupId, params BackupParams) (BackupStatus, error) + GetBackupStatus(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) (BackupStatus, error) // DeleteBackup deletes a backup. - DeleteBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error + DeleteBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, + config BackupConfig, secrets BackupSecrets) error } // BackupStatus is the backup-status response contract expected by Codesphere: diff --git a/provider/routes.go b/provider/routes.go index 01f0936..233be4d 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -14,10 +14,39 @@ import ( "github.com/codesphere-cloud/managed-services-lib/model" ) +// planSpec is the {"parameters": ...} envelope Codesphere wraps a plan in. +type planSpec[Params any] struct { + Parameters Params `json:"parameters"` +} + +// codesphereFields are the service fields Codesphere sends with create and +// update payloads. On update the ID comes from the path instead. +type codesphereFields struct { + ID model.ServiceID `json:"id"` + TeamID int `json:"teamId"` + CustomSubdomain string `json:"customSubdomain"` +} + +// createBody is the create payload: the Codesphere fields plus the provider's +// own sections. +type createBody[PlanParams, Config, Secrets any] struct { + codesphereFields + Plan planSpec[PlanParams] `json:"plan"` + Config Config `json:"config"` + Secrets Secrets `json:"secrets"` +} + +// backupBody is the backup payload; the backup ID comes from the path. +type backupBody[Config, Secrets any] struct { + MsID model.ServiceID `json:"msId"` + Config Config `json:"config"` + Secrets Secrets `json:"secrets"` +} + // RegisterRoutes registers CRUD routes for a managed service provider on the given router group. -func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( +func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( group *gin.RouterGroup, - p Provider[CreateParams, Status, UpdateParams], + p Provider[PlanParams, Config, Secrets, Details, UpdateParams], ) { // GET / - List all service IDs or get detailed status group.GET("", func(c *gin.Context) { @@ -49,13 +78,14 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( // POST / - Create a new service group.POST("", func(c *gin.Context) { - params, err := parseCreate[CreateParams](c) - if err != nil { + var body createBody[PlanParams, Config, Secrets] + if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := p.Create(c.Request.Context(), params); err != nil { + if err := p.Create(c.Request.Context(), body.ID, body.TeamID, body.CustomSubdomain, + body.Plan.Parameters, body.Config, body.Secrets); err != nil { HandleError(c, err) return } @@ -64,13 +94,24 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( // PATCH /:id - Update an existing service group.PATCH("/:id", func(c *gin.Context) { - id, args, err := parseUpdate[UpdateParams](c) - if err != nil { + // Two passes over the same body: the Codesphere fields and the provider's + // partial payload are siblings in one JSON object, and a type parameter + // cannot be embedded. ShouldBindBodyWithJSON caches the raw body. + var fields codesphereFields + if err := c.ShouldBindBodyWithJSON(&fields); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := p.Update(c.Request.Context(), id, args); err != nil { + var args UpdateParams + if err := c.ShouldBindBodyWithJSON(&args); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id := model.ServiceID(c.Param("id")) + if err := p.Update(c.Request.Context(), id, fields.TeamID, fields.CustomSubdomain, + args); err != nil { HandleError(c, err) return } @@ -89,19 +130,19 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( } // RegisterBackupRoutes mounts backup endpoints. -func RegisterBackupRoutes[BackupParams any]( +func RegisterBackupRoutes[BackupConfig, BackupSecrets any]( group *gin.RouterGroup, - b Backups[BackupParams], + b Backups[BackupConfig, BackupSecrets], ) { // PUT /backups/:id - Take a backup group.PUT("/backups/:id", func(c *gin.Context) { - var params BackupParams - if err := c.ShouldBindJSON(¶ms); err != nil { + backupID, body, err := parseBackup[BackupConfig, BackupSecrets](c) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := b.TakeBackup(c.Request.Context(), model.BackupId(c.Param("id")), params); err != nil { + if err := b.TakeBackup(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets); err != nil { HandleError(c, err) return } @@ -110,13 +151,13 @@ func RegisterBackupRoutes[BackupParams any]( // POST /backups/:id/status - Get backup status group.POST("/backups/:id/status", func(c *gin.Context) { - var params BackupParams - if err := c.ShouldBindJSON(¶ms); err != nil { + backupID, body, err := parseBackup[BackupConfig, BackupSecrets](c) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - status, err := b.GetBackupStatus(c.Request.Context(), model.BackupId(c.Param("id")), params) + status, err := b.GetBackupStatus(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets) if err != nil { HandleError(c, err) return @@ -126,13 +167,13 @@ func RegisterBackupRoutes[BackupParams any]( // DELETE /backups/:id - Delete a backup group.DELETE("/backups/:id", func(c *gin.Context) { - var params BackupParams - if err := c.ShouldBindJSON(¶ms); err != nil { + backupID, body, err := parseBackup[BackupConfig, BackupSecrets](c) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := b.DeleteBackup(c.Request.Context(), model.BackupId(c.Param("id")), params); err != nil { + if err := b.DeleteBackup(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets); err != nil { HandleError(c, err) return } @@ -140,20 +181,13 @@ func RegisterBackupRoutes[BackupParams any]( }) } -func parseCreate[T any](c *gin.Context) (T, error) { - var svc T - if err := c.ShouldBindJSON(&svc); err != nil { - return svc, err - } - return svc, nil -} - -func parseUpdate[T any](c *gin.Context) (model.ServiceID, T, error) { - var args T - if err := c.ShouldBindJSON(&args); err != nil { - return "", args, err +// parseBackup decodes a backup payload and pairs it with the backup ID from the path. +func parseBackup[Config, Secrets any](c *gin.Context) (model.BackupId, backupBody[Config, Secrets], error) { + var body backupBody[Config, Secrets] + if err := c.ShouldBindJSON(&body); err != nil { + return "", body, err } - return model.ServiceID(c.Param("id")), args, nil + return model.BackupId(c.Param("id")), body, nil } // HandleError handles provider errors and returns appropriate HTTP responses. diff --git a/provider/routes_test.go b/provider/routes_test.go new file mode 100644 index 0000000..40975f9 --- /dev/null +++ b/provider/routes_test.go @@ -0,0 +1,295 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package provider_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/managed-services-lib/model" + "github.com/codesphere-cloud/managed-services-lib/provider" +) + +type ( + fakeParams struct { + Storage int `json:"storage"` + } + + fakeConfig struct { + Version string `json:"version"` + } + + fakeSecrets struct { + Password string `json:"password"` + } + + fakeDetails struct { + Hostname string `json:"hostname"` + Ready bool `json:"ready"` + } + + fakeUpdate struct { + Plan *struct { + Parameters fakeParams `json:"parameters"` + } `json:"plan"` + Config *fakeConfig `json:"config"` + } + + fakeBackupConfig struct { + Bucket string `json:"bucket"` + } + + fakeBackupSecrets struct { + AccessKey string `json:"accessKey"` + } +) + +type createCall struct { + ID model.ServiceID + TeamID int + CustomSubdomain string + Plan fakeParams + Config fakeConfig + Secrets fakeSecrets +} + +type updateCall struct { + ID model.ServiceID + TeamID int + CustomSubdomain string + Args fakeUpdate +} + +type backupCall struct { + BackupID model.BackupId + MsID model.ServiceID + Config fakeBackupConfig + Secrets fakeBackupSecrets +} + +type fakeProvider struct { + created []createCall + updated []updateCall + deleted []model.ServiceID + backedUp []backupCall + status map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails] +} + +func (f *fakeProvider) Create(_ context.Context, id model.ServiceID, teamID int, customSubdomain string, + plan fakeParams, config fakeConfig, secrets fakeSecrets) error { + f.created = append(f.created, createCall{id, teamID, customSubdomain, plan, config, secrets}) + return nil +} + +func (f *fakeProvider) List(_ context.Context) ([]model.ServiceID, error) { + return []model.ServiceID{"svc-1", "svc-2"}, nil +} + +func (f *fakeProvider) GetStatus(_ context.Context, _ []model.ServiceID) (map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails], error) { + return f.status, nil +} + +func (f *fakeProvider) Update(_ context.Context, id model.ServiceID, teamID int, customSubdomain string, + args fakeUpdate) error { + f.updated = append(f.updated, updateCall{id, teamID, customSubdomain, args}) + return nil +} + +func (f *fakeProvider) Delete(_ context.Context, id model.ServiceID) error { + f.deleted = append(f.deleted, id) + return nil +} + +func (f *fakeProvider) TakeBackup(_ context.Context, backupID model.BackupId, msID model.ServiceID, + config fakeBackupConfig, secrets fakeBackupSecrets) error { + f.backedUp = append(f.backedUp, backupCall{backupID, msID, config, secrets}) + return nil +} + +func (f *fakeProvider) GetBackupStatus(_ context.Context, _ model.BackupId, _ model.ServiceID, + _ fakeBackupConfig, _ fakeBackupSecrets) (provider.BackupStatus, error) { + return provider.BackupStatus{Exists: true}, nil +} + +func (f *fakeProvider) DeleteBackup(_ context.Context, _ model.BackupId, _ model.ServiceID, + _ fakeBackupConfig, _ fakeBackupSecrets) error { + return nil +} + +var _ = Describe("Routes", func() { + var ( + p *fakeProvider + router *gin.Engine + ) + + // do issues a request against the registered routes. + do := func(method, path, body string) *httptest.ResponseRecorder { + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + BeforeEach(func() { + gin.SetMode(gin.TestMode) + p = &fakeProvider{} + router = gin.New() + group := router.Group("/api/v1/fake") + // Passing the concrete provider directly also pins the type-inference + // behaviour: registration needs no explicit type arguments. + provider.RegisterRoutes(group, p) + provider.RegisterBackupRoutes(group, p) + }) + + Describe("POST /", func() { + // The payload from the Codesphere REST contract, plus the identity fields + // the platform sends alongside id. + const body = `{ + "id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + "teamId": 7, + "customSubdomain": "my-db", + "plan": {"parameters": {"storage": 1000}}, + "config": {"version": "14.2"}, + "secrets": {"password": "super-secret-password"} + }` + + It("passes each Codesphere-supplied field as its own argument", func() { + w := do(http.MethodPost, "/api/v1/fake", body) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(p.created).To(HaveLen(1)) + Expect(p.created[0].ID).To(Equal(model.ServiceID("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))) + Expect(p.created[0].TeamID).To(Equal(7)) + Expect(p.created[0].CustomSubdomain).To(Equal("my-db")) + }) + + It("unwraps plan.parameters into the provider's params type", func() { + do(http.MethodPost, "/api/v1/fake", body) + + Expect(p.created[0].Plan).To(Equal(fakeParams{Storage: 1000})) + }) + + It("decodes the provider's own config and secrets sections", func() { + do(http.MethodPost, "/api/v1/fake", body) + + Expect(p.created[0].Config).To(Equal(fakeConfig{Version: "14.2"})) + Expect(p.created[0].Secrets).To(Equal(fakeSecrets{Password: "super-secret-password"})) + }) + + It("rejects a malformed body", func() { + w := do(http.MethodPost, "/api/v1/fake", `{"plan": "not-an-object"}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.created).To(BeEmpty()) + }) + }) + + Describe("PATCH /:id", func() { + It("takes the ID from the path and decodes identity alongside the partial payload", func() { + w := do(http.MethodPatch, "/api/v1/fake/svc-1", + `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(p.updated).To(HaveLen(1)) + Expect(p.updated[0].ID).To(Equal(model.ServiceID("svc-1"))) + Expect(p.updated[0].TeamID).To(Equal(7)) + Expect(p.updated[0].CustomSubdomain).To(Equal("my-db")) + Expect(p.updated[0].Args.Plan).NotTo(BeNil()) + Expect(p.updated[0].Args.Plan.Parameters).To(Equal(fakeParams{Storage: 2000})) + }) + + It("leaves sections Codesphere did not send unset", func() { + do(http.MethodPatch, "/api/v1/fake/svc-1", + `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) + + Expect(p.updated[0].Args.Config).To(BeNil()) + }) + }) + + Describe("GET /", func() { + It("lists service IDs when no id is given", func() { + w := do(http.MethodGet, "/api/v1/fake", "") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal(`["svc-1","svc-2"]`)) + }) + + It("exposes the wrapped plan parameters to the provider that built it", func() { + status := provider.NewServiceStatus( + fakeParams{Storage: 1000}, + fakeConfig{Version: "14.2"}, + fakeDetails{Ready: true}, + ) + + // The wrapper type is unexported, but its fields are readable, so a + // provider can inspect a status it built without owning the envelope. + Expect(status.Plan.Parameters).To(Equal(fakeParams{Storage: 1000})) + }) + + It("re-wraps plan parameters in the status response", func() { + p.status = map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails]{ + "svc-1": provider.NewServiceStatus( + fakeParams{Storage: 1000}, + fakeConfig{Version: "14.2"}, + fakeDetails{Hostname: "10.0.0.5", Ready: true}, + ), + } + + w := do(http.MethodGet, "/api/v1/fake?id=svc-1", "") + + Expect(w.Code).To(Equal(http.StatusOK)) + var got map[string]map[string]json.RawMessage + Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed()) + Expect(string(got["svc-1"]["plan"])).To(MatchJSON(`{"parameters":{"storage":1000}}`)) + Expect(string(got["svc-1"]["config"])).To(MatchJSON(`{"version":"14.2"}`)) + Expect(string(got["svc-1"]["details"])).To(MatchJSON(`{"hostname":"10.0.0.5","ready":true}`)) + }) + }) + + Describe("DELETE /:id", func() { + It("passes the path ID through", func() { + w := do(http.MethodDelete, "/api/v1/fake/svc-1", "") + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(p.deleted).To(Equal([]model.ServiceID{"svc-1"})) + }) + }) + + Describe("PUT /backups/:id", func() { + It("takes the backup ID from the path and the service ID from msId", func() { + w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", + `{"msId": "svc-1", "config": {"bucket": "b"}, "secrets": {"accessKey": "k"}}`) + + Expect(w.Code).To(Equal(http.StatusAccepted)) + Expect(p.backedUp).To(Equal([]backupCall{{ + BackupID: "backup-1", + MsID: "svc-1", + Config: fakeBackupConfig{Bucket: "b"}, + Secrets: fakeBackupSecrets{AccessKey: "k"}, + }})) + }) + }) + + Describe("POST /backups/:id/status", func() { + It("returns the backup status contract", func() { + w := do(http.MethodPost, "/api/v1/fake/backups/backup-1/status", `{"msId": "svc-1"}`) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON(`{"exists":true}`)) + }) + }) +}) From 67b6f06ddcc927fda5f184aad2770c524c1d80c8 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 11:53:25 +0200 Subject: [PATCH 2/8] make customSubdomain optional --- README.md | 4 ++-- provider/interface.go | 4 ++-- provider/routes.go | 2 +- provider/routes_test.go | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index db1a2ec..11f754b 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ Go 1.26+. ```go type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { - Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, plan PlanParams, config Config, secrets Secrets) error List(ctx context.Context) ([]model.ServiceID, error) GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]ServiceStatus[PlanParams, Config, Details], error) - Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, args UpdateParams) error Delete(ctx context.Context, id model.ServiceID) error } diff --git a/provider/interface.go b/provider/interface.go index a229dcd..c64f9ef 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -30,7 +30,7 @@ import ( // type MyProvider = provider.Provider[Params, Config, Secrets, Details, UpdateParams] type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { // Create creates a new managed service. - Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, plan PlanParams, config Config, secrets Secrets) error // List returns all service IDs managed by this provider. @@ -42,7 +42,7 @@ type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface // Update updates an existing managed service. args holds whichever of the // provider's own fields changed. - Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain string, + Update(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, args UpdateParams) error // Delete deletes a managed service. diff --git a/provider/routes.go b/provider/routes.go index 233be4d..84be135 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -24,7 +24,7 @@ type planSpec[Params any] struct { type codesphereFields struct { ID model.ServiceID `json:"id"` TeamID int `json:"teamId"` - CustomSubdomain string `json:"customSubdomain"` + CustomSubdomain *string `json:"customSubdomain"` } // createBody is the create payload: the Codesphere fields plus the provider's diff --git a/provider/routes_test.go b/provider/routes_test.go index 40975f9..a3c55ff 100644 --- a/provider/routes_test.go +++ b/provider/routes_test.go @@ -55,7 +55,7 @@ type ( type createCall struct { ID model.ServiceID TeamID int - CustomSubdomain string + CustomSubdomain *string Plan fakeParams Config fakeConfig Secrets fakeSecrets @@ -64,7 +64,7 @@ type createCall struct { type updateCall struct { ID model.ServiceID TeamID int - CustomSubdomain string + CustomSubdomain *string Args fakeUpdate } @@ -83,7 +83,7 @@ type fakeProvider struct { status map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails] } -func (f *fakeProvider) Create(_ context.Context, id model.ServiceID, teamID int, customSubdomain string, +func (f *fakeProvider) Create(_ context.Context, id model.ServiceID, teamID int, customSubdomain *string, plan fakeParams, config fakeConfig, secrets fakeSecrets) error { f.created = append(f.created, createCall{id, teamID, customSubdomain, plan, config, secrets}) return nil @@ -97,7 +97,7 @@ func (f *fakeProvider) GetStatus(_ context.Context, _ []model.ServiceID) (map[mo return f.status, nil } -func (f *fakeProvider) Update(_ context.Context, id model.ServiceID, teamID int, customSubdomain string, +func (f *fakeProvider) Update(_ context.Context, id model.ServiceID, teamID int, customSubdomain *string, args fakeUpdate) error { f.updated = append(f.updated, updateCall{id, teamID, customSubdomain, args}) return nil @@ -174,7 +174,7 @@ var _ = Describe("Routes", func() { Expect(p.created).To(HaveLen(1)) Expect(p.created[0].ID).To(Equal(model.ServiceID("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))) Expect(p.created[0].TeamID).To(Equal(7)) - Expect(p.created[0].CustomSubdomain).To(Equal("my-db")) + Expect(p.created[0].CustomSubdomain).To(HaveValue(Equal("my-db"))) }) It("unwraps plan.parameters into the provider's params type", func() { @@ -207,7 +207,7 @@ var _ = Describe("Routes", func() { Expect(p.updated).To(HaveLen(1)) Expect(p.updated[0].ID).To(Equal(model.ServiceID("svc-1"))) Expect(p.updated[0].TeamID).To(Equal(7)) - Expect(p.updated[0].CustomSubdomain).To(Equal("my-db")) + Expect(p.updated[0].CustomSubdomain).To(HaveValue(Equal("my-db"))) Expect(p.updated[0].Args.Plan).NotTo(BeNil()) Expect(p.updated[0].Args.Plan.Parameters).To(Equal(fakeParams{Storage: 2000})) }) From 5b85974b4a8e5a4e8a427bc099c8ba8102632af2 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 12:27:24 +0200 Subject: [PATCH 3/8] fix vuln --- .github/workflows/ci.yml | 4 ++-- .github/workflows/security.yml | 2 +- go.mod | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 762ce6f..7167075 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.5' + go-version: '1.26.6' - name: Run golangci-lint run: make lint @@ -30,7 +30,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.5' + go-version: '1.26.6' - name: Unit tests run: make test diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 05b2b41..63a3700 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.5' + go-version: '1.26.6' - name: Run govulncheck run: | diff --git a/go.mod b/go.mod index e3ef079..c5f3234 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/codesphere-cloud/managed-services-lib go 1.26 -toolchain go1.26.5 +toolchain go1.26.6 require ( github.com/gin-gonic/gin v1.12.0 From 6292045f62877afb576a9d7d1a7283ca7fb039b5 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 10:30:07 +0000 Subject: [PATCH 4/8] chore: update NOTICE Signed-off-by: utkuerol --- NOTICE | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/NOTICE b/NOTICE index e79722b..dccead5 100644 --- a/NOTICE +++ b/NOTICE @@ -221,15 +221,15 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.52.0 +Version: v0.53.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.52.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.53.0:LICENSE ---------- Module: golang.org/x/net -Version: v0.55.0 +Version: v0.56.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE +License URL: https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE ---------- Module: golang.org/x/oauth2 @@ -239,21 +239,21 @@ License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.45.0 +Version: v0.46.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.46.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.43.0 +Version: v0.44.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.43.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.44.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.37.0 +Version: v0.40.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.40.0:LICENSE ---------- Module: golang.org/x/time/rate From 3a8ce9c3737702ab4b4315c1f1391ad1c7cc45e3 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 13:44:29 +0200 Subject: [PATCH 5/8] dont use hardcoded go version --- .github/workflows/ci.yml | 4 ++-- .github/workflows/security.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7167075..d0d6921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.6' + go-version-file: 'go.mod' - name: Run golangci-lint run: make lint @@ -30,7 +30,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.6' + go-version-file: 'go.mod' - name: Unit tests run: make test diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 63a3700..b47ebc8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.6' + go-version-file: 'go.mod' - name: Run govulncheck run: | From ac53b26714858b558025aa0f97070c4fc59d7163 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Mon, 17 Aug 2026 13:57:56 +0200 Subject: [PATCH 6/8] refac --- README.md | 14 ++++---------- provider/interface.go | 11 ++++------- provider/routes.go | 20 +++++++++----------- provider/routes_test.go | 13 +++---------- 4 files changed, 20 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 11f754b..67e3e0f 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface } ``` -The split between the platform and your provider is visible in the signatures — you never declare -the Codesphere-supplied fields or the contract's envelopes yourself: +The split between the contract and your provider is visible in the signatures — you never declare +the contract's own fields or envelopes yourself: -| Codesphere provides | You define | +| The contract defines | You define | |---|---| | `id`, `teamId`, `customSubdomain` — passed as arguments | `PlanParams` — contents of `plan.parameters` | | the `plan: {parameters: …}` wrapper, unwrapped on the way in and re-wrapped on the way out | `Config` — contents of `config` | @@ -42,13 +42,7 @@ the Codesphere-supplied fields or the contract's envelopes yourself: | HTTP status codes and error mapping | `UpdateParams` — your partial `PATCH` payload | `PATCH` bodies are partial, so make `UpdateParams` fields pointers to tell "not sent" from "sent -empty". Alias the instantiation once so the five type parameters stay out of your way: - -```go -type MyProvider = provider.Provider[Params, Config, Secrets, Details, UpdateParams] -``` - -Build status values with `provider.NewServiceStatus(plan, config, details)`. Embed +empty". Build status values with `provider.NewServiceStatus(plan, config, details)`. Embed `provider.Base` for the shared dependencies (Kubernetes client, logger) and helpers. Backups are an **opt-in capability**, generic over the provider's own backup-store schemas: diff --git a/provider/interface.go b/provider/interface.go index c64f9ef..fe27d62 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -13,21 +13,18 @@ import ( // Each provider (e.g., Postgres, FerretDB) implements this interface // to handle its specific lifecycle operations. // -// Whatever Codesphere sends or structures is an explicit parameter; the type +// Whatever the REST contract defines is an explicit parameter; the type // parameters are the provider's own schemas. Providers therefore never declare -// the id/teamId/customSubdomain fields or the plan.parameters wrapper themselves. +// the id/teamId/customSubdomain fields or the plan.parameters wrapper themselves — +// the library decodes them off the request and passes them in. // // Generic parameters, each the contents of one provider-defined section of the -// REST contract: +// contract: // - PlanParams: plan.parameters // - Config: config // - Secrets: secrets // - Details: details, the read-only part of the status response // - UpdateParams: the provider's partial PATCH payload -// -// Providers are expected to alias the instantiation once: -// -// type MyProvider = provider.Provider[Params, Config, Secrets, Details, UpdateParams] type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { // Create creates a new managed service. Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, diff --git a/provider/routes.go b/provider/routes.go index 84be135..0aaf331 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -14,23 +14,24 @@ import ( "github.com/codesphere-cloud/managed-services-lib/model" ) -// planSpec is the {"parameters": ...} envelope Codesphere wraps a plan in. +// planSpec is the {"parameters": ...} envelope the contract wraps a plan in. type planSpec[Params any] struct { Parameters Params `json:"parameters"` } -// codesphereFields are the service fields Codesphere sends with create and -// update payloads. On update the ID comes from the path instead. -type codesphereFields struct { +// serviceFields are the service-level fields the contract defines on create and +// update payloads, as opposed to the provider's own sections. On update the ID +// comes from the path instead. +type serviceFields struct { ID model.ServiceID `json:"id"` TeamID int `json:"teamId"` CustomSubdomain *string `json:"customSubdomain"` } -// createBody is the create payload: the Codesphere fields plus the provider's -// own sections. +// createBody is the create payload: the service fields plus the provider's own +// sections. type createBody[PlanParams, Config, Secrets any] struct { - codesphereFields + serviceFields Plan planSpec[PlanParams] `json:"plan"` Config Config `json:"config"` Secrets Secrets `json:"secrets"` @@ -94,10 +95,7 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( // PATCH /:id - Update an existing service group.PATCH("/:id", func(c *gin.Context) { - // Two passes over the same body: the Codesphere fields and the provider's - // partial payload are siblings in one JSON object, and a type parameter - // cannot be embedded. ShouldBindBodyWithJSON caches the raw body. - var fields codesphereFields + var fields serviceFields if err := c.ShouldBindBodyWithJSON(&fields); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/provider/routes_test.go b/provider/routes_test.go index a3c55ff..0223515 100644 --- a/provider/routes_test.go +++ b/provider/routes_test.go @@ -130,7 +130,6 @@ var _ = Describe("Routes", func() { router *gin.Engine ) - // do issues a request against the registered routes. do := func(method, path, body string) *httptest.ResponseRecorder { var req *http.Request if body == "" { @@ -149,15 +148,11 @@ var _ = Describe("Routes", func() { p = &fakeProvider{} router = gin.New() group := router.Group("/api/v1/fake") - // Passing the concrete provider directly also pins the type-inference - // behaviour: registration needs no explicit type arguments. provider.RegisterRoutes(group, p) provider.RegisterBackupRoutes(group, p) }) Describe("POST /", func() { - // The payload from the Codesphere REST contract, plus the identity fields - // the platform sends alongside id. const body = `{ "id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", "teamId": 7, @@ -167,7 +162,7 @@ var _ = Describe("Routes", func() { "secrets": {"password": "super-secret-password"} }` - It("passes each Codesphere-supplied field as its own argument", func() { + It("passes each service-level field as its own argument", func() { w := do(http.MethodPost, "/api/v1/fake", body) Expect(w.Code).To(Equal(http.StatusCreated)) @@ -199,7 +194,7 @@ var _ = Describe("Routes", func() { }) Describe("PATCH /:id", func() { - It("takes the ID from the path and decodes identity alongside the partial payload", func() { + It("takes the ID from the path and decodes the service fields alongside the partial payload", func() { w := do(http.MethodPatch, "/api/v1/fake/svc-1", `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) @@ -212,7 +207,7 @@ var _ = Describe("Routes", func() { Expect(p.updated[0].Args.Plan.Parameters).To(Equal(fakeParams{Storage: 2000})) }) - It("leaves sections Codesphere did not send unset", func() { + It("leaves sections the request omits unset", func() { do(http.MethodPatch, "/api/v1/fake/svc-1", `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) @@ -235,8 +230,6 @@ var _ = Describe("Routes", func() { fakeDetails{Ready: true}, ) - // The wrapper type is unexported, but its fields are readable, so a - // provider can inspect a status it built without owning the envelope. Expect(status.Plan.Parameters).To(Equal(fakeParams{Storage: 1000})) }) From 0777d6a591ce910704d2af236b24c12b86ea4626 Mon Sep 17 00:00:00 2001 From: utkuerol Date: Fri, 21 Aug 2026 10:16:11 +0200 Subject: [PATCH 7/8] review --- provider/routes.go | 17 ++++++++++++++++- provider/routes_test.go | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/provider/routes.go b/provider/routes.go index 0aaf331..6bd1bdb 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -85,6 +85,15 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( return } + if body.ID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + if body.TeamID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "teamId must be a positive integer"}) + return + } + if err := p.Create(c.Request.Context(), body.ID, body.TeamID, body.CustomSubdomain, body.Plan.Parameters, body.Config, body.Secrets); err != nil { HandleError(c, err) @@ -108,6 +117,10 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( } id := model.ServiceID(c.Param("id")) + if fields.ID != id { + c.JSON(http.StatusBadRequest, gin.H{"error": "id in body must match path"}) + return + } if err := p.Update(c.Request.Context(), id, fields.TeamID, fields.CustomSubdomain, args); err != nil { HandleError(c, err) @@ -179,12 +192,14 @@ func RegisterBackupRoutes[BackupConfig, BackupSecrets any]( }) } -// parseBackup decodes a backup payload and pairs it with the backup ID from the path. func parseBackup[Config, Secrets any](c *gin.Context) (model.BackupId, backupBody[Config, Secrets], error) { var body backupBody[Config, Secrets] if err := c.ShouldBindJSON(&body); err != nil { return "", body, err } + if body.MsID == "" { + return "", body, errors.New("msId is required") + } return model.BackupId(c.Param("id")), body, nil } diff --git a/provider/routes_test.go b/provider/routes_test.go index 0223515..095df68 100644 --- a/provider/routes_test.go +++ b/provider/routes_test.go @@ -191,12 +191,26 @@ var _ = Describe("Routes", func() { Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(p.created).To(BeEmpty()) }) + + It("rejects a body without an id", func() { + w := do(http.MethodPost, "/api/v1/fake", `{"teamId": 7}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.created).To(BeEmpty()) + }) + + It("rejects a body without a teamId", func() { + w := do(http.MethodPost, "/api/v1/fake", `{"id": "svc-1"}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.created).To(BeEmpty()) + }) }) Describe("PATCH /:id", func() { It("takes the ID from the path and decodes the service fields alongside the partial payload", func() { w := do(http.MethodPatch, "/api/v1/fake/svc-1", - `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) + `{"id": "svc-1", "teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(p.updated).To(HaveLen(1)) @@ -207,9 +221,16 @@ var _ = Describe("Routes", func() { Expect(p.updated[0].Args.Plan.Parameters).To(Equal(fakeParams{Storage: 2000})) }) + It("rejects a body whose id does not match the path", func() { + w := do(http.MethodPatch, "/api/v1/fake/svc-1", `{"id": "svc-2", "teamId": 7}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.updated).To(BeEmpty()) + }) + It("leaves sections the request omits unset", func() { do(http.MethodPatch, "/api/v1/fake/svc-1", - `{"teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) + `{"id": "svc-1", "teamId": 7, "customSubdomain": "my-db", "plan": {"parameters": {"storage": 2000}}}`) Expect(p.updated[0].Args.Config).To(BeNil()) }) @@ -275,6 +296,13 @@ var _ = Describe("Routes", func() { Secrets: fakeBackupSecrets{AccessKey: "k"}, }})) }) + + It("rejects a body without an msId", func() { + w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", `{"config": {"bucket": "b"}}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.backedUp).To(BeEmpty()) + }) }) Describe("POST /backups/:id/status", func() { From 7d89ee47265582782e3bf9430acc16f34ef3b9cf Mon Sep 17 00:00:00 2001 From: utkuerol Date: Fri, 21 Aug 2026 11:14:12 +0200 Subject: [PATCH 8/8] add missing backup types --- model/common.go | 45 ++++++++++++++++++++++++++++++++++++ provider/backupjob.go | 4 ++-- provider/interface.go | 49 +++++++++++---------------------------- provider/routes.go | 34 +++++++++++++++------------ provider/routes_test.go | 51 +++++++++++++++++++++++++---------------- 5 files changed, 111 insertions(+), 72 deletions(-) diff --git a/model/common.go b/model/common.go index 4073d2d..79b5b95 100644 --- a/model/common.go +++ b/model/common.go @@ -3,8 +3,53 @@ package model +import ( + "encoding/json" +) + // ServiceID is a unique identifier for a managed service instance. type ServiceID string // BackupId is a unique identifier for a managed service backup. type BackupId string + +// PlanSpec is the {"parameters": ...} envelope the contract wraps a plan in. +type PlanSpec[Params any] struct { + Parameters Params `json:"parameters"` +} + +// ServiceStatus is the per-service value of the status response. Build it with +// NewServiceStatus, which applies the contract's plan.parameters wrapper. +type ServiceStatus[PlanParams, Config, Details any] struct { + // Plan echoes the service's current plan parameters. + Plan PlanSpec[PlanParams] `json:"plan"` + + // Config echoes the service's current configuration. + Config Config `json:"config"` + + // Details is read-only provider data (hostnames, ports, readiness, ...). + Details Details `json:"details"` + + // Error carries a provider-detected problem with the service, if any; the + // caller sets this directly on the value NewServiceStatus returns. + Error string `json:"error,omitempty"` +} + +// RecoverFrom, sent only on create, asks the provider to restore the new +// service from an existing backup instead of provisioning it empty. Config +// and Secrets are deferred as raw JSON since they are provider specific. +type RecoverFrom struct { + ID BackupId `json:"id"` + Config json.RawMessage `json:"config"` + Secrets json.RawMessage `json:"secrets"` +} + +// BackupStatus is the backup-status response contract expected by Codesphere: +// whether the backup exists (was taken successfully) and, if it failed, why. +type BackupStatus struct { + // Exists is true once the backup has been taken successfully. + Exists bool `json:"exists"` + + // Error contains the failure reason when the backup failed; empty otherwise. + Error string `json:"error,omitempty"` +} diff --git a/provider/backupjob.go b/provider/backupjob.go index 5b4ee11..0a56f21 100644 --- a/provider/backupjob.go +++ b/provider/backupjob.go @@ -19,8 +19,8 @@ func DeleteBackupJobName(backupID model.BackupId) string { } // BackupStatusFromJob maps a Job snapshot to the backup status contract. -func BackupStatusFromJob(s client.JobState) BackupStatus { - status := BackupStatus{Exists: s.Phase == client.JobSucceeded} +func BackupStatusFromJob(s client.JobState) model.BackupStatus { + status := model.BackupStatus{Exists: s.Phase == client.JobSucceeded} if s.Phase == client.JobFailed { status.Error = s.Reason } diff --git a/provider/interface.go b/provider/interface.go index fe27d62..0f6537e 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -27,15 +27,16 @@ import ( // - UpdateParams: the provider's partial PATCH payload type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { // Create creates a new managed service. + // recoverFrom is set when the request asks to restore the new service from a backup. Create(ctx context.Context, id model.ServiceID, teamID int, customSubdomain *string, - plan PlanParams, config Config, secrets Secrets) error + plan PlanParams, config Config, secrets Secrets, recoverFrom *model.RecoverFrom) error // List returns all service IDs managed by this provider. List(ctx context.Context) ([]model.ServiceID, error) // GetStatus returns the status of the specified services. // Services that don't exist are simply omitted from the result map. - GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]ServiceStatus[PlanParams, Config, Details], error) + GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]model.ServiceStatus[PlanParams, Config, Details], error) // Update updates an existing managed service. args holds whichever of the // provider's own fields changed. @@ -46,28 +47,15 @@ type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface Delete(ctx context.Context, id model.ServiceID) error } -// ServiceStatus is the per-service value of the status response. Build it with -// NewServiceStatus, which applies the contract's plan.parameters wrapper. -type ServiceStatus[PlanParams, Config, Details any] struct { - // Plan echoes the service's current plan parameters. - Plan planSpec[PlanParams] `json:"plan"` - - // Config echoes the service's current configuration. - Config Config `json:"config"` - - // Details is read-only provider data (hostnames, ports, readiness, ...). - Details Details `json:"details"` -} - // NewServiceStatus assembles a ServiceStatus, wrapping plan in the contract's // plan.parameters envelope. func NewServiceStatus[PlanParams, Config, Details any]( plan PlanParams, config Config, details Details, -) ServiceStatus[PlanParams, Config, Details] { - return ServiceStatus[PlanParams, Config, Details]{ - Plan: planSpec[PlanParams]{Parameters: plan}, +) model.ServiceStatus[PlanParams, Config, Details] { + return model.ServiceStatus[PlanParams, Config, Details]{ + Plan: model.PlanSpec[PlanParams]{Parameters: plan}, Config: config, Details: details, } @@ -75,27 +63,18 @@ func NewServiceStatus[PlanParams, Config, Details any]( // Backups is the optional backup capability, kept separate from Provider so a // provider opts in by implementing it. The type parameters are the provider's own -// backup-store schemas. +// backup-store schemas. retentionDays is nil when the request left retention +// unmanaged. type Backups[BackupConfig, BackupSecrets any] interface { // TakeBackup initiates a backup of the managed service. - TakeBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, - config BackupConfig, secrets BackupSecrets) error + TakeBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, teamID int, + config BackupConfig, secrets BackupSecrets, retentionDays *int) error // GetBackupStatus returns the status of a backup. - GetBackupStatus(ctx context.Context, backupID model.BackupId, msID model.ServiceID, - config BackupConfig, secrets BackupSecrets) (BackupStatus, error) + GetBackupStatus(ctx context.Context, backupID model.BackupId, msID model.ServiceID, teamID int, + config BackupConfig, secrets BackupSecrets, retentionDays *int) (model.BackupStatus, error) // DeleteBackup deletes a backup. - DeleteBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, - config BackupConfig, secrets BackupSecrets) error -} - -// BackupStatus is the backup-status response contract expected by Codesphere: -// whether the backup exists (was taken successfully) and, if it failed, why. -type BackupStatus struct { - // Exists is true once the backup has been taken successfully. - Exists bool `json:"exists"` - - // Error contains the failure reason when the backup failed; empty otherwise. - Error string `json:"error,omitempty"` + DeleteBackup(ctx context.Context, backupID model.BackupId, msID model.ServiceID, teamID int, + config BackupConfig, secrets BackupSecrets, retentionDays *int) error } diff --git a/provider/routes.go b/provider/routes.go index 6bd1bdb..ac6d74c 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -14,11 +14,6 @@ import ( "github.com/codesphere-cloud/managed-services-lib/model" ) -// planSpec is the {"parameters": ...} envelope the contract wraps a plan in. -type planSpec[Params any] struct { - Parameters Params `json:"parameters"` -} - // serviceFields are the service-level fields the contract defines on create and // update payloads, as opposed to the provider's own sections. On update the ID // comes from the path instead. @@ -32,16 +27,19 @@ type serviceFields struct { // sections. type createBody[PlanParams, Config, Secrets any] struct { serviceFields - Plan planSpec[PlanParams] `json:"plan"` - Config Config `json:"config"` - Secrets Secrets `json:"secrets"` + Plan model.PlanSpec[PlanParams] `json:"plan"` + Config Config `json:"config"` + Secrets Secrets `json:"secrets"` + RecoverFrom *model.RecoverFrom `json:"recoverFrom,omitempty"` } // backupBody is the backup payload; the backup ID comes from the path. type backupBody[Config, Secrets any] struct { - MsID model.ServiceID `json:"msId"` - Config Config `json:"config"` - Secrets Secrets `json:"secrets"` + MsID model.ServiceID `json:"msId"` + TeamID int `json:"teamId"` + Config Config `json:"config"` + Secrets Secrets `json:"secrets"` + RetentionDays *int `json:"retentionDays,omitempty"` } // RegisterRoutes registers CRUD routes for a managed service provider on the given router group. @@ -95,7 +93,7 @@ func RegisterRoutes[PlanParams, Config, Secrets, Details, UpdateParams any]( } if err := p.Create(c.Request.Context(), body.ID, body.TeamID, body.CustomSubdomain, - body.Plan.Parameters, body.Config, body.Secrets); err != nil { + body.Plan.Parameters, body.Config, body.Secrets, body.RecoverFrom); err != nil { HandleError(c, err) return } @@ -153,7 +151,8 @@ func RegisterBackupRoutes[BackupConfig, BackupSecrets any]( return } - if err := b.TakeBackup(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets); err != nil { + if err := b.TakeBackup(c.Request.Context(), backupID, body.MsID, body.TeamID, + body.Config, body.Secrets, body.RetentionDays); err != nil { HandleError(c, err) return } @@ -168,7 +167,8 @@ func RegisterBackupRoutes[BackupConfig, BackupSecrets any]( return } - status, err := b.GetBackupStatus(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets) + status, err := b.GetBackupStatus(c.Request.Context(), backupID, body.MsID, body.TeamID, + body.Config, body.Secrets, body.RetentionDays) if err != nil { HandleError(c, err) return @@ -184,7 +184,8 @@ func RegisterBackupRoutes[BackupConfig, BackupSecrets any]( return } - if err := b.DeleteBackup(c.Request.Context(), backupID, body.MsID, body.Config, body.Secrets); err != nil { + if err := b.DeleteBackup(c.Request.Context(), backupID, body.MsID, body.TeamID, + body.Config, body.Secrets, body.RetentionDays); err != nil { HandleError(c, err) return } @@ -200,6 +201,9 @@ func parseBackup[Config, Secrets any](c *gin.Context) (model.BackupId, backupBod if body.MsID == "" { return "", body, errors.New("msId is required") } + if body.TeamID <= 0 { + return "", body, errors.New("teamId must be a positive integer") + } return model.BackupId(c.Param("id")), body, nil } diff --git a/provider/routes_test.go b/provider/routes_test.go index 095df68..76bff91 100644 --- a/provider/routes_test.go +++ b/provider/routes_test.go @@ -59,6 +59,7 @@ type createCall struct { Plan fakeParams Config fakeConfig Secrets fakeSecrets + RecoverFrom *model.RecoverFrom } type updateCall struct { @@ -69,10 +70,12 @@ type updateCall struct { } type backupCall struct { - BackupID model.BackupId - MsID model.ServiceID - Config fakeBackupConfig - Secrets fakeBackupSecrets + BackupID model.BackupId + MsID model.ServiceID + TeamID int + Config fakeBackupConfig + Secrets fakeBackupSecrets + RetentionDays *int } type fakeProvider struct { @@ -80,12 +83,12 @@ type fakeProvider struct { updated []updateCall deleted []model.ServiceID backedUp []backupCall - status map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails] + status map[model.ServiceID]model.ServiceStatus[fakeParams, fakeConfig, fakeDetails] } func (f *fakeProvider) Create(_ context.Context, id model.ServiceID, teamID int, customSubdomain *string, - plan fakeParams, config fakeConfig, secrets fakeSecrets) error { - f.created = append(f.created, createCall{id, teamID, customSubdomain, plan, config, secrets}) + plan fakeParams, config fakeConfig, secrets fakeSecrets, recoverFrom *model.RecoverFrom) error { + f.created = append(f.created, createCall{id, teamID, customSubdomain, plan, config, secrets, recoverFrom}) return nil } @@ -93,7 +96,7 @@ func (f *fakeProvider) List(_ context.Context) ([]model.ServiceID, error) { return []model.ServiceID{"svc-1", "svc-2"}, nil } -func (f *fakeProvider) GetStatus(_ context.Context, _ []model.ServiceID) (map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails], error) { +func (f *fakeProvider) GetStatus(_ context.Context, _ []model.ServiceID) (map[model.ServiceID]model.ServiceStatus[fakeParams, fakeConfig, fakeDetails], error) { return f.status, nil } @@ -108,19 +111,19 @@ func (f *fakeProvider) Delete(_ context.Context, id model.ServiceID) error { return nil } -func (f *fakeProvider) TakeBackup(_ context.Context, backupID model.BackupId, msID model.ServiceID, - config fakeBackupConfig, secrets fakeBackupSecrets) error { - f.backedUp = append(f.backedUp, backupCall{backupID, msID, config, secrets}) +func (f *fakeProvider) TakeBackup(_ context.Context, backupID model.BackupId, msID model.ServiceID, teamID int, + config fakeBackupConfig, secrets fakeBackupSecrets, retentionDays *int) error { + f.backedUp = append(f.backedUp, backupCall{backupID, msID, teamID, config, secrets, retentionDays}) return nil } -func (f *fakeProvider) GetBackupStatus(_ context.Context, _ model.BackupId, _ model.ServiceID, - _ fakeBackupConfig, _ fakeBackupSecrets) (provider.BackupStatus, error) { - return provider.BackupStatus{Exists: true}, nil +func (f *fakeProvider) GetBackupStatus(_ context.Context, _ model.BackupId, _ model.ServiceID, _ int, + _ fakeBackupConfig, _ fakeBackupSecrets, _ *int) (model.BackupStatus, error) { + return model.BackupStatus{Exists: true}, nil } -func (f *fakeProvider) DeleteBackup(_ context.Context, _ model.BackupId, _ model.ServiceID, - _ fakeBackupConfig, _ fakeBackupSecrets) error { +func (f *fakeProvider) DeleteBackup(_ context.Context, _ model.BackupId, _ model.ServiceID, _ int, + _ fakeBackupConfig, _ fakeBackupSecrets, _ *int) error { return nil } @@ -255,7 +258,7 @@ var _ = Describe("Routes", func() { }) It("re-wraps plan parameters in the status response", func() { - p.status = map[model.ServiceID]provider.ServiceStatus[fakeParams, fakeConfig, fakeDetails]{ + p.status = map[model.ServiceID]model.ServiceStatus[fakeParams, fakeConfig, fakeDetails]{ "svc-1": provider.NewServiceStatus( fakeParams{Storage: 1000}, fakeConfig{Version: "14.2"}, @@ -286,19 +289,27 @@ var _ = Describe("Routes", func() { Describe("PUT /backups/:id", func() { It("takes the backup ID from the path and the service ID from msId", func() { w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", - `{"msId": "svc-1", "config": {"bucket": "b"}, "secrets": {"accessKey": "k"}}`) + `{"msId": "svc-1", "teamId": 7, "config": {"bucket": "b"}, "secrets": {"accessKey": "k"}}`) Expect(w.Code).To(Equal(http.StatusAccepted)) Expect(p.backedUp).To(Equal([]backupCall{{ BackupID: "backup-1", MsID: "svc-1", + TeamID: 7, Config: fakeBackupConfig{Bucket: "b"}, Secrets: fakeBackupSecrets{AccessKey: "k"}, }})) }) It("rejects a body without an msId", func() { - w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", `{"config": {"bucket": "b"}}`) + w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", `{"teamId": 7, "config": {"bucket": "b"}}`) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(p.backedUp).To(BeEmpty()) + }) + + It("rejects a body without a teamId", func() { + w := do(http.MethodPut, "/api/v1/fake/backups/backup-1", `{"msId": "svc-1", "config": {"bucket": "b"}}`) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(p.backedUp).To(BeEmpty()) @@ -307,7 +318,7 @@ var _ = Describe("Routes", func() { Describe("POST /backups/:id/status", func() { It("returns the backup status contract", func() { - w := do(http.MethodPost, "/api/v1/fake/backups/backup-1/status", `{"msId": "svc-1"}`) + w := do(http.MethodPost, "/api/v1/fake/backups/backup-1/status", `{"msId": "svc-1", "teamId": 7}`) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(MatchJSON(`{"exists":true}`))