Repository: aksiksi/compose2nix
Pull request #52 (2024-09-28 19:06:55)

feat: initial support for Compose Build spec
The idea is we generate a systemd (one-shot) service per build that users can run _manually_ to build the container and add it to the runtime's image store.

Users can also pass in `-build=true` to enable the build service to run as a dependency for the container. However, keeping this config would result in a _new_ build on every restart of the system (or root target), which would implicitly result in an update of the container. So, users will need to re-generate the config with `-build=false`.

For now, we do not support Git repo build contexts (i.e., pull from repo before building).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) Report
Attention: Patch coverage is `63.41463%` with `15 lines` in your changes missing coverage. Please review.
> Project coverage is 72.03%. Comparing base [(`c432239`)](https://app.codecov.io/gh/aksiksi/compose2nix/commit/c432239992e62f5ddbfca1a510f6c73ecce6bd3a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) to head [(`f69d6ca`)](https://app.codecov.io/gh/aksiksi/compose2nix/commit/f69d6ca3ae85f056ceb0ec0518357d2ff30d9b95?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).

| [Files with missing lines](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) | Patch % | Lines |
|---|---|---|
| [compose.go](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?src=pr&el=tree&filepath=compose.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi#diff-Y29tcG9zZS5nbw==) | 62.06% | [7 Missing and 4 partials :warning: ](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) |
| [nix.go](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?src=pr&el=tree&filepath=nix.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi#diff-bml4Lmdv) | 66.66% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #52      +/-   ##
==========================================
- Coverage   73.34%   72.03%   -1.31%     
==========================================
  Files           6        6              
  Lines         769      801      +32     
==========================================
+ Hits          564      577      +13     
- Misses        164      179      +15     
- Partials       41       45       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aksiksi/compose2nix/pull/52?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Assil+Ksiksi).


Patch:
diff --git a/compose.go b/compose.go
index fcfdd16..e047357 100644
--- a/compose.go
+++ b/compose.go
@@ -126,9 +126,11 @@ type Generator struct {
 	WriteHeader             bool
 	NoWriteNixSetup         bool
 	DefaultStopTimeout      time.Duration
+	IncludeBuild            bool
 	GetWorkingDir           getWorkingDir
 
 	serviceToContainerName map[string]string
+	rootPath               string
 }
 
 func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
@@ -136,6 +138,7 @@ func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
 	if err != nil {
 		return nil, err
 	}
+	g.rootPath = rootPath
 
 	// Transform env files into absolute paths. This ensures that we can compare
 	// them to Compose env files when building Nix containers.
@@ -185,7 +188,7 @@ func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
 
 	networks, networkMap := g.buildNixNetworks(composeProject)
 	volumes, volumeMap := g.buildNixVolumes(composeProject)
-	containers, err := g.buildNixContainers(composeProject, networkMap, volumeMap)
+	containers, builds, err := g.buildNixContainers(composeProject, networkMap, volumeMap)
 	if err != nil {
 		return nil, err
 	}
@@ -203,12 +206,14 @@ func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
 		Project:          g.Project,
 		Runtime:          g.Runtime,
 		Containers:       containers,
+		Builds:           builds,
 		Networks:         networks,
 		Volumes:          volumes,
 		CreateRootTarget: !g.NoCreateRootTarget,
 		AutoStart:        g.AutoStart,
 		WriteNixSetup:    !g.NoWriteNixSetup,
 		AutoFormat:       g.AutoFormat,
+		IncludeBuild:     g.IncludeBuild,
 	}, nil
 }
 
@@ -351,20 +356,6 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig, networkMap ma
 			// This is a bind mount.
 			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)
-			}
-
 			if g.CheckBindMounts {
 				if _, err := os.Stat(sourcePath); os.IsNotExist(err) {
 					return nil, fmt.Errorf("service %q: bind mount source path %q does not exist", service.Name, sourcePath)
@@ -719,11 +710,6 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig, networkMap ma
 		}
 	}
 
