WIP: test refactoring

This commit is contained in:
Jaka Hudoklin 2020-04-05 21:25:34 +07:00 committed by David Arnold
parent 8ad3b90a52
commit bbc5e3d477
No known key found for this signature in database
GPG key ID: 6D6A936E69C59D08
17 changed files with 714 additions and 66 deletions

View file

@ -4,12 +4,12 @@ with lib;
{ {
copyDockerImages = { images, dest, args ? "" }: copyDockerImages = { images, dest, args ? "" }:
pkgs.writeScriptBin "copy-docker-images" (concatMapStrings (image: '' pkgs.writeScript "copy-docker-images.sh" (concatMapStrings (image: ''
#!${pkgs.bash}/bin/bash #!${pkgs.runtimeShell}
set -e set -e
echo "copying ${image.imageName}:${image.imageTag}" echo "copying '${image.imageName}:${image.imageTag}' to '${dest}/${image.imageName}:${image.imageTag}'"
${pkgs.skopeo}/bin/skopeo copy ${args} $@ docker-archive:${image} ${dest}/${image.imageName}:${image.imageTag} ${pkgs.skopeo}/bin/skopeo copy ${args} $@ docker-archive:${image} ${dest}/${image.imageName}:${image.imageTag}
'') images); '') images);
} }

View file

@ -30,6 +30,12 @@ rec {
remarshal -i ${pkgs.writeText "to-json" (builtins.toJSON config)} -if json -of yaml > $out remarshal -i ${pkgs.writeText "to-json" (builtins.toJSON config)} -if json -of yaml > $out
''); '');
toMultiDocumentYaml = name: documents: pkgs.runCommand name {
buildInputs = [ pkgs.remarshal ];
} (concatMapStringsSep "\necho --- >> $out\n" (d:
"remarshal -i ${builtins.toFile "doc" (builtins.toJSON d)} -if json -of yaml >> $out"
) documents);
toBase64 = value: toBase64 = value:
builtins.readFile builtins.readFile
(pkgs.runCommand "value-to-b64" {} "echo -n '${value}' | ${pkgs.coreutils}/bin/base64 -w0 > $out"); (pkgs.runCommand "value-to-b64" {} "echo -n '${value}' | ${pkgs.coreutils}/bin/base64 -w0 > $out");

View file

@ -5,8 +5,8 @@
submodule = ./submodule.nix; submodule = ./submodule.nix;
helm = ./helm.nix; helm = ./helm.nix;
docker = ./docker.nix; docker = ./docker.nix;
testing = ./testing.nix; testing = ./testing;
test = ./test.nix; test = ./testing/test-options.nix;
module = ./module.nix; module = ./module.nix;
legacy = ./legacy.nix; legacy = ./legacy.nix;
} }

View file

@ -207,8 +207,8 @@ in {
namespace = mkOption { namespace = mkOption {
description = "Default namespace where to deploy kubernetes resources"; description = "Default namespace where to deploy kubernetes resources";
type = types.str; type = types.nullOr types.str;
default = "default"; default = null;
}; };
resourceOrder = mkOption { resourceOrder = mkOption {
@ -308,6 +308,11 @@ in {
description = "Generated kubernetes JSON file"; description = "Generated kubernetes JSON file";
type = types.package; type = types.package;
}; };
resultYAML = mkOption {
description = "Genrated kubernetes YAML file";
type = types.package;
};
}; };
config = { config = {
@ -355,10 +360,14 @@ in {
defaults = [{ defaults = [{
default = { default = {
# set default kubernetes namespace to all resources # set default kubernetes namespace to all resources
metadata.namespace = mkDefault config.kubernetes.namespace; metadata.namespace = mkIf (config.kubernetes.namespace != null)
(mkDefault config.kubernetes.namespace);
# set project name to all resources # set project name to all resources
metadata.labels."kubenix/project-name" = config.kubenix.project; metadata.annotations = {
"kubenix/project-name" = config.kubenix.project;
"kubenix/k8s-version" = cfg.version;
};
}; };
}]; }];
}] ++ }] ++
@ -390,6 +399,9 @@ in {
}; };
kubernetes.result = kubernetes.result =
pkgs.writeText "kubenix-generated.json" (builtins.toJSON cfg.generated); pkgs.writeText "${config.kubenix.project}-generated.json" (builtins.toJSON cfg.generated);
kubernetes.resultYAML =
toMultiDocumentYaml "${config.kubenix.project}-generated.yaml" (config.kubernetes.objects);
}; };
} }

