Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support user-supplied Docker image archive derivations #92

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions examples/custom-image/arion-compose.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{ pkgs, ... }:

let
webRoot = "/www";

webserverImage = pkgs.dockerTools.buildLayeredImage {
name = "a-webserver";

config = {
Entrypoint = [
"${pkgs.darkhttpd}/bin/darkhttpd"
webRoot
];

Volumes = {
"${webRoot}" = { };
};
};
};
in
{
project.name = "custom-image";
services = {

webserver = {
image.tarball = webserverImage;

# The following is essentially equivalent to
#
# { image.tarball = webserverImage; }
#
# It is included here as a demonstration of how to configure Arion to
# load a Docker image from *any* valid image tarball, not just one
# produced with a `pkgs.dockerTools` builder function.
#image.tarball = webserverImage.outPath;
#image.name = webserverImage.imageName;
#image.tag = webserverImage.imageTag;

service.command = [ "--port" "8000" ];
service.ports = [
"8000:8000" # host:container
];
service.volumes = [
"${pkgs.nix.doc}/share/doc/nix/manual:${webRoot}"
];
};
};
}
6 changes: 6 additions & 0 deletions examples/custom-image/arion-pkgs.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Instead of pinning Nixpkgs, we can opt to use the one in NIX_PATH
import <nixpkgs> {
# We specify the architecture explicitly. Use a Linux remote builder when
# calling arion from other platforms.
system = "x86_64-linux";
}
44 changes: 28 additions & 16 deletions src/nix/modules/composition/images.nix
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,15 @@ let

addDetails = serviceName: service:
builtins.addErrorContext "while evaluating the image for service ${serviceName}"
(let
inherit (service) build;
in {
imageName = build.imageName or service.image.name;
imageTag =
if build.image.imageTag != ""
then build.image.imageTag
else lib.head (lib.strings.splitString "-" (baseNameOf build.image.outPath));
} // (if build.image.isExe or false
then {
imageExe = build.image.outPath;
(
let
imageAttrName = "image${lib.optionalString (service.image.tarball.isExe or false) "Exe"}";
in
{
inherit (service.image.tarball) imageName imageTag;
${imageAttrName} = service.image.tarball.outPath;
}
else {
image = build.image.outPath;
}
)
);
);
in
{
options = {
Expand All @@ -40,6 +32,26 @@ in
};
};
config = {
assertions =
let
assertionsForRepoTagComponent = component: attrName:
lib.mapAttrsToList
(name: value: {
assertion = lib.types.nonEmptyStr.check value.${attrName};
message = lib.replaceStrings [ "\n" ] [ " " ] ''
Unable to infer the ${component} of the image associated with
config.services.${name}. Please set
config.services.${name}.image.${attrName} to a non-empty
string.
'';
})
serviceImages;

nameAssertions = assertionsForRepoTagComponent "name" "imageName";
tagAssertions = assertionsForRepoTagComponent "tag" "imageTag";
in
nameAssertions ++ tagAssertions;

build.imagesToLoad = lib.attrValues serviceImages;
docker-compose.extended.images = config.build.imagesToLoad;
};
Expand Down
176 changes: 146 additions & 30 deletions src/nix/modules/service/image.nix
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{ pkgs, lib, config, options, ... }:
let
inherit (lib)
all
flip
functionArgs
hasAttr
isDerivation
mkOption
optionalAttrs
types
Expand All @@ -10,14 +14,91 @@ let
inherit (pkgs)
dockerTools
;
inherit (types) attrsOf listOf nullOr package str unspecified bool;
inherit (types)
addCheck
attrs
attrsOf
bool
coercedTo
listOf
nonEmptyStr
nullOr
oneOf
package
path
str
unspecified
;

# TODO: dummy-config is a useless layer. Nix 2.3 will let us inspect
# the string context instead, so we can avoid this.
contentsList = config.image.contents ++ [
(pkgs.writeText "dummy-config.json" (builtins.toJSON config.image.rawConfig))
];

# Neither image names nor tags can can be empty strings; setting either to an
# empty string will cause `docker load` to croak with the error message
# "invalid reference format".
fallbackImageRepoTagComponent = component: fallback:
if nonEmptyStr.check component
then component
else fallback;
fallbackImageName = fallbackImageRepoTagComponent config.image.name;
fallbackImageTag = fallbackImageRepoTagComponent config.image.tag;

