229 lines
8.7 KiB
Go
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
|
|
}
|