Merge pull request 'mic92' (#74) from mic92 into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/74
This commit is contained in:
Mic92
2023-08-03 11:35:23 +00:00
13 changed files with 230 additions and 210 deletions

49
flake.lock generated
View File

@@ -95,54 +95,6 @@
"type": "github" "type": "github"
} }
}, },
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"nix-unit",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nix-unit": {
"inputs": {
"flake-parts": [
"flake-parts"
],
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": [
"treefmt-nix"
]
},
"locked": {
"lastModified": 1690289081,
"narHash": "sha256-PCXQAQt8+i2pkUym9P1JY4JGoeZJLzzxWBhprHDdItM=",
"owner": "adisbladis",
"repo": "nix-unit",
"rev": "a9d6f33e50d4dcd9cfc0c92253340437bbae282b",
"type": "github"
},
"original": {
"owner": "adisbladis",
"repo": "nix-unit",
"type": "github"
}
},
"nixlib": { "nixlib": {
"locked": { "locked": {
"lastModified": 1689469483, "lastModified": 1689469483,
@@ -253,7 +205,6 @@
"inputs": { "inputs": {
"disko": "disko", "disko": "disko",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nix-unit": "nix-unit",
"nixos-generators": "nixos-generators", "nixos-generators": "nixos-generators",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pre-commit-hooks-nix": "pre-commit-hooks-nix", "pre-commit-hooks-nix": "pre-commit-hooks-nix",

View File

@@ -12,10 +12,6 @@
treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix"; pre-commit-hooks-nix.url = "github:cachix/pre-commit-hooks.nix";
nix-unit.url = "github:adisbladis/nix-unit";
nix-unit.inputs.flake-parts.follows = "flake-parts";
nix-unit.inputs.nixpkgs.follows = "nixpkgs";
nix-unit.inputs.treefmt-nix.follows = "treefmt-nix";
}; };
outputs = inputs @ { flake-parts, ... }: outputs = inputs @ { flake-parts, ... }:
@@ -35,6 +31,7 @@
./templates/flake-module.nix ./templates/flake-module.nix
./templates/python-project/flake-module.nix ./templates/python-project/flake-module.nix
./pkgs/clan-cli/flake-module.nix ./pkgs/clan-cli/flake-module.nix
./pkgs/nix-unit/flake-module.nix
./lib/flake-module.nix ./lib/flake-module.nix
]; ];
}); });

View File

@@ -1,8 +1,10 @@
import argparse import argparse
import subprocess
import sys import sys
from . import admin, config, secrets, ssh from . import admin, config, secrets, ssh
from .errors import ClanError from .errors import ClanError
from .tty import warn
has_argcomplete = True has_argcomplete = True
try: try:
@@ -20,7 +22,10 @@ def main() -> None:
admin.register_parser(parser_admin) admin.register_parser(parser_admin)
parser_config = subparsers.add_parser("config") parser_config = subparsers.add_parser("config")
config.register_parser(parser_config) try:
config.register_parser(parser_config)
except subprocess.CalledProcessError:
warn("The config command does not in the nix sandbox")
parser_ssh = subparsers.add_parser("ssh", help="ssh to a remote machine") parser_ssh = subparsers.add_parser("ssh", help="ssh to a remote machine")
ssh.register_parser(parser_ssh) ssh.register_parser(parser_ssh)

View File

