Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -43,15 +51,17 @@ 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
},
}

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

Expand Down
64 changes: 0 additions & 64 deletions model/backups.go

This file was deleted.

3 changes: 3 additions & 0 deletions model/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 0 additions & 29 deletions provider/backup.go

This file was deleted.

24 changes: 10 additions & 14 deletions provider/backupjob.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
35 changes: 11 additions & 24 deletions provider/backupjob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
package provider_test

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

Expand Down Expand Up @@ -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())
}
})
})
})
36 changes: 12 additions & 24 deletions provider/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
29 changes: 15 additions & 14 deletions provider/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params); 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
}
Expand All @@ -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(&params); 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
Expand All @@ -122,22 +126,20 @@ 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(&params); 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
}
c.Status(http.StatusAccepted)
})
}

// 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 {
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion provider/servicejob_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading