diff --git a/compose.go b/compose.go index 6562107..84111d5 100644 --- a/compose.go +++ b/compose.go @@ -5,14 +5,22 @@ import ( "context" "fmt" "log" + "regexp" "slices" + "strconv" "strings" + "time" "github.com/compose-spec/compose-go/loader" "github.com/compose-spec/compose-go/types" "golang.org/x/exp/maps" ) +// Examples: +// nixose.systemd.service.RuntimeMaxSec=100 +// nixose.systemd.unit.StartLimitBurst=10 +var systemdLabelRegexp regexp.Regexp = *regexp.MustCompile(`nixose\.systemd\.(service|unit)\.(\w+)`) + func composeEnvironmentToMap(env types.MappingWithEquals) map[string]string { m := make(map[string]string) for k, v := range env { @@ -85,6 +93,91 @@ func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) { }, nil } +func parseRestartPolicyAndSystemdLabels(service *types.ServiceConfig) (*NixContainerSystemdConfig, error) { + p := &NixContainerSystemdConfig{ + Service: make(map[string]any), + Unit: make(map[string]any), + } + + // https://docs.docker.com/compose/compose-file/compose-file-v2/#restart + switch restart := service.Restart; restart { + case "": + p.Service["Restart"] = "no" + case "no", "always", "on-failure": + p.Service["Restart"] = restart + case "unless-stopped": + p.Service["Restart"] = "always" + default: + if strings.HasPrefix(restart, "on-failure") && strings.Contains(restart, ":") { + p.Service["Restart"] = "on-failure" + maxAttemptsString := strings.TrimSpace(strings.Split(restart, ":")[1]) + if maxAttempts, err := strconv.ParseInt(maxAttemptsString, 10, 64); err != nil { + return nil, fmt.Errorf("failed to parse on-failure attempts: %q: %w", maxAttemptsString, err) + } else { + v := int(maxAttempts) + p.StartLimitBurst = &v + } + } else { + return nil, fmt.Errorf("unsupported restart: %q", restart) + } + } + + if service.Deploy != nil { + // The newer "deploy" config will always override the legacy "restart" config. + // https://docs.docker.com/compose/compose-file/compose-file-v3/#restart_policy + if restartPolicy := service.Deploy.RestartPolicy; restartPolicy != nil { + switch condition := restartPolicy.Condition; condition { + case "none": + p.Service["Restart"] = "no" + case "any": + p.Service["Restart"] = "always" + case "on-failure": + p.Service["Restart"] = "on-failure" + default: + return nil, fmt.Errorf("unsupported condition: %q", condition) + } + if delay := restartPolicy.Delay; delay != nil { + p.Service["RestartSec"] = delay.String() + } + if maxAttempts := restartPolicy.MaxAttempts; maxAttempts != nil { + v := int(*maxAttempts) + p.StartLimitBurst = &v + } + if window := restartPolicy.Window; window != nil { + windowSecs := int(time.Duration(*window).Seconds()) + p.StartLimitIntervalSec = &windowSecs + } + } + } + + // Custom values provided via labels will override any explicit restart settings. + var labelsToDrop []string + for label, value := range service.Labels { + if !strings.HasPrefix(label, "nixose.") { + continue + } + m := systemdLabelRegexp.FindStringSubmatch(label) + if len(m) == 0 { + return nil, fmt.Errorf("invalid nixose label specified for service %q: %q", service.Name, label) + } + typ, key := m[1], m[2] + switch typ { + case "service": + p.Service[key] = parseSystemdValue(value) + case "unit": + p.Unit[key] = parseSystemdValue(value) + default: + return nil, fmt.Errorf(`invalid systemd type %q - must be "service" or "unit"`, typ) + } + labelsToDrop = append(labelsToDrop, label) + } + for _, label := range labelsToDrop { + delete(service.Labels, label) + } + + return p, nil +} + func (g *Generator) buildNixContainer(service types.ServiceConfig) NixContainer { dependsOn := service.GetDependencies() if g.Project != nil { @@ -103,18 +196,25 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig) NixContainer name = service.Name } + systemdConfig, err := parseRestartPolicyAndSystemdLabels(&service) + if err != nil { + // TODO(aksiksi): Return error here instead of panicing. + panic(err) + } + c := NixContainer{ - Project: g.Project, - Runtime: g.Runtime, - Name: name, - Image: service.Image, - Labels: service.Labels, - Ports: portConfigsToPortStrings(service.Ports), - User: service.User, - Volumes: make(map[string]string), - Networks: maps.Keys(service.Networks), - DependsOn: dependsOn, - AutoStart: g.AutoStart, + Project: g.Project, + Runtime: g.Runtime, + Name: name, + Image: service.Image, + Labels: service.Labels, + Ports: portConfigsToPortStrings(service.Ports), + User: service.User, + Volumes: make(map[string]string), + Networks: maps.Keys(service.Networks), + SystemdConfig: systemdConfig, + DependsOn: dependsOn, + AutoStart: g.AutoStart, } slices.Sort(c.Networks) diff --git a/helpers.go b/helpers.go index 1640f03..b24bf81 100644 --- a/helpers.go +++ b/helpers.go @@ -6,9 +6,24 @@ import ( "log" "os" "slices" + "strconv" "strings" ) +// https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html +func parseSystemdValue(v string) any { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return int(i) + } + switch v { + case "true", "yes", "on", "1": + return true + case "false", "no", "off", "0": + return false + } + return v +} + // mapToKeyValArray converts a map into a _sorted_ list of KEY=VAL entries. func mapToKeyValArray(m map[string]string) []string { var arr []string diff --git a/nixose.go b/nixose.go index 69248a1..df4da4b 100644 --- a/nixose.go +++ b/nixose.go @@ -1,41 +1,11 @@ package nixose import ( - "embed" "fmt" "strings" "text/template" - - "github.com/Masterminds/sprig" ) -//go:embed templates/*.tmpl -var templateFS embed.FS -var nixTemplates = template.New("nix").Funcs(sprig.FuncMap()).Funcs(funcMap) - -func labelMapToLabelFlags(l map[string]string) []string { - // https://docs.docker.com/engine/reference/commandline/run/#label - // https://docs.podman.io/en/latest/markdown/podman-run.1.html#label-l-key-value - labels := mapToKeyValArray(l) - for i, label := range labels { - labels[i] = fmt.Sprintf("--label=%s", label) - } - return labels -} - -func execTemplate(t *template.Template) func(string, any) (string, error) { - return func(name string, v any) (string, error) { - var s strings.Builder - err := t.ExecuteTemplate(&s, name, v) - return s.String(), err - } -} - -var funcMap template.FuncMap = template.FuncMap{ - "labelMapToLabelFlags": labelMapToLabelFlags, - "mapToKeyValArray": mapToKeyValArray, -} - const DefaultProjectSeparator = "-" type ContainerRuntime int @@ -98,22 +68,39 @@ type NixVolume struct { Containers []string } +// NixContainerSystemdConfig configures the container's systemd config. +// In particular, this allows control of the container restart policy through systemd +// service and unit configs. +// +// Each key-value pair in a map represents a systemd key and its value (e.g., Restart=always). +// Users can provide custom config keys by setting the nixose.systemd.* label on the service. +type NixContainerSystemdConfig struct { + Service map[string]any + Unit map[string]any + // NixOS treats these differently, probably to fix the rename issue in + // earlier systemd versions. + // See: https://unix.stackexchange.com/a/464098 + StartLimitBurst *int + StartLimitIntervalSec *int +} + // https://search.nixos.org/options?channel=unstable&from=0&size=50&sort=relevance&type=packages&query=oci-container type NixContainer struct { - Project *Project - Runtime ContainerRuntime - Name string - Image string - Environment map[string]string - EnvFiles []string - Volumes map[string]string - Ports []string - Labels map[string]string - Networks []string - DependsOn []string - ExtraOptions []string - User string - AutoStart bool + Project *Project + Runtime ContainerRuntime + Name string + Image string + Environment map[string]string + EnvFiles []string + Volumes map[string]string + Ports []string + Labels map[string]string + Networks []string + DependsOn []string + ExtraOptions []string + SystemdConfig *NixContainerSystemdConfig + User string + AutoStart bool } type NixContainerConfig struct { diff --git a/template.go b/template.go new file mode 100644 index 0000000..39ffce8 --- /dev/null +++ b/template.go @@ -0,0 +1,52 @@ +package nixose + +import ( + "embed" + "fmt" + "strings" + "text/template" + + "github.com/Masterminds/sprig" +) + +//go:embed templates/*.tmpl +var templateFS embed.FS +var nixTemplates = template.New("nix").Funcs(sprig.FuncMap()).Funcs(funcMap) + +func labelMapToLabelFlags(l map[string]string) []string { + // https://docs.docker.com/engine/reference/commandline/run/#label + // https://docs.podman.io/en/latest/markdown/podman-run.1.html#label-l-key-value + labels := mapToKeyValArray(l) + for i, label := range labels { + labels[i] = fmt.Sprintf("--label=%s", label) + } + return labels +} + +func execTemplate(t *template.Template) func(string, any) (string, error) { + return func(name string, v any) (string, error) { + var s strings.Builder + err := t.ExecuteTemplate(&s, name, v) + return s.String(), err + } +} + +func derefInt(v *int) int { + return *v +} + +func toNixValue(v any) any { + switch v.(type) { + case string: + return fmt.Sprintf("%q", v) + default: + return v + } +} + +var funcMap template.FuncMap = template.FuncMap{ + "derefInt": derefInt, + "labelMapToLabelFlags": labelMapToLabelFlags, + "mapToKeyValArray": mapToKeyValArray, + "toNixValue": toNixValue, +} diff --git a/templates/container.nix.tmpl b/templates/container.nix.tmpl index 6168b65..e080ab7 100644 --- a/templates/container.nix.tmpl +++ b/templates/container.nix.tmpl @@ -67,4 +67,28 @@ virtualisation.oci-containers.containers."{{$name}}" = { {{- if not .AutoStart}} autoStart = false; {{- end}} -}; \ No newline at end of file +}; +{{- if .SystemdConfig}} +systemd.services."{{$runtime}}-{{$name}}" = { + {{- if .SystemdConfig.Service}} + serviceConfig = { + {{- range $k, $v := .SystemdConfig.Service}} + {{$k}} = {{toNixValue $v}}; + {{- end}} + }; + {{- end}} + {{- if .SystemdConfig.Unit}} + unitConfig = { + {{- range $k, $v := .SystemdConfig.Unit}} + {{$k}} = {{toNixValue $v}}; + {{- end}} + }; + {{- end}} + {{- if .SystemdConfig.StartLimitBurst}} + startLimitBurst = {{derefInt .SystemdConfig.StartLimitBurst}}; + {{- end}} + {{- if .SystemdConfig.StartLimitIntervalSec}} + startLimitIntervalSec = {{derefInt .SystemdConfig.StartLimitIntervalSec}}; + {{- end}} +}; +{{- end}} \ No newline at end of file diff --git a/testdata/TestDocker_out.nix b/testdata/TestDocker_out.nix index 738624b..1c48335 100644 --- a/testdata/TestDocker_out.nix +++ b/testdata/TestDocker_out.nix @@ -31,6 +31,11 @@ ]; autoStart = false; }; + systemd.services."docker-jellyseerr" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."photoprism-mariadb" = { image = "docker.io/library/mariadb:10.9"; environment = { @@ -51,6 +56,11 @@ user = "1000:1000"; autoStart = false; }; + systemd.services."docker-photoprism-mariadb" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."sabnzbd" = { image = "lscr.io/linuxserver/sabnzbd"; environment = { @@ -78,6 +88,12 @@ ]; autoStart = false; }; + systemd.services."docker-sabnzbd" = { + serviceConfig = { + Restart = "always"; + RuntimeMaxSec = 10; + }; + }; virtualisation.oci-containers.containers."traefik" = { image = "docker.io/library/traefik"; environment = { @@ -106,6 +122,11 @@ ]; autoStart = false; }; + systemd.services."docker-traefik" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."transmission" = { image = "docker.io/haugene/transmission-openvpn"; environment = { @@ -146,6 +167,12 @@ ]; autoStart = false; }; + systemd.services."docker-transmission" = { + serviceConfig = { + Restart = "on-failure"; + }; + startLimitBurst = 3; + }; # Networks systemd.services."create-docker-network-default" = { diff --git a/testdata/TestPodman_out.nix b/testdata/TestPodman_out.nix index 171979a..eb5291a 100644 --- a/testdata/TestPodman_out.nix +++ b/testdata/TestPodman_out.nix @@ -36,6 +36,11 @@ ]; autoStart = false; }; + systemd.services."podman-jellyseerr" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."photoprism-mariadb" = { image = "docker.io/library/mariadb:10.9"; environment = { @@ -56,6 +61,11 @@ user = "1000:1000"; autoStart = false; }; + systemd.services."podman-photoprism-mariadb" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."sabnzbd" = { image = "lscr.io/linuxserver/sabnzbd"; environment = { @@ -83,6 +93,12 @@ ]; autoStart = false; }; + systemd.services."podman-sabnzbd" = { + serviceConfig = { + Restart = "always"; + RuntimeMaxSec = 10; + }; + }; virtualisation.oci-containers.containers."traefik" = { image = "docker.io/library/traefik"; environment = { @@ -111,6 +127,11 @@ ]; autoStart = false; }; + systemd.services."podman-traefik" = { + serviceConfig = { + Restart = "always"; + }; + }; virtualisation.oci-containers.containers."transmission" = { image = "docker.io/haugene/transmission-openvpn"; environment = { @@ -151,6 +172,12 @@ ]; autoStart = false; }; + systemd.services."podman-transmission" = { + serviceConfig = { + Restart = "on-failure"; + }; + startLimitBurst = 3; + }; # Networks systemd.services."create-podman-network-default" = { diff --git a/testdata/docker-compose.yml b/testdata/docker-compose.yml index db12a07..e4eff35 100644 --- a/testdata/docker-compose.yml +++ b/testdata/docker-compose.yml @@ -19,6 +19,7 @@ services: - "traefik.http.routers.sabnzbd.rule=Host(`${HOME_DOMAIN}`) && PathPrefix(`/sabnzbd`)" - "traefik.http.routers.sabnzbd.tls.certresolver=htpc" - "traefik.http.routers.sabnzbd.middlewares=chain-authelia@file" + - "nixose.systemd.service.RuntimeMaxSec=10" logging: driver: "json-file" options: