feat(instances): add controlled updates mods and rollback history
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user