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.

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.

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 85add5d
Show file tree
Hide file tree
Showing 13 changed files with 643 additions and 15 deletions.
68 changes: 53 additions & 15 deletions compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,19 @@ type Generator struct {
WriteHeader bool
NoWriteNixSetup bool
DefaultStopTimeout time.Duration
Build bool
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 +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
}
Expand All @@ -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,
Build: g.Build,
}, nil
}

Expand Down Expand Up @@ -358,11 +363,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 366 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L366

Added line #L366 was not covered by tests
}

if g.CheckBindMounts {
Expand Down Expand Up @@ -719,11 +720,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
Expand All @@ -732,23 +728,65 @@ 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")

Check warning on line 734 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L734

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

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
}

b := &NixBuild{
Runtime: g.Runtime,
Context: cx,
Args: service.Build.Args,
Tags: service.Build.Tags,
Dockerfile: service.Build.Dockerfile,
ServiceName: service.Name,
}

if g.Build {
// 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())
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 769 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L769

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

Check warning on line 776 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L776

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

Check warning on line 788 in compose.go

View check run for this annotation

Codecov / codecov/patch

compose.go#L787-L788

Added lines #L787 - L788 were not covered by tests
return containers, builds, nil
}

func (g *Generator) networkNameToService(name string) string {
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -113,6 +114,7 @@ func main() {
NoWriteNixSetup: !*writeNixSetup,
AutoFormat: *autoFormat,
DefaultStopTimeout: *defaultStopTimeout,
Build: *build,

Check warning on line 117 in main.go

View check run for this annotation

Codecov / codecov/patch

main.go#L117

Added line #L117 was not covered by tests
GetWorkingDir: &OsGetWd{},
}
containerConfig, err := g.Run(ctx)
Expand Down
37 changes: 37 additions & 0 deletions nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("%[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)

Check warning on line 224 in nix.go

View check run for this annotation

Codecov / codecov/patch

nix.go#L224

Added line #L224 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
WriteNixSetup bool
AutoFormat bool
AutoStart bool
Build bool
}

func (c *NixContainerConfig) String() string {
Expand Down
22 changes: 22 additions & 0 deletions nix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Build: true,
UseUpheldBy: true,
}
runSubtestsWithGenerator(t, g)
}
9 changes: 9 additions & 0 deletions systemd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
18 changes: 18 additions & 0 deletions templates/build.nix.tmpl
Original file line number Diff line number Diff line change
@@ -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.Build}}
RemainAfterExit = true;
{{- end}}
};
script = ''
cd {{.Context}}
{{escapeIndentedNixString .Command}}
'';
{{- if and cfg.Build rootTarget}}
partOf = [ "{{rootTarget}}.target" ];
wantedBy = [ "{{rootTarget}}.target" ];
{{- end}}
};
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
29 changes: 29 additions & 0 deletions testdata/TestBuildSpec.compose.yml
Original file line number Diff line number Diff line change
@@ -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:
Loading

0 comments on commit 85add5d

Please sign in to comment.