Repository: aksiksi/compose2nix (go)

Issue:
Should compose2nix reconsider relative volumes mounts from cwd?
Hi!

`compose2nix` really is awesome, and exceeds all of my expectations!

As I’m migrating a bunch of Docker Compose definitions, I was wondering if it would be wise to add an option to the parser to handle relative volume mountpoints more effectively.

It would leverage the issue where a `docker-compose.yml` contains a bunch of relative mountpoints from the current working directory.

### Current behavior

Currently, with the following definition:

```yaml
services:
   foo:
      image: bar
      volumes:
        - ./baz:/baz
```

Using `compose2nix -project fo`, the following Nix definition is generated:

```nix
{
  # …
  # Containers
  virtualisation.oci-containers.containers."foo-foo" = {
    image = "bar";
    volumes = [
      "baz:/baz:rw" # <---
    ];
    # …
  };
  # …
}
```

### Expected

Instead, I’d expect either a warning or the following output:

```nix
    volumes = [
      "/some/cwd/from/compose2nix/baz:/baz:rw"
    ];
```

## Propositions

I considered those 2 options:
1. Re-use the current working directory during `compose2nix` call (fastest but dumbest, and might break things if `docker-compose` wasn’t meant to be called from the same place `compose2nix` have been)
2. Add a flag to prefix the relative volume paths by the given directory (smartest, but requires checking help and yet another mandatory flag)

In any way, by default, I believe we should not let `compose2nix` trim the `./` prefix from relative volume mountpoints. We might want to consider issuing a warning instead (as it is done using `-check_systemd_mounts`)

Pull request:
Prepend relative volumes with absolute path
Closes #17.

Patch:
diff --git a/README.md b/README.md
index 3ac0c51..f025b63 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,14 @@ You can do one of the following:
 sudo systemctl start podman-compose-myservice-root.target
 ```
 
+### NixOS Version Support Policy
+
+I always aim to support the **latest** stable version of NixOS (24.05 at the
+time of writing). As a result, some NixOS unstable options are not used.
+
+If the option has a strong usecase, I am open to adding a CLI flag that can be
+deprecated once the option is stable.
+
 ### Usage
 
 ```
@@ -203,12 +211,16 @@ Usage of compose2nix:
         project name used as a prefix for generated resources. this overrides any top-level "name" set in the Compose file(s).
   -remove_volumes
         if set, volumes will be removed on systemd service stop.
+  -root_path string
+        root path to use for any relative paths in the Compose file (e.g., volumes).
   -runtime string
         one of: ["podman", "docker"]. (default "podman")
   -service_include string
         regex pattern for services to include.
   -use_compose_log_driver
         if set, always use the Docker Compose log driver.
+  -use_upheld_by
+        if set, upheldBy will be used for service dependencies (NixOS 24.05+).
   -version
         display version and exit
   -write_nix_setup
diff --git a/compose.go b/compose.go
index 1d4614f..64affd7 100644
--- a/compose.go
+++ b/compose.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"log"
 	"os"
+	"path"
 	"regexp"
 	"slices"
 	"strconv"
@@ -51,11 +52,23 @@ func portConfigsToPortStrings(portConfigs []types.ServicePortConfig) []string {
 	return ports
 }
 
+func (g *Generator) GetRootPath() (string, error) {
+	if g.RootPath != "" {
+		return g.RootPath, nil
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		return "", fmt.Errorf("failed to get current working directory: %w", err)
+	}
+	return cwd, nil
+}
+
 type Generator struct {
 	Project                 *Project
 	Runtime                 ContainerRuntime
 	Inputs                  []string
 	EnvFiles                []string
+	RootPath                string
 	IncludeEnvFiles         bool
 	EnvFilesOnly            bool
 	IgnoreMissingEnvFiles   bool
@@ -233,15 +246,29 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig, networkMap ma
 			}
 		} else {
 			// This is a bind mount.
-			c.Volumes[v.Source] = v.String()
+			sourcePath := v.Source
+
+			// Let's first check if this is a relative path. If it is, we'll
+			// prepend the root path configured if set, or the current working
+			// dir otherwise. Either way, we cannot use relative paths.
+			//
+			// TODO(aksiksi): Evaluate if erroring out is better if no root
+			// path is set.
+			if !path.IsAbs(sourcePath) {
+				root, err := g.GetRootPath()
+				if err != nil {
+					return nil, fmt.Errorf("failed to get root path for relative volume path %q: %w", sourcePath, err)
+				}
+				sourcePath = path.Join(root, sourcePath)
+			}
+
+			// Replace the source path in the volume string.
+			volumeString := strings.Split(v.String(), ":")
+			volumeString[0] = sourcePath
+			c.Volumes[sourcePath] = strings.Join(volumeString, ":")
 
 			if g.CheckSystemdMounts {
-				path := v.Source
-				if !strings.HasPrefix(path, "/") {
-					log.Printf("Bind mount path %q is not absolute; skipping systemd mount dependency for service %q", path, service.Name)
-				} else {
-					c.SystemdConfig.Unit.RequiresMountsFor = append(c.SystemdConfig.Unit.RequiresMountsFor, path)
-				}
+				c.SystemdConfig.Unit.RequiresMountsFor = append(c.SystemdConfig.Unit.RequiresMountsFor, sourcePath)
 			}
 		}
 	}
