diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 762ce6f..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.5' + 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.5' + go-version-file: 'go.mod' - name: Unit tests run: make test diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 05b2b41..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.5' + go-version-file: 'go.mod' - name: Run govulncheck run: | 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 diff --git a/README.md b/README.md index 0dcb307..67e3e0f 100644 --- a/README.md +++ b/README.md @@ -19,24 +19,42 @@ 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 contract and your provider is visible in the signatures — you never declare +the contract's own fields or envelopes yourself: -Backups are an **opt-in capability**, generic over the provider's own request type: +| 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` | +| `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". 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/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 diff --git a/model/common.go b/model/common.go index 87846d7..79b5b95 100644 --- a/model/common.go +++ b/model/common.go @@ -3,43 +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 -// PlanParameters defines the resource allocation for a managed service. -type PlanParameters struct { - // StorageMiB is the storage size in MiB. - StorageMiB int `json:"storage"` +// PlanSpec is the {"parameters": ...} envelope the contract wraps a plan in. +type PlanSpec[Params any] struct { + Parameters Params `json:"parameters"` +} - // CPUTenths is the CPU allocation in tenths of a core. - CPUTenths int `json:"cpu"` +// 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"` - // MemoryMiB is the memory allocation in MiB. - MemoryMiB int `json:"memory"` -} + // Config echoes the service's current configuration. + Config Config `json:"config"` -// Plan wraps the plan parameters. -type Plan struct { - Parameters PlanParameters `json:"parameters"` -} + // Details is read-only provider data (hostnames, ports, readiness, ...). + Details Details `json:"details"` -// ServiceConfig holds configuration for a managed service. -type ServiceConfig struct { - // Version is the version of the managed service. - Version string `json:"version"` + // 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"` } -// ServiceSecrets holds sensitive data for a managed service. -type ServiceSecrets struct { - // SuperuserPassword is the superuser/admin password. - SuperuserPassword string `json:"superuserPassword"` +// 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"` } -// 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"` +// 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 170dd56..0f6537e 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -13,47 +13,68 @@ 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 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 library decodes them off the request and passes them in. +// +// Generic parameters, each the contents of one provider-defined section of the +// 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 +type Provider[PlanParams, Config, Secrets, Details, UpdateParams any] interface { // Create creates a new managed service. - Create(ctx context.Context, params CreateParams) error + // 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, 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]Status, error) + GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]model.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 } +// 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, +) model.ServiceStatus[PlanParams, Config, Details] { + return model.ServiceStatus[PlanParams, Config, Details]{ + Plan: model.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. 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, params BackupParams) 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, params BackupParams) (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, params BackupParams) 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 01f0936..ac6d74c 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -14,10 +14,38 @@ import ( "github.com/codesphere-cloud/managed-services-lib/model" ) +// 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 service fields plus the provider's own +// sections. +type createBody[PlanParams, Config, Secrets any] struct { + serviceFields + 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"` + 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. -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 +77,23 @@ 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 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, body.RecoverFrom); err != nil { HandleError(c, err) return } @@ -64,13 +102,25 @@ 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 { + var fields serviceFields + 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 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) return } @@ -89,19 +139,20 @@ 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.TeamID, + body.Config, body.Secrets, body.RetentionDays); err != nil { HandleError(c, err) return } @@ -110,13 +161,14 @@ 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.TeamID, + body.Config, body.Secrets, body.RetentionDays) if err != nil { HandleError(c, err) return @@ -126,13 +178,14 @@ 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.TeamID, + body.Config, body.Secrets, body.RetentionDays); err != nil { HandleError(c, err) return } @@ -140,20 +193,18 @@ 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 +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 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 + 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.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..76bff91 --- /dev/null +++ b/provider/routes_test.go @@ -0,0 +1,327 @@ +// 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 + RecoverFrom *model.RecoverFrom +} + +type updateCall struct { + ID model.ServiceID + TeamID int + CustomSubdomain *string + Args fakeUpdate +} + +type backupCall struct { + BackupID model.BackupId + MsID model.ServiceID + TeamID int + Config fakeBackupConfig + Secrets fakeBackupSecrets + RetentionDays *int +} + +type fakeProvider struct { + created []createCall + updated []updateCall + deleted []model.ServiceID + backedUp []backupCall + 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, recoverFrom *model.RecoverFrom) error { + f.created = append(f.created, createCall{id, teamID, customSubdomain, plan, config, secrets, recoverFrom}) + 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]model.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, 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, _ int, + _ fakeBackupConfig, _ fakeBackupSecrets, _ *int) (model.BackupStatus, error) { + return model.BackupStatus{Exists: true}, nil +} + +func (f *fakeProvider) DeleteBackup(_ context.Context, _ model.BackupId, _ model.ServiceID, _ int, + _ fakeBackupConfig, _ fakeBackupSecrets, _ *int) error { + return nil +} + +var _ = Describe("Routes", func() { + var ( + p *fakeProvider + router *gin.Engine + ) + + 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") + provider.RegisterRoutes(group, p) + provider.RegisterBackupRoutes(group, p) + }) + + Describe("POST /", func() { + 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 service-level 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(HaveValue(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()) + }) + + 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", + `{"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)) + 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(HaveValue(Equal("my-db"))) + Expect(p.updated[0].Args.Plan).NotTo(BeNil()) + 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", + `{"id": "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}, + ) + + Expect(status.Plan.Parameters).To(Equal(fakeParams{Storage: 1000})) + }) + + It("re-wraps plan parameters in the status response", func() { + p.status = map[model.ServiceID]model.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", "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", `{"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()) + }) + }) + + 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", "teamId": 7}`) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON(`{"exists":true}`)) + }) + }) +})