# Shim for `services.<name>.image.tarball` definitions that refer to
# arbitrary paths and not `dockerTools`-produced derivations.
dummyImagePackage = outPath:
let
tarballSuffix = ".tar.gz";
repoTagSeparator = "-";
baseName = baseNameOf outPath;
baseNameNoExtension = lib.strings.removeSuffix tarballSuffix baseName;
baseNameComponents = lib.strings.splitString repoTagSeparator baseNameNoExtension;
fallbacks =
if ((lib.isStorePath outPath) && (lib.hasSuffix tarballSuffix baseName) && (lib.hasInfix repoTagSeparator baseName))
then {
imageName = lib.concatStringsSep repoTagSeparator (lib.tail baseNameComponents);
imageTag = lib.head baseNameComponents;
}
else {
imageName = null;
imageTag = null;
};
in
{
inherit outPath;
type = "derivation";
imageName = fallbackImageName fallbacks.imageName;
imageTag = fallbackImageTag fallbacks.imageTag;
};

# Type matching the essential attributes of derivations produced by
# `dockerTools` builder functions.
imagePackageType = addCheck attrs (x: isDerivation x && (all (flip hasAttr x) [ "imageName" "imageTag" ]));

# `coercedTo path dummyImagePackage package` looks sufficient, but it is not.
# `coercedTo` defines this `check` function:
#
# x: (coercedType.check x && finalType.check (coerceFunc x)) || finalType.check x;
#
# and this `merge` function:
#
# loc: defs:
# let
# coerceVal = val:
# if coercedType.check val then coerceFunc val
# else val;
# in finalType.merge loc (map (def: def // { value = coerceVal def.value; }) defs);
#
# Meaning that values that satisfy `finalType.check` may still be subject to
# coercion. In this case, derivations satisfy `path.check`, so will be
# coerced using the `dummyImagePackage` function. To avoid this unnecessary
# coercion, we instead force checking whether the value satisfies
# `imagePackageType.check` *first* via placing `imagePackageType` at the head
# of the list provided to `oneOf`.
imageTarballType = oneOf [ imagePackageType (coercedTo path dummyImagePackage imagePackageType) ];

includeStorePathsWarningAndDefault = lib.warn ''
You're using a version of Nixpkgs that doesn't support the includeStorePaths
parameter in dockerTools.streamLayeredImage. Without this, Arion's
Expand Down Expand Up @@ -47,10 +128,11 @@ let

