Files
DoGaMa-serv/internal/instance/configuration.go
T
codex 368ae918a8
CI / validate (pull_request) Successful in 26m27s
feat(runtime): add configurable game server uid gid
2026-08-25 22:57:23 +02:00

229 lines
8.7 KiB
Go

package instance
import (
"context"
"errors"
"regexp"
"strings"
"time"
)
type ConfigurationRepository interface {
GetGameContainerRuntimeIdentity(context.Context) (RuntimeIdentity, error)
SetGameContainerRuntimeIdentity(context.Context, RuntimeIdentity) error
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, 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 {
InstanceID string `json:"instance_id"`
ContainerConfigPending bool `json:"container_config_pending"`
Preview Preview `json:"configuration"`
}
func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) {
repository, ok := s.repository.(ConfigurationRepository)
if !ok {
return OperationResult{}, errors.New("container configuration is unavailable")
}
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
parsed, err := ParseLabels(labels)
if err != nil {
return OperationResult{}, err
}
defaultTag := current.Preview.TemplateDefaultTag
if defaultTag == "" {
parts := strings.Split(current.Preview.Image, ":")
defaultTag = parts[len(parts)-1]
}
validated, err := ValidateImageTag(tag, defaultTag)
if err != nil {
return OperationResult{}, err
}
preview := current.Preview
preview.CustomLabels, preview.ImageTag = parsed, validated
base := imageRepository(preview.Image)
preview.Image = base + ":" + validated.Tag
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate, "container_configuration", ""); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
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, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "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 := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
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
}