Files
DoGaMa-serv/internal/instance/container_config.go
T

217 lines
6.0 KiB
Go

package instance
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
const (
DockerUserDoGaMa = "dogama"
DockerUserCustom = "custom"
DockerUserImage = "image"
ImageTagTracked = "tracked"
ImageTagPinned = "pinned"
)
var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
)
var AllowedLabelVariables = []string{
"game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name",
}
type DockerUser struct {
Mode string `json:"mode"`
UID *uint32 `json:"uid,omitempty"`
GID *uint32 `json:"gid,omitempty"`
}
type ImageTag struct {
Mode string `json:"mode"`
Tag string `json:"tag"`
}
type ContainerConfiguration struct {
Labels map[string]string `json:"labels"`
DockerUser DockerUser `json:"docker_user"`
ImageTag ImageTag `json:"image_tag"`
}
type LabelContext struct {
GameName, GameID, GameIconURL string
InstanceName, InstanceID, InstanceSlug string
ServerName string
}
func ParseLabels(input string) (map[string]string, error) {
labels := make(map[string]string)
for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
separator := strings.IndexByte(line, '=')
if separator < 1 {
return nil, fmt.Errorf("label line %d must use key=value", index+1)
}
key := strings.TrimSpace(line[:separator])
if !labelKeyPattern.MatchString(key) {
return nil, fmt.Errorf("label line %d has an invalid key", index+1)
}
lower := strings.ToLower(key)
if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1)
}
value := line[separator+1:]
if err := ValidateLabelTemplate(value); err != nil {
return nil, fmt.Errorf("label line %d: %w", index+1, err)
}
labels[key] = value
}
return labels, nil
}
func ValidateLabelTemplate(value string) error {
allowed := make(map[string]bool, len(AllowedLabelVariables))
for _, variable := range AllowedLabelVariables {
allowed[variable] = true
}
for _, match := range variablePattern.FindAllStringSubmatch(value, -1) {
if !allowed[match[1]] {
return fmt.Errorf("unknown label variable %q", match[1])
}
}
withoutKnown := variablePattern.ReplaceAllString(value, "")
if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") {
return errors.New("invalid label variable syntax")
}
return nil
}
func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) {
values := map[string]string{
"game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL,
"instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug,
"server.name": context.ServerName,
}
result := make(map[string]string, len(labels))
for key, value := range labels {
if err := ValidateLabelTemplate(value); err != nil {
return nil, err
}
result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string {
name := token[2 : len(token)-2]
return values[name]
})
}
return result, nil
}
func MergeLabels(technical, global, local map[string]string) map[string]string {
result := make(map[string]string, len(technical)+len(global)+len(local))
for key, value := range global {
result[key] = value
}
for key, value := range local {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
func FormatLabels(labels map[string]string) string {
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+labels[key])
}
return strings.Join(lines, "\n")
}
func Slugify(value string) string {
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value)))
var builder strings.Builder
separator := false
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if separator && builder.Len() > 0 {
builder.WriteByte('-')
}
builder.WriteRune(r)
separator = false
} else {
separator = true
}
}
return strings.Trim(builder.String(), "-")
}
func ValidateDockerUser(user DockerUser) error {
switch user.Mode {
case DockerUserDoGaMa, DockerUserImage:
if user.UID != nil || user.GID != nil {
return errors.New("UID and GID are only valid in custom mode")
}
case DockerUserCustom:
if user.UID == nil || user.GID == nil {
return errors.New("custom Docker user requires UID and GID")
}
default:
return errors.New("invalid Docker user mode")
}
return nil
}
func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) {
if err := ValidateDockerUser(user); err != nil {
return "", err
}
switch user.Mode {
case DockerUserImage:
return "", nil
case DockerUserDoGaMa:
return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil
default:
return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil
}
}
func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) {
if value.Mode == "" {
value.Mode = ImageTagTracked
}
switch value.Mode {
case ImageTagTracked:
value.Tag = defaultTag
case ImageTagPinned:
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid pinned Docker image tag")
}
default:
return ImageTag{}, errors.New("invalid Docker image tag mode")
}
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid Docker image tag")
}
return value, nil
}