Skip to content

Commit

Permalink
feat: initial support for Compose Build spec
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
aksiksi committed Sep 28, 2024
1 parent c432239 commit f69d6ca
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 10 deletions.
47 changes: 37 additions & 10 deletions compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ type Generator struct {
GetWorkingDir getWorkingDir

serviceToContainerName map[string]string
rootPath string
}

func (g *Generator) Run(ctx context.Context) (*NixContainerConfig, error) {
rootPath, err := g.GetRootPath()
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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Check warning on line 364 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L364

Added line #L364 was not covered by tests
}

if g.CheckBindMounts {
Expand Down Expand Up @@ -732,23 +731,51 @@ 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")

Check warning on line 737 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L737

Added line #L737 was not covered by tests
}
if !path.IsAbs(cx) {
cx = path.Join(g.rootPath, cx)

Check warning on line 740 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L740

Added line #L740 was not covered by tests
}
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())
continue
}
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)

Check warning on line 760 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L760

Added line #L760 was not covered by tests
}
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)

Check warning on line 767 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L767

Added line #L767 was not covered by tests
}
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)
})

Check warning on line 777 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L776-L777

Added lines #L776 - L777 were not covered by tests
return containers, builds, nil
}

func (g *Generator) networkNameToService(name string) string {
Expand Down
32 changes: 32 additions & 0 deletions nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Check warning on line 214 in nix.go

View check run for this annotation

Codecov / codecov/patch

nix.go#L214

Added line #L214 was not covered by tests
}
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)

Check warning on line 220 in nix.go

View check run for this annotation

Codecov / codecov/patch

nix.go#L220

Added line #L220 was not covered by tests
}
}
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
Expand Down
10 changes: 10 additions & 0 deletions nix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions templates/build.nix.tmpl
Original file line number Diff line number Diff line change
@@ -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}}
'';
};
11 changes: 11 additions & 0 deletions templates/main.nix.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions testdata/TestBuildSpec.compose.yml
Original file line number Diff line number Diff line change
@@ -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:
100 changes: 100 additions & 0 deletions testdata/TestBuildSpec.docker.nix
Original file line number Diff line number Diff line change
@@ -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.";
};
};
}
Loading

0 comments on commit f69d6ca

Please sign in to comment.