View file

@ -0,0 +1,99 @@
{ nixosPath, config, pkgs, lib, kubenix, ... }:
with lib;
let
cfg = config.testing;
testModule = {
imports = [ ./test.nix ];
# passthru testing configuration
config._module.args = {
inherit pkgs kubenix;
testing = cfg;
};
};
isTestEnabled = test:
(cfg.enabledTests == null || elem test.name cfg.enabledTests) && test.enable;
in {
imports = [
./docker.nix
./driver/kubetest.nix
./runtime/local.nix
];
options.testing = {
name = mkOption {
description = "Testing suite name";
type = types.str;
default = "default";
};
throwError = mkOption {
description = "Whether to throw error";
type = types.bool;
default = true;
};
defaults = mkOption {
description = "List of defaults to apply to tests";
type = types.listOf (types.submodule ({config, ...}: {
options = {
features = mkOption {
description = "List of features that test has to have to apply defaults";
type = types.listOf types.str;
default = [];
};
default = mkOption {
description = "Default to apply to test";
type = types.unspecified;
default = {};
};
};
}));
default = [];
};
tests = mkOption {
description = "List of test cases";
default = [];
type = types.listOf (types.coercedTo types.path (module: {
inherit module;
}) (types.submodule testModule));
apply = tests: filter isTestEnabled tests;
};
testsByName = mkOption {
description = "Tests by name";
type = types.attrsOf types.attrs;
default = listToAttrs (map (test: nameValuePair test.name test) cfg.tests);
};
enabledTests = mkOption {
description = "List of enabled tests (by default all tests are enabled)";
type = types.nullOr (types.listOf types.str);
default = null;
};
args = mkOption {
description = "Attribute set of extra args passed to tests";
type = types.attrs;
default = {};
};
success = mkOption {
description = "Whether testing was a success";
type = types.bool;
default = all (test: test.success) cfg.tests;
};
testScript = mkOption {
type = types.package;
description = "Script to run e2e tests";
};
};
}

View file

@ -0,0 +1,46 @@
{ config, lib, pkgs, ... }:
with lib;
with import ../../lib/docker.nix { inherit lib pkgs; };
let
testing = config.testing;
allImages = flatten (map (t: t.evaled.config.docker.export or []) testing.tests);
cfg = config.testing.docker;
in {
options.testing.docker = {
registryUrl = mkOption {
description = "Docker registry url";
type = types.str;
};
images = mkOption {
description = "List of images to export";
type = types.listOf types.package;
};
copyScript = mkOption {
description = "Script to copy images to registry";
type = types.package;
};
};
config.testing.docker = {
images = allImages;
copyScript = copyDockerImages {
images = cfg.images;
dest = "docker://" + cfg.registryUrl;
};
};
config.testing.defaults = [{
features = ["docker"];
default = {
docker.registry.url = cfg.registryUrl;
};
}];
}

View file

@ -0,0 +1,51 @@
{ lib, config, pkgs, ... }:
with lib;
let
testing = config.testing;
cfg = testing.driver.kubetest;
pythonEnv = pkgs.python37.withPackages (ps: with ps; [
pytest
kubetest
kubernetes
] ++ cfg.extraPackages);
toTestScript = t:
if isString t.script
then pkgs.writeText "${t.name}.py" ''
${cfg.defaultHeader}
${t.script}
''
else p.script;
tests = pkgs.linkFarm "${testing.name}-tests" (map (t: {
path = toTestScript t;
name = "${t.name}_test.py";
}) testing.tests);
testScript = pkgs.writeScript "test-${testing.name}.sh" ''
#!/usr/bin/env bash
${pythonEnv}/bin/pytest -p no:cacheprovider ${tests} $@
'';
in {
options.testing.driver.kubetest = {
defaultHeader = mkOption {
type = types.lines;
description = "Default test header";
default = ''
import pytest
'';
};
extraPackages = mkOption {
type = types.listOf types.package;
description = "Extra packages to pass to tests";
default = [];
};
};
config.testing.testScript = testScript;
}

