Merge branch 'main' of git.clan.lol:clan/clan-core into Qubasa-main

This commit is contained in:
Luis-Hebendanz
2023-09-19 15:30:25 +02:00
59 changed files with 1005 additions and 384 deletions

View File

@@ -8,4 +8,4 @@ jobs:
runs-on: nix runs-on: nix
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: nix run --refresh github:Mic92/nix-ci-build - run: nix run --refresh github:Mic92/nix-fast-build/ae50c356c2f9e790f3d9d8e00bfa9f4b54f49bdd

6
.gitignore vendored
View File

@@ -1,8 +1,8 @@
.direnv .direnv
result* result*
pkgs/clan-cli/clan_cli/nixpkgs /pkgs/clan-cli/clan_cli/nixpkgs
pkgs/clan-cli/clan_cli/webui/assets /pkgs/clan-cli/clan_cli/webui/assets
machines /machines
# python # python
__pycache__ __pycache__

View File

@@ -15,7 +15,7 @@
]; ];
shellHook = '' shellHook = ''
# no longer used # no longer used
rm "$(git rev-parse --show-toplevel)/.git/hooks/pre-commit" rm -f "$(git rev-parse --show-toplevel)/.git/hooks/pre-commit"
''; '';
}; };
}; };

View File

@@ -43,6 +43,7 @@ Absolutely, let's break down the migration step by step, explaining each action
```nix ```nix
inputs.clan-core = { inputs.clan-core = {
url = "git+https://git.clan.lol/clan/clan-core"; url = "git+https://git.clan.lol/clan/clan-core";
# Don't do this if your machines are on nixpkgs stable.
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
``` ```
@@ -75,7 +76,8 @@ Absolutely, let's break down the migration step by step, explaining each action
```nix ```nix
nixosConfigurations = clan-core.lib.buildClan { nixosConfigurations = clan-core.lib.buildClan {
directory = ./.; # this needs to point at the repository root
directory = self;
specialArgs = {}; specialArgs = {};
machines = { machines = {
example-desktop = { example-desktop = {

View File

@@ -86,7 +86,7 @@ $ clan secrets machines list
For existing machines, add their keys: For existing machines, add their keys:
```console ```console
$ clan secrets machine add <machine_name> <age_key> $ clan secrets machines add <machine_name> <age_key>
``` ```
To fetch an age key from an SSH host key: To fetch an age key from an SSH host key:

24
flake.lock generated
View File

@@ -7,11 +7,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1694511957, "lastModified": 1694925805,
"narHash": "sha256-teCLY68npc0nuyOHYJURLuJSOME0yotJI29WXcpF1E4=", "narHash": "sha256-UNMivSc89undITtNoy6o6bf3Dck4v75rzGiMEXAPEB0=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "be98cffef02e5ebf438ea80b34b86e669c48eff1", "rev": "9ab96378f8cf602d5f3ce5a32f2c339509288d8e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -47,11 +47,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1691024356, "lastModified": 1694873346,
"narHash": "sha256-uGLyhkwew6ORO6nAz0Y7KHdiQrDJVI2n6rl4gl7mWzk=", "narHash": "sha256-Uvh03bg0a6ZnNWiX1Gb8g+m343wSJ/wb8ryUASt0loc=",
"owner": "aakropotkin", "owner": "aakropotkin",
"repo": "floco", "repo": "floco",
"rev": "1e84b4b16bba5746e1195fa3a4d8addaaf2d9ef4", "rev": "d16bd444ab9d29a6640f52ee4e43a66528e07515",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -98,11 +98,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1694422566, "lastModified": 1694767346,
"narHash": "sha256-lHJ+A9esOz9vln/3CJG23FV6Wd2OoOFbDeEs4cMGMqc=", "narHash": "sha256-5uH27SiVFUwsTsqC5rs3kS7pBoNhtoy9QfTP9BmknGk=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3a2786eea085f040a66ecde1bc3ddc7099f6dbeb", "rev": "ace5093e36ab1e95cb9463863491bee90d5a4183",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -151,11 +151,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1693817438, "lastModified": 1694528738,
"narHash": "sha256-fg3+n4Ky1gCzDtPm0MomMTFw0YkH05Y8ojy5t7bkfHg=", "narHash": "sha256-aWMEjib5oTqEzF9f3WXffC1cwICo6v/4dYKjwNktV8k=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "b8d3a059f5487d6767d07c3716386753e3132d9f", "rev": "7a49c388d7a6b63bb551b1ddedfa4efab8f400d8",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -31,8 +31,6 @@
./formatter.nix ./formatter.nix
./templates/flake-module.nix ./templates/flake-module.nix
./flakeModules/clan-config.nix
./pkgs/flake-module.nix ./pkgs/flake-module.nix
./lib/flake-module.nix ./lib/flake-module.nix

View File

@@ -1,42 +0,0 @@
{ ... } @ clanCore: {
flake.flakeModules.clan-config = { self, inputs, ... }:
let
# take the default nixos configuration
options = self.nixosConfigurations.default.options;
# this is actually system independent as it uses toFile
docs = inputs.nixpkgs.legacyPackages.x86_64-linux.nixosOptionsDoc {
inherit options;
};
optionsJSONFile = docs.optionsJSON.options;
warnIfNoDefaultConfig = return:
if ! self ? nixosConfigurations.default
then
builtins.trace
"WARNING: .#nixosConfigurations.default could not be found. Please define it."
return
else return;
in
{
flake.clanOptions = warnIfNoDefaultConfig optionsJSONFile;
flake.clanSettings = self + /clan-settings.json;
perSystem = { pkgs, ... }: {
devShells.clan-config = pkgs.mkShell {
packages = [
clanCore.config.flake.packages.${pkgs.system}.clan-cli
];
shellHook = ''
export CLAN_OPTIONS_FILE=$(nix eval --raw .#clanOptions)
export XDG_DATA_DIRS="${clanCore.config.flake.packages.${pkgs.system}.clan-cli}/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export fish_complete_path="${clanCore.config.flake.packages.${pkgs.system}.clan-cli}/share/fish/vendor_completions.d''${fish_complete_path:+:$fish_complete_path}"
'';
};
};
};
}

View File

@@ -7,6 +7,7 @@ let
float = "number"; float = "number";
int = "integer"; int = "integer";
str = "string"; str = "string";
path = "string"; # TODO add prober path checks
}; };
# remove _module attribute from options # remove _module attribute from options
@@ -103,6 +104,13 @@ rec {
type = "string"; type = "string";
} }
# parse string
else if option.type.name == "path"
# return jsonschema property definition for path
then default // description // {
type = "string";
}
# parse enum # parse enum
else if option.type.name == "enum" else if option.type.name == "enum"
# return jsonschema property definition for enum # return jsonschema property definition for enum

View File

@@ -3,9 +3,11 @@
imports = [ imports = [
./secrets ./secrets
./zerotier.nix ./zerotier.nix
./networking.nix
inputs.sops-nix.nixosModules.sops inputs.sops-nix.nixosModules.sops
# just some example options. Can be removed later # just some example options. Can be removed later
./bloatware ./bloatware
./vm.nix
]; ];
options.clanSchema = lib.mkOption { options.clanSchema = lib.mkOption {
type = lib.types.attrs; type = lib.types.attrs;

View File

@@ -0,0 +1,15 @@
{ config, lib, ... }:
{
options.clan.networking = {
deploymentAddress = lib.mkOption {
description = ''
The target SSH node for deployment.
By default, the node's attribute name will be used.
If set to null, only local deployment will be supported.
'';
type = lib.types.nullOr lib.types.str;
default = "root@${config.networking.hostName}";
};
};
}

View File

@@ -1,6 +1,16 @@
{ config, lib, ... }: { config, lib, pkgs, ... }:
{ {
options.clanCore.secretStore = lib.mkOption {
type = lib.types.enum [ "sops" "password-store" "custom" ];
default = "sops";
description = ''
method to store secrets
custom can be used to define a custom secret store.
one would have to define system.clan.generateSecrets and system.clan.uploadSecrets
'';
};
options.clanCore.secrets = lib.mkOption { options.clanCore.secrets = lib.mkOption {
default = { };
type = lib.types.attrsOf type = lib.types.attrsOf
(lib.types.submodule (secret: { (lib.types.submodule (secret: {
options = { options = {
@@ -49,10 +59,11 @@
description = '' description = ''
path to a fact which is generated by the generator path to a fact which is generated by the generator
''; '';
default = "${config.clanCore.clanDir}/machines/${config.clanCore.machineName}/facts/${fact.config._module.args.name}"; default = "machines/${config.clanCore.machineName}/facts/${fact.config._module.args.name}";
}; };
value = lib.mkOption { value = lib.mkOption {
default = builtins.readFile fact.config.path; defaultText = lib.literalExpression "\${config.clanCore.clanDir}/\${fact.config.path}";
default = builtins.readFile "${config.clanCore.clanDir}/${fact.config.path}";
}; };
}; };
})); }));
@@ -60,7 +71,12 @@
}; };
})); }));
}; };
config.system.build.generateUploadSecrets = pkgs.writeScript "generate_upload_secrets" ''
${config.system.clan.generateSecrets}
${config.system.clan.uploadSecrets}
'';
imports = [ imports = [
./sops.nix # for now we have only one implementation, thats why we import it here and not in clanModules ./sops.nix
./password-store.nix
]; ];
} }

