diff --git a/README.md b/README.md index c785ffb..fc3975a 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,22 @@ type Provider[CreateParams any, Status any, UpdateParams any] interface { GetStatus(ctx context.Context, ids []model.ServiceID) (map[model.ServiceID]Status, error) Update(ctx context.Context, id model.ServiceID, args UpdateParams) error Delete(ctx context.Context, id model.ServiceID) error +} +``` + +Embed `provider.Base` for the shared dependencies (Kubernetes client, logger) and helpers. - TakeBackup(ctx context.Context, args model.TakeBackupArgs) error - GetBackupStatus(ctx context.Context, backupID string, retry model.TakeBackupArgs) (BackupStatus, error) - DeleteBackup(ctx context.Context, args model.TakeBackupArgs) error +Backups are an **opt-in capability**, generic over the provider's own request type: + +```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 } ``` -Embed `provider.Base` for the shared dependencies (Kubernetes client, logger, storage class) and helpers. Embed `provider.UnimplementedBackups` if the provider has no backups; its backup endpoints then return `501`. +A provider that supports backups implements `Backups` and calls `RegisterBackupRoutes`. ## Wiring @@ -43,7 +51,9 @@ logger := slog.Default() routes := map[string]func(*gin.RouterGroup){ "mysvc": func(g *gin.RouterGroup) { - provider.RegisterRoutes(g, mysvc.NewProvider(k8s, logger)) + p := mysvc.NewProvider(k8s, logger) + provider.RegisterRoutes(g, p) // CRUD + provider.RegisterBackupRoutes(g, p) // backups }, } @@ -51,7 +61,7 @@ server, _ := api.NewServer(cfg, routes) server.Run() ``` -`RegisterRoutes` mounts CRUD and backup endpoints under `/api/v1/{name}`. +`RegisterRoutes` mounts the CRUD endpoints under `/api/v1/{name}`; `RegisterBackupRoutes` adds the `/backups` endpoints for providers that implement `Backups`. ## Detached Jobs diff --git a/model/backups.go b/model/backups.go deleted file mode 100644 index cc97e94..0000000 --- a/model/backups.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package model - -// BackupStoreConfig holds configuration for backup storage. -type BackupStoreConfig struct { - // EndpointURL is the S3-compatible endpoint URL. - EndpointURL string `json:"endpointUrl"` - - // DestinationPath is the path/bucket for backups. - DestinationPath string `json:"destinationPath"` - - // AccessKeyID is the access key ID for the backup store. - AccessKeyID string `json:"accessKeyId"` -} - -// BackupStoreSecrets holds sensitive data for backup storage. -type BackupStoreSecrets struct { - // SecretKey is the secret access key. - SecretKey string `json:"secretKey"` -} - -// RecoverFromBackup specifies recovery from a specific backup. -type RecoverFromBackup struct { - // ID is the backup ID to recover from. - ID string `json:"id"` - - // Config is the backup store configuration. - Config BackupStoreConfig `json:"config"` - - // Secrets is the backup store secrets. - Secrets BackupStoreSecrets `json:"secrets"` -} - -// RecoverFromTimestamp specifies point-in-time recovery. -type RecoverFromTimestamp struct { - // MsID is the managed service ID to recover from. - MsID string `json:"msId"` - - // Time is the target timestamp in ISO 8601 format. - Time string `json:"time"` - - // Config is the backup store configuration. - Config BackupStoreConfig `json:"config"` - - // Secrets is the backup store secrets. - Secrets BackupStoreSecrets `json:"secrets"` -} - -// TakeBackupArgs contains arguments for taking a backup. -type TakeBackupArgs struct { - // ID is the backup ID. - ID string `json:"id"` - - // MsID is the managed service ID. - MsID string `json:"msId"` - - // Config is the backup store configuration. - Config BackupStoreConfig `json:"config"` - - // Secrets is the backup store secrets. - Secrets BackupStoreSecrets `json:"secrets"` -} diff --git a/model/common.go b/model/common.go index 4e565b9..87846d7 100644 --- a/model/common.go +++ b/model/common.go @@ -6,6 +6,9 @@ package model // 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. diff --git a/provider/backup.go b/provider/backup.go deleted file mode 100644 index 47ec423..0000000 --- a/provider/backup.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package provider - -import ( - "context" - - "github.com/codesphere-cloud/managed-services-lib/model" -) - -// UnimplementedBackups provides backup methods that report ErrNotImplemented. -// Embed it in a provider that does not support backups. -type UnimplementedBackups struct{} - -// TakeBackup reports that backups are not implemented for this provider. -func (UnimplementedBackups) TakeBackup(_ context.Context, _ model.TakeBackupArgs) error { - return ErrNotImplemented -} - -// GetBackupStatus reports that backups are not implemented for this provider. -func (UnimplementedBackups) GetBackupStatus(_ context.Context, _ string, _ model.TakeBackupArgs) (BackupStatus, error) { - return BackupStatus{}, ErrNotImplemented -} - -// DeleteBackup reports that backups are not implemented for this provider. -func (UnimplementedBackups) DeleteBackup(_ context.Context, _ model.TakeBackupArgs) error { - return ErrNotImplemented -} diff --git a/provider/backupjob.go b/provider/backupjob.go index 9274b0d..5b4ee11 100644 --- a/provider/backupjob.go +++ b/provider/backupjob.go @@ -5,28 +5,24 @@ package provider import ( "github.com/codesphere-cloud/managed-services-lib/client" + "github.com/codesphere-cloud/managed-services-lib/model" ) // BackupJobName is the name of the Job that takes a backup. -func BackupJobName(backupID string) string { - return ServiceJobName(JobOpBackup, backupID) +func BackupJobName(backupID model.BackupId) string { + return ServiceJobName(JobOpBackup, string(backupID)) } // DeleteBackupJobName is the name of the Job that deletes a backup. -func DeleteBackupJobName(backupID string) string { - return ServiceJobName(JobOpDeleteBackup, backupID) +func DeleteBackupJobName(backupID model.BackupId) string { + return ServiceJobName(JobOpDeleteBackup, string(backupID)) } -// BackupStatusFromJob maps a Job snapshot onto the backup status contract. A Job -// that no longer exists is reported as pending — a provider that needs to -// distinguish "never taken" from "completed and garbage-collected" should check -// the JobState phase directly before calling this. +// BackupStatusFromJob maps a Job snapshot to the backup status contract. func BackupStatusFromJob(s client.JobState) BackupStatus { - op := OperationStatusFromJob(s) - return BackupStatus{ - Phase: BackupPhase(op.Phase), - StartedAt: op.StartedAt, - CompletedAt: op.CompletedAt, - Error: op.Error, + status := BackupStatus{Exists: s.Phase == client.JobSucceeded} + if s.Phase == client.JobFailed { + status.Error = s.Reason } + return status } diff --git a/provider/backupjob_test.go b/provider/backupjob_test.go index c698450..0c63eab 100644 --- a/provider/backupjob_test.go +++ b/provider/backupjob_test.go @@ -4,8 +4,6 @@ package provider_test import ( - "time" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,35 +32,24 @@ var _ = Describe("Backup job helpers", func() { }) Describe("BackupStatusFromJob", func() { - started := time.Date(2026, 7, 17, 10, 0, 0, 0, time.UTC) - finished := time.Date(2026, 7, 17, 10, 5, 0, 0, time.UTC) - - It("maps a running job", func() { - s := provider.BackupStatusFromJob(client.JobState{Phase: client.JobRunning, StartedAt: &started}) - Expect(s.Phase).To(Equal(provider.BackupPhaseRunning)) - Expect(s.StartedAt).To(Equal("2026-07-17T10:00:00Z")) + It("reports a succeeded job as existing, with no error", func() { + s := provider.BackupStatusFromJob(client.JobState{Phase: client.JobSucceeded}) + Expect(s.Exists).To(BeTrue()) Expect(s.Error).To(BeEmpty()) }) - It("maps a succeeded job with timestamps", func() { - s := provider.BackupStatusFromJob(client.JobState{ - Phase: client.JobSucceeded, StartedAt: &started, FinishedAt: &finished, - }) - Expect(s.Phase).To(Equal(provider.BackupPhaseCompleted)) - Expect(s.CompletedAt).To(Equal("2026-07-17T10:05:00Z")) - }) - - It("maps a failed job, carrying the reason into Error", func() { + It("reports a failed job as not existing, carrying the reason into Error", func() { s := provider.BackupStatusFromJob(client.JobState{Phase: client.JobFailed, Reason: "boom"}) - Expect(s.Phase).To(Equal(provider.BackupPhaseFailed)) + Expect(s.Exists).To(BeFalse()) Expect(s.Error).To(Equal("boom")) }) - It("maps pending and not-found to pending", func() { - Expect(provider.BackupStatusFromJob(client.JobState{Phase: client.JobPending}).Phase). - To(Equal(provider.BackupPhasePending)) - Expect(provider.BackupStatusFromJob(client.JobState{Phase: client.JobNotFound}).Phase). - To(Equal(provider.BackupPhasePending)) + It("reports running, pending and not-found jobs as not existing, no error", func() { + for _, phase := range []client.JobPhase{client.JobRunning, client.JobPending, client.JobNotFound} { + s := provider.BackupStatusFromJob(client.JobState{Phase: phase}) + Expect(s.Exists).To(BeFalse()) + Expect(s.Error).To(BeEmpty()) + } }) }) }) diff --git a/provider/interface.go b/provider/interface.go index bc235b9..170dd56 100644 --- a/provider/interface.go +++ b/provider/interface.go @@ -33,39 +33,27 @@ type Provider[CreateParams any, Status any, UpdateParams any] interface { // Delete deletes a managed service. Delete(ctx context.Context, id model.ServiceID) error +} +// Backups is the optional backup capability, kept separate from Provider so a +// provider opts in by implementing it. +type Backups[BackupParams any] interface { // TakeBackup initiates a backup of the managed service. - TakeBackup(ctx context.Context, args model.TakeBackupArgs) error + TakeBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error // GetBackupStatus returns the status of a backup. - GetBackupStatus(ctx context.Context, backupID string, retryArgs model.TakeBackupArgs) (BackupStatus, error) + GetBackupStatus(ctx context.Context, backupID model.BackupId, params BackupParams) (BackupStatus, error) // DeleteBackup deletes a backup. - DeleteBackup(ctx context.Context, args model.TakeBackupArgs) error + DeleteBackup(ctx context.Context, backupID model.BackupId, params BackupParams) error } -// BackupStatus represents the status of a backup operation. +// 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 { - // Phase is the current phase of the backup. - Phase BackupPhase `json:"phase"` - - // StartedAt is when the backup started. - StartedAt string `json:"startedAt,omitempty"` - - // CompletedAt is when the backup completed. - CompletedAt string `json:"completedAt,omitempty"` + // Exists is true once the backup has been taken successfully. + Exists bool `json:"exists"` - // Error contains any error message. + // Error contains the failure reason when the backup failed; empty otherwise. Error string `json:"error,omitempty"` } - -// BackupPhase represents the phase of a backup. -type BackupPhase string - -// Backup phase constants. -const ( - BackupPhasePending BackupPhase = "pending" - BackupPhaseRunning BackupPhase = "running" - BackupPhaseCompleted BackupPhase = "completed" - BackupPhaseFailed BackupPhase = "failed" -) diff --git a/provider/routes.go b/provider/routes.go index 6040f29..01f0936 100644 --- a/provider/routes.go +++ b/provider/routes.go @@ -86,17 +86,22 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( } c.Status(http.StatusNoContent) }) +} +// RegisterBackupRoutes mounts backup endpoints. +func RegisterBackupRoutes[BackupParams any]( + group *gin.RouterGroup, + b Backups[BackupParams], +) { // PUT /backups/:id - Take a backup group.PUT("/backups/:id", func(c *gin.Context) { - var args model.TakeBackupArgs - if err := c.ShouldBindJSON(&args); err != nil { + var params BackupParams + if err := c.ShouldBindJSON(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - args.ID = c.Param("id") - if err := p.TakeBackup(c.Request.Context(), args); err != nil { + if err := b.TakeBackup(c.Request.Context(), model.BackupId(c.Param("id")), params); err != nil { HandleError(c, err) return } @@ -105,14 +110,13 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( // POST /backups/:id/status - Get backup status group.POST("/backups/:id/status", func(c *gin.Context) { - var retryArgs model.TakeBackupArgs - if err := c.ShouldBindJSON(&retryArgs); err != nil { + var params BackupParams + if err := c.ShouldBindJSON(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - retryArgs.ID = c.Param("id") - status, err := p.GetBackupStatus(c.Request.Context(), c.Param("id"), retryArgs) + status, err := b.GetBackupStatus(c.Request.Context(), model.BackupId(c.Param("id")), params) if err != nil { HandleError(c, err) return @@ -122,14 +126,13 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( // DELETE /backups/:id - Delete a backup group.DELETE("/backups/:id", func(c *gin.Context) { - var args model.TakeBackupArgs - if err := c.ShouldBindJSON(&args); err != nil { + var params BackupParams + if err := c.ShouldBindJSON(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - args.ID = c.Param("id") - if err := p.DeleteBackup(c.Request.Context(), args); err != nil { + if err := b.DeleteBackup(c.Request.Context(), model.BackupId(c.Param("id")), params); err != nil { HandleError(c, err) return } @@ -137,7 +140,6 @@ func RegisterRoutes[CreateParams any, Status any, UpdateParams any]( }) } -// ParseCreate binds JSON to the Service domain type. func parseCreate[T any](c *gin.Context) (T, error) { var svc T if err := c.ShouldBindJSON(&svc); err != nil { @@ -146,7 +148,6 @@ func parseCreate[T any](c *gin.Context) (T, error) { return svc, nil } -// ParseUpdate binds JSON to the UpdateArgs domain type. func parseUpdate[T any](c *gin.Context) (model.ServiceID, T, error) { var args T if err := c.ShouldBindJSON(&args); err != nil { diff --git a/provider/servicejob_usage_test.go b/provider/servicejob_usage_test.go index 1964b05..0000fb6 100644 --- a/provider/servicejob_usage_test.go +++ b/provider/servicejob_usage_test.go @@ -135,7 +135,8 @@ var _ = Describe("Dispatching operations with ServiceJob + JobRunner", func() { st, err := runner.State(ctx, namespace, provider.BackupJobName("bkp-7")) Expect(err).NotTo(HaveOccurred()) - Expect(provider.BackupStatusFromJob(st).Phase).To(Equal(provider.BackupPhaseRunning)) + // A still-running backup does not yet exist. + Expect(provider.BackupStatusFromJob(st).Exists).To(BeFalse()) }) It("DeleteBackup cleanup: removes the backup job and its owned secret", func() {