-	slices.Sort(c.SystemdConfig.Unit.After)
-	slices.Sort(c.SystemdConfig.Unit.Requires)
-	slices.Sort(c.SystemdConfig.Unit.RequiresMountsFor)
-	slices.Sort(c.SystemdConfig.Unit.UpheldBy)
-
 	// systemd configs provided via labels always override everything else.
 	if err := c.SystemdConfig.ParseSystemdLabels(&service); err != nil {
 		return nil, err
@@ -732,8 +718,37 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig, networkMap ma
 	return c, nil
 }
 
-func (g *Generator) buildNixContainers(composeProject *types.Project, networkMap map[string]*NixNetwork, volumeMap map[string]*NixVolume) ([]*NixContainer, error) {
-	var containers []*NixContainer
+func (g *Generator) parseServiceBuild(service types.ServiceConfig, c *NixContainer) (*NixBuild, error) {
+	cx := service.Build.Context
+	if strings.HasPrefix(cx, "http") {
+		return nil, fmt.Errorf("Git repo build context is not yet supported")
+	}
+	if !path.IsAbs(cx) {
+		cx = path.Join(g.rootPath, cx)
+	}
+
+	b := &NixBuild{
+		Runtime:     g.Runtime,
+		Context:     cx,
+		Args:        service.Build.Args,
+		Tags:        service.Build.Tags,
+		Dockerfile:  service.Build.Dockerfile,
+		ServiceName: service.Name,
+	}
+
+	if g.IncludeBuild {
+		// Add dependency on build systemd service.
+		c.SystemdConfig.Unit.After = append(c.SystemdConfig.Unit.After, b.Unit())
+		c.SystemdConfig.Unit.Requires = append(c.SystemdConfig.Unit.Requires, b.Unit())
+		if g.UseUpheldBy {
+			c.SystemdConfig.Unit.UpheldBy = append(c.SystemdConfig.Unit.UpheldBy, b.Unit())
+		}
+	}
+
+	return b, nil
+}
+
+func (g *Generator) buildNixContainers(composeProject *types.Project, networkMap map[string]*NixNetwork, volumeMap map[string]*NixVolume) (containers []*NixContainer, builds []*NixBuild, _ error) {
 	for _, s := range composeProject.Services {
 		if g.ServiceInclude != nil && !g.ServiceInclude.MatchString(s.Name) {
 			log.Printf("Skipping service %q due to include regex %q", s.Name, g.ServiceInclude.String())
@@ -741,14 +756,27 @@ func (g *Generator) buildNixContainers(composeProject *types.Project, networkMap
 		}
 		c, err := g.buildNixContainer(s, networkMap, volumeMap)
 		if err != nil {
-			return nil, fmt.Errorf("failed to build container for service %q: %w", s.Name, err)
+			return nil, nil, fmt.Errorf("failed to build container for service %q: %w", s.Name, err)
 		}
 		containers = append(containers, c)
+
+		if s.Build != nil {
+			b, err := g.parseServiceBuild(s, c)
+			if err != nil {
+				return nil, nil, fmt.Errorf("failed to parse build for service %q: %w", s.Name, err)
+			}
+			builds = append(builds, b)
+		}
+
+		c.SystemdConfig.Sort()
 	}
 	slices.SortFunc(containers, func(c1, c2 *NixContainer) int {
 		return cmp.Compare(c1.Name, c2.Name)
 	})
-	return containers, nil
+	slices.SortFunc(builds, func(c1, c2 *NixBuild) int {
+		return cmp.Compare(c1.ServiceName, c2.ServiceName)
+	})
+	return containers, builds, nil
 }
 
 func (g *Generator) networkNameToService(name string) string {
diff --git a/main.go b/main.go
index af44d8d..ec7660d 100644
--- a/main.go
+++ b/main.go
@@ -38,6 +38,7 @@ var useUpheldBy = flag.Bool("use_upheld_by", false, "if set, upheldBy will be us
 var removeVolumes = flag.Bool("remove_volumes", false, "if set, volumes will be removed on systemd service stop.")
 var createRootTarget = flag.Bool("create_root_target", true, "if set, a root systemd target will be created, which when stopped tears down all resources.")
 var defaultStopTimeout = flag.Duration("default_stop_timeout", defaultSystemdStopTimeout, "default stop timeout for generated container services.")
+var build = flag.Bool("build", false, "if set, generated container build systemd services will be enabled.")
 var writeNixSetup = flag.Bool("write_nix_setup", true, "if true, Nix setup code is written to output (runtime, DNS, autoprune, etc.)")
 var autoFormat = flag.Bool("auto_format", false, `if true, Nix output will be formatted using "nixfmt" (must be present in $PATH).`)
 var version = flag.Bool("version", false, "display version and exit")
@@ -113,6 +114,7 @@ func main() {
 		NoWriteNixSetup:         !*writeNixSetup,
 		AutoFormat:              *autoFormat,
 		DefaultStopTimeout:      *defaultStopTimeout,
+		IncludeBuild:            *build,
 		GetWorkingDir:           &OsGetWd{},
 	}
 	containerConfig, err := g.Run(ctx)
diff --git a/nix.go b/nix.go
index 8eb36de..c104929 100644
--- a/nix.go
+++ b/nix.go
@@ -196,17 +196,54 @@ func (c *NixContainer) Unit() string {
 	return fmt.Sprintf("%s-%s.service", c.Runtime, c.Name)
 }
 
+// https://docs.docker.com/reference/compose-file/build/
+// https://docs.docker.com/reference/cli/docker/buildx/build/
+type NixBuild struct {
+	Runtime     ContainerRuntime
+	Context     string
+	Args        map[string]*string
+	Tags        []string
+	Dockerfile  string // Relative to context path.
+	ServiceName string
+}
+
+func (b *NixBuild) Unit() string {
+	return fmt.Sprintf("podman-build-%s.service", b.ServiceName)
+}
+
+func (b *NixBuild) Command() string {
+	cmd := fmt.Sprintf("%s build", b.Runtime)
+
+	for _, tag := range b.Tags {
+		cmd += fmt.Sprintf(" -t %s", tag)
+	}
+	for name, arg := range b.Args {
+		if arg != nil {
+			cmd += fmt.Sprintf(" --build-arg %s=%s", name, *arg)
+		} else {
+			cmd += fmt.Sprintf(" --build-arg %s", name)
+		}
+	}
+	if b.Dockerfile != "" && b.Dockerfile != "Dockerfile" {
+		cmd += fmt.Sprintf(" -f %s", b.Dockerfile)
+	}
+
+	return cmd + " ."
+}
+
 type NixContainerConfig struct {
 	Version          string
 	Project          *Project
 	Runtime          ContainerRuntime
 	Containers       []*NixContainer
+	Builds           []*NixBuild
 	Networks         []*NixNetwork
 	Volumes          []*NixVolume
 	CreateRootTarget bool
 	WriteNixSetup    bool
 	AutoFormat       bool
 	AutoStart        bool
+	IncludeBuild     bool
 }
 
 func (c *NixContainerConfig) String() string {
diff --git a/systemd.go b/systemd.go
index 7d176f6..ad185db 100644
--- a/systemd.go
+++ b/systemd.go
@@ -198,3 +198,12 @@ func (c *NixContainerSystemdConfig) ParseSystemdLabels(service *types.ServiceCon
 	}
 	return nil
 }
+
+func (c *NixContainerSystemdConfig) Sort() {
+	slices.Sort(c.Unit.After)
+	slices.Sort(c.Unit.Requires)
+	slices.Sort(c.Unit.PartOf)
+	slices.Sort(c.Unit.UpheldBy)
+	slices.Sort(c.Unit.WantedBy)
+	slices.Sort(c.Unit.RequiresMountsFor)
+}
diff --git a/templates/build.nix.tmpl b/templates/build.nix.tmpl
new file mode 100644
index 0000000..3e569dc
--- /dev/null
+++ b/templates/build.nix.tmpl
@@ -0,0 +1,18 @@
+systemd.services."{{.Runtime}}-build-{{.ServiceName}}" = {
+  {{- /* TODO: Support Git repo as a build source. */}}
+  path = [ pkgs.{{.Runtime}} pkgs.git ];
+  serviceConfig = {
+    Type = "oneshot";
+	{{- if cfg.IncludeBuild}}
+    RemainAfterExit = true;
+	{{- end}}
+  };
+  script = ''
+    cd {{.Context}}
+    {{escapeIndentedNixString .Command}}
+  '';
+  {{- if and cfg.IncludeBuild rootTarget}}
+  partOf = [ "{{rootTarget}}.target" ];
+  wantedBy = [ "{{rootTarget}}.target" ];
+  {{- end}}
+};
\ No newline at end of file
diff --git a/templates/main.nix.tmpl b/templates/main.nix.tmpl
index ba4ea30..296c340 100644
--- a/templates/main.nix.tmpl
+++ b/templates/main.nix.tmpl
@@ -55,6 +55,17 @@
   {{- end}}
   {{- end}}
 
+  {{- if .Builds}}
+
+  # Builds
+  #
+  # NOTE: These must be run manually before running any containers that require
+  # them to be present in the image store.
+  {{- range .Builds}}
+{{execTemplate "build.nix.tmpl" . | indent 2}}
+  {{- end}}
+  {{- end}}
+
   {{- if .CreateRootTarget}}
 
   # Root service


Test patch:
diff --git a/nix_test.go b/nix_test.go
index fe12f6a..0b98356 100644
--- a/nix_test.go
+++ b/nix_test.go
@@ -406,3 +406,25 @@ services:
 		t.Error(err)
 	}
 }
+
+func TestBuildSpec(t *testing.T) {
+	composePath, _ := getPaths(t, false)
+	g := &Generator{
+		Inputs:   []string{composePath},
+		Project:  NewProject("test"),
+		RootPath: "/some/path",
+	}
+	runSubtestsWithGenerator(t, g)
+}
+
+func TestBuildSpec_BuildEnabled(t *testing.T) {
+	composePath, _ := getPaths(t, false)
+	g := &Generator{
+		Inputs:       []string{composePath},
+		Project:      NewProject("test"),
+		RootPath:     "/some/path",
+		IncludeBuild: true,
+		UseUpheldBy:  true,
+	}
+	runSubtestsWithGenerator(t, g)
+}
diff --git a/testdata/TestBuildSpec.compose.yml b/testdata/TestBuildSpec.compose.yml
new file mode 100644
index 0000000..9b414f1
--- /dev/null
+++ b/testdata/TestBuildSpec.compose.yml
@@ -0,0 +1,29 @@
+# Adapted from: https://github.com/ente-io/ente/blob/main/server/compose.yaml
+services:
+  museum:
+    build:
+      context: .
+      args:
+        GIT_COMMIT: development-cluster
+      tags:
+        - latest
+        - non-latest
+    ports:
+      - 8080:8080 # API
+      - 2112:2112 # Prometheus metrics
+    environment:
+      # Pass-in the config to connect to the DB and MinIO
+      ENTE_CREDENTIALS_FILE: /credentials.yaml
+    volumes:
+      - custom-logs:/var/logs
+      - ./museum.yaml:/museum.yaml:ro
+      - ./scripts/compose/credentials.yaml:/credentials.yaml:ro
+      - ./data:/data:ro
+    networks:
+      - internal
+
+volumes:
+  custom-logs:
+
+networks:
+  internal:
diff --git a/testdata/TestBuildSpec.docker.nix b/testdata/TestBuildSpec.docker.nix
new file mode 100644
index 0000000..5d412ce
--- /dev/null
+++ b/testdata/TestBuildSpec.docker.nix
@@ -0,0 +1,100 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.docker = {
+    enable = true;
+    autoPrune.enable = true;
+  };
+  virtualisation.oci-containers.backend = "docker";
+
+  # Containers
+  virtualisation.oci-containers.containers."test-museum" = {
+    image = "";
+    environment = {
+      "ENTE_CREDENTIALS_FILE" = "/credentials.yaml";
+    };
+    volumes = [
+      "/some/path/data:/data:ro"
+      "/some/path/museum.yaml:/museum.yaml:ro"
+      "/some/path/scripts/compose/credentials.yaml:/credentials.yaml:ro"
+      "test_custom-logs:/var/logs:rw"
+    ];
+    ports = [
+      "8080:8080/tcp"
+      "2112:2112/tcp"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=museum"
+      "--network=test_internal"
+    ];
+  };
+  systemd.services."docker-test-museum" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 90 "no";
+    };
+    after = [
+      "docker-network-test_internal.service"
+      "docker-volume-test_custom-logs.service"
+    ];
+    requires = [
+      "docker-network-test_internal.service"
+      "docker-volume-test_custom-logs.service"
+    ];
+  };
+
+  # Networks
+  systemd.services."docker-network-test_internal" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "docker network rm -f test_internal";
+    };
+    script = ''
+      docker network inspect test_internal || docker network create test_internal
+    '';
+    partOf = [ "docker-compose-test-root.target" ];
+    wantedBy = [ "docker-compose-test-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."docker-volume-test_custom-logs" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect test_custom-logs || docker volume create test_custom-logs
+    '';
+    partOf = [ "docker-compose-test-root.target" ];
+    wantedBy = [ "docker-compose-test-root.target" ];
+  };
+
+  # Builds
+  #
+  # NOTE: These must be run manually before running any containers that require
+  # them to be present in the image store.
+  systemd.services."docker-build-museum" = {
+    path = [ pkgs.docker pkgs.git ];
+    serviceConfig = {
+      Type = "oneshot";
+    };
+    script = ''
+      cd /some/path
+      docker build -t latest -t non-latest --build-arg GIT_COMMIT=development-cluster .
+    '';
+  };
+
+  # 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-test-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}
diff --git a/testdata/TestBuildSpec.podman.nix b/testdata/TestBuildSpec.podman.nix
new file mode 100644
index 0000000..0c0557f
--- /dev/null
+++ b/testdata/TestBuildSpec.podman.nix
@@ -0,0 +1,110 @@
+{ 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;
+    };
+  };
+
+  # Enable container name DNS for non-default Podman networks.
+  # https://github.com/NixOS/nixpkgs/issues/226365
+  networking.firewall.interfaces."podman+".allowedUDPPorts = [ 53 ];
+
+  virtualisation.oci-containers.backend = "podman";
+
+  # Containers
+  virtualisation.oci-containers.containers."test-museum" = {
+    image = "";
+    environment = {
+      "ENTE_CREDENTIALS_FILE" = "/credentials.yaml";
+    };
+    volumes = [
+      "/some/path/data:/data:ro"
+      "/some/path/museum.yaml:/museum.yaml:ro"
+      "/some/path/scripts/compose/credentials.yaml:/credentials.yaml:ro"
+      "test_custom-logs:/var/logs:rw"
+    ];
+    ports = [
+      "8080:8080/tcp"
+      "2112:2112/tcp"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=museum"
+      "--network=test_internal"
+    ];
+  };
+  systemd.services."podman-test-museum" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 90 "no";
+    };
+    after = [
+      "podman-network-test_internal.service"
+      "podman-volume-test_custom-logs.service"
+    ];
+    requires = [
+      "podman-network-test_internal.service"
+      "podman-volume-test_custom-logs.service"
+    ];
+  };
+
+  # Networks
+  systemd.services."podman-network-test_internal" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "podman network rm -f test_internal";
+    };
+    script = ''
+      podman network inspect test_internal || podman network create test_internal
+    '';
+    partOf = [ "podman-compose-test-root.target" ];
+    wantedBy = [ "podman-compose-test-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."podman-volume-test_custom-logs" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect test_custom-logs || podman volume create test_custom-logs
+    '';
+    partOf = [ "podman-compose-test-root.target" ];
+    wantedBy = [ "podman-compose-test-root.target" ];
+  };
+
+  # Builds
+  #
+  # NOTE: These must be run manually before running any containers that require
+  # them to be present in the image store.
+  systemd.services."podman-build-museum" = {
+    path = [ pkgs.podman pkgs.git ];
+    serviceConfig = {
+      Type = "oneshot";
+    };
+    script = ''
+      cd /some/path
+      podman build -t latest -t non-latest --build-arg GIT_COMMIT=development-cluster .
+    '';
+  };
+
+  # 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-test-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}
diff --git a/testdata/TestBuildSpec_BuildEnabled.compose.yml b/testdata/TestBuildSpec_BuildEnabled.compose.yml
new file mode 100644
index 0000000..bdda380
--- /dev/null
+++ b/testdata/TestBuildSpec_BuildEnabled.compose.yml
@@ -0,0 +1,27 @@
+# Adapted from: https://github.com/ente-io/ente/blob/main/server/compose.yaml
+services:
+  museum:
+    build:
+      context: .
+      args:
+        GIT_COMMIT: development-cluster
+      dockerfile: path/Dockerfile
+    ports:
+      - 8080:8080 # API
+      - 2112:2112 # Prometheus metrics
+    environment:
+      # Pass-in the config to connect to the DB and MinIO
+      ENTE_CREDENTIALS_FILE: /credentials.yaml
+    volumes:
+      - custom-logs:/var/logs
+      - ./museum.yaml:/museum.yaml:ro
+      - ./scripts/compose/credentials.yaml:/credentials.yaml:ro
+      - ./data:/data:ro
+    networks:
+      - internal
+
+volumes:
+  custom-logs:
+
+networks:
+  internal:
diff --git a/testdata/TestBuildSpec_BuildEnabled.docker.nix b/testdata/TestBuildSpec_BuildEnabled.docker.nix
new file mode 100644
index 0000000..7307b61
--- /dev/null
+++ b/testdata/TestBuildSpec_BuildEnabled.docker.nix
@@ -0,0 +1,108 @@
+{ pkgs, lib, ... }:
+
+{
+  # Runtime
+  virtualisation.docker = {
+    enable = true;
+    autoPrune.enable = true;
+  };
+  virtualisation.oci-containers.backend = "docker";
+
+  # Containers
+  virtualisation.oci-containers.containers."test-museum" = {
+    image = "";
+    environment = {
+      "ENTE_CREDENTIALS_FILE" = "/credentials.yaml";
+    };
+    volumes = [
+      "/some/path/data:/data:ro"
+      "/some/path/museum.yaml:/museum.yaml:ro"
+      "/some/path/scripts/compose/credentials.yaml:/credentials.yaml:ro"
+      "test_custom-logs:/var/logs:rw"
+    ];
+    ports = [
+      "8080:8080/tcp"
+      "2112:2112/tcp"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=museum"
+      "--network=test_internal"
+    ];
+  };
+  systemd.services."docker-test-museum" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 90 "no";
+    };
+    after = [
+      "docker-network-test_internal.service"
+      "docker-volume-test_custom-logs.service"
+      "podman-build-museum.service"
+    ];
+    requires = [
+      "docker-network-test_internal.service"
+      "docker-volume-test_custom-logs.service"
+      "podman-build-museum.service"
+    ];
+    upheldBy = [
+      "podman-build-museum.service"
+    ];
+  };
+
+  # Networks
+  systemd.services."docker-network-test_internal" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "docker network rm -f test_internal";
+    };
+    script = ''
+      docker network inspect test_internal || docker network create test_internal
+    '';
+    partOf = [ "docker-compose-test-root.target" ];
+    wantedBy = [ "docker-compose-test-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."docker-volume-test_custom-logs" = {
+    path = [ pkgs.docker ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      docker volume inspect test_custom-logs || docker volume create test_custom-logs
+    '';
+    partOf = [ "docker-compose-test-root.target" ];
+    wantedBy = [ "docker-compose-test-root.target" ];
+  };
+
+  # Builds
+  #
+  # NOTE: These must be run manually before running any containers that require
+  # them to be present in the image store.
+  systemd.services."docker-build-museum" = {
+    path = [ pkgs.docker pkgs.git ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      cd /some/path
+      docker build --build-arg GIT_COMMIT=development-cluster -f path/Dockerfile .
+    '';
+    partOf = [ "docker-compose-test-root.target" ];
+    wantedBy = [ "docker-compose-test-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-test-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}
diff --git a/testdata/TestBuildSpec_BuildEnabled.podman.nix b/testdata/TestBuildSpec_BuildEnabled.podman.nix
new file mode 100644
index 0000000..a6e1292
--- /dev/null
+++ b/testdata/TestBuildSpec_BuildEnabled.podman.nix
@@ -0,0 +1,118 @@
+{ 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;
+    };
+  };
+
+  # Enable container name DNS for non-default Podman networks.
+  # https://github.com/NixOS/nixpkgs/issues/226365
+  networking.firewall.interfaces."podman+".allowedUDPPorts = [ 53 ];
+
+  virtualisation.oci-containers.backend = "podman";
+
+  # Containers
+  virtualisation.oci-containers.containers."test-museum" = {
+    image = "";
+    environment = {
+      "ENTE_CREDENTIALS_FILE" = "/credentials.yaml";
+    };
+    volumes = [
+      "/some/path/data:/data:ro"
+      "/some/path/museum.yaml:/museum.yaml:ro"
+      "/some/path/scripts/compose/credentials.yaml:/credentials.yaml:ro"
+      "test_custom-logs:/var/logs:rw"
+    ];
+    ports = [
+      "8080:8080/tcp"
+      "2112:2112/tcp"
+    ];
+    log-driver = "journald";
+    autoStart = false;
+    extraOptions = [
+      "--network-alias=museum"
+      "--network=test_internal"
+    ];
+  };
+  systemd.services."podman-test-museum" = {
+    serviceConfig = {
+      Restart = lib.mkOverride 90 "no";
+    };
+    after = [
+      "podman-build-museum.service"
+      "podman-network-test_internal.service"
+      "podman-volume-test_custom-logs.service"
+    ];
+    requires = [
+      "podman-build-museum.service"
+      "podman-network-test_internal.service"
+      "podman-volume-test_custom-logs.service"
+    ];
+    upheldBy = [
+      "podman-build-museum.service"
+    ];
+  };
+
+  # Networks
+  systemd.services."podman-network-test_internal" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+      ExecStop = "podman network rm -f test_internal";
+    };
+    script = ''
+      podman network inspect test_internal || podman network create test_internal
+    '';
+    partOf = [ "podman-compose-test-root.target" ];
+    wantedBy = [ "podman-compose-test-root.target" ];
+  };
+
+  # Volumes
+  systemd.services."podman-volume-test_custom-logs" = {
+    path = [ pkgs.podman ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      podman volume inspect test_custom-logs || podman volume create test_custom-logs
+    '';
+    partOf = [ "podman-compose-test-root.target" ];
+    wantedBy = [ "podman-compose-test-root.target" ];
+  };
+
+  # Builds
+  #
+  # NOTE: These must be run manually before running any containers that require
+  # them to be present in the image store.
+  systemd.services."podman-build-museum" = {
+    path = [ pkgs.podman pkgs.git ];
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+    script = ''
+      cd /some/path
+      podman build --build-arg GIT_COMMIT=development-cluster -f path/Dockerfile .
+    '';
+    partOf = [ "podman-compose-test-root.target" ];
+    wantedBy = [ "podman-compose-test-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-test-root" = {
+    unitConfig = {
+      Description = "Root target generated by compose2nix.";
+    };
+  };
+}