@@ -4,12 +4,14 @@ import json
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Union from typing import Any, Optional, Type, Union
from clan_cli.errors import ClanError
class Kwargs: class Kwargs:
def __init__(self): def __init__(self) -> None:
self.type = None self.type: Optional[Type] = None
self.default: Any = None self.default: Any = None
self.required: bool = False self.required: bool = False
self.help: Optional[str] = None self.help: Optional[str] = None
@@ -19,7 +21,7 @@ class Kwargs:
def schema_from_module_file( def schema_from_module_file(
file: Union[str, Path] = "./tests/config/example-interface.nix", file: Union[str, Path] = "./tests/config/example-interface.nix",
) -> dict: ) -> dict[str, Any]:
absolute_path = Path(file).absolute() absolute_path = Path(file).absolute()
# define a nix expression that loads the given module file using lib.evalModules # define a nix expression that loads the given module file using lib.evalModules
nix_expr = f""" nix_expr = f"""
@@ -37,22 +39,31 @@ def schema_from_module_file(
) )
# takes a (sub)parser and configures it
def register_parser( def register_parser(
parser: Optional[argparse.ArgumentParser] = None, parser: argparse.ArgumentParser,
schema: Union[dict, str, Path] = "./tests/config/example-interface.nix", file: Path = Path("./tests/config/example-interface.nix"),
) -> dict: ) -> None:
if file.name.endswith(".nix"):
schema = schema_from_module_file(file)
else:
schema = json.loads(file.read_text())
return _register_parser(parser, schema)
# takes a (sub)parser and configures it
def _register_parser(
parser: Optional[argparse.ArgumentParser],
schema: dict[str, Any],
) -> None:
# check if schema is a .nix file and load it in that case # check if schema is a .nix file and load it in that case
if isinstance(schema, str) and schema.endswith(".nix"): if "type" not in schema:
schema = schema_from_module_file(schema) raise ClanError("Schema has no type")
elif not isinstance(schema, dict): if schema["type"] != "object":
with open(str(schema)) as f: raise ClanError("Schema is not an object")
schema: dict = json.load(f)
assert "type" in schema and schema["type"] == "object"
required_set = set(schema.get("required", [])) required_set = set(schema.get("required", []))
type_map = { type_map: dict[str, Type] = {
"array": list, "array": list,
"boolean": bool, "boolean": bool,
"integer": int, "integer": int,
@@ -60,8 +71,7 @@ def register_parser(
"string": str, "string": str,
} }
if parser is None: parser = argparse.ArgumentParser(description=schema.get("description"))
parser = argparse.ArgumentParser(description=schema.get("description"))
subparsers = parser.add_subparsers( subparsers = parser.add_subparsers(
title="more options", title="more options",
@@ -72,11 +82,12 @@ def register_parser(
for name, value in schema.get("properties", {}).items(): for name, value in schema.get("properties", {}).items():
assert isinstance(value, dict) assert isinstance(value, dict)
type_ = value.get("type")
# TODO: add support for nested objects # TODO: add support for nested objects
if value.get("type") == "object": if type_ == "object":
subparser = subparsers.add_parser(name, help=value.get("description")) subparser = subparsers.add_parser(name, help=value.get("description"))
register_parser(parser=subparser, schema=value) _register_parser(parser=subparser, schema=value)
continue continue
# elif value.get("type") == "array": # elif value.get("type") == "array":
# subparser = parser.add_subparsers(dest=name) # subparser = parser.add_subparsers(dest=name)
@@ -92,22 +103,25 @@ def register_parser(
if "enum" in value: if "enum" in value:
enum_list = value["enum"] enum_list = value["enum"]
assert len(enum_list) > 0, "Enum List is Empty" if len(enum_list) == 0:
raise ClanError("Enum List is Empty")
arg_type = type(enum_list[0]) arg_type = type(enum_list[0])
assert all( if not all(arg_type is type(item) for item in enum_list):
arg_type is type(item) for item in enum_list raise ClanError(f"Items in [{enum_list}] with Different Types")
), f"Items in [{enum_list}] with Different Types"
kwargs.type = arg_type kwargs.type = arg_type
kwargs.choices = enum_list kwargs.choices = enum_list
else: elif type_ in type_map:
kwargs.type = type_map[value.get("type")] kwargs.type = type_map[type_]
del kwargs.choices del kwargs.choices
else:
raise ClanError(f"Unsupported Type '{type_}' in schema")
name = f"--{name}" name = f"--{name}"
if kwargs.type is bool: if kwargs.type is bool:
assert not kwargs.default, "boolean have to be False in default" if kwargs.default:
raise ClanError("Boolean have to be False in default")
kwargs.default = False kwargs.default = False
kwargs.action = "store_true" kwargs.action = "store_true"
del kwargs.type del kwargs.type
@@ -117,7 +131,7 @@ def register_parser(
parser.add_argument(name, **vars(kwargs)) parser.add_argument(name, **vars(kwargs))
def main(): def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
"schema", "schema",
@@ -125,8 +139,7 @@ def main():
type=str, type=str,
) )
args = parser.parse_args(sys.argv[1:2]) args = parser.parse_args(sys.argv[1:2])
schema = args.schema register_parser(parser, args.schema)
register_parser(schema=schema, parser=parser)
parser.parse_args(sys.argv[2:]) parser.parse_args(sys.argv[2:])

View File

@@ -1,4 +1,3 @@
import json
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -40,32 +39,3 @@ def remove_object(path: Path, name: str) -> None:
raise ClanError(f"{name} not found in {path}") raise ClanError(f"{name} not found in {path}")
if not os.listdir(path): if not os.listdir(path):
os.rmdir(path) os.rmdir(path)
def add_key(path: Path, publickey: str, overwrite: bool) -> None:
path.mkdir(parents=True, exist_ok=True)
try:
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
if not overwrite:
flags |= os.O_EXCL
fd = os.open(path / "key.json", flags)
except FileExistsError:
raise ClanError(f"{path.name} already exists in {path}")
with os.fdopen(fd, "w") as f:
json.dump({"publickey": publickey, "type": "age"}, f, indent=2)
def read_key(path: Path) -> str:
with open(path / "key.json") as f:
try:
key = json.load(f)
except json.JSONDecodeError as e:
raise ClanError(f"Failed to decode {path.name}: {e}")
if key["type"] != "age":
raise ClanError(
f"{path.name} is not an age key but {key['type']}. This is not supported"
)
publickey = key.get("publickey")
if not publickey:
raise ClanError(f"{path.name} does not contain a public key")
return publickey

View File

@@ -1,7 +1,8 @@
import argparse import argparse
from . import secrets from . import secrets
from .folders import add_key, list_objects, remove_object, sops_machines_folder from .folders import list_objects, remove_object, sops_machines_folder
from .sops import add_key
from .types import ( from .types import (
machine_name_type, machine_name_type,
public_or_private_age_key_type, public_or_private_age_key_type,

View File

@@ -1,3 +1,4 @@
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -7,8 +8,9 @@ from typing import IO
from .. import tty from .. import tty
from ..dirs import user_config_dir from ..dirs import user_config_dir
from ..errors import ClanError
from ..nix import nix_shell from ..nix import nix_shell
from .folders import add_key, read_key, sops_users_folder from .folders import sops_users_folder
class SopsKey: class SopsKey:
@@ -122,3 +124,32 @@ def encrypt_file(secret_path: Path, content: IO[str], keys: list[str]) -> None:
os.remove(f.name) os.remove(f.name)
except OSError: except OSError:
pass pass
def add_key(path: Path, publickey: str, overwrite: bool) -> None:
path.mkdir(parents=True, exist_ok=True)
try:
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
if not overwrite:
flags |= os.O_EXCL
fd = os.open(path / "key.json", flags)
except FileExistsError:
raise ClanError(f"{path.name} already exists in {path}")
with os.fdopen(fd, "w") as f:
json.dump({"publickey": publickey, "type": "age"}, f, indent=2)
def read_key(path: Path) -> str:
with open(path / "key.json") as f:
try:
key = json.load(f)
except json.JSONDecodeError as e:
raise ClanError(f"Failed to decode {path.name}: {e}")
if key["type"] != "age":
raise ClanError(
f"{path.name} is not an age key but {key['type']}. This is not supported"
)
publickey = key.get("publickey")
if not publickey:
raise ClanError(f"{path.name} does not contain a public key")
return publickey

View File

@@ -1,7 +1,8 @@
import argparse import argparse
from . import secrets from . import secrets
from .folders import add_key, list_objects, remove_object, sops_users_folder from .folders import list_objects, remove_object, sops_users_folder
from .sops import add_key
from .types import ( from .types import (
VALID_SECRET_NAME, VALID_SECRET_NAME,
public_or_private_age_key_type, public_or_private_age_key_type,

View File

@@ -1,5 +1,4 @@
{ pkgs { lib
, lib
, python3 , python3
, ruff , ruff
, runCommand , runCommand
@@ -8,78 +7,75 @@
, bubblewrap , bubblewrap
, sops , sops
, age , age
, black
, nix
, mypy
, setuptools
, self , self
, argcomplete
, pytest
, pytest-cov
, pytest-subprocess
, wheel
}: }:
let let
pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml); dependencies = [ argcomplete ];
name = pyproject.project.name;
src = lib.cleanSource ./.; testDependencies = [
pytest
pytest-cov
pytest-subprocess
mypy
];
dependencies = lib.attrValues { checkPython = python3.withPackages (_ps: dependencies ++ testDependencies);
inherit (python3.pkgs)
argcomplete
;
};
devDependencies = lib.attrValues {
inherit (pkgs) ruff;
inherit (python3.pkgs)
black
mypy
pytest
pytest-cov
pytest-subprocess
setuptools
wheel
;
};
package = python3.pkgs.buildPythonPackage {
inherit name src;
format = "pyproject";
nativeBuildInputs = [
python3.pkgs.setuptools
installShellFiles
];
propagatedBuildInputs =
dependencies
++ [ ];
passthru.tests = { inherit clan-mypy clan-pytest; };
passthru.devDependencies = devDependencies;
makeWrapperArgs = [
"--set CLAN_FLAKE ${self}"
];
postInstall = ''
installShellCompletion --bash --name clan \
<(${python3.pkgs.argcomplete}/bin/register-python-argcomplete --shell bash clan)
installShellCompletion --fish --name clan.fish \
<(${python3.pkgs.argcomplete}/bin/register-python-argcomplete --shell fish clan)
'';
meta.mainProgram = "clan";
};
checkPython = python3.withPackages (_ps: devDependencies ++ dependencies);
clan-mypy = runCommand "${name}-mypy" { } ''
cp -r ${src} ./src
chmod +w -R ./src
cd src
${checkPython}/bin/mypy .
touch $out
'';
clan-pytest = runCommand "${name}-tests"
{
nativeBuildInputs = [ zerotierone bubblewrap sops age ];
} ''
cp -r ${src} ./src
chmod +w -R ./src
cd src
${checkPython}/bin/python -m pytest ./tests
touch $out
'';
in in
package python3.pkgs.buildPythonPackage {
name = "clan";
src = lib.cleanSource ./.;
format = "pyproject";
nativeBuildInputs = [
setuptools
installShellFiles
];
propagatedBuildInputs = dependencies;
passthru.tests = {
clan-mypy = runCommand "clan-mypy" { } ''
cp -r ${./.} ./src
chmod +w -R ./src
cd src
${checkPython}/bin/mypy .
touch $out
'';
clan-pytest = runCommand "clan-tests"
{
nativeBuildInputs = [ age zerotierone bubblewrap sops nix ];
} ''
cp -r ${./.} ./src
chmod +w -R ./src
cd src
${checkPython}/bin/python -m pytest ./tests
touch $out
'';
};
passthru.devDependencies = [
ruff
black
setuptools
wheel
] ++ testDependencies;
makeWrapperArgs = [
"--set CLAN_FLAKE ${self}"
];
postInstall = ''
installShellCompletion --bash --name clan \
<(${argcomplete}/bin/register-python-argcomplete --shell bash clan)
installShellCompletion --fish --name clan.fish \
<(${argcomplete}/bin/register-python-argcomplete --shell fish clan)
'';
meta.mainProgram = "clan";
}

View File

@@ -1,11 +1,11 @@
{ self, ... }: { { self, ... }: {
perSystem = { inputs', self', pkgs, ... }: { perSystem = { self', pkgs, ... }: {
devShells.clan = pkgs.callPackage ./shell.nix { devShells.clan = pkgs.callPackage ./shell.nix {
inherit self; inherit self;
inherit (self'.packages) clan; inherit (self'.packages) clan;
}; };
packages = { packages = {
clan = pkgs.callPackage ./default.nix { clan = pkgs.python3.pkgs.callPackage ./default.nix {
inherit self; inherit self;
zerotierone = self'.packages.zerotierone; zerotierone = self'.packages.zerotierone;
}; };
@@ -18,34 +18,39 @@
openssh openssh
sshpass sshpass
zbar zbar
tor; tor
age
sops;
# Override license so that we can build zerotierone without # Override license so that we can build zerotierone without
# having to re-import nixpkgs. # having to re-import nixpkgs.
zerotierone = pkgs.zerotierone.overrideAttrs (_old: { meta = { }; }); zerotierone = pkgs.zerotierone.overrideAttrs (_old: { meta = { }; });
## End optional dependencies ## End optional dependencies
}; };
# check if the `clan config` example jsonschema and data is valid checks = self'.packages.clan.tests // {
checks.clan-config-example-schema-valid = pkgs.runCommand "clan-config-example-schema-valid" { } '' # check if the `clan config` example jsonschema and data is valid
echo "Checking that example-schema.json is valid" clan-config-example-schema-valid = pkgs.runCommand "clan-config-example-schema-valid" { } ''
${pkgs.check-jsonschema}/bin/check-jsonschema \ echo "Checking that example-schema.json is valid"
--check-metaschema ${./.}/tests/config/example-schema.json ${pkgs.check-jsonschema}/bin/check-jsonschema \
--check-metaschema ${./.}/tests/config/example-schema.json
echo "Checking that example-data.json is valid according to example-schema.json" echo "Checking that example-data.json is valid according to example-schema.json"
${pkgs.check-jsonschema}/bin/check-jsonschema \ ${pkgs.check-jsonschema}/bin/check-jsonschema \
--schemafile ${./.}/tests/config/example-schema.json \ --schemafile ${./.}/tests/config/example-schema.json \
${./.}/tests/config/example-data.json ${./.}/tests/config/example-data.json
touch $out touch $out
''; '';
# check if the `clan config` nix jsonschema converter unit tests succeed # check if the `clan config` nix jsonschema converter unit tests succeed
checks.clan-config-nix-unit-tests = pkgs.runCommand "clan-edit-unit-tests" { } '' clan-config-nix-unit-tests = pkgs.runCommand "clan-edit-unit-tests" { } ''
export NIX_PATH=nixpkgs=${pkgs.path} export NIX_PATH=nixpkgs=${pkgs.path}
${inputs'.nix-unit.packages.nix-unit}/bin/nix-unit \ ${self'.packages.nix-unit}/bin/nix-unit \
${./.}/tests/config/test.nix \ ${./.}/tests/config/test.nix \
--eval-store $(realpath .) --eval-store $(realpath .)
touch $out touch $out
''; '';
};
}; };
} }

View File

@@ -15,7 +15,7 @@ in
pkgs.mkShell { pkgs.mkShell {
packages = [ packages = [
pkgs.ruff pkgs.ruff
self.inputs.nix-unit.packages.${pkgs.system}.nix-unit self.packages.${pkgs.system}.nix-unit
pythonWithDeps pythonWithDeps
]; ];
# sets up an editable install and add enty points to $PATH # sets up an editable install and add enty points to $PATH

45
pkgs/nix-unit/default.nix Normal file
View File

@@ -0,0 +1,45 @@
{ stdenv
, lib
, nixVersions
, fetchFromGitHub
, nlohmann_json
, boost
, bear
, meson
, pkg-config
, ninja
, cmake
, clang-tools
}:
stdenv.mkDerivation {
pname = "nix-unit";
version = "0.1";
src = fetchFromGitHub {
owner = "adisbladis";
repo = "nix-unit";
rev = "a9d6f33e50d4dcd9cfc0c92253340437bbae282b";
sha256 = "sha256-PCXQAQt8+i2pkUym9P1JY4JGoeZJLzzxWBhprHDdItM=";
};
buildInputs = [
nlohmann_json
nixVersions.unstable
boost
];
nativeBuildInputs = [
bear
meson
pkg-config
ninja
# nlohmann_json can be only discovered via cmake files
cmake
] ++ (lib.optional stdenv.cc.isClang [ bear clang-tools ]);
meta = {
description = "Nix unit test runner";
homepage = "https://github.com/adisbladis/nix-unit";
license = lib.licenses.gpl3;
maintainers = with lib.maintainers; [ adisbladis ];
platforms = lib.platforms.unix;
};
}

View File

@@ -0,0 +1,5 @@
{
perSystem = { pkgs, ... }: {
packages.nix-unit = pkgs.callPackage ./default.nix { };
};
}