View File

@@ -0,0 +1,118 @@
{ config, lib, pkgs, ... }:
let
passwordstoreDir = "\${PASSWORD_STORE_DIR:-$HOME/.password-store}";
in
{
options.clan.password-store.targetDirectory = lib.mkOption {
type = lib.types.path;
default = "/etc/secrets";
description = ''
The directory where the password store is uploaded to.
'';
};
config = lib.mkIf (config.clanCore.secretStore == "password-store") {
system.clan.generateSecrets = pkgs.writeScript "generate-secrets" ''
#!/bin/sh
set -efu
test -d "$CLAN_DIR"
PATH=${lib.makeBinPath [
pkgs.pass
]}:$PATH
# TODO maybe initialize password store if it doesn't exist yet
${lib.foldlAttrs (acc: n: v: ''
${acc}
# ${n}
# if any of the secrets are missing, we regenerate all connected facts/secrets
(if ! ${lib.concatMapStringsSep " && " (x: "pass show machines/${config.clanCore.machineName}/${x.name} >/dev/null") (lib.attrValues v.secrets)}; then
facts=$(mktemp -d)
trap "rm -rf $facts" EXIT
secrets=$(mktemp -d)
trap "rm -rf $secrets" EXIT
${v.generator}
${lib.concatMapStrings (fact: ''
mkdir -p "$(dirname ${fact.path})"
cp "$facts"/${fact.name} "$CLAN_DIR"/${fact.path}
'') (lib.attrValues v.facts)}
${lib.concatMapStrings (secret: ''
cat "$secrets"/${secret.name} | pass insert -m machines/${config.clanCore.machineName}/${secret.name}
'') (lib.attrValues v.secrets)}
fi)
'') "" config.clanCore.secrets}
'';
system.clan.uploadSecrets = pkgs.writeScript "upload-secrets" ''
#!/bin/sh
set -efu
target=$1
umask 0077
PATH=${lib.makeBinPath [
pkgs.pass
pkgs.git
pkgs.findutils
pkgs.rsync
]}:$PATH:${lib.getBin pkgs.openssh}
if test -e ${passwordstoreDir}/.git; then
local_pass_info=$(
git -C ${passwordstoreDir} log -1 --format=%H machines/${config.clanCore.machineName}
# we append a hash for every symlink, otherwise we would miss updates on
# files where the symlink points to
find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type l \
-exec realpath {} + |
sort |
xargs -r -n 1 git -C ${passwordstoreDir} log -1 --format=%H
)
remote_pass_info=$(ssh "$target" -- ${lib.escapeShellArg ''
cat ${config.clan.password-store.targetDirectory}/.pass_info || :
''})
if test "$local_pass_info" = "$remote_pass_info"; then
echo secrets already match
exit 0
fi
fi
tmp_dir=$(mktemp -dt populate-pass.XXXXXXXX)
trap cleanup EXIT
cleanup() {
rm -fR "$tmp_dir"
}
find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type f -follow ! -name .gpg-id |
while read -r gpg_path; do
rel_name=''${gpg_path#${passwordstoreDir}}
rel_name=''${rel_name%.gpg}
pass_date=$(
if test -e ${passwordstoreDir}/.git; then
git -C ${passwordstoreDir} log -1 --format=%aI "$gpg_path"
fi
)
pass_name=$rel_name
tmp_path=$tmp_dir/$(basename $rel_name)
mkdir -p "$(dirname "$tmp_path")"
pass show "$pass_name" > "$tmp_path"
if [ -n "$pass_date" ]; then
touch -d "$pass_date" "$tmp_path"
fi
done
if test -n "''${local_pass_info-}"; then
echo "$local_pass_info" > "$tmp_dir"/.pass_info
fi
rsync --mkpath --delete -a "$tmp_dir"/ "$target":${config.clan.password-store.targetDirectory}/
'';
};
}

View File

@@ -21,11 +21,12 @@ let
secrets = filterDir containsMachineOrGroups secretsDir; secrets = filterDir containsMachineOrGroups secretsDir;
in in
{ {
config = { config = lib.mkIf (config.clanCore.secretStore == "sops") {
system.clan.generateSecrets = pkgs.writeScript "generate-secrets" '' system.clan.generateSecrets = pkgs.writeScript "generate-secrets" ''
#!/bin/sh #!/bin/sh
set -efu set -efu
set -x # remove for prod
test -d "$CLAN_DIR"
PATH=$PATH:${lib.makeBinPath [ PATH=$PATH:${lib.makeBinPath [
config.clanCore.clanPkgs.clan-cli config.clanCore.clanPkgs.clan-cli
@@ -55,7 +56,7 @@ in
${lib.concatMapStrings (fact: '' ${lib.concatMapStrings (fact: ''
mkdir -p "$(dirname ${fact.path})" mkdir -p "$(dirname ${fact.path})"
cp "$facts"/${fact.name} ${fact.path} cp "$facts"/${fact.name} "$CLAN_DIR"/${fact.path}
'') (lib.attrValues v.facts)} '') (lib.attrValues v.facts)}
${lib.concatMapStrings (secret: '' ${lib.concatMapStrings (secret: ''
@@ -64,6 +65,9 @@ in
fi) fi)
'') "" config.clanCore.secrets} '') "" config.clanCore.secrets}
''; '';
system.clan.uploadSecrets = pkgs.writeScript "upload-secrets" ''
echo upload is not needed for sops secret store, since the secrets are part of the flake
'';
sops.secrets = builtins.mapAttrs sops.secrets = builtins.mapAttrs
(name: _: { (name: _: {
sopsFile = config.clanCore.clanDir + "/sops/secrets/${name}/secret"; sopsFile = config.clanCore.clanDir + "/sops/secrets/${name}/secret";

View File

@@ -0,0 +1,8 @@
{ config, options, lib, ... }: {
system.clan.vm.config = {
enabled = options.virtualisation ? cores;
} // (lib.optionalAttrs (options.virtualisation ? cores) {
inherit (config.virtualisation) cores graphics;
memory_size = config.virtualisation.memorySize;
});
}

View File

@@ -1,10 +1,9 @@
import argparse import argparse
import os
import sys import sys
from types import ModuleType from types import ModuleType
from typing import Optional from typing import Optional
from . import admin, config, machines, secrets, webui, zerotier from . import config, create, machines, secrets, webui, zerotier
from .errors import ClanError from .errors import ClanError
from .ssh import cli as ssh_cli from .ssh import cli as ssh_cli
@@ -19,11 +18,9 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog=prog, description="cLAN tool") parser = argparse.ArgumentParser(prog=prog, description="cLAN tool")
subparsers = parser.add_subparsers() subparsers = parser.add_subparsers()
parser_admin = subparsers.add_parser("admin", help="administrate a clan") parser_create = subparsers.add_parser("create", help="create a clan flake")
admin.register_parser(parser_admin) create.register_parser(parser_create)
# DISABLED: this currently crashes if a flake does not define .#clanOptions
if os.environ.get("CLAN_OPTIONS_FILE") is not None:
parser_config = subparsers.add_parser("config", help="set nixos configuration") parser_config = subparsers.add_parser("config", help="set nixos configuration")
config.register_parser(parser_config) config.register_parser(parser_config)

View File

@@ -1,37 +0,0 @@
# !/usr/bin/env python3
import argparse
import os
import subprocess
def create(args: argparse.Namespace) -> None:
os.makedirs(args.folder, exist_ok=True)
# TODO create clan template in flake
subprocess.run(
[
"nix",
"flake",
"init",
"-t",
"git+https://git.clan.lol/clan/clan-core#new-clan",
]
)
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-f",
"--folder",
help="the folder where the clan is defined, default to the current folder",
default=os.environ["PWD"],
)
subparser = parser.add_subparsers(
title="command",
description="the command to run",
help="the command to run",
required=True,
)
parser_create = subparser.add_parser("create", help="create a new clan")
parser_create.set_defaults(func=create)

View File

@@ -9,6 +9,7 @@ from typing import Any, Optional, Type
from clan_cli.dirs import get_clan_flake_toplevel from clan_cli.dirs import get_clan_flake_toplevel
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.machines.folders import machine_settings_file
from clan_cli.nix import nix_eval from clan_cli.nix import nix_eval
script_dir = Path(__file__).parent script_dir = Path(__file__).parent
@@ -100,7 +101,6 @@ def options_for_machine(machine_name: str, flake: Optional[Path] = None) -> dict
proc = subprocess.run( proc = subprocess.run(
nix_eval( nix_eval(
flags=[ flags=[
"--json",
"--show-trace", "--show-trace",
"--impure", "--impure",
"--expr", "--expr",
@@ -138,7 +138,6 @@ def read_machine_option_value(machine_name: str, option: str) -> str:
proc = subprocess.run( proc = subprocess.run(
nix_eval( nix_eval(
flags=[ flags=[
"--json",
"--show-trace", "--show-trace",
"--extra-experimental-features", "--extra-experimental-features",
"nix-command flakes", "nix-command flakes",
@@ -168,7 +167,6 @@ def get_or_set_option(args: argparse.Namespace) -> None:
print(read_machine_option_value(args.machine, args.option)) print(read_machine_option_value(args.machine, args.option))
else: else:
# load options # load options
print(args.options_file)
if args.options_file is None: if args.options_file is None:
options = options_for_machine(machine_name=args.machine) options = options_for_machine(machine_name=args.machine)
else: else:
@@ -176,8 +174,8 @@ def get_or_set_option(args: argparse.Namespace) -> None:
options = json.load(f) options = json.load(f)
# compute settings json file location # compute settings json file location
if args.settings_file is None: if args.settings_file is None:
flake = get_clan_flake_toplevel() get_clan_flake_toplevel()
settings_file = flake / "machines" / f"{args.machine}.json" settings_file = machine_settings_file(args.machine)
else: else:
settings_file = args.settings_file settings_file = args.settings_file
# set the option with the given value # set the option with the given value
@@ -288,7 +286,7 @@ def register_parser(
# add single positional argument for the option (e.g. "foo.bar") # add single positional argument for the option (e.g. "foo.bar")
parser.add_argument( parser.add_argument(
"option", "option",
help="Option to configure", help="Option to read or set",
type=str, type=str,
) )

View File

@@ -12,7 +12,7 @@ from clan_cli.nix import nix_eval
def config_for_machine(machine_name: str) -> dict: def config_for_machine(machine_name: str) -> dict:
# read the config from a json file located at {flake}/machines/{machine_name}.json # read the config from a json file located at {flake}/machines/{machine_name}/settings.json
if not machine_folder(machine_name).exists(): if not machine_folder(machine_name).exists():
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
@@ -26,7 +26,7 @@ def config_for_machine(machine_name: str) -> dict:
def set_config_for_machine(machine_name: str, config: dict) -> None: def set_config_for_machine(machine_name: str, config: dict) -> None:
# write the config to a json file located at {flake}/machines/{machine_name}.json # write the config to a json file located at {flake}/machines/{machine_name}/settings.json
if not machine_folder(machine_name).exists(): if not machine_folder(machine_name).exists():
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
@@ -45,7 +45,6 @@ def schema_for_machine(machine_name: str, flake: Optional[Path] = None) -> dict:
proc = subprocess.run( proc = subprocess.run(
nix_eval( nix_eval(
flags=[ flags=[
"--json",
"--impure", "--impure",
"--show-trace", "--show-trace",
"--extra-experimental-features", "--extra-experimental-features",

View File

@@ -3,7 +3,8 @@ import subprocess
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Type, Union from typing import Any, Optional, Type, Union
from clan_cli.errors import ClanError from ..errors import ClanError
from ..nix import nix_eval
script_dir = Path(__file__).parent script_dir = Path(__file__).parent
@@ -30,11 +31,9 @@ def schema_from_module_file(
slib.parseModule {absolute_path} slib.parseModule {absolute_path}
""" """
# run the nix expression and parse the output as json # run the nix expression and parse the output as json
return json.loads( cmd = nix_eval(["--expr", nix_expr])
subprocess.check_output( proc = subprocess.run(cmd, stdout=subprocess.PIPE, check=True)
["nix", "eval", "--impure", "--json", "--expr", nix_expr] return json.loads(proc.stdout)
)
)
def subtype_from_schema(schema: dict[str, Any]) -> Type: def subtype_from_schema(schema: dict[str, Any]) -> Type:

View File

@@ -0,0 +1,25 @@
# !/usr/bin/env python3
import argparse
import subprocess
from .nix import nix_command
def create(args: argparse.Namespace) -> None:
# TODO create clan template in flake
subprocess.run(
nix_command(
[
"flake",
"init",
"-t",
"git+https://git.clan.lol/clan/clan-core#new-clan",
]
),
check=True,
)
# takes a (sub)parser and configures it
def register_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=create)

View File

@@ -1,7 +1,13 @@
import argparse import argparse
import json import json
import os
import subprocess import subprocess
from typing import Optional
from ..dirs import get_clan_flake_toplevel
from ..nix import nix_command, nix_eval
from ..secrets.generate import generate_secrets
from ..secrets.upload import upload_secrets
from ..ssh import Host, HostGroup, HostKeyCheck from ..ssh import Host, HostGroup, HostKeyCheck
@@ -13,11 +19,13 @@ def deploy_nixos(hosts: HostGroup) -> None:
def deploy(h: Host) -> None: def deploy(h: Host) -> None:
target = f"{h.user or 'root'}@{h.host}" target = f"{h.user or 'root'}@{h.host}"
ssh_arg = f"-p {h.port}" if h.port else "" ssh_arg = f"-p {h.port}" if h.port else ""
env = os.environ.copy()
env["NIX_SSHOPTS"] = ssh_arg
res = h.run_local( res = h.run_local(
["nix", "flake", "archive", "--to", f"ssh://{target}", "--json"], nix_command(["flake", "archive", "--to", f"ssh://{target}", "--json"]),
check=True, check=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
extra_env=dict(NIX_SSHOPTS=ssh_arg), extra_env=env,
) )
data = json.loads(res.stdout) data = json.loads(res.stdout)
path = data["path"] path = data["path"]
@@ -29,6 +37,9 @@ def deploy_nixos(hosts: HostGroup) -> None:
ssh_arg += " -i " + h.key if h.key else "" ssh_arg += " -i " + h.key if h.key else ""
generate_secrets(h.host)
upload_secrets(h.host)
flake_attr = h.meta.get("flake_attr", "") flake_attr = h.meta.get("flake_attr", "")
if flake_attr: if flake_attr:
flake_attr = "#" + flake_attr flake_attr = "#" + flake_attr
@@ -67,20 +78,46 @@ def deploy_nixos(hosts: HostGroup) -> None:
# FIXME: we want some kind of inventory here. # FIXME: we want some kind of inventory here.
def update(args: argparse.Namespace) -> None: def update(args: argparse.Namespace) -> None:
meta = {} clan_dir = get_clan_flake_toplevel().as_posix()
if args.flake_uri: host = json.loads(
meta["flake_uri"] = args.flake_uri subprocess.run(
if args.flake_attr: nix_eval(
meta["flake_attr"] = args.flake_attr [
deploy_nixos(HostGroup([Host(args.host, user=args.user, meta=meta)])) f'{clan_dir}#nixosConfigurations."{args.machine}".config.clan.networking.deploymentAddress'
]
),
stdout=subprocess.PIPE,
check=True,
text=True,
).stdout
)
parts = host.split("@")
user: Optional[str] = None
if len(parts) > 1:
user = parts[0]
hostname = parts[1]
else:
hostname = parts[0]
maybe_port = hostname.split(":")
port = None
if len(maybe_port) > 1:
hostname = maybe_port[0]
port = int(maybe_port[1])
print(f"deploying {host}")
deploy_nixos(
HostGroup(
[
Host(
host=hostname,
port=port,
user=user,
meta=dict(flake_attr=args.machine),
)
]
)
)
def register_update_parser(parser: argparse.ArgumentParser) -> None: def register_update_parser(parser: argparse.ArgumentParser) -> None:
# TODO pass all args we don't parse into ssh_args, currently it fails if arg starts with - parser.add_argument("machine", type=str)
parser.add_argument("--flake-uri", type=str, default=".#", help="nix flake uri")
parser.add_argument(
"--flake-attr", type=str, help="nixos configuration in the flake"
)
parser.add_argument("--user", type=str, default="root")
parser.add_argument("host", type=str)
parser.set_defaults(func=update) parser.set_defaults(func=update)

View File

@@ -1,54 +1,43 @@
import json
import os import os
import tempfile import tempfile
from pathlib import Path
from .dirs import get_clan_flake_toplevel, nixpkgs_flake, nixpkgs_source, unfree_nixpkgs from .dirs import nixpkgs_flake, nixpkgs_source, unfree_nixpkgs
def nix_build_machine( def nix_command(flags: list[str]) -> list[str]:
machine: str, attr: list[str], flake_url: Path | None = None return ["nix", "--experimental-features", "nix-command flakes"] + flags
def nix_build(
flags: list[str],
) -> list[str]: ) -> list[str]:
if flake_url is None: return (
flake_url = get_clan_flake_toplevel() nix_command(
payload = json.dumps( [
dict(
clan_flake=flake_url,
machine=machine,
attr=attr,
)
)
escaped_payload = json.dumps(payload)
return [
"nix",
"build", "build",
"--impure", "--no-link",
"--print-out-paths", "--print-out-paths",
"--expr", "--extra-experimental-features",
f"let args = builtins.fromJSON {escaped_payload}; in " "nix-command flakes",
"""
let
flake = builtins.getFlake args.clan_flake;
config = flake.nixosConfigurations.${args.machine}.extendModules {
modules = [{
clanCore.clanDir = args.clan_flake;
}];
};
in
flake.inputs.nixpkgs.lib.getAttrFromPath args.attr config
""",
] ]
)
+ flags
)
def nix_eval(flags: list[str]) -> list[str]: def nix_eval(flags: list[str]) -> list[str]:
if os.environ.get("IN_NIX_SANDBOX"): default_flags = nix_command(
with tempfile.TemporaryDirectory() as nix_store: [
return [
"nix",
"eval", "eval",
"--show-trace", "--show-trace",
"--extra-experimental-features", "--json",
"nix-command flakes", ]
)
if os.environ.get("IN_NIX_SANDBOX"):
with tempfile.TemporaryDirectory() as nix_store:
return (
default_flags
+ [
"--override-input", "--override-input",
"nixpkgs", "nixpkgs",
str(nixpkgs_source()), str(nixpkgs_source()),
@@ -56,14 +45,10 @@ def nix_eval(flags: list[str]) -> list[str]:
# error: cannot unlink '/nix/store/6xg259477c90a229xwmb53pdfkn6ig3g-default-builder.sh': Operation not permitted # error: cannot unlink '/nix/store/6xg259477c90a229xwmb53pdfkn6ig3g-default-builder.sh': Operation not permitted
"--store", "--store",
nix_store, nix_store,
] + flags ]
return [ + flags
"nix", )
"eval", return default_flags + flags
"--show-trace",
"--extra-experimental-features",
"nix-command flakes",
] + flags
def nix_shell(packages: list[str], cmd: list[str]) -> list[str]: def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
@@ -73,14 +58,13 @@ def nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
return cmd return cmd
wrapped_packages = [f"nixpkgs#{p}" for p in packages] wrapped_packages = [f"nixpkgs#{p}" for p in packages]
return ( return (
nix_command(
[ [
"nix",
"shell", "shell",
"--extra-experimental-features",
"nix-command flakes",
"--inputs-from", "--inputs-from",
f"{str(nixpkgs_flake())}", f"{str(nixpkgs_flake())}",
] ]
)
+ wrapped_packages + wrapped_packages
+ ["-c"] + ["-c"]
+ cmd + cmd
@@ -91,14 +75,13 @@ def unfree_nix_shell(packages: list[str], cmd: list[str]) -> list[str]:
if os.environ.get("IN_NIX_SANDBOX"): if os.environ.get("IN_NIX_SANDBOX"):
return cmd return cmd
return ( return (
nix_command(
[ [
"nix",
"shell", "shell",
"--extra-experimental-features",
"nix-command flakes",
"-f", "-f",
str(unfree_nixpkgs()), str(unfree_nixpkgs()),
] ]
)
+ packages + packages
+ ["-c"] + ["-c"]
+ cmd + cmd

View File

@@ -7,6 +7,7 @@ from .import_sops import register_import_sops_parser
from .key import register_key_parser from .key import register_key_parser
from .machines import register_machines_parser from .machines import register_machines_parser
from .secrets import register_secrets_parser from .secrets import register_secrets_parser
from .upload import register_upload_parser
from .users import register_users_parser from .users import register_users_parser
@@ -36,6 +37,9 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
) )
register_generate_parser(parser_generate) register_generate_parser(parser_generate)
parser_upload = subparser.add_parser("upload", help="upload secrets for machines")
register_upload_parser(parser_upload)
parser_key = subparser.add_parser("key", help="create and show age keys") parser_key = subparser.add_parser("key", help="create and show age keys")
register_key_parser(parser_key) register_key_parser(parser_key)

View File

@@ -1,24 +1,25 @@
import argparse import argparse
import os
import subprocess import subprocess
import sys import sys
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from ..dirs import get_clan_flake_toplevel
from ..nix import nix_build
def generate_secrets(machine: str) -> None:
clan_dir = get_clan_flake_toplevel().as_posix().strip()
env = os.environ.copy()
env["CLAN_DIR"] = clan_dir
def get_secret_script(machine: str) -> None:
proc = subprocess.run( proc = subprocess.run(
nix_build(
[ [
"nix", f'path:{clan_dir}#nixosConfigurations."{machine}".config.system.clan.generateSecrets'
"build", ]
"--impure", ),
"--print-out-paths",
"--expr",
"let f = builtins.getFlake (toString ./.); in "
f"(f.nixosConfigurations.{machine}.extendModules "
"{ modules = [{ clanCore.clanDir = toString ./.; }]; })"
".config.system.clan.generateSecrets",
],
check=True,
capture_output=True, capture_output=True,
text=True, text=True,
) )
@@ -30,7 +31,7 @@ def get_secret_script(machine: str) -> None:
print(secret_generator_script) print(secret_generator_script)
secret_generator = subprocess.run( secret_generator = subprocess.run(
[secret_generator_script], [secret_generator_script],
check=True, env=env,
) )
if secret_generator.returncode != 0: if secret_generator.returncode != 0:
@@ -40,7 +41,7 @@ def get_secret_script(machine: str) -> None:
def generate_command(args: argparse.Namespace) -> None: def generate_command(args: argparse.Namespace) -> None:
get_secret_script(args.machine) generate_secrets(args.machine)
def register_generate_parser(parser: argparse.ArgumentParser) -> None: def register_generate_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -0,0 +1,60 @@
import argparse
import json
import subprocess
from clan_cli.errors import ClanError
from ..dirs import get_clan_flake_toplevel
from ..nix import nix_build, nix_eval
def upload_secrets(machine: str) -> None:
clan_dir = get_clan_flake_toplevel().as_posix()
proc = subprocess.run(
nix_build(
[
f'{clan_dir}#nixosConfigurations."{machine}".config.system.clan.uploadSecrets'
]
),
stdout=subprocess.PIPE,
text=True,
check=True,
)
host = json.loads(
subprocess.run(
nix_eval(
[
f'{clan_dir}#nixosConfigurations."{machine}".config.clan.networking.deploymentAddress'
]
),
stdout=subprocess.PIPE,
text=True,
check=True,
).stdout
)
secret_upload_script = proc.stdout.strip()
secret_upload = subprocess.run(
[
secret_upload_script,
host,
],
)
if secret_upload.returncode != 0:
raise ClanError("failed to upload secrets")
else:
print("successfully uploaded secrets")
def upload_command(args: argparse.Namespace) -> None:
upload_secrets(args.machine)
def register_upload_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"machine",
help="The machine to upload secrets to",
)
parser.set_defaults(func=upload_command)

View File

@@ -1,16 +1,30 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .assets import asset_path from .assets import asset_path
from .routers import health, machines, root from .routers import health, machines, root, vms
origins = [
"http://localhost:3000",
]
def setup_app() -> FastAPI: def setup_app() -> FastAPI:
app = FastAPI() app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router) app.include_router(health.router)
app.include_router(machines.router) app.include_router(machines.router)
app.include_router(root.router) app.include_router(root.router)
app.include_router(vms.router)
app.add_exception_handler(vms.NixBuildException, vms.nix_build_exception_handler)
app.mount("/static", StaticFiles(directory=asset_path()), name="static") app.mount("/static", StaticFiles(directory=asset_path()), name="static")

View File

@@ -0,0 +1,113 @@
import asyncio
import json
import shlex
from typing import Annotated, AsyncIterator
from fastapi import APIRouter, Body, HTTPException, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse, StreamingResponse
from ...nix import nix_build, nix_eval
from ..schemas import VmConfig, VmInspectResponse
router = APIRouter()
class NixBuildException(HTTPException):
def __init__(self, msg: str, loc: list = ["body", "flake_attr"]):
detail = [
{
"loc": loc,
"msg": msg,
"type": "value_error",
}
]
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail
)
def nix_build_exception_handler(
request: Request, exc: NixBuildException
) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=jsonable_encoder(dict(detail=exc.detail)),
)
def nix_inspect_vm(machine: str, flake_url: str) -> list[str]:
return nix_eval(
[
f"{flake_url}#nixosConfigurations.{json.dumps(machine)}.config.system.clan.vm.config"
]
)
def nix_build_vm(machine: str, flake_url: str) -> list[str]:
return nix_build(
[
f"{flake_url}#nixosConfigurations.{json.dumps(machine)}.config.system.build.vm"
]
)
@router.post("/api/vms/inspect")
async def inspect_vm(
flake_url: Annotated[str, Body()], flake_attr: Annotated[str, Body()]
) -> VmInspectResponse:
cmd = nix_inspect_vm(flake_attr, flake_url=flake_url)
proc = await asyncio.create_subprocess_exec(
cmd[0],
*cmd[1:],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise NixBuildException(
f"""
Failed to evaluate vm from '{flake_url}#{flake_attr}'.
command: {shlex.join(cmd)}
exit code: {proc.returncode}
command output:
{stderr.decode("utf-8")}
"""
)
data = json.loads(stdout)
return VmInspectResponse(
config=VmConfig(flake_url=flake_url, flake_attr=flake_attr, **data)
)
async def vm_build(vm: VmConfig) -> AsyncIterator[str]:
cmd = nix_build_vm(vm.flake_attr, flake_url=vm.flake_url)
proc = await asyncio.create_subprocess_exec(
cmd[0],
*cmd[1:],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
assert proc.stdout is not None and proc.stderr is not None
async for line in proc.stdout:
yield line.decode("utf-8", "ignore")
stderr = ""
async for line in proc.stderr:
stderr += line.decode("utf-8", "ignore")
res = await proc.wait()
if res != 0:
raise NixBuildException(
f"""
Failed to build vm from '{vm.flake_url}#{vm.flake_attr}'.
command: {shlex.join(cmd)}
exit code: {res}
command output:
{stderr}
"""
)
@router.post("/api/vms/create")
async def create_vm(vm: Annotated[VmConfig, Body()]) -> StreamingResponse:
return StreamingResponse(vm_build(vm))

View File

@@ -32,3 +32,16 @@ class ConfigResponse(BaseModel):
class SchemaResponse(BaseModel): class SchemaResponse(BaseModel):
schema_: dict = Field(alias="schema") schema_: dict = Field(alias="schema")
class VmConfig(BaseModel):
flake_url: str
flake_attr: str
cores: int
memory_size: int
graphics: bool
class VmInspectResponse(BaseModel):
config: VmConfig

View File

@@ -62,10 +62,18 @@ def start_server(args: argparse.Namespace) -> None:
if ":" in host: if ":" in host:
host = f"[{host}]" host = f"[{host}]"
headers = [ headers = [
( # (
"Access-Control-Allow-Origin", # "Access-Control-Allow-Origin",
f"http://{host}:{args.dev_port}", # f"http://{host}:{args.dev_port}",
) # ),
# (
# "Access-Control-Allow-Methods",
# "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
# ),
# (
# "Allow",
# "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
# )
] ]
else: else:
open_url = f"http://[{args.host}]:{args.port}" open_url = f"http://[{args.host}]:{args.port}"

View File

@@ -22,9 +22,6 @@
, ui-assets , ui-assets
}: }:
let let
# This provides dummy options for testing clan config and prevents it from
# evaluating the flake .#
CLAN_OPTIONS_FILE = ./clan_cli/config/jsonschema/options.json;
dependencies = [ dependencies = [
argcomplete # optional dependency: if not enabled, shell completion will not work argcomplete # optional dependency: if not enabled, shell completion will not work
@@ -81,8 +78,6 @@ python3.pkgs.buildPythonPackage {
src = source; src = source;
format = "pyproject"; format = "pyproject";
inherit CLAN_OPTIONS_FILE;
nativeBuildInputs = [ nativeBuildInputs = [
setuptools setuptools
installShellFiles installShellFiles
@@ -93,12 +88,12 @@ python3.pkgs.buildPythonPackage {
{ {
nativeBuildInputs = [ age zerotierone bubblewrap sops nix openssh rsync stdenv.cc ]; nativeBuildInputs = [ age zerotierone bubblewrap sops nix openssh rsync stdenv.cc ];
} '' } ''
export CLAN_OPTIONS_FILE="${CLAN_OPTIONS_FILE}"
cp -r ${source} ./src cp -r ${source} ./src
chmod +w -R ./src chmod +w -R ./src
cd ./src cd ./src
NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 ${checkPython}/bin/python -m pytest -s ./tests export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1
${checkPython}/bin/python -m pytest -m "not impure" -s ./tests
touch $out touch $out
''; '';
passthru.clan-openapi = runCommand "clan-openapi" { } '' passthru.clan-openapi = runCommand "clan-openapi" { } ''

View File

@@ -18,6 +18,7 @@ clan_cli = [ "config/jsonschema/*", "webui/assets/**/*"]
faulthandler_timeout = 30 faulthandler_timeout = 30
addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail --workers auto --durations 5" addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail --workers auto --durations 5"
norecursedirs = "tests/helpers" norecursedirs = "tests/helpers"
markers = [ "impure" ]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"

View File

@@ -6,6 +6,7 @@ let
++ clan-cli.devDependencies ++ clan-cli.devDependencies
++ [ ++ [
ps.pip ps.pip
ps.ipdb
] ]
); );
checkScript = writeScriptBin "check" '' checkScript = writeScriptBin "check" ''
@@ -19,10 +20,9 @@ mkShell {
pythonWithDeps pythonWithDeps
]; ];
# sets up an editable install and add enty points to $PATH # sets up an editable install and add enty points to $PATH
# This provides dummy options for testing clan config and prevents it from
# evaluating the flake .#
CLAN_OPTIONS_FILE = ./clan_cli/config/jsonschema/options.json;
PYTHONPATH = "${pythonWithDeps}/${pythonWithDeps.sitePackages}"; PYTHONPATH = "${pythonWithDeps}/${pythonWithDeps.sitePackages}";
PYTHONBREAKPOINT = "ipdb.set_trace";
shellHook = '' shellHook = ''
tmp_path=$(realpath ./.direnv) tmp_path=$(realpath ./.direnv)

View File

@@ -1,23 +0,0 @@
from pathlib import Path
from typing import Iterator
import pytest
@pytest.fixture
def clan_flake(temporary_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
flake = temporary_dir / "clan-flake"
flake.mkdir()
(flake / ".clan-flake").touch()
(flake / "flake.nix").write_text(
"""
{
description = "A flake for testing clan";
inputs = {};
outputs = { self }: {};
}
"""
)
monkeypatch.chdir(flake)
monkeypatch.setenv("HOME", str(temporary_dir))
yield flake

View File

@@ -1,51 +1,16 @@
import os import os
import sys import sys
import tempfile
from pathlib import Path
from typing import Generator
import pytest
from clan_cli.dirs import nixpkgs_source
sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) sys.path.append(os.path.join(os.path.dirname(__file__), "helpers"))
pytest_plugins = [ pytest_plugins = [
"api", "api",
"temporary_dir", "temporary_dir",
"clan_flake",
"root", "root",
"age_keys", "age_keys",
"sshd", "sshd",
"command", "command",
"ports", "ports",
"host_group", "host_group",
"test_flake",
] ]
@pytest.fixture(scope="module")
def monkeymodule() -> Generator[pytest.MonkeyPatch, None, None]:
with pytest.MonkeyPatch.context() as mp:
yield mp
@pytest.fixture(scope="module")
def machine_flake(monkeymodule: pytest.MonkeyPatch) -> Generator[Path, None, None]:
template = Path(__file__).parent / "machine_flake"
# copy the template to a new temporary location
with tempfile.TemporaryDirectory() as tmpdir_:
flake = Path(tmpdir_)
for path in template.glob("**/*"):
if path.is_dir():
(flake / path.relative_to(template)).mkdir()
else:
(flake / path.relative_to(template)).write_text(path.read_text())
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
# provided by get_clan_flake_toplevel
flake_nix = flake / "flake.nix"
flake_nix.write_text(
flake_nix.read_text().replace("__NIXPKGS__", str(nixpkgs_source()))
)
# check that an empty config is returned if no json file exists
monkeymodule.chdir(flake)
yield flake

View File

@@ -4,12 +4,13 @@ import pytest
TEST_ROOT = Path(__file__).parent.resolve() TEST_ROOT = Path(__file__).parent.resolve()
PROJECT_ROOT = TEST_ROOT.parent PROJECT_ROOT = TEST_ROOT.parent
CLAN_CORE = PROJECT_ROOT.parent.parent
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def project_root() -> Path: def project_root() -> Path:
""" """
Root directory of the tests Root directory the clan-cli
""" """
return PROJECT_ROOT return PROJECT_ROOT
@@ -20,3 +21,11 @@ def test_root() -> Path:
Root directory of the tests Root directory of the tests
""" """
return TEST_ROOT return TEST_ROOT
@pytest.fixture(scope="session")
def clan_core() -> Path:
"""
Directory of the clan-core flake
"""
return CLAN_CORE

View File

@@ -1,14 +0,0 @@
from typing import Union
import pytest_subprocess.fake_process
from cli import Cli
from pytest_subprocess import utils
# using fp fixture from pytest-subprocess
def test_create(fp: pytest_subprocess.fake_process.FakeProcess) -> None:
cmd: list[Union[str, utils.Any]] = ["nix", "flake", "init", "-t", fp.any()]
fp.register(cmd)
cli = Cli()
cli.run(["admin", "--folder", "./my-clan", "create"])
assert fp.call_count(cmd) == 1

View File

@@ -0,0 +1,12 @@
from pathlib import Path
import pytest
from cli import Cli
@pytest.mark.impure
def test_template(monkeypatch: pytest.MonkeyPatch, temporary_dir: Path) -> None:
monkeypatch.chdir(temporary_dir)
cli = Cli()
cli.run(["create"])
assert (temporary_dir / ".clan-flake").exists()

View File

@@ -50,7 +50,7 @@ def test_set_some_option(
def test_configure_machine( def test_configure_machine(
machine_flake: Path, test_flake: Path,
temporary_dir: Path, temporary_dir: Path,
capsys: pytest.CaptureFixture, capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,

View File

@@ -0,0 +1,53 @@
import fileinput
import shutil
import tempfile
from pathlib import Path
from typing import Iterator
import pytest
from root import CLAN_CORE
from clan_cli.dirs import nixpkgs_source
@pytest.fixture(scope="module")
def monkeymodule() -> Iterator[pytest.MonkeyPatch]:
with pytest.MonkeyPatch.context() as mp:
yield mp
def create_flake(
monkeymodule: pytest.MonkeyPatch, name: str, clan_core_flake: Path | None = None
) -> Iterator[Path]:
template = Path(__file__).parent / name
# copy the template to a new temporary location
with tempfile.TemporaryDirectory() as tmpdir_:
home = Path(tmpdir_)
flake = home / name
shutil.copytree(template, flake)
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
# provided by get_test_flake_toplevel
flake_nix = flake / "flake.nix"
for line in fileinput.input(flake_nix, inplace=True):
line = line.replace("__NIXPKGS__", str(nixpkgs_source()))
if clan_core_flake:
line = line.replace("__CLAN_CORE__", str(clan_core_flake))
print(line)
# check that an empty config is returned if no json file exists
monkeymodule.chdir(flake)
monkeymodule.setenv("HOME", str(home))
yield flake
@pytest.fixture(scope="module")
def test_flake(monkeymodule: pytest.MonkeyPatch) -> Iterator[Path]:
yield from create_flake(monkeymodule, "test_flake")
@pytest.fixture(scope="module")
def test_flake_with_core(monkeymodule: pytest.MonkeyPatch) -> Iterator[Path]:
if not (CLAN_CORE / "flake.nix").exists():
raise Exception(
"clan-core flake not found. This test requires the clan-core flake to be present"
)
yield from create_flake(monkeymodule, "test_flake_with_core", CLAN_CORE)

View File

@@ -1,15 +1,13 @@
{ {
inputs = {
# this placeholder is replaced by the path to nixpkgs # this placeholder is replaced by the path to nixpkgs
nixpkgs.url = "__NIXPKGS__"; inputs.nixpkgs.url = "__NIXPKGS__";
};
outputs = inputs: { outputs = inputs: {
nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem { nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem {
modules = [ modules = [
./nixosModules/machine1.nix ./nixosModules/machine1.nix
(if builtins.pathExists ./machines/machine1.json (if builtins.pathExists ./machines/machine1/settings.json
then builtins.fromJSON (builtins.readFile ./machines/machine1.json) then builtins.fromJSON (builtins.readFile ./machines/machine1/settings.json)
else { }) else { })
{ {
nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.hostPlatform = "x86_64-linux";

View File

@@ -0,0 +1,18 @@
{
# Use this path to our repo root e.g. for UI test
# inputs.clan-core.url = "../../../../.";
# this placeholder is replaced by the path to nixpkgs
inputs.clan-core.url = "__CLAN_CORE__";
outputs = { self, clan-core }: {
nixosConfigurations = clan-core.lib.buildClan {
directory = self;
machines = {
vm1 = { modulesPath, ... }: {
imports = [ "${toString modulesPath}/virtualisation/qemu-vm.nix" ];
};
};
};
};
}

View File

@@ -10,7 +10,7 @@ if TYPE_CHECKING:
def test_import_sops( def test_import_sops(
test_root: Path, test_root: Path,
clan_flake: Path, test_flake: Path,
capsys: pytest.CaptureFixture, capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],

View File

@@ -3,7 +3,7 @@ from pathlib import Path
from api import TestClient from api import TestClient
def test_machines(api: TestClient, clan_flake: Path) -> None: def test_machines(api: TestClient, test_flake: Path) -> None:
response = api.get("/api/machines") response = api.get("/api/machines")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"machines": []} assert response.json() == {"machines": []}
@@ -21,7 +21,7 @@ def test_machines(api: TestClient, clan_flake: Path) -> None:
assert response.json() == {"machines": [{"name": "test", "status": "unknown"}]} assert response.json() == {"machines": [{"name": "test", "status": "unknown"}]}
def test_configure_machine(api: TestClient, machine_flake: Path) -> None: def test_configure_machine(api: TestClient, test_flake: Path) -> None:
# ensure error 404 if machine does not exist when accessing the config # ensure error 404 if machine does not exist when accessing the config
response = api.get("/api/machines/machine1/config") response = api.get("/api/machines/machine1/config")
assert response.status_code == 404 assert response.status_code == 404

View File

@@ -4,7 +4,7 @@ import pytest
from cli import Cli from cli import Cli
def test_machine_subcommands(clan_flake: Path, capsys: pytest.CaptureFixture) -> None: def test_machine_subcommands(test_flake: Path, capsys: pytest.CaptureFixture) -> None:
cli = Cli() cli = Cli()
cli.run(["machines", "create", "machine1"]) cli.run(["machines", "create", "machine1"])

View File

@@ -3,6 +3,6 @@ from pathlib import Path
from clan_cli.config import machine from clan_cli.config import machine
def test_schema_for_machine(machine_flake: Path) -> None: def test_schema_for_machine(test_flake: Path) -> None:
schema = machine.schema_for_machine("machine1", machine_flake) schema = machine.schema_for_machine("machine1", test_flake)
assert "properties" in schema assert "properties" in schema

View File

@@ -1,35 +0,0 @@
import os
import shutil
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from host_group import HostGroup
def test_update(
clan_flake: Path, host_group: HostGroup, monkeypatch: pytest.MonkeyPatch
) -> None:
assert len(host_group.hosts) == 1
host = host_group.hosts[0]
with TemporaryDirectory() as tmpdir:
host.meta["flake_uri"] = clan_flake
host.meta["flake_path"] = str(Path(tmpdir) / "rsync-target")
host.ssh_options["SendEnv"] = "REALPATH"
bin = Path(tmpdir).joinpath("bin")
bin.mkdir()
nixos_rebuild = bin.joinpath("nixos-rebuild")
bash = shutil.which("bash")
assert bash is not None
nixos_rebuild.write_text(
f"""#!{bash}
exit 0
"""
)
nixos_rebuild.chmod(0o755)
f"{tmpdir}/bin:{os.environ['PATH']}"
nix_state_dir = Path(tmpdir).joinpath("nix")
nix_state_dir.mkdir()
monkeypatch.setenv("REALPATH", str(nix_state_dir))

View File

@@ -14,12 +14,12 @@ if TYPE_CHECKING:
def _test_identities( def _test_identities(
what: str, what: str,
clan_flake: Path, test_flake: Path,
capsys: pytest.CaptureFixture, capsys: pytest.CaptureFixture,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
) -> None: ) -> None:
cli = Cli() cli = Cli()
sops_folder = clan_flake / "sops" sops_folder = test_flake / "sops"
cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey]) cli.run(["secrets", what, "add", "foo", age_keys[0].pubkey])
assert (sops_folder / what / "foo" / "key.json").exists() assert (sops_folder / what / "foo" / "key.json").exists()
@@ -60,19 +60,19 @@ def _test_identities(
def test_users( def test_users(
clan_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"] test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None: ) -> None:
_test_identities("users", clan_flake, capsys, age_keys) _test_identities("users", test_flake, capsys, age_keys)
def test_machines( def test_machines(
clan_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"] test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None: ) -> None:
_test_identities("machines", clan_flake, capsys, age_keys) _test_identities("machines", test_flake, capsys, age_keys)
def test_groups( def test_groups(
clan_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"] test_flake: Path, capsys: pytest.CaptureFixture, age_keys: list["KeyPair"]
) -> None: ) -> None:
cli = Cli() cli = Cli()
capsys.readouterr() # empty the buffer capsys.readouterr() # empty the buffer
@@ -100,7 +100,7 @@ def test_groups(
cli.run(["secrets", "groups", "remove-user", "group1", "user1"]) cli.run(["secrets", "groups", "remove-user", "group1", "user1"])
cli.run(["secrets", "groups", "remove-machine", "group1", "machine1"]) cli.run(["secrets", "groups", "remove-machine", "group1", "machine1"])
groups = os.listdir(clan_flake / "sops" / "groups") groups = os.listdir(test_flake / "sops" / "groups")
assert len(groups) == 0 assert len(groups) == 0
@@ -114,7 +114,7 @@ def use_key(key: str, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
def test_secrets( def test_secrets(
clan_flake: Path, test_flake: Path,
capsys: pytest.CaptureFixture, capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
age_keys: list["KeyPair"], age_keys: list["KeyPair"],
@@ -125,7 +125,7 @@ def test_secrets(
assert capsys.readouterr().out == "" assert capsys.readouterr().out == ""
monkeypatch.setenv("SOPS_NIX_SECRET", "foo") monkeypatch.setenv("SOPS_NIX_SECRET", "foo")
monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(clan_flake / ".." / "age.key")) monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake / ".." / "age.key"))
cli.run(["secrets", "key", "generate"]) cli.run(["secrets", "key", "generate"])
capsys.readouterr() # empty the buffer capsys.readouterr() # empty the buffer
cli.run(["secrets", "key", "show"]) cli.run(["secrets", "key", "show"])

View File

@@ -30,9 +30,8 @@ def test_ssh_no_pass(
monkeypatch.delenv("IN_NIX_SANDBOX") monkeypatch.delenv("IN_NIX_SANDBOX")
cmd: list[Union[str, utils.Any]] = [ cmd: list[Union[str, utils.Any]] = [
"nix", "nix",
fp.any(),
"shell", "shell",
"--extra-experimental-features",
"nix-command flakes",
fp.any(), fp.any(),
"-c", "-c",
"torify", "torify",
@@ -61,9 +60,8 @@ def test_ssh_with_pass(
monkeypatch.delenv("IN_NIX_SANDBOX") monkeypatch.delenv("IN_NIX_SANDBOX")
cmd: list[Union[str, utils.Any]] = [ cmd: list[Union[str, utils.Any]] = [
"nix", "nix",
fp.any(),
"shell", "shell",
"--extra-experimental-features",
"nix-command flakes",
fp.any(), fp.any(),
"-c", "-c",
"torify", "torify",

View File

@@ -0,0 +1,33 @@
from pathlib import Path
import pytest
from api import TestClient
@pytest.mark.impure
def test_inspect(api: TestClient, test_flake_with_core: Path) -> None:
response = api.post(
"/api/vms/inspect",
json=dict(flake_url=str(test_flake_with_core), flake_attr="vm1"),
)
assert response.status_code == 200, "Failed to inspect vm"
config = response.json()["config"]
assert config.get("flake_attr") == "vm1"
assert config.get("cores") == 1
assert config.get("memory_size") == 1024
assert config.get("graphics") is True
@pytest.mark.impure
def test_create(api: TestClient, test_flake_with_core: Path) -> None:
response = api.post(
"/api/vms/create",
json=dict(
flake_url=str(test_flake_with_core),
flake_attr="vm1",
cores=1,
memory_size=1024,
graphics=True,
),
)
assert response.status_code == 200, "Failed to inspect vm"

View File

@@ -10986,6 +10986,11 @@
descriptor = "^0.4.1"; descriptor = "^0.4.1";
pin = "0.4.1"; pin = "0.4.1";
}; };
pretty-bytes = {
descriptor = "^6.1.1";
pin = "6.1.1";
runtime = true;
};
react = { react = {
descriptor = "18.2.0"; descriptor = "18.2.0";
pin = "18.2.0"; pin = "18.2.0";
@@ -13086,6 +13091,9 @@
dev = true; dev = true;
key = "prettier-plugin-tailwindcss/0.4.1"; key = "prettier-plugin-tailwindcss/0.4.1";
}; };
"node_modules/pretty-bytes" = {
key = "pretty-bytes/6.1.1";
};
"node_modules/printable-characters" = { "node_modules/printable-characters" = {
dev = true; dev = true;
key = "printable-characters/1.0.42"; key = "printable-characters/1.0.42";
@@ -15195,6 +15203,19 @@
version = "0.4.1"; version = "0.4.1";
}; };
}; };
pretty-bytes = {
"6.1.1" = {
fetchInfo = {
narHash = "sha256-ERXqMD/9tkPebbHVL3n/9EQRz7mFs5VYO6k/wo5JDzQ=";
type = "tarball";
url = "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz";
};
ident = "pretty-bytes";
ltype = "file";
treeInfo = { };
version = "6.1.1";
};
};
printable-characters = { printable-characters = {
"1.0.42" = { "1.0.42" = {
fetchInfo = { fetchInfo = {

View File

@@ -1,5 +1,5 @@
const config = { const config = {
petstore: { clan: {
output: { output: {
mode: "tags-split", mode: "tags-split",
target: "src/api", target: "src/api",

View File

@@ -22,6 +22,7 @@
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"next": "13.4.12", "next": "13.4.12",
"postcss": "8.4.27", "postcss": "8.4.27",
"pretty-bytes": "^6.1.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
@@ -6810,6 +6811,17 @@
} }
} }
}, },
"node_modules/pretty-bytes": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/printable-characters": { "node_modules/printable-characters": {
"version": "1.0.42", "version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",

View File

@@ -26,6 +26,7 @@
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"next": "13.4.12", "next": "13.4.12",
"postcss": "8.4.27", "postcss": "8.4.27",
"pretty-bytes": "^6.1.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",

View File

@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import { VmConfig } from "@/api/model";
import { useVms } from "@/components/hooks/useVms";
import prettyBytes from "pretty-bytes";
import {
Alert,
AlertTitle,
Button,
Chip,
LinearProgress,
ListSubheader,
Switch,
Typography,
} from "@mui/material";
import { useSearchParams } from "next/navigation";
import { toast } from "react-hot-toast";
import { Error, Numbers } from "@mui/icons-material";
import { createVm, inspectVm } from "@/api/default/default";
interface FlakeBadgeProps {
flakeUrl: string;
flakeAttr: string;
}
const FlakeBadge = (props: FlakeBadgeProps) => (
<Chip
color="secondary"
label={`${props.flakeUrl}#${props.flakeAttr}`}
sx={{ p: 2 }}
/>
);
interface VmPropLabelProps {
children: React.ReactNode;
}
const VmPropLabel = (props: VmPropLabelProps) => (
<div className="col-span-4 flex items-center sm:col-span-1">
{props.children}
</div>
);
interface VmPropContentProps {
children: React.ReactNode;
}
const VmPropContent = (props: VmPropContentProps) => (
<div className="col-span-4 font-bold sm:col-span-3">{props.children}</div>
);
interface VmDetailsProps {
vmConfig: VmConfig;
}
const VmDetails = (props: VmDetailsProps) => {
const { vmConfig } = props;
const { cores, flake_attr, flake_url, graphics, memory_size } = vmConfig;
const [isStarting, setStarting] = useState(false);
const handleStartVm = async () => {
setStarting(true);
const response = await createVm(vmConfig);
setStarting(false);
if (response.statusText === "OK") {
toast.success(("VM created @ " + response?.data) as string);
} else {
toast.error("Could not create VM");
}
};
return (
<div className="grid grid-cols-4 gap-y-10">
<div className="col-span-4">
<ListSubheader>General</ListSubheader>
</div>
<VmPropLabel>Flake</VmPropLabel>
<VmPropContent>
<FlakeBadge flakeAttr={flake_attr} flakeUrl={flake_url} />
</VmPropContent>
<VmPropLabel>Machine</VmPropLabel>
<VmPropContent>{flake_attr}</VmPropContent>
<div className="col-span-4">
<ListSubheader>VM</ListSubheader>
</div>
<VmPropLabel>CPU Cores</VmPropLabel>
<VmPropContent>
<Numbers fontSize="inherit" />
<span className="font-bold text-black">{cores}</span>
</VmPropContent>
<VmPropLabel>Graphics</VmPropLabel>
<VmPropContent>
<Switch checked={graphics} />
</VmPropContent>
<VmPropLabel>Memory Size</VmPropLabel>
<VmPropContent>{prettyBytes(memory_size * 1024 * 1024)}</VmPropContent>
<div className="col-span-4 grid items-center">
{isStarting && <LinearProgress />}
<Button
disabled={isStarting}
variant="contained"
onClick={handleStartVm}
>
Spin up VM
</Button>
</div>
</div>
);
};
interface ErrorLogOptions {
lines: string[];
}
const ErrorLog = (props: ErrorLogOptions) => {
const { lines } = props;
return (
<div className="w-full bg-slate-800 p-4 text-white shadow-inner shadow-black">
<div className="mb-1 text-slate-400">Log</div>
{lines.map((item, idx) => (
<span key={`${idx}`} className="mb-2 block break-words">
{item}
<br />
</span>
))}
</div>
);
};
export default function Page() {
const queryParams = useSearchParams();
const flakeUrl = queryParams.get("flake") || "";
const flakeAttribute = queryParams.get("attr") || "default";
const { config, error, isLoading } = useVms({
url: flakeUrl,
attr: flakeAttribute,
});
const clanName = "Lassul.us";
return (
<div className="grid h-[70vh] w-full place-items-center gap-y-4">
<Typography variant="h4" className="w-full text-center">
Join{" "}
<Typography variant="h4" className="font-bold" component={"span"}>
{clanName}
</Typography>
{"' "}
Clan
</Typography>
{error && (
<Alert severity="error" className="w-full max-w-xl">
<AlertTitle>Error</AlertTitle>
An Error occurred - See details below
</Alert>
)}
<div className="w-full max-w-xl">
{isLoading && (
<div className="w-full">
<Typography variant="subtitle2">Loading Flake</Typography>
<LinearProgress className="mb-2 w-full" />
<div className="grid w-full place-items-center">
<FlakeBadge flakeUrl={flakeUrl} flakeAttr={flakeAttribute} />
</div>
<Typography variant="subtitle1"></Typography>
</div>
)}
{(!flakeUrl || !flakeAttribute) && <div>Invalid URL</div>}
{config && <VmDetails vmConfig={config} />}
{error && (
<ErrorLog
lines={
error?.response?.data?.detail
?.map((err, idx) => err.msg.split("\n"))
?.flat()
.filter(Boolean) || []
}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { inspectVm } from "@/api/default/default";
import { HTTPValidationError, VmConfig } from "@/api/model";
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
interface UseVmsOptions {
url: string;
attr: string;
}
export const useVms = (options: UseVmsOptions) => {
const { url, attr } = options;
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<VmConfig>();
const [error, setError] = useState<AxiosError<HTTPValidationError>>();
useEffect(() => {
const getVmInfo = async (url: string, attr: string) => {
if (url === "") {
toast.error("Flake url is missing", { id: "missing.flake.url" });
return undefined;
}
try {
const response = await inspectVm({
flake_attr: attr,
flake_url: url,
});
const {
data: { config },
} = response;
setError(undefined);
return config;
} catch (e) {
const err = e as AxiosError<HTTPValidationError>;
setError(err);
toast.error(err.message);
return undefined;
} finally {
setIsLoading(false);
}
};
getVmInfo(url, attr).then((c) => setConfig(c));
}, [url, attr]);
return {
error,
isLoading,
config,
};
};