diff --git a/main.go b/main.go
index 4fe6de4..414f83d 100644
--- a/main.go
+++ b/main.go
@@ -20,13 +20,14 @@ const (
 
 // TODO(aksiksi): Investigate parsing flags into structs using the *Val functions.
 var inputs = flag.String("inputs", "docker-compose.yml", "one or more comma-separated path(s) to Compose file(s).")
+var output = flag.String("output", "docker-compose.nix", "path to output Nix file.")
+var project = flag.String("project", "", "project name used as a prefix for generated resources. this overrides any top-level \"name\" set in the Compose file(s).")
+var serviceInclude = flag.String("service_include", "", "regex pattern for services to include.")
+var rootPath = flag.String("root_path", "", "root path to use for any relative paths in the Compose file (e.g., volumes).")
 var envFiles = flag.String("env_files", "", "one or more comma-separated paths to .env file(s).")
 var includeEnvFiles = flag.Bool("include_env_files", false, "include env files in the NixOS container definition.")
 var envFilesOnly = flag.Bool("env_files_only", false, "only use env file(s) in the NixOS container definitions.")
 var ignoreMissingEnvFiles = flag.Bool("ignore_missing_env_files", false, "if set, missing env files will be ignored.")
-var output = flag.String("output", "docker-compose.nix", "path to output Nix file.")
-var project = flag.String("project", "", "project name used as a prefix for generated resources. this overrides any top-level \"name\" set in the Compose file(s).")
-var serviceInclude = flag.String("service_include", "", "regex pattern for services to include.")
 var autoStart = flag.Bool("auto_start", true, "auto-start setting for generated service(s). this applies to all services, not just containers.")
 var runtime = flag.String("runtime", "podman", `one of: ["podman", "docker"].`)
 var useComposeLogDriver = flag.Bool("use_compose_log_driver", false, "if set, always use the Docker Compose log driver.")
@@ -80,6 +81,7 @@ func main() {
 		Runtime:                 containerRuntime,
 		Inputs:                  inputs,
 		EnvFiles:                envFiles,
+		RootPath:                *rootPath,
 		IncludeEnvFiles:         *includeEnvFiles,
 		EnvFilesOnly:            *envFilesOnly,
 		IgnoreMissingEnvFiles:   *ignoreMissingEnvFiles,


Test patch:
diff --git a/nix_test.go b/nix_test.go
index 2086f37..6e0a52c 100644
--- a/nix_test.go
+++ b/nix_test.go
@@ -198,3 +198,12 @@ func TestNetworkAndVolumeNames(t *testing.T) {
 	}
 	runSubtestsWithGenerator(t, g)
 }
+
+func TestRelativeServiceVolumes(t *testing.T) {
+	composePath, _ := getPaths(t, false)
+	g := &Generator{
+		Inputs:   []string{composePath},
+		RootPath: "/my/root",
+	}
+	runSubtestsWithGenerator(t, g)
+}
diff --git a/testdata/TestRelativeServiceVolumes.compose.yml b/testdata/TestRelativeServiceVolumes.compose.yml
new file mode 100644
index 0000000..a137481
--- /dev/null
+++ b/testdata/TestRelativeServiceVolumes.compose.yml
@@ -0,0 +1,20 @@
+name: "myproject"
+services:
+  traefik:
+    container_name: traefik
+    image: docker.io/library/traefik
+    volumes:
+      - ./abc:/abc
+      - /some/abc:/some/abc
+      - test1:/test1
+      - test2:/test2
+      - test3:/test3
+    restart: unless-stopped
+
+volumes:
+  test1:
+    external: true
+  test2:
+    name: my-volume
+  test3:
+
diff --git a/testdata/TestRelativeServiceVolumes.docker.nix b/testdata/TestRelativeServiceVolumes.docker.nix
new file mode 100644
index 0000000..5a9dfdf
--- /dev/null
+++ b/testdata/TestRelativeServiceVolumes.docker.nix
@@ -0,0 +1,102 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.docker = {
+    enable = true;
+    autoPrune.enable = true;
+  };
+  virtualisation.oci-containers.backend = "docker";
+
+  # Containers
+  virtualisation.oci-containers.containers."traefik" = {
+    image = "docker.io/library/traefik";
+    volumes = [
+      "/my/root/abc:/abc:rw"
+      "/some/abc:/some/abc:rw"
+      "test2:/test2:rw"
+      "test3:/test3:rw"
+      "test1:/test1:rw"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=traefik"
+      "--network=myproject_default"
+    ];
+  };
+  systemd.services."docker-traefik" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+      RestartMaxDelaySec = lib.mkOverride 500 "1m";
+      RestartSec = lib.mkOverride 500 "100ms";
+      RestartSteps = lib.mkOverride 500 9;
+    };
+    after = [
+      "docker-network-myproject_default.service"
+      "docker-volume-my-volume.service"
+      "docker-volume-myproject_test3.service"
+    ];
+    requires = [
+      "docker-network-myproject_default.service"
+      "docker-volume-my-volume.service"
+      "docker-volume-myproject_test3.service"
+    ];
+    partOf = [
+      "docker-compose-myproject-root.target"
+    ];
+    wantedBy = [
+      "docker-compose-myproject-root.target"
+    ];
+  };
+
+  # Networks
+  systemd.services."docker-network-myproject_default" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "${pkgs.docker}/bin/docker network rm -f myproject_default";
+    };
+    script = ''
+      docker network inspect myproject_default || docker network create myproject_default
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."docker-volume-my-volume" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect my-volume || docker volume create my-volume
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+  systemd.services."docker-volume-myproject_test3" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect myproject_test3 || docker volume create myproject_test3
+    '';
+    partOf = [ "docker-compose-myproject-root.target" ];
+    wantedBy = [ "docker-compose-myproject-root.target" ];
+  };
+
+  # Root service
+  # When started, this will automatically create all resources and start
+  # the containers. When stopped, this will teardown all resources.
+  systemd.targets."docker-compose-myproject-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}
diff --git a/testdata/TestRelativeServiceVolumes.podman.nix b/testdata/TestRelativeServiceVolumes.podman.nix
new file mode 100644
index 0000000..fce9b8a
--- /dev/null
+++ b/testdata/TestRelativeServiceVolumes.podman.nix
@@ -0,0 +1,104 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.podman = {
+    enable = true;
+    autoPrune.enable = true;
+    dockerCompat = true;
+    defaultNetwork.settings = {
+      # Required for container networking to be able to use names.
+      dns_enabled = true;
+    };
+  };
+  virtualisation.oci-containers.backend = "podman";
+
+  # Containers
+  virtualisation.oci-containers.containers."traefik" = {
+    image = "docker.io/library/traefik";
+    volumes = [
+      "/my/root/abc:/abc:rw"
+      "/some/abc:/some/abc:rw"
+      "test2:/test2:rw"
+      "test3:/test3:rw"
+      "test1:/test1:rw"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=traefik"
+      "--network=myproject_default"
+    ];
+  };
+  systemd.services."podman-traefik" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 500 "always";
+    };
+    after = [
+      "podman-network-myproject_default.service"
+      "podman-volume-my-volume.service"
+      "podman-volume-myproject_test3.service"
+    ];
+    requires = [
+      "podman-network-myproject_default.service"
+      "podman-volume-my-volume.service"
+      "podman-volume-myproject_test3.service"
+    ];
+    partOf = [
+      "podman-compose-myproject-root.target"
+    ];
+    wantedBy = [
+      "podman-compose-myproject-root.target"
+    ];
+  };
+
+  # Networks
+  systemd.services."podman-network-myproject_default" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "${pkgs.podman}/bin/podman network rm -f myproject_default";
+    };
+    script = ''
+      podman network inspect myproject_default || podman network create myproject_default
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."podman-volume-my-volume" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect my-volume || podman volume create my-volume
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+  systemd.services."podman-volume-myproject_test3" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect myproject_test3 || podman volume create myproject_test3
+    '';
+    partOf = [ "podman-compose-myproject-root.target" ];
+    wantedBy = [ "podman-compose-myproject-root.target" ];
+  };
+
+  # Root service
+  # When started, this will automatically create all resources and start
+  # the containers. When stopped, this will teardown all resources.
+  systemd.targets."podman-compose-myproject-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}