View file

@ -0,0 +1,28 @@
{ lib, config, ... }:
with lib;
let
cfg = config.kubetest;
in {
options.test.kubetest = {
enable = mkOption {
description = "Whether to use kubetest test driver";
type = types.bool;
default = cfg.testScript != "";
};
testScript = mkOption {
type = types.lines;
description = "Test script to use for kubetest";
default = "";
};
extraPackages = mkOption {
type = types.listOf types.package;
description = "List of extra packages to use for kubetest";
default = [];
};
};
}

View file

@ -0,0 +1,15 @@
{ lib, config, ... }:
with lib;
{
options.testing.kubetest = {
defaultHeader = mkOption {
description = "Default test header";
type = types.lines;
default = ''
import pytest
'';
};
};
}

View file

@ -0,0 +1,65 @@
{ lib, config, pkgs, ... }:
with lib;
let
testing = config.testing;
script = pkgs.writeScript "run-local-k8s-tests-${testing.name}.sh" ''
#!${pkgs.runtimeShell}
set -e
KUBECONFIG=''${KUBECONFIG:-~/.kube/config}
SKOPEOARGS=""
while (( "$#" )); do
case "$1" in
--kubeconfig)
KUBECONFIG=$2
shift 2
;;
--skopeo-args)
SKOPEOARGS=$2
shift 2
;;
esac
done
echo "--> copying docker images to registry"
${testing.docker.copyScript} $SKOPEOARGS
echo "--> running tests"
${testing.testScript} --kube-config=$KUBECONFIG
'';
in {
options.testing.runtime.local = {
script = mkOption {
type = types.package;
description = "Runtime script";
};
docker = {
registryUrl = mkOption {
type = types.str;
description = "Docker registry url";
};
copyScript = mkOption {
type = types.package;
description = "Script used to copy docker images";
};
};
copyImages = mkOption {
type = types.package;
description = "Script used to copy docker images";
};
registryUrl = mkOption {
type = types.str;
description = "Registry url to copy docker images";
};
};
config.testing.runtime.local.script = script;
}

View file