builtImage = buildOrStreamLayeredImage {
inherit (config.image)
name
tag
contents
includeStorePaths
;
name = fallbackImageName ("localhost/" + config.service.name);
config = config.image.rawConfig;
maxLayers = 100;

Expand All @@ -76,23 +158,6 @@ let
in
{
options = {
build.image = mkOption {
type = nullOr package;
description = ''
Docker image derivation to be `docker load`-ed.
'';
internal = true;
};
build.imageName = mkOption {
type = str;
description = "Derived from `build.image`";
internal = true;
};
build.imageTag = mkOption {
type = str;
description = "Derived from `build.image`";
internal = true;
};
image.nixBuild = mkOption {
type = bool;
description = ''
Expand All @@ -106,14 +171,42 @@ in
'';
};
image.name = mkOption {
type = str;
default = "localhost/" + config.service.name;
defaultText = lib.literalExpression or lib.literalExample ''"localhost/" + config.service.name'';
type = nullOr str;
default = null;
description = ''
A human readable name for the docker image.
A human readable name for the Docker image.

Shows up in the `docker ps` output in the
`IMAGE` column, among other places.

::: {.important}
If you set {option}`services.<name>.image.tarball` to an arbitrary
Docker image tarball (and not, say, a derivation produced by one of the
[`dockerTools`](https://nixos.org/manual/nixpkgs/stable/#sec-pkgs-dockerTools)
builder functions), then you **must** set {option}`services.<name>.image.name`
to the name of the image in the tarball. Otherwise, Arion will not be
able to properly identify the image in the generated Docker Compose
configuration file.
:::
'';
};
image.tag = mkOption {
type = nullOr str;
default = null;
description = ''
A tag to assign to the built image, or (if you specified an image archive
with `image.tarball`) the tag that arion should use when referring to
the loaded image.

::: {.important}
If you set {option}`services.<name>.image.tarball` to an arbitrary
Docker image tarball (and not, say, a derivation produced by one of the
[`dockerTools`](https://nixos.org/manual/nixpkgs/stable/#sec-pkgs-dockerTools)
builder functions), then you **must** set {option}`services.<name>.image.tag`
to one of the tags associated with `services.<name>.image.name` in the
image tarball. Otherwise, Arion will not be able to properly identify
the image in the generated Docker Compose configuration file.
:::
'';
};
image.contents = mkOption {
Expand Down Expand Up @@ -162,20 +255,43 @@ in
description = ''
'';
};
image.tarball = mkOption {
type = nullOr imageTarballType;
default = builtImage;
defaultText = "${builtins.storeDir}/image-built-from-service-configuration.tar.gz";
description = ''
Docker image tarball to be `docker load`-ed. This can be a derivation
produced with one of the [`dockerTools`](https://nixos.org/manual/nixpkgs/stable/#sec-pkgs-dockerTools)
builder functions, or a Docker image tarball at some arbitrary
location.

By default, when `services.<name>.image.nixBuild` is enabled, this is
the image produced using `services.<name>.image.command`,
`services.<name>.image.contents`, and
`services.<name>.image.rawConfig`.

::: {.note}
Using this option causes Arion to ignore most other options in the
{option}`services.<name>.image` namespace. The exceptions are
{option}`services.<name>.image.name` and {option}`services.<name>.image.tag`,
which are used when the provided {option}`services.<name>.image.tarball`
is not a derivation with the attributes `imageName` and `imageTag`.
:::
'';
example = lib.literalExpression ''
pkgs.dockerTools.buildLayeredImage {
# ...
};
'';
};
};
config = lib.mkMerge [{
build.image = builtImage;
build.imageName = config.build.image.imageName;
build.imageTag =
if config.build.image.imageTag != ""
then config.build.image.imageTag
else lib.head (lib.strings.splitString "-" (baseNameOf config.build.image.outPath));
image.rawConfig.Cmd = config.image.command;
image.nixBuild = lib.mkDefault (priorityIsDefault options.service.image);
}
( lib.mkIf (config.service.build.context == null)
{
service.image = lib.mkDefault "${config.build.imageName}:${config.build.imageTag}";
service.image = lib.mkDefault "${config.image.tarball.imageName}:${config.image.tarball.imageTag}";
})
];
}
13 changes: 13 additions & 0 deletions tests/arion-test/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ in
# Pre-build the image because we don't want to build the world
# in the vm.
(preEval [ ../../examples/minimal/arion-compose.nix ]).config.out.dockerComposeYaml
(preEval [ ../../examples/custom-image/arion-compose.nix ]).config.out.dockerComposeYaml
(preEval [ ../../examples/full-nixos/arion-compose.nix ]).config.out.dockerComposeYaml
(preEval [ ../../examples/nixos-unit/arion-compose.nix ]).config.out.dockerComposeYaml
(preEval [ ../../examples/traefik/arion-compose.nix ]).config.out.dockerComposeYaml
Expand Down Expand Up @@ -66,6 +67,18 @@ in
)
machine.wait_until_fails("curl --fail localhost:8000")

# Tests
# - examples/custom-image
with subtest("custom-image"):
machine.succeed(
"rm -rf work && cp -frT ${../../examples/custom-image} work && cd work && NIX_PATH=nixpkgs='${pkgs.path}' arion up -d"
)
machine.wait_until_succeeds("curl --fail localhost:8000")
machine.succeed(
"cd work && NIX_PATH=nixpkgs='${pkgs.path}' arion down"
)
machine.wait_until_fails("curl --fail localhost:8000")

# Tests
# - running same image again doesn't require a `docker load`
with subtest("docker load only once"):
Expand Down
4 changes: 4 additions & 0 deletions tests/flake-module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
modules = [ ../examples/minimal/arion-compose.nix ];
};

testModuleOptions = import ./module-options-arion-test {
inherit lib;
pkgs = final;
};
};
};
}
Loading