From f69d6ca3ae85f056ceb0ec0518357d2ff30d9b95 Mon Sep 17 00:00:00 2001 From: Assil Ksiksi Date: Sat, 28 Sep 2024 15:03:25 -0400 Subject: [PATCH] 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. An alternative considered is a one-shot service that automatically runs prior to the service. However, this would result in a _new_ build on every restart of the root target (or system), which would implicitly result in an update of the container. Similar to the Compose CLI, we can put this logic behind a `--build` flag, but this means that users would need to regenerate their Nix config after the build completes to disable/remove this service. For now, we do not support Git repo build contexts (i.e., pull from repo before building). --- compose.go | 47 +++++++++--- nix.go | 32 +++++++++ nix_test.go | 10 +++ templates/build.nix.tmpl | 11 +++ templates/main.nix.tmpl | 11 +++ testdata/TestBuildSpec.compose.yml | 26 +++++++ testdata/TestBuildSpec.docker.nix | 100 ++++++++++++++++++++++++++ testdata/TestBuildSpec.podman.nix | 110 +++++++++++++++++++++++++++++ 8 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 templates/build.nix.tmpl create mode 100644 testdata/TestBuildSpec.compose.yml create mode 100644 testdata/TestBuildSpec.docker.nix create mode 100644 testdata/TestBuildSpec.podman.nix 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."; + }; + }; +}