diff --git a/compose.go b/compose.go index fcfdd16..280f9a2 100644 --- a/compose.go +++ b/compose.go @@ -129,6 +129,7 @@ type Generator struct { GetWorkingDir getWorkingDir serviceToContainerName map[string]string + rootPath string } func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) { @@ -136,6 +137,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 +187,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,6 +205,7 @@ 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, @@ -358,11 +361,7 @@ func (g *Generator) buildNixContainer(service types.ServiceConfig, networkMap ma // 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) + sourcePath = path.Join(g.rootPath, sourcePath) } if g.CheckBindMounts { @@ -732,8 +731,25 @@ 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) (*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) + } + return &NixBuild{ + Runtime: g.Runtime, + Context: cx, + Args: service.Build.Args, + Tags: service.Build.Tags, + Dockerfile: service.Build.Dockerfile, + ServiceName: service.Name, + }, 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 +757,25 @@ 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) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse build for service %q: %w", s.Name, err) + } + builds = append(builds, b) + } } 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/nix.go b/nix.go index 8eb36de..231a556 100644 --- a/nix.go +++ b/nix.go @@ -196,11 +196,43 @@ 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) Command() string { + cmd := fmt.Sprintf("%[1]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 != "" { + 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 diff --git a/nix_test.go b/nix_test.go index fe12f6a..55e75f0 100644 --- a/nix_test.go +++ b/nix_test.go @@ -406,3 +406,13 @@ 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) +} diff --git a/templates/build.nix.tmpl b/templates/build.nix.tmpl new file mode 100644 index 0000000..5f03530 --- /dev/null +++ b/templates/build.nix.tmpl @@ -0,0 +1,11 @@ +systemd.services."{{.Runtime}}-build-{{.ServiceName}}" = { + {{- /* TODO: Support Git repo as a build source. */}} + path = [ pkgs.{{.Runtime}} pkgs.git ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + cd {{.Context}} + {{escapeIndentedNixString .Command}} + ''; +}; \ 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 diff --git a/testdata/TestBuildSpec.compose.yml b/testdata/TestBuildSpec.compose.yml new file mode 100644 index 0000000..cb0710c --- /dev/null +++ b/testdata/TestBuildSpec.compose.yml @@ -0,0 +1,26 @@ +# Adapted from: https://github.com/ente-io/ente/blob/main/server/compose.yaml +services: + museum: + build: + context: . + args: + GIT_COMMIT: development-cluster + 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..01c4b6c --- /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 --build-arg GIT_COMMIT=development-cluster -f Dockerfile . + ''; + }; + + # 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..bd28754 --- /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 --build-arg GIT_COMMIT=development-cluster -f Dockerfile . + ''; + }; + + # 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."; + }; + }; +}