Compare commits
90 Commits
update-tem
...
vars-new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d143359a2d | ||
|
|
448e60f866 | ||
|
|
324e934204 | ||
|
|
3f6e5968b5 | ||
|
|
e4c8aba5bc | ||
|
|
76503b2a92 | ||
|
|
d585052007 | ||
|
|
65904d8d8e | ||
|
|
d5aa917ee7 | ||
|
|
cb9284360f | ||
|
|
3f1fdc0aae | ||
|
|
b35ca4f1a8 | ||
|
|
76e653f37f | ||
|
|
10737f7d94 | ||
|
|
eb54fdc741 | ||
|
|
4aa90f009f | ||
|
|
247151e93f | ||
|
|
543c518ed0 | ||
|
|
7f4f11751e | ||
|
|
a53efb9386 | ||
|
|
c509f333e4 | ||
|
|
ea93d8fec7 | ||
|
|
68b2aaea89 | ||
|
|
1e7453ab04 | ||
|
|
c148ece02e | ||
|
|
b526242744 | ||
|
|
76b0a9bf13 | ||
|
|
541732462b | ||
|
|
1558a366de | ||
|
|
6aab8ffd0c | ||
|
|
ae9d219dea | ||
|
|
899051a570 | ||
|
|
a44740d902 | ||
|
|
ba0397242f | ||
|
|
79560ac202 | ||
|
|
52aaad272f | ||
|
|
62c1db9769 | ||
|
|
b41029ea48 | ||
|
|
a0a9cef2a6 | ||
|
|
14b428216d | ||
|
|
91df5c258e | ||
|
|
fcb38820ec | ||
|
|
6d85cc0ff2 | ||
|
|
10fbae0c15 | ||
|
|
aef1edf8e3 | ||
|
|
18735a150f | ||
|
|
c354a87765 | ||
|
|
70d57cb267 | ||
|
|
24b8cb799a | ||
|
|
68e61d66d7 | ||
|
|
2e191d7db8 | ||
|
|
969b7606a6 | ||
|
|
631d17b6e9 | ||
|
|
ba5b81abf0 | ||
|
|
1bcd2be478 | ||
|
|
a6409f921b | ||
|
|
8f9d88a104 | ||
|
|
9003204b54 | ||
|
|
7939cfc9a9 | ||
|
|
7232892feb | ||
|
|
c3ba72e82c | ||
|
|
17b4f95055 | ||
|
|
3c72ad1c92 | ||
|
|
5b46136ca8 | ||
|
|
04c59c76ee | ||
|
|
fbb93c8412 | ||
|
|
e0993559db | ||
|
|
76bba13a7f | ||
|
|
12c2c4ee89 | ||
|
|
f8d36634ee | ||
|
|
b27ed51284 | ||
|
|
a81701b59a | ||
|
|
609db2f00c | ||
|
|
40065c7a00 | ||
|
|
2e4cbdc7c8 | ||
|
|
9aa7be3aba | ||
|
|
b2e8b8bf59 | ||
|
|
4c2bb0791d | ||
|
|
5cc8f3b2b3 | ||
|
|
fb5dca567e | ||
|
|
97bdf49814 | ||
|
|
b8feb652f6 | ||
|
|
58c9c929ba | ||
|
|
58862215ab | ||
|
|
667bbffb3f | ||
|
|
31b1725f6f | ||
|
|
0bd4074927 | ||
|
|
749a847d83 | ||
|
|
faf6ac82eb | ||
|
|
6c7beb7aaa |
@@ -22,7 +22,6 @@
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
self
|
self
|
||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-backup.config.system.clan.deployment.file
|
|
||||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{ fetchgit }:
|
{ fetchgit }:
|
||||||
fetchgit {
|
fetchgit {
|
||||||
url = "https://git.clan.lol/clan/clan-core.git";
|
url = "https://git.clan.lol/clan/clan-core.git";
|
||||||
rev = "28131afbbcd379a8ff04c79c66c670ef655ed889";
|
rev = "eea93ea22c9818da67e148ba586277bab9e73cea";
|
||||||
sha256 = "1294cwjlnc341fl6zbggn4rgq8z33gqkcyggjfvk9cf7zdgygrf6";
|
sha256 = "sha256-PV0Z+97QuxQbkYSVuNIJwUNXMbHZG/vhsA9M4cDTCOE=";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ in
|
|||||||
imports = filter pathExists [
|
imports = filter pathExists [
|
||||||
./backups/flake-module.nix
|
./backups/flake-module.nix
|
||||||
../nixosModules/clanCore/machine-id/tests/flake-module.nix
|
../nixosModules/clanCore/machine-id/tests/flake-module.nix
|
||||||
|
../nixosModules/clanCore/state-version/tests/flake-module.nix
|
||||||
./devshell/flake-module.nix
|
./devshell/flake-module.nix
|
||||||
./flash/flake-module.nix
|
./flash/flake-module.nix
|
||||||
./impure/flake-module.nix
|
./impure/flake-module.nix
|
||||||
|
|||||||
@@ -50,8 +50,6 @@
|
|||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.toplevel
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.build.diskoScript.drvPath
|
||||||
self.nixosConfigurations."test-flash-machine-${pkgs.hostPlatform.system}".config.system.clan.deployment.file
|
|
||||||
|
|
||||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -1,63 +1,9 @@
|
|||||||
{
|
{
|
||||||
self,
|
self,
|
||||||
lib,
|
lib,
|
||||||
|
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
|
||||||
installer =
|
|
||||||
{ modulesPath, pkgs, ... }:
|
|
||||||
let
|
|
||||||
dependencies = [
|
|
||||||
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
|
|
||||||
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
|
|
||||||
self.clan.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.clan.deployment.file
|
|
||||||
pkgs.stdenv.drvPath
|
|
||||||
pkgs.bash.drvPath
|
|
||||||
pkgs.nixos-anywhere
|
|
||||||
pkgs.bubblewrap
|
|
||||||
pkgs.buildPackages.xorg.lndir
|
|
||||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
|
||||||
in
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
(modulesPath + "/../tests/common/auto-format-root-device.nix")
|
|
||||||
];
|
|
||||||
networking.useNetworkd = true;
|
|
||||||
services.openssh.enable = true;
|
|
||||||
services.openssh.settings.UseDns = false;
|
|
||||||
services.openssh.settings.PasswordAuthentication = false;
|
|
||||||
system.nixos.variant_id = "installer";
|
|
||||||
environment.systemPackages = [
|
|
||||||
self.packages.${pkgs.system}.clan-cli-full
|
|
||||||
pkgs.nixos-facter
|
|
||||||
];
|
|
||||||
environment.etc."install-closure".source = "${closureInfo}/store-paths";
|
|
||||||
virtualisation.emptyDiskImages = [ 512 ];
|
|
||||||
virtualisation.diskSize = 8 * 1024;
|
|
||||||
virtualisation.rootDevice = "/dev/vdb";
|
|
||||||
# both installer and target need to use the same diskImage
|
|
||||||
virtualisation.diskImage = "./target.qcow2";
|
|
||||||
virtualisation.memorySize = 3048;
|
|
||||||
nix.settings = {
|
|
||||||
substituters = lib.mkForce [ ];
|
|
||||||
hashed-mirrors = null;
|
|
||||||
connect-timeout = lib.mkForce 3;
|
|
||||||
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
|
|
||||||
experimental-features = [
|
|
||||||
"nix-command"
|
|
||||||
"flakes"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
users.users.nonrootuser = {
|
|
||||||
isNormalUser = true;
|
|
||||||
openssh.authorizedKeys.keyFiles = [ ../assets/ssh/pubkey ];
|
|
||||||
extraGroups = [ "wheel" ];
|
|
||||||
};
|
|
||||||
security.sudo.wheelNeedsPassword = false;
|
|
||||||
system.extraDependencies = dependencies;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
{
|
||||||
|
|
||||||
# The purpose of this test is to ensure `clan machines install` works
|
# The purpose of this test is to ensure `clan machines install` works
|
||||||
@@ -106,6 +52,25 @@ in
|
|||||||
|
|
||||||
environment.etc."install-successful".text = "ok";
|
environment.etc."install-successful".text = "ok";
|
||||||
|
|
||||||
|
# Enable SSH and add authorized key for testing
|
||||||
|
services.openssh.enable = true;
|
||||||
|
services.openssh.settings.PasswordAuthentication = false;
|
||||||
|
users.users.nonrootuser = {
|
||||||
|
isNormalUser = true;
|
||||||
|
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
home = "/home/nonrootuser";
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
# Allow users to manage their own SSH keys
|
||||||
|
services.openssh.authorizedKeysFiles = [
|
||||||
|
"/root/.ssh/authorized_keys"
|
||||||
|
"/home/%u/.ssh/authorized_keys"
|
||||||
|
"/etc/ssh/authorized_keys.d/%u"
|
||||||
|
];
|
||||||
|
security.sudo.wheelNeedsPassword = false;
|
||||||
|
|
||||||
boot.consoleLogLevel = lib.mkForce 100;
|
boot.consoleLogLevel = lib.mkForce 100;
|
||||||
boot.kernelParams = [ "boot.shell_on_fail" ];
|
boot.kernelParams = [ "boot.shell_on_fail" ];
|
||||||
|
|
||||||
@@ -182,55 +147,199 @@ in
|
|||||||
# vm-test-run-test-installation-> target: waiting for the VM to finish booting
|
# vm-test-run-test-installation-> target: waiting for the VM to finish booting
|
||||||
# vm-test-run-test-installation-> target: Guest root shell did not produce any data yet...
|
# vm-test-run-test-installation-> target: Guest root shell did not produce any data yet...
|
||||||
# vm-test-run-test-installation-> target: To debug, enter the VM and run 'systemctl status backdoor.service'.
|
# vm-test-run-test-installation-> target: To debug, enter the VM and run 'systemctl status backdoor.service'.
|
||||||
checks = pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) {
|
checks =
|
||||||
nixos-test-installation = self.clanLib.test.baseTest {
|
let
|
||||||
name = "installation";
|
# Custom Python package for port management utilities
|
||||||
nodes.target = {
|
closureInfo = pkgs.closureInfo {
|
||||||
services.openssh.enable = true;
|
rootPaths = [
|
||||||
virtualisation.diskImage = "./target.qcow2";
|
self.checks.x86_64-linux.clan-core-for-checks
|
||||||
virtualisation.useBootLoader = true;
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
|
||||||
|
pkgs.stdenv.drvPath
|
||||||
|
pkgs.bash.drvPath
|
||||||
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
};
|
};
|
||||||
nodes.installer = installer;
|
in
|
||||||
|
pkgs.lib.mkIf (pkgs.stdenv.isLinux && !pkgs.stdenv.isAarch64) {
|
||||||
|
nixos-test-installation = self.clanLib.test.baseTest {
|
||||||
|
name = "installation";
|
||||||
|
nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target;
|
||||||
|
extraPythonPackages = _p: [
|
||||||
|
self.legacyPackages.${pkgs.system}.nixosTestLib
|
||||||
|
];
|
||||||
|
|
||||||
testScript = ''
|
testScript = ''
|
||||||
installer.start()
|
import tempfile
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped]
|
||||||
|
from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped]
|
||||||
|
|
||||||
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
|
def create_test_machine(oldmachine, qemu_test_bin: str, **kwargs):
|
||||||
|
"""Create a new test machine from an installed disk image"""
|
||||||
|
start_command = [
|
||||||
|
f"{qemu_test_bin}/bin/qemu-kvm",
|
||||||
|
"-cpu",
|
||||||
|
"max",
|
||||||
|
"-m",
|
||||||
|
"3048",
|
||||||
|
"-virtfs",
|
||||||
|
"local,path=/nix/store,security_model=none,mount_tag=nix-store",
|
||||||
|
"-drive",
|
||||||
|
f"file={oldmachine.state_dir}/target.qcow2,id=drive1,if=none,index=1,werror=report",
|
||||||
|
"-device",
|
||||||
|
"virtio-blk-pci,drive=drive1",
|
||||||
|
"-netdev",
|
||||||
|
"user,id=net0",
|
||||||
|
"-device",
|
||||||
|
"virtio-net-pci,netdev=net0",
|
||||||
|
]
|
||||||
|
machine = create_machine(start_command=" ".join(start_command), **kwargs)
|
||||||
|
driver.machines.append(machine)
|
||||||
|
return machine
|
||||||
|
|
||||||
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
|
target.start()
|
||||||
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
|
|
||||||
|
|
||||||
installer.succeed("clan machines install --no-reboot --debug --flake test-flake --yes test-install-machine-without-system --target-host nonrootuser@localhost --update-hardware-config nixos-facter >&2")
|
# Set up test environment
|
||||||
installer.shutdown()
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
# Prepare test flake and Nix store
|
||||||
|
flake_dir = prepare_test_flake(
|
||||||
|
temp_dir,
|
||||||
|
"${self.checks.x86_64-linux.clan-core-for-checks}",
|
||||||
|
"${closureInfo}"
|
||||||
|
)
|
||||||
|
|
||||||
# We are missing the test instrumentation somehow. Test this later.
|
# Set up SSH connection
|
||||||
target.state_dir = installer.state_dir
|
ssh_conn = setup_ssh_connection(
|
||||||
target.start()
|
target,
|
||||||
target.wait_for_unit("multi-user.target")
|
temp_dir,
|
||||||
'';
|
"${../assets/ssh/privkey}"
|
||||||
} { inherit pkgs self; };
|
)
|
||||||
|
|
||||||
nixos-test-update-hardware-configuration = self.clanLib.test.baseTest {
|
# Run clan install from host using port forwarding
|
||||||
name = "update-hardware-configuration";
|
clan_cmd = [
|
||||||
nodes.installer = installer;
|
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
|
||||||
|
"machines",
|
||||||
|
"install",
|
||||||
|
"--phases", "disko,install",
|
||||||
|
"--debug",
|
||||||
|
"--flake", flake_dir,
|
||||||
|
"--yes", "test-install-machine-without-system",
|
||||||
|
"--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}",
|
||||||
|
"-i", ssh_conn.ssh_key,
|
||||||
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
|
"--update-hardware-config", "nixos-facter",
|
||||||
|
]
|
||||||
|
|
||||||
testScript = ''
|
subprocess.run(clan_cmd, check=True)
|
||||||
installer.start()
|
|
||||||
installer.succeed("${pkgs.coreutils}/bin/install -Dm 600 ${../assets/ssh/privkey} /root/.ssh/id_ed25519")
|
|
||||||
installer.wait_until_succeeds("timeout 2 ssh -o StrictHostKeyChecking=accept-new -v nonrootuser@localhost hostname")
|
|
||||||
installer.succeed("cp -r ${self.checks.x86_64-linux.clan-core-for-checks} test-flake && chmod -R +w test-flake")
|
|
||||||
installer.fail("test -f test-flake/machines/test-install-machine/hardware-configuration.nix")
|
|
||||||
installer.fail("test -f test-flake/machines/test-install-machine/facter.json")
|
|
||||||
|
|
||||||
installer.succeed("clan machines update-hardware-config --debug --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2")
|
# Shutdown the installer machine gracefully
|
||||||
installer.succeed("test -f test-flake/machines/test-install-machine-without-system/facter.json")
|
try:
|
||||||
installer.succeed("rm test-flake/machines/test-install-machine-without-system/facter.json")
|
target.shutdown()
|
||||||
|
except BrokenPipeError:
|
||||||
|
# qemu has already exited
|
||||||
|
pass
|
||||||
|
|
||||||
installer.succeed("clan machines update-hardware-config --debug --backend nixos-generate-config --flake test-flake test-install-machine-without-system nonrootuser@localhost >&2")
|
# Create a new machine instance that boots from the installed system
|
||||||
installer.succeed("test -f test-flake/machines/test-install-machine-without-system/hardware-configuration.nix")
|
installed_machine = create_test_machine(target, "${pkgs.qemu_test}", name="after_install")
|
||||||
installer.succeed("rm test-flake/machines/test-install-machine-without-system/hardware-configuration.nix")
|
installed_machine.start()
|
||||||
'';
|
installed_machine.wait_for_unit("multi-user.target")
|
||||||
} { inherit pkgs self; };
|
installed_machine.succeed("test -f /etc/install-successful")
|
||||||
};
|
'';
|
||||||
|
} { inherit pkgs self; };
|
||||||
|
|
||||||
|
nixos-test-update-hardware-configuration = self.clanLib.test.baseTest {
|
||||||
|
name = "update-hardware-configuration";
|
||||||
|
nodes.target = (import ./test-helpers.nix { inherit lib pkgs self; }).target;
|
||||||
|
extraPythonPackages = _p: [
|
||||||
|
self.legacyPackages.${pkgs.system}.nixosTestLib
|
||||||
|
];
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from nixos_test_lib.ssh import setup_ssh_connection # type: ignore[import-untyped]
|
||||||
|
from nixos_test_lib.nix_setup import prepare_test_flake # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
target.start()
|
||||||
|
|
||||||
|
# Set up test environment
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
# Prepare test flake and Nix store
|
||||||
|
flake_dir = prepare_test_flake(
|
||||||
|
temp_dir,
|
||||||
|
"${self.checks.x86_64-linux.clan-core-for-checks}",
|
||||||
|
"${closureInfo}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up SSH connection
|
||||||
|
ssh_conn = setup_ssh_connection(
|
||||||
|
target,
|
||||||
|
temp_dir,
|
||||||
|
"${../assets/ssh/privkey}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify files don't exist initially
|
||||||
|
hw_config_file = os.path.join(flake_dir, "machines/test-install-machine/hardware-configuration.nix")
|
||||||
|
facter_file = os.path.join(flake_dir, "machines/test-install-machine/facter.json")
|
||||||
|
|
||||||
|
assert not os.path.exists(hw_config_file), "hardware-configuration.nix should not exist initially"
|
||||||
|
assert not os.path.exists(facter_file), "facter.json should not exist initially"
|
||||||
|
|
||||||
|
# Set CLAN_FLAKE for the commands
|
||||||
|
os.environ["CLAN_FLAKE"] = flake_dir
|
||||||
|
|
||||||
|
# Test facter backend
|
||||||
|
clan_cmd = [
|
||||||
|
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
|
||||||
|
"machines",
|
||||||
|
"update-hardware-config",
|
||||||
|
"--debug",
|
||||||
|
"--flake", ".",
|
||||||
|
"--host-key-check", "none",
|
||||||
|
"test-install-machine-without-system",
|
||||||
|
"-i", ssh_conn.ssh_key,
|
||||||
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
|
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Clan update-hardware-config failed: {result.stderr.decode()}")
|
||||||
|
raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}")
|
||||||
|
|
||||||
|
facter_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/facter.json")
|
||||||
|
assert os.path.exists(facter_without_system_file), "facter.json should exist after update"
|
||||||
|
os.remove(facter_without_system_file)
|
||||||
|
|
||||||
|
# Test nixos-generate-config backend
|
||||||
|
clan_cmd = [
|
||||||
|
"${self.packages.${pkgs.system}.clan-cli-full}/bin/clan",
|
||||||
|
"machines",
|
||||||
|
"update-hardware-config",
|
||||||
|
"--debug",
|
||||||
|
"--backend", "nixos-generate-config",
|
||||||
|
"--host-key-check", "none",
|
||||||
|
"--flake", ".",
|
||||||
|
"test-install-machine-without-system",
|
||||||
|
"-i", ssh_conn.ssh_key,
|
||||||
|
"--option", "store", os.environ['CLAN_TEST_STORE'],
|
||||||
|
f"nonrootuser@localhost:{ssh_conn.host_port}"
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(clan_cmd, capture_output=True, cwd=flake_dir)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Clan update-hardware-config (nixos-generate-config) failed: {result.stderr.decode()}")
|
||||||
|
raise Exception(f"Clan update-hardware-config failed with return code {result.returncode}")
|
||||||
|
|
||||||
|
hw_config_without_system_file = os.path.join(flake_dir, "machines/test-install-machine-without-system/hardware-configuration.nix")
|
||||||
|
assert os.path.exists(hw_config_without_system_file), "hardware-configuration.nix should exist after update"
|
||||||
|
'';
|
||||||
|
} { inherit pkgs self; };
|
||||||
|
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
44
checks/installation/pyproject.toml
Normal file
44
checks/installation/pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "nixos-test-lib"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "NixOS test utilities for clan VM testing"
|
||||||
|
authors = [
|
||||||
|
{name = "Clan Core Team"}
|
||||||
|
]
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy",
|
||||||
|
"ruff"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["nixos_test_lib*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"nixos_test_lib" = ["py.typed"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"D", # docstrings
|
||||||
|
"ANN", # type annotations
|
||||||
|
"COM812", # trailing comma
|
||||||
|
"ISC001", # string concatenation
|
||||||
|
]
|
||||||
173
checks/installation/test-helpers.nix
Normal file
173
checks/installation/test-helpers.nix
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
self,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Common target VM configuration used by both installation and update tests
|
||||||
|
target =
|
||||||
|
{ modulesPath, pkgs, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
(modulesPath + "/../tests/common/auto-format-root-device.nix")
|
||||||
|
];
|
||||||
|
networking.useNetworkd = true;
|
||||||
|
services.openssh.enable = true;
|
||||||
|
services.openssh.settings.UseDns = false;
|
||||||
|
services.openssh.settings.PasswordAuthentication = false;
|
||||||
|
system.nixos.variant_id = "installer";
|
||||||
|
environment.systemPackages = [
|
||||||
|
pkgs.nixos-facter
|
||||||
|
];
|
||||||
|
# Disable cache.nixos.org to speed up tests
|
||||||
|
nix.settings.substituters = [ ];
|
||||||
|
nix.settings.trusted-public-keys = [ ];
|
||||||
|
virtualisation.emptyDiskImages = [ 512 ];
|
||||||
|
virtualisation.diskSize = 8 * 1024;
|
||||||
|
virtualisation.rootDevice = "/dev/vdb";
|
||||||
|
# both installer and target need to use the same diskImage
|
||||||
|
virtualisation.diskImage = "./target.qcow2";
|
||||||
|
virtualisation.memorySize = 3048;
|
||||||
|
users.users.nonrootuser = {
|
||||||
|
isNormalUser = true;
|
||||||
|
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
};
|
||||||
|
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
# Allow users to manage their own SSH keys
|
||||||
|
services.openssh.authorizedKeysFiles = [
|
||||||
|
"/root/.ssh/authorized_keys"
|
||||||
|
"/home/%u/.ssh/authorized_keys"
|
||||||
|
"/etc/ssh/authorized_keys.d/%u"
|
||||||
|
];
|
||||||
|
security.sudo.wheelNeedsPassword = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Common base test machine configuration
|
||||||
|
baseTestMachine =
|
||||||
|
{ lib, modulesPath, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
(modulesPath + "/testing/test-instrumentation.nix")
|
||||||
|
(modulesPath + "/profiles/qemu-guest.nix")
|
||||||
|
self.clanLib.test.minifyModule
|
||||||
|
];
|
||||||
|
|
||||||
|
# Enable SSH and add authorized key for testing
|
||||||
|
services.openssh.enable = true;
|
||||||
|
services.openssh.settings.PasswordAuthentication = false;
|
||||||
|
users.users.nonrootuser = {
|
||||||
|
isNormalUser = true;
|
||||||
|
openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
extraGroups = [ "wheel" ];
|
||||||
|
home = "/home/nonrootuser";
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
users.users.root.openssh.authorizedKeys.keys = [ (builtins.readFile ../assets/ssh/pubkey) ];
|
||||||
|
# Allow users to manage their own SSH keys
|
||||||
|
services.openssh.authorizedKeysFiles = [
|
||||||
|
"/root/.ssh/authorized_keys"
|
||||||
|
"/home/%u/.ssh/authorized_keys"
|
||||||
|
"/etc/ssh/authorized_keys.d/%u"
|
||||||
|
];
|
||||||
|
security.sudo.wheelNeedsPassword = false;
|
||||||
|
|
||||||
|
boot.consoleLogLevel = lib.mkForce 100;
|
||||||
|
boot.kernelParams = [ "boot.shell_on_fail" ];
|
||||||
|
|
||||||
|
# disko config
|
||||||
|
boot.loader.grub.efiSupport = lib.mkDefault true;
|
||||||
|
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
|
||||||
|
clan.core.vars.settings.secretStore = "vm";
|
||||||
|
clan.core.vars.generators.test = {
|
||||||
|
files.test.neededFor = "partitioning";
|
||||||
|
script = ''
|
||||||
|
echo "notok" > "$out"/test
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
disko.devices = {
|
||||||
|
disk = {
|
||||||
|
main = {
|
||||||
|
type = "disk";
|
||||||
|
device = "/dev/vda";
|
||||||
|
|
||||||
|
preCreateHook = ''
|
||||||
|
test -e /run/partitioning-secrets/test/test
|
||||||
|
'';
|
||||||
|
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
boot = {
|
||||||
|
size = "1M";
|
||||||
|
type = "EF02"; # for grub MBR
|
||||||
|
priority = 1;
|
||||||
|
};
|
||||||
|
ESP = {
|
||||||
|
size = "512M";
|
||||||
|
type = "EF00";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "vfat";
|
||||||
|
mountpoint = "/boot";
|
||||||
|
mountOptions = [ "umask=0077" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
root = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "ext4";
|
||||||
|
mountpoint = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# NixOS test library combining port utils and clan VM test utilities
|
||||||
|
nixosTestLib = pkgs.python3Packages.buildPythonPackage {
|
||||||
|
pname = "nixos-test-lib";
|
||||||
|
version = "1.0.0";
|
||||||
|
format = "pyproject";
|
||||||
|
src = lib.fileset.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
./pyproject.toml
|
||||||
|
./nixos_test_lib
|
||||||
|
];
|
||||||
|
};
|
||||||
|
nativeBuildInputs = with pkgs.python3Packages; [
|
||||||
|
setuptools
|
||||||
|
wheel
|
||||||
|
];
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Common closure info
|
||||||
|
closureInfo = pkgs.closureInfo {
|
||||||
|
rootPaths = [
|
||||||
|
self.checks.x86_64-linux.clan-core-for-checks
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.toplevel
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.initialRamdisk
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.build.diskoScript
|
||||||
|
self.clanInternals.machines.${pkgs.hostPlatform.system}.test-install-machine-with-system.config.system.clan.deployment.file
|
||||||
|
pkgs.stdenv.drvPath
|
||||||
|
pkgs.bash.drvPath
|
||||||
|
pkgs.buildPackages.xorg.lndir
|
||||||
|
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
target
|
||||||
|
baseTestMachine
|
||||||
|
nixosTestLib
|
||||||
|
closureInfo
|
||||||
|
;
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
pkgs.stdenv.drvPath
|
pkgs.stdenv.drvPath
|
||||||
pkgs.stdenvNoCC
|
pkgs.stdenvNoCC
|
||||||
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
self.nixosConfigurations.test-morph-machine.config.system.build.toplevel
|
||||||
self.nixosConfigurations.test-morph-machine.config.system.clan.deployment.file
|
|
||||||
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs);
|
||||||
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ nixosLib.runTest (
|
|||||||
clan.test.fromFlake = ./.;
|
clan.test.fromFlake = ./.;
|
||||||
|
|
||||||
extraPythonPackages = _p: [
|
extraPythonPackages = _p: [
|
||||||
clan-core.legacyPackages.${hostPkgs.system}.setupNixInNixPythonPackage
|
clan-core.legacyPackages.${hostPkgs.system}.nixosTestLib
|
||||||
];
|
];
|
||||||
|
|
||||||
testScript =
|
testScript =
|
||||||
{ nodes, ... }:
|
{ nodes, ... }:
|
||||||
''
|
''
|
||||||
from setup_nix_in_nix import setup_nix_in_nix # type: ignore[import-untyped]
|
from nixos_test_lib.nix_setup import setup_nix_in_nix # type: ignore[import-untyped]
|
||||||
setup_nix_in_nix()
|
setup_nix_in_nix(None) # No closure info for this test
|
||||||
|
|
||||||
def run_clan(cmd: list[str], **kwargs) -> str:
|
def run_clan(cmd: list[str], **kwargs) -> str:
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ in
|
|||||||
{
|
{
|
||||||
|
|
||||||
warnings = [
|
warnings = [
|
||||||
"The clan.state-version module is deprecated and will be removed on 2025-07-15.
|
''
|
||||||
Please migrate to user-maintained configuration or the new equivalent clan services
|
The clan.state-version service is deprecated and will be
|
||||||
(https://docs.clan.lol/reference/clanServices)."
|
removed on 2025-07-15 in favor of a nix option.
|
||||||
|
|
||||||
|
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
|
||||||
|
''
|
||||||
];
|
];
|
||||||
|
|
||||||
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
var = config.clan.core.vars.generators.state-version.files.version or { };
|
var = config.clan.core.vars.generators.state-version.files.version or { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
||||||
|
warnings = [
|
||||||
|
''
|
||||||
|
The clan.state-version service is deprecated and will be
|
||||||
|
removed on 2025-07-15 in favor of a nix option.
|
||||||
|
|
||||||
|
Please migrate your configuration to use `clan.core.settings.state-version.enable = true` instead.
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
system.stateVersion = lib.mkDefault (lib.removeSuffix "\n" var.value);
|
||||||
|
|
||||||
clan.core.vars.generators.state-version = {
|
clan.core.vars.generators.state-version = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
{ lib, ... }:
|
||||||
{
|
{
|
||||||
name = "state-version";
|
name = "service-state-version";
|
||||||
|
|
||||||
clan = {
|
clan = {
|
||||||
directory = ./.;
|
directory = ./.;
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
|
|
||||||
nodes.server = { };
|
nodes.server = { };
|
||||||
|
|
||||||
testScript = ''
|
testScript = lib.mkDefault ''
|
||||||
start_all()
|
start_all()
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
networking.networkmanager.ensureProfiles.profiles = flip mapAttrs settings.networks (
|
networking.networkmanager.ensureProfiles.profiles = flip mapAttrs settings.networks (
|
||||||
name: _network: {
|
name: networkCfg: {
|
||||||
connection.id = "$ssid_${name}";
|
connection.id = "$ssid_${name}";
|
||||||
connection.type = "wifi";
|
connection.type = "wifi";
|
||||||
|
connection.autoconnect = networkCfg.autoConnect;
|
||||||
wifi.mode = "infrastructure";
|
wifi.mode = "infrastructure";
|
||||||
wifi.ssid = "$ssid_${name}";
|
wifi.ssid = "$ssid_${name}";
|
||||||
wifi-security.psk = "$pw_${name}";
|
wifi-security.psk = "$pw_${name}";
|
||||||
@@ -102,7 +103,7 @@ in
|
|||||||
# Generate the secrets file
|
# Generate the secrets file
|
||||||
echo "Generating wifi secrets file: $env_file"
|
echo "Generating wifi secrets file: $env_file"
|
||||||
${flip (concatMapAttrsStringSep "\n") settings.networks (
|
${flip (concatMapAttrsStringSep "\n") settings.networks (
|
||||||
name: _network: ''
|
name: _networkCfg: ''
|
||||||
echo "ssid_${name}=\"$(cat "${ssid_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
|
echo "ssid_${name}=\"$(cat "${ssid_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
|
||||||
echo "pw_${name}=\"$(cat "${password_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
|
echo "pw_${name}=\"$(cat "${password_path name}")\"" >> /run/secrets/NetworkManager/wifi-secrets
|
||||||
''
|
''
|
||||||
|
|||||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
/site/reference
|
/site/reference
|
||||||
/site/static
|
/site/static
|
||||||
/site/options-page
|
/site/options-page
|
||||||
|
/site/openapi.json
|
||||||
!/site/static/extra.css
|
!/site/static/extra.css
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ nav:
|
|||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Guides:
|
- Guides:
|
||||||
- Getting Started:
|
- Getting Started:
|
||||||
- Creating Your First Clan: guides/getting-started/index.md
|
- 🚀 Creating Your First Clan: guides/getting-started/index.md
|
||||||
- Create USB Installer (optional): guides/getting-started/installer.md
|
- 📀 Create USB Installer (optional): guides/getting-started/installer.md
|
||||||
- Add Machines: guides/getting-started/add-machines.md
|
- ⚙️ Add Machines: guides/getting-started/add-machines.md
|
||||||
- Add Services: guides/getting-started/add-services.md
|
- ⚙️ Add Services: guides/getting-started/add-services.md
|
||||||
- Secrets & Facts: guides/getting-started/secrets.md
|
- 🔐 Secrets & Facts: guides/getting-started/secrets.md
|
||||||
- Deploy Machine: guides/getting-started/deploy.md
|
- 🚢 Deploy Machine: guides/getting-started/deploy.md
|
||||||
- Continuous Integration: guides/getting-started/check.md
|
- 🧪 Continuous Integration: guides/getting-started/check.md
|
||||||
- clanServices: guides/clanServices.md
|
- clanServices: guides/clanServices.md
|
||||||
- Disk Encryption: guides/disk-encryption.md
|
- Disk Encryption: guides/disk-encryption.md
|
||||||
- Mesh VPN: guides/mesh-vpn.md
|
- Mesh VPN: guides/mesh-vpn.md
|
||||||
@@ -181,6 +181,9 @@ nav:
|
|||||||
- 05-deployment-parameters: decisions/05-deployment-parameters.md
|
- 05-deployment-parameters: decisions/05-deployment-parameters.md
|
||||||
- Template: decisions/_template.md
|
- Template: decisions/_template.md
|
||||||
- Options: options.md
|
- Options: options.md
|
||||||
|
- Developer:
|
||||||
|
- Introduction: intern/index.md
|
||||||
|
- API: intern/api.md
|
||||||
|
|
||||||
docs_dir: site
|
docs_dir: site
|
||||||
site_dir: out
|
site_dir: out
|
||||||
@@ -238,3 +241,4 @@ extra:
|
|||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- macros
|
- macros
|
||||||
|
- redoc-tag
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
module-docs,
|
module-docs,
|
||||||
clan-cli-docs,
|
clan-cli-docs,
|
||||||
|
clan-lib-openapi,
|
||||||
asciinema-player-js,
|
asciinema-player-js,
|
||||||
asciinema-player-css,
|
asciinema-player-css,
|
||||||
roboto,
|
roboto,
|
||||||
@@ -29,6 +30,7 @@ pkgs.stdenv.mkDerivation {
|
|||||||
mkdocs
|
mkdocs
|
||||||
mkdocs-material
|
mkdocs-material
|
||||||
mkdocs-macros
|
mkdocs-macros
|
||||||
|
mkdocs-redoc-tag
|
||||||
]);
|
]);
|
||||||
configurePhase = ''
|
configurePhase = ''
|
||||||
pushd docs
|
pushd docs
|
||||||
@@ -36,6 +38,10 @@ pkgs.stdenv.mkDerivation {
|
|||||||
mkdir -p ./site/reference/cli
|
mkdir -p ./site/reference/cli
|
||||||
cp -af ${module-docs}/* ./site/reference/
|
cp -af ${module-docs}/* ./site/reference/
|
||||||
cp -af ${clan-cli-docs}/* ./site/reference/cli/
|
cp -af ${clan-cli-docs}/* ./site/reference/cli/
|
||||||
|
|
||||||
|
mkdir -p ./site/reference/internal
|
||||||
|
cp -af ${clan-lib-openapi} ./site/openapi.json
|
||||||
|
|
||||||
chmod -R +w ./site/reference
|
chmod -R +w ./site/reference
|
||||||
echo "Generated API documentation in './site/reference/' "
|
echo "Generated API documentation in './site/reference/' "
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,12 @@
|
|||||||
packages = {
|
packages = {
|
||||||
docs = pkgs.python3.pkgs.callPackage ./default.nix {
|
docs = pkgs.python3.pkgs.callPackage ./default.nix {
|
||||||
clan-core = self;
|
clan-core = self;
|
||||||
inherit (self'.packages) clan-cli-docs docs-options inventory-api-docs;
|
inherit (self'.packages)
|
||||||
|
clan-cli-docs
|
||||||
|
docs-options
|
||||||
|
inventory-api-docs
|
||||||
|
clan-lib-openapi
|
||||||
|
;
|
||||||
inherit (inputs) nixpkgs;
|
inherit (inputs) nixpkgs;
|
||||||
inherit module-docs;
|
inherit module-docs;
|
||||||
inherit asciinema-player-js;
|
inherit asciinema-player-js;
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ For example:
|
|||||||
```nix
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
borgbackup = {
|
borgbackup = {
|
||||||
roles.client.machines = [ "laptop" "server1" ];
|
roles.client.machines."laptop" = {};
|
||||||
roles.server.machines = [ "backup-box" ];
|
roles.client.machines."server1" = {};
|
||||||
|
|
||||||
|
roles.server.machines."backup-box" = {};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -40,7 +42,8 @@ Example of instantiating a `borgbackup` service using `clan-core`:
|
|||||||
```nix
|
```nix
|
||||||
inventory.instances = {
|
inventory.instances = {
|
||||||
# Instance Name: Different name for this 'borgbackup' instance
|
# Instance Name: Different name for this 'borgbackup' instance
|
||||||
borgbackup-example = {
|
borgbackup = {
|
||||||
|
# Since this is instances."borgbackup" the whole `module = { ... }` below is equivalent and optional.
|
||||||
module = {
|
module = {
|
||||||
name = "borgbackup"; # <-- Name of the module (optional)
|
name = "borgbackup"; # <-- Name of the module (optional)
|
||||||
input = "clan-core"; # <-- The flake input where the service is defined (optional)
|
input = "clan-core"; # <-- The flake input where the service is defined (optional)
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ Replace `kernelModules` with the ethernet module loaded one on your target machi
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Copying SSH Public Key
|
||||||
### Step 1: Copying SSH Public Key
|
|
||||||
|
|
||||||
Before starting the installation process, ensure that the SSH public key is copied to the NixOS installer.
|
Before starting the installation process, ensure that the SSH public key is copied to the NixOS installer.
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ Before starting the installation process, ensure that the SSH public key is copi
|
|||||||
ssh-copy-id -o PreferredAuthentications=password -o PubkeyAuthentication=no root@nixos-installer.local
|
ssh-copy-id -o PreferredAuthentications=password -o PubkeyAuthentication=no root@nixos-installer.local
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 1.5: Prepare Secret Key and Partition Disks
|
## Prepare Secret Key and Partition Disks
|
||||||
|
|
||||||
1. Access the installer using SSH:
|
1. Access the installer using SSH:
|
||||||
|
|
||||||
@@ -100,7 +99,7 @@ blkdiscard /dev/disk/by-id/<installdisk>
|
|||||||
clan machines install gchq-local --target-host root@nixos-installer --phases kexec,disko
|
clan machines install gchq-local --target-host root@nixos-installer --phases kexec,disko
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: ZFS Pool Import and System Installation
|
## ZFS Pool Import and System Installation
|
||||||
|
|
||||||
1. SSH into the installer once again:
|
1. SSH into the installer once again:
|
||||||
|
|
||||||
@@ -151,7 +150,7 @@ zpool export zroot
|
|||||||
|
|
||||||
8. Perform a reboot of the machine and remove the USB installer.
|
8. Perform a reboot of the machine and remove the USB installer.
|
||||||
|
|
||||||
### Step 3: Accessing the Initial Ramdisk (initrd) Environment
|
## Accessing the Initial Ramdisk (initrd) Environment
|
||||||
|
|
||||||
1. SSH into the initrd environment using the `initrd_rsa_key` and provided port:
|
1. SSH into the initrd environment using the `initrd_rsa_key` and provided port:
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Clan supports integration with [flake-parts](https://flake.parts/), a framework
|
|||||||
|
|
||||||
To construct your Clan using flake-parts, follow these steps:
|
To construct your Clan using flake-parts, follow these steps:
|
||||||
|
|
||||||
## 1. Update Your Flake Inputs
|
## Update Your Flake Inputs
|
||||||
|
|
||||||
To begin, you'll need to add `flake-parts` as a new dependency in your flake's inputs. This is alongside the already existing dependencies, such as `clan-core` and `nixpkgs`. Here's how you can update your `flake.nix` file:
|
To begin, you'll need to add `flake-parts` as a new dependency in your flake's inputs. This is alongside the already existing dependencies, such as `clan-core` and `nixpkgs`. Here's how you can update your `flake.nix` file:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ inputs = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Import the Clan flake-parts Module
|
## Import the Clan flake-parts Module
|
||||||
|
|
||||||
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../reference/nix-api/clan.md) available within `mkFlake`.
|
After updating your flake inputs, the next step is to import the Clan flake-parts module. This will make the [Clan options](../reference/nix-api/clan.md) available within `mkFlake`.
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ After updating your flake inputs, the next step is to import the Clan flake-part
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configure Clan Settings and Define Machines
|
## Configure Clan Settings and Define Machines
|
||||||
|
|
||||||
Next you'll need to configure Clan wide settings and define machines, here's an example of how `flake.nix` should look:
|
Next you'll need to configure Clan wide settings and define machines, here's an example of how `flake.nix` should look:
|
||||||
|
|
||||||
@@ -91,6 +91,6 @@ Next you'll need to configure Clan wide settings and define machines, here's an
|
|||||||
```
|
```
|
||||||
|
|
||||||
For detailed information about configuring `flake-parts` and the available options within Clan,
|
For detailed information about configuring `flake-parts` and the available options within Clan,
|
||||||
refer to the Clan module documentation located [here](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix).
|
refer to the [Clan module](https://git.clan.lol/clan/clan-core/src/branch/main/flakeModules/clan.nix) documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -119,26 +119,34 @@ clan = {
|
|||||||
1. It is required to define a *targetHost* for each machine before deploying. Best practice has been, to use the zerotier ip/hostname or the ip from the from overlay network you decided to use.
|
1. It is required to define a *targetHost* for each machine before deploying. Best practice has been, to use the zerotier ip/hostname or the ip from the from overlay network you decided to use.
|
||||||
2. Add your *ssh key* here - That will ensure you can always login to your machine via *ssh* in case something goes wrong.
|
2. Add your *ssh key* here - That will ensure you can always login to your machine via *ssh* in case something goes wrong.
|
||||||
|
|
||||||
### (Optional): Renaming Machine
|
### (Optional) Renaming a Machine
|
||||||
|
|
||||||
For renaming jon to your own machine name, you can use the following command:
|
Older templates included static machine folders like `jon` and `sara`.
|
||||||
|
If your setup still uses such static machines, you can rename a machine folder to match your own machine name:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
git mv ./machines/jon ./machines/newname
|
git mv ./machines/jon ./machines/<your-machine-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that our clan lives inside a git repository.
|
Since your Clan configuration lives inside a Git repository, remember:
|
||||||
Only files that have been added with `git add` are recognized by `nix`.
|
|
||||||
So for every file that you add or rename you also need to run:
|
|
||||||
|
|
||||||
```
|
* Only files tracked by Git (`git add`) are recognized.
|
||||||
git add ./path/to/my/file
|
* Whenever you add, rename, or remove files, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ./machines/<your-machine-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
### (Optional): Removing a Machine
|
to stage the changes.
|
||||||
|
|
||||||
If you only want to setup a single machine at this point, you can delete `sara` from `flake.nix` as well as from the machines directory:
|
---
|
||||||
|
|
||||||
```
|
### (Optional) Removing a Machine
|
||||||
|
|
||||||
|
If you want to work with a single machine for now, you can remove other machine entries both from your `flake.nix` and from the `machines` directory. For example, to remove the machine `sara`:
|
||||||
|
|
||||||
|
```bash
|
||||||
git rm -rf ./machines/sara
|
git rm -rf ./machines/sara
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Make sure to also remove or update any references to that machine in your `nix files` or `inventory.json` if you have any of that
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Now that you have created a new machine, we will walk through how to install it.
|
|||||||
!!! Warning "NixOS can cause strange issues when booting in certain cloud environments."
|
!!! Warning "NixOS can cause strange issues when booting in certain cloud environments."
|
||||||
If on Linode: Make sure that the system uses Direct Disk boot kernel (found in the configuration pannel)
|
If on Linode: Make sure that the system uses Direct Disk boot kernel (found in the configuration pannel)
|
||||||
|
|
||||||
### Step 1. Setting `targetHost`
|
## Setting `targetHost`
|
||||||
|
|
||||||
=== "flake.nix (flake-parts)"
|
=== "flake.nix (flake-parts)"
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ Now that you have created a new machine, we will walk through how to install it.
|
|||||||
The use of `root@` in the target address implies SSH access as the `root` user.
|
The use of `root@` in the target address implies SSH access as the `root` user.
|
||||||
Ensure that the root login is secured and only used when necessary.
|
Ensure that the root login is secured and only used when necessary.
|
||||||
|
|
||||||
### Step 2. Identify the Target Disk
|
## Identify the Target Disk
|
||||||
|
|
||||||
On the setup computer, SSH into the target:
|
On the setup computer, SSH into the target:
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ In this example we would copy `nvme-eui.e8238fa6bf530001001b448b4aec2929`
|
|||||||
!!! tip
|
!!! tip
|
||||||
For advanced partitioning, see [Disko templates](https://github.com/nix-community/disko-templates) or [Disko examples](https://github.com/nix-community/disko/tree/master/example).
|
For advanced partitioning, see [Disko templates](https://github.com/nix-community/disko-templates) or [Disko examples](https://github.com/nix-community/disko/tree/master/example).
|
||||||
|
|
||||||
### Step 3. Fill in hardware specific machine configuration
|
## Fill in hardware specific machine configuration
|
||||||
|
|
||||||
Edit the following fields inside the `./machines/<machine_name>/configuration.nix`
|
Edit the following fields inside the `./machines/<machine_name>/configuration.nix`
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
|
|||||||
!!! Info "Replace `__CHANGE_ME__` with the appropriate `ID-LINK` identifier, such as `nvme-eui.e8238fa6bf530001001b448b4aec2929`"
|
!!! Info "Replace `__CHANGE_ME__` with the appropriate `ID-LINK` identifier, such as `nvme-eui.e8238fa6bf530001001b448b4aec2929`"
|
||||||
!!! Info "Replace `__YOUR_SSH_KEY__` with your personal key, like `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoMI0NC5eT9pHlQExrvR5ASV3iW9+BXwhfchq0smXUJ jon@jon-desktop`"
|
!!! Info "Replace `__YOUR_SSH_KEY__` with your personal key, like `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoMI0NC5eT9pHlQExrvR5ASV3iW9+BXwhfchq0smXUJ jon@jon-desktop`"
|
||||||
|
|
||||||
### Step 4. Deploy the machine
|
## Deploy the machine
|
||||||
|
|
||||||
**Finally deployment time!** Use the following command to build and deploy the image via SSH onto your machine.
|
**Finally deployment time!** Use the following command to build and deploy the image via SSH onto your machine.
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
|
|||||||
```
|
```
|
||||||
2. The root password for the installer medium.
|
2. The root password for the installer medium.
|
||||||
This password is autogenerated and meant to be easily typeable.
|
This password is autogenerated and meant to be easily typeable.
|
||||||
3. See how to connect the installer medium to wlan [here](./installer.md#optional-connect-to-wifi-manually).
|
3. See [how to connect to wlan](./installer.md#optional-connect-to-wifi-manually).
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
Use [KDE Connect](https://apps.kde.org/de/kdeconnect/) for easyily sharing QR codes from phone to desktop
|
Use [KDE Connect](https://apps.kde.org/de/kdeconnect/) for easyily sharing QR codes from phone to desktop
|
||||||
@@ -236,21 +236,21 @@ Edit the following fields inside the `./machines/<machine_name>/configuration.ni
|
|||||||
|
|
||||||
Just run the command **Option B: Cloud VM** below
|
Just run the command **Option B: Cloud VM** below
|
||||||
|
|
||||||
#### Deployment Commands
|
### Deployment Commands
|
||||||
|
|
||||||
##### Using password auth
|
#### Using password auth
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clan machines install [MACHINE] --target-host <IP> --update-hardware-config nixos-facter
|
clan machines install [MACHINE] --target-host <IP> --update-hardware-config nixos-facter
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Using QR JSON
|
#### Using QR JSON
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clan machines install [MACHINE] --json "[JSON]" --update-hardware-config nixos-facter
|
clan machines install [MACHINE] --json "[JSON]" --update-hardware-config nixos-facter
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Using QR image file
|
#### Using QR image file
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clan machines install [MACHINE] --png [PATH] --update-hardware-config nixos-facter
|
clan machines install [MACHINE] --png [PATH] --update-hardware-config nixos-facter
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ Ready to create your own Clan and manage a fleet of machines? Follow these simpl
|
|||||||
|
|
||||||
By the end of this guide, you'll have a fresh NixOS configuration ready to push to one or more machines. You'll create a new Git repository and a flake, and all you need is at least one machine to push to. This is the easiest way to begin, and we recommend you to copy your existing configuration into this new setup!
|
By the end of this guide, you'll have a fresh NixOS configuration ready to push to one or more machines. You'll create a new Git repository and a flake, and all you need is at least one machine to push to. This is the easiest way to begin, and we recommend you to copy your existing configuration into this new setup!
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
=== "**Linux**"
|
=== "**Linux**"
|
||||||
|
|
||||||
@@ -37,22 +36,23 @@ By the end of this guide, you'll have a fresh NixOS configuration ready to push
|
|||||||
|
|
||||||
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
|
If you have previously installed Nix, make sure `experimental-features = nix-command flakes` is present in `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`. If this is not the case, please add it to `~/.config/nix/nix.conf`.
|
||||||
|
|
||||||
### Step 1: Add Clan CLI to Your Shell
|
## Add Clan CLI to Your Shell
|
||||||
|
|
||||||
Add the Clan CLI into your development workflow:
|
Add the Clan CLI into your environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix shell git+https://git.clan.lol/clan/clan-core#clan-cli --refresh
|
nix shell git+https://git.clan.lol/clan/clan-core#clan-cli --refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find reference documentation for the `clan` CLI program [here](../../reference/cli/index.md).
|
|
||||||
|
|
||||||
Alternatively you can check out the help pages directly:
|
|
||||||
```terminalSession
|
```terminalSession
|
||||||
clan --help
|
clan --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Initialize Your Project
|
Should print the avilable commands.
|
||||||
|
|
||||||
|
Also checkout the [cli-reference documentation](../../reference/cli/index.md).
|
||||||
|
|
||||||
|
## Initialize Your Project
|
||||||
|
|
||||||
If you want to migrate an existing project, follow this [guide](../migrations/migration-guide.md).
|
If you want to migrate an existing project, follow this [guide](../migrations/migration-guide.md).
|
||||||
|
|
||||||
@@ -62,36 +62,29 @@ Set the foundation of your Clan project by initializing it by running:
|
|||||||
clan flakes create my-clan
|
clan flakes create my-clan
|
||||||
```
|
```
|
||||||
|
|
||||||
This command creates the `flake.nix` and `.clan-flake` files for your project.
|
This command creates a `flake.nix` and some other files for your project.
|
||||||
It will also generate files from a default template, to help show general clan usage patterns.
|
|
||||||
|
|
||||||
### Step 3: Verify the Project Structure
|
## Explore the Project Structure
|
||||||
|
|
||||||
Ensure that all project files exist by running:
|
Take a lookg at all project files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd my-clan
|
cd my-clan
|
||||||
tree
|
tree
|
||||||
```
|
```
|
||||||
|
|
||||||
This should yield the following:
|
For example, you might see something like:
|
||||||
|
|
||||||
``` { .console .no-copy }
|
``` { .console .no-copy }
|
||||||
.
|
.
|
||||||
├── flake.nix
|
├── flake.nix
|
||||||
├── machines
|
├── machines/
|
||||||
│ ├── jon
|
├── modules/
|
||||||
│ │ ├── configuration.nix
|
└── README.md
|
||||||
│ │ └── hardware-configuration.nix
|
|
||||||
│ └── sara
|
|
||||||
│ ├── configuration.nix
|
|
||||||
│ └── hardware-configuration.nix
|
|
||||||
└── modules
|
|
||||||
└── shared.nix
|
|
||||||
|
|
||||||
5 directories, 9 files
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Don’t worry if your output looks different—the template evolves over time.
|
||||||
|
|
||||||
??? info "Recommended way of sourcing the `clan` CLI tool"
|
??? info "Recommended way of sourcing the `clan` CLI tool"
|
||||||
|
|
||||||
The default template adds the `clan` CLI tool to the development shell.
|
The default template adds the `clan` CLI tool to the development shell.
|
||||||
@@ -109,17 +102,23 @@ This should yield the following:
|
|||||||
To automatically add the `clan` CLI tool to your environment without having to
|
To automatically add the `clan` CLI tool to your environment without having to
|
||||||
run `nix develop` every time, we recommend setting up [direnv](https://direnv.net/).
|
run `nix develop` every time, we recommend setting up [direnv](https://direnv.net/).
|
||||||
|
|
||||||
|
```
|
||||||
```bash
|
|
||||||
clan machines list
|
clan machines list
|
||||||
```
|
```
|
||||||
|
|
||||||
``` { .console .no-copy }
|
If you see no output yet, that’s expected — [add machines](./add-machines.md) to populate it.
|
||||||
jon
|
|
||||||
sara
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! success
|
---
|
||||||
|
|
||||||
You just successfully bootstrapped your first Clan.
|
## Next Steps
|
||||||
|
|
||||||
|
You can continue with **any** of the following steps at your own pace:
|
||||||
|
|
||||||
|
- [x] [Install Nix & Clan CLI](./index.md)
|
||||||
|
- [x] [Initialize Clan](./index.md#initialize-your-project)
|
||||||
|
- [ ] [Create USB Installer (optional)](./installer.md)
|
||||||
|
- [ ] [Add Machines](./add-machines.md)
|
||||||
|
- [ ] [Add Services](./add-services.md)
|
||||||
|
- [ ] [Configure Secrets](./secrets.md)
|
||||||
|
- [ ] [Deploy](./deploy.md) - Requires configured secrets
|
||||||
|
- [ ] [Setup CI (optional)](./check.md)
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ To install Clan on physical machines, you need to use our custom installer image
|
|||||||
??? info "Reasons for a Custom Install Image"
|
??? info "Reasons for a Custom Install Image"
|
||||||
Our custom install images are built to include essential tools like [nixos-facter](https://github.com/nix-community/nixos-facter) and support for [ZFS](https://wiki.archlinux.org/title/ZFS). They're also optimized to run on systems with as little as 1 GB of RAM, ensuring efficient performance even on lower-end hardware.
|
Our custom install images are built to include essential tools like [nixos-facter](https://github.com/nix-community/nixos-facter) and support for [ZFS](https://wiki.archlinux.org/title/ZFS). They're also optimized to run on systems with as little as 1 GB of RAM, ensuring efficient performance even on lower-end hardware.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
### Step 0. Prerequisites
|
|
||||||
|
|
||||||
- [x] A free USB Drive with at least 1.5GB (All data on it will be lost)
|
- [x] A free USB Drive with at least 1.5GB (All data on it will be lost)
|
||||||
- [x] Linux/NixOS Machine with Internet
|
- [x] Linux/NixOS Machine with Internet
|
||||||
|
|
||||||
### Step 1. Identify the USB Flash Drive
|
## Identify the USB Flash Drive
|
||||||
|
|
||||||
1. Insert your USB flash drive into your computer.
|
1. Insert your USB flash drive into your computer.
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ To install Clan on physical machines, you need to use our custom installer image
|
|||||||
sudo umount /dev/sdb1
|
sudo umount /dev/sdb1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2. Installer
|
## Installer
|
||||||
|
|
||||||
=== "**Linux OS**"
|
=== "**Linux OS**"
|
||||||
**Create a Custom Installer**
|
**Create a Custom Installer**
|
||||||
@@ -118,7 +117,7 @@ sudo umount /dev/sdb1
|
|||||||
!!! Note
|
!!! Note
|
||||||
If you don't have `wget` installed, you can use `curl --progress-bar -OL <url>` instead.
|
If you don't have `wget` installed, you can use `curl --progress-bar -OL <url>` instead.
|
||||||
|
|
||||||
### Step 2.5 Flash the Installer to the USB Drive
|
## Flash the Installer to the USB Drive
|
||||||
|
|
||||||
!!! Danger "Specifying the wrong device can lead to unrecoverable data loss."
|
!!! Danger "Specifying the wrong device can lead to unrecoverable data loss."
|
||||||
|
|
||||||
@@ -151,11 +150,10 @@ sudo umount /dev/sdb1
|
|||||||
If you need to configure Wi-Fi first, refer to the next section.
|
If you need to configure Wi-Fi first, refer to the next section.
|
||||||
If Multicast-DNS (Avahi) is enabled on your own machine, you can also access the installer using the `nixos-installer.local` address.
|
If Multicast-DNS (Avahi) is enabled on your own machine, you can also access the installer using the `nixos-installer.local` address.
|
||||||
|
|
||||||
|
## Boot From USB Stick
|
||||||
|
|
||||||
### Step 3: Boot From USB Stick
|
|
||||||
- To use, boot from the Clan USB drive with **secure boot turned off**. For step by step instructions go to [Disabling Secure Boot](../../guides/secure-boot.md)
|
- To use, boot from the Clan USB drive with **secure boot turned off**. For step by step instructions go to [Disabling Secure Boot](../../guides/secure-boot.md)
|
||||||
|
|
||||||
|
|
||||||
## (Optional) Connect to Wifi Manually
|
## (Optional) Connect to Wifi Manually
|
||||||
|
|
||||||
If you don't have access via LAN the Installer offers support for connecting via Wifi.
|
If you don't have access via LAN the Installer offers support for connecting via Wifi.
|
||||||
@@ -203,4 +201,3 @@ Press ++ctrl+d++ to exit `IWD`.
|
|||||||
Press ++ctrl+d++ **again** to update the displayed QR code and connection information.
|
Press ++ctrl+d++ **again** to update the displayed QR code and connection information.
|
||||||
|
|
||||||
You're all set up
|
You're all set up
|
||||||
|
|
||||||
|
|||||||
@@ -52,65 +52,6 @@ For more information see the [SOPS] guide on [encrypting with age].
|
|||||||
!!! note
|
!!! note
|
||||||
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
|
It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`.
|
||||||
|
|
||||||
### Using Age Plugins
|
|
||||||
|
|
||||||
If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed.
|
|
||||||
|
|
||||||
You must **precede your secret key with a comment that contains its corresponding recipient**.
|
|
||||||
|
|
||||||
This is usually output as part of the generation process
|
|
||||||
and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key.
|
|
||||||
|
|
||||||
Here is an example:
|
|
||||||
|
|
||||||
```title="~/.config/sops/age/keys.txt"
|
|
||||||
# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l
|
|
||||||
AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV...
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
The comment that precedes the plugin secret key need only contain the recipient.
|
|
||||||
Any other text is ignored.
|
|
||||||
|
|
||||||
In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even
|
|
||||||
just `# age1zdy....`
|
|
||||||
|
|
||||||
You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins
|
|
||||||
are loaded when using Clan:
|
|
||||||
|
|
||||||
```nix title="flake.nix"
|
|
||||||
{
|
|
||||||
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
|
||||||
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
|
||||||
|
|
||||||
outputs =
|
|
||||||
{ self, clan-core, ... }:
|
|
||||||
let
|
|
||||||
clan = clan-core.clanLib.clan {
|
|
||||||
inherit self;
|
|
||||||
|
|
||||||
meta.name = "myclan";
|
|
||||||
|
|
||||||
# Add Yubikey and FIDO2 HMAC plugins
|
|
||||||
# Note: the plugins listed here must be available in nixpkgs.
|
|
||||||
secrets.age.plugins = [
|
|
||||||
"age-plugin-yubikey"
|
|
||||||
"age-plugin-fido2-hmac"
|
|
||||||
];
|
|
||||||
|
|
||||||
machines = {
|
|
||||||
# elided for brevity
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
inherit (clan) nixosConfigurations nixosModules clanInternals;
|
|
||||||
|
|
||||||
# elided for brevity
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add Your Public Key(s)
|
### Add Your Public Key(s)
|
||||||
|
|
||||||
```console
|
```console
|
||||||
@@ -176,3 +117,62 @@ clan secrets users remove-key $USER --age-key <your_public_key>
|
|||||||
[age plugin]: https://github.com/FiloSottile/awesome-age?tab=readme-ov-file#plugins
|
[age plugin]: https://github.com/FiloSottile/awesome-age?tab=readme-ov-file#plugins
|
||||||
[sops]: https://github.com/getsops/sops
|
[sops]: https://github.com/getsops/sops
|
||||||
[encrypting with age]: https://github.com/getsops/sops?tab=readme-ov-file#encrypting-using-age
|
[encrypting with age]: https://github.com/getsops/sops?tab=readme-ov-file#encrypting-using-age
|
||||||
|
|
||||||
|
## Further: Using Age Plugins
|
||||||
|
|
||||||
|
If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed.
|
||||||
|
|
||||||
|
You must **precede your secret key with a comment that contains its corresponding recipient**.
|
||||||
|
|
||||||
|
This is usually output as part of the generation process
|
||||||
|
and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key.
|
||||||
|
|
||||||
|
Here is an example:
|
||||||
|
|
||||||
|
```title="~/.config/sops/age/keys.txt"
|
||||||
|
# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l
|
||||||
|
AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV...
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
The comment that precedes the plugin secret key need only contain the recipient.
|
||||||
|
Any other text is ignored.
|
||||||
|
|
||||||
|
In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even
|
||||||
|
just `# age1zdy....`
|
||||||
|
|
||||||
|
You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins
|
||||||
|
are loaded when using Clan:
|
||||||
|
|
||||||
|
```nix title="flake.nix"
|
||||||
|
{
|
||||||
|
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||||
|
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, clan-core, ... }:
|
||||||
|
let
|
||||||
|
clan = clan-core.lib.clan {
|
||||||
|
inherit self;
|
||||||
|
|
||||||
|
meta.name = "myclan";
|
||||||
|
|
||||||
|
# Add Yubikey and FIDO2 HMAC plugins
|
||||||
|
# Note: the plugins listed here must be available in nixpkgs.
|
||||||
|
secrets.age.plugins = [
|
||||||
|
"age-plugin-yubikey"
|
||||||
|
"age-plugin-fido2-hmac"
|
||||||
|
];
|
||||||
|
|
||||||
|
machines = {
|
||||||
|
# elided for brevity
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit (clan) nixosConfigurations nixosModules clanInternals;
|
||||||
|
|
||||||
|
# elided for brevity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -121,16 +121,3 @@ It is possible to add services to multiple machines via tags as shown
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### API specification
|
|
||||||
|
|
||||||
**The complete schema specification is available [here](../reference/nix-api/inventory.md)**
|
|
||||||
|
|
||||||
Or it can build anytime via:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nix build git+https://git.clan.lol/clan/clan-core#schemas.inventory
|
|
||||||
> result
|
|
||||||
> ├── schema.cue
|
|
||||||
> └── schema.json
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Currently, Clan supports the following features for macOS:
|
|||||||
- `clan machines update` for existing [nix-darwin](https://github.com/nix-darwin/nix-darwin) installations
|
- `clan machines update` for existing [nix-darwin](https://github.com/nix-darwin/nix-darwin) installations
|
||||||
- Support for [vars](../guides/vars-backend.md)
|
- Support for [vars](../guides/vars-backend.md)
|
||||||
|
|
||||||
## Step 1: Add Your Machine to Your Clan Flake
|
## Add Your Machine to Your Clan Flake
|
||||||
|
|
||||||
In this example, we'll name the machine `yourmachine`. Replace this with your preferred machine name.
|
In this example, we'll name the machine `yourmachine`. Replace this with your preferred machine name.
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ clan-core.lib.clan {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 2: Add a `configuration.nix` for Your Machine
|
## Add a `configuration.nix` for Your Machine
|
||||||
|
|
||||||
Create the file `./machines/yourmachine/configuration.nix` with the following content (replace `yourmachine` with your chosen machine name):
|
Create the file `./machines/yourmachine/configuration.nix` with the following content (replace `yourmachine` with your chosen machine name):
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Create the file `./machines/yourmachine/configuration.nix` with the following co
|
|||||||
|
|
||||||
After creating the file, run `git add` to ensure Nix recognizes it.
|
After creating the file, run `git add` to ensure Nix recognizes it.
|
||||||
|
|
||||||
## Step 3: Generate Vars (If Needed)
|
## Generate Vars (If Needed)
|
||||||
|
|
||||||
If your machine uses vars, generate them with:
|
If your machine uses vars, generate them with:
|
||||||
|
|
||||||
@@ -58,12 +58,12 @@ clan vars generate yourmachine
|
|||||||
|
|
||||||
Replace `yourmachine` with your chosen machine name.
|
Replace `yourmachine` with your chosen machine name.
|
||||||
|
|
||||||
## Step 4: Install Nix
|
## Install Nix
|
||||||
|
|
||||||
Install Nix on your macOS machine using one of the methods described in the [nix-darwin prerequisites](https://github.com/nix-darwin/nix-darwin?tab=readme-ov-file#prerequisites).
|
Install Nix on your macOS machine using one of the methods described in the [nix-darwin prerequisites](https://github.com/nix-darwin/nix-darwin?tab=readme-ov-file#prerequisites).
|
||||||
|
|
||||||
|
|
||||||
## Step 5: Install nix-darwin
|
## Install nix-darwin
|
||||||
|
|
||||||
Upload your Clan flake to the macOS machine. Then, from within your flake directory, run:
|
Upload your Clan flake to the macOS machine. Then, from within your flake directory, run:
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ sudo nix run nix-darwin/master#darwin-rebuild -- switch --flake .#yourmachine
|
|||||||
|
|
||||||
Replace `yourmachine` with your chosen machine name.
|
Replace `yourmachine` with your chosen machine name.
|
||||||
|
|
||||||
## Step 6: Manage Your Machine with Clan
|
## Manage Your Machine with Clan
|
||||||
|
|
||||||
Once all the steps above are complete, you can start managing your machine with:
|
Once all the steps above are complete, you can start managing your machine with:
|
||||||
|
|
||||||
|
|||||||
@@ -15,140 +15,86 @@ Clan
|
|||||||
Node B
|
Node B
|
||||||
```
|
```
|
||||||
|
|
||||||
If you select multiple network technologies at the same time. e.g. (zerotier + yggdrassil)
|
This guide shows you how to configure `zerotier` through clan's `Inventory` System.
|
||||||
You must choose one of them as primary network and the machines are always connected via the primary network.
|
|
||||||
|
|
||||||
This guide shows you how to configure `zerotier` either through `NixOS Options` directly, or Clan's `Inventory` System.
|
## The Controller
|
||||||
|
|
||||||
|
The controller is the initial entrypoint for new machines into the vpn.
|
||||||
|
It will sign the id's of new machines.
|
||||||
|
Once id's are signed, the controller's continuous operation is not essential.
|
||||||
|
A good controller choice is nevertheless a machine that can always be reached for updates - so that new peers can be added to the network.
|
||||||
|
|
||||||
=== "**Inventory**"
|
For the purpose of this guide we have two machines:
|
||||||
## 1. Choose the Controller
|
|
||||||
|
|
||||||
The controller is the initial entrypoint for new machines into the vpn.
|
- The `controller` machine, which will be the zerotier controller.
|
||||||
It will sign the id's of new machines.
|
- The `new_machine` machine, which is the machine we want to add to the vpn network.
|
||||||
Once id's are signed, the controller's continuous operation is not essential.
|
|
||||||
A good controller choice is nevertheless a machine that can always be reached for updates - so that new peers can be added to the network.
|
|
||||||
|
|
||||||
For the purpose of this guide we have two machines:
|
## Configure the Service
|
||||||
|
|
||||||
- The `controller` machine, which will be the zerotier controller.
|
```nix {.nix title="flake.nix" hl_lines="19-25"}
|
||||||
- The `new_machine` machine, which is the machine we want to add to the vpn network.
|
{
|
||||||
|
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||||
|
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
||||||
|
|
||||||
## 2. Configure the Inventory
|
outputs =
|
||||||
|
{ self, clan-core, ... }:
|
||||||
|
let
|
||||||
|
clan = clan-core.lib.clan {
|
||||||
|
inherit self;
|
||||||
|
|
||||||
Note: consider picking a more descriptive name for the VPN than "default".
|
meta.name = "myclan";
|
||||||
It will be added as an altname for the Zerotier virtual ethernet interface, and
|
|
||||||
will also be visible in the Zerotier app.
|
|
||||||
|
|
||||||
```nix
|
inventory.machines = {
|
||||||
clan.inventory = {
|
controller = {};
|
||||||
services.zerotier.default = {
|
new_machine = {};
|
||||||
roles.controller.machines = [
|
};
|
||||||
"controller"
|
|
||||||
];
|
inventory.instances = {
|
||||||
roles.peer.machines = [
|
zerotier = {
|
||||||
"new_machine"
|
# Assign the controller machine to the role "controller"
|
||||||
];
|
roles.controller.machines."controller" = {};
|
||||||
|
|
||||||
|
# All clan machines are zerotier peers
|
||||||
|
roles.peer.tags."all" = {};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit (clan) nixosConfigurations nixosModules clanInternals;
|
||||||
|
|
||||||
|
# elided for brevity
|
||||||
};
|
};
|
||||||
```
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Apply the Configuration
|
## Apply the Configuration
|
||||||
Update the `controller` machine:
|
|
||||||
|
|
||||||
```bash
|
Update the `controller` machine first:
|
||||||
clan machines update controller
|
|
||||||
```
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clan machines update controller
|
||||||
|
```
|
||||||
|
|
||||||
=== "**NixOS Options**"
|
Then update all other peers:
|
||||||
## 1. Set-Up the VPN Controller
|
|
||||||
|
|
||||||
The VPN controller is initially essential for providing configuration to new
|
```bash
|
||||||
peers. Once addresses are allocated, the controller's continuous operation is not essential.
|
clan machines update
|
||||||
|
```
|
||||||
|
|
||||||
1. **Designate a Machine**: Label a machine as the VPN controller in the clan,
|
### Verify Connection
|
||||||
referred to as `<CONTROLLER>` henceforth in this guide.
|
|
||||||
2. **Add Configuration**: Input the following configuration to the NixOS
|
|
||||||
configuration of the controller machine:
|
|
||||||
```nix
|
|
||||||
clan.core.networking.zerotier.controller = {
|
|
||||||
enable = true;
|
|
||||||
public = true;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
3. **Update the Controller Machine**: Execute the following:
|
|
||||||
```bash
|
|
||||||
clan machines update <CONTROLLER>
|
|
||||||
```
|
|
||||||
Your machine is now operational as the VPN controller.
|
|
||||||
|
|
||||||
## 2. Add Machines to the VPN
|
On the `new_machine` run:
|
||||||
|
|
||||||
To introduce a new machine to the VPN, adhere to the following steps:
|
```bash
|
||||||
|
$ sudo zerotier-cli info
|
||||||
|
```
|
||||||
|
|
||||||
1. **Update Configuration**: On the new machine, incorporate the following to its
|
The status should be "ONLINE":
|
||||||
configuration, substituting `<CONTROLLER>` with the controller machine name:
|
|
||||||
```nix
|
|
||||||
{ config, ... }: {
|
|
||||||
clan.core.networking.zerotier.networkId = builtins.readFile ../../vars/per-machine/<CONTROLLER>/zerotier/zerotier-network-id/value;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
1. **Update the New Machine**: Execute:
|
|
||||||
```bash
|
|
||||||
$ clan machines update <NEW_MACHINE>
|
|
||||||
```
|
|
||||||
Replace `<NEW_MACHINE>` with the designated new machine name.
|
|
||||||
|
|
||||||
!!! Note "For Private Networks"
|
```{.console, .no-copy}
|
||||||
1. **Retrieve Zerotier Metadata**
|
200 info d2c71971db 1.12.1 ONLINE
|
||||||
|
```
|
||||||
=== "From the repo"
|
|
||||||
**Retrieve the ZeroTier IP**: In the clan repo, execute:
|
|
||||||
```console
|
|
||||||
$ clan facts list <NEW_MACHINE> | jq -r '.["zerotier-ip"]'
|
|
||||||
```
|
|
||||||
|
|
||||||
The returned address is the Zerotier IP address of the machine.
|
|
||||||
|
|
||||||
=== "On the new machine"
|
|
||||||
**Retrieve the ZeroTier ID**: On the `new_machine`, execute:
|
|
||||||
```bash
|
|
||||||
$ sudo zerotier-cli info
|
|
||||||
```
|
|
||||||
Example Output:
|
|
||||||
```{.console, .no-copy}
|
|
||||||
200 info d2c71971db 1.12.1 OFFLINE
|
|
||||||
```
|
|
||||||
, where `d2c71971db` is the ZeroTier ID.
|
|
||||||
|
|
||||||
|
|
||||||
2. **Authorize the New Machine on the Controller**: On the controller machine,
|
|
||||||
execute:
|
|
||||||
|
|
||||||
=== "with ZerotierIP"
|
|
||||||
```bash
|
|
||||||
$ sudo zerotier-members allow --member-ip <IP>
|
|
||||||
```
|
|
||||||
Substitute `<IP>` with the ZeroTier IP obtained previously.
|
|
||||||
=== "with ZerotierID"
|
|
||||||
```bash
|
|
||||||
$ sudo zerotier-members allow <ID>
|
|
||||||
```
|
|
||||||
Substitute `<ID>` with the ZeroTier ID obtained previously.
|
|
||||||
|
|
||||||
2. **Verify Connection**: On the `new_machine`, re-execute:
|
|
||||||
```bash
|
|
||||||
$ sudo zerotier-cli info
|
|
||||||
```
|
|
||||||
The status should now be "ONLINE":
|
|
||||||
```{.console, .no-copy}
|
|
||||||
200 info d2c71971db 1.12.1 ONLINE
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! success "Congratulations!"
|
|
||||||
The new machine is now part of the VPN, and the ZeroTier
|
|
||||||
configuration on NixOS within the Clan project is complete.
|
|
||||||
|
|
||||||
## Further
|
## Further
|
||||||
|
|
||||||
@@ -158,3 +104,45 @@ In the future we plan to add additional network technologies like tinc, head/tai
|
|||||||
We chose zerotier because in our tests it was a straight forwards solution to bootstrap.
|
We chose zerotier because in our tests it was a straight forwards solution to bootstrap.
|
||||||
It allows you to selfhost a controller and the controller doesn't need to be globally reachable.
|
It allows you to selfhost a controller and the controller doesn't need to be globally reachable.
|
||||||
Which made it a good fit for starting the project.
|
Which made it a good fit for starting the project.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Retrieve the ZeroTier ID
|
||||||
|
|
||||||
|
In the repo:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ clan vars list <machineName>
|
||||||
|
```
|
||||||
|
|
||||||
|
```{.console, .no-copy}
|
||||||
|
$ clan vars list controller
|
||||||
|
# ... elided
|
||||||
|
zerotier/zerotier-identity-secret: ********
|
||||||
|
zerotier/zerotier-ip: fd0a:b849:2928:1234:c99:930a:a959:2928
|
||||||
|
zerotier/zerotier-network-id: 0aa959282834000c
|
||||||
|
```
|
||||||
|
|
||||||
|
On the machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo zerotier-cli info
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manually Authorize a Machine on the Controller
|
||||||
|
|
||||||
|
=== "with ZerotierIP"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo zerotier-members allow --member-ip <IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<IP>` with the ZeroTier IP obtained previously.
|
||||||
|
|
||||||
|
=== "with ZerotierID"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo zerotier-members allow <ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute `<ID>` with the ZeroTier ID obtained previously.
|
||||||
@@ -74,9 +74,7 @@ instances = {
|
|||||||
|
|
||||||
## Steps to Migrate
|
## Steps to Migrate
|
||||||
|
|
||||||
|
### Move `services` entries to `instances`
|
||||||
|
|
||||||
### 1. Move `services` entries to `instances`
|
|
||||||
|
|
||||||
Check if a service that you use has been migrated [In our reference](../../reference/clanServices/index.md)
|
Check if a service that you use has been migrated [In our reference](../../reference/clanServices/index.md)
|
||||||
|
|
||||||
@@ -96,7 +94,7 @@ Each nested service-instance-pair becomes a flat key, like `borgbackup.simple
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Add `module.name` and `module.input`
|
### Add `module.name` and `module.input`
|
||||||
|
|
||||||
Each instance must declare the module name and flake input it comes from:
|
Each instance must declare the module name and flake input it comes from:
|
||||||
|
|
||||||
@@ -117,7 +115,7 @@ Then refer to it as `input = "clan-core"`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Move role and machine config under `roles`
|
### Move role and machine config under `roles`
|
||||||
|
|
||||||
In the new system:
|
In the new system:
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org/wiki/Secure_Boot). Therefore, you need to disable it in the BIOS. You can watch this [video guide](https://www.youtube.com/watch?v=BKVShiMUePc) or follow the instructions below:
|
At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org/wiki/Secure_Boot). Therefore, you need to disable it in the BIOS. You can watch this [video guide](https://www.youtube.com/watch?v=BKVShiMUePc) or follow the instructions below:
|
||||||
|
|
||||||
### Step 1: Insert the USB Stick
|
## Insert the USB Stick
|
||||||
|
|
||||||
- Begin by inserting the USB stick into a USB port on your computer.
|
- Begin by inserting the USB stick into a USB port on your computer.
|
||||||
|
|
||||||
### Step 2: Access the UEFI/BIOS Menu
|
## Access the UEFI/BIOS Menu
|
||||||
|
|
||||||
- Restart your computer.
|
- Restart your computer.
|
||||||
- As your computer restarts, press the appropriate key to enter the UEFI/BIOS settings.
|
- As your computer restarts, press the appropriate key to enter the UEFI/BIOS settings.
|
||||||
??? tip "The key depends on your laptop or motherboard manufacturer. Click to see a reference list:"
|
??? tip "The key depends on your laptop or motherboard manufacturer. Click to see a reference list:"
|
||||||
@@ -32,18 +34,22 @@ At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org
|
|||||||
!!! Note
|
!!! Note
|
||||||
Pressing the key quickly and repeatedly is sometimes necessary to access the UEFI/BIOS menu, as the window to enter this mode is brief.
|
Pressing the key quickly and repeatedly is sometimes necessary to access the UEFI/BIOS menu, as the window to enter this mode is brief.
|
||||||
|
|
||||||
### Step 3: Access Advanced Mode (Optional)
|
## Access Advanced Mode (Optional)
|
||||||
|
|
||||||
- If your UEFI/BIOS has a `Simple` or `Easy` mode interface, look for an option labeled `Advanced Mode` (often found in the lower right corner).
|
- If your UEFI/BIOS has a `Simple` or `Easy` mode interface, look for an option labeled `Advanced Mode` (often found in the lower right corner).
|
||||||
- Click on `Advanced Mode` to access more settings. This step is optional, as your boot settings might be available in the basic view.
|
- Click on `Advanced Mode` to access more settings. This step is optional, as your boot settings might be available in the basic view.
|
||||||
|
|
||||||
### Step 4: Disable Secure Boot
|
## Disable Secure Boot
|
||||||
|
|
||||||
- Locate the `Secure Boot` option in your UEFI/BIOS settings. This is typically found under a `Security` tab, `Boot` tab, or a similarly named section.
|
- Locate the `Secure Boot` option in your UEFI/BIOS settings. This is typically found under a `Security` tab, `Boot` tab, or a similarly named section.
|
||||||
- Set the `Secure Boot` option to `Disabled`.
|
- Set the `Secure Boot` option to `Disabled`.
|
||||||
|
|
||||||
### Step 5: Change Boot Order
|
## Change Boot Order
|
||||||
|
|
||||||
- Find the option to adjust the boot order—often labeled `Boot Order`, `Boot Sequence`, or `Boot Priority`.
|
- Find the option to adjust the boot order—often labeled `Boot Order`, `Boot Sequence`, or `Boot Priority`.
|
||||||
- Ensure that your USB device is set as the first boot option. This allows your computer to boot from the USB stick.
|
- Ensure that your USB device is set as the first boot option. This allows your computer to boot from the USB stick.
|
||||||
|
|
||||||
### Step 6: Save and Exit
|
## Save and Exit
|
||||||
|
|
||||||
- Save your changes before exiting the UEFI/BIOS menu. Look for a `Save & Exit` option or press the corresponding function key (often `F10`).
|
- Save your changes before exiting the UEFI/BIOS menu. Look for a `Save & Exit` option or press the corresponding function key (often `F10`).
|
||||||
- Your computer should now restart and boot from the USB stick.
|
- Your computer should now restart and boot from the USB stick.
|
||||||
|
|||||||
7
docs/site/intern/api.md
Normal file
7
docs/site/intern/api.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
template: options.html
|
||||||
|
hide:
|
||||||
|
- navigation
|
||||||
|
- toc
|
||||||
|
---
|
||||||
|
<redoc src="/openapi.json" />
|
||||||
25
docs/site/intern/index.md
Normal file
25
docs/site/intern/index.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Developer Documentation
|
||||||
|
|
||||||
|
!!! Danger
|
||||||
|
This documentation is **not** intended for external users. It may contain low-level details and internal-only interfaces.*
|
||||||
|
|
||||||
|
Welcome to the internal developer documentation.
|
||||||
|
|
||||||
|
This section is intended for contributors, engineers, and internal stakeholders working directly with our system, tooling, and APIs. It provides a technical overview of core components, internal APIs, conventions, and patterns that support the platform.
|
||||||
|
|
||||||
|
Our goal is to make the internal workings of the system **transparent, discoverable, and consistent** — helping you contribute confidently, troubleshoot effectively, and build faster.
|
||||||
|
|
||||||
|
## What's Here?
|
||||||
|
|
||||||
|
!!! note "docs migration ongoing"
|
||||||
|
|
||||||
|
- [ ] **API Reference**: 🚧🚧🚧 Detailed documentation of internal API functions, inputs, and expected outputs. 🚧🚧🚧
|
||||||
|
- [ ] **System Concepts**: Architectural overviews and domain-specific guides.
|
||||||
|
- [ ] **Development Guides**: How to test, extend, or integrate with key components.
|
||||||
|
- [ ] **Design Notes**: Rationales behind major design decisions or patterns.
|
||||||
|
|
||||||
|
## Who is This For?
|
||||||
|
|
||||||
|
* Developers contributing to the platform
|
||||||
|
* Engineers debugging or extending internal systems
|
||||||
|
* Anyone needing to understand **how** and **why** things work under the hood
|
||||||
18
flake.lock
generated
18
flake.lock
generated
@@ -34,11 +34,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1750903843,
|
"lastModified": 1751607816,
|
||||||
"narHash": "sha256-Ng9+f0H5/dW+mq/XOKvB9uwvGbsuiiO6HrPdAcVglCs=",
|
"narHash": "sha256-5PtrwjqCIJ4DKQhzYdm8RFePBuwb+yTzjV52wWoGSt4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "disko",
|
"repo": "disko",
|
||||||
"rev": "83c4da299c1d7d300f8c6fd3a72ac46cb0d59aae",
|
"rev": "da6109c917b48abc1f76dd5c9bf3901c8c80f662",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -164,10 +164,10 @@
|
|||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 315532800,
|
"lastModified": 315532800,
|
||||||
"narHash": "sha256-VgDAFPxHNhCfC7rI5I5wFqdiVJBH43zUefVo8hwo7cI=",
|
"narHash": "sha256-0HRxGUoOMtOYnwlMWY0AkuU88WHaI3Q5GEILmsWpI8U=",
|
||||||
"rev": "41da1e3ea8e23e094e5e3eeb1e6b830468a7399e",
|
"rev": "a48741b083d4f36dd79abd9f760c84da6b4dc0e5",
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre814815.41da1e3ea8e2/nixexprs.tar.xz"
|
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre823094.a48741b083d4/nixexprs.tar.xz"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"type": "tarball",
|
"type": "tarball",
|
||||||
@@ -221,11 +221,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1750119275,
|
"lastModified": 1751606940,
|
||||||
"narHash": "sha256-Rr7Pooz9zQbhdVxux16h7URa6mA80Pb/G07T4lHvh0M=",
|
"narHash": "sha256-KrDPXobG7DFKTOteqdSVeL1bMVitDcy7otpVZWDE6MA=",
|
||||||
"owner": "Mic92",
|
"owner": "Mic92",
|
||||||
"repo": "sops-nix",
|
"repo": "sops-nix",
|
||||||
"rev": "77c423a03b9b2b79709ea2cb63336312e78b72e2",
|
"rev": "3633fc4acf03f43b260244d94c71e9e14a2f6e0d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ in
|
|||||||
options = {
|
options = {
|
||||||
# ModuleSpec
|
# ModuleSpec
|
||||||
module = lib.mkOption {
|
module = lib.mkOption {
|
||||||
|
default = { };
|
||||||
type = types.submodule {
|
type = types.submodule {
|
||||||
options.input = lib.mkOption {
|
options.input = lib.mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.nullOr types.str;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ find = {}
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
test_driver = ["py.typed"]
|
test_driver = ["py.typed"]
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.13"
|
||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
disallow_untyped_calls = true
|
disallow_untyped_calls = true
|
||||||
disallow_untyped_defs = true
|
disallow_untyped_defs = true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
./nixos-facter.nix
|
./nixos-facter.nix
|
||||||
./vm.nix
|
./vm.nix
|
||||||
./machine-id
|
./machine-id
|
||||||
|
./state-version
|
||||||
./wayland-proxy-virtwl.nix
|
./wayland-proxy-virtwl.nix
|
||||||
./zerotier
|
./zerotier
|
||||||
./zfs.nix
|
./zfs.nix
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
pkgs,
|
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
@@ -24,6 +23,14 @@
|
|||||||
description = ''
|
description = ''
|
||||||
the location of the deployment.json file
|
the location of the deployment.json file
|
||||||
'';
|
'';
|
||||||
|
default = throw ''
|
||||||
|
deployment.json file generation has been removed in favor of direct selectors.
|
||||||
|
|
||||||
|
Please upgrade your clan-cli to the latest version.
|
||||||
|
|
||||||
|
The deployment data is now accessed directly from the configuration
|
||||||
|
instead of being written to a separate JSON file.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
deployment.buildHost = lib.mkOption {
|
deployment.buildHost = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
@@ -83,8 +90,5 @@
|
|||||||
inherit (config.system.clan.deployment) nixosMobileWorkaround;
|
inherit (config.system.clan.deployment) nixosMobileWorkaround;
|
||||||
inherit (config.clan.deployment) requireExplicitUpdate;
|
inherit (config.clan.deployment) requireExplicitUpdate;
|
||||||
};
|
};
|
||||||
system.clan.deployment.file = pkgs.writeText "deployment.json" (
|
|
||||||
builtins.toJSON config.system.clan.deployment.data
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg",
|
||||||
|
"type": "age"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.11
|
||||||
@@ -73,10 +73,5 @@ in
|
|||||||
) [ ] (lib.attrValues generator.files)
|
) [ ] (lib.attrValues generator.files)
|
||||||
) [ ] (lib.attrValues config.clan.core.vars.generators);
|
) [ ] (lib.attrValues config.clan.core.vars.generators);
|
||||||
|
|
||||||
system.clan.deployment.data = {
|
|
||||||
vars = config.clan.core.vars._serialized;
|
|
||||||
inherit (config.clan.core.networking) targetHost buildHost;
|
|
||||||
inherit (config.clan.core.deployment) requireExplicitUpdate;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,50 +34,6 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
_serialized = lib.mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
internal = true;
|
|
||||||
description = ''
|
|
||||||
JSON serialization of the generators.
|
|
||||||
This is read from the python client to generate the specified resources.
|
|
||||||
'';
|
|
||||||
default = {
|
|
||||||
# TODO: We don't support per-machine choice of backends
|
|
||||||
# Configuring different backend doesn't work, this information should be made read only and configured
|
|
||||||
# Via clan.settings instead.
|
|
||||||
inherit (config.settings) secretModule publicModule;
|
|
||||||
# Serialize generators, so that we can use them in the python client
|
|
||||||
# This need to be done because we have some non-serializable values in the generators
|
|
||||||
# Like the finalScript (derivation) or pkgs.
|
|
||||||
generators = lib.flip lib.mapAttrs config.generators (
|
|
||||||
_name: generator: {
|
|
||||||
inherit (generator)
|
|
||||||
name
|
|
||||||
dependencies
|
|
||||||
validationHash
|
|
||||||
migrateFact
|
|
||||||
share
|
|
||||||
prompts
|
|
||||||
;
|
|
||||||
|
|
||||||
files = lib.flip lib.mapAttrs generator.files (
|
|
||||||
_name: file: {
|
|
||||||
inherit (file)
|
|
||||||
name
|
|
||||||
owner
|
|
||||||
group
|
|
||||||
mode
|
|
||||||
deploy
|
|
||||||
secret
|
|
||||||
neededFor
|
|
||||||
;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
settings = import ./settings-opts.nix { inherit lib; };
|
settings = import ./settings-opts.nix { inherit lib; };
|
||||||
generators = lib.mkOption {
|
generators = lib.mkOption {
|
||||||
description = ''
|
description = ''
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
config = {
|
config = {
|
||||||
system.clan.deployment.data.password-store.secretLocation =
|
|
||||||
config.clan.vars.password-store.secretLocation;
|
|
||||||
clan.core.vars.settings =
|
clan.core.vars.settings =
|
||||||
lib.mkIf (config.clan.core.vars.settings.secretStore == "password-store")
|
lib.mkIf (config.clan.core.vars.settings.secretStore == "password-store")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: N801
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import clan_lib.machines.actions # noqa: F401
|
import clan_lib.machines.actions # noqa: F401
|
||||||
from clan_lib.api import API, tasks
|
from clan_lib.api import API, load_in_all_api_functions, tasks
|
||||||
|
|
||||||
# TODO: We have to manually import python files to make the API.register be triggered.
|
|
||||||
# We NEED to fix this, as this is super unintuitive and error-prone.
|
|
||||||
from clan_lib.api.tasks import list_tasks as dummy_list # noqa: F401
|
|
||||||
from clan_lib.custom_logger import setup_logging
|
from clan_lib.custom_logger import setup_logging
|
||||||
from clan_lib.dirs import user_data_dir
|
from clan_lib.dirs import user_data_dir
|
||||||
from clan_lib.log_manager import LogManager
|
from clan_lib.log_manager import LogGroupConfig, LogManager
|
||||||
from clan_lib.log_manager import api as log_manager_api
|
from clan_lib.log_manager import api as log_manager_api
|
||||||
|
|
||||||
from clan_app.api.file_gtk import open_file
|
from clan_app.api.file_gtk import open_file
|
||||||
@@ -45,16 +41,22 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
|||||||
|
|
||||||
webview = Webview(debug=app_opts.debug)
|
webview = Webview(debug=app_opts.debug)
|
||||||
webview.title = "Clan App"
|
webview.title = "Clan App"
|
||||||
# This seems to call the gtk api correctly but and gtk also seems to our icon, but somehow the icon is not loaded.
|
|
||||||
|
|
||||||
# Init LogManager global in log_manager_api module
|
# Add a log group ["clans", <dynamic_name>, "machines", <dynamic_name>]
|
||||||
log_manager_api.LOG_MANAGER_INSTANCE = LogManager(
|
log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs")
|
||||||
base_dir=user_data_dir() / "clan-app" / "logs"
|
clan_log_group = LogGroupConfig("clans", "Clans").add_child(
|
||||||
|
LogGroupConfig("machines", "Machines")
|
||||||
)
|
)
|
||||||
|
log_manager = log_manager.add_root_group_config(clan_log_group)
|
||||||
|
# Init LogManager global in log_manager_api module
|
||||||
|
log_manager_api.LOG_MANAGER_INSTANCE = log_manager
|
||||||
|
|
||||||
# Init BAKEND_THREADS in tasks module
|
# Init BAKEND_THREADS global in tasks module
|
||||||
tasks.BAKEND_THREADS = webview.threads
|
tasks.BAKEND_THREADS = webview.threads
|
||||||
|
|
||||||
|
# Populate the API global with all functions
|
||||||
|
load_in_all_api_functions()
|
||||||
|
|
||||||
API.overwrite_fn(open_file)
|
API.overwrite_fn(open_file)
|
||||||
webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE)
|
webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE)
|
||||||
webview.size = Size(1280, 1024, SizeHint.NONE)
|
webview.size = Size(1280, 1024, SizeHint.NONE)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# ruff: noqa: TRY301
|
||||||
import functools
|
import functools
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -66,15 +67,24 @@ class Webview:
|
|||||||
) -> None:
|
) -> None:
|
||||||
op_key = op_key_bytes.decode()
|
op_key = op_key_bytes.decode()
|
||||||
args = json.loads(request_data.decode())
|
args = json.loads(request_data.decode())
|
||||||
log.debug(f"Calling {method_name}({args})")
|
log.debug(f"Calling {method_name}({json.dumps(args, indent=4)})")
|
||||||
header: dict[str, Any]
|
header: dict[str, Any]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Initialize dataclasses from the payload
|
# Initialize dataclasses from the payload
|
||||||
reconciled_arguments = {}
|
reconciled_arguments = {}
|
||||||
if len(args) > 1:
|
if len(args) == 1:
|
||||||
header = args[1]
|
request = args[0]
|
||||||
for k, v in args[0].items():
|
header = request.get("header", {})
|
||||||
|
msg = f"Expected header to be a dict, got {type(header)}"
|
||||||
|
if not isinstance(header, dict):
|
||||||
|
raise TypeError(msg)
|
||||||
|
body = request.get("body", {})
|
||||||
|
msg = f"Expected body to be a dict, got {type(body)}"
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
for k, v in body.items():
|
||||||
# Some functions expect to be called with dataclass instances
|
# Some functions expect to be called with dataclass instances
|
||||||
# But the js api returns dictionaries.
|
# But the js api returns dictionaries.
|
||||||
# Introspect the function and create the expected dataclass from dict dynamically
|
# Introspect the function and create the expected dataclass from dict dynamically
|
||||||
@@ -84,8 +94,11 @@ class Webview:
|
|||||||
# TODO: rename from_dict into something like construct_checked_value
|
# TODO: rename from_dict into something like construct_checked_value
|
||||||
# from_dict really takes Anything and returns an instance of the type/class
|
# from_dict really takes Anything and returns an instance of the type/class
|
||||||
reconciled_arguments[k] = from_dict(arg_class, v)
|
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||||
elif len(args) == 1:
|
elif len(args) > 1:
|
||||||
header = args[0]
|
msg = (
|
||||||
|
"Expected a single argument, got multiple arguments to api_wrapper"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
reconciled_arguments["op_key"] = op_key
|
reconciled_arguments["op_key"] = op_key
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -110,17 +123,39 @@ class Webview:
|
|||||||
def thread_task(stop_event: threading.Event) -> None:
|
def thread_task(stop_event: threading.Event) -> None:
|
||||||
ctx: AsyncContext = get_async_ctx()
|
ctx: AsyncContext = get_async_ctx()
|
||||||
ctx.should_cancel = lambda: stop_event.is_set()
|
ctx.should_cancel = lambda: stop_event.is_set()
|
||||||
# If the API call has set log_group in metadata,
|
|
||||||
# create the log file under that group.
|
|
||||||
log_group = header.get("logging", {}).get("group", None)
|
|
||||||
if log_group is not None:
|
|
||||||
log.warning(
|
|
||||||
f"Using log group {log_group} for {method_name} with op_key {op_key}"
|
|
||||||
)
|
|
||||||
|
|
||||||
log_file = log_manager.create_log_file(
|
try:
|
||||||
wrap_method, op_key=op_key, group=log_group
|
# If the API call has set log_group in metadata,
|
||||||
).get_file_path()
|
# create the log file under that group.
|
||||||
|
log_group: list[str] = header.get("logging", {}).get("group_path", None)
|
||||||
|
if log_group is not None:
|
||||||
|
if not isinstance(log_group, list):
|
||||||
|
msg = f"Expected log_group to be a list, got {type(log_group)}"
|
||||||
|
raise TypeError(msg)
|
||||||
|
log.warning(
|
||||||
|
f"Using log group {log_group} for {method_name} with op_key {op_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_file = log_manager.create_log_file(
|
||||||
|
wrap_method, op_key=op_key, group_path=log_group
|
||||||
|
).get_file_path()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error while handling request header of {method_name}")
|
||||||
|
result = ErrorDataClass(
|
||||||
|
op_key=op_key,
|
||||||
|
status="error",
|
||||||
|
errors=[
|
||||||
|
ApiError(
|
||||||
|
message="An internal error occured",
|
||||||
|
description=str(e),
|
||||||
|
location=["header_middleware", method_name],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
serialized = json.dumps(
|
||||||
|
dataclass_to_dict(result), indent=4, ensure_ascii=False
|
||||||
|
)
|
||||||
|
self.return_(op_key, FuncStatus.SUCCESS, serialized)
|
||||||
|
|
||||||
with log_file.open("ab") as log_f:
|
with log_file.open("ab") as log_f:
|
||||||
# Redirect all cmd.run logs to this file.
|
# Redirect all cmd.run logs to this file.
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
{
|
{
|
||||||
perSystem =
|
perSystem =
|
||||||
{
|
{
|
||||||
lib,
|
|
||||||
self',
|
self',
|
||||||
pkgs,
|
pkgs,
|
||||||
config,
|
config,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
packages =
|
packages = {
|
||||||
{
|
webview-lib = pkgs.callPackage ./webview-lib { };
|
||||||
webview-lib = pkgs.callPackage ./webview-lib { };
|
clan-app = pkgs.callPackage ./default.nix {
|
||||||
clan-app = pkgs.callPackage ./default.nix {
|
inherit (config.packages) clan-cli clan-app-ui webview-lib;
|
||||||
inherit (config.packages) clan-cli clan-app-ui webview-lib;
|
pythonRuntime = pkgs.python3;
|
||||||
pythonRuntime = pkgs.python3;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
fonts = pkgs.callPackage ./fonts.nix { };
|
fonts = pkgs.callPackage ./fonts.nix { };
|
||||||
|
|
||||||
clan-app-ui = pkgs.callPackage ./ui.nix {
|
clan-app-ui = pkgs.callPackage ./ui.nix {
|
||||||
clan-ts-api = config.packages.clan-ts-api;
|
clan-ts-api = config.packages.clan-ts-api;
|
||||||
fonts = config.packages.fonts;
|
fonts = config.packages.fonts;
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
};
|
||||||
//
|
# //
|
||||||
# todo add darwin support
|
# todo add darwin support
|
||||||
(lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
# todo re-enable
|
||||||
clan-app-ui-storybook = self'.packages.clan-app-ui.storybook;
|
# see ui.nix for an explanation of why this is disabled for now
|
||||||
});
|
# (lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||||
|
# clan-app-ui-storybook = self'.packages.clan-app-ui.storybook;
|
||||||
|
# });
|
||||||
|
|
||||||
devShells.clan-app = pkgs.callPackage ./shell.nix {
|
devShells.clan-app = pkgs.callPackage ./shell.nix {
|
||||||
inherit self';
|
inherit self';
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ norecursedirs = "tests/helpers"
|
|||||||
markers = ["impure"]
|
markers = ["impure"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.13"
|
||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
disallow_untyped_calls = true
|
disallow_untyped_calls = true
|
||||||
disallow_untyped_defs = true
|
disallow_untyped_defs = true
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def wayland_compositor() -> Generator[Popen, None, None]:
|
def wayland_compositor() -> Generator[Popen]:
|
||||||
# Start the Wayland compositor (e.g., Weston)
|
# Start the Wayland compositor (e.g., Weston)
|
||||||
# compositor = Popen(["weston", "--backend=headless-backend.so"])
|
# compositor = Popen(["weston", "--backend=headless-backend.so"])
|
||||||
compositor = Popen(["weston"])
|
compositor = Popen(["weston"])
|
||||||
@@ -20,7 +20,7 @@ GtkProc = NewType("GtkProc", Popen)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app() -> Generator[GtkProc, None, None]:
|
def app() -> Generator[GtkProc]:
|
||||||
cmd = [sys.executable, "-m", "clan_app"]
|
cmd = [sys.executable, "-m", "clan_app"]
|
||||||
print(f"Running: {cmd}")
|
print(f"Running: {cmd}")
|
||||||
rapp = Popen(
|
rapp = Popen(
|
||||||
|
|||||||
@@ -23,42 +23,25 @@ export type SuccessQuery<T extends OperationNames> = Extract<
|
|||||||
>;
|
>;
|
||||||
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
||||||
|
|
||||||
function isMachine(obj: unknown): obj is Machine {
|
interface SendHeaderType {
|
||||||
return (
|
logging?: { group_path: string[] };
|
||||||
!!obj &&
|
}
|
||||||
typeof obj === "object" &&
|
interface BackendSendType<K extends OperationNames> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
body: OperationArgs<K>;
|
||||||
typeof (obj as any).name === "string" &&
|
header?: SendHeaderType;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
typeof (obj as any).flake === "object" &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
typeof (obj as any).flake.identifier === "string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Machine type with flake for API calls
|
|
||||||
interface Machine {
|
|
||||||
name: string;
|
|
||||||
flake: {
|
|
||||||
identifier: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendOpts {
|
|
||||||
logging?: { group: string | Machine };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface ReceiveHeaderType {}
|
||||||
interface BackendReturnType<K extends OperationNames> {
|
interface BackendReturnType<K extends OperationNames> {
|
||||||
body: OperationResponse<K>;
|
body: OperationResponse<K>;
|
||||||
|
header: ReceiveHeaderType;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
header: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _callApi = <K extends OperationNames>(
|
const _callApi = <K extends OperationNames>(
|
||||||
method: K,
|
method: K,
|
||||||
args: OperationArgs<K>,
|
args: OperationArgs<K>,
|
||||||
backendOpts?: BackendOpts,
|
backendOpts?: SendHeaderType,
|
||||||
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
||||||
// if window[method] does not exist, throw an error
|
// if window[method] does not exist, throw an error
|
||||||
if (!(method in window)) {
|
if (!(method in window)) {
|
||||||
@@ -82,26 +65,19 @@ const _callApi = <K extends OperationNames>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let header: BackendOpts = {};
|
const message: BackendSendType<OperationNames> = {
|
||||||
if (backendOpts != undefined) {
|
body: args,
|
||||||
header = { ...backendOpts };
|
header: backendOpts,
|
||||||
const group = backendOpts?.logging?.group;
|
};
|
||||||
if (group != undefined && isMachine(group)) {
|
|
||||||
header = {
|
|
||||||
logging: { group: group.flake.identifier + "#" + group.name },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = (
|
const promise = (
|
||||||
window as unknown as Record<
|
window as unknown as Record<
|
||||||
OperationNames,
|
OperationNames,
|
||||||
(
|
(
|
||||||
args: OperationArgs<OperationNames>,
|
args: BackendSendType<OperationNames>,
|
||||||
metadata: BackendOpts,
|
|
||||||
) => Promise<BackendReturnType<OperationNames>>
|
) => Promise<BackendReturnType<OperationNames>>
|
||||||
>
|
>
|
||||||
)[method](args, header) as Promise<BackendReturnType<K>>;
|
)[method](message) as Promise<BackendReturnType<K>>;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const op_key = (promise as any)._webviewMessageId as string;
|
const op_key = (promise as any)._webviewMessageId as string;
|
||||||
@@ -153,7 +129,7 @@ const handleCancel = async <K extends OperationNames>(
|
|||||||
export const callApi = <K extends OperationNames>(
|
export const callApi = <K extends OperationNames>(
|
||||||
method: K,
|
method: K,
|
||||||
args: OperationArgs<K>,
|
args: OperationArgs<K>,
|
||||||
backendOpts?: BackendOpts,
|
backendOpts?: SendHeaderType,
|
||||||
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
||||||
console.log("Calling API", method, args, backendOpts);
|
console.log("Calling API", method, args, backendOpts);
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export function RemoteForm(props: RemoteFormProps) {
|
|||||||
props.queryFn,
|
props.queryFn,
|
||||||
props.machine?.name,
|
props.machine?.name,
|
||||||
props.machine?.flake,
|
props.machine?.flake,
|
||||||
|
props.machine?.flake.identifier,
|
||||||
props.field || "targetHost",
|
props.field || "targetHost",
|
||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -209,7 +210,12 @@ export function RemoteForm(props: RemoteFormProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: {
|
logging: {
|
||||||
group: { name: props.machine.name, flake: props.machine.flake },
|
group_path: [
|
||||||
|
"clans",
|
||||||
|
props.machine.flake.identifier,
|
||||||
|
"machines",
|
||||||
|
props.machine.name,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
|||||||
flake: { identifier: active_clan },
|
flake: { identifier: active_clan },
|
||||||
name: name,
|
name: name,
|
||||||
},
|
},
|
||||||
{ logging: { group: { name, flake: { identifier: active_clan } } } },
|
{
|
||||||
|
logging: { group_path: ["clans", active_clan, "machines", name] },
|
||||||
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|
||||||
if (target_host.status == "error") {
|
if (target_host.status == "error") {
|
||||||
@@ -115,7 +117,9 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
|||||||
name: name,
|
name: name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: { group: { name, flake: { identifier: active_clan } } },
|
logging: {
|
||||||
|
group_path: ["clans", active_clan, "machines", name],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|
||||||
@@ -141,7 +145,11 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
|||||||
flake: { identifier: active_clan },
|
flake: { identifier: active_clan },
|
||||||
name: name,
|
name: name,
|
||||||
},
|
},
|
||||||
{ logging: { group: { name, flake: { identifier: active_clan } } } },
|
{
|
||||||
|
logging: {
|
||||||
|
group_path: ["clans", active_clan, "machines", name],
|
||||||
|
},
|
||||||
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|
||||||
if (build_host.status == "error") {
|
if (build_host.status == "error") {
|
||||||
@@ -166,7 +174,11 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
|||||||
target_host: target_host.data!.data,
|
target_host: target_host.data!.data,
|
||||||
build_host: build_host.data?.data || null,
|
build_host: build_host.data?.data || null,
|
||||||
},
|
},
|
||||||
{ logging: { group: { name, flake: { identifier: active_clan } } } },
|
{
|
||||||
|
logging: {
|
||||||
|
group_path: ["clans", active_clan, "machines", name],
|
||||||
|
},
|
||||||
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function MachineForm(props: MachineFormProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: {
|
logging: {
|
||||||
group: { name: machine_name, flake: { identifier: base_dir } },
|
group_path: ["clans", base_dir, "machines", machine_name],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).promise;
|
).promise;
|
||||||
@@ -130,7 +130,9 @@ export function MachineForm(props: MachineFormProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: { group: { name: machine, flake: { identifier: curr_uri } } },
|
logging: {
|
||||||
|
group_path: ["clans", curr_uri, "machines", machine],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|
||||||
@@ -161,7 +163,9 @@ export function MachineForm(props: MachineFormProps) {
|
|||||||
build_host: null,
|
build_host: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: { group: { name: machine, flake: { identifier: curr_uri } } },
|
logging: {
|
||||||
|
group_path: ["clans", curr_uri, "machines", machine],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
).promise.finally(() => {
|
).promise.finally(() => {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const VarsStep = (props: VarsStepProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
logging: {
|
logging: {
|
||||||
group: { name: props.machine_id, flake: { identifier: props.dir } },
|
group_path: ["clans", props.dir, "machines", props.machine_id],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).promise;
|
).promise;
|
||||||
|
|||||||
@@ -16,14 +16,22 @@ export const MachineInstall = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const curr = activeClanURI();
|
const curr = activeClanURI();
|
||||||
if (curr) {
|
if (curr) {
|
||||||
const result = await callApi("get_machine_details", {
|
const result = await callApi(
|
||||||
machine: {
|
"get_machine_details",
|
||||||
flake: {
|
{
|
||||||
identifier: curr,
|
machine: {
|
||||||
|
flake: {
|
||||||
|
identifier: curr,
|
||||||
|
},
|
||||||
|
name: params.id,
|
||||||
},
|
},
|
||||||
name: params.id,
|
|
||||||
},
|
},
|
||||||
}).promise;
|
{
|
||||||
|
logging: {
|
||||||
|
group_path: ["clans", curr, "machines", params.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).promise;
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Icon from "@/src/components/icon";
|
|||||||
import { Header } from "@/src/layout/header";
|
import { Header } from "@/src/layout/header";
|
||||||
import { makePersisted } from "@solid-primitives/storage";
|
import { makePersisted } from "@solid-primitives/storage";
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
import { useClanContext } from "@/src/contexts/clan";
|
||||||
import { debug } from "console";
|
|
||||||
|
|
||||||
type MachinesModel = Extract<
|
type MachinesModel = Extract<
|
||||||
OperationResponse<"list_machines">,
|
OperationResponse<"list_machines">,
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
nodejs_22,
|
nodejs_22,
|
||||||
importNpmLock,
|
importNpmLock,
|
||||||
clan-ts-api,
|
clan-ts-api,
|
||||||
playwright-driver,
|
|
||||||
ps,
|
|
||||||
fonts,
|
fonts,
|
||||||
}:
|
}:
|
||||||
buildNpmPackage (finalAttrs: {
|
buildNpmPackage (_finalAttrs: {
|
||||||
pname = "clan-app-ui";
|
pname = "clan-app-ui";
|
||||||
version = "0.0.1";
|
version = "0.0.1";
|
||||||
nodejs = nodejs_22;
|
nodejs = nodejs_22;
|
||||||
@@ -25,35 +23,39 @@ buildNpmPackage (finalAttrs: {
|
|||||||
cp -r ${fonts} ".fonts"
|
cp -r ${fonts} ".fonts"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
passthru = rec {
|
# todo figure out why this fails only inside of Nix
|
||||||
storybook = buildNpmPackage {
|
# Something about passing orientation in any of the Form stories is causing the browser to crash
|
||||||
pname = "${finalAttrs.pname}-storybook";
|
# `npm run test-storybook-static` works fine in the devshell
|
||||||
inherit (finalAttrs)
|
#
|
||||||
version
|
# passthru = rec {
|
||||||
nodejs
|
# storybook = buildNpmPackage {
|
||||||
src
|
# pname = "${finalAttrs.pname}-storybook";
|
||||||
npmDeps
|
# inherit (finalAttrs)
|
||||||
npmConfigHook
|
# version
|
||||||
preBuild
|
# nodejs
|
||||||
;
|
# src
|
||||||
|
# npmDeps
|
||||||
nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
|
# npmConfigHook
|
||||||
ps
|
# preBuild
|
||||||
];
|
# ;
|
||||||
|
#
|
||||||
npmBuildScript = "test-storybook-static";
|
# nativeBuildInputs = finalAttrs.nativeBuildInputs ++ [
|
||||||
|
# ps
|
||||||
env = finalAttrs.env // {
|
# ];
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
|
#
|
||||||
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
|
# npmBuildScript = "test-storybook-static";
|
||||||
withChromiumHeadlessShell = true;
|
#
|
||||||
}}";
|
# env = finalAttrs.env // {
|
||||||
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
|
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = 1;
|
||||||
};
|
# PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers.override {
|
||||||
|
# withChromiumHeadlessShell = true;
|
||||||
postBuild = ''
|
# }}";
|
||||||
mv storybook-static $out
|
# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE = "ubuntu-24.04";
|
||||||
'';
|
# };
|
||||||
};
|
#
|
||||||
};
|
# postBuild = ''
|
||||||
|
# mv storybook-static $out
|
||||||
|
# '';
|
||||||
|
# };
|
||||||
|
# };
|
||||||
})
|
})
|
||||||
|
|||||||
22
pkgs/clan-app/ui/package-lock.json
generated
22
pkgs/clan-app/ui/package-lock.json
generated
@@ -43,9 +43,11 @@
|
|||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.17.0",
|
"eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
|
"extend": "^3.0.2",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"knip": "^5.61.2",
|
"knip": "^5.61.2",
|
||||||
|
"markdown-to-jsx": "^7.7.10",
|
||||||
"playwright": "~1.52.0",
|
"playwright": "~1.52.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"postcss-url": "^10.1.3",
|
"postcss-url": "^10.1.3",
|
||||||
@@ -4529,6 +4531,13 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -5684,6 +5693,19 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-to-jsx": {
|
||||||
|
"version": "7.7.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.10.tgz",
|
||||||
|
"integrity": "sha512-au62yyLyJukhC2P1TYi3uBi/RScGYai69uT72D8a048QH8rRj+yhND3C21GdZHE+6emtsf6Yqemcf//K+EIWDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 0.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.17.0",
|
"eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
|
"extend": "^3.0.2",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"knip": "^5.61.2",
|
"knip": "^5.61.2",
|
||||||
|
"markdown-to-jsx": "^7.7.10",
|
||||||
"playwright": "~1.52.0",
|
"playwright": "~1.52.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"postcss-url": "^10.1.3",
|
"postcss-url": "^10.1.3",
|
||||||
|
|||||||
@@ -23,42 +23,25 @@ export type SuccessQuery<T extends OperationNames> = Extract<
|
|||||||
>;
|
>;
|
||||||
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
||||||
|
|
||||||
function isMachine(obj: unknown): obj is Machine {
|
interface SendHeaderType {
|
||||||
return (
|
logging?: { group_path: string[] };
|
||||||
!!obj &&
|
}
|
||||||
typeof obj === "object" &&
|
interface BackendSendType<K extends OperationNames> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
body: OperationArgs<K>;
|
||||||
typeof (obj as any).name === "string" &&
|
header?: SendHeaderType;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
typeof (obj as any).flake === "object" &&
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
typeof (obj as any).flake.identifier === "string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Machine type with flake for API calls
|
|
||||||
interface Machine {
|
|
||||||
name: string;
|
|
||||||
flake: {
|
|
||||||
identifier: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendOpts {
|
|
||||||
logging?: { group: string | Machine };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface ReceiveHeaderType {}
|
||||||
interface BackendReturnType<K extends OperationNames> {
|
interface BackendReturnType<K extends OperationNames> {
|
||||||
body: OperationResponse<K>;
|
body: OperationResponse<K>;
|
||||||
|
header: ReceiveHeaderType;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
header: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _callApi = <K extends OperationNames>(
|
const _callApi = <K extends OperationNames>(
|
||||||
method: K,
|
method: K,
|
||||||
args: OperationArgs<K>,
|
args: OperationArgs<K>,
|
||||||
backendOpts?: BackendOpts,
|
backendOpts?: SendHeaderType,
|
||||||
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
||||||
// if window[method] does not exist, throw an error
|
// if window[method] does not exist, throw an error
|
||||||
if (!(method in window)) {
|
if (!(method in window)) {
|
||||||
@@ -82,26 +65,19 @@ const _callApi = <K extends OperationNames>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let header: BackendOpts = {};
|
const message: BackendSendType<OperationNames> = {
|
||||||
if (backendOpts != undefined) {
|
body: args,
|
||||||
header = { ...backendOpts };
|
header: backendOpts,
|
||||||
const group = backendOpts?.logging?.group;
|
};
|
||||||
if (group != undefined && isMachine(group)) {
|
|
||||||
header = {
|
|
||||||
logging: { group: group.flake.identifier + "#" + group.name },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = (
|
const promise = (
|
||||||
window as unknown as Record<
|
window as unknown as Record<
|
||||||
OperationNames,
|
OperationNames,
|
||||||
(
|
(
|
||||||
args: OperationArgs<OperationNames>,
|
args: BackendSendType<OperationNames>,
|
||||||
metadata: BackendOpts,
|
|
||||||
) => Promise<BackendReturnType<OperationNames>>
|
) => Promise<BackendReturnType<OperationNames>>
|
||||||
>
|
>
|
||||||
)[method](args, header) as Promise<BackendReturnType<K>>;
|
)[method](message) as Promise<BackendReturnType<K>>;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const op_key = (promise as any)._webviewMessageId as string;
|
const op_key = (promise as any)._webviewMessageId as string;
|
||||||
@@ -153,7 +129,7 @@ const handleCancel = async <K extends OperationNames>(
|
|||||||
export const callApi = <K extends OperationNames>(
|
export const callApi = <K extends OperationNames>(
|
||||||
method: K,
|
method: K,
|
||||||
args: OperationArgs<K>,
|
args: OperationArgs<K>,
|
||||||
backendOpts?: BackendOpts,
|
backendOpts?: SendHeaderType,
|
||||||
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
||||||
console.log("Calling API", method, args, backendOpts);
|
console.log("Calling API", method, args, backendOpts);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { Button, ButtonProps } from "./Button";
|
|||||||
import { Component } from "solid-js";
|
import { Component } from "solid-js";
|
||||||
import { expect, fn, waitFor } from "storybook/test";
|
import { expect, fn, waitFor } from "storybook/test";
|
||||||
import { StoryContext } from "@kachurun/storybook-solid-vite";
|
import { StoryContext } from "@kachurun/storybook-solid-vite";
|
||||||
import { StorybookClock } from "@/tests/clock";
|
|
||||||
|
|
||||||
const clock = StorybookClock();
|
|
||||||
|
|
||||||
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
|
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
|
||||||
|
|
||||||
@@ -147,32 +144,22 @@ export default meta;
|
|||||||
|
|
||||||
type Story = StoryObj<ButtonProps>;
|
type Story = StoryObj<ButtonProps>;
|
||||||
|
|
||||||
|
const timeout = process.env.NODE_ENV === "test" ? 500 : 2000;
|
||||||
|
|
||||||
export const Primary: Story = {
|
export const Primary: Story = {
|
||||||
args: {
|
args: {
|
||||||
hierarchy: "primary",
|
hierarchy: "primary",
|
||||||
onAction: fn(async () => {
|
onAction: fn(async () => {
|
||||||
// wait 500 ms to simulate an action
|
// wait 500 ms to simulate an action
|
||||||
await new Promise((resolve) => clock.setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, timeout));
|
||||||
// randomly fail to check that the loading state still returns to normal
|
// randomly fail to check that the loading state still returns to normal
|
||||||
if (Math.random() > 0.5) {
|
if (Math.random() > 0.5) {
|
||||||
throw new Error("Action failure");
|
throw new Error("Action failure");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
parameters: {
|
|
||||||
test: {
|
|
||||||
// increase test timeout to allow for the loading action
|
|
||||||
mockTimers: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
play: async ({
|
play: async ({ canvas, step, userEvent, args }: StoryContext) => {
|
||||||
canvas,
|
|
||||||
canvasElement,
|
|
||||||
step,
|
|
||||||
userEvent,
|
|
||||||
args,
|
|
||||||
}: StoryContext) => {
|
|
||||||
const buttons = await canvas.findAllByRole("button");
|
const buttons = await canvas.findAllByRole("button");
|
||||||
|
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
@@ -201,23 +188,20 @@ export const Primary: Story = {
|
|||||||
// click the button
|
// click the button
|
||||||
await userEvent.click(button);
|
await userEvent.click(button);
|
||||||
|
|
||||||
// advance the clock
|
|
||||||
clock.tick(1);
|
|
||||||
|
|
||||||
// check the button has changed
|
// check the button has changed
|
||||||
await waitFor(async () => {
|
await waitFor(
|
||||||
// the action handler should have been called
|
async () => {
|
||||||
await expect(args.onAction).toHaveBeenCalled();
|
// the action handler should have been called
|
||||||
// the button should have a loading class
|
await expect(args.onAction).toHaveBeenCalled();
|
||||||
await expect(button).toHaveClass("loading");
|
// the button should have a loading class
|
||||||
// the loader should be visible
|
await expect(button).toHaveClass("loading");
|
||||||
await expect(loader.clientWidth).toBeGreaterThan(0);
|
// the loader should be visible
|
||||||
// the pointer should have changed to wait
|
await expect(loader.clientWidth).toBeGreaterThan(0);
|
||||||
await expect(getCursorStyle(button)).toEqual("wait");
|
// the pointer should have changed to wait
|
||||||
});
|
await expect(getCursorStyle(button)).toEqual("wait");
|
||||||
|
},
|
||||||
// advance the clock
|
{ timeout: timeout + 500 },
|
||||||
clock.tick(2000);
|
);
|
||||||
|
|
||||||
// wait for the action handler to finish
|
// wait for the action handler to finish
|
||||||
await waitFor(
|
await waitFor(
|
||||||
@@ -229,7 +213,7 @@ export const Primary: Story = {
|
|||||||
// the pointer should be normal
|
// the pointer should be normal
|
||||||
await expect(getCursorStyle(button)).toEqual("pointer");
|
await expect(getCursorStyle(button)).toEqual("pointer");
|
||||||
},
|
},
|
||||||
{ timeout: 2500 },
|
{ timeout: timeout + 500 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import "./Divider.css";
|
import "./Divider.css";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
|
||||||
export type Orientation = "horizontal" | "vertical";
|
|
||||||
|
|
||||||
export interface DividerProps {
|
export interface DividerProps {
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
orientation?: Orientation;
|
orientation?: "horizontal" | "vertical";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Divider = (props: DividerProps) => {
|
export const Divider = (props: DividerProps) => {
|
||||||
const inverted = props.inverted || false;
|
const inverted = props.inverted || false;
|
||||||
const orientation = props.orientation || "horizontal";
|
const orientation = () => props.orientation || "horizontal";
|
||||||
|
|
||||||
return <div class={cx("divider", orientation, { inverted: inverted })} />;
|
return <div class={cx("divider", orientation(), { inverted: inverted })} />;
|
||||||
};
|
};
|
||||||
|
|||||||
44
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.css
Normal file
44
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.css
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
div.form-field {
|
||||||
|
&.checkbox {
|
||||||
|
@apply items-start;
|
||||||
|
|
||||||
|
& div.checkbox-control {
|
||||||
|
@apply w-5 h-5 rounded-sm bg-def-1 border border-inv-1 p-[0.0625rem];
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply border-def-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply border-semantic-error-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inverted {
|
||||||
|
&.checkbox {
|
||||||
|
& div.checkbox-control {
|
||||||
|
@apply bg-inv-1;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[data-checked] {
|
||||||
|
@apply bg-inv-acc-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply bg-def-4 border-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.s {
|
||||||
|
& div.checkbox-control {
|
||||||
|
@apply w-4 h-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.stories.tsx
Normal file
103
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.stories.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Checkbox, CheckboxProps } from "@/src/components/v2/Form/Checkbox";
|
||||||
|
|
||||||
|
const Examples = (props: CheckboxProps) => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<Checkbox {...props} />
|
||||||
|
<Checkbox {...props} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<Checkbox {...props} inverted={true} />
|
||||||
|
<Checkbox {...props} inverted={true} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<Checkbox {...props} orientation="horizontal" />
|
||||||
|
<Checkbox {...props} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<Checkbox {...props} inverted={true} orientation="horizontal" />
|
||||||
|
<Checkbox {...props} inverted={true} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/Checkbox",
|
||||||
|
component: Examples,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<CheckboxProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cx({
|
||||||
|
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||||
|
"w-[1024px]": context.args.orientation == "horizontal",
|
||||||
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<CheckboxProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Bare: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label: Story = {
|
||||||
|
args: {
|
||||||
|
...Bare.args,
|
||||||
|
label: "Accept Terms",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Description: Story = {
|
||||||
|
args: {
|
||||||
|
...Label.args,
|
||||||
|
description: "That stuff you never bother reading",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
...Description.args,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: Story = {
|
||||||
|
args: {
|
||||||
|
...Required.args,
|
||||||
|
tooltip:
|
||||||
|
"Let people know how you got here, great achievements or obstacles overcome",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Invalid: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
validationState: "invalid",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
readOnly: true,
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
47
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.tsx
Normal file
47
pkgs/clan-app/ui/src/components/v2/Form/Checkbox.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Checkbox as KCheckbox,
|
||||||
|
CheckboxInputProps as KCheckboxInputProps,
|
||||||
|
CheckboxRootProps as KCheckboxRootProps,
|
||||||
|
} from "@kobalte/core/checkbox";
|
||||||
|
import Icon from "@/src/components/v2/Icon/Icon";
|
||||||
|
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||||
|
import "./Checkbox.css";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
import { Orienter } from "./Orienter";
|
||||||
|
|
||||||
|
export type CheckboxProps = FieldProps &
|
||||||
|
KCheckboxRootProps & {
|
||||||
|
input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Checkbox = (props: CheckboxProps) => (
|
||||||
|
<KCheckbox
|
||||||
|
class={cx("form-field", "checkbox", props.size, props.orientation, {
|
||||||
|
inverted: props.inverted,
|
||||||
|
ghost: props.ghost,
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Orienter orientation={props.orientation} align={"start"}>
|
||||||
|
<Label
|
||||||
|
labelComponent={KCheckbox.Label}
|
||||||
|
descriptionComponent={KCheckbox.Description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<KCheckbox.Input {...props.input} />
|
||||||
|
<KCheckbox.Control class="checkbox-control">
|
||||||
|
<KCheckbox.Indicator>
|
||||||
|
<Icon
|
||||||
|
icon="Checkmark"
|
||||||
|
inverted={props.inverted}
|
||||||
|
color="secondary"
|
||||||
|
size="100%"
|
||||||
|
/>
|
||||||
|
</KCheckbox.Indicator>
|
||||||
|
</KCheckbox.Control>
|
||||||
|
</Orienter>
|
||||||
|
</KCheckbox>
|
||||||
|
);
|
||||||
212
pkgs/clan-app/ui/src/components/v2/Form/Combobox.css
Normal file
212
pkgs/clan-app/ui/src/components/v2/Form/Combobox.css
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
div.form-field.combobox {
|
||||||
|
div.control {
|
||||||
|
@apply flex flex-col w-full gap-2;
|
||||||
|
|
||||||
|
div.selected-options {
|
||||||
|
@apply flex flex-wrap gap-1 w-full min-h-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.input-container {
|
||||||
|
@apply relative left-0 top-0;
|
||||||
|
@apply inline-flex justify-between w-full;
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply w-full px-2 py-1.5 rounded-sm;
|
||||||
|
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: "Archivo", sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-def-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-acc-1 outline-def-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@apply bg-def-1 outline-def-acc-3;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.def.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply outline-def-2 fg-def-4 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-readonly] {
|
||||||
|
@apply outline-def-2 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > button.trigger {
|
||||||
|
@apply flex items-center justify-center w-8;
|
||||||
|
@apply absolute right-2 top-1 h-5 w-6 bg-def-2 rounded-sm;
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span.icon {
|
||||||
|
@apply h-full w-full py-0.5 px-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
@apply flex-row gap-2 justify-between;
|
||||||
|
|
||||||
|
div.control {
|
||||||
|
@apply w-1/2 grow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.s {
|
||||||
|
div.control > div.input-container {
|
||||||
|
& > input {
|
||||||
|
@apply px-1.5 py-1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > button.trigger {
|
||||||
|
@apply top-[0.1875rem] h-4 w-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inverted {
|
||||||
|
div.control > div.input-container {
|
||||||
|
& > button.trigger {
|
||||||
|
@apply bg-inv-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-inv-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-inv-acc-2 outline-inv-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@apply bg-inv-acc-4;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.inv.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ghost {
|
||||||
|
div.control > div.input-container {
|
||||||
|
& > input {
|
||||||
|
@apply outline-none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.combobox-content {
|
||||||
|
@apply rounded-sm bg-def-1 border border-def-2;
|
||||||
|
|
||||||
|
transform-origin: var(--kb-combobox-content-transform-origin);
|
||||||
|
animation: comboboxContentHide 250ms ease-in forwards;
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
animation: comboboxContentShow 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > ul.listbox {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
|
||||||
|
@apply px-2 py-3;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.item {
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
@apply relative px-2 py-1;
|
||||||
|
@apply select-none outline-none rounded-[0.25rem];
|
||||||
|
|
||||||
|
color: hsl(240 4% 16%);
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
color: hsl(240 5% 65%);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlighted] {
|
||||||
|
@apply outline-none bg-def-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-indicator {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.combobox-control {
|
||||||
|
@apply flex flex-col w-full gap-2;
|
||||||
|
|
||||||
|
& > div.selected-options {
|
||||||
|
@apply flex gap-2 flex-wrap w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div.input-container {
|
||||||
|
@apply w-full flex gap-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes comboboxContentShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes comboboxContentHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx
Normal file
135
pkgs/clan-app/ui/src/components/v2/Form/Combobox.stories.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
|
import { Combobox, ComboboxProps } from "./Combobox";
|
||||||
|
|
||||||
|
const ComboboxExamples = (props: ComboboxProps<string>) => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<Combobox {...props} />
|
||||||
|
<Combobox {...props} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<Combobox {...props} inverted={true} />
|
||||||
|
<Combobox {...props} inverted={true} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<Combobox {...props} orientation="horizontal" />
|
||||||
|
<Combobox {...props} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<Combobox {...props} inverted={true} orientation="horizontal" />
|
||||||
|
<Combobox {...props} inverted={true} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/Combobox",
|
||||||
|
component: ComboboxExamples,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<ComboboxProps<string>>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cx({
|
||||||
|
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||||
|
"w-[1024px]": context.args.orientation == "horizontal",
|
||||||
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<ComboboxProps<string>>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Bare: Story = {
|
||||||
|
args: {
|
||||||
|
options: ["foo", "bar", "baz"],
|
||||||
|
defaultValue: "foo",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label: Story = {
|
||||||
|
args: {
|
||||||
|
...Bare.args,
|
||||||
|
label: "DOB",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Description: Story = {
|
||||||
|
args: {
|
||||||
|
...Label.args,
|
||||||
|
description: "The date you were born",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
...Description.args,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Multiple: Story = {
|
||||||
|
args: {
|
||||||
|
...Description.args,
|
||||||
|
required: true,
|
||||||
|
multiple: true,
|
||||||
|
defaultValue: ["foo", "bar"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: Story = {
|
||||||
|
args: {
|
||||||
|
...Required.args,
|
||||||
|
tooltip: "The day you came out of your momma",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ghost: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
ghost: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Invalid: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
validationState: "invalid",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Multiple.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleReadonly: Story = {
|
||||||
|
args: {
|
||||||
|
...Multiple.args,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
171
pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx
Normal file
171
pkgs/clan-app/ui/src/components/v2/Form/Combobox.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import Icon from "@/src/components/v2/Icon/Icon";
|
||||||
|
import {
|
||||||
|
Combobox as KCombobox,
|
||||||
|
ComboboxRootOptions as KComboboxRootOptions,
|
||||||
|
} from "@kobalte/core/combobox";
|
||||||
|
import { isFunction } from "@kobalte/utils";
|
||||||
|
|
||||||
|
import "./Combobox.css";
|
||||||
|
import { CollectionNode } from "@kobalte/core";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
import { Orienter } from "./Orienter";
|
||||||
|
import { Typography } from "@/src/components/v2/Typography/Typography";
|
||||||
|
import { Accessor, Component, For, Show, splitProps } from "solid-js";
|
||||||
|
import { Tag } from "@/src/components/v2/Tag/Tag";
|
||||||
|
|
||||||
|
export type ComboboxProps<Option, OptGroup = never> = FieldProps &
|
||||||
|
KComboboxRootOptions<Option, OptGroup> & {
|
||||||
|
inverted: boolean;
|
||||||
|
itemControl?: Component<ComboboxControlState<Option>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultItemComponent = <Option,>(
|
||||||
|
props: ComboboxItemComponentProps<Option>,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<ComboboxItem item={props.item} class="item">
|
||||||
|
<ComboboxItemLabel>
|
||||||
|
<Typography hierarchy="body" size="xs" weight="bold">
|
||||||
|
{props.item.textValue}
|
||||||
|
</Typography>
|
||||||
|
</ComboboxItemLabel>
|
||||||
|
<ComboboxItemIndicator class="item-indicator">
|
||||||
|
<Icon icon="Checkmark" />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// adapted from https://github.com/kobaltedev/kobalte/blob/98a4810903c0c425d28cef4f0d1984192a225788/packages/core/src/combobox/combobox-base.tsx#L439
|
||||||
|
const getOptionTextValue = <Option,>(
|
||||||
|
option: Option,
|
||||||
|
optionTextValue:
|
||||||
|
| keyof Exclude<Option, null>
|
||||||
|
| ((option: Exclude<Option, null>) => string)
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
|
if (optionTextValue == null) {
|
||||||
|
// If no `optionTextValue`, the option itself is the label (ex: string[] of options).
|
||||||
|
return String(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the label from the option object as a string.
|
||||||
|
return String(
|
||||||
|
isFunction(optionTextValue)
|
||||||
|
? optionTextValue(option as never)
|
||||||
|
: (option as never)[optionTextValue],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultItemControl = <Option,>(
|
||||||
|
props: ComboboxControlState<Option>,
|
||||||
|
) => (
|
||||||
|
<>
|
||||||
|
<Show when={props.multiple}>
|
||||||
|
<div class="selected-options">
|
||||||
|
<For each={props.selectedOptions()}>
|
||||||
|
{(option) => (
|
||||||
|
<Tag
|
||||||
|
inverted={props.inverted}
|
||||||
|
label={getOptionTextValue<Option>(option, props.optionTextValue)}
|
||||||
|
action={
|
||||||
|
props.disabled || props.readOnly
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
icon: "Close",
|
||||||
|
onClick: () => props.remove(option),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="input-container">
|
||||||
|
<KCombobox.Input />
|
||||||
|
<KCombobox.Trigger class="trigger">
|
||||||
|
<KCombobox.Icon class="icon">
|
||||||
|
<Icon icon="Expand" inverted={props.inverted} size="100%" />
|
||||||
|
</KCombobox.Icon>
|
||||||
|
</KCombobox.Trigger>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// todo aria-label on combobox.control and combobox.input
|
||||||
|
export const Combobox = <Option, OptGroup = never>(
|
||||||
|
props: ComboboxProps<Option, OptGroup>,
|
||||||
|
) => {
|
||||||
|
const itemControl = () => props.itemControl || DefaultItemControl;
|
||||||
|
const itemComponent = () => props.itemComponent || DefaultItemComponent;
|
||||||
|
|
||||||
|
const align = () => (props.orientation === "horizontal" ? "start" : "center");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KCombobox
|
||||||
|
class={cx("form-field", "combobox", props.size, props.orientation, {
|
||||||
|
inverted: props.inverted,
|
||||||
|
ghost: props.ghost,
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
itemComponent={itemComponent()}
|
||||||
|
>
|
||||||
|
<Orienter orientation={props.orientation} align={align()}>
|
||||||
|
<Label
|
||||||
|
labelComponent={KCombobox.Label}
|
||||||
|
descriptionComponent={KCombobox.Description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KCombobox.Control<Option> class="control">
|
||||||
|
{(state) => {
|
||||||
|
const [controlProps] = splitProps(props, [
|
||||||
|
"inverted",
|
||||||
|
"multiple",
|
||||||
|
"readOnly",
|
||||||
|
"disabled",
|
||||||
|
]);
|
||||||
|
return itemControl()({ ...state, ...controlProps });
|
||||||
|
}}
|
||||||
|
</KCombobox.Control>
|
||||||
|
|
||||||
|
<KCombobox.Portal>
|
||||||
|
<KCombobox.Content class="combobox-content">
|
||||||
|
<KCombobox.Listbox class="listbox" />
|
||||||
|
</KCombobox.Content>
|
||||||
|
</KCombobox.Portal>
|
||||||
|
</Orienter>
|
||||||
|
</KCombobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// todo can we replicate the . notation that Kobalte achieves with their type definitions?
|
||||||
|
export const ComboboxItem = KCombobox.Item;
|
||||||
|
export const ComboboxItemDescription = KCombobox.ItemDescription;
|
||||||
|
export const ComboboxItemIndicator = KCombobox.ItemIndicator;
|
||||||
|
export const ComboboxItemLabel = KCombobox.ItemLabel;
|
||||||
|
|
||||||
|
// these interfaces were not exported, so we re-declare them
|
||||||
|
export interface ComboboxItemComponentProps<Option> {
|
||||||
|
/** The item to render. */
|
||||||
|
item: CollectionNode<Option>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComboboxSectionComponentProps<OptGroup> {
|
||||||
|
/** The section to render. */
|
||||||
|
section: CollectionNode<OptGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComboboxControlState<Option> = Pick<
|
||||||
|
ComboboxProps<Option>,
|
||||||
|
"optionTextValue" | "inverted" | "multiple" | "size" | "readOnly" | "disabled"
|
||||||
|
> & {
|
||||||
|
/** The selected options. */
|
||||||
|
selectedOptions: Accessor<Option[]>;
|
||||||
|
/** A function to remove an option from the selection. */
|
||||||
|
remove: (option: Option) => void;
|
||||||
|
/** A function to clear the selection. */
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
11
pkgs/clan-app/ui/src/components/v2/Form/Field.tsx
Normal file
11
pkgs/clan-app/ui/src/components/v2/Form/Field.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface FieldProps {
|
||||||
|
class?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
ghost?: boolean;
|
||||||
|
|
||||||
|
size?: "default" | "s";
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
inverted?: boolean;
|
||||||
|
}
|
||||||
22
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.css
Normal file
22
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
fieldset {
|
||||||
|
@apply flex flex-col w-full;
|
||||||
|
|
||||||
|
legend {
|
||||||
|
@apply mb-2.5 w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fields {
|
||||||
|
@apply flex flex-col gap-4 w-full rounded-md;
|
||||||
|
@apply px-4 py-5 bg-def-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.error {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inverted {
|
||||||
|
div.fields {
|
||||||
|
@apply bg-inv-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.stories.tsx
Normal file
123
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.stories.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { Fieldset, FieldsetProps } from "@/src/components/v2/Form/Fieldset";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { TextInput } from "@/src/components/v2/Form/TextInput";
|
||||||
|
import { TextArea } from "@/src/components/v2/Form/TextArea";
|
||||||
|
import { Checkbox } from "@/src/components/v2/Form/Checkbox";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
|
||||||
|
const FieldsetExamples = (props: FieldsetProps) => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<Fieldset {...props} />
|
||||||
|
<Fieldset {...props} inverted={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/Fieldset",
|
||||||
|
component: FieldsetExamples,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<FieldsetProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cx({
|
||||||
|
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||||
|
"w-[1024px]": context.args.orientation == "horizontal",
|
||||||
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<FieldsetProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
legend: "Signup",
|
||||||
|
fields: (props: FieldProps) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
label="First Name"
|
||||||
|
required={true}
|
||||||
|
input={{ placeholder: "Ron" }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
label="Last Name"
|
||||||
|
required={true}
|
||||||
|
input={{ placeholder: "Burgundy" }}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
{...props}
|
||||||
|
label="Bio"
|
||||||
|
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
|
||||||
|
/>
|
||||||
|
<Checkbox {...props} label="Accept Terms" required={true} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Horizontal: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Vertical: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
legend: "Signup",
|
||||||
|
error: "You must enter a First Name",
|
||||||
|
fields: (props: FieldProps) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
label="First Name"
|
||||||
|
required={true}
|
||||||
|
validationState="invalid"
|
||||||
|
input={{ placeholder: "Ron" }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
{...props}
|
||||||
|
label="Last Name"
|
||||||
|
required={true}
|
||||||
|
validationState="invalid"
|
||||||
|
input={{ placeholder: "Burgundy" }}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
{...props}
|
||||||
|
label="Bio"
|
||||||
|
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
{...props}
|
||||||
|
label="Accept Terms"
|
||||||
|
validationState="invalid"
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
54
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.tsx
Normal file
54
pkgs/clan-app/ui/src/components/v2/Form/Fieldset.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import "./Fieldset.css";
|
||||||
|
import { JSX } from "solid-js";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Typography } from "@/src/components/v2/Typography/Typography";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
|
||||||
|
export interface FieldsetProps extends FieldProps {
|
||||||
|
legend: string;
|
||||||
|
disabled: boolean;
|
||||||
|
error?: string;
|
||||||
|
fields: (props: FieldProps) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Fieldset = (props: FieldsetProps) => {
|
||||||
|
const orientation = () => props.orientation || "vertical";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
role="group"
|
||||||
|
class={cx(orientation(), { inverted: props.inverted })}
|
||||||
|
disabled={props.disabled}
|
||||||
|
>
|
||||||
|
<legend>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="default"
|
||||||
|
weight="normal"
|
||||||
|
color="tertiary"
|
||||||
|
transform="uppercase"
|
||||||
|
inverted={props.inverted}
|
||||||
|
>
|
||||||
|
{props.legend}
|
||||||
|
</Typography>
|
||||||
|
</legend>
|
||||||
|
<div class="fields">
|
||||||
|
{props.fields({ ...props, orientation: orientation() })}
|
||||||
|
</div>
|
||||||
|
{props.error && (
|
||||||
|
<div class="error" role="alert">
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xxs"
|
||||||
|
weight="medium"
|
||||||
|
color="error"
|
||||||
|
inverted={props.inverted}
|
||||||
|
>
|
||||||
|
{props.error}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
pkgs/clan-app/ui/src/components/v2/Form/Label.css
Normal file
61
pkgs/clan-app/ui/src/components/v2/Form/Label.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
div.form-label {
|
||||||
|
@apply flex flex-col gap-1 w-full;
|
||||||
|
|
||||||
|
& > label,
|
||||||
|
& > div {
|
||||||
|
@apply w-full;
|
||||||
|
/* remove line height which messes with sizing */
|
||||||
|
@apply leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > label {
|
||||||
|
@apply flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > label[data-required] {
|
||||||
|
span.typography::after {
|
||||||
|
@apply fg-def-4 ml-1;
|
||||||
|
|
||||||
|
content: "*";
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.tooltip-content {
|
||||||
|
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;
|
||||||
|
|
||||||
|
max-width: min(calc(100vw - 16px), 380px);
|
||||||
|
transform-origin: var(--kb-tooltip-content-transform-origin);
|
||||||
|
animation: tooltipHide 250ms ease-in forwards;
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
animation: tooltipShow 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inverted {
|
||||||
|
@apply bg-def-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltipShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes tooltipHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
pkgs/clan-app/ui/src/components/v2/Form/Label.tsx
Normal file
94
pkgs/clan-app/ui/src/components/v2/Form/Label.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Typography } from "@/src/components/v2/Typography/Typography";
|
||||||
|
import { Tooltip as KTooltip } from "@kobalte/core/tooltip";
|
||||||
|
import Icon from "@/src/components/v2/Icon/Icon";
|
||||||
|
import { TextField } from "@kobalte/core/text-field";
|
||||||
|
import { Checkbox } from "@kobalte/core/checkbox";
|
||||||
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
|
import "./Label.css";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
|
export type Size = "default" | "s";
|
||||||
|
|
||||||
|
export type LabelComponent =
|
||||||
|
| typeof TextField.Label
|
||||||
|
| typeof Checkbox.Label
|
||||||
|
| typeof Combobox.Label;
|
||||||
|
export type DescriptionComponent =
|
||||||
|
| typeof TextField.Description
|
||||||
|
| typeof Checkbox.Description
|
||||||
|
| typeof Combobox.Description;
|
||||||
|
|
||||||
|
export interface LabelProps {
|
||||||
|
labelComponent: LabelComponent;
|
||||||
|
descriptionComponent: DescriptionComponent;
|
||||||
|
size?: Size;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
icon?: string;
|
||||||
|
inverted?: boolean;
|
||||||
|
validationState?: "valid" | "invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Label = (props: LabelProps) => {
|
||||||
|
const descriptionSize = () => (props.size == "default" ? "xs" : "xxs");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.label}>
|
||||||
|
<div class="form-label">
|
||||||
|
<props.labelComponent>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size={props.size || "default"}
|
||||||
|
color={props.validationState == "invalid" ? "error" : "primary"}
|
||||||
|
weight="bold"
|
||||||
|
inverted={props.inverted}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</Typography>
|
||||||
|
{props.tooltip && (
|
||||||
|
<KTooltip placement="top">
|
||||||
|
<KTooltip.Trigger>
|
||||||
|
<Icon
|
||||||
|
icon="Info"
|
||||||
|
color="tertiary"
|
||||||
|
inverted={props.inverted}
|
||||||
|
size={props.size == "default" ? "0.85em" : "0.75rem"}
|
||||||
|
/>
|
||||||
|
<KTooltip.Portal>
|
||||||
|
<KTooltip.Content
|
||||||
|
class={cx("tooltip-content", { inverted: props.inverted })}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
inverted={!props.inverted}
|
||||||
|
>
|
||||||
|
{props.tooltip}
|
||||||
|
</Typography>
|
||||||
|
<KTooltip.Arrow />
|
||||||
|
</KTooltip.Content>
|
||||||
|
</KTooltip.Portal>
|
||||||
|
</KTooltip.Trigger>
|
||||||
|
</KTooltip>
|
||||||
|
)}
|
||||||
|
</props.labelComponent>
|
||||||
|
{props.description && (
|
||||||
|
<props.descriptionComponent>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size={descriptionSize()}
|
||||||
|
color="secondary"
|
||||||
|
weight="normal"
|
||||||
|
inverted={props.inverted}
|
||||||
|
>
|
||||||
|
{props.description}
|
||||||
|
</Typography>
|
||||||
|
</props.descriptionComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
pkgs/clan-app/ui/src/components/v2/Form/Orienter.css
Normal file
22
pkgs/clan-app/ui/src/components/v2/Form/Orienter.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
div.orienter {
|
||||||
|
@apply flex flex-col gap-2 w-full;
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
@apply flex-row gap-2 justify-between;
|
||||||
|
|
||||||
|
& > div.form-label {
|
||||||
|
@apply w-1/2 shrink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.align-center {
|
||||||
|
@apply items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.align-start {
|
||||||
|
@apply items-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
pkgs/clan-app/ui/src/components/v2/Form/Orienter.tsx
Normal file
20
pkgs/clan-app/ui/src/components/v2/Form/Orienter.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import cx from "classnames";
|
||||||
|
import { JSX } from "solid-js";
|
||||||
|
|
||||||
|
import "./Orienter.css";
|
||||||
|
|
||||||
|
export interface OrienterProps {
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
|
align?: "center" | "start";
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Orienter = (props: OrienterProps) => {
|
||||||
|
const alignment = () => `align-${props.align || "center"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={cx("orienter", alignment(), props.orientation)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
116
pkgs/clan-app/ui/src/components/v2/Form/TextArea.stories.tsx
Normal file
116
pkgs/clan-app/ui/src/components/v2/Form/TextArea.stories.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { TextArea, TextAreaProps } from "./TextArea";
|
||||||
|
|
||||||
|
const Examples = (props: TextAreaProps) => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<TextArea {...props} />
|
||||||
|
<TextArea {...props} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<TextArea {...props} inverted={true} />
|
||||||
|
<TextArea {...props} inverted={true} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<TextArea {...props} orientation="horizontal" />
|
||||||
|
<TextArea {...props} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<TextArea {...props} inverted={true} orientation="horizontal" />
|
||||||
|
<TextArea {...props} inverted={true} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/TextArea",
|
||||||
|
component: Examples,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<TextAreaProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cx({
|
||||||
|
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||||
|
"w-[1024px]": context.args.orientation == "horizontal",
|
||||||
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<TextAreaProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Bare: Story = {
|
||||||
|
args: {
|
||||||
|
input: {
|
||||||
|
rows: 10,
|
||||||
|
placeholder: "I like craft beer and long walks on the beach",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label: Story = {
|
||||||
|
args: {
|
||||||
|
...Bare.args,
|
||||||
|
label: "Biography",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Description: Story = {
|
||||||
|
args: {
|
||||||
|
...Label.args,
|
||||||
|
description: "Tell us about yourself",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
...Description.args,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: Story = {
|
||||||
|
args: {
|
||||||
|
...Required.args,
|
||||||
|
tooltip:
|
||||||
|
"Let people know how you got here, great achievements or obstacles overcome",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ghost: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
ghost: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Invalid: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
validationState: "invalid",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
readOnly: true,
|
||||||
|
defaultValue:
|
||||||
|
"Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ",
|
||||||
|
},
|
||||||
|
};
|
||||||
37
pkgs/clan-app/ui/src/components/v2/Form/TextArea.tsx
Normal file
37
pkgs/clan-app/ui/src/components/v2/Form/TextArea.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
TextFieldRootProps,
|
||||||
|
TextFieldTextAreaProps,
|
||||||
|
} from "@kobalte/core/text-field";
|
||||||
|
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||||
|
|
||||||
|
import "./TextInput.css";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
import { Orienter } from "./Orienter";
|
||||||
|
|
||||||
|
export type TextAreaProps = FieldProps &
|
||||||
|
TextFieldRootProps & {
|
||||||
|
input?: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextArea = (props: TextAreaProps) => (
|
||||||
|
<TextField
|
||||||
|
class={cx("form-field", "textarea", props.size, props.orientation, {
|
||||||
|
inverted: props.inverted,
|
||||||
|
ghost: props.ghost,
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Orienter orientation={props.orientation} align={"start"}>
|
||||||
|
<Label
|
||||||
|
labelComponent={TextField.Label}
|
||||||
|
descriptionComponent={TextField.Description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<TextField.TextArea {...props.input} />
|
||||||
|
</Orienter>
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
130
pkgs/clan-app/ui/src/components/v2/Form/TextInput.css
Normal file
130
pkgs/clan-app/ui/src/components/v2/Form/TextInput.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
div.form-field {
|
||||||
|
&.text input,
|
||||||
|
&.textarea textarea {
|
||||||
|
@apply w-full px-2 py-1.5 rounded-sm;
|
||||||
|
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: "Archivo", sans-serif;
|
||||||
|
line-height: 132%;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-def-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-acc-1 outline-def-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@apply bg-def-1 outline-def-acc-3;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.def.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply outline-def-2 fg-def-4 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-readonly] {
|
||||||
|
@apply outline-def-2 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
@apply flex-row gap-2 justify-between;
|
||||||
|
|
||||||
|
&.text div.input-container,
|
||||||
|
&.textarea textarea {
|
||||||
|
@apply w-1/2 grow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text div.input-container {
|
||||||
|
@apply inline-block relative w-full h-[1.875rem];
|
||||||
|
|
||||||
|
/* I'm unsure why I have to do this */
|
||||||
|
@apply leading-none;
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
@apply w-full h-[1.875rem];
|
||||||
|
|
||||||
|
&.has-icon {
|
||||||
|
@apply pl-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .icon {
|
||||||
|
@apply absolute left-2 top-1/2 transform -translate-y-1/2;
|
||||||
|
@apply w-[0.875rem] h-[0.875rem] pointer-events-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.s {
|
||||||
|
&.text input,
|
||||||
|
&.textarea textarea {
|
||||||
|
@apply px-1.5 py-1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text div.input-container {
|
||||||
|
@apply h-[1.25rem];
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply h-[1.25rem];
|
||||||
|
}
|
||||||
|
|
||||||
|
input.has-icon {
|
||||||
|
@apply pl-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .icon {
|
||||||
|
@apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inverted {
|
||||||
|
&.text input,
|
||||||
|
&.textarea textarea {
|
||||||
|
@apply bg-inv-1 fg-inv-1 outline-inv-acc-1;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
@apply fg-inv-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-inv-acc-2 outline-inv-acc-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@apply bg-inv-acc-4;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.125rem theme(colors.bg.inv.1),
|
||||||
|
0 0 0 0.1875rem theme(colors.border.semantic.info.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
@apply outline-semantic-error-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ghost {
|
||||||
|
&.text input,
|
||||||
|
&.textarea textarea {
|
||||||
|
@apply outline-none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
pkgs/clan-app/ui/src/components/v2/Form/TextInput.stories.tsx
Normal file
120
pkgs/clan-app/ui/src/components/v2/Form/TextInput.stories.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { TextInput, TextInputProps } from "@/src/components/v2/Form/TextInput";
|
||||||
|
|
||||||
|
const Examples = (props: TextInputProps) => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<TextInput {...props} />
|
||||||
|
<TextInput {...props} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<TextInput {...props} inverted={true} />
|
||||||
|
<TextInput {...props} inverted={true} size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8">
|
||||||
|
<TextInput {...props} orientation="horizontal" />
|
||||||
|
<TextInput {...props} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
|
||||||
|
<TextInput {...props} inverted={true} orientation="horizontal" />
|
||||||
|
<TextInput {...props} inverted={true} orientation="horizontal" size="s" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/TextInput",
|
||||||
|
component: Examples,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<TextInputProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cx({
|
||||||
|
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
|
||||||
|
"w-[1024px]": context.args.orientation == "horizontal",
|
||||||
|
"bg-inv-acc-3": context.args.inverted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<TextInputProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Bare: Story = {
|
||||||
|
args: {
|
||||||
|
input: {
|
||||||
|
placeholder: "e.g. 11/06/89",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label: Story = {
|
||||||
|
args: {
|
||||||
|
...Bare.args,
|
||||||
|
label: "DOB",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Description: Story = {
|
||||||
|
args: {
|
||||||
|
...Label.args,
|
||||||
|
description: "The date you were born",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
...Description.args,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: Story = {
|
||||||
|
args: {
|
||||||
|
...Required.args,
|
||||||
|
tooltip: "The day you came out of your momma",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Icon: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
icon: "Checkmark",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Ghost: Story = {
|
||||||
|
args: {
|
||||||
|
...Icon.args,
|
||||||
|
ghost: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Invalid: Story = {
|
||||||
|
args: {
|
||||||
|
...Tooltip.args,
|
||||||
|
validationState: "invalid",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Icon.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: {
|
||||||
|
...Icon.args,
|
||||||
|
readOnly: true,
|
||||||
|
defaultValue: "14/05/02",
|
||||||
|
},
|
||||||
|
};
|
||||||
50
pkgs/clan-app/ui/src/components/v2/Form/TextInput.tsx
Normal file
50
pkgs/clan-app/ui/src/components/v2/Form/TextInput.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
TextFieldInputProps,
|
||||||
|
TextFieldRootProps,
|
||||||
|
} from "@kobalte/core/text-field";
|
||||||
|
import Icon, { IconVariant } from "@/src/components/v2/Icon/Icon";
|
||||||
|
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import "./TextInput.css";
|
||||||
|
import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||||
|
import { FieldProps } from "./Field";
|
||||||
|
import { Orienter } from "./Orienter";
|
||||||
|
|
||||||
|
export type TextInputProps = FieldProps &
|
||||||
|
TextFieldRootProps & {
|
||||||
|
icon?: IconVariant;
|
||||||
|
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextInput = (props: TextInputProps) => (
|
||||||
|
<TextField
|
||||||
|
class={cx("form-field", "text", props.size, props.orientation, {
|
||||||
|
inverted: props.inverted,
|
||||||
|
ghost: props.ghost,
|
||||||
|
})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Orienter orientation={props.orientation}>
|
||||||
|
<Label
|
||||||
|
labelComponent={TextField.Label}
|
||||||
|
descriptionComponent={TextField.Description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div class="input-container">
|
||||||
|
{props.icon && (
|
||||||
|
<Icon
|
||||||
|
icon={props.icon}
|
||||||
|
inverted={props.inverted}
|
||||||
|
color={props.disabled ? "tertiary" : "quaternary"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextField.Input
|
||||||
|
{...props.input}
|
||||||
|
classList={{ "has-icon": props.icon }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Orienter>
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
@@ -4,6 +4,7 @@ import { For } from "solid-js";
|
|||||||
import { Tag } from "@/src/components/v2/Tag/Tag";
|
import { Tag } from "@/src/components/v2/Tag/Tag";
|
||||||
|
|
||||||
export interface TagGroupProps {
|
export interface TagGroupProps {
|
||||||
|
class?: string;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
}
|
}
|
||||||
@@ -12,7 +13,7 @@ export const TagGroup = (props: TagGroupProps) => {
|
|||||||
const inverted = () => props.inverted || false;
|
const inverted = () => props.inverted || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={cx("tag-group", { inverted: inverted() })}>
|
<div class={cx("tag-group", props.class, { inverted: inverted() })}>
|
||||||
<For each={props.labels}>
|
<For each={props.labels}>
|
||||||
{(label) => <Tag label={label} inverted={inverted()} />}
|
{(label) => <Tag label={label} inverted={inverted()} />}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -67,6 +67,12 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0.0075rem;
|
letter-spacing: 0.0075rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.size-xxs {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.family-mono {
|
&.family-mono {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
|||||||
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
|
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
|
||||||
export type Weight = "normal" | "medium" | "bold";
|
export type Weight = "normal" | "medium" | "bold";
|
||||||
export type Family = "regular" | "condensed" | "mono";
|
export type Family = "regular" | "condensed" | "mono";
|
||||||
|
export type Transform = "uppercase" | "lowercase" | "capitalize";
|
||||||
|
|
||||||
// type Size = "default" | "xs" | "s" | "m" | "l";
|
// type Size = "default" | "xs" | "s" | "m" | "l";
|
||||||
interface SizeForHierarchy {
|
interface SizeForHierarchy {
|
||||||
@@ -21,6 +22,7 @@ interface SizeForHierarchy {
|
|||||||
default: string;
|
default: string;
|
||||||
s: string;
|
s: string;
|
||||||
xs: string;
|
xs: string;
|
||||||
|
xxs: string;
|
||||||
};
|
};
|
||||||
headline: {
|
headline: {
|
||||||
default: string;
|
default: string;
|
||||||
@@ -62,6 +64,7 @@ const sizeHierarchyMap: SizeForHierarchy = {
|
|||||||
default: cx("size-default"),
|
default: cx("size-default"),
|
||||||
s: cx("size-s"),
|
s: cx("size-s"),
|
||||||
xs: cx("size-xs"),
|
xs: cx("size-xs"),
|
||||||
|
xxs: cx("size-xxs"),
|
||||||
},
|
},
|
||||||
teaser: {
|
teaser: {
|
||||||
default: cx("size-default"),
|
default: cx("size-default"),
|
||||||
@@ -92,6 +95,7 @@ interface _TypographyProps<H extends Hierarchy> {
|
|||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
tag?: Tag;
|
tag?: Tag;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
transform?: Transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
|
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
|
||||||
@@ -111,6 +115,7 @@ export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
|
|||||||
weight(),
|
weight(),
|
||||||
size(),
|
size(),
|
||||||
color(),
|
color(),
|
||||||
|
props.transform,
|
||||||
props.class,
|
props.class,
|
||||||
)}
|
)}
|
||||||
component={props.tag || "span"}
|
component={props.tag || "span"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type Color =
|
|||||||
| "secondary"
|
| "secondary"
|
||||||
| "tertiary"
|
| "tertiary"
|
||||||
| "quaternary"
|
| "quaternary"
|
||||||
|
| "error"
|
||||||
| "inherit";
|
| "inherit";
|
||||||
|
|
||||||
export const AllColors: Color[] = [
|
export const AllColors: Color[] = [
|
||||||
@@ -10,6 +11,7 @@ export const AllColors: Color[] = [
|
|||||||
"secondary",
|
"secondary",
|
||||||
"tertiary",
|
"tertiary",
|
||||||
"quaternary",
|
"quaternary",
|
||||||
|
"error",
|
||||||
"inherit",
|
"inherit",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const colorMap: Record<Color, string> = {
|
|||||||
secondary: "fg-def-2",
|
secondary: "fg-def-2",
|
||||||
tertiary: "fg-def-3",
|
tertiary: "fg-def-3",
|
||||||
quaternary: "fg-def-4",
|
quaternary: "fg-def-4",
|
||||||
|
error: "fg-semantic-error-4",
|
||||||
inherit: "text-inherit",
|
inherit: "text-inherit",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ const invertedColorMap: Record<Color, string> = {
|
|||||||
secondary: "fg-inv-2",
|
secondary: "fg-inv-2",
|
||||||
tertiary: "fg-inv-3",
|
tertiary: "fg-inv-3",
|
||||||
quaternary: "fg-inv-4",
|
quaternary: "fg-inv-4",
|
||||||
|
error: "fg-semantic-error-2",
|
||||||
inherit: "text-inherit",
|
inherit: "text-inherit",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const colorSystem = {
|
|||||||
1: primaries.secondary["950"],
|
1: primaries.secondary["950"],
|
||||||
2: primaries.secondary["900"],
|
2: primaries.secondary["900"],
|
||||||
3: primaries.secondary["700"],
|
3: primaries.secondary["700"],
|
||||||
4: primaries.secondary["400"],
|
4: primaries.secondary["500"],
|
||||||
},
|
},
|
||||||
inv: {
|
inv: {
|
||||||
1: primaries.off.white,
|
1: primaries.off.white,
|
||||||
@@ -283,6 +283,13 @@ export default plugin.withOptions(
|
|||||||
addUtilities(mkColorUtil(["border-r"], "borderRight", border));
|
addUtilities(mkColorUtil(["border-r"], "borderRight", border));
|
||||||
addUtilities(mkColorUtil(["border-b"], "borderBottom", border));
|
addUtilities(mkColorUtil(["border-b"], "borderBottom", border));
|
||||||
addUtilities(mkColorUtil(["border-l"], "borderLeft", border));
|
addUtilities(mkColorUtil(["border-l"], "borderLeft", border));
|
||||||
|
|
||||||
|
// re-use the border colors for outline colors
|
||||||
|
addUtilities(mkColorUtil(["outline"], "outlineColor", border));
|
||||||
|
addUtilities(mkColorUtil(["outline-t"], "outlineTop", border));
|
||||||
|
addUtilities(mkColorUtil(["outline-r"], "outlineRight", border));
|
||||||
|
addUtilities(mkColorUtil(["outline-b"], "outlineBottom", border));
|
||||||
|
addUtilities(mkColorUtil(["outline-l"], "outlineLeft", border));
|
||||||
},
|
},
|
||||||
// add configuration which is merged with the final config
|
// add configuration which is merged with the final config
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { query } from "@solidjs/router";
|
|
||||||
import set = query.set;
|
|
||||||
import FakeTimers, { Clock } from "@sinonjs/fake-timers";
|
|
||||||
|
|
||||||
export interface StorybookClock {
|
|
||||||
tick: (ms: number) => void;
|
|
||||||
setTimeout: (
|
|
||||||
callback: (...args: any[]) => void,
|
|
||||||
delay: number,
|
|
||||||
...args: any[]
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class BrowserClock implements StorybookClock {
|
|
||||||
setTimeout(
|
|
||||||
callback: (...args: any[]) => void,
|
|
||||||
delay: number,
|
|
||||||
args: any,
|
|
||||||
): void {
|
|
||||||
// set a normal timeout
|
|
||||||
setTimeout(callback, delay, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(_: number): void {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeClock implements StorybookClock {
|
|
||||||
private clock: Clock;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.clock = FakeTimers.createClock();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(
|
|
||||||
callback: (...args: any[]) => void,
|
|
||||||
delay: number,
|
|
||||||
args: any,
|
|
||||||
): void {
|
|
||||||
this.clock.setTimeout(callback, delay, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(ms: number): void {
|
|
||||||
this.clock.tick(ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StorybookClock(): StorybookClock {
|
|
||||||
// Check if we're in a browser environment
|
|
||||||
const isBrowser = process.env.NODE_ENV !== "test";
|
|
||||||
|
|
||||||
console.log("is browser", isBrowser);
|
|
||||||
|
|
||||||
return isBrowser ? new BrowserClock() : new FakeClock();
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
import json
|
||||||
import pkgutil
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
|
from clan_lib.api import load_in_all_api_functions
|
||||||
def import_all_modules_from_package(pkg: ModuleType) -> None:
|
|
||||||
for _loader, module_name, _is_pkg in pkgutil.walk_packages(
|
|
||||||
pkg.__path__, prefix=f"{pkg.__name__}."
|
|
||||||
):
|
|
||||||
base_name = module_name.split(".")[-1]
|
|
||||||
|
|
||||||
# Skip test modules
|
|
||||||
if (
|
|
||||||
base_name.startswith("test_")
|
|
||||||
or base_name.endswith("_test")
|
|
||||||
or base_name == "conftest"
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
importlib.import_module(module_name)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
import clan_cli
|
load_in_all_api_functions()
|
||||||
import clan_lib
|
|
||||||
|
|
||||||
import_all_modules_from_package(clan_cli)
|
|
||||||
import_all_modules_from_package(clan_lib)
|
|
||||||
|
|
||||||
from clan_lib.api import API
|
from clan_lib.api import API
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from tempfile import TemporaryDirectory
|
|||||||
from clan_lib.cmd import RunOpts, run
|
from clan_lib.cmd import RunOpts, run
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
from clan_lib.git import commit_files
|
from clan_lib.git import commit_files
|
||||||
|
from clan_lib.machines.list import list_full_machines
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ from clan_cli.completions import (
|
|||||||
complete_machines,
|
complete_machines,
|
||||||
complete_services_for_machine,
|
complete_services_for_machine,
|
||||||
)
|
)
|
||||||
from clan_cli.machines.list import list_full_machines
|
|
||||||
|
|
||||||
from .check import check_secrets
|
from .check import check_secrets
|
||||||
from .public_modules import FactStoreBase
|
from .public_modules import FactStoreBase
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class SecretStore(SecretStoreBase):
|
|||||||
sops_secrets_folder(self.machine.flake_dir)
|
sops_secrets_folder(self.machine.flake_dir)
|
||||||
/ f"{self.machine.name}-age.key",
|
/ f"{self.machine.name}-age.key",
|
||||||
priv_key,
|
priv_key,
|
||||||
add_groups=self.machine.deployment["sops"]["defaultGroups"],
|
add_groups=self.machine.select("config.clan.core.sops.defaultGroups"),
|
||||||
age_plugins=load_age_plugins(self.machine.flake),
|
age_plugins=load_age_plugins(self.machine.flake),
|
||||||
)
|
)
|
||||||
add_machine(self.machine.flake_dir, self.machine.name, pub_key, False)
|
add_machine(self.machine.flake_dir, self.machine.name, pub_key, False)
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ def flash_machine(
|
|||||||
"users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}}
|
"users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
for generator in machine.vars_generators():
|
from clan_cli.vars.generate import Generator
|
||||||
|
|
||||||
|
for generator in Generator.generators_from_flake(machine.name, machine.flake):
|
||||||
for file in generator.files:
|
for file in generator.files:
|
||||||
if file.needed_for == "partitioning":
|
if file.needed_for == "partitioning":
|
||||||
msg = f"Partitioning time secrets are not supported with `clan flash write`: clan.core.vars.generators.{generator.name}.files.{file.name}"
|
msg = f"Partitioning time secrets are not supported with `clan flash write`: clan.core.vars.generators.{generator.name}.files.{file.name}"
|
||||||
|
|||||||
28
pkgs/clan-cli/clan_cli/host_key_check.py
Normal file
28
pkgs/clan-cli/clan_cli/host_key_check.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Common argument types and utilities for host key checking in clan CLI commands."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from clan_lib.ssh.host_key import HostKeyCheck
|
||||||
|
|
||||||
|
|
||||||
|
def host_key_check_type(value: str) -> HostKeyCheck:
|
||||||
|
"""
|
||||||
|
Argparse type converter for HostKeyCheck enum.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return HostKeyCheck(value)
|
||||||
|
except ValueError:
|
||||||
|
valid_values = [e.value for e in HostKeyCheck]
|
||||||
|
msg = f"Invalid host key check mode: {value}. Valid options: {', '.join(valid_values)}"
|
||||||
|
raise argparse.ArgumentTypeError(msg) from None
|
||||||
|
|
||||||
|
|
||||||
|
def add_host_key_check_arg(
|
||||||
|
parser: argparse.ArgumentParser, default: HostKeyCheck = HostKeyCheck.ASK
|
||||||
|
) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--host-key-check",
|
||||||
|
type=host_key_check_type,
|
||||||
|
default=default,
|
||||||
|
help=f"Host key (.ssh/known_hosts) check mode. Options: {', '.join([e.value for e in HostKeyCheck])}",
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from clan_lib.machines.hardware import (
|
from clan_lib.machines.hardware import (
|
||||||
HardwareConfig,
|
HardwareConfig,
|
||||||
@@ -11,6 +12,7 @@ from clan_lib.machines.suggestions import validate_machine_names
|
|||||||
from clan_lib.ssh.remote import Remote
|
from clan_lib.ssh.remote import Remote
|
||||||
|
|
||||||
from clan_cli.completions import add_dynamic_completer, complete_machines
|
from clan_cli.completions import add_dynamic_completer, complete_machines
|
||||||
|
from clan_cli.host_key_check import add_host_key_check_arg
|
||||||
|
|
||||||
from .types import machine_name_type
|
from .types import machine_name_type
|
||||||
|
|
||||||
@@ -19,7 +21,6 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def update_hardware_config_command(args: argparse.Namespace) -> None:
|
def update_hardware_config_command(args: argparse.Namespace) -> None:
|
||||||
validate_machine_names([args.machine], args.flake)
|
validate_machine_names([args.machine], args.flake)
|
||||||
host_key_check = args.host_key_check
|
|
||||||
machine = Machine(flake=args.flake, name=args.machine)
|
machine = Machine(flake=args.flake, name=args.machine)
|
||||||
opts = HardwareGenerateOptions(
|
opts = HardwareGenerateOptions(
|
||||||
machine=machine,
|
machine=machine,
|
||||||
@@ -30,9 +31,13 @@ def update_hardware_config_command(args: argparse.Namespace) -> None:
|
|||||||
if args.target_host:
|
if args.target_host:
|
||||||
target_host = Remote.from_ssh_uri(
|
target_host = Remote.from_ssh_uri(
|
||||||
machine_name=machine.name, address=args.target_host
|
machine_name=machine.name, address=args.target_host
|
||||||
).override(host_key_check=host_key_check)
|
)
|
||||||
else:
|
else:
|
||||||
target_host = machine.target_host().override(host_key_check=host_key_check)
|
target_host = machine.target_host()
|
||||||
|
|
||||||
|
target_host = target_host.override(
|
||||||
|
host_key_check=args.host_key_check, private_key=args.identity_file
|
||||||
|
)
|
||||||
|
|
||||||
generate_machine_hardware_info(opts, target_host)
|
generate_machine_hardware_info(opts, target_host)
|
||||||
|
|
||||||
@@ -51,12 +56,7 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None:
|
|||||||
nargs="?",
|
nargs="?",
|
||||||
help="ssh address to install to in the form of user@host:2222",
|
help="ssh address to install to in the form of user@host:2222",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
add_host_key_check_arg(parser)
|
||||||
"--host-key-check",
|
|
||||||
choices=["strict", "ask", "tofu", "none"],
|
|
||||||
default="ask",
|
|
||||||
help="Host key (.ssh/known_hosts) check mode.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--password",
|
"--password",
|
||||||
help="Pre-provided password the cli will prompt otherwise if needed.",
|
help="Pre-provided password the cli will prompt otherwise if needed.",
|
||||||
@@ -69,3 +69,9 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None:
|
|||||||
choices=["nixos-generate-config", "nixos-facter"],
|
choices=["nixos-generate-config", "nixos-facter"],
|
||||||
default="nixos-facter",
|
default="nixos-facter",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-i",
|
||||||
|
dest="identity_file",
|
||||||
|
type=Path,
|
||||||
|
help="specify which SSH private key file to use",
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from clan_cli.completions import (
|
|||||||
complete_machines,
|
complete_machines,
|
||||||
complete_target_host,
|
complete_target_host,
|
||||||
)
|
)
|
||||||
|
from clan_cli.host_key_check import add_host_key_check_arg
|
||||||
from clan_cli.machines.hardware import HardwareConfig
|
from clan_cli.machines.hardware import HardwareConfig
|
||||||
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse
|
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse
|
||||||
|
|
||||||
@@ -97,12 +98,7 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
help="do not reboot after installation (deprecated)",
|
help="do not reboot after installation (deprecated)",
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
add_host_key_check_arg(parser)
|
||||||
"--host-key-check",
|
|
||||||
choices=["strict", "ask", "tofu", "none"],
|
|
||||||
default="ask",
|
|
||||||
help="Host key (.ssh/known_hosts) check mode.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--build-on",
|
"--build-on",
|
||||||
choices=[x.value for x in BuildOn],
|
choices=[x.value for x in BuildOn],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from clan_lib.flake import Flake
|
from clan_lib.flake import Flake
|
||||||
from clan_lib.machines.list import list_full_machines, query_machines_by_tags
|
from clan_lib.machines.actions import list_machines
|
||||||
|
|
||||||
from clan_cli.completions import add_dynamic_completer, complete_tags
|
from clan_cli.completions import add_dynamic_completer, complete_tags
|
||||||
|
|
||||||
@@ -12,12 +12,8 @@ log = logging.getLogger(__name__)
|
|||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
flake: Flake = args.flake
|
flake: Flake = args.flake
|
||||||
|
|
||||||
if args.tags:
|
for name in list_machines(flake, opts={"filter": {"tags": args.tags}}):
|
||||||
for name in query_machines_by_tags(flake, args.tags):
|
print(name)
|
||||||
print(name)
|
|
||||||
else:
|
|
||||||
for name in list_full_machines(flake):
|
|
||||||
print(name)
|
|
||||||
|
|
||||||
|
|
||||||
def register_list_parser(parser: argparse.ArgumentParser) -> None:
|
def register_list_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sys
|
|||||||
|
|
||||||
from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime
|
from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
|
from clan_lib.machines.list import list_full_machines, query_machines_by_tags
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
from clan_lib.machines.suggestions import validate_machine_names
|
from clan_lib.machines.suggestions import validate_machine_names
|
||||||
from clan_lib.machines.update import deploy_machine
|
from clan_lib.machines.update import deploy_machine
|
||||||
@@ -15,7 +16,7 @@ from clan_cli.completions import (
|
|||||||
complete_machines,
|
complete_machines,
|
||||||
complete_tags,
|
complete_tags,
|
||||||
)
|
)
|
||||||
from clan_cli.machines.list import list_full_machines, query_machines_by_tags
|
from clan_cli.host_key_check import add_host_key_check_arg
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ def update_command(args: argparse.Namespace) -> None:
|
|||||||
msg = "Could not find clan flake toplevel directory"
|
msg = "Could not find clan flake toplevel directory"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
machines: list[Machine] = []
|
all_machines: list[Machine] = []
|
||||||
if args.tags:
|
if args.tags:
|
||||||
tag_filtered_machines = query_machines_by_tags(args.flake, args.tags)
|
tag_filtered_machines = query_machines_by_tags(args.flake, args.tags)
|
||||||
if args.machines:
|
if args.machines:
|
||||||
@@ -51,15 +52,18 @@ def update_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
for machine_name in selected_machines:
|
for machine_name in selected_machines:
|
||||||
machine = Machine(name=machine_name, flake=args.flake)
|
machine = Machine(name=machine_name, flake=args.flake)
|
||||||
machines.append(machine)
|
all_machines.append(machine)
|
||||||
|
|
||||||
if args.target_host is not None and len(machines) > 1:
|
if args.target_host is not None and len(all_machines) > 1:
|
||||||
msg = "Target Host can only be set for one machines"
|
msg = "Target Host can only be set for one machines"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
def filter_machine(m: Machine) -> bool:
|
def filter_machine(m: Machine) -> bool:
|
||||||
if m.deployment.get("requireExplicitUpdate", False):
|
try:
|
||||||
return False
|
if m.select("config.clan.deployment.requireExplicitUpdate"):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# check if the machine has a target host set
|
# check if the machine has a target host set
|
||||||
@@ -69,13 +73,13 @@ def update_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
machines_to_update = machines
|
machines_to_update = all_machines
|
||||||
implicit_all: bool = len(args.machines) == 0 and not args.tags
|
implicit_all: bool = len(args.machines) == 0 and not args.tags
|
||||||
if implicit_all:
|
if implicit_all:
|
||||||
machines_to_update = list(filter(filter_machine, machines))
|
machines_to_update = list(filter(filter_machine, all_machines))
|
||||||
|
|
||||||
# machines that are in the list but not included in the update list
|
# machines that are in the list but not included in the update list
|
||||||
ignored_machines = {m.name for m in machines if m not in machines_to_update}
|
ignored_machines = {m.name for m in all_machines if m not in machines_to_update}
|
||||||
|
|
||||||
if not machines_to_update and ignored_machines:
|
if not machines_to_update and ignored_machines:
|
||||||
print(
|
print(
|
||||||
@@ -96,13 +100,24 @@ def update_command(args: argparse.Namespace) -> None:
|
|||||||
args.flake.precache(
|
args.flake.precache(
|
||||||
[
|
[
|
||||||
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
|
||||||
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.system.clan.deployment.file",
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.deployment.requireExplicitUpdate",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.system.clan.deployment.nixosMobileWorkaround",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.facts.secretModule",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.facts.publicModule",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.settings.secretModule",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.settings.publicModule",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.facts.services",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.{{share,dependencies,migrateFact,prompts}}",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.files.*.{{secret,deploy,owner,group,mode,neededFor}}",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.facts.secretUploadDirectory",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.vars.password-store.secretLocation",
|
||||||
|
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.settings.passBackend",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
host_key_check = args.host_key_check
|
host_key_check = args.host_key_check
|
||||||
with AsyncRuntime() as runtime:
|
with AsyncRuntime() as runtime:
|
||||||
for machine in machines:
|
for machine in machines_to_update:
|
||||||
if args.target_host:
|
if args.target_host:
|
||||||
target_host = Remote.from_ssh_uri(
|
target_host = Remote.from_ssh_uri(
|
||||||
machine_name=machine.name,
|
machine_name=machine.name,
|
||||||
@@ -149,12 +164,7 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
add_dynamic_completer(tag_parser, complete_tags)
|
add_dynamic_completer(tag_parser, complete_tags)
|
||||||
|
|
||||||
parser.add_argument(
|
add_host_key_check_arg(parser)
|
||||||
"--host-key-check",
|
|
||||||
choices=["strict", "ask", "tofu", "none"],
|
|
||||||
default="ask",
|
|
||||||
help="Host key (.ssh/known_hosts) check mode.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--target-host",
|
"--target-host",
|
||||||
type=str,
|
type=str,
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ from typing import Any
|
|||||||
from clan_lib.cmd import run
|
from clan_lib.cmd import run
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
from clan_lib.ssh.remote import HostKeyCheck, Remote
|
from clan_lib.ssh.host_key import HostKeyCheck
|
||||||
|
from clan_lib.ssh.remote import Remote
|
||||||
|
|
||||||
from clan_cli.completions import (
|
from clan_cli.completions import (
|
||||||
add_dynamic_completer,
|
add_dynamic_completer,
|
||||||
complete_machines,
|
complete_machines,
|
||||||
)
|
)
|
||||||
|
from clan_cli.host_key_check import add_host_key_check_arg
|
||||||
from clan_cli.ssh.tor import TorTarget, spawn_tor, ssh_tor_reachable
|
from clan_cli.ssh.tor import TorTarget, spawn_tor, ssh_tor_reachable
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -181,10 +183,5 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
"--png",
|
"--png",
|
||||||
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)",
|
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
add_host_key_check_arg(parser, default=HostKeyCheck.TOFU)
|
||||||
"--host-key-check",
|
|
||||||
choices=["strict", "ask", "tofu", "none"],
|
|
||||||
default="tofu",
|
|
||||||
help="Host key (.ssh/known_hosts) check mode.",
|
|
||||||
)
|
|
||||||
parser.set_defaults(func=ssh_command)
|
parser.set_defaults(func=ssh_command)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from clan_lib.cmd import RunOpts, run
|
from clan_lib.cmd import RunOpts, run
|
||||||
from clan_lib.nix import nix_shell
|
from clan_lib.nix import nix_shell
|
||||||
|
from clan_lib.ssh.host_key import HostKeyCheck
|
||||||
from clan_lib.ssh.remote import Remote
|
from clan_lib.ssh.remote import Remote
|
||||||
|
|
||||||
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host
|
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host
|
||||||
@@ -23,7 +24,7 @@ def test_qrcode_scan(temp_dir: Path) -> None:
|
|||||||
run(cmd, RunOpts(input=data.encode()))
|
run(cmd, RunOpts(input=data.encode()))
|
||||||
|
|
||||||
# Call the qrcode_scan function
|
# Call the qrcode_scan function
|
||||||
deploy_info = DeployInfo.from_qr_code(img_path, "none")
|
deploy_info = DeployInfo.from_qr_code(img_path, HostKeyCheck.NONE)
|
||||||
|
|
||||||
host = deploy_info.addrs[0]
|
host = deploy_info.addrs[0]
|
||||||
assert host.address == "192.168.122.86"
|
assert host.address == "192.168.122.86"
|
||||||
@@ -46,7 +47,7 @@ def test_qrcode_scan(temp_dir: Path) -> None:
|
|||||||
|
|
||||||
def test_from_json() -> None:
|
def test_from_json() -> None:
|
||||||
data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}'
|
data = '{"pass":"scabbed-defender-headlock","tor":"qjeerm4r6t55hcfum4pinnvscn5njlw2g3k7ilqfuu7cdt3ahaxhsbid.onion","addrs":["192.168.122.86"]}'
|
||||||
deploy_info = DeployInfo.from_json(json.loads(data), "none")
|
deploy_info = DeployInfo.from_json(json.loads(data), HostKeyCheck.NONE)
|
||||||
|
|
||||||
host = deploy_info.addrs[0]
|
host = deploy_info.addrs[0]
|
||||||
assert host.password == "scabbed-defender-headlock"
|
assert host.password == "scabbed-defender-headlock"
|
||||||
@@ -69,7 +70,9 @@ def test_from_json() -> None:
|
|||||||
@pytest.mark.with_core
|
@pytest.mark.with_core
|
||||||
def test_find_reachable_host(hosts: list[Remote]) -> None:
|
def test_find_reachable_host(hosts: list[Remote]) -> None:
|
||||||
host = hosts[0]
|
host = hosts[0]
|
||||||
deploy_info = DeployInfo.from_hostnames(["172.19.1.2", host.ssh_url()], "none")
|
deploy_info = DeployInfo.from_hostnames(
|
||||||
|
["172.19.1.2", host.ssh_url()], HostKeyCheck.NONE
|
||||||
|
)
|
||||||
|
|
||||||
assert deploy_info.addrs[0].address == "172.19.1.2"
|
assert deploy_info.addrs[0].address == "172.19.1.2"
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import shutil
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_cli.tests import age_keys
|
from clan_cli.tests import age_keys
|
||||||
@@ -38,7 +38,12 @@ def def_value() -> defaultdict:
|
|||||||
return defaultdict(def_value)
|
return defaultdict(def_value)
|
||||||
|
|
||||||
|
|
||||||
nested_dict: Callable[[], dict[str, Any]] = lambda: defaultdict(def_value)
|
def nested_dict() -> defaultdict:
|
||||||
|
"""
|
||||||
|
Creates a defaultdict that allows for arbitrary levels of nesting.
|
||||||
|
For example: d['a']['b']['c'] = value
|
||||||
|
"""
|
||||||
|
return defaultdict(def_value)
|
||||||
|
|
||||||
|
|
||||||
# Substitutes strings in a file.
|
# Substitutes strings in a file.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def def_value() -> defaultdict:
|
|
||||||
return defaultdict(def_value)
|
|
||||||
|
|
||||||
|
|
||||||
# allows defining nested dictionary in a single line
|
|
||||||
nested_dict: Callable[[], dict[str, Any]] = lambda: defaultdict(def_value)
|
|
||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from clan_cli.tests.sshd import Sshd
|
from clan_cli.tests.sshd import Sshd
|
||||||
|
from clan_lib.ssh.host_key import HostKeyCheck
|
||||||
from clan_lib.ssh.remote import Remote
|
from clan_lib.ssh.remote import Remote
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ def hosts(sshd: Sshd) -> list[Remote]:
|
|||||||
port=sshd.port,
|
port=sshd.port,
|
||||||
user=login,
|
user=login,
|
||||||
private_key=Path(sshd.key),
|
private_key=Path(sshd.key),
|
||||||
host_key_check="none",
|
host_key_check=HostKeyCheck.NONE,
|
||||||
command_prefix="local_test",
|
command_prefix="local_test",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user