@ -0,0 +1,130 @@
# nixos-k8s implements nixos kubernetes testing runtime
{ nixosPath, config, pkgs, lib, kubenix, ... }:
let
testConfig = config.evaled.config;
nixosTesting = import "${nixosPath}/lib/testing.nix" {
inherit pkgs;
system = "x86_64-linux";
};
kubernetesBaseConfig = { modulesPath, config, pkgs, lib, nodes, ... }: let
master = findFirst
(node: any (role: role == "master") node.config.services.kubernetes.roles)
(throw "no master node")
(attrValues nodes);
extraHosts = ''
${master.config.networking.primaryIPAddress} etcd.${config.networking.domain}
${master.config.networking.primaryIPAddress} api.${config.networking.domain}
${concatMapStringsSep "\n"
(node: let n = node.config.networking; in "${n.primaryIPAddress} ${n.hostName}.${n.domain}")
(attrValues nodes)}
'';
in {
imports = [ "${toString modulesPath}/profiles/minimal.nix" ];
config = mkMerge [
# base configuration for master and nodes
{
boot.postBootCommands = "rm -fr /var/lib/kubernetes/secrets /tmp/shared/*";
virtualisation.memorySize = mkDefault 2048;
virtualisation.cores = mkDefault 16;
virtualisation.diskSize = mkDefault 4096;
networking = {
inherit extraHosts;
domain = "my.xzy";
nameservers = ["10.0.0.254"];
firewall = {
allowedTCPPorts = [
10250 # kubelet
];
trustedInterfaces = ["docker0" "cni0"];
extraCommands = concatMapStrings (node: ''
iptables -A INPUT -s ${node.config.networking.primaryIPAddress} -j ACCEPT
'') (attrValues nodes);
};
};
environment.systemPackages = [ pkgs.kubectl ];
environment.variables.KUBECONFIG = "/etc/kubernetes/cluster-admin.kubeconfig";
services.flannel.iface = "eth1";
services.kubernetes = {
easyCerts = true;
apiserver = {
securePort = 443;
advertiseAddress = master.config.networking.primaryIPAddress;
};
masterAddress = "${master.config.networking.hostName}.${master.config.networking.domain}";
seedDockerImages = mkIf (elem "docker" testConfig._m.features) testConfig.docker.export;
};
systemd.extraConfig = "DefaultLimitNOFILE=1048576";
}
# configuration only applied on master nodes
(mkIf (any (role: role == "master") config.services.kubernetes.roles) {
networking.firewall.allowedTCPPorts = [
443 # kubernetes apiserver
];
})
];
};
mkKubernetesSingleNodeTest = { name, testScript, extraConfiguration ? {} }:
nixosTesting.makeTest {
inherit name;
nodes.kube = { config, pkgs, nodes, ... }: {
imports = [ kubernetesBaseConfig extraConfiguration ];
services.kubernetes = {
roles = ["master" "node"];
flannel.enable = false;
kubelet = {
networkPlugin = "cni";
cni.config = [{
name = "mynet";
type = "bridge";
bridge = "cni0";
addIf = true;
ipMasq = true;
isGateway = true;
ipam = {
type = "host-local";
subnet = "10.1.0.0/16";
gateway = "10.1.0.1";
routes = [{
dst = "0.0.0.0/0";
}];
};
}];
};
};
networking.primaryIPAddress = mkForce "192.168.1.1";
};
testScript = ''
startAll;
$kube->waitUntilSucceeds("kubectl get node kube.my.xzy | grep -w Ready");
${testScript}
'';
};
in {
options = {
runtime.nixos-k8s = {
driver = mkOption {
description = "Test driver";
type = types.package;
internal = true;
};
};
runtime.nixos-k8s.driver = mkKubernetesSingleNodeTest {
inherit (config) name testScript;
};
};
}

View file

@ -0,0 +1,7 @@
{ config, ... }:
{
options.runtime.nixos-k8s = {
};
}

View file

@ -4,6 +4,7 @@ with lib;
let let
cfg = config.test; cfg = config.test;
in { in {
options.test = { options.test = {
name = mkOption { name = mkOption {
@ -38,7 +39,7 @@ in {
}; };
}); });
default = []; default = [];
example = [ { assertion = false; message = "you can't enable this for that reason"; } ]; example = [ { assertion = false; message = "you can't enable this for some reason"; } ];
description = '' description = ''
This option allows modules to express conditions that must This option allows modules to express conditions that must
hold for the evaluation of the system configuration to hold for the evaluation of the system configuration to
@ -46,10 +47,9 @@ in {
''; '';
}; };
extraCheckInputs = mkOption { script = mkOption {
description = "Extra check inputs"; description = "Test script to use for e2e test";
type = types.listOf types.package; type = types.nullOr (types.either types.lines types.path);
default = [];
}; };
testScript = mkOption { testScript = mkOption {
@ -64,10 +64,9 @@ in {
default = null; default = null;
}; };
extraConfiguration = mkOption { driver = mkOption {
description = "Extra configuration for running test"; description = "Name of the driver to use for testing";
type = types.unspecified; type = types.str;
default = {};
}; };
}; };
} }

134
modules/testing/test.nix Normal file
View file

@ -0,0 +1,134 @@
{ lib, config, testing, kubenix, ... }:
with lib;
let
modules = [
# testing module
config.module
./test-options.nix
../base.nix
# passthru some options to test
{
config = {
kubenix.project = mkDefault config.name;
_module.args = {
inherit kubenix;
test = config;
} // testing.args;
};
}
];
# eval without checking
evaled' = kubenix.evalModules {
check = false;
inherit modules;
};
# test configuration
testConfig = evaled'.config.test;
# test features
testFeatures = evaled'.config._m.features;
# defaults that can be applied on tests
defaults =
filter (d:
(intersectLists d.features testFeatures) == d.features ||
(length d.features) == 0
) testing.defaults;
# add default modules to all modules
modulesWithDefaults = modules ++ (map (d: d.default) defaults);
# evaled test
evaled = let
evaled' = kubenix.evalModules {
modules = modulesWithDefaults;
};
in
if testing.throwError then evaled'
else if (builtins.tryEval evaled'.config.test.assertions).success
then evaled' else null;
in {
imports = [
./driver/kubetest.nix
];
options = {
name = mkOption {
description = "test name";
type = types.str;
internal = true;
};
description = mkOption {
description = "test description";
type = types.str;
internal = true;
};
enable = mkOption {
description = "Whether to enable test";
type = types.bool;
internal = true;
};
module = mkOption {
description = "Module defining kubenix test";
type = types.unspecified;
};
evaled = mkOption {
description = "Test evaulation result";
type = types.nullOr types.attrs;
internal = true;
};
success = mkOption {
description = "Whether test assertions were successfull";
type = types.bool;
internal = true;
default = false;
};
assertions = mkOption {
description = "Test result";
type = types.unspecified;
internal = true;
default = [];
};
script = mkOption {
description = "Test script to use for e2e test";
type = types.nullOr (types.either types.lines types.path);
internal = true;
};
driver = mkOption {
description = "Name of the driver to use for testing";
type = types.str;
internal = true;
};
};
config = mkMerge [
{
inherit evaled;
inherit (testConfig) name description enable driver;
}
# if test is evaled check assertions
(mkIf (config.evaled != null) {
inherit (evaled.config.test) assertions;
# if all assertions are true, test is successfull
success = all (el: el.assertion) config.assertions;
script = evaled.config.test.script;
})
];
}

View file

@ -1,48 +1,60 @@
{ pkgs ? import <nixpkgs> {} { pkgs ? import <nixpkgs> {}
, lib ? pkgs.lib , lib ? pkgs.lib
, kubenix ? import ../. { inherit pkgs lib; } , kubenix ? import ../. { inherit pkgs lib; }
, k8sVersion ? "1.21"
, nixosPath ? toString <nixpkgs/nixos> , nixosPath ? toString <nixpkgs/nixos>
# whether any testing error should throw an error , k8sVersion ? "1.18"
, throwError ? true , registryUrl ? throw "Registry url not defined"
, e2e ? true }: , throwError ? true # whether any testing error should throw an error
, enabledTests ? null }:
with lib; with lib;
let let
images = pkgs.callPackage ./images.nix {}; images = pkgs.callPackage ./images.nix {};
test = (kubenix.evalModules { config = (kubenix.evalModules {
modules = [ modules = [
kubenix.modules.testing kubenix.modules.testing
{ {
testing.name = "k8s-${k8sVersion}"; testing = {
testing.throwError = throwError; name = "kubenix-${k8sVersion}";
testing.e2e = e2e; throwError = throwError;
testing.tests = [ enabledTests = enabledTests;
./k8s/simple.nix tests = [
./k8s/deployment.nix ./k8s/simple.nix
./k8s/deployment-k3s.nix ./k8s/deployment.nix
# ./k8s/crd.nix # flaky ./k8s/deployment-k3s.nix
./k8s/defaults.nix # ./k8s/crd.nix # flaky
./k8s/order.nix ./k8s/defaults.nix
./k8s/submodule.nix ./k8s/order.nix
./k8s/imports.nix ./k8s/submodule.nix
# ./legacy/k8s.nix ./k8s/imports.nix
# ./legacy/crd.nix #./legacy/k8s.nix
# ./legacy/modules.nix #./legacy/crd.nix
./helm/simple.nix #./legacy/modules.nix
# ./istio/bookinfo.nix # infinite recusion ./helm/simple.nix
./submodules/simple.nix # ./istio/bookinfo.nix # infinite recursion
./submodules/defaults.nix ./submodules/simple.nix
./submodules/versioning.nix ./submodules/defaults.nix
./submodules/exports.nix ./submodules/versioning.nix
./submodules/passthru.nix ./submodules/exports.nix
]; ./submodules/passthru.nix
testing.args = { ];
inherit images k8sVersion; args = {
inherit images;
};
docker.registryUrl = registryUrl;
defaults = [
{
features = ["k8s"];
default = {
kubernetes.version = k8sVersion;
};
}
];
}; };
} }
]; ];
@ -53,4 +65,4 @@ let
inherit kubenix nixosPath; inherit kubenix nixosPath;
}; };
}).config; }).config;
in pkgs.recurseIntoAttrs test.testing in pkgs.recurseIntoAttrs config.testing

View file

@ -3,6 +3,13 @@
with lib; with lib;
{ {
curl = dockerTools.buildLayeredImage {
name = "curl";
tag = "latest";
config.Cmd = [ "${pkgs.bash}" "-c" "sleep infinity" ];
contents = [ pkgs.bash pkgs.curl pkgs.cacert ];
};
nginx = let nginx = let
nginxPort = "80"; nginxPort = "80";
nginxConf = pkgs.writeText "nginx.conf" '' nginxConf = pkgs.writeText "nginx.conf" ''

View file

@ -1,10 +1,26 @@
{ config, lib, pkgs, kubenix, images, k8sVersion, ... }: { config, lib, pkgs, kubenix, images, ... }:
with lib; with lib;
let let
cfg = config.kubernetes.api.resources.deployments.nginx; cfg = config.kubernetes.api.resources.deployments.nginx;
image = images.nginx; image = images.nginx;
clientPod = builtins.toFile "client.json" (builtins.toJSON {
apiVersion = "v1";
kind = "Pod";
metadata = {
namespace = config.kubernetes.namespace;
name = "curl";
};
spec.containers = [{
name = "curl";
image = config.docker.images.curl.path;
args = ["curl" "--retry" "20" "--retry-connrefused" "http://nginx"];
}];
spec.restartPolicy = "Never";
});
in { in {
imports = [ kubenix.modules.test kubenix.modules.k8s kubenix.modules.docker ]; imports = [ kubenix.modules.test kubenix.modules.k8s kubenix.modules.docker ];
@ -24,34 +40,55 @@ in {
assertion = cfg.kind == "Deployment"; assertion = cfg.kind == "Deployment";
} { } {
message = "should have replicas set"; message = "should have replicas set";
assertion = cfg.spec.replicas == 10; assertion = cfg.spec.replicas == 3;
}]; }];
extraConfiguration = { driver = "kubetest";
environment.systemPackages = [ pkgs.curl ]; script = ''
services.kubernetes.kubelet.seedDockerImages = config.docker.export; import time
};
testScript = ''
kube.wait_until_succeeds("kubectl apply -f ${config.kubernetes.result}")
kube.succeed("kubectl get deployment | grep -i nginx") @pytest.mark.applymanifest('${config.kubernetes.resultYAML}')
kube.wait_until_succeeds("kubectl get deployment -o go-template nginx --template={{.status.readyReplicas}} | grep 10") def test_deployment(kube):
kube.wait_until_succeeds("curl http://nginx.default.svc.cluster.local | grep -i hello") """Tests whether deployment gets successfully created"""
kube.wait_for_registered(timeout=30)
deployments = kube.get_deployments()
nginx_deploy = deployments.get('nginx')
assert nginx_deploy is not None
pods = nginx_deploy.get_pods()
assert len(pods) == 3
client_pod = kube.load_pod('${clientPod}')
client_pod.create()
client_pod.wait_until_ready(timeout=30)
client_pod.wait_until_containers_start()
container = client_pod.get_container('curl')
time.sleep(5)
logs = container.get_logs()
assert "Hello from NGINX" in logs
''; '';
}; };
docker.images.nginx.image = image; docker.images = {
nginx.image = image;
kubernetes.version = k8sVersion; curl.image = images.curl;
};
kubernetes.resources.deployments.nginx = { kubernetes.resources.deployments.nginx = {
spec = { spec = {
replicas = 10; replicas = 3;
selector.matchLabels.app = "nginx"; selector.matchLabels.app = "nginx";
template.metadata.labels.app = "nginx"; template.metadata.labels.app = "nginx";
template.spec = { template.spec = {
containers.nginx = { containers.nginx = {
image = config.docker.images.nginx.path; image = config.docker.images.nginx.path;
imagePullPolicy = "Never"; imagePullPolicy = "IfNotPresent";
}; };
}; };
}; };