4 Commits
13 changed files with 575 additions and 22 deletions
+8 -2
View File
@@ -43,7 +43,10 @@ Stop and report any request that would weaken these boundaries.
- Work only on a non-`main` feature branch. If the task starts on `main`, create or request a working branch before editing.
- Never modify, commit on, merge into, rebase, reset, delete or push `main`.
- Commit or push a working branch only when the task requests delivery. Never approve or merge a Gitea pull request.
- Develop each milestone on its own dedicated working branch.
- After a milestone's validations and commits, always push its working branch to Gitea using the `codex` account. The milestone is not complete until the remote branch exists.
- After pushing, create a pull request from the working branch to `main` when the available tools permit it. If automatic creation is unavailable, provide the URL or exact information needed to open it immediately.
- Never approve or merge a Gitea pull request.
- Do not alter remotes, credentials or repository-wide Git configuration unless explicitly requested.
- Never use destructive recovery commands such as `git reset --hard`, `git clean`, or checkout-based restoration without explicit approval and a verified target list.
- Before committing, review `git status`, `git diff --stat`, the complete relevant diff and `git diff --check`.
@@ -58,7 +61,8 @@ Stop and report any request that would weaken these boundaries.
6. Run targeted tests first.
7. Run the applicable global validations.
8. Update `docs/PROJECT-STATE.md` with the new baseline, delivered behavior, durable decisions, limitations and next work.
9. Review and report the final diff and validation status.
9. Review the final diff and validation status, then commit the completed milestone.
10. Push the working branch to Gitea with the `codex` account and create, or provide the exact link to create, a pull request to `main`.
## Validation
@@ -86,6 +90,8 @@ git diff --check
Use installed tools and pinned dependencies. Do not silently install missing tools; report the exact blocker. Keep caches under `.cache/codex/` or an OS temporary directory and remove only artifacts created by the current task.
For changes that affect the web interface, exercise the relevant screens and states in a real browser when the environment permits it. Capture screenshots and use them to check at least the overall rendering, alignment, overflow, labels, primary states, relevant responsive behavior and obvious visual regressions. Screenshots are local validation artifacts and must not be committed unless explicitly requested or another project rule requires it. If browser validation or screenshots are technically unavailable, state that explicitly in the completion report.
## Completion report
- Summarize behavior and contract changes.
+9 -6
View File
@@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
## Baseline
- Current reference: `main` after milestone 7 and `feat(instances): add Docker labels, user IDs, image tags and deferred recreation` (`c820c9c`).
- Released SQLite migrations: `0001` through `0007`; never rewrite them.
- Roadmap milestones 1-7 are implemented. The instance-container configuration feature landed after milestone 7.
- Current reference: milestone 8 implementation `7f5fa30` after baseline `c820c9c`.
- Released SQLite migrations: `0001` through `0008`; never rewrite them.
- Roadmap milestones 1-8 are implemented.
## Architecture
@@ -25,6 +25,9 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Backup scheduling/retention, safe imports, export and restore with safety backups.
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-icon route.
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
## Durable decisions
@@ -39,10 +42,11 @@ Read this compact operational baseline before starting a milestone. Open detaile
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
- Replacement-requiring changes use the generic `container_config_pending` desired-versus-applied state. Replacements preserve bind-mounted data and prior running/stopped intent.
- The main app never gains Docker-socket access; the agent remains deny-by-default and independently validates privileged plan fields.
- Update candidates are explicit `tag@sha256:digest` references. Mutable tags alone are rejected; automatic updates remain disabled.
- Mod configuration is data-only. Provider commands, scripts and arbitrary download URLs are forbidden.
## Known limitations and debt
- Roadmap milestone 8 remains broader than the delivered instance-container configuration: controlled update history, mods and rollback paths are not complete.
- Notification channels, audit delivery/retention UI and release hardening remain roadmap work.
- The web interface is intentionally modest; several advanced workflows are API-first.
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
@@ -57,6 +61,5 @@ Read this compact operational baseline before starting a milestone. Open detaile
## Next known work
- Roadmap milestone 8: controlled game updates, configuration history and rollback; complete mod configuration only within declarative safe contracts.
- Then milestone 9: notifications and light audit trail.
- Roadmap milestone 9: notifications and light audit trail.
- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements.
+6 -1
View File
@@ -31,18 +31,24 @@ The template states destination mount, ordering, restart requirement, dependency
DoGaMa clearly labels unofficial mod support and never assumes a server update is compatible with installed mods.
The V1 implementation accepts only Steam Workshop numeric item IDs when the pinned template explicitly declares that provider. It persists a normalized declarative list and rejects provider commands, scripts, arbitrary URLs and changes for templates without mod support.
## Configuration application
Each template field declares `apply: immediate` or `restart_required`. Secret fields are write-only. Validate types, ranges, patterns and conflicts on both client and server. A preview lists pending changes and whether container replacement or game restart is needed.
Keep the last 10 redacted configuration revisions by default. Rollback revalidates the old revision against the pinned template/module versions before applying it.
Every desired container or mod change creates a revision, listed newest first. Rollback never restores secrets, never changes the immutable Docker user and may be immediate or deferred until the next explicit start.
## Updates
Image updates are digest-aware. A mutable tag alone is never treated as proof that nothing changed. The UI shows current and candidate references, template release notes if available, mod warnings and whether a backup will run.
The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default.
The V1 API requires an explicit candidate tag and SHA-256 image digest plus confirmation. A required `pre_update` backup must finish before replacement. Readiness is bounded by the template timeout; failed replacement, start or readiness restores the prior image/configuration plan when rollback is enabled, without restoring player data.
## Game-container labels, users and tags
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
@@ -70,4 +76,3 @@ Label and tag changes can apply immediately or at the next start. Immediate appl
- Local copied templates are independent and are never overwritten by their origin.
- Module packages update independently and require compatibility plus connection tests before activation.
- Rollback keeps the prior template snapshot, module binary and container plan available until the new combination is verified.
+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);