feat(instances): add controlled updates mods and rollback history

This commit is contained in:
2026-08-07 20:00:44 +00:00
parent f9676ef9cd
commit 7f5fa30d0f
10 changed files with 552 additions and 13 deletions
+12
View File
@@ -96,6 +96,18 @@ type Template struct {
DestinationRelativePath string `json:"destination_relative_path"`
RequiresStoppedServer bool `json:"requires_stopped_server"`
} `json:"imports"`
Mods struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
RestartRequired bool `json:"restart_required"`
} `json:"mods"`
Updates struct {
BackupBeforeUpdate bool `json:"backup_before_update"`
AutomaticDefault bool `json:"automatic_default"`
RollbackOnFailure bool `json:"rollback_on_failure"`
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
} `json:"updates"`
}
type Resources struct {
+138 -3
View File
@@ -3,14 +3,27 @@ package instance
import (
"context"
"errors"
"regexp"
"strings"
"time"
)
type ConfigurationRepository interface {
GetGlobalLabels(context.Context) (map[string]string, error)
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
SaveInstanceConfiguration(context.Context, string, Preview, bool) error
SaveInstanceConfiguration(context.Context, string, Preview, bool, string, string) error
ClearContainerConfigPending(context.Context, string, string) error
ListConfigurationRevisions(context.Context, string) ([]ConfigurationRevision, error)
GetConfigurationRevision(context.Context, string, int) (ConfigurationRevision, error)
}
type ConfigurationRevision struct {
InstanceID string `json:"instance_id"`
Revision int `json:"revision"`
Snapshot Preview `json:"snapshot"`
Reason string `json:"reason"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ConfigurationStatus struct {
@@ -44,9 +57,9 @@ func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels str
}
preview := current.Preview
preview.CustomLabels, preview.ImageTag = parsed, validated
base := preview.Image[:strings.LastIndex(preview.Image, ":")]
base := imageRepository(preview.Image)
preview.Image = base + ":" + validated.Tag
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate); err != nil {
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
@@ -89,3 +102,125 @@ func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels str
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func imageRepository(reference string) string {
if at := strings.Index(reference, "@"); at >= 0 {
reference = reference[:at]
}
if colon := strings.LastIndex(reference, ":"); colon > strings.LastIndex(reference, "/") {
return reference[:colon]
}
return reference
}
var workshopIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,19}$`)
func (s *LifecycleService) ConfigureMods(ctx context.Context, instanceID string, items []string, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if !current.Preview.Mods.Supported {
return OperationResult{}, errors.New("template does not support mods")
}
if current.Preview.Mods.Provider != "steam_workshop" {
return OperationResult{}, errors.New("mod provider is not implemented safely")
}
if len(items) > 256 {
return OperationResult{}, errors.New("too many mods")
}
seen := map[string]struct{}{}
for _, id := range items {
if !workshopIDPattern.MatchString(id) {
return OperationResult{}, errors.New("invalid Steam Workshop item ID")
}
if _, exists := seen[id]; exists {
return OperationResult{}, errors.New("duplicate mod item ID")
}
seen[id] = struct{}{}
}
items = append([]string(nil), items...)
preview := current.Preview
preview.Mods.Items = items
repository := s.repository.(ConfigurationRepository)
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "mods", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, preview, "restart")
})
}
func (s *LifecycleService) RollbackConfiguration(ctx context.Context, instanceID string, revision int, immediate bool, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
repository := s.repository.(ConfigurationRepository)
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
old, err := repository.GetConfigurationRevision(ctx, instanceID, revision)
if err != nil {
return OperationResult{}, err
}
if old.Snapshot.Template != current.Preview.Template {
return OperationResult{}, errors.New("revision template no longer matches pinned template")
}
old.Snapshot.DockerUser = current.Preview.DockerUser
old.Snapshot.DockerUserValue = current.Preview.DockerUserValue
if _, err := ValidateImageTag(old.Snapshot.ImageTag, old.Snapshot.TemplateDefaultTag); err != nil {
return OperationResult{}, err
}
if _, err := old.Snapshot.DeploymentPlan(instanceID); err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, old.Snapshot, !immediate, "rollback", actorID); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
return s.replaceConfigured(ctx, current, old.Snapshot, "restart")
})
}
func (s *LifecycleService) replaceConfigured(ctx context.Context, current StoredInstance, preview Preview, kind string) (OperationResult, error) {
agent, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, current.ID, kind, "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(current.ID)
if err != nil {
return s.fail(ctx, operationID, current.ID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, current.ID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, current.ID)
}
if err != nil {
return s.fail(ctx, operationID, current.ID, "agent_start_failed", err)
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
if err := s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, current.ID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
}
+23
View File
@@ -3,6 +3,7 @@ package instance_test
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
@@ -46,10 +47,32 @@ func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int)
return a.StartInstance(ctx, id)
}
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (a *lifecycleAgent) ReplaceInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-2", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
}
func TestUpdatePreviewRequiresDigestAndWarnsAboutMods(t *testing.T) {
current := instance.StoredInstance{Preview: instance.Preview{
Image: "registry.example/game:old",
TemplateDefaultTag: "old",
Mods: instance.ModsConfiguration{Items: []string{"123"}},
UpdatePolicy: instance.UpdatePolicy{BackupBeforeUpdate: true, RollbackOnFailure: true},
}}
if _, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "latest"}); err == nil {
t.Fatal("mutable tag without digest was accepted")
}
value, err := instance.PreviewUpdate(current, instance.UpdateRequest{CandidateTag: "new", CandidateDigest: "sha256:" + strings.Repeat("a", 64)})
if err != nil {
t.Fatal(err)
}
if !value.BackupRequired || !value.ModWarning || !value.RollbackOnFailure || !strings.Contains(value.CandidateReference, "@sha256:") {
t.Fatalf("preview = %#v", value)
}
}
func TestLifecycleInstallStartStopAndSafeContainerDeletion(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
+20 -1
View File
@@ -59,6 +59,23 @@ type Preview struct {
ImageTag ImageTag `json:"image_tag"`
TemplateDefaultTag string `json:"template_default_tag"`
Game GameReference `json:"game"`
Mods ModsConfiguration `json:"mods"`
UpdatePolicy UpdatePolicy `json:"update_policy"`
}
type ModsConfiguration struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
RestartRequired bool `json:"restart_required"`
Items []string `json:"items"`
}
type UpdatePolicy struct {
BackupBeforeUpdate bool `json:"backup_before_update"`
RollbackOnFailure bool `json:"rollback_on_failure"`
Automatic bool `json:"automatic"`
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
}
type GameReference struct {
@@ -235,7 +252,9 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
CustomLabels: customLabels, DockerUser: request.DockerUser, DockerUserValue: userValue, ImageTag: tag, TemplateDefaultTag: snapshot.Template.Container.Tag,
Game: GameReference{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID},
Game: GameReference{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID},
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
}
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
return Preview{}, errors.New("backup retention must be between 1 and 1000")
+135
View File
@@ -0,0 +1,135 @@
package instance
import (
"context"
"errors"
"regexp"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
var imageDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
type UpdateRequest struct {
CandidateTag string `json:"candidate_tag"`
CandidateDigest string `json:"candidate_digest"`
Confirmed bool `json:"confirmed"`
}
type UpdatePreview struct {
CurrentReference string `json:"current_reference"`
CandidateReference string `json:"candidate_reference"`
BackupRequired bool `json:"backup_required"`
ModWarning bool `json:"mod_warning"`
RollbackOnFailure bool `json:"rollback_on_failure"`
}
func PreviewUpdate(current StoredInstance, request UpdateRequest) (UpdatePreview, error) {
tag, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}, current.Preview.TemplateDefaultTag)
if err != nil {
return UpdatePreview{}, err
}
if !imageDigestPattern.MatchString(request.CandidateDigest) {
return UpdatePreview{}, errors.New("candidate image digest must be sha256")
}
base := imageRepository(current.Preview.Image)
return UpdatePreview{CurrentReference: current.Preview.Image, CandidateReference: base + ":" + tag.Tag + "@" + request.CandidateDigest, BackupRequired: current.Preview.UpdatePolicy.BackupBeforeUpdate, ModWarning: len(current.Preview.Mods.Items) != 0, RollbackOnFailure: current.Preview.UpdatePolicy.RollbackOnFailure}, nil
}
func (s *LifecycleService) Update(ctx context.Context, instanceID string, request UpdateRequest, actorID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
candidate, err := PreviewUpdate(current, request)
if err != nil {
return OperationResult{}, err
}
if !request.Confirmed {
return OperationResult{}, errors.New("update confirmation is required")
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
replacement, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
previous := current.Preview
next := previous
next.Image = candidate.CandidateReference
next.ImageTag = ImageTag{Mode: ImageTagPinned, Tag: request.CandidateTag}
repository := s.repository.(ConfigurationRepository)
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
if err := repository.SaveInstanceConfiguration(ctx, instanceID, next, false, "update", actorID); err != nil {
return s.fail(ctx, operationID, instanceID, "update_configuration_failed", err)
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := next.DeploymentPlan(instanceID)
if err == nil {
_, err = replacement.ReplaceInstance(ctx, plan)
}
var stateErr error
if err == nil && current.DesiredRunning {
_, stateErr = s.agent.StartInstance(ctx, instanceID)
err = stateErr
}
rollback := func() {
if next.UpdatePolicy.RollbackOnFailure {
_ = repository.SaveInstanceConfiguration(ctx, instanceID, previous, false, "update_rollback", actorID)
if oldPlan, planErr := previous.DeploymentPlan(instanceID); planErr == nil {
if _, rollbackErr := replacement.ReplaceInstance(ctx, oldPlan); rollbackErr == nil && current.DesiredRunning {
_, _ = s.agent.StartInstance(ctx, instanceID)
}
}
}
}
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_failed", err)
}
state, err := s.waitForUpdateReadiness(ctx, instanceID, current.DesiredRunning, next.UpdatePolicy.HealthTimeoutSeconds)
if err != nil {
rollback()
return s.fail(ctx, operationID, instanceID, "update_health_failed", err)
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) waitForUpdateReadiness(ctx context.Context, instanceID string, shouldRun bool, timeoutSeconds int) (agentwire.InstanceState, error) {
if timeoutSeconds < 10 {
timeoutSeconds = 10
}
deadline, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
for {
state, err := s.agent.InspectInstance(deadline, instanceID)
if err != nil {
return agentwire.InstanceState{}, err
}
if !shouldRun && !state.Running {
return state, nil
}
if shouldRun && state.Ready {
return state, nil
}
select {
case <-deadline.Done():
return agentwire.InstanceState{}, errors.New("updated instance did not become ready before timeout")
case <-time.After(time.Second):
}
}
}
+10 -2
View File
@@ -103,12 +103,20 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro
if err != nil {
return fmt.Errorf("encode draft preview: %w", err)
}
_, err = r.db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, created_at, updated_at)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, string(mustJSON(draft.Preview.CustomLabels)), draft.Preview.DockerUser.Mode, draft.Preview.DockerUser.UID, draft.Preview.DockerUser.GID, draft.Preview.ImageTag.Mode, draft.Preview.ImageTag.Tag, now, now)
if err != nil {
return fmt.Errorf("create draft instance: %w", err)
}
return nil
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) VALUES(?,1,?,'creation',?)`, draft.ID, string(previewJSON), now); err != nil {
return fmt.Errorf("create initial configuration revision: %w", err)
}
return tx.Commit()
}
func (r *Repository) GetInstance(ctx context.Context, id string) (instance.StoredInstance, error) {
+61 -3
View File
@@ -77,6 +77,12 @@ func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]stri
if _, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=?`, update.body, pendingValue, now, update.id); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_at) SELECT id, revision, preview_json, 'global_labels', ? FROM instances WHERE id=?`, now, update.id); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, update.id, update.id); err != nil {
return 0, 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
@@ -84,14 +90,20 @@ func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]stri
return len(updates), running, nil
}
func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool) error {
func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool, reason, actorID string) error {
body := string(mustJSON(preview))
labels := string(mustJSON(preview.CustomLabels))
pendingValue := 0
if pending {
pendingValue = 1
}
result, err := r.db.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, r.now().UTC().Format(time.RFC3339Nano), id)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, now, id)
if err != nil {
return fmt.Errorf("save instance container configuration: %w", err)
}
@@ -99,7 +111,53 @@ func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, p
if changed != 1 {
return instance.ErrInstanceNotFound
}
return nil
var revision int
if err := tx.QueryRowContext(ctx, `SELECT revision FROM instances WHERE id=?`, id).Scan(&revision); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO configuration_revisions(instance_id, revision, redacted_snapshot, reason, created_by, created_at) VALUES(?,?,?,?,?,?)`, id, revision, body, reason, nullable(actorID), now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM configuration_revisions WHERE instance_id=? AND revision NOT IN (SELECT revision FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC LIMIT 10)`, id, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) ListConfigurationRevisions(ctx context.Context, id string) ([]instance.ConfigurationRevision, error) {
rows, err := r.db.QueryContext(ctx, `SELECT revision, redacted_snapshot, reason, COALESCE(created_by,''), created_at FROM configuration_revisions WHERE instance_id=? ORDER BY revision DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var result []instance.ConfigurationRevision
for rows.Next() {
var value instance.ConfigurationRevision
var body, created string
value.InstanceID = id
if err := rows.Scan(&value.Revision, &body, &value.Reason, &value.CreatedBy, &created); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(body), &value.Snapshot); err != nil {
return nil, err
}
value.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
result = append(result, value)
}
return result, rows.Err()
}
func (r *Repository) GetConfigurationRevision(ctx context.Context, id string, revision int) (instance.ConfigurationRevision, error) {
values, err := r.ListConfigurationRevisions(ctx, id)
if err != nil {
return instance.ConfigurationRevision{}, err
}
for _, value := range values {
if value.Revision == revision {
return value, nil
}
}
return instance.ConfigurationRevision{}, instance.ErrInstanceNotFound
}
func (r *Repository) ClearContainerConfigPending(ctx context.Context, id, planDigest string) error {
+4 -4
View File
@@ -24,8 +24,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 6 {
t.Fatalf("got %d migrations, want 6", count)
if count != 8 {
t.Fatalf("got %d migrations, want 8", count)
}
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports"} {
var found int
@@ -62,8 +62,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 6 {
t.Fatalf("reopened database has %d migrations, want 6", count)
if count != 8 {
t.Fatalf("reopened database has %d migrations, want 8", count)
}
}
+138
View File
@@ -15,6 +15,7 @@ import (
"log/slog"
"net/http"
"regexp"
"strconv"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
@@ -149,6 +150,12 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
mux.HandleFunc("DELETE /api/v1/instances/{id}", s.instanceDeleteContainer)
mux.HandleFunc("GET /api/v1/instances/{id}/container-configuration", s.instanceConfigurationGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/container-configuration", s.instanceConfigurationPut)
mux.HandleFunc("GET /api/v1/instances/{id}/configuration-revisions", s.configurationRevisionList)
mux.HandleFunc("POST /api/v1/instances/{id}/configuration-revisions/{revision}/rollback", s.configurationRevisionRollback)
mux.HandleFunc("GET /api/v1/instances/{id}/mods", s.instanceModsGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/mods", s.instanceModsPut)
mux.HandleFunc("POST /api/v1/instances/{id}/update/preview", s.instanceUpdatePreview)
mux.HandleFunc("POST /api/v1/instances/{id}/update", s.instanceUpdate)
}
if backupService != nil {
mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList)
@@ -366,6 +373,137 @@ func (s *server) instanceConfigurationPut(w http.ResponseWriter, r *http.Request
s.apiJSON(w, 200, result)
}
func (s *server) configurationRevisionList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
values, err := s.repository.(instance.ConfigurationRepository).ListConfigurationRevisions(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, 200, map[string]any{"revisions": values})
}
func (s *server) configurationRevisionRollback(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure)
if !ok {
return
}
revision, err := strconv.Atoi(r.PathValue("revision"))
if err != nil || revision < 1 {
s.apiProblem(w, 422, "invalid_revision", "Revision must be positive.")
return
}
var request applicationModeRequest
if !s.decodeStrictJSON(w, r, &request) {
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
result, err := s.lifecycle.RollbackConfiguration(r.Context(), r.PathValue("id"), revision, request.Apply == "immediate", actor.ID)
if err != nil {
s.apiProblem(w, 422, "configuration_rollback_failed", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) instanceModsGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, 200, current.Preview.Mods)
}
func (s *server) instanceModsPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionModsManage)
if !ok {
return
}
var request struct {
Items []string `json:"items"`
Apply string `json:"apply"`
}
if !s.decodeStrictJSON(w, r, &request) {
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
result, err := s.lifecycle.ConfigureMods(r.Context(), r.PathValue("id"), request.Items, request.Apply == "immediate", actor.ID)
if err != nil {
s.apiProblem(w, 422, "invalid_mod_configuration", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) instanceUpdatePreview(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate); !ok {
return
}
var request instance.UpdateRequest
if !s.decodeStrictJSON(w, r, &request) {
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
value, err := instance.PreviewUpdate(current, request)
if err != nil {
s.apiProblem(w, 422, "invalid_update", err.Error())
return
}
s.apiJSON(w, 200, value)
}
func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate)
if !ok {
return
}
var request instance.UpdateRequest
if !s.decodeStrictJSON(w, r, &request) {
return
}
if !request.Confirmed {
s.apiProblem(w, 422, "update_confirmation_required", "Update confirmation is required.")
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
if current.Preview.UpdatePolicy.BackupBeforeUpdate {
if s.backups == nil {
s.apiProblem(w, 409, "backup_unavailable", "The required safety backup service is unavailable.")
return
}
if _, err := s.backups.Create(r.Context(), actor.ID, current.ID, "pre_update"); err != nil {
s.apiProblem(w, 422, "pre_update_backup_failed", "The safety backup failed; the update was not started.")
return
}
}
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
if err != nil {
s.apiProblem(w, 422, "update_failed", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE configuration_revisions (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
revision INTEGER NOT NULL CHECK (revision >= 1),
redacted_snapshot TEXT NOT NULL,
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
created_by TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (instance_id, revision)
);
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);