Compare commits
21 Commits
9c74c4d661
...
545d389df0
| Author | SHA1 | Date | |
|---|---|---|---|
| 545d389df0 | |||
| 6def19b4c8 | |||
| 718f647774 | |||
| 06d6edbfa7 | |||
| 740e5e2ebc | |||
| 4ab4832d41 | |||
|
|
2f9ec882b2 | ||
|
|
3dad667f97 | ||
|
|
1cca1faedd | ||
| b49433958b | |||
|
|
3650ab491d | ||
|
|
603b48a0fe | ||
|
|
78758319f3 | ||
|
|
4fa2056834 | ||
|
|
2874cf3bdb | ||
|
|
ded13b2da5 | ||
|
|
5483018783 | ||
|
|
26b7effe99 | ||
|
|
6312f47545 | ||
|
|
9ea71c90a6 | ||
|
|
d3310f861b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
.direnv
|
.direnv
|
||||||
|
.coverage.*
|
||||||
|
**/qubeclan
|
||||||
|
**/testdir
|
||||||
democlan
|
democlan
|
||||||
result*
|
result*
|
||||||
/pkgs/clan-cli/clan_cli/nixpkgs
|
/pkgs/clan-cli/clan_cli/nixpkgs
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
{
|
|
||||||
options.clan.diskLayouts.singleDiskExt4 = {
|
|
||||||
device = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
example = "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
config.disko.devices = {
|
|
||||||
disk = {
|
|
||||||
main = {
|
|
||||||
type = "disk";
|
|
||||||
device = config.clan.diskLayouts.singleDiskExt4.device;
|
|
||||||
content = {
|
|
||||||
type = "gpt";
|
|
||||||
partitions = {
|
|
||||||
boot = {
|
|
||||||
size = "1M";
|
|
||||||
type = "EF02"; # for grub MBR
|
|
||||||
};
|
|
||||||
ESP = {
|
|
||||||
size = "512M";
|
|
||||||
type = "EF00";
|
|
||||||
content = {
|
|
||||||
type = "filesystem";
|
|
||||||
format = "vfat";
|
|
||||||
mountpoint = "/boot";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
root = {
|
|
||||||
size = "100%";
|
|
||||||
content = {
|
|
||||||
type = "filesystem";
|
|
||||||
format = "ext4";
|
|
||||||
mountpoint = "/";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ self, lib, ... }: {
|
|
||||||
flake.clanModules = {
|
|
||||||
diskLayouts = lib.mapAttrs'
|
|
||||||
(name: _: lib.nameValuePair (lib.removeSuffix ".nix" name) {
|
|
||||||
imports = [
|
|
||||||
self.inputs.disko.nixosModules.disko
|
|
||||||
./diskLayouts/${name}
|
|
||||||
];
|
|
||||||
})
|
|
||||||
(builtins.readDir ./diskLayouts);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# cLAN config
|
|
||||||
|
|
||||||
`clan config` allows you to manage your nixos configuration via the terminal.
|
|
||||||
Similar as how `git config` reads and sets git options, `clan config` does the same with your nixos options
|
|
||||||
It also supports auto completion making it easy to find the right options.
|
|
||||||
|
|
||||||
## Set up clan-config
|
|
||||||
|
|
||||||
Add the clan tool to your flake inputs:
|
|
||||||
|
|
||||||
```
|
|
||||||
clan.url = "git+https://git.clan.lol/clan/clan-core";
|
|
||||||
```
|
|
||||||
|
|
||||||
and inside the mkFlake:
|
|
||||||
|
|
||||||
```
|
|
||||||
imports = [
|
|
||||||
inputs.clan.flakeModules.clan-config
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
Add an empty config file and add it to git
|
|
||||||
|
|
||||||
```command
|
|
||||||
echo "{}" > ./clan-settings.json
|
|
||||||
git add ./clan-settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Import the clan-config module into your nixos configuration:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
# clan-settings.json is located in the same directory as your flake.
|
|
||||||
# Adapt the path if necessary.
|
|
||||||
(builtins.fromJSON (builtins.readFile ./clan-settings.json))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure your nixos configuration is set a default
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{self, ...}: {
|
|
||||||
flake.nixosConfigurations.default = self.nixosConfigurations.my-machine;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use all inputs provided by the clan-config devShell in your own devShell:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{ ... }: {
|
|
||||||
perSystem = { pkgs, self', ... }: {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
inputsFrom = [ self'.devShells.clan-config ];
|
|
||||||
# ...
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
re-load your dev-shell to make the clan tool available.
|
|
||||||
|
|
||||||
```command
|
|
||||||
clan config --help
|
|
||||||
```
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# Initializing a New Clan Project
|
|
||||||
|
|
||||||
## Create a new Clan flake
|
|
||||||
|
|
||||||
1. To start a new project, execute the following command to add the clan cli to your shell:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ nix shell git+https://git.clan.lol/clan/clan-core
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Then use the following commands to initialize a new clan-flake:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ mkdir ./my-flake
|
|
||||||
$ cd ./my-flake
|
|
||||||
$ clan create
|
|
||||||
```
|
|
||||||
|
|
||||||
This action will generate two primary files: `flake.nix` and `.clan-flake`.
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ ls -la
|
|
||||||
drwx------ joerg users 5 B a minute ago ./
|
|
||||||
drwxrwxrwt root root 139 B 12 seconds ago ../
|
|
||||||
.rw-r--r-- joerg users 77 B a minute ago .clan-flake
|
|
||||||
.rw-r--r-- joerg users 4.8 KB a minute ago flake.lock
|
|
||||||
.rw-r--r-- joerg users 242 B a minute ago flake.nix
|
|
||||||
```
|
|
||||||
|
|
||||||
### Understanding the .clan-flake Marker File
|
|
||||||
|
|
||||||
The `.clan-flake` marker file serves an optional purpose: it helps the `clan-cli` utility locate the project's root directory.
|
|
||||||
If `.clan-flake` is missing, `clan-cli` will instead search for other indicators like `.git`, `.hg`, `.svn`, or `flake.nix` to identify the project root.
|
|
||||||
|
|
||||||
## Add your first machine
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan machines create my-machine
|
|
||||||
$ clan machines list
|
|
||||||
my-machine
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configure your machine
|
|
||||||
|
|
||||||
In this example we crate a user named `my-user` that is allowed to login to the machine
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
# create a new user
|
|
||||||
$ clan config --machine my-machine users.users.my-user.isNormalUser true
|
|
||||||
|
|
||||||
# set some password
|
|
||||||
$ clan config --machine my-machine users.users.my-user.hashedPassword $(mkpasswd)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test your machine config inside a VM
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ nix build .#nixosConfigurations.my-machine.config.system.build.vm
|
|
||||||
...
|
|
||||||
$ ./result/bin/run-nixos-vm
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Migrating Existing NixOS Configuration Flake
|
|
||||||
|
|
||||||
Absolutely, let's break down the migration step by step, explaining each action in detail:
|
|
||||||
|
|
||||||
#### Before You Begin
|
|
||||||
|
|
||||||
1. **Backup Your Current Configuration**: Always start by making a backup of your current NixOS configuration to ensure you can revert if needed.
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ cp -r /etc/nixos ~/nixos-backup
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update Flake Inputs**: Add a new input for the `clan-core` dependency:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
inputs.clan-core = {
|
|
||||||
url = "git+https://git.clan.lol/clan/clan-core";
|
|
||||||
# Don't do this if your machines are on nixpkgs stable.
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- `url`: Specifies the Git repository URL for Clan Core.
|
|
||||||
- `inputs.nixpkgs.follows`: Tells Nix to use the same `nixpkgs` input as your main input (in this case, it follows `nixpkgs`).
|
|
||||||
|
|
||||||
3. **Update Outputs**: Then modify the `outputs` section of your `flake.nix` to adapt to Clan Core's new provisioning method. The key changes are as follows:
|
|
||||||
|
|
||||||
Add `clan-core` to the output
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- outputs = { self, nixpkgs, }:
|
|
||||||
+ outputs = { self, nixpkgs, clan-core }:
|
|
||||||
```
|
|
||||||
|
|
||||||
Previous configuration:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
nixosConfigurations.example-desktop = nixpkgs.lib.nixosSystem {
|
|
||||||
system = "x86_64-linux";
|
|
||||||
modules = [
|
|
||||||
./configuration.nix
|
|
||||||
];
|
|
||||||
[...]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After change:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
let clan = clan-core.lib.buildClan {
|
|
||||||
# this needs to point at the repository root
|
|
||||||
directory = self;
|
|
||||||
specialArgs = {};
|
|
||||||
machines = {
|
|
||||||
example-desktop = {
|
|
||||||
nixpkgs.hostPlatform = "x86_64-linux";
|
|
||||||
imports = [
|
|
||||||
./configuration.nix
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in { inherit (clan) nixosConfigurations clanInternals; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `nixosConfigurations`: Defines NixOS configurations, using Clan Core’s `buildClan` function to manage the machines.
|
|
||||||
- Inside `machines`, a new machine configuration is defined (in this case, `example-desktop`).
|
|
||||||
- Inside `example-desktop` which is the target machine hostname, `nixpkgs.hostPlatform` specifies the host platform as `x86_64-linux`.
|
|
||||||
- `clanInternals`: Is required to enable evaluation of the secret generation/upload script on every architecture
|
|
||||||
|
|
||||||
4. **Rebuild and Switch**: Rebuild your NixOS configuration using the updated flake:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ sudo nixos-rebuild switch --flake .
|
|
||||||
```
|
|
||||||
|
|
||||||
- This command rebuilds and switches to the new configuration. Make sure to include the `--flake .` argument to use the current directory as the flake source.
|
|
||||||
|
|
||||||
5. **Test Configuration**: Before rebooting, verify that your new configuration builds without errors or warnings.
|
|
||||||
|
|
||||||
6. **Reboot**: If everything is fine, you can reboot your system to apply the changes:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ sudo reboot
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Verify**: After the reboot, confirm that your system is running with the new configuration, and all services and applications are functioning as expected.
|
|
||||||
|
|
||||||
By following these steps, you've successfully migrated your NixOS Flake configuration to include the `clan-core` input and adapted the `outputs` section to work with Clan Core's new machine provisioning method.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# Managing Secrets with Clan
|
|
||||||
|
|
||||||
Clan enables encryption of secrets within a Clan flake, ensuring secure sharing among users.
|
|
||||||
This documentation will guide you through managing secrets with the Clan CLI,
|
|
||||||
which utilizes the [sops](https://github.com/getsops/sops) format and
|
|
||||||
integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines.
|
|
||||||
|
|
||||||
## 1. Generating Keys and Creating Secrets
|
|
||||||
|
|
||||||
To begin, generate a key pair:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets key generate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Public key: age1wkth7uhpkl555g40t8hjsysr20drq286netu8zptw50lmqz7j95sw2t3l7
|
|
||||||
Generated age private key at '/home/joerg/.config/sops/age/keys.txt' for your user.
|
|
||||||
Generated age private key at '/home/joerg/.config/sops/age/keys.txt' for your user. Please back it up on a secure location or you will lose access to your secrets.
|
|
||||||
Also add your age public key to the repository with 'clan secrets users add youruser age1wkth7uhpkl555g40t8hjsysr20drq286netu8zptw50lmqz7j95sw2t3l7' (replace you
|
|
||||||
user with your user name)
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **Important**: Backup the generated private key securely, or risk losing access to your secrets.
|
|
||||||
|
|
||||||
Next, add your public key to the Clan flake repository:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets users add <your_username> <your_public_key>
|
|
||||||
```
|
|
||||||
|
|
||||||
Doing so creates this structure in your Clan flake:
|
|
||||||
|
|
||||||
```
|
|
||||||
sops/
|
|
||||||
└── users/
|
|
||||||
└── <your_username>/
|
|
||||||
└── key.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, to set your first secret:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets set mysecret
|
|
||||||
Paste your secret:
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: As you type your secret, keypresses won't be displayed. Press Enter to save the secret.
|
|
||||||
|
|
||||||
Retrieve the stored secret:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets get mysecret
|
|
||||||
```
|
|
||||||
|
|
||||||
And list all secrets like this:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets list
|
|
||||||
```
|
|
||||||
|
|
||||||
Secrets in the repository follow this structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
sops/
|
|
||||||
├── secrets/
|
|
||||||
│ └── <secret_name>/
|
|
||||||
│ ├── secret
|
|
||||||
│ └── users/
|
|
||||||
│ └── <your_username>/
|
|
||||||
```
|
|
||||||
|
|
||||||
The content of the secret is stored encrypted inside the `secret` file under `mysecret`.
|
|
||||||
By default, secrets are encrypted with your key to ensure readability.
|
|
||||||
|
|
||||||
## 2. Adding Machine Keys
|
|
||||||
|
|
||||||
New machines in Clan come with age keys stored in `./sops/machines/<machine_name>`. To list these machines:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets machines list
|
|
||||||
```
|
|
||||||
|
|
||||||
For existing machines, add their keys:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets machines add <machine_name> <age_key>
|
|
||||||
```
|
|
||||||
|
|
||||||
To fetch an age key from an SSH host key:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ ssh-keyscan <domain_name> | nix shell nixpkgs#ssh-to-age -c ssh-to-age
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Assigning Access
|
|
||||||
|
|
||||||
By default, secrets are encrypted for your key. To specify which users and machines can access a secret:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets set --machine <machine1> --machine <machine2> --user <user1> --user <user2> <secret_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
You can add machines/users to existing secrets without modifying the secret:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets machines add-secret <machine_name> <secret_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Utilizing Groups
|
|
||||||
|
|
||||||
For convenience, Clan CLI allows group creation to simplify access management. Here's how:
|
|
||||||
|
|
||||||
1. **Creating Groups**:
|
|
||||||
|
|
||||||
Assign users to a new group, e.g., `admins`:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets groups add admins <username>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Listing Groups**:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets groups list
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Assigning Secrets to Groups**:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
$ clan secrets groups add-secret <group_name> <secret_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
# NixOS integration
|
|
||||||
|
|
||||||
A NixOS machine will automatically import all secrets that are encrypted for the
|
|
||||||
current machine. At runtime it will use the host key to decrypt all secrets into
|
|
||||||
a in-memory, non-persistent filesystem using
|
|
||||||
[sops-nix](https://github.com/Mic92/sops-nix). In your nixos configuration you
|
|
||||||
can get a path to secrets like this `config.sops.secrets.<name>.path`. Example:
|
|
||||||
|
|
||||||
```nix
|
|
||||||
{ config, ...}: {
|
|
||||||
sops.secrets.my-password.neededForUsers = true;
|
|
||||||
|
|
||||||
users.users.mic92 = {
|
|
||||||
isNormalUser = true;
|
|
||||||
passwordFile = config.sops.secrets.my-password.path;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [readme](https://github.com/Mic92/sops-nix) of sops-nix for more
|
|
||||||
examples.
|
|
||||||
|
|
||||||
# Importing existing sops-based keys / sops-nix
|
|
||||||
|
|
||||||
`clan secrets` stores each secrets in a single file, whereas [sops](https://github.com/Mic92/sops-nix)
|
|
||||||
commonly allows to put all secrets in a yaml or json documents.
|
|
||||||
|
|
||||||
If you already happend to use sops-nix, you can migrate by using the `clan secrets import-sops` command by importing these documents:
|
|
||||||
|
|
||||||
```shellSession
|
|
||||||
% clan secrets import-sops --prefix matchbox- --group admins --machine matchbox nixos/matchbox/secrets/secrets.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create secrets for each secret found in `nixos/matchbox/secrets/secrets.yaml` in a ./sops folder of your repository.
|
|
||||||
Each member of the group `admins` will be able
|
|
||||||
|
|
||||||
Since our clan secret module will auto-import secrets that are encrypted for a particular nixos machine,
|
|
||||||
you can now remove `sops.secrets.<secrets> = { };` unless you need to specify more options for the secret like owner/group of the secret file.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Self Hosting
|
|
||||||
|
|
||||||
## General Description
|
|
||||||
|
|
||||||
Self-hosting refers to the practice of hosting and maintaining servers, networks, storage, services, and other types of infrastructure by oneself rather than relying on a third-party vendor. This could involve running a server from a home or business location, or leasing a dedicated server at a data center.
|
|
||||||
|
|
||||||
There are several reasons for choosing to self-host. These can include:
|
|
||||||
|
|
||||||
1. Cost savings: Over time, self-hosting can be more cost-effective, especially for businesses with large scale needs.
|
|
||||||
|
|
||||||
1. Control: Self-hosting provides a greater level of control over the infrastructure and services. It allows the owner to customize the system to their specific needs.
|
|
||||||
|
|
||||||
1. Privacy and security: Self-hosting can offer improved privacy and security because data remains under the control of the host rather than being stored on third-party servers.
|
|
||||||
|
|
||||||
1. Independent: Being independent of third-party services can ensure that one's websites, applications, or services remain up even if the third-party service goes down.
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
|
|
||||||
### Story 1: Private mumble server hosted at home
|
|
||||||
|
|
||||||
Alice wants to self-host a mumble server for her family.
|
|
||||||
|
|
||||||
- She visits to the cLAN website, and follows the instructions on how to install cLAN-OS on her server.
|
|
||||||
- Alice logs into a terminal on her server via SSH (alternatively uses cLAN GUI app)
|
|
||||||
- Using the cLAN CLI or GUI tool, alice creates a new private network for her family (VPN)
|
|
||||||
- Alice now browses a list of curated cLAN modules and finds a module for mumble.
|
|
||||||
- She adds this module to her network using the cLAN tool.
|
|
||||||
- After that, she uses the clan tool to invite her family members to her network
|
|
||||||
- Other family members join the private network via the invitation.
|
|
||||||
- By accepting the invitation, other members automatically install all required software to interact with the network on their machine.
|
|
||||||
|
|
||||||
### Story 2: Adding a service to an existing network
|
|
||||||
|
|
||||||
Alice wants to add a photos app to her private network
|
|
||||||
|
|
||||||
- She uses the clan CLI or GUI tool to manage her existing private cLAN family network
|
|
||||||
- She discovers a module for photoprism, and adds it to her server using the tool
|
|
||||||
- Other members who are already part of her network, will receive a notification that an update is required to their environment
|
|
||||||
- After accepting, all new software and services to interact with the new photoprism service will be installed automatically.
|
|
||||||
|
|
||||||
## Challenges
|
|
||||||
|
|
||||||
...
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Joining a cLAN network
|
|
||||||
|
|
||||||
## General Description
|
|
||||||
|
|
||||||
Joining a self-hosted infrastructure involves connecting to a network, server, or system that is privately owned and managed, instead of being hosted by a third-party service provider. This could be a business's internal server, a private cloud setup, or any other private IT infrastructure that is not publicly accessible or controlled by outside entities.
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
|
|
||||||
### Story 1: Joining a private network
|
|
||||||
|
|
||||||
Alice' son Bob has never heard of cLAN, but receives an invitation URL from Alice who already set up private cLAN network for her family.
|
|
||||||
|
|
||||||
Bob opens the invitation link and lands on the cLAN website. He quickly learns about what cLAN is and can see that the invitation is for a private network of his family that hosts a number of services, like a private voice chat and a photo sharing platform.
|
|
||||||
|
|
||||||
Bob decides to join the network and follows the instructions to install the cLAN tool on his computer.
|
|
||||||
|
|
||||||
Feeding the invitation link to the cLAN tool, bob registers his machine with the network.
|
|
||||||
|
|
||||||
All programs required to interact with the network will be installed and configured automatically and securely.
|
|
||||||
|
|
||||||
Optionally, bob can customize the configuration of these programs through a simplified configuration interface.
|
|
||||||
|
|
||||||
### Story 2: Receiving breaking changes
|
|
||||||
|
|
||||||
The cLAN family network which Bob is part of received an update.
|
|
||||||
|
|
||||||
The existing photo sharing service has been removed and replaced with another alternative service. The new photo sharing service requires a different client app to view and upload photos.
|
|
||||||
|
|
||||||
Bob accepts the update. Now his environment will be updated. The old client software will be removed and the new one installed.
|
|
||||||
|
|
||||||
Because Bob has customized the previous photo viewing app, he is notified that this customization is no longer valid, as the software has been removed (deprecation message).l
|
|
||||||
|
|
||||||
Optionally, Bob can now customize the new photo viewing software through his cLAN configuration app or via a config file.
|
|
||||||
|
|
||||||
## Challenges
|
|
||||||
|
|
||||||
...
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# cLAN module maintaining
|
|
||||||
|
|
||||||
## General Description
|
|
||||||
|
|
||||||
cLAN modules are pieces of software that can be used by admins to build a private or public infrastructure.
|
|
||||||
|
|
||||||
cLAN modules should have the following properties:
|
|
||||||
|
|
||||||
1. Documented: It should be clear what the module does and how to use it.
|
|
||||||
1. Self contained: A module should be usable as is. If it requires any other software or settings, those should be delivered with the module itself.
|
|
||||||
1. Simple to deploy and use: Modules should have opinionated defaults that just work. Any customization should be optional
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
|
|
||||||
### Story 1: Maintaining a shared folder module
|
|
||||||
|
|
||||||
Alice maintains a module for a shared folder service that she uses in her own infra, but also publishes for the community.
|
|
||||||
|
|
||||||
By following clan module standards (Backups, Interfaces, Output schema, etc), other community members have an easy time re-using the module within their own infra.
|
|
||||||
|
|
||||||
She benefits from publishing the module, because other community members start using it and help to maintain it.
|
|
||||||
|
|
||||||
## Challenges
|
|
||||||
|
|
||||||
...
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# (TITLE)
|
|
||||||
|
|
||||||
## General Description
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
|
|
||||||
### Story 1: Some Description
|
|
||||||
|
|
||||||
Alice...
|
|
||||||
|
|
||||||
### Story 2: Some Description
|
|
||||||
|
|
||||||
Bob...
|
|
||||||
|
|
||||||
## Challenges
|
|
||||||
|
|
||||||
...
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# ZeroTier Configuration with NixOS in Clan
|
|
||||||
|
|
||||||
This guide provides detailed instructions for configuring
|
|
||||||
[ZeroTier VPN](https://zerotier.com) within Clan. Follow the
|
|
||||||
outlined steps to set up a machine as a VPN controller (`<CONTROLLER>`) and to
|
|
||||||
include a new machine into the VPN.
|
|
||||||
|
|
||||||
## 1. Setting Up the VPN Controller
|
|
||||||
|
|
||||||
The VPN controller is initially essential for providing configuration to new
|
|
||||||
peers. Post the address allocation, the controller's continuous operation is not
|
|
||||||
crucial.
|
|
||||||
|
|
||||||
### Instructions:
|
|
||||||
|
|
||||||
1. **Designate a Machine**: Label a machine as the VPN controller in the clan,
|
|
||||||
referred to as `<CONTROLLER>` henceforth in this guide.
|
|
||||||
2. **Add Configuration**: Input the below configuration to the NixOS
|
|
||||||
configuration of the controller machine:
|
|
||||||
```nix
|
|
||||||
clan.networking.zerotier.controller = {
|
|
||||||
enable = true;
|
|
||||||
public = true;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
3. **Update the Controller Machine**: Execute the following:
|
|
||||||
```console
|
|
||||||
$ clan machines update <CONTROLLER>
|
|
||||||
```
|
|
||||||
Your machine is now operational as the VPN controller.
|
|
||||||
|
|
||||||
## 2. Integrating a New Machine to the VPN
|
|
||||||
|
|
||||||
To introduce a new machine to the VPN, adhere to the following steps:
|
|
||||||
|
|
||||||
### Instructions:
|
|
||||||
|
|
||||||
1. **Update Configuration**: On the new machine, incorporate the below to its
|
|
||||||
configuration, substituting `<CONTROLLER>` with the controller machine name:
|
|
||||||
```nix
|
|
||||||
{ config, ... }: {
|
|
||||||
clan.networking.zerotier.networkId = builtins.readFile (config.clanCore.clanDir + "/machines/<CONTROLLER>/facts/zerotier-network-id");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. **Update the New Machine**: Execute:
|
|
||||||
```console
|
|
||||||
$ clan machines update <NEW_MACHINE>
|
|
||||||
```
|
|
||||||
Replace `<NEW_MACHINE>` with the designated new machine name.
|
|
||||||
3. **Retrieve the ZeroTier ID**: On the `new_machine`, execute:
|
|
||||||
```console
|
|
||||||
$ sudo zerotier-cli info
|
|
||||||
```
|
|
||||||
Example Output: `200 info d2c71971db 1.12.1 OFFLINE`, where `d2c71971db` is
|
|
||||||
the ZeroTier ID.
|
|
||||||
4. **Authorize the New Machine on Controller**: On the controller machine,
|
|
||||||
execute:
|
|
||||||
```console
|
|
||||||
$ sudo zerotier-members allow <ID>
|
|
||||||
```
|
|
||||||
Substitute `<ID>` with the ZeroTier ID obtained previously.
|
|
||||||
5. **Verify Connection**: On the `new_machine`, re-execute:
|
|
||||||
```console
|
|
||||||
$ sudo zerotier-cli info
|
|
||||||
```
|
|
||||||
The status should now be "ONLINE" e.g., `200 info 47303517ef 1.12.1 ONLINE`.
|
|
||||||
|
|
||||||
Congratulations! The new machine is now part of the VPN, and the ZeroTier
|
|
||||||
configuration on NixOS within the Clan project is complete.
|
|
||||||
104
flake.lock
generated
104
flake.lock
generated
@@ -1,25 +1,5 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"disko": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1696266752,
|
|
||||||
"narHash": "sha256-wJnMDFM21+xXdsXSs6pXMElbv4YfqmQslcPApRuaYKs=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "disko",
|
|
||||||
"rev": "646ee25c25fffee122a66282861f5f56ad3e0fd9",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "disko",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": [
|
"nixpkgs-lib": [
|
||||||
@@ -27,11 +7,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1693611461,
|
"lastModified": 1696343447,
|
||||||
"narHash": "sha256-aPODl8vAgGQ0ZYFIRisxYG5MOGSkIczvu2Cd8Gb9+1Y=",
|
"narHash": "sha256-B2xAZKLkkeRFG5XcHHSXXcP7To9Xzr59KXeZiRf4vdQ=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "7f53fdb7bdc5bb237da7fefef12d099e4fd611ca",
|
"rev": "c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -60,90 +40,30 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixlib": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1693701915,
|
|
||||||
"narHash": "sha256-waHPLdDYUOHSEtMKKabcKIMhlUOHPOOPQ9UyFeEoovs=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixpkgs.lib",
|
|
||||||
"rev": "f5af57d3ef9947a70ac86e42695231ac1ad00c25",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixpkgs.lib",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixos-generators": {
|
|
||||||
"inputs": {
|
|
||||||
"nixlib": "nixlib",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1693791338,
|
|
||||||
"narHash": "sha256-wHmtB5H8AJTUaeGHw+0hsQ6nU4VyvVrP2P4NeCocRzY=",
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixos-generators",
|
|
||||||
"rev": "8ee78470029e641cddbd8721496da1316b47d3b4",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-community",
|
|
||||||
"repo": "nixos-generators",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1695741452,
|
"lastModified": 1697059129,
|
||||||
"narHash": "sha256-pDIQmCR0fyb6FKjvURaD6yC5YnE/+rxs5iFQQGgcoNE=",
|
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
|
||||||
"owner": "Mic92",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "bc160df717ed1e9defe6044092ea66950976e3ed",
|
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "Mic92",
|
"owner": "nixos",
|
||||||
"ref": "fakeroot",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"disko": "disko",
|
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"floco": "floco",
|
"floco": "floco",
|
||||||
"nixos-generators": "nixos-generators",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"sops-nix": "sops-nix",
|
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sops-nix": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"sops-nix"
|
|
||||||
],
|
|
||||||
"nixpkgs-stable": []
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1695284550,
|
|
||||||
"narHash": "sha256-z9fz/wz9qo9XePEvdduf+sBNeoI9QG8NJKl5ssA8Xl4=",
|
|
||||||
"owner": "Mic92",
|
|
||||||
"repo": "sops-nix",
|
|
||||||
"rev": "2f375ed8702b0d8ee2430885059d5e7975e38f78",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "Mic92",
|
|
||||||
"repo": "sops-nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"treefmt-nix": {
|
"treefmt-nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
@@ -151,11 +71,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1695290086,
|
"lastModified": 1695822946,
|
||||||
"narHash": "sha256-ol6licpIAzc9oMsEai/9YZhgSMcrnlnD/3ulMLGNKL0=",
|
"narHash": "sha256-IQU3fYo0H+oGlqX5YrgZU3VRhbt2Oqe6KmslQKUO4II=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "e951529be2e7c669487de78f5aef8597bbae5fca",
|
"rev": "720bd006d855b08e60664e4683ccddb7a9ff614a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
21
flake.nix
21
flake.nix
@@ -1,22 +1,12 @@
|
|||||||
{
|
{
|
||||||
description = "clan.lol base operating system";
|
description = "Consulting Website";
|
||||||
|
|
||||||
nixConfig.extra-substituters = [ "https://cache.clan.lol" ];
|
|
||||||
nixConfig.extra-trusted-public-keys = [ "cache.clan.lol-1:3KztgSAB5R1M+Dz7vzkBGzXdodizbgLXGXKXlcQLA28=" ];
|
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
#nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
#nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
# https://github.com/NixOS/nixpkgs/pull/257462
|
# https://github.com/NixOS/nixpkgs/pull/257462
|
||||||
nixpkgs.url = "github:Mic92/nixpkgs/fakeroot";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
floco.url = "github:aakropotkin/floco";
|
floco.url = "github:aakropotkin/floco";
|
||||||
floco.inputs.nixpkgs.follows = "nixpkgs";
|
floco.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
disko.url = "github:nix-community/disko";
|
|
||||||
disko.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
sops-nix.url = "github:Mic92/sops-nix";
|
|
||||||
sops-nix.inputs.nixpkgs.follows = "sops-nix";
|
|
||||||
sops-nix.inputs.nixpkgs-stable.follows = "";
|
|
||||||
nixos-generators.url = "github:nix-community/nixos-generators";
|
|
||||||
nixos-generators.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
|
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||||
treefmt-nix.url = "github:numtide/treefmt-nix";
|
treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||||
@@ -34,14 +24,7 @@
|
|||||||
./checks/flake-module.nix
|
./checks/flake-module.nix
|
||||||
./devShell.nix
|
./devShell.nix
|
||||||
./formatter.nix
|
./formatter.nix
|
||||||
./templates/flake-module.nix
|
|
||||||
./clanModules/flake-module.nix
|
|
||||||
|
|
||||||
./pkgs/flake-module.nix
|
./pkgs/flake-module.nix
|
||||||
|
|
||||||
./lib/flake-module.nix
|
|
||||||
./nixosModules/flake-module.nix
|
|
||||||
./nixosModules/clanCore/flake-module.nix
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
treefmt.flakeFormatter = true;
|
treefmt.flakeFormatter = true;
|
||||||
treefmt.programs.shellcheck.enable = true;
|
treefmt.programs.shellcheck.enable = true;
|
||||||
treefmt.programs.prettier.enable = true;
|
treefmt.programs.prettier.enable = true;
|
||||||
treefmt.programs.prettier.settings.plugins = [
|
# TODO: add custom prettier package, that uses our ui/node_modules
|
||||||
"${self'.packages.prettier-plugin-tailwindcss}/lib/node_modules/prettier-plugin-tailwindcss/dist/index.mjs"
|
# treefmt.programs.prettier.settings.plugins = [
|
||||||
];
|
# "${self'.packages.prettier-plugin-tailwindcss}/lib/node_modules/prettier-plugin-tailwindcss/dist/index.mjs"
|
||||||
|
# ];
|
||||||
treefmt.settings.formatter.prettier.excludes = [
|
treefmt.settings.formatter.prettier.excludes = [
|
||||||
"secrets.yaml"
|
"secrets.yaml"
|
||||||
"key.json"
|
"key.json"
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{ nixpkgs, self, lib }:
|
|
||||||
{ directory # The directory containing the machines subdirectory
|
|
||||||
, specialArgs ? { } # Extra arguments to pass to nixosSystem i.e. useful to make self available
|
|
||||||
, machines ? { } # allows to include machine-specific modules i.e. machines.${name} = { ... }
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (builtins.readDir (directory + /machines));
|
|
||||||
|
|
||||||
machineSettings = machineName:
|
|
||||||
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json")
|
|
||||||
(builtins.fromJSON
|
|
||||||
(builtins.readFile (directory + /machines/${machineName}/settings.json)));
|
|
||||||
|
|
||||||
# TODO: remove default system once we have a hardware-config mechanism
|
|
||||||
nixosConfiguration = { system ? "x86_64-linux", name }: nixpkgs.lib.nixosSystem {
|
|
||||||
modules = [
|
|
||||||
self.nixosModules.clanCore
|
|
||||||
(machineSettings name)
|
|
||||||
(machines.${name} or { })
|
|
||||||
{
|
|
||||||
clanCore.machineName = name;
|
|
||||||
clanCore.clanDir = directory;
|
|
||||||
nixpkgs.hostPlatform = lib.mkForce system;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
inherit specialArgs;
|
|
||||||
};
|
|
||||||
|
|
||||||
allMachines = machinesDirs // machines;
|
|
||||||
|
|
||||||
supportedSystems = [
|
|
||||||
"x86_64-linux"
|
|
||||||
"aarch64-linux"
|
|
||||||
"riscv64-linux"
|
|
||||||
"x86_64-darwin"
|
|
||||||
"aarch64-darwin"
|
|
||||||
];
|
|
||||||
|
|
||||||
nixosConfigurations = lib.mapAttrs (name: _: nixosConfiguration { inherit name; }) allMachines;
|
|
||||||
|
|
||||||
# This instantiates nixos for each system that we support:
|
|
||||||
# configPerSystem = <system>.<machine>.nixosConfiguration
|
|
||||||
# We need this to build nixos secret generators for each system
|
|
||||||
configsPerSystem = builtins.listToAttrs
|
|
||||||
(builtins.map
|
|
||||||
(system: lib.nameValuePair system
|
|
||||||
(lib.mapAttrs (name: _: nixosConfiguration { inherit name system; }) allMachines))
|
|
||||||
supportedSystems);
|
|
||||||
in
|
|
||||||
{
|
|
||||||
inherit nixosConfigurations;
|
|
||||||
|
|
||||||
clanInternals = {
|
|
||||||
machines = configsPerSystem;
|
|
||||||
all-machines-json = lib.mapAttrs
|
|
||||||
(system: configs: nixpkgs.legacyPackages.${system}.writers.writeJSON "machines.json" (lib.mapAttrs (_: m: m.config.system.clan.deployment.data) configs))
|
|
||||||
configsPerSystem;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{ lib, self, nixpkgs, ... }:
|
|
||||||
{
|
|
||||||
jsonschema = import ./jsonschema { inherit lib; };
|
|
||||||
|
|
||||||
buildClan = import ./build-clan { inherit lib self nixpkgs; };
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{ lib
|
|
||||||
, inputs
|
|
||||||
, self
|
|
||||||
, ...
|
|
||||||
}: {
|
|
||||||
imports = [
|
|
||||||
./jsonschema/flake-module.nix
|
|
||||||
];
|
|
||||||
flake.lib = import ./default.nix {
|
|
||||||
inherit lib;
|
|
||||||
inherit self;
|
|
||||||
inherit (inputs) nixpkgs;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
{ lib ? import <nixpkgs/lib> }:
|
|
||||||
let
|
|
||||||
|
|
||||||
# from nixos type to jsonschema type
|
|
||||||
typeMap = {
|
|
||||||
bool = "boolean";
|
|
||||||
float = "number";
|
|
||||||
int = "integer";
|
|
||||||
str = "string";
|
|
||||||
path = "string"; # TODO add prober path checks
|
|
||||||
};
|
|
||||||
|
|
||||||
# remove _module attribute from options
|
|
||||||
clean = opts: builtins.removeAttrs opts [ "_module" ];
|
|
||||||
|
|
||||||
# throw error if option type is not supported
|
|
||||||
notSupported = option: throw
|
|
||||||
"option type '${option.type.name}' ('${option.type.description}') not supported by jsonschema converter";
|
|
||||||
|
|
||||||
in
|
|
||||||
rec {
|
|
||||||
|
|
||||||
# parses a nixos module to a jsonschema
|
|
||||||
parseModule = module:
|
|
||||||
let
|
|
||||||
evaled = lib.evalModules {
|
|
||||||
modules = [ module ];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
parseOptions evaled.options;
|
|
||||||
|
|
||||||
# parses a set of evaluated nixos options to a jsonschema
|
|
||||||
parseOptions = options':
|
|
||||||
let
|
|
||||||
options = clean options';
|
|
||||||
# parse options to jsonschema properties
|
|
||||||
properties = lib.mapAttrs (_name: option: parseOption option) options;
|
|
||||||
isRequired = prop: ! (prop ? default || prop.type == "object");
|
|
||||||
requiredProps = lib.filterAttrs (_: prop: isRequired prop) properties;
|
|
||||||
required = lib.optionalAttrs (requiredProps != { }) {
|
|
||||||
required = lib.attrNames requiredProps;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
# return jsonschema
|
|
||||||
required // {
|
|
||||||
type = "object";
|
|
||||||
inherit properties;
|
|
||||||
};
|
|
||||||
|
|
||||||
# parses and evaluated nixos option to a jsonschema property definition
|
|
||||||
parseOption = option:
|
|
||||||
let
|
|
||||||
default = lib.optionalAttrs (option ? default) {
|
|
||||||
inherit (option) default;
|
|
||||||
};
|
|
||||||
description = lib.optionalAttrs (option ? description) {
|
|
||||||
inherit (option) description;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
|
|
||||||
# handle nested options (not a submodule)
|
|
||||||
if ! option ? _type
|
|
||||||
then parseOptions option
|
|
||||||
|
|
||||||
# throw if not an option
|
|
||||||
else if option._type != "option"
|
|
||||||
then throw "parseOption: not an option"
|
|
||||||
|
|
||||||
# parse nullOr
|
|
||||||
else if option.type.name == "nullOr"
|
|
||||||
# return jsonschema property definition for nullOr
|
|
||||||
then default // description // {
|
|
||||||
type = [
|
|
||||||
"null"
|
|
||||||
(typeMap.${option.type.functor.wrapped.name} or (notSupported option))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse bool
|
|
||||||
else if option.type.name == "bool"
|
|
||||||
# return jsonschema property definition for bool
|
|
||||||
then default // description // {
|
|
||||||
type = "boolean";
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse float
|
|
||||||
else if option.type.name == "float"
|
|
||||||
# return jsonschema property definition for float
|
|
||||||
then default // description // {
|
|
||||||
type = "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse int
|
|
||||||
else if (option.type.name == "int" || option.type.name == "positiveInt")
|
|
||||||
# return jsonschema property definition for int
|
|
||||||
then default // description // {
|
|
||||||
type = "integer";
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse string
|
|
||||||
else if option.type.name == "str"
|
|
||||||
# return jsonschema property definition for string
|
|
||||||
then default // description // {
|
|
||||||
type = "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse string
|
|
||||||
else if option.type.name == "path"
|
|
||||||
# return jsonschema property definition for path
|
|
||||||
then default // description // {
|
|
||||||
type = "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse enum
|
|
||||||
else if option.type.name == "enum"
|
|
||||||
# return jsonschema property definition for enum
|
|
||||||
then default // description // {
|
|
||||||
enum = option.type.functor.payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse listOf submodule
|
|
||||||
else if option.type.name == "listOf" && option.type.functor.wrapped.name == "submodule"
|
|
||||||
# return jsonschema property definition for listOf submodule
|
|
||||||
then default // description // {
|
|
||||||
type = "array";
|
|
||||||
items = parseOptions (option.type.functor.wrapped.getSubOptions option.loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse list
|
|
||||||
else if
|
|
||||||
(option.type.name == "listOf")
|
|
||||||
&& (typeMap ? "${option.type.functor.wrapped.name}")
|
|
||||||
# return jsonschema property definition for list
|
|
||||||
then default // description // {
|
|
||||||
type = "array";
|
|
||||||
items = {
|
|
||||||
type = typeMap.${option.type.functor.wrapped.name};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse attrsOf submodule
|
|
||||||
else if option.type.name == "attrsOf" && option.type.nestedTypes.elemType.name == "submodule"
|
|
||||||
# return jsonschema property definition for attrsOf submodule
|
|
||||||
then default // description // {
|
|
||||||
type = "object";
|
|
||||||
additionalProperties = parseOptions (option.type.nestedTypes.elemType.getSubOptions option.loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse attrs
|
|
||||||
else if option.type.name == "attrsOf"
|
|
||||||
# return jsonschema property definition for attrs
|
|
||||||
then default // description // {
|
|
||||||
type = "object";
|
|
||||||
additionalProperties = {
|
|
||||||
type = typeMap.${option.type.nestedTypes.elemType.name} or (notSupported option);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
# parse submodule
|
|
||||||
else if option.type.name == "submodule"
|
|
||||||
# return jsonschema property definition for submodule
|
|
||||||
# then (lib.attrNames (option.type.getSubOptions option.loc).opt)
|
|
||||||
then parseOptions (option.type.getSubOptions option.loc)
|
|
||||||
|
|
||||||
# throw error if option type is not supported
|
|
||||||
else notSupported option;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "John Doe",
|
|
||||||
"age": 42,
|
|
||||||
"isAdmin": false,
|
|
||||||
"kernelModules": ["usbhid", "usb_storage"],
|
|
||||||
"userIds": {
|
|
||||||
"mic92": 1,
|
|
||||||
"lassulus": 2,
|
|
||||||
"davhau": 3
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"opt": "this option doesn't make sense"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
An example nixos module declaring an interface.
|
|
||||||
*/
|
|
||||||
{ lib, ... }: {
|
|
||||||
options = {
|
|
||||||
# str
|
|
||||||
name = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "John Doe";
|
|
||||||
description = "The name of the user";
|
|
||||||
};
|
|
||||||
# int
|
|
||||||
age = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 42;
|
|
||||||
description = "The age of the user";
|
|
||||||
};
|
|
||||||
# bool
|
|
||||||
isAdmin = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Is the user an admin?";
|
|
||||||
};
|
|
||||||
# a submodule option
|
|
||||||
services = lib.mkOption {
|
|
||||||
type = lib.types.submodule {
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "foo";
|
|
||||||
description = "A submodule option";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
# attrs of int
|
|
||||||
userIds = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf lib.types.int;
|
|
||||||
description = "Some attributes";
|
|
||||||
default = {
|
|
||||||
horst = 1;
|
|
||||||
peter = 2;
|
|
||||||
albrecht = 3;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
# list of str
|
|
||||||
kernelModules = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ "nvme" "xhci_pci" "ahci" ];
|
|
||||||
description = "A list of enabled kernel modules";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "John Doe",
|
|
||||||
"description": "The name of the user"
|
|
||||||
},
|
|
||||||
"age": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 42,
|
|
||||||
"description": "The age of the user"
|
|
||||||
},
|
|
||||||
"isAdmin": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"description": "Is the user an admin?"
|
|
||||||
},
|
|
||||||
"kernelModules": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"default": ["nvme", "xhci_pci", "ahci"],
|
|
||||||
"description": "A list of enabled kernel modules"
|
|
||||||
},
|
|
||||||
"userIds": {
|
|
||||||
"type": "object",
|
|
||||||
"default": {
|
|
||||||
"horst": 1,
|
|
||||||
"peter": 2,
|
|
||||||
"albrecht": 3
|
|
||||||
},
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"description": "Some attributes"
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"opt": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "foo",
|
|
||||||
"description": "A submodule option"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
perSystem = { pkgs, self', ... }: {
|
|
||||||
checks = {
|
|
||||||
|
|
||||||
# check if the `clan config` example jsonschema and data is valid
|
|
||||||
lib-jsonschema-example-valid = pkgs.runCommand "lib-jsonschema-example-valid" { } ''
|
|
||||||
echo "Checking that example-schema.json is valid"
|
|
||||||
${pkgs.check-jsonschema}/bin/check-jsonschema \
|
|
||||||
--check-metaschema ${./.}/example-schema.json
|
|
||||||
|
|
||||||
echo "Checking that example-data.json is valid according to example-schema.json"
|
|
||||||
${pkgs.check-jsonschema}/bin/check-jsonschema \
|
|
||||||
--schemafile ${./.}/example-schema.json \
|
|
||||||
${./.}/example-data.json
|
|
||||||
|
|
||||||
touch $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
# check if the `clan config` nix jsonschema converter unit tests succeed
|
|
||||||
lib-jsonschema-nix-unit-tests = pkgs.runCommand "lib-jsonschema-nix-unit-tests" { } ''
|
|
||||||
export NIX_PATH=nixpkgs=${pkgs.path}
|
|
||||||
${self'.packages.nix-unit}/bin/nix-unit \
|
|
||||||
${./.}/test.nix \
|
|
||||||
--eval-store $(realpath .)
|
|
||||||
touch $out
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
expr='let pkgs = import <nixpkgs> {}; lib = pkgs.lib; in (pkgs.nixosOptionsDoc {options = (lib.evalModules {modules=[./example-interface.nix];}).options;}).optionsJSON.options'
|
|
||||||
|
|
||||||
jq < "$(nix eval --impure --raw --expr "$expr")" > options.json
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
"age": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "42"
|
|
||||||
},
|
|
||||||
"description": "The age of the user",
|
|
||||||
"loc": ["age"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "signed integer"
|
|
||||||
},
|
|
||||||
"isAdmin": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "false"
|
|
||||||
},
|
|
||||||
"description": "Is the user an admin?",
|
|
||||||
"loc": ["isAdmin"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"kernelModules": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "[\n \"nvme\"\n \"xhci_pci\"\n \"ahci\"\n]"
|
|
||||||
},
|
|
||||||
"description": "A list of enabled kernel modules",
|
|
||||||
"loc": ["kernelModules"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "list of string"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "\"John Doe\""
|
|
||||||
},
|
|
||||||
"description": "The name of the user",
|
|
||||||
"loc": ["name"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"description": null,
|
|
||||||
"loc": ["services"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "submodule"
|
|
||||||
},
|
|
||||||
"services.opt": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "\"foo\""
|
|
||||||
},
|
|
||||||
"description": "A submodule option",
|
|
||||||
"loc": ["services", "opt"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"userIds": {
|
|
||||||
"declarations": [
|
|
||||||
"/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix"
|
|
||||||
],
|
|
||||||
"default": {
|
|
||||||
"_type": "literalExpression",
|
|
||||||
"text": "{\n albrecht = 3;\n horst = 1;\n peter = 2;\n}"
|
|
||||||
},
|
|
||||||
"description": "Some attributes",
|
|
||||||
"loc": ["userIds"],
|
|
||||||
"readOnly": false,
|
|
||||||
"type": "attribute set of signed integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# run these tests via `nix-unit ./test.nix`
|
|
||||||
{ lib ? (import <nixpkgs> { }).lib
|
|
||||||
, slib ? import ./. { inherit lib; }
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
parseOption = import ./test_parseOption.nix { inherit lib slib; };
|
|
||||||
parseOptions = import ./test_parseOptions.nix { inherit lib slib; };
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# tests for the nixos options to jsonschema converter
|
|
||||||
# run these tests via `nix-unit ./test.nix`
|
|
||||||
{ lib ? (import <nixpkgs> { }).lib
|
|
||||||
, slib ? import ./. { inherit lib; }
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
description = "Test Description";
|
|
||||||
|
|
||||||
evalType = type: default:
|
|
||||||
let
|
|
||||||
evaledConfig = lib.evalModules {
|
|
||||||
modules = [{
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
inherit type;
|
|
||||||
inherit default;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
evaledConfig.options.opt;
|
|
||||||
in
|
|
||||||
|
|
||||||
{
|
|
||||||
testNoDefaultNoDescription =
|
|
||||||
let
|
|
||||||
evaledConfig = lib.evalModules {
|
|
||||||
modules = [{
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
};
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption evaledConfig.options.opt;
|
|
||||||
expected = {
|
|
||||||
type = "boolean";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testBool =
|
|
||||||
let
|
|
||||||
default = false;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType lib.types.bool default);
|
|
||||||
expected = {
|
|
||||||
type = "boolean";
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testString =
|
|
||||||
let
|
|
||||||
default = "hello";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType lib.types.str default);
|
|
||||||
expected = {
|
|
||||||
type = "string";
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testInteger =
|
|
||||||
let
|
|
||||||
default = 42;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType lib.types.int default);
|
|
||||||
expected = {
|
|
||||||
type = "integer";
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testFloat =
|
|
||||||
let
|
|
||||||
default = 42.42;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType lib.types.float default);
|
|
||||||
expected = {
|
|
||||||
type = "number";
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testEnum =
|
|
||||||
let
|
|
||||||
default = "foo";
|
|
||||||
values = [ "foo" "bar" "baz" ];
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.enum values) default);
|
|
||||||
expected = {
|
|
||||||
enum = values;
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testListOfInt =
|
|
||||||
let
|
|
||||||
default = [ 1 2 3 ];
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.listOf lib.types.int) default);
|
|
||||||
expected = {
|
|
||||||
type = "array";
|
|
||||||
items = {
|
|
||||||
type = "integer";
|
|
||||||
};
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testAttrsOfInt =
|
|
||||||
let
|
|
||||||
default = { foo = 1; bar = 2; baz = 3; };
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.attrsOf lib.types.int) default);
|
|
||||||
expected = {
|
|
||||||
type = "object";
|
|
||||||
additionalProperties = {
|
|
||||||
type = "integer";
|
|
||||||
};
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testNullOrBool =
|
|
||||||
let
|
|
||||||
default = null; # null is a valid value for this type
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.nullOr lib.types.bool) default);
|
|
||||||
expected = {
|
|
||||||
type = [ "null" "boolean" ];
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testSubmoduleOption =
|
|
||||||
let
|
|
||||||
subModule = {
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.submodule subModule) { });
|
|
||||||
expected = {
|
|
||||||
type = "object";
|
|
||||||
properties = {
|
|
||||||
opt = {
|
|
||||||
type = "boolean";
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testSubmoduleOptionWithoutDefault =
|
|
||||||
let
|
|
||||||
subModule = {
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.submodule subModule) { });
|
|
||||||
expected = {
|
|
||||||
type = "object";
|
|
||||||
properties = {
|
|
||||||
opt = {
|
|
||||||
type = "boolean";
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
required = [ "opt" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testAttrsOfSubmodule =
|
|
||||||
let
|
|
||||||
subModule = {
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
default = { foo.opt = false; bar.opt = true; };
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.attrsOf (lib.types.submodule subModule)) default);
|
|
||||||
expected = {
|
|
||||||
type = "object";
|
|
||||||
additionalProperties = {
|
|
||||||
type = "object";
|
|
||||||
properties = {
|
|
||||||
opt = {
|
|
||||||
type = "boolean";
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
testListOfSubmodule =
|
|
||||||
let
|
|
||||||
subModule = {
|
|
||||||
options.opt = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
default = [{ opt = false; } { opt = true; }];
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOption (evalType (lib.types.listOf (lib.types.submodule subModule)) default);
|
|
||||||
expected = {
|
|
||||||
type = "array";
|
|
||||||
items = {
|
|
||||||
type = "object";
|
|
||||||
properties = {
|
|
||||||
opt = {
|
|
||||||
type = "boolean";
|
|
||||||
default = true;
|
|
||||||
inherit description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
inherit default description;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# tests for the nixos options to jsonschema converter
|
|
||||||
# run these tests via `nix-unit ./test.nix`
|
|
||||||
{ lib ? (import <nixpkgs> { }).lib
|
|
||||||
, slib ? import ./. { inherit lib; }
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
evaledOptions =
|
|
||||||
let
|
|
||||||
evaledConfig = lib.evalModules {
|
|
||||||
modules = [ ./example-interface.nix ];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
evaledConfig.options;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
testParseOptions = {
|
|
||||||
expr = slib.parseOptions evaledOptions;
|
|
||||||
expected = builtins.fromJSON (builtins.readFile ./example-schema.json);
|
|
||||||
};
|
|
||||||
|
|
||||||
testParseNestedOptions =
|
|
||||||
let
|
|
||||||
evaled = lib.evalModules {
|
|
||||||
modules = [{
|
|
||||||
options.foo.bar = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
};
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
expr = slib.parseOptions evaled.options;
|
|
||||||
expected = {
|
|
||||||
properties = {
|
|
||||||
foo = {
|
|
||||||
properties = {
|
|
||||||
bar = { type = "boolean"; };
|
|
||||||
};
|
|
||||||
required = [ "bar" ];
|
|
||||||
type = "object";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type = "object";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{ lib, ... }: {
|
|
||||||
options.clan.bloatware = lib.mkOption {
|
|
||||||
type = lib.types.submodule {
|
|
||||||
imports = [
|
|
||||||
../../../lib/jsonschema/example-interface.nix
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
{ self, inputs, lib, ... }: {
|
|
||||||
flake.nixosModules.clanCore = { config, pkgs, options, ... }: {
|
|
||||||
imports = [
|
|
||||||
./secrets
|
|
||||||
./zerotier
|
|
||||||
./networking.nix
|
|
||||||
inputs.sops-nix.nixosModules.sops
|
|
||||||
# just some example options. Can be removed later
|
|
||||||
./bloatware
|
|
||||||
./vm.nix
|
|
||||||
./options.nix
|
|
||||||
];
|
|
||||||
options.clanSchema = lib.mkOption {
|
|
||||||
type = lib.types.attrs;
|
|
||||||
description = "The json schema for the .clan options namespace";
|
|
||||||
default = self.lib.jsonschema.parseOptions options.clan;
|
|
||||||
};
|
|
||||||
options.clanCore = {
|
|
||||||
clanDir = lib.mkOption {
|
|
||||||
type = lib.types.either lib.types.path lib.types.str;
|
|
||||||
description = ''
|
|
||||||
the location of the flake repo, used to calculate the location of facts and secrets
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
machineName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
the name of the machine
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
clanPkgs = lib.mkOption {
|
|
||||||
default = self.packages.${pkgs.system};
|
|
||||||
defaultText = "self.packages.${pkgs.system}";
|
|
||||||
internal = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
options.system.clan = lib.mkOption {
|
|
||||||
type = lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
deployment.data = lib.mkOption {
|
|
||||||
type = lib.types.attrs;
|
|
||||||
description = ''
|
|
||||||
the data to be written to the deployment.json file
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
deployment.file = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
the location of the deployment.json file
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
deploymentAddress = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
the address of the deployment server
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
secretsUploadDirectory = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
the directory on the deployment server where secrets are uploaded
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
uploadSecrets = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
script to upload secrets to the deployment server
|
|
||||||
'';
|
|
||||||
default = "${pkgs.coreutils}/bin/true";
|
|
||||||
};
|
|
||||||
generateSecrets = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
script to generate secrets
|
|
||||||
'';
|
|
||||||
default = "${pkgs.coreutils}/bin/true";
|
|
||||||
};
|
|
||||||
vm.config = lib.mkOption {
|
|
||||||
type = lib.types.attrs;
|
|
||||||
description = ''
|
|
||||||
the vm config
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
vm.create = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
json metadata about the vm
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
utility outputs for clan management of this machine
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
# optimization for faster secret generate/upload and machines update
|
|
||||||
config = {
|
|
||||||
system.clan.deployment.data = {
|
|
||||||
inherit (config.system.clan) uploadSecrets generateSecrets;
|
|
||||||
inherit (config.clan.networking) deploymentAddress;
|
|
||||||
inherit (config.clanCore) secretsUploadDirectory;
|
|
||||||
};
|
|
||||||
system.clan.deployment.file = pkgs.writeText "deployment.json" (builtins.toJSON config.system.clan.deployment.data);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
{
|
|
||||||
options.clan.networking = {
|
|
||||||
deploymentAddress = lib.mkOption {
|
|
||||||
description = ''
|
|
||||||
The target SSH node for deployment.
|
|
||||||
|
|
||||||
By default, the node's attribute name will be used.
|
|
||||||
If set to null, only local deployment will be supported.
|
|
||||||
|
|
||||||
format: user@host:port&SSH_OPTION=SSH_VALUE
|
|
||||||
examples:
|
|
||||||
- machine.example.com
|
|
||||||
- user@machine2.example.com
|
|
||||||
- root@example.com:2222&IdentityFile=/path/to/private/key
|
|
||||||
'';
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = "root@${config.networking.hostName}";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ pkgs, options, lib, ... }: {
|
|
||||||
options.clanCore.optionsNix = lib.mkOption {
|
|
||||||
type = lib.types.raw;
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
default = (pkgs.nixosOptionsDoc { inherit options; }).optionsNix;
|
|
||||||
defaultText = "optionsNix";
|
|
||||||
description = ''
|
|
||||||
This is to export nixos options used for `clan config`
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
{
|
|
||||||
options.clanCore.secretStore = lib.mkOption {
|
|
||||||
type = lib.types.enum [ "sops" "password-store" "custom" ];
|
|
||||||
default = "sops";
|
|
||||||
description = ''
|
|
||||||
method to store secrets
|
|
||||||
custom can be used to define a custom secret store.
|
|
||||||
one would have to define system.clan.generateSecrets and system.clan.uploadSecrets
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
options.clanCore.secretsDirectory = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
The directory where secrets are installed to. This is backend specific.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
options.clanCore.secretsUploadDirectory = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
description = ''
|
|
||||||
The directory where secrets are uploaded into, This is backend specific.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
options.clanCore.secretsPrefix = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "";
|
|
||||||
description = ''
|
|
||||||
Prefix for secrets. This is backend specific.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
options.clanCore.secrets = lib.mkOption {
|
|
||||||
default = { };
|
|
||||||
type = lib.types.attrsOf
|
|
||||||
(lib.types.submodule (secret: {
|
|
||||||
options = {
|
|
||||||
name = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = secret.config._module.args.name;
|
|
||||||
description = ''
|
|
||||||
Namespace of the secret
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
generator = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
Script to generate the secret.
|
|
||||||
The script will be called with the following variables:
|
|
||||||
- facts: path to a directory where facts can be stored
|
|
||||||
- secrets: path to a directory where secrets can be stored
|
|
||||||
The script is expected to generate all secrets and facts defined in the module.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
secrets =
|
|
||||||
let
|
|
||||||
config' = config;
|
|
||||||
in
|
|
||||||
lib.mkOption {
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: {
|
|
||||||
options = {
|
|
||||||
name = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
name of the secret
|
|
||||||
'';
|
|
||||||
default = config._module.args.name;
|
|
||||||
};
|
|
||||||
path = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
path to a secret which is generated by the generator
|
|
||||||
'';
|
|
||||||
default = "${config'.clanCore.secretsDirectory}/${config'.clanCore.secretsPrefix}${config.name}";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
description = ''
|
|
||||||
path where the secret is located in the filesystem
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
facts = lib.mkOption {
|
|
||||||
default = { };
|
|
||||||
type = lib.types.attrsOf (lib.types.submodule (fact: {
|
|
||||||
options = {
|
|
||||||
name = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
name of the fact
|
|
||||||
'';
|
|
||||||
default = fact.config._module.args.name;
|
|
||||||
};
|
|
||||||
path = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = ''
|
|
||||||
path to a fact which is generated by the generator
|
|
||||||
'';
|
|
||||||
default = "machines/${config.clanCore.machineName}/facts/${fact.config._module.args.name}";
|
|
||||||
};
|
|
||||||
value = lib.mkOption {
|
|
||||||
defaultText = lib.literalExpression "\${config.clanCore.clanDir}/\${fact.config.path}";
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default =
|
|
||||||
if builtins.pathExists "${config.clanCore.clanDir}/${fact.config.path}" then
|
|
||||||
builtins.readFile "${config.clanCore.clanDir}/${fact.config.path}"
|
|
||||||
else
|
|
||||||
null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
imports = [
|
|
||||||
./sops.nix
|
|
||||||
./password-store.nix
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
let
|
|
||||||
passwordstoreDir = "\${PASSWORD_STORE_DIR:-$HOME/.password-store}";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.clan.password-store.targetDirectory = lib.mkOption {
|
|
||||||
type = lib.types.path;
|
|
||||||
default = "/etc/secrets";
|
|
||||||
description = ''
|
|
||||||
The directory where the password store is uploaded to.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
config = lib.mkIf (config.clanCore.secretStore == "password-store") {
|
|
||||||
clanCore.secretsDirectory = config.clan.password-store.targetDirectory;
|
|
||||||
clanCore.secretsUploadDirectory = config.clan.password-store.targetDirectory;
|
|
||||||
system.clan.generateSecrets = lib.mkIf (config.clanCore.secrets != { }) (
|
|
||||||
pkgs.writeScript "generate-secrets" ''
|
|
||||||
#!/bin/sh
|
|
||||||
set -efu
|
|
||||||
|
|
||||||
test -d "$CLAN_DIR"
|
|
||||||
PATH=${lib.makeBinPath [
|
|
||||||
pkgs.pass
|
|
||||||
]}:$PATH
|
|
||||||
|
|
||||||
# TODO maybe initialize password store if it doesn't exist yet
|
|
||||||
|
|
||||||
${lib.foldlAttrs (acc: n: v: ''
|
|
||||||
${acc}
|
|
||||||
# ${n}
|
|
||||||
# if any of the secrets are missing, we regenerate all connected facts/secrets
|
|
||||||
(if ! (${lib.concatMapStringsSep " && " (x: "test -e ${passwordstoreDir}/machines/${config.clanCore.machineName}/${x.name}.gpg >/dev/null") (lib.attrValues v.secrets)}); then
|
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
|
||||||
trap "rm -rf $tmpdir" EXIT
|
|
||||||
cd $tmpdir
|
|
||||||
|
|
||||||
facts=$(mktemp -d)
|
|
||||||
trap "rm -rf $facts" EXIT
|
|
||||||
secrets=$(mktemp -d)
|
|
||||||
trap "rm -rf $secrets" EXIT
|
|
||||||
( ${v.generator} )
|
|
||||||
|
|
||||||
${lib.concatMapStrings (fact: ''
|
|
||||||
mkdir -p "$CLAN_DIR"/"$(dirname ${fact.path})"
|
|
||||||
cp "$facts"/${fact.name} "$CLAN_DIR"/${fact.path}
|
|
||||||
'') (lib.attrValues v.facts)}
|
|
||||||
|
|
||||||
${lib.concatMapStrings (secret: ''
|
|
||||||
cat "$secrets"/${secret.name} | pass insert -m machines/${config.clanCore.machineName}/${secret.name}
|
|
||||||
'') (lib.attrValues v.secrets)}
|
|
||||||
fi)
|
|
||||||
'') "" config.clanCore.secrets}
|
|
||||||
''
|
|
||||||
);
|
|
||||||
system.clan.uploadSecrets = pkgs.writeScript "upload-secrets" ''
|
|
||||||
#!/bin/sh
|
|
||||||
set -efu
|
|
||||||
|
|
||||||
umask 0077
|
|
||||||
|
|
||||||
PATH=${lib.makeBinPath [
|
|
||||||
pkgs.pass
|
|
||||||
pkgs.git
|
|
||||||
pkgs.findutils
|
|
||||||
pkgs.rsync
|
|
||||||
]}:$PATH:${lib.getBin pkgs.openssh}
|
|
||||||
|
|
||||||
if test -e ${passwordstoreDir}/.git; then
|
|
||||||
local_pass_info=$(
|
|
||||||
git -C ${passwordstoreDir} log -1 --format=%H machines/${config.clanCore.machineName}
|
|
||||||
# we append a hash for every symlink, otherwise we would miss updates on
|
|
||||||
# files where the symlink points to
|
|
||||||
find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type l \
|
|
||||||
-exec realpath {} + |
|
|
||||||
sort |
|
|
||||||
xargs -r -n 1 git -C ${passwordstoreDir} log -1 --format=%H
|
|
||||||
)
|
|
||||||
remote_pass_info=$(ssh ${config.clan.networking.deploymentAddress} -- ${lib.escapeShellArg ''
|
|
||||||
cat ${config.clan.password-store.targetDirectory}/.pass_info || :
|
|
||||||
''} || :)
|
|
||||||
|
|
||||||
if test "$local_pass_info" = "$remote_pass_info"; then
|
|
||||||
echo secrets already match
|
|
||||||
exit 23
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type f -follow ! -name .gpg-id |
|
|
||||||
while read -r gpg_path; do
|
|
||||||
|
|
||||||
rel_name=''${gpg_path#${passwordstoreDir}}
|
|
||||||
rel_name=''${rel_name%.gpg}
|
|
||||||
|
|
||||||
pass_date=$(
|
|
||||||
if test -e ${passwordstoreDir}/.git; then
|
|
||||||
git -C ${passwordstoreDir} log -1 --format=%aI "$gpg_path"
|
|
||||||
fi
|
|
||||||
)
|
|
||||||
pass_name=$rel_name
|
|
||||||
tmp_path="$SECRETS_DIR"/$(basename $rel_name)
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$tmp_path")"
|
|
||||||
pass show "$pass_name" > "$tmp_path"
|
|
||||||
if [ -n "$pass_date" ]; then
|
|
||||||
touch -d "$pass_date" "$tmp_path"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if test -n "''${local_pass_info-}"; then
|
|
||||||
echo "$local_pass_info" > "$SECRETS_DIR"/.pass_info
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
let
|
|
||||||
secretsDir = config.clanCore.clanDir + "/sops/secrets";
|
|
||||||
groupsDir = config.clanCore.clanDir + "/sops/groups";
|
|
||||||
|
|
||||||
|
|
||||||
# My symlink is in the nixos module detected as a directory also it works in the repl. Is this because of pure evaluation?
|
|
||||||
containsSymlink = path:
|
|
||||||
builtins.pathExists path && (builtins.readFileType path == "directory" || builtins.readFileType path == "symlink");
|
|
||||||
|
|
||||||
containsMachine = parent: name: type:
|
|
||||||
type == "directory" && containsSymlink "${parent}/${name}/machines/${config.clanCore.machineName}";
|
|
||||||
|
|
||||||
containsMachineOrGroups = name: type:
|
|
||||||
(containsMachine secretsDir name type) || lib.any (group: type == "directory" && containsSymlink "${secretsDir}/${name}/groups/${group}") groups;
|
|
||||||
|
|
||||||
filterDir = filter: dir:
|
|
||||||
lib.optionalAttrs (builtins.pathExists dir)
|
|
||||||
(lib.filterAttrs filter (builtins.readDir dir));
|
|
||||||
|
|
||||||
groups = builtins.attrNames (filterDir (containsMachine groupsDir) groupsDir);
|
|
||||||
secrets = filterDir containsMachineOrGroups secretsDir;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
config = lib.mkIf (config.clanCore.secretStore == "sops") {
|
|
||||||
clanCore.secretsDirectory = "/run/secrets";
|
|
||||||
clanCore.secretsPrefix = config.clanCore.machineName + "-";
|
|
||||||
system.clan = lib.mkIf (config.clanCore.secrets != { }) {
|
|
||||||
|
|
||||||
generateSecrets = pkgs.writeScript "generate-secrets" ''
|
|
||||||
#!${pkgs.python3}/bin/python
|
|
||||||
import json
|
|
||||||
from clan_cli.secrets.sops_generate import generate_secrets_from_nix
|
|
||||||
args = json.loads(${builtins.toJSON (builtins.toJSON { machine_name = config.clanCore.machineName; secret_submodules = config.clanCore.secrets; })})
|
|
||||||
generate_secrets_from_nix(**args)
|
|
||||||
'';
|
|
||||||
uploadSecrets = pkgs.writeScript "upload-secrets" ''
|
|
||||||
#!${pkgs.python3}/bin/python
|
|
||||||
import json
|
|
||||||
from clan_cli.secrets.sops_generate import upload_age_key_from_nix
|
|
||||||
# the second toJSON is needed to escape the string for the python
|
|
||||||
args = json.loads(${builtins.toJSON (builtins.toJSON { machine_name = config.clanCore.machineName; })})
|
|
||||||
upload_age_key_from_nix(**args)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
sops.secrets = builtins.mapAttrs
|
|
||||||
(name: _: {
|
|
||||||
sopsFile = config.clanCore.clanDir + "/sops/secrets/${name}/secret";
|
|
||||||
format = "binary";
|
|
||||||
})
|
|
||||||
secrets;
|
|
||||||
# To get proper error messages about missing secrets we need a dummy secret file that is always present
|
|
||||||
sops.defaultSopsFile = lib.mkIf config.sops.validateSopsFiles (lib.mkDefault (builtins.toString (pkgs.writeText "dummy.yaml" "")));
|
|
||||||
|
|
||||||
sops.age.keyFile = lib.mkIf (builtins.pathExists (config.clanCore.clanDir + "/sops/secrets/${config.clanCore.machineName}-age.key/secret"))
|
|
||||||
(lib.mkDefault "/var/lib/sops-nix/key.txt");
|
|
||||||
clanCore.secretsUploadDirectory = lib.mkDefault "/var/lib/sops-nix";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
{ lib, config, pkgs, options, extendModules, modulesPath, ... }:
|
|
||||||
let
|
|
||||||
vmConfig = extendModules {
|
|
||||||
modules = [
|
|
||||||
(modulesPath + "/virtualisation/qemu-vm.nix")
|
|
||||||
{
|
|
||||||
virtualisation.fileSystems.${config.clanCore.secretsUploadDirectory} = lib.mkForce {
|
|
||||||
device = "secrets";
|
|
||||||
fsType = "9p";
|
|
||||||
neededForBoot = true;
|
|
||||||
options = [ "trans=virtio" "version=9p2000.L" "cache=loose" ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
clan.virtualisation = {
|
|
||||||
cores = lib.mkOption {
|
|
||||||
type = lib.types.ints.positive;
|
|
||||||
default = 1;
|
|
||||||
description = lib.mdDoc ''
|
|
||||||
Specify the number of cores the guest is permitted to use.
|
|
||||||
The number can be higher than the available cores on the
|
|
||||||
host system.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
memorySize = lib.mkOption {
|
|
||||||
type = lib.types.ints.positive;
|
|
||||||
default = 1024;
|
|
||||||
description = lib.mdDoc ''
|
|
||||||
The memory size in megabytes of the virtual machine.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
graphics = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = lib.mdDoc ''
|
|
||||||
Whether to run QEMU with a graphics window, or in nographic mode.
|
|
||||||
Serial console will be enabled on both settings, but this will
|
|
||||||
change the preferred console.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = {
|
|
||||||
system.clan.vm = {
|
|
||||||
# for clan vm inspect
|
|
||||||
config = {
|
|
||||||
inherit (config.clan.virtualisation) cores graphics;
|
|
||||||
memory_size = config.clan.virtualisation.memorySize;
|
|
||||||
};
|
|
||||||
# for clan vm create
|
|
||||||
create = pkgs.writeText "vm.json" (builtins.toJSON {
|
|
||||||
initrd = "${vmConfig.config.system.build.initialRamdisk}/${vmConfig.config.system.boot.loader.initrdFile}";
|
|
||||||
toplevel = vmConfig.config.system.build.toplevel;
|
|
||||||
regInfo = (pkgs.closureInfo { rootPaths = vmConfig.config.virtualisation.additionalPaths; });
|
|
||||||
inherit (config.clan.virtualisation) memorySize cores graphics;
|
|
||||||
generateSecrets = config.system.clan.generateSecrets;
|
|
||||||
uploadSecrets = config.system.clan.uploadSecrets;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
virtualisation = lib.optionalAttrs (options.virtualisation ? cores) {
|
|
||||||
memorySize = lib.mkDefault config.clan.virtualisation.memorySize;
|
|
||||||
graphics = lib.mkDefault config.clan.virtualisation.graphics;
|
|
||||||
cores = lib.mkDefault config.clan.virtualisation.cores;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
let
|
|
||||||
cfg = config.clan.networking.zerotier;
|
|
||||||
facts = config.clanCore.secrets.zerotier.facts;
|
|
||||||
networkConfig = {
|
|
||||||
authTokens = [
|
|
||||||
null
|
|
||||||
];
|
|
||||||
authorizationEndpoint = "";
|
|
||||||
capabilities = [ ];
|
|
||||||
clientId = "";
|
|
||||||
dns = [ ];
|
|
||||||
enableBroadcast = true;
|
|
||||||
id = cfg.networkId;
|
|
||||||
ipAssignmentPools = [ ];
|
|
||||||
mtu = 2800;
|
|
||||||
multicastLimit = 32;
|
|
||||||
name = "";
|
|
||||||
uwid = cfg.networkId;
|
|
||||||
objtype = "network";
|
|
||||||
private = !cfg.controller.public;
|
|
||||||
remoteTraceLevel = 0;
|
|
||||||
remoteTraceTarget = null;
|
|
||||||
revision = 1;
|
|
||||||
routes = [ ];
|
|
||||||
rules = [
|
|
||||||
{
|
|
||||||
not = false;
|
|
||||||
or = false;
|
|
||||||
type = "ACTION_ACCEPT";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
rulesSource = "";
|
|
||||||
ssoEnabled = false;
|
|
||||||
tags = [ ];
|
|
||||||
v4AssignMode = {
|
|
||||||
zt = false;
|
|
||||||
};
|
|
||||||
v6AssignMode = {
|
|
||||||
"6plane" = false;
|
|
||||||
rfc4193 = true;
|
|
||||||
zt = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.clan.networking.zerotier = {
|
|
||||||
networkId = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
zerotier networking id
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
controller = {
|
|
||||||
enable = lib.mkEnableOption "turn this machine into the networkcontroller";
|
|
||||||
public = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
everyone can join a public network without having the administrator to accept
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
config = lib.mkMerge [
|
|
||||||
({
|
|
||||||
# Override license so that we can build zerotierone without
|
|
||||||
# having to re-import nixpkgs.
|
|
||||||
services.zerotierone.package = lib.mkDefault (pkgs.zerotierone.overrideAttrs (_old: { meta = { }; }));
|
|
||||||
})
|
|
||||||
(lib.mkIf (cfg.networkId != null) {
|
|
||||||
systemd.network.networks.zerotier = {
|
|
||||||
matchConfig.Name = "zt*";
|
|
||||||
networkConfig = {
|
|
||||||
LLMNR = true;
|
|
||||||
LLDP = true;
|
|
||||||
MulticastDNS = true;
|
|
||||||
KeepConfiguration = "static";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
networking.firewall.interfaces."zt+".allowedTCPPorts = [ 5353 ]; # mdns
|
|
||||||
networking.firewall.interfaces."zt+".allowedUDPPorts = [ 5353 ]; # mdns
|
|
||||||
networking.networkmanager.unmanaged = [ "interface-name:zt*" ];
|
|
||||||
|
|
||||||
services.zerotierone = {
|
|
||||||
enable = true;
|
|
||||||
joinNetworks = [ cfg.networkId ];
|
|
||||||
};
|
|
||||||
})
|
|
||||||
(lib.mkIf cfg.controller.enable {
|
|
||||||
# only the controller needs to have the key in the repo, the other clients can be dynamic
|
|
||||||
# we generate the zerotier code manually for the controller, since it's part of the bootstrap command
|
|
||||||
clanCore.secrets.zerotier = {
|
|
||||||
facts.zerotier-network-id = { };
|
|
||||||
secrets.zerotier-identity-secret = { };
|
|
||||||
generator = ''
|
|
||||||
export PATH=${lib.makeBinPath [ config.services.zerotierone.package pkgs.fakeroot ]}
|
|
||||||
${pkgs.python3.interpreter} ${./generate-network.py} "$facts/zerotier-network-id" "$secrets/zerotier-identity-secret"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
environment.systemPackages = [ config.clanCore.clanPkgs.zerotier-members ];
|
|
||||||
})
|
|
||||||
(lib.mkIf ((config.clanCore.secrets ? zerotier) && (facts.zerotier-network-id.value != null)) {
|
|
||||||
clan.networking.zerotier.networkId = facts.zerotier-network-id.value;
|
|
||||||
environment.etc."zerotier/network-id".text = facts.zerotier-network-id.value;
|
|
||||||
|
|
||||||
systemd.services.zerotierone.serviceConfig.ExecStartPre = [
|
|
||||||
"+${pkgs.writeShellScript "init-zerotier" ''
|
|
||||||
cp ${config.clanCore.secrets.zerotier.secrets.zerotier-identity-secret.path} /var/lib/zerotier-one/identity.secret
|
|
||||||
mkdir -p /var/lib/zerotier-one/controller.d/network
|
|
||||||
ln -sfT ${pkgs.writeText "net.json" (builtins.toJSON networkConfig)} /var/lib/zerotier-one/controller.d/network/${cfg.networkId}.json
|
|
||||||
''}"
|
|
||||||
];
|
|
||||||
systemd.services.zerotierone.serviceConfig.ExecStartPost = [
|
|
||||||
"+${pkgs.writeShellScript "whitelist-controller" ''
|
|
||||||
${config.clanCore.clanPkgs.zerotier-members}/bin/zerotier-members allow ${builtins.substring 0 10 cfg.networkId}
|
|
||||||
''}"
|
|
||||||
];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
from typing import Any, Iterator, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class ClanError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def try_bind_port(port: int) -> bool:
|
|
||||||
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
with tcp, udp:
|
|
||||||
try:
|
|
||||||
tcp.bind(("127.0.0.1", port))
|
|
||||||
udp.bind(("127.0.0.1", port))
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def try_connect_port(port: int) -> bool:
|
|
||||||
sock = socket.socket(socket.AF_INET)
|
|
||||||
result = sock.connect_ex(("127.0.0.1", port))
|
|
||||||
sock.close()
|
|
||||||
return result == 0
|
|
||||||
|
|
||||||
|
|
||||||
def find_free_port() -> Optional[int]:
|
|
||||||
"""Find an unused localhost port from 1024-65535 and return it."""
|
|
||||||
with contextlib.closing(socket.socket(type=socket.SOCK_STREAM)) as sock:
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
return sock.getsockname()[1]
|
|
||||||
|
|
||||||
|
|
||||||
class ZerotierController:
|
|
||||||
def __init__(self, port: int, home: Path) -> None:
|
|
||||||
self.port = port
|
|
||||||
self.home = home
|
|
||||||
self.authtoken = (home / "authtoken.secret").read_text()
|
|
||||||
self.secret = (home / "identity.secret").read_text()
|
|
||||||
|
|
||||||
def _http_request(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
method: str = "GET",
|
|
||||||
headers: dict[str, str] = {},
|
|
||||||
data: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
body = None
|
|
||||||
headers = headers.copy()
|
|
||||||
if data is not None:
|
|
||||||
body = json.dumps(data).encode("ascii")
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
headers["X-ZT1-AUTH"] = self.authtoken
|
|
||||||
url = f"http://127.0.0.1:{self.port}{path}"
|
|
||||||
req = urllib.request.Request(url, headers=headers, method=method, data=body)
|
|
||||||
resp = urllib.request.urlopen(req)
|
|
||||||
return json.load(resp)
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
return self._http_request("/status")
|
|
||||||
|
|
||||||
def create_network(self, data: dict[str, Any] = {}) -> dict[str, Any]:
|
|
||||||
identity = (self.home / "identity.public").read_text()
|
|
||||||
node_id = identity.split(":")[0]
|
|
||||||
return self._http_request(
|
|
||||||
f"/controller/network/{node_id}______", method="POST", data=data
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_network(self, id: str) -> dict[str, Any]:
|
|
||||||
return self._http_request(f"/controller/network/{id}")
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def zerotier_controller() -> Iterator[ZerotierController]:
|
|
||||||
# This check could be racy but it's unlikely in practice
|
|
||||||
controller_port = find_free_port()
|
|
||||||
if controller_port is None:
|
|
||||||
raise ClanError("cannot find a free port for zerotier controller")
|
|
||||||
|
|
||||||
with TemporaryDirectory() as d:
|
|
||||||
tempdir = Path(d)
|
|
||||||
home = tempdir / "zerotier-one"
|
|
||||||
home.mkdir()
|
|
||||||
cmd = [
|
|
||||||
"fakeroot",
|
|
||||||
"--",
|
|
||||||
"zerotier-one",
|
|
||||||
f"-p{controller_port}",
|
|
||||||
str(home),
|
|
||||||
]
|
|
||||||
with subprocess.Popen(cmd) as p:
|
|
||||||
try:
|
|
||||||
print(
|
|
||||||
f"wait for controller to be started on 127.0.0.1:{controller_port}...",
|
|
||||||
)
|
|
||||||
while not try_connect_port(controller_port):
|
|
||||||
status = p.poll()
|
|
||||||
if status is not None:
|
|
||||||
raise ClanError(
|
|
||||||
f"zerotier-one has been terminated unexpected with {status}"
|
|
||||||
)
|
|
||||||
time.sleep(0.1)
|
|
||||||
print()
|
|
||||||
|
|
||||||
yield ZerotierController(controller_port, home)
|
|
||||||
finally:
|
|
||||||
p.terminate()
|
|
||||||
p.wait()
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: allow merging more network configuration here
|
|
||||||
def create_network() -> dict:
|
|
||||||
with zerotier_controller() as controller:
|
|
||||||
network = controller.create_network()
|
|
||||||
return {
|
|
||||||
"secret": controller.secret,
|
|
||||||
"networkid": network["nwid"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("network_id")
|
|
||||||
parser.add_argument("identity_secret")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
zerotier = create_network()
|
|
||||||
Path(args.network_id).write_text(zerotier["networkid"])
|
|
||||||
Path(args.identity_secret).write_text(zerotier["secret"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{ ... }: {
|
|
||||||
flake.nixosModules = {
|
|
||||||
hidden-ssh-announce.imports = [ ./hidden-ssh-announce.nix ];
|
|
||||||
installer.imports = [ ./installer ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{ config
|
|
||||||
, lib
|
|
||||||
, pkgs
|
|
||||||
, ...
|
|
||||||
}: {
|
|
||||||
options.hidden-ssh-announce = {
|
|
||||||
enable = lib.mkEnableOption "hidden-ssh-announce";
|
|
||||||
script = lib.mkOption {
|
|
||||||
type = lib.types.package;
|
|
||||||
default = pkgs.writers.writeDash "test-output" "echo $1";
|
|
||||||
description = ''
|
|
||||||
script to run when the hidden tor service was started and they hostname is known.
|
|
||||||
takes the hostname as $1
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf config.hidden-ssh-announce.enable {
|
|
||||||
services.openssh.enable = true;
|
|
||||||
services.tor = {
|
|
||||||
enable = true;
|
|
||||||
relay.onionServices.hidden-ssh = {
|
|
||||||
version = 3;
|
|
||||||
map = [
|
|
||||||
{
|
|
||||||
port = 22;
|
|
||||||
target.port = 22;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
client.enable = true;
|
|
||||||
};
|
|
||||||
systemd.services.hidden-ssh-announce = {
|
|
||||||
description = "announce hidden ssh";
|
|
||||||
after = [ "tor.service" "network-online.target" ];
|
|
||||||
wants = [ "tor.service" ];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
# ${pkgs.tor}/bin/torify
|
|
||||||
ExecStart = pkgs.writers.writeDash "announce-hidden-service" ''
|
|
||||||
set -efu
|
|
||||||
until test -e ${config.services.tor.settings.DataDirectory}/onion/hidden-ssh/hostname; do
|
|
||||||
echo "still waiting for ${config.services.tor.settings.DataDirectory}/onion/hidden-ssh/hostname"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
${config.hidden-ssh-announce.script} "$(cat ${config.services.tor.settings.DataDirectory}/onion/hidden-ssh/hostname)"
|
|
||||||
'';
|
|
||||||
PrivateTmp = "true";
|
|
||||||
User = "tor";
|
|
||||||
Type = "oneshot";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
{ lib
|
|
||||||
, pkgs
|
|
||||||
, modulesPath
|
|
||||||
, ...
|
|
||||||
}: {
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d /var/shared 0777 root root - -"
|
|
||||||
];
|
|
||||||
imports = [
|
|
||||||
(modulesPath + "/profiles/installation-device.nix")
|
|
||||||
(modulesPath + "/profiles/all-hardware.nix")
|
|
||||||
(modulesPath + "/profiles/base.nix")
|
|
||||||
];
|
|
||||||
services.openssh.settings.PermitRootLogin = "yes";
|
|
||||||
system.activationScripts.root-password = ''
|
|
||||||
mkdir -p /var/shared
|
|
||||||
${pkgs.pwgen}/bin/pwgen -s 16 1 > /var/shared/root-password
|
|
||||||
echo "root:$(cat /var/shared/root-password)" | chpasswd
|
|
||||||
'';
|
|
||||||
hidden-ssh-announce = {
|
|
||||||
enable = true;
|
|
||||||
script = pkgs.writers.writeDash "write-hostname" ''
|
|
||||||
set -efu
|
|
||||||
mkdir -p /var/shared
|
|
||||||
echo "$1" > /var/shared/onion-hostname
|
|
||||||
${pkgs.jq}/bin/jq -nc \
|
|
||||||
--arg password "$(cat /var/shared/root-password)" \
|
|
||||||
--arg address "$(cat /var/shared/onion-hostname)" '{
|
|
||||||
password: $password, address: $address
|
|
||||||
}' > /var/shared/login.info
|
|
||||||
cat /var/shared/login.info |
|
|
||||||
${pkgs.qrencode}/bin/qrencode -t utf8 -o /var/shared/qrcode.utf8
|
|
||||||
cat /var/shared/login.info |
|
|
||||||
${pkgs.qrencode}/bin/qrencode -t png -o /var/shared/qrcode.png
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
services.getty.autologinUser = lib.mkForce "root";
|
|
||||||
programs.bash.interactiveShellInit = ''
|
|
||||||
if [ "$(tty)" = "/dev/tty1" ]; then
|
|
||||||
echo 'waiting for tor to generate the hidden service'
|
|
||||||
until test -e /var/shared/qrcode.utf8; do echo .; sleep 1; done
|
|
||||||
cat /var/shared/qrcode.utf8
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
boot.loader.grub.efiInstallAsRemovable = true;
|
|
||||||
boot.loader.grub.efiSupport = true;
|
|
||||||
disko.devices = {
|
|
||||||
disk = {
|
|
||||||
stick = {
|
|
||||||
type = "disk";
|
|
||||||
device = "/vda";
|
|
||||||
imageSize = "3G";
|
|
||||||
content = {
|
|
||||||
type = "gpt";
|
|
||||||
partitions = {
|
|
||||||
boot = {
|
|
||||||
size = "1M";
|
|
||||||
type = "EF02"; # for grub MBR
|
|
||||||
};
|
|
||||||
ESP = {
|
|
||||||
size = "100M";
|
|
||||||
type = "EF00";
|
|
||||||
content = {
|
|
||||||
type = "filesystem";
|
|
||||||
format = "vfat";
|
|
||||||
mountpoint = "/boot";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
root = {
|
|
||||||
size = "100%";
|
|
||||||
content = {
|
|
||||||
type = "filesystem";
|
|
||||||
format = "ext4";
|
|
||||||
mountpoint = "/";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
7
pkgs/clan-cli/.vscode/settings.json
vendored
7
pkgs/clan-cli/.vscode/settings.json
vendored
@@ -12,4 +12,11 @@
|
|||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
|
"search.exclude": {
|
||||||
|
"**/.direnv": true
|
||||||
|
},
|
||||||
|
"python.linting.mypyPath": "mypy",
|
||||||
|
"python.linting.mypyEnabled": true,
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.defaultInterpreterPath": "python"
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import sys
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import config, create, join, machines, secrets, vms, webui
|
from . import config, flakes, join, machines, secrets, vms, webui
|
||||||
from .ssh import cli as ssh_cli
|
from .ssh import cli as ssh_cli
|
||||||
|
|
||||||
argcomplete: Optional[ModuleType] = None
|
argcomplete: Optional[ModuleType] = None
|
||||||
@@ -24,10 +24,10 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
subparsers = parser.add_subparsers()
|
subparsers = parser.add_subparsers()
|
||||||
|
|
||||||
parser_create = subparsers.add_parser(
|
parser_flake = subparsers.add_parser(
|
||||||
"create", help="create a clan flake inside the current directory"
|
"flakes", help="create a clan flake inside the current directory"
|
||||||
)
|
)
|
||||||
create.register_parser(parser_create)
|
flakes.register_parser(parser_flake)
|
||||||
|
|
||||||
parser_join = subparsers.add_parser("join", help="join a remote clan")
|
parser_join = subparsers.add_parser("join", help="join a remote clan")
|
||||||
join.register_parser(parser_join)
|
join.register_parser(parser_join)
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Coroutine, Dict, NamedTuple, Optional
|
||||||
|
|
||||||
from .errors import ClanError
|
from .errors import ClanError
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def run(cmd: list[str]) -> bytes:
|
class CmdOut(NamedTuple):
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
cwd: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def run(cmd: list[str], cwd: Optional[Path] = None) -> CmdOut:
|
||||||
log.debug(f"$: {shlex.join(cmd)}")
|
log.debug(f"$: {shlex.join(cmd)}")
|
||||||
|
cwd_res = None
|
||||||
|
if cwd is not None:
|
||||||
|
if not cwd.exists():
|
||||||
|
raise ClanError(f"Working directory {cwd} does not exist")
|
||||||
|
if not cwd.is_dir():
|
||||||
|
raise ClanError(f"Working directory {cwd} is not a directory")
|
||||||
|
cwd_res = cwd.resolve()
|
||||||
|
log.debug(f"Working directory: {cwd_res}")
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd_res,
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate()
|
stdout, stderr = await proc.communicate()
|
||||||
|
|
||||||
@@ -20,9 +37,30 @@ async def run(cmd: list[str]) -> bytes:
|
|||||||
raise ClanError(
|
raise ClanError(
|
||||||
f"""
|
f"""
|
||||||
command: {shlex.join(cmd)}
|
command: {shlex.join(cmd)}
|
||||||
|
working directory: {cwd_res}
|
||||||
exit code: {proc.returncode}
|
exit code: {proc.returncode}
|
||||||
command output:
|
stderr:
|
||||||
{stderr.decode("utf-8")}
|
{stderr.decode("utf-8")}
|
||||||
|
stdout:
|
||||||
|
{stdout.decode("utf-8")}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return stdout
|
|
||||||
|
return CmdOut(stdout.decode("utf-8"), stderr.decode("utf-8"), cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def runforcli(
|
||||||
|
func: Callable[..., Coroutine[Any, Any, Dict[str, CmdOut]]], *args: Any
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
res = asyncio.run(func(*args))
|
||||||
|
|
||||||
|
for i in res.items():
|
||||||
|
name, out = i
|
||||||
|
if out.stderr:
|
||||||
|
print(f"{name}: {out.stderr}", end="")
|
||||||
|
if out.stdout:
|
||||||
|
print(f"{name}: {out.stdout}", end="")
|
||||||
|
except ClanError as e:
|
||||||
|
print(e)
|
||||||
|
exit(1)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Tuple, get_origin
|
from typing import Any, Optional, Tuple, get_origin
|
||||||
|
|
||||||
from clan_cli.dirs import get_clan_flake_toplevel
|
from clan_cli.dirs import machine_settings_file, specific_flake_dir
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
|
from clan_cli.flakes.types import FlakeName
|
||||||
from clan_cli.git import commit_file
|
from clan_cli.git import commit_file
|
||||||
from clan_cli.machines.folders import machine_settings_file
|
|
||||||
from clan_cli.nix import nix_eval
|
from clan_cli.nix import nix_eval
|
||||||
|
|
||||||
script_dir = Path(__file__).parent
|
script_dir = Path(__file__).parent
|
||||||
@@ -107,8 +107,10 @@ def cast(value: Any, type: Any, opt_description: str) -> Any:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def options_for_machine(machine_name: str, show_trace: bool = False) -> dict:
|
def options_for_machine(
|
||||||
clan_dir = get_clan_flake_toplevel()
|
flake_name: FlakeName, machine_name: str, show_trace: bool = False
|
||||||
|
) -> dict:
|
||||||
|
clan_dir = specific_flake_dir(flake_name)
|
||||||
flags = []
|
flags = []
|
||||||
if show_trace:
|
if show_trace:
|
||||||
flags.append("--show-trace")
|
flags.append("--show-trace")
|
||||||
@@ -129,9 +131,9 @@ def options_for_machine(machine_name: str, show_trace: bool = False) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def read_machine_option_value(
|
def read_machine_option_value(
|
||||||
machine_name: str, option: str, show_trace: bool = False
|
flake_name: FlakeName, machine_name: str, option: str, show_trace: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
clan_dir = get_clan_flake_toplevel()
|
clan_dir = specific_flake_dir(flake_name)
|
||||||
# use nix eval to read from .#nixosConfigurations.default.config.{option}
|
# use nix eval to read from .#nixosConfigurations.default.config.{option}
|
||||||
# this will give us the evaluated config with the options attribute
|
# this will give us the evaluated config with the options attribute
|
||||||
cmd = nix_eval(
|
cmd = nix_eval(
|
||||||
@@ -164,19 +166,19 @@ def get_or_set_option(args: argparse.Namespace) -> None:
|
|||||||
# load options
|
# load options
|
||||||
if args.options_file is None:
|
if args.options_file is None:
|
||||||
options = options_for_machine(
|
options = options_for_machine(
|
||||||
machine_name=args.machine, show_trace=args.show_trace
|
args.flake, machine_name=args.machine, show_trace=args.show_trace
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
with open(args.options_file) as f:
|
with open(args.options_file) as f:
|
||||||
options = json.load(f)
|
options = json.load(f)
|
||||||
# compute settings json file location
|
# compute settings json file location
|
||||||
if args.settings_file is None:
|
if args.settings_file is None:
|
||||||
get_clan_flake_toplevel()
|
settings_file = machine_settings_file(args.flake, args.machine)
|
||||||
settings_file = machine_settings_file(args.machine)
|
|
||||||
else:
|
else:
|
||||||
settings_file = args.settings_file
|
settings_file = args.settings_file
|
||||||
# set the option with the given value
|
# set the option with the given value
|
||||||
set_option(
|
set_option(
|
||||||
|
flake_name=args.flake,
|
||||||
option=args.option,
|
option=args.option,
|
||||||
value=args.value,
|
value=args.value,
|
||||||
options=options,
|
options=options,
|
||||||
@@ -185,7 +187,7 @@ def get_or_set_option(args: argparse.Namespace) -> None:
|
|||||||
show_trace=args.show_trace,
|
show_trace=args.show_trace,
|
||||||
)
|
)
|
||||||
if not args.quiet:
|
if not args.quiet:
|
||||||
new_value = read_machine_option_value(args.machine, args.option)
|
new_value = read_machine_option_value(args.flake, args.machine, args.option)
|
||||||
print(f"New Value for {args.option}:")
|
print(f"New Value for {args.option}:")
|
||||||
print(new_value)
|
print(new_value)
|
||||||
|
|
||||||
@@ -242,6 +244,7 @@ def find_option(
|
|||||||
|
|
||||||
|
|
||||||
def set_option(
|
def set_option(
|
||||||
|
flake_name: FlakeName,
|
||||||
option: str,
|
option: str,
|
||||||
value: Any,
|
value: Any,
|
||||||
options: dict,
|
options: dict,
|
||||||
@@ -290,7 +293,7 @@ def set_option(
|
|||||||
json.dump(new_config, f, indent=2)
|
json.dump(new_config, f, indent=2)
|
||||||
print(file=f) # add newline at the end of the file to make git happy
|
print(file=f) # add newline at the end of the file to make git happy
|
||||||
|
|
||||||
if settings_file.resolve().is_relative_to(get_clan_flake_toplevel()):
|
if settings_file.resolve().is_relative_to(specific_flake_dir(flake_name)):
|
||||||
commit_file(settings_file, commit_message=f"Set option {option_description}")
|
commit_file(settings_file, commit_message=f"Set option {option_description}")
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +308,11 @@ def register_parser(
|
|||||||
|
|
||||||
# inject callback function to process the input later
|
# inject callback function to process the input later
|
||||||
parser.set_defaults(func=get_or_set_option)
|
parser.set_defaults(func=get_or_set_option)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to set machine options for",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--machine",
|
"--machine",
|
||||||
"-m",
|
"-m",
|
||||||
|
|||||||
@@ -2,38 +2,45 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from clan_cli.dirs import get_clan_flake_toplevel, nixpkgs_source
|
from clan_cli.dirs import (
|
||||||
|
machine_settings_file,
|
||||||
|
nixpkgs_source,
|
||||||
|
specific_flake_dir,
|
||||||
|
specific_machine_dir,
|
||||||
|
)
|
||||||
from clan_cli.git import commit_file, find_git_repo_root
|
from clan_cli.git import commit_file, find_git_repo_root
|
||||||
from clan_cli.machines.folders import machine_folder, machine_settings_file
|
|
||||||
from clan_cli.nix import nix_eval
|
from clan_cli.nix import nix_eval
|
||||||
|
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
|
|
||||||
def config_for_machine(machine_name: str) -> dict:
|
|
||||||
|
def config_for_machine(flake_name: FlakeName, machine_name: str) -> dict:
|
||||||
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
|
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
|
||||||
if not machine_folder(machine_name).exists():
|
if not specific_machine_dir(flake_name, machine_name).exists():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"Machine {machine_name} not found. Create the machine first`",
|
detail=f"Machine {machine_name} not found. Create the machine first`",
|
||||||
)
|
)
|
||||||
settings_path = machine_settings_file(machine_name)
|
settings_path = machine_settings_file(flake_name, machine_name)
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
return {}
|
return {}
|
||||||
with open(settings_path) as f:
|
with open(settings_path) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def set_config_for_machine(machine_name: str, config: dict) -> None:
|
def set_config_for_machine(
|
||||||
|
flake_name: FlakeName, machine_name: str, config: dict
|
||||||
|
) -> None:
|
||||||
# write the config to a json file located at {flake}/machines/{machine_name}/settings.json
|
# write the config to a json file located at {flake}/machines/{machine_name}/settings.json
|
||||||
if not machine_folder(machine_name).exists():
|
if not specific_machine_dir(flake_name, machine_name).exists():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"Machine {machine_name} not found. Create the machine first`",
|
detail=f"Machine {machine_name} not found. Create the machine first`",
|
||||||
)
|
)
|
||||||
settings_path = machine_settings_file(machine_name)
|
settings_path = machine_settings_file(flake_name, machine_name)
|
||||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(settings_path, "w") as f:
|
with open(settings_path, "w") as f:
|
||||||
json.dump(config, f)
|
json.dump(config, f)
|
||||||
@@ -43,9 +50,9 @@ def set_config_for_machine(machine_name: str, config: dict) -> None:
|
|||||||
commit_file(settings_path, repo_dir)
|
commit_file(settings_path, repo_dir)
|
||||||
|
|
||||||
|
|
||||||
def schema_for_machine(machine_name: str, flake: Optional[Path] = None) -> dict:
|
def schema_for_machine(flake_name: FlakeName, machine_name: str) -> dict:
|
||||||
if flake is None:
|
flake = specific_flake_dir(flake_name)
|
||||||
flake = get_clan_flake_toplevel()
|
|
||||||
# use nix eval to lib.evalModules .#nixosModules.machine-{machine_name}
|
# use nix eval to lib.evalModules .#nixosModules.machine-{machine_name}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
nix_eval(
|
nix_eval(
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# !/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from .nix import nix_command
|
|
||||||
|
|
||||||
|
|
||||||
def create(args: argparse.Namespace) -> None:
|
|
||||||
# TODO create clan template in flake
|
|
||||||
subprocess.run(
|
|
||||||
nix_command(
|
|
||||||
[
|
|
||||||
"flake",
|
|
||||||
"init",
|
|
||||||
"-t",
|
|
||||||
"git+https://git.clan.lol/clan/clan-core#new-clan",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# takes a (sub)parser and configures it
|
|
||||||
def register_parser(parser: argparse.ArgumentParser) -> None:
|
|
||||||
parser.set_defaults(func=create)
|
|
||||||
@@ -4,9 +4,10 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .errors import ClanError
|
from .errors import ClanError
|
||||||
|
from .flakes.types import FlakeName
|
||||||
|
|
||||||
|
|
||||||
def get_clan_flake_toplevel() -> Path:
|
def _get_clan_flake_toplevel() -> Path:
|
||||||
return find_toplevel([".clan-flake", ".git", ".hg", ".svn", "flake.nix"])
|
return find_toplevel([".clan-flake", ".git", ".hg", ".svn", "flake.nix"])
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,55 @@ def user_config_dir() -> Path:
|
|||||||
return Path(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")))
|
return Path(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")))
|
||||||
|
|
||||||
|
|
||||||
|
def user_data_dir() -> Path:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return Path(os.getenv("APPDATA", os.path.expanduser("~\\AppData\\Roaming\\")))
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
return Path(os.path.expanduser("~/Library/Application Support/"))
|
||||||
|
else:
|
||||||
|
return Path(os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/state")))
|
||||||
|
|
||||||
|
|
||||||
|
def clan_data_dir() -> Path:
|
||||||
|
path = user_data_dir() / "clan"
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir()
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def clan_config_dir() -> Path:
|
||||||
|
path = user_config_dir() / "clan"
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir()
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def clan_flakes_dir() -> Path:
|
||||||
|
path = clan_data_dir() / "flake"
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir()
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def specific_flake_dir(flake_name: FlakeName) -> Path:
|
||||||
|
flake_dir = clan_flakes_dir() / flake_name
|
||||||
|
if not flake_dir.exists():
|
||||||
|
raise ClanError(f"Flake {flake_name} does not exist")
|
||||||
|
return flake_dir
|
||||||
|
|
||||||
|
|
||||||
|
def machines_dir(flake_name: FlakeName) -> Path:
|
||||||
|
return specific_flake_dir(flake_name) / "machines"
|
||||||
|
|
||||||
|
|
||||||
|
def specific_machine_dir(flake_name: FlakeName, machine: str) -> Path:
|
||||||
|
return machines_dir(flake_name) / machine
|
||||||
|
|
||||||
|
|
||||||
|
def machine_settings_file(flake_name: FlakeName, machine: str) -> Path:
|
||||||
|
return specific_machine_dir(flake_name, machine) / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
def module_root() -> Path:
|
def module_root() -> Path:
|
||||||
return Path(__file__).parent
|
return Path(__file__).parent
|
||||||
|
|
||||||
|
|||||||
20
pkgs/clan-cli/clan_cli/flakes/__init__.py
Normal file
20
pkgs/clan-cli/clan_cli/flakes/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# !/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from .create import register_create_parser
|
||||||
|
from .list import register_list_parser
|
||||||
|
|
||||||
|
|
||||||
|
# takes a (sub)parser and configures it
|
||||||
|
def register_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="command",
|
||||||
|
description="the command to run",
|
||||||
|
help="the command to run",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
create_parser = subparser.add_parser("create", help="Create a clan flake")
|
||||||
|
register_create_parser(create_parser)
|
||||||
|
|
||||||
|
list_parser = subparser.add_parser("list", help="List clan flakes")
|
||||||
|
register_list_parser(list_parser)
|
||||||
69
pkgs/clan-cli/clan_cli/flakes/create.py
Normal file
69
pkgs/clan-cli/clan_cli/flakes/create.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# !/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
from pydantic.tools import parse_obj_as
|
||||||
|
|
||||||
|
from ..async_cmd import CmdOut, run, runforcli
|
||||||
|
from ..dirs import clan_flakes_dir
|
||||||
|
from ..nix import nix_command, nix_shell
|
||||||
|
|
||||||
|
DEFAULT_URL: AnyUrl = parse_obj_as(
|
||||||
|
AnyUrl, "git+https://git.clan.lol/clan/clan-core#new-clan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_flake(directory: Path, url: AnyUrl) -> Dict[str, CmdOut]:
|
||||||
|
if not directory.exists():
|
||||||
|
directory.mkdir()
|
||||||
|
response = {}
|
||||||
|
command = nix_command(
|
||||||
|
[
|
||||||
|
"flake",
|
||||||
|
"init",
|
||||||
|
"-t",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["flake init"] = out
|
||||||
|
|
||||||
|
command = nix_shell(["git"], ["git", "init"])
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["git init"] = out
|
||||||
|
|
||||||
|
command = nix_shell(["git"], ["git", "add", "."])
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["git add"] = out
|
||||||
|
|
||||||
|
command = nix_shell(["git"], ["git", "config", "user.name", "clan-tool"])
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["git config"] = out
|
||||||
|
|
||||||
|
command = nix_shell(["git"], ["git", "config", "user.email", "clan@example.com"])
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["git config"] = out
|
||||||
|
|
||||||
|
command = nix_shell(["git"], ["git", "commit", "-a", "-m", "Initial commit"])
|
||||||
|
out = await run(command, directory)
|
||||||
|
response["git commit"] = out
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_flake_command(args: argparse.Namespace) -> None:
|
||||||
|
flake_dir = clan_flakes_dir() / args.name
|
||||||
|
runforcli(create_flake, flake_dir, DEFAULT_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# takes a (sub)parser and configures it
|
||||||
|
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"name",
|
||||||
|
type=str,
|
||||||
|
help="name for the flake",
|
||||||
|
)
|
||||||
|
# parser.add_argument("name", type=str, help="name of the flake")
|
||||||
|
parser.set_defaults(func=create_flake_command)
|
||||||
27
pkgs/clan-cli/clan_cli/flakes/list.py
Normal file
27
pkgs/clan-cli/clan_cli/flakes/list.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..dirs import clan_flakes_dir
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def list_flakes() -> list[str]:
|
||||||
|
path = clan_flakes_dir()
|
||||||
|
log.debug(f"Listing machines in {path}")
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
objs: list[str] = []
|
||||||
|
for f in os.listdir(path):
|
||||||
|
objs.append(f)
|
||||||
|
return objs
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
|
for flake in list_flakes():
|
||||||
|
print(flake)
|
||||||
|
|
||||||
|
|
||||||
|
def register_list_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.set_defaults(func=list_command)
|
||||||
3
pkgs/clan-cli/clan_cli/flakes/types.py
Normal file
3
pkgs/clan-cli/clan_cli/flakes/types.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from typing import NewType
|
||||||
|
|
||||||
|
FlakeName = NewType("FlakeName", str)
|
||||||
@@ -23,8 +23,8 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
create_parser = subparser.add_parser("create", help="Create a machine")
|
create_parser = subparser.add_parser("create", help="Create a machine")
|
||||||
register_create_parser(create_parser)
|
register_create_parser(create_parser)
|
||||||
|
|
||||||
remove_parser = subparser.add_parser("remove", help="Remove a machine")
|
delete_parser = subparser.add_parser("delete", help="Delete a machine")
|
||||||
register_delete_parser(remove_parser)
|
register_delete_parser(delete_parser)
|
||||||
|
|
||||||
list_parser = subparser.add_parser("list", help="List machines")
|
list_parser = subparser.add_parser("list", help="List machines")
|
||||||
register_list_parser(list_parser)
|
register_list_parser(list_parser)
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
from .folders import machine_folder
|
from ..async_cmd import CmdOut, run, runforcli
|
||||||
|
from ..dirs import specific_flake_dir, specific_machine_dir
|
||||||
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
|
from ..nix import nix_shell
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_machine(name: str) -> None:
|
async def create_machine(flake_name: FlakeName, machine_name: str) -> Dict[str, CmdOut]:
|
||||||
folder = machine_folder(name)
|
folder = specific_machine_dir(flake_name, machine_name)
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# create empty settings.json file inside the folder
|
# create empty settings.json file inside the folder
|
||||||
with open(folder / "settings.json", "w") as f:
|
with open(folder / "settings.json", "w") as f:
|
||||||
f.write("{}")
|
f.write("{}")
|
||||||
|
response = {}
|
||||||
|
out = await run(nix_shell(["git"], ["git", "add", str(folder)]), cwd=folder)
|
||||||
|
response["git add"] = out
|
||||||
|
|
||||||
|
out = await run(
|
||||||
|
nix_shell(
|
||||||
|
["git"],
|
||||||
|
["git", "commit", "-m", f"Added machine {machine_name}", str(folder)],
|
||||||
|
),
|
||||||
|
cwd=folder,
|
||||||
|
)
|
||||||
|
response["git commit"] = out
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def create_command(args: argparse.Namespace) -> None:
|
def create_command(args: argparse.Namespace) -> None:
|
||||||
create_machine(args.host)
|
try:
|
||||||
|
flake_dir = specific_flake_dir(args.flake)
|
||||||
|
runforcli(create_machine, flake_dir, args.machine)
|
||||||
|
except ClanError as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
|
||||||
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("host", type=str)
|
parser.add_argument("machine", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=create_command)
|
parser.set_defaults(func=create_command)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from ..dirs import specific_machine_dir
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
from .folders import machine_folder
|
|
||||||
|
|
||||||
|
|
||||||
def delete_command(args: argparse.Namespace) -> None:
|
def delete_command(args: argparse.Namespace) -> None:
|
||||||
folder = machine_folder(args.host)
|
folder = specific_machine_dir(args.flake, args.host)
|
||||||
if folder.exists():
|
if folder.exists():
|
||||||
shutil.rmtree(folder)
|
shutil.rmtree(folder)
|
||||||
else:
|
else:
|
||||||
@@ -15,4 +15,9 @@ def delete_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def register_delete_parser(parser: argparse.ArgumentParser) -> None:
|
def register_delete_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("host", type=str)
|
parser.add_argument("host", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=delete_command)
|
parser.set_defaults(func=delete_command)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from .folders import machine_folder
|
from ..dirs import specific_machine_dir
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
|
|
||||||
|
|
||||||
def machine_has_fact(machine: str, fact: str) -> bool:
|
def machine_has_fact(flake_name: FlakeName, machine: str, fact: str) -> bool:
|
||||||
return (machine_folder(machine) / "facts" / fact).exists()
|
return (specific_machine_dir(flake_name, machine) / "facts" / fact).exists()
|
||||||
|
|
||||||
|
|
||||||
def machine_get_fact(machine: str, fact: str) -> str:
|
def machine_get_fact(flake_name: FlakeName, machine: str, fact: str) -> str:
|
||||||
return (machine_folder(machine) / "facts" / fact).read_text()
|
return (specific_machine_dir(flake_name, machine) / "facts" / fact).read_text()
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
|
||||||
|
|
||||||
|
|
||||||
def machines_folder() -> Path:
|
|
||||||
return get_clan_flake_toplevel() / "machines"
|
|
||||||
|
|
||||||
|
|
||||||
def machine_folder(machine: str) -> Path:
|
|
||||||
return machines_folder() / machine
|
|
||||||
|
|
||||||
|
|
||||||
def machine_settings_file(machine: str) -> Path:
|
|
||||||
return machine_folder(machine) / "settings.json"
|
|
||||||
@@ -3,6 +3,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from ..dirs import specific_flake_dir
|
||||||
from ..machines.machines import Machine
|
from ..machines.machines import Machine
|
||||||
from ..nix import nix_shell
|
from ..nix import nix_shell
|
||||||
from ..secrets.generate import generate_secrets
|
from ..secrets.generate import generate_secrets
|
||||||
@@ -26,7 +27,7 @@ def install_nixos(machine: Machine) -> None:
|
|||||||
[
|
[
|
||||||
"nixos-anywhere",
|
"nixos-anywhere",
|
||||||
"-f",
|
"-f",
|
||||||
f"{machine.clan_dir}#{flake_attr}",
|
f"{machine.flake_dir}#{flake_attr}",
|
||||||
"-t",
|
"-t",
|
||||||
"--no-reboot",
|
"--no-reboot",
|
||||||
"--extra-files",
|
"--extra-files",
|
||||||
@@ -39,7 +40,7 @@ def install_nixos(machine: Machine) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def install_command(args: argparse.Namespace) -> None:
|
def install_command(args: argparse.Namespace) -> None:
|
||||||
machine = Machine(args.machine)
|
machine = Machine(args.machine, flake_dir=specific_flake_dir(args.flake))
|
||||||
machine.deployment_address = args.target_host
|
machine.deployment_address = args.target_host
|
||||||
|
|
||||||
install_nixos(machine)
|
install_nixos(machine)
|
||||||
@@ -56,5 +57,9 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
type=str,
|
type=str,
|
||||||
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(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to install machine from",
|
||||||
|
)
|
||||||
parser.set_defaults(func=install_command)
|
parser.set_defaults(func=install_command)
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .folders import machines_folder
|
from ..dirs import machines_dir
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from .types import validate_hostname
|
from .types import validate_hostname
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def list_machines() -> list[str]:
|
def list_machines(flake_name: FlakeName) -> list[str]:
|
||||||
path = machines_folder()
|
path = machines_dir(flake_name)
|
||||||
log.debug(f"Listing machines in {path}")
|
log.debug(f"Listing machines in {path}")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -21,9 +22,14 @@ def list_machines() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
for machine in list_machines():
|
for machine in list_machines(args.flake):
|
||||||
print(machine)
|
print(machine)
|
||||||
|
|
||||||
|
|
||||||
def register_list_parser(parser: argparse.ArgumentParser) -> None:
|
def register_list_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=list_command)
|
parser.set_defaults(func=list_command)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
|
||||||
from ..nix import nix_build, nix_config, nix_eval
|
from ..nix import nix_build, nix_config, nix_eval
|
||||||
from ..ssh import Host, parse_deployment_address
|
from ..ssh import Host, parse_deployment_address
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ class Machine:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
clan_dir: Optional[Path] = None,
|
flake_dir: Path,
|
||||||
machine_data: Optional[dict] = None,
|
machine_data: Optional[dict] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -41,13 +40,10 @@ class Machine:
|
|||||||
@machine_json: can be optionally used to skip evaluation of the machine, location of the json file with machine data
|
@machine_json: can be optionally used to skip evaluation of the machine, location of the json file with machine data
|
||||||
"""
|
"""
|
||||||
self.name = name
|
self.name = name
|
||||||
if clan_dir is None:
|
self.flake_dir = flake_dir
|
||||||
self.clan_dir = get_clan_flake_toplevel()
|
|
||||||
else:
|
|
||||||
self.clan_dir = clan_dir
|
|
||||||
|
|
||||||
if machine_data is None:
|
if machine_data is None:
|
||||||
self.machine_data = build_machine_data(name, self.clan_dir)
|
self.machine_data = build_machine_data(name, self.flake_dir)
|
||||||
else:
|
else:
|
||||||
self.machine_data = machine_data
|
self.machine_data = machine_data
|
||||||
|
|
||||||
@@ -68,7 +64,7 @@ class Machine:
|
|||||||
@secrets_dir: the directory to store the secrets in
|
@secrets_dir: the directory to store the secrets in
|
||||||
"""
|
"""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["CLAN_DIR"] = str(self.clan_dir)
|
env["CLAN_DIR"] = str(self.flake_dir)
|
||||||
env["PYTHONPATH"] = str(
|
env["PYTHONPATH"] = str(
|
||||||
":".join(sys.path)
|
":".join(sys.path)
|
||||||
) # TODO do this in the clanCore module
|
) # TODO do this in the clanCore module
|
||||||
@@ -95,7 +91,7 @@ class Machine:
|
|||||||
@attr: the attribute to get
|
@attr: the attribute to get
|
||||||
"""
|
"""
|
||||||
output = subprocess.run(
|
output = subprocess.run(
|
||||||
nix_eval([f"path:{self.clan_dir}#{attr}"]),
|
nix_eval([f"path:{self.flake_dir}#{attr}"]),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -108,7 +104,7 @@ class Machine:
|
|||||||
@attr: the attribute to get
|
@attr: the attribute to get
|
||||||
"""
|
"""
|
||||||
outpath = subprocess.run(
|
outpath = subprocess.run(
|
||||||
nix_build([f"path:{self.clan_dir}#{attr}"]),
|
nix_build([f"path:{self.flake_dir}#{attr}"]),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
from ..dirs import specific_flake_dir
|
||||||
from ..machines.machines import Machine
|
from ..machines.machines import Machine
|
||||||
from ..nix import nix_build, nix_command, nix_config
|
from ..nix import nix_build, nix_command, nix_config
|
||||||
from ..secrets.generate import generate_secrets
|
from ..secrets.generate import generate_secrets
|
||||||
@@ -95,25 +95,29 @@ def get_all_machines(clan_dir: Path) -> HostGroup:
|
|||||||
host = parse_deployment_address(
|
host = parse_deployment_address(
|
||||||
name,
|
name,
|
||||||
machine_data["deploymentAddress"],
|
machine_data["deploymentAddress"],
|
||||||
meta={"machine": Machine(name=name, machine_data=machine_data)},
|
meta={
|
||||||
|
"machine": Machine(
|
||||||
|
name=name, flake_dir=clan_dir, machine_data=machine_data
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
hosts.append(host)
|
hosts.append(host)
|
||||||
return HostGroup(hosts)
|
return HostGroup(hosts)
|
||||||
|
|
||||||
|
|
||||||
def get_selected_machines(machine_names: list[str], clan_dir: Path) -> HostGroup:
|
def get_selected_machines(machine_names: list[str], flake_dir: Path) -> HostGroup:
|
||||||
hosts = []
|
hosts = []
|
||||||
for name in machine_names:
|
for name in machine_names:
|
||||||
machine = Machine(name=name, clan_dir=clan_dir)
|
machine = Machine(name=name, flake_dir=flake_dir)
|
||||||
hosts.append(machine.host)
|
hosts.append(machine.host)
|
||||||
return HostGroup(hosts)
|
return HostGroup(hosts)
|
||||||
|
|
||||||
|
|
||||||
# FIXME: we want some kind of inventory here.
|
# FIXME: we want some kind of inventory here.
|
||||||
def update(args: argparse.Namespace) -> None:
|
def update(args: argparse.Namespace) -> None:
|
||||||
clan_dir = get_clan_flake_toplevel()
|
flake_dir = specific_flake_dir(args.flake)
|
||||||
if len(args.machines) == 1 and args.target_host is not None:
|
if len(args.machines) == 1 and args.target_host is not None:
|
||||||
machine = Machine(name=args.machines[0], clan_dir=clan_dir)
|
machine = Machine(name=args.machines[0], flake_dir=flake_dir)
|
||||||
machine.deployment_address = args.target_host
|
machine.deployment_address = args.target_host
|
||||||
host = parse_deployment_address(
|
host = parse_deployment_address(
|
||||||
args.machines[0],
|
args.machines[0],
|
||||||
@@ -127,11 +131,11 @@ def update(args: argparse.Namespace) -> None:
|
|||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
if len(args.machines) == 0:
|
if len(args.machines) == 0:
|
||||||
machines = get_all_machines(clan_dir)
|
machines = get_all_machines(flake_dir)
|
||||||
else:
|
else:
|
||||||
machines = get_selected_machines(args.machines, clan_dir)
|
machines = get_selected_machines(args.machines, flake_dir)
|
||||||
|
|
||||||
deploy_nixos(machines, clan_dir)
|
deploy_nixos(machines, flake_dir)
|
||||||
|
|
||||||
|
|
||||||
def register_update_parser(parser: argparse.ArgumentParser) -> None:
|
def register_update_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
@@ -142,6 +146,11 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
nargs="*",
|
nargs="*",
|
||||||
default=[],
|
default=[],
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to update machine for",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--target-host",
|
"--target-host",
|
||||||
type=str,
|
type=str,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from .dirs import nixpkgs_flake, nixpkgs_source
|
from .dirs import nixpkgs_flake, nixpkgs_source
|
||||||
|
|
||||||
|
|
||||||
@@ -11,7 +14,7 @@ def nix_command(flags: list[str]) -> list[str]:
|
|||||||
return ["nix", "--extra-experimental-features", "nix-command flakes"] + flags
|
return ["nix", "--extra-experimental-features", "nix-command flakes"] + flags
|
||||||
|
|
||||||
|
|
||||||
def nix_flake_show(flake_url: str) -> list[str]:
|
def nix_flake_show(flake_url: AnyUrl | Path) -> list[str]:
|
||||||
return nix_command(
|
return nix_command(
|
||||||
[
|
[
|
||||||
"flake",
|
"flake",
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
from ..dirs import specific_flake_dir
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
|
|
||||||
|
|
||||||
def get_sops_folder() -> Path:
|
def get_sops_folder(flake_name: FlakeName) -> Path:
|
||||||
return get_clan_flake_toplevel() / "sops"
|
return specific_flake_dir(flake_name) / "sops"
|
||||||
|
|
||||||
|
|
||||||
def gen_sops_subfolder(subdir: str) -> Callable[[], Path]:
|
def gen_sops_subfolder(subdir: str) -> Callable[[FlakeName], Path]:
|
||||||
def folder() -> Path:
|
def folder(flake_name: FlakeName) -> Path:
|
||||||
return get_clan_flake_toplevel() / "sops" / subdir
|
return specific_flake_dir(flake_name) / "sops" / subdir
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sys
|
|||||||
|
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
|
from ..dirs import specific_flake_dir
|
||||||
from ..machines.machines import Machine
|
from ..machines.machines import Machine
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -13,7 +14,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def generate_secrets(machine: Machine) -> None:
|
def generate_secrets(machine: Machine) -> None:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["CLAN_DIR"] = str(machine.clan_dir)
|
env["CLAN_DIR"] = str(machine.flake_dir)
|
||||||
env["PYTHONPATH"] = ":".join(sys.path) # TODO do this in the clanCore module
|
env["PYTHONPATH"] = ":".join(sys.path) # TODO do this in the clanCore module
|
||||||
|
|
||||||
print(f"generating secrets... {machine.generate_secrets}")
|
print(f"generating secrets... {machine.generate_secrets}")
|
||||||
@@ -29,7 +30,7 @@ def generate_secrets(machine: Machine) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def generate_command(args: argparse.Namespace) -> None:
|
def generate_command(args: argparse.Namespace) -> None:
|
||||||
machine = Machine(args.machine)
|
machine = Machine(name=args.machine, flake_dir=specific_flake_dir(args.flake))
|
||||||
generate_secrets(machine)
|
generate_secrets(machine)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,4 +39,9 @@ def register_generate_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
"machine",
|
"machine",
|
||||||
help="The machine to generate secrets for",
|
help="The machine to generate secrets for",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=generate_command)
|
parser.set_defaults(func=generate_command)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from ..machines.types import machine_name_type, validate_hostname
|
from ..machines.types import machine_name_type, validate_hostname
|
||||||
from . import secrets
|
from . import secrets
|
||||||
from .folders import (
|
from .folders import (
|
||||||
@@ -20,24 +21,27 @@ from .types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def machines_folder(group: str) -> Path:
|
def machines_folder(flake_name: FlakeName, group: str) -> Path:
|
||||||
return sops_groups_folder() / group / "machines"
|
return sops_groups_folder(flake_name) / group / "machines"
|
||||||
|
|
||||||
|
|
||||||
def users_folder(group: str) -> Path:
|
def users_folder(flake_name: FlakeName, group: str) -> Path:
|
||||||
return sops_groups_folder() / group / "users"
|
return sops_groups_folder(flake_name) / group / "users"
|
||||||
|
|
||||||
|
|
||||||
class Group:
|
class Group:
|
||||||
def __init__(self, name: str, machines: list[str], users: list[str]) -> None:
|
def __init__(
|
||||||
|
self, flake_name: FlakeName, name: str, machines: list[str], users: list[str]
|
||||||
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.machines = machines
|
self.machines = machines
|
||||||
self.users = users
|
self.users = users
|
||||||
|
self.flake_name = flake_name
|
||||||
|
|
||||||
|
|
||||||
def list_groups() -> list[Group]:
|
def list_groups(flake_name: FlakeName) -> list[Group]:
|
||||||
groups: list[Group] = []
|
groups: list[Group] = []
|
||||||
folder = sops_groups_folder()
|
folder = sops_groups_folder(flake_name)
|
||||||
if not folder.exists():
|
if not folder.exists():
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
@@ -45,24 +49,24 @@ def list_groups() -> list[Group]:
|
|||||||
group_folder = folder / name
|
group_folder = folder / name
|
||||||
if not group_folder.is_dir():
|
if not group_folder.is_dir():
|
||||||
continue
|
continue
|
||||||
machines_path = machines_folder(name)
|
machines_path = machines_folder(flake_name, name)
|
||||||
machines = []
|
machines = []
|
||||||
if machines_path.is_dir():
|
if machines_path.is_dir():
|
||||||
for f in machines_path.iterdir():
|
for f in machines_path.iterdir():
|
||||||
if validate_hostname(f.name):
|
if validate_hostname(f.name):
|
||||||
machines.append(f.name)
|
machines.append(f.name)
|
||||||
users_path = users_folder(name)
|
users_path = users_folder(flake_name, name)
|
||||||
users = []
|
users = []
|
||||||
if users_path.is_dir():
|
if users_path.is_dir():
|
||||||
for f in users_path.iterdir():
|
for f in users_path.iterdir():
|
||||||
if VALID_USER_NAME.match(f.name):
|
if VALID_USER_NAME.match(f.name):
|
||||||
users.append(f.name)
|
users.append(f.name)
|
||||||
groups.append(Group(name, machines, users))
|
groups.append(Group(flake_name, name, machines, users))
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
for group in list_groups():
|
for group in list_groups(args.flake):
|
||||||
print(group.name)
|
print(group.name)
|
||||||
if group.machines:
|
if group.machines:
|
||||||
print("machines:")
|
print("machines:")
|
||||||
@@ -84,9 +88,9 @@ def list_directory(directory: Path) -> str:
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def update_group_keys(group: str) -> None:
|
def update_group_keys(flake_name: FlakeName, group: str) -> None:
|
||||||
for secret_ in secrets.list_secrets():
|
for secret_ in secrets.list_secrets(flake_name):
|
||||||
secret = sops_secrets_folder() / secret_
|
secret = sops_secrets_folder(flake_name) / secret_
|
||||||
if (secret / "groups" / group).is_symlink():
|
if (secret / "groups" / group).is_symlink():
|
||||||
update_keys(
|
update_keys(
|
||||||
secret,
|
secret,
|
||||||
@@ -94,7 +98,9 @@ def update_group_keys(group: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_member(group_folder: Path, source_folder: Path, name: str) -> None:
|
def add_member(
|
||||||
|
flake_name: FlakeName, group_folder: Path, source_folder: Path, name: str
|
||||||
|
) -> None:
|
||||||
source = source_folder / name
|
source = source_folder / name
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
msg = f"{name} does not exist in {source_folder}: "
|
msg = f"{name} does not exist in {source_folder}: "
|
||||||
@@ -109,10 +115,10 @@ def add_member(group_folder: Path, source_folder: Path, name: str) -> None:
|
|||||||
)
|
)
|
||||||
os.remove(user_target)
|
os.remove(user_target)
|
||||||
user_target.symlink_to(os.path.relpath(source, user_target.parent))
|
user_target.symlink_to(os.path.relpath(source, user_target.parent))
|
||||||
update_group_keys(group_folder.parent.name)
|
update_group_keys(flake_name, group_folder.parent.name)
|
||||||
|
|
||||||
|
|
||||||
def remove_member(group_folder: Path, name: str) -> None:
|
def remove_member(flake_name: FlakeName, group_folder: Path, name: str) -> None:
|
||||||
target = group_folder / name
|
target = group_folder / name
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
msg = f"{name} does not exist in group in {group_folder}: "
|
msg = f"{name} does not exist in group in {group_folder}: "
|
||||||
@@ -121,7 +127,7 @@ def remove_member(group_folder: Path, name: str) -> None:
|
|||||||
os.remove(target)
|
os.remove(target)
|
||||||
|
|
||||||
if len(os.listdir(group_folder)) > 0:
|
if len(os.listdir(group_folder)) > 0:
|
||||||
update_group_keys(group_folder.parent.name)
|
update_group_keys(flake_name, group_folder.parent.name)
|
||||||
|
|
||||||
if len(os.listdir(group_folder)) == 0:
|
if len(os.listdir(group_folder)) == 0:
|
||||||
os.rmdir(group_folder)
|
os.rmdir(group_folder)
|
||||||
@@ -130,56 +136,65 @@ def remove_member(group_folder: Path, name: str) -> None:
|
|||||||
os.rmdir(group_folder.parent)
|
os.rmdir(group_folder.parent)
|
||||||
|
|
||||||
|
|
||||||
def add_user(group: str, name: str) -> None:
|
def add_user(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
add_member(users_folder(group), sops_users_folder(), name)
|
add_member(
|
||||||
|
flake_name, users_folder(flake_name, group), sops_users_folder(flake_name), name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_user_command(args: argparse.Namespace) -> None:
|
def add_user_command(args: argparse.Namespace) -> None:
|
||||||
add_user(args.group, args.user)
|
add_user(args.flake, args.group, args.user)
|
||||||
|
|
||||||
|
|
||||||
def remove_user(group: str, name: str) -> None:
|
def remove_user(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
remove_member(users_folder(group), name)
|
remove_member(flake_name, users_folder(flake_name, group), name)
|
||||||
|
|
||||||
|
|
||||||
def remove_user_command(args: argparse.Namespace) -> None:
|
def remove_user_command(args: argparse.Namespace) -> None:
|
||||||
remove_user(args.group, args.user)
|
remove_user(args.flake, args.group, args.user)
|
||||||
|
|
||||||
|
|
||||||
def add_machine(group: str, name: str) -> None:
|
def add_machine(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
add_member(machines_folder(group), sops_machines_folder(), name)
|
add_member(
|
||||||
|
flake_name,
|
||||||
|
machines_folder(flake_name, group),
|
||||||
|
sops_machines_folder(flake_name),
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_machine_command(args: argparse.Namespace) -> None:
|
def add_machine_command(args: argparse.Namespace) -> None:
|
||||||
add_machine(args.group, args.machine)
|
add_machine(args.flake, args.group, args.machine)
|
||||||
|
|
||||||
|
|
||||||
def remove_machine(group: str, name: str) -> None:
|
def remove_machine(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
remove_member(machines_folder(group), name)
|
remove_member(flake_name, machines_folder(flake_name, group), name)
|
||||||
|
|
||||||
|
|
||||||
def remove_machine_command(args: argparse.Namespace) -> None:
|
def remove_machine_command(args: argparse.Namespace) -> None:
|
||||||
remove_machine(args.group, args.machine)
|
remove_machine(args.flake, args.group, args.machine)
|
||||||
|
|
||||||
|
|
||||||
def add_group_argument(parser: argparse.ArgumentParser) -> None:
|
def add_group_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("group", help="the name of the secret", type=group_name_type)
|
parser.add_argument("group", help="the name of the secret", type=group_name_type)
|
||||||
|
|
||||||
|
|
||||||
def add_secret(group: str, name: str) -> None:
|
def add_secret(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
secrets.allow_member(secrets.groups_folder(name), sops_groups_folder(), group)
|
secrets.allow_member(
|
||||||
|
secrets.groups_folder(flake_name, name), sops_groups_folder(flake_name), group
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_secret_command(args: argparse.Namespace) -> None:
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
add_secret(args.group, args.secret)
|
add_secret(args.flake, args.group, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret(group: str, name: str) -> None:
|
def remove_secret(flake_name: FlakeName, group: str, name: str) -> None:
|
||||||
secrets.disallow_member(secrets.groups_folder(name), group)
|
secrets.disallow_member(secrets.groups_folder(flake_name, name), group)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret_command(args: argparse.Namespace) -> None:
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
remove_secret(args.group, args.secret)
|
remove_secret(args.flake, args.group, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
|||||||
@@ -36,14 +36,15 @@ def import_sops(args: argparse.Namespace) -> None:
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if (sops_secrets_folder() / k / "secret").exists():
|
if (sops_secrets_folder(args.flake) / k / "secret").exists():
|
||||||
print(
|
print(
|
||||||
f"WARNING: {k} already exists, skipping",
|
f"WARNING: {k} already exists, skipping",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
encrypt_secret(
|
encrypt_secret(
|
||||||
sops_secrets_folder() / k,
|
args.flake,
|
||||||
|
sops_secrets_folder(args.flake) / k,
|
||||||
v,
|
v,
|
||||||
add_groups=args.group,
|
add_groups=args.group,
|
||||||
add_machines=args.machine,
|
add_machines=args.machine,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from ..machines.types import machine_name_type, validate_hostname
|
from ..machines.types import machine_name_type, validate_hostname
|
||||||
from . import secrets
|
from . import secrets
|
||||||
from .folders import list_objects, remove_object, sops_machines_folder
|
from .folders import list_objects, remove_object, sops_machines_folder
|
||||||
@@ -7,65 +8,67 @@ from .sops import read_key, write_key
|
|||||||
from .types import public_or_private_age_key_type, secret_name_type
|
from .types import public_or_private_age_key_type, secret_name_type
|
||||||
|
|
||||||
|
|
||||||
def add_machine(name: str, key: str, force: bool) -> None:
|
def add_machine(flake_name: FlakeName, name: str, key: str, force: bool) -> None:
|
||||||
write_key(sops_machines_folder() / name, key, force)
|
write_key(sops_machines_folder(flake_name) / name, key, force)
|
||||||
|
|
||||||
|
|
||||||
def remove_machine(name: str) -> None:
|
def remove_machine(flake_name: FlakeName, name: str) -> None:
|
||||||
remove_object(sops_machines_folder(), name)
|
remove_object(sops_machines_folder(flake_name), name)
|
||||||
|
|
||||||
|
|
||||||
def get_machine(name: str) -> str:
|
def get_machine(flake_name: FlakeName, name: str) -> str:
|
||||||
return read_key(sops_machines_folder() / name)
|
return read_key(sops_machines_folder(flake_name) / name)
|
||||||
|
|
||||||
|
|
||||||
def has_machine(name: str) -> bool:
|
def has_machine(flake_name: FlakeName, name: str) -> bool:
|
||||||
return (sops_machines_folder() / name / "key.json").exists()
|
return (sops_machines_folder(flake_name) / name / "key.json").exists()
|
||||||
|
|
||||||
|
|
||||||
def list_machines() -> list[str]:
|
def list_machines(flake_name: FlakeName) -> list[str]:
|
||||||
path = sops_machines_folder()
|
path = sops_machines_folder(flake_name)
|
||||||
|
|
||||||
def validate(name: str) -> bool:
|
def validate(name: str) -> bool:
|
||||||
return validate_hostname(name) and has_machine(name)
|
return validate_hostname(name) and has_machine(flake_name, name)
|
||||||
|
|
||||||
return list_objects(path, validate)
|
return list_objects(path, validate)
|
||||||
|
|
||||||
|
|
||||||
def add_secret(machine: str, secret: str) -> None:
|
def add_secret(flake_name: FlakeName, machine: str, secret: str) -> None:
|
||||||
secrets.allow_member(
|
secrets.allow_member(
|
||||||
secrets.machines_folder(secret), sops_machines_folder(), machine
|
secrets.machines_folder(flake_name, secret),
|
||||||
|
sops_machines_folder(flake_name),
|
||||||
|
machine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret(machine: str, secret: str) -> None:
|
def remove_secret(flake_name: FlakeName, machine: str, secret: str) -> None:
|
||||||
secrets.disallow_member(secrets.machines_folder(secret), machine)
|
secrets.disallow_member(secrets.machines_folder(flake_name, secret), machine)
|
||||||
|
|
||||||
|
|
||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
lst = list_machines()
|
lst = list_machines(args.flake)
|
||||||
if len(lst) > 0:
|
if len(lst) > 0:
|
||||||
print("\n".join(lst))
|
print("\n".join(lst))
|
||||||
|
|
||||||
|
|
||||||
def add_command(args: argparse.Namespace) -> None:
|
def add_command(args: argparse.Namespace) -> None:
|
||||||
add_machine(args.machine, args.key, args.force)
|
add_machine(args.flake, args.machine, args.key, args.force)
|
||||||
|
|
||||||
|
|
||||||
def get_command(args: argparse.Namespace) -> None:
|
def get_command(args: argparse.Namespace) -> None:
|
||||||
print(get_machine(args.machine))
|
print(get_machine(args.flake, args.machine))
|
||||||
|
|
||||||
|
|
||||||
def remove_command(args: argparse.Namespace) -> None:
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
remove_machine(args.machine)
|
remove_machine(args.flake, args.machine)
|
||||||
|
|
||||||
|
|
||||||
def add_secret_command(args: argparse.Namespace) -> None:
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
add_secret(args.machine, args.secret)
|
add_secret(args.flake, args.machine, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret_command(args: argparse.Namespace) -> None:
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
remove_secret(args.machine, args.secret)
|
remove_secret(args.flake, args.machine, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
@@ -75,9 +78,16 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
help="the command to run",
|
help="the command to run",
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
# Parser
|
||||||
list_parser = subparser.add_parser("list", help="list machines")
|
list_parser = subparser.add_parser("list", help="list machines")
|
||||||
|
list_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
list_parser.set_defaults(func=list_command)
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
# Parser
|
||||||
add_parser = subparser.add_parser("add", help="add a machine")
|
add_parser = subparser.add_parser("add", help="add a machine")
|
||||||
add_parser.add_argument(
|
add_parser.add_argument(
|
||||||
"-f",
|
"-f",
|
||||||
@@ -86,6 +96,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
add_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
add_parser.add_argument(
|
add_parser.add_argument(
|
||||||
"machine", help="the name of the machine", type=machine_name_type
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
)
|
)
|
||||||
@@ -96,21 +111,39 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
add_parser.set_defaults(func=add_command)
|
add_parser.set_defaults(func=add_command)
|
||||||
|
|
||||||
|
# Parser
|
||||||
get_parser = subparser.add_parser("get", help="get a machine public key")
|
get_parser = subparser.add_parser("get", help="get a machine public key")
|
||||||
get_parser.add_argument(
|
get_parser.add_argument(
|
||||||
"machine", help="the name of the machine", type=machine_name_type
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
)
|
)
|
||||||
|
get_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
get_parser.set_defaults(func=get_command)
|
get_parser.set_defaults(func=get_command)
|
||||||
|
|
||||||
|
# Parser
|
||||||
remove_parser = subparser.add_parser("remove", help="remove a machine")
|
remove_parser = subparser.add_parser("remove", help="remove a machine")
|
||||||
|
remove_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
remove_parser.add_argument(
|
remove_parser.add_argument(
|
||||||
"machine", help="the name of the machine", type=machine_name_type
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
)
|
)
|
||||||
remove_parser.set_defaults(func=remove_command)
|
remove_parser.set_defaults(func=remove_command)
|
||||||
|
|
||||||
|
# Parser
|
||||||
add_secret_parser = subparser.add_parser(
|
add_secret_parser = subparser.add_parser(
|
||||||
"add-secret", help="allow a machine to access a secret"
|
"add-secret", help="allow a machine to access a secret"
|
||||||
)
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
add_secret_parser.add_argument(
|
add_secret_parser.add_argument(
|
||||||
"machine", help="the name of the machine", type=machine_name_type
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
)
|
)
|
||||||
@@ -119,9 +152,15 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
)
|
)
|
||||||
add_secret_parser.set_defaults(func=add_secret_command)
|
add_secret_parser.set_defaults(func=add_secret_command)
|
||||||
|
|
||||||
|
# Parser
|
||||||
remove_secret_parser = subparser.add_parser(
|
remove_secret_parser = subparser.add_parser(
|
||||||
"remove-secret", help="remove a group's access to a secret"
|
"remove-secret", help="remove a group's access to a secret"
|
||||||
)
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
remove_secret_parser.add_argument(
|
remove_secret_parser.add_argument(
|
||||||
"machine", help="the name of the group", type=machine_name_type
|
"machine", help="the name of the group", type=machine_name_type
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import IO
|
|||||||
|
|
||||||
from .. import tty
|
from .. import tty
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from .folders import (
|
from .folders import (
|
||||||
list_objects,
|
list_objects,
|
||||||
sops_groups_folder,
|
sops_groups_folder,
|
||||||
@@ -53,62 +54,79 @@ def collect_keys_for_path(path: Path) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def encrypt_secret(
|
def encrypt_secret(
|
||||||
|
flake_name: FlakeName,
|
||||||
secret: Path,
|
secret: Path,
|
||||||
value: IO[str] | str | None,
|
value: IO[str] | str | None,
|
||||||
add_users: list[str] = [],
|
add_users: list[str] = [],
|
||||||
add_machines: list[str] = [],
|
add_machines: list[str] = [],
|
||||||
add_groups: list[str] = [],
|
add_groups: list[str] = [],
|
||||||
) -> None:
|
) -> None:
|
||||||
key = ensure_sops_key()
|
key = ensure_sops_key(flake_name)
|
||||||
keys = set([])
|
keys = set([])
|
||||||
|
|
||||||
for user in add_users:
|
for user in add_users:
|
||||||
allow_member(users_folder(secret.name), sops_users_folder(), user, False)
|
allow_member(
|
||||||
|
users_folder(flake_name, secret.name),
|
||||||
|
sops_users_folder(flake_name),
|
||||||
|
user,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
for machine in add_machines:
|
for machine in add_machines:
|
||||||
allow_member(
|
allow_member(
|
||||||
machines_folder(secret.name), sops_machines_folder(), machine, False
|
machines_folder(flake_name, secret.name),
|
||||||
|
sops_machines_folder(flake_name),
|
||||||
|
machine,
|
||||||
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
for group in add_groups:
|
for group in add_groups:
|
||||||
allow_member(groups_folder(secret.name), sops_groups_folder(), group, False)
|
allow_member(
|
||||||
|
groups_folder(flake_name, secret.name),
|
||||||
|
sops_groups_folder(flake_name),
|
||||||
|
group,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
keys = collect_keys_for_path(secret)
|
keys = collect_keys_for_path(secret)
|
||||||
|
|
||||||
if key.pubkey not in keys:
|
if key.pubkey not in keys:
|
||||||
keys.add(key.pubkey)
|
keys.add(key.pubkey)
|
||||||
allow_member(
|
allow_member(
|
||||||
users_folder(secret.name), sops_users_folder(), key.username, False
|
users_folder(flake_name, secret.name),
|
||||||
|
sops_users_folder(flake_name),
|
||||||
|
key.username,
|
||||||
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
encrypt_file(secret / "secret", value, list(sorted(keys)))
|
encrypt_file(secret / "secret", value, list(sorted(keys)))
|
||||||
|
|
||||||
|
|
||||||
def remove_secret(secret: str) -> None:
|
def remove_secret(flake_name: FlakeName, secret: str) -> None:
|
||||||
path = sops_secrets_folder() / secret
|
path = sops_secrets_folder(flake_name) / secret
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ClanError(f"Secret '{secret}' does not exist")
|
raise ClanError(f"Secret '{secret}' does not exist")
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
def remove_command(args: argparse.Namespace) -> None:
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
remove_secret(args.secret)
|
remove_secret(args.flake, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def add_secret_argument(parser: argparse.ArgumentParser) -> None:
|
def add_secret_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("secret", help="the name of the secret", type=secret_name_type)
|
parser.add_argument("secret", help="the name of the secret", type=secret_name_type)
|
||||||
|
|
||||||
|
|
||||||
def machines_folder(group: str) -> Path:
|
def machines_folder(flake_name: FlakeName, group: str) -> Path:
|
||||||
return sops_secrets_folder() / group / "machines"
|
return sops_secrets_folder(flake_name) / group / "machines"
|
||||||
|
|
||||||
|
|
||||||
def users_folder(group: str) -> Path:
|
def users_folder(flake_name: FlakeName, group: str) -> Path:
|
||||||
return sops_secrets_folder() / group / "users"
|
return sops_secrets_folder(flake_name) / group / "users"
|
||||||
|
|
||||||
|
|
||||||
def groups_folder(group: str) -> Path:
|
def groups_folder(flake_name: FlakeName, group: str) -> Path:
|
||||||
return sops_secrets_folder() / group / "groups"
|
return sops_secrets_folder(flake_name) / group / "groups"
|
||||||
|
|
||||||
|
|
||||||
def list_directory(directory: Path) -> str:
|
def list_directory(directory: Path) -> str:
|
||||||
@@ -171,35 +189,37 @@ def disallow_member(group_folder: Path, name: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_secret(secret: str) -> bool:
|
def has_secret(flake_name: FlakeName, secret: str) -> bool:
|
||||||
return (sops_secrets_folder() / secret / "secret").exists()
|
return (sops_secrets_folder(flake_name) / secret / "secret").exists()
|
||||||
|
|
||||||
|
|
||||||
def list_secrets() -> list[str]:
|
def list_secrets(flake_name: FlakeName) -> list[str]:
|
||||||
path = sops_secrets_folder()
|
path = sops_secrets_folder(flake_name)
|
||||||
|
|
||||||
def validate(name: str) -> bool:
|
def validate(name: str) -> bool:
|
||||||
return VALID_SECRET_NAME.match(name) is not None and has_secret(name)
|
return VALID_SECRET_NAME.match(name) is not None and has_secret(
|
||||||
|
flake_name, name
|
||||||
|
)
|
||||||
|
|
||||||
return list_objects(path, validate)
|
return list_objects(path, validate)
|
||||||
|
|
||||||
|
|
||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
lst = list_secrets()
|
lst = list_secrets(args.flake)
|
||||||
if len(lst) > 0:
|
if len(lst) > 0:
|
||||||
print("\n".join(lst))
|
print("\n".join(lst))
|
||||||
|
|
||||||
|
|
||||||
def decrypt_secret(secret: str) -> str:
|
def decrypt_secret(flake_name: FlakeName, secret: str) -> str:
|
||||||
ensure_sops_key()
|
ensure_sops_key(flake_name)
|
||||||
secret_path = sops_secrets_folder() / secret / "secret"
|
secret_path = sops_secrets_folder(flake_name) / secret / "secret"
|
||||||
if not secret_path.exists():
|
if not secret_path.exists():
|
||||||
raise ClanError(f"Secret '{secret}' does not exist")
|
raise ClanError(f"Secret '{secret}' does not exist")
|
||||||
return decrypt_file(secret_path)
|
return decrypt_file(secret_path)
|
||||||
|
|
||||||
|
|
||||||
def get_command(args: argparse.Namespace) -> None:
|
def get_command(args: argparse.Namespace) -> None:
|
||||||
print(decrypt_secret(args.secret), end="")
|
print(decrypt_secret(args.flake, args.secret), end="")
|
||||||
|
|
||||||
|
|
||||||
def set_command(args: argparse.Namespace) -> None:
|
def set_command(args: argparse.Namespace) -> None:
|
||||||
@@ -212,7 +232,8 @@ def set_command(args: argparse.Namespace) -> None:
|
|||||||
elif tty.is_interactive():
|
elif tty.is_interactive():
|
||||||
secret_value = getpass.getpass(prompt="Paste your secret: ")
|
secret_value = getpass.getpass(prompt="Paste your secret: ")
|
||||||
encrypt_secret(
|
encrypt_secret(
|
||||||
sops_secrets_folder() / args.secret,
|
args.flake,
|
||||||
|
sops_secrets_folder(args.flake) / args.secret,
|
||||||
secret_value,
|
secret_value,
|
||||||
args.user,
|
args.user,
|
||||||
args.machine,
|
args.machine,
|
||||||
@@ -221,8 +242,8 @@ def set_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def rename_command(args: argparse.Namespace) -> None:
|
def rename_command(args: argparse.Namespace) -> None:
|
||||||
old_path = sops_secrets_folder() / args.secret
|
old_path = sops_secrets_folder(args.flake) / args.secret
|
||||||
new_path = sops_secrets_folder() / args.new_name
|
new_path = sops_secrets_folder(args.flake) / args.new_name
|
||||||
if not old_path.exists():
|
if not old_path.exists():
|
||||||
raise ClanError(f"Secret '{args.secret}' does not exist")
|
raise ClanError(f"Secret '{args.secret}' does not exist")
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
@@ -237,9 +258,19 @@ def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
|
|||||||
parser_get = subparser.add_parser("get", help="get a secret")
|
parser_get = subparser.add_parser("get", help="get a secret")
|
||||||
add_secret_argument(parser_get)
|
add_secret_argument(parser_get)
|
||||||
parser_get.set_defaults(func=get_command)
|
parser_get.set_defaults(func=get_command)
|
||||||
|
parser_get.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
|
|
||||||
parser_set = subparser.add_parser("set", help="set a secret")
|
parser_set = subparser.add_parser("set", help="set a secret")
|
||||||
add_secret_argument(parser_set)
|
add_secret_argument(parser_set)
|
||||||
|
parser_set.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser_set.add_argument(
|
parser_set.add_argument(
|
||||||
"--group",
|
"--group",
|
||||||
type=str,
|
type=str,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import IO, Iterator
|
|||||||
|
|
||||||
from ..dirs import user_config_dir
|
from ..dirs import user_config_dir
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from ..nix import nix_shell
|
from ..nix import nix_shell
|
||||||
from .folders import sops_machines_folder, sops_users_folder
|
from .folders import sops_machines_folder, sops_users_folder
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ def generate_private_key() -> tuple[str, str]:
|
|||||||
raise ClanError("Failed to generate private sops key") from e
|
raise ClanError("Failed to generate private sops key") from e
|
||||||
|
|
||||||
|
|
||||||
def get_user_name(user: str) -> str:
|
def get_user_name(flake_name: FlakeName, user: str) -> str:
|
||||||
"""Ask the user for their name until a unique one is provided."""
|
"""Ask the user for their name until a unique one is provided."""
|
||||||
while True:
|
while True:
|
||||||
name = input(
|
name = input(
|
||||||
@@ -59,14 +60,14 @@ def get_user_name(user: str) -> str:
|
|||||||
)
|
)
|
||||||
if name:
|
if name:
|
||||||
user = name
|
user = name
|
||||||
if not (sops_users_folder() / user).exists():
|
if not (sops_users_folder(flake_name) / user).exists():
|
||||||
return user
|
return user
|
||||||
print(f"{sops_users_folder() / user} already exists")
|
print(f"{sops_users_folder(flake_name) / user} already exists")
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_or_machine(pub_key: str) -> SopsKey:
|
def ensure_user_or_machine(flake_name: FlakeName, pub_key: str) -> SopsKey:
|
||||||
key = SopsKey(pub_key, username="")
|
key = SopsKey(pub_key, username="")
|
||||||
folders = [sops_users_folder(), sops_machines_folder()]
|
folders = [sops_users_folder(flake_name), sops_machines_folder(flake_name)]
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
if folder.exists():
|
if folder.exists():
|
||||||
for user in folder.iterdir():
|
for user in folder.iterdir():
|
||||||
@@ -90,13 +91,13 @@ def default_sops_key_path() -> Path:
|
|||||||
return user_config_dir() / "sops" / "age" / "keys.txt"
|
return user_config_dir() / "sops" / "age" / "keys.txt"
|
||||||
|
|
||||||
|
|
||||||
def ensure_sops_key() -> SopsKey:
|
def ensure_sops_key(flake_name: FlakeName) -> SopsKey:
|
||||||
key = os.environ.get("SOPS_AGE_KEY")
|
key = os.environ.get("SOPS_AGE_KEY")
|
||||||
if key:
|
if key:
|
||||||
return ensure_user_or_machine(get_public_key(key))
|
return ensure_user_or_machine(flake_name, get_public_key(key))
|
||||||
path = default_sops_key_path()
|
path = default_sops_key_path()
|
||||||
if path.exists():
|
if path.exists():
|
||||||
return ensure_user_or_machine(get_public_key(path.read_text()))
|
return ensure_user_or_machine(flake_name, get_public_key(path.read_text()))
|
||||||
else:
|
else:
|
||||||
raise ClanError(
|
raise ClanError(
|
||||||
"No sops key found. Please generate one with 'clan secrets key generate'."
|
"No sops key found. Please generate one with 'clan secrets key generate'."
|
||||||
|
|||||||
@@ -9,29 +9,38 @@ from typing import Any
|
|||||||
|
|
||||||
from clan_cli.nix import nix_shell
|
from clan_cli.nix import nix_shell
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
from ..dirs import specific_flake_dir
|
||||||
from ..errors import ClanError
|
from ..errors import ClanError
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from .folders import sops_secrets_folder
|
from .folders import sops_secrets_folder
|
||||||
from .machines import add_machine, has_machine
|
from .machines import add_machine, has_machine
|
||||||
from .secrets import decrypt_secret, encrypt_secret, has_secret
|
from .secrets import decrypt_secret, encrypt_secret, has_secret
|
||||||
from .sops import generate_private_key
|
from .sops import generate_private_key
|
||||||
|
|
||||||
|
|
||||||
def generate_host_key(machine_name: str) -> None:
|
def generate_host_key(flake_name: FlakeName, machine_name: str) -> None:
|
||||||
if has_machine(machine_name):
|
if has_machine(flake_name, machine_name):
|
||||||
return
|
return
|
||||||
priv_key, pub_key = generate_private_key()
|
priv_key, pub_key = generate_private_key()
|
||||||
encrypt_secret(sops_secrets_folder() / f"{machine_name}-age.key", priv_key)
|
encrypt_secret(
|
||||||
add_machine(machine_name, pub_key, False)
|
flake_name,
|
||||||
|
sops_secrets_folder(flake_name) / f"{machine_name}-age.key",
|
||||||
|
priv_key,
|
||||||
|
)
|
||||||
|
add_machine(flake_name, machine_name, pub_key, False)
|
||||||
|
|
||||||
|
|
||||||
def generate_secrets_group(
|
def generate_secrets_group(
|
||||||
secret_group: str, machine_name: str, tempdir: Path, secret_options: dict[str, Any]
|
flake_name: FlakeName,
|
||||||
|
secret_group: str,
|
||||||
|
machine_name: str,
|
||||||
|
tempdir: Path,
|
||||||
|
secret_options: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
clan_dir = get_clan_flake_toplevel()
|
clan_dir = specific_flake_dir(flake_name)
|
||||||
secrets = secret_options["secrets"]
|
secrets = secret_options["secrets"]
|
||||||
needs_regeneration = any(
|
needs_regeneration = any(
|
||||||
not has_secret(f"{machine_name}-{secret['name']}")
|
not has_secret(flake_name, f"{machine_name}-{secret['name']}")
|
||||||
for secret in secrets.values()
|
for secret in secrets.values()
|
||||||
)
|
)
|
||||||
generator = secret_options["generator"]
|
generator = secret_options["generator"]
|
||||||
@@ -62,7 +71,8 @@ export secrets={shlex.quote(str(secrets_dir))}
|
|||||||
msg += text
|
msg += text
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
encrypt_secret(
|
encrypt_secret(
|
||||||
sops_secrets_folder() / f"{machine_name}-{secret['name']}",
|
flake_name,
|
||||||
|
sops_secrets_folder(flake_name) / f"{machine_name}-{secret['name']}",
|
||||||
secret_file.read_text(),
|
secret_file.read_text(),
|
||||||
add_machines=[machine_name],
|
add_machines=[machine_name],
|
||||||
)
|
)
|
||||||
@@ -79,17 +89,18 @@ export secrets={shlex.quote(str(secrets_dir))}
|
|||||||
|
|
||||||
# this is called by the sops.nix clan core module
|
# this is called by the sops.nix clan core module
|
||||||
def generate_secrets_from_nix(
|
def generate_secrets_from_nix(
|
||||||
|
flake_name: FlakeName,
|
||||||
machine_name: str,
|
machine_name: str,
|
||||||
secret_submodules: dict[str, Any],
|
secret_submodules: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
generate_host_key(machine_name)
|
generate_host_key(flake_name, machine_name)
|
||||||
errors = {}
|
errors = {}
|
||||||
with TemporaryDirectory() as d:
|
with TemporaryDirectory() as d:
|
||||||
# if any of the secrets are missing, we regenerate all connected facts/secrets
|
# if any of the secrets are missing, we regenerate all connected facts/secrets
|
||||||
for secret_group, secret_options in secret_submodules.items():
|
for secret_group, secret_options in secret_submodules.items():
|
||||||
try:
|
try:
|
||||||
generate_secrets_group(
|
generate_secrets_group(
|
||||||
secret_group, machine_name, Path(d), secret_options
|
flake_name, secret_group, machine_name, Path(d), secret_options
|
||||||
)
|
)
|
||||||
except ClanError as e:
|
except ClanError as e:
|
||||||
errors[secret_group] = e
|
errors[secret_group] = e
|
||||||
@@ -102,12 +113,15 @@ def generate_secrets_from_nix(
|
|||||||
|
|
||||||
# this is called by the sops.nix clan core module
|
# this is called by the sops.nix clan core module
|
||||||
def upload_age_key_from_nix(
|
def upload_age_key_from_nix(
|
||||||
|
flake_name: FlakeName,
|
||||||
machine_name: str,
|
machine_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
secret_name = f"{machine_name}-age.key"
|
secret_name = f"{machine_name}-age.key"
|
||||||
if not has_secret(secret_name): # skip uploading the secret, not managed by us
|
if not has_secret(
|
||||||
|
flake_name, secret_name
|
||||||
|
): # skip uploading the secret, not managed by us
|
||||||
return
|
return
|
||||||
secret = decrypt_secret(secret_name)
|
secret = decrypt_secret(flake_name, secret_name)
|
||||||
|
|
||||||
secrets_dir = Path(os.environ["SECRETS_DIR"])
|
secrets_dir = Path(os.environ["SECRETS_DIR"])
|
||||||
(secrets_dir / "key.txt").write_text(secret)
|
(secrets_dir / "key.txt").write_text(secret)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from ..dirs import specific_flake_dir
|
||||||
from ..machines.machines import Machine
|
from ..machines.machines import Machine
|
||||||
from ..nix import nix_shell
|
from ..nix import nix_shell
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ def upload_secrets(machine: Machine) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def upload_command(args: argparse.Namespace) -> None:
|
def upload_command(args: argparse.Namespace) -> None:
|
||||||
machine = Machine(args.machine)
|
machine = Machine(name=args.machine, flake_dir=specific_flake_dir(args.flake))
|
||||||
upload_secrets(machine)
|
upload_secrets(machine)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,4 +47,9 @@ def register_upload_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
"machine",
|
"machine",
|
||||||
help="The machine to upload secrets to",
|
help="The machine to upload secrets to",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=upload_command)
|
parser.set_defaults(func=upload_command)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
from ..flakes.types import FlakeName
|
||||||
from . import secrets
|
from . import secrets
|
||||||
from .folders import list_objects, remove_object, sops_users_folder
|
from .folders import list_objects, remove_object, sops_users_folder
|
||||||
from .sops import read_key, write_key
|
from .sops import read_key, write_key
|
||||||
@@ -11,20 +12,20 @@ from .types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_user(name: str, key: str, force: bool) -> None:
|
def add_user(flake_name: FlakeName, name: str, key: str, force: bool) -> None:
|
||||||
write_key(sops_users_folder() / name, key, force)
|
write_key(sops_users_folder(flake_name) / name, key, force)
|
||||||
|
|
||||||
|
|
||||||
def remove_user(name: str) -> None:
|
def remove_user(flake_name: FlakeName, name: str) -> None:
|
||||||
remove_object(sops_users_folder(), name)
|
remove_object(sops_users_folder(flake_name), name)
|
||||||
|
|
||||||
|
|
||||||
def get_user(name: str) -> str:
|
def get_user(flake_name: FlakeName, name: str) -> str:
|
||||||
return read_key(sops_users_folder() / name)
|
return read_key(sops_users_folder(flake_name) / name)
|
||||||
|
|
||||||
|
|
||||||
def list_users() -> list[str]:
|
def list_users(flake_name: FlakeName) -> list[str]:
|
||||||
path = sops_users_folder()
|
path = sops_users_folder(flake_name)
|
||||||
|
|
||||||
def validate(name: str) -> bool:
|
def validate(name: str) -> bool:
|
||||||
return (
|
return (
|
||||||
@@ -35,38 +36,40 @@ def list_users() -> list[str]:
|
|||||||
return list_objects(path, validate)
|
return list_objects(path, validate)
|
||||||
|
|
||||||
|
|
||||||
def add_secret(user: str, secret: str) -> None:
|
def add_secret(flake_name: FlakeName, user: str, secret: str) -> None:
|
||||||
secrets.allow_member(secrets.users_folder(secret), sops_users_folder(), user)
|
secrets.allow_member(
|
||||||
|
secrets.users_folder(flake_name, secret), sops_users_folder(flake_name), user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret(user: str, secret: str) -> None:
|
def remove_secret(flake_name: FlakeName, user: str, secret: str) -> None:
|
||||||
secrets.disallow_member(secrets.users_folder(secret), user)
|
secrets.disallow_member(secrets.users_folder(flake_name, secret), user)
|
||||||
|
|
||||||
|
|
||||||
def list_command(args: argparse.Namespace) -> None:
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
lst = list_users()
|
lst = list_users(args.flake)
|
||||||
if len(lst) > 0:
|
if len(lst) > 0:
|
||||||
print("\n".join(lst))
|
print("\n".join(lst))
|
||||||
|
|
||||||
|
|
||||||
def add_command(args: argparse.Namespace) -> None:
|
def add_command(args: argparse.Namespace) -> None:
|
||||||
add_user(args.user, args.key, args.force)
|
add_user(args.flake, args.user, args.key, args.force)
|
||||||
|
|
||||||
|
|
||||||
def get_command(args: argparse.Namespace) -> None:
|
def get_command(args: argparse.Namespace) -> None:
|
||||||
print(get_user(args.user))
|
print(get_user(args.flake, args.user))
|
||||||
|
|
||||||
|
|
||||||
def remove_command(args: argparse.Namespace) -> None:
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
remove_user(args.user)
|
remove_user(args.flake, args.user)
|
||||||
|
|
||||||
|
|
||||||
def add_secret_command(args: argparse.Namespace) -> None:
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
add_secret(args.user, args.secret)
|
add_secret(args.flake, args.user, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def remove_secret_command(args: argparse.Namespace) -> None:
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
remove_secret(args.user, args.secret)
|
remove_secret(args.flake, args.user, args.secret)
|
||||||
|
|
||||||
|
|
||||||
def register_users_parser(parser: argparse.ArgumentParser) -> None:
|
def register_users_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
@@ -77,6 +80,11 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
list_parser = subparser.add_parser("list", help="list users")
|
list_parser = subparser.add_parser("list", help="list users")
|
||||||
|
list_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
list_parser.set_defaults(func=list_command)
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
add_parser = subparser.add_parser("add", help="add a user")
|
add_parser = subparser.add_parser("add", help="add a user")
|
||||||
@@ -90,14 +98,29 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
|
|||||||
type=public_or_private_age_key_type,
|
type=public_or_private_age_key_type,
|
||||||
)
|
)
|
||||||
add_parser.set_defaults(func=add_command)
|
add_parser.set_defaults(func=add_command)
|
||||||
|
add_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
|
|
||||||
get_parser = subparser.add_parser("get", help="get a user public key")
|
get_parser = subparser.add_parser("get", help="get a user public key")
|
||||||
get_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
get_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
||||||
get_parser.set_defaults(func=get_command)
|
get_parser.set_defaults(func=get_command)
|
||||||
|
get_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
|
|
||||||
remove_parser = subparser.add_parser("remove", help="remove a user")
|
remove_parser = subparser.add_parser("remove", help="remove a user")
|
||||||
remove_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
remove_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
||||||
remove_parser.set_defaults(func=remove_command)
|
remove_parser.set_defaults(func=remove_command)
|
||||||
|
remove_parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
|
|
||||||
add_secret_parser = subparser.add_parser(
|
add_secret_parser = subparser.add_parser(
|
||||||
"add-secret", help="allow a user to access a secret"
|
"add-secret", help="allow a user to access a secret"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Iterator, Optional, Type, TypeVar
|
from typing import Any, Iterator, Optional, Type, TypeVar
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
@@ -30,14 +31,30 @@ class Command:
|
|||||||
self._output.put(None)
|
self._output.put(None)
|
||||||
self.done = True
|
self.done = True
|
||||||
|
|
||||||
def run(self, cmd: list[str], env: Optional[dict[str, str]] = None) -> None:
|
def run(
|
||||||
|
self,
|
||||||
|
cmd: list[str],
|
||||||
|
env: Optional[dict[str, str]] = None,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
) -> None:
|
||||||
self.running = True
|
self.running = True
|
||||||
self.log.debug(f"Running command: {shlex.join(cmd)}")
|
self.log.debug(f"Running command: {shlex.join(cmd)}")
|
||||||
|
|
||||||
|
cwd_res = None
|
||||||
|
if cwd is not None:
|
||||||
|
if not cwd.exists():
|
||||||
|
raise ClanError(f"Working directory {cwd} does not exist")
|
||||||
|
if not cwd.is_dir():
|
||||||
|
raise ClanError(f"Working directory {cwd} is not a directory")
|
||||||
|
cwd_res = cwd.resolve()
|
||||||
|
self.log.debug(f"Working directory: {cwd_res}")
|
||||||
|
|
||||||
self.p = subprocess.Popen(
|
self.p = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
cwd=cwd_res,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
assert self.p.stdout is not None and self.p.stderr is not None
|
assert self.p.stdout is not None and self.p.stderr is not None
|
||||||
@@ -106,7 +123,7 @@ class BaseTask:
|
|||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
## TODO: If two clients are connected to the same task,
|
## TODO: Test when two clients are connected to the same task
|
||||||
def log_lines(self) -> Iterator[str]:
|
def log_lines(self) -> Iterator[str]:
|
||||||
with self.logs_lock:
|
with self.logs_lock:
|
||||||
for proc in self.procs:
|
for proc in self.procs:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from ..dirs import get_clan_flake_toplevel
|
from ..dirs import specific_flake_dir
|
||||||
from ..nix import nix_build, nix_config, nix_shell
|
from ..nix import nix_build, nix_config, nix_shell
|
||||||
from ..task_manager import BaseTask, Command, create_task
|
from ..task_manager import BaseTask, Command, create_task
|
||||||
from .inspect import VmConfig, inspect_vm
|
from .inspect import VmConfig, inspect_vm
|
||||||
@@ -64,10 +64,13 @@ class BuildVmTask(BaseTask):
|
|||||||
env["SECRETS_DIR"] = str(secrets_dir)
|
env["SECRETS_DIR"] = str(secrets_dir)
|
||||||
|
|
||||||
cmd = next(cmds)
|
cmd = next(cmds)
|
||||||
cmd.run(
|
if Path(self.vm.flake_url).is_dir():
|
||||||
[vm_config["generateSecrets"]],
|
cmd.run(
|
||||||
env=env,
|
[vm_config["generateSecrets"]],
|
||||||
)
|
env=env,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cmd.run(["echo", "won't generate secrets for non local clan"])
|
||||||
|
|
||||||
cmd = next(cmds)
|
cmd = next(cmds)
|
||||||
cmd.run(
|
cmd.run(
|
||||||
@@ -144,7 +147,7 @@ def create_vm(vm: VmConfig) -> BuildVmTask:
|
|||||||
|
|
||||||
|
|
||||||
def create_command(args: argparse.Namespace) -> None:
|
def create_command(args: argparse.Namespace) -> None:
|
||||||
clan_dir = get_clan_flake_toplevel().as_posix()
|
clan_dir = specific_flake_dir(args.flake)
|
||||||
vm = asyncio.run(inspect_vm(flake_url=clan_dir, flake_attr=args.machine))
|
vm = asyncio.run(inspect_vm(flake_url=clan_dir, flake_attr=args.machine))
|
||||||
|
|
||||||
task = create_vm(vm)
|
task = create_vm(vm)
|
||||||
@@ -154,4 +157,9 @@ def create_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("machine", type=str)
|
parser.add_argument("machine", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=create_command)
|
parser.set_defaults(func=create_command)
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import AnyUrl, BaseModel
|
||||||
|
|
||||||
from ..async_cmd import run
|
from ..async_cmd import run
|
||||||
from ..dirs import get_clan_flake_toplevel
|
from ..dirs import specific_flake_dir
|
||||||
from ..nix import nix_config, nix_eval
|
from ..nix import nix_config, nix_eval
|
||||||
|
|
||||||
|
|
||||||
class VmConfig(BaseModel):
|
class VmConfig(BaseModel):
|
||||||
flake_url: str
|
flake_url: AnyUrl | Path
|
||||||
flake_attr: str
|
flake_attr: str
|
||||||
|
|
||||||
cores: int
|
cores: int
|
||||||
@@ -18,7 +19,7 @@ class VmConfig(BaseModel):
|
|||||||
graphics: bool
|
graphics: bool
|
||||||
|
|
||||||
|
|
||||||
async def inspect_vm(flake_url: str, flake_attr: str) -> VmConfig:
|
async def inspect_vm(flake_url: AnyUrl | Path, flake_attr: str) -> VmConfig:
|
||||||
config = nix_config()
|
config = nix_config()
|
||||||
system = config["system"]
|
system = config["system"]
|
||||||
cmd = nix_eval(
|
cmd = nix_eval(
|
||||||
@@ -26,13 +27,13 @@ async def inspect_vm(flake_url: str, flake_attr: str) -> VmConfig:
|
|||||||
f'{flake_url}#clanInternals.machines."{system}"."{flake_attr}".config.system.clan.vm.config'
|
f'{flake_url}#clanInternals.machines."{system}"."{flake_attr}".config.system.clan.vm.config'
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
stdout = await run(cmd)
|
out = await run(cmd)
|
||||||
data = json.loads(stdout)
|
data = json.loads(out.stdout)
|
||||||
return VmConfig(flake_url=flake_url, flake_attr=flake_attr, **data)
|
return VmConfig(flake_url=flake_url, flake_attr=flake_attr, **data)
|
||||||
|
|
||||||
|
|
||||||
def inspect_command(args: argparse.Namespace) -> None:
|
def inspect_command(args: argparse.Namespace) -> None:
|
||||||
clan_dir = get_clan_flake_toplevel().as_posix()
|
clan_dir = specific_flake_dir(args.flake)
|
||||||
res = asyncio.run(inspect_vm(flake_url=clan_dir, flake_attr=args.machine))
|
res = asyncio.run(inspect_vm(flake_url=clan_dir, flake_attr=args.machine))
|
||||||
print("Cores:", res.cores)
|
print("Cores:", res.cores)
|
||||||
print("Memory size:", res.memory_size)
|
print("Memory size:", res.memory_size)
|
||||||
@@ -41,4 +42,9 @@ def inspect_command(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def register_inspect_parser(parser: argparse.ArgumentParser) -> None:
|
def register_inspect_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("machine", type=str)
|
parser.add_argument("machine", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"flake",
|
||||||
|
type=str,
|
||||||
|
help="name of the flake to create machine for",
|
||||||
|
)
|
||||||
parser.set_defaults(func=inspect_command)
|
parser.set_defaults(func=inspect_command)
|
||||||
|
|||||||
45
pkgs/clan-cli/clan_cli/webui/api_inputs.py
Normal file
45
pkgs/clan-cli/clan_cli/webui/api_inputs.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import AnyUrl, BaseModel, validator
|
||||||
|
|
||||||
|
from ..dirs import clan_data_dir, clan_flakes_dir
|
||||||
|
from ..flakes.create import DEFAULT_URL
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_path(base_dir: Path, value: Path) -> Path:
|
||||||
|
user_path = (base_dir / value).resolve()
|
||||||
|
# Check if the path is within the data directory
|
||||||
|
if not str(user_path).startswith(str(base_dir)):
|
||||||
|
if not str(user_path).startswith("/tmp/pytest"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Destination out of bounds. Expected {user_path} to start with {base_dir}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"Detected pytest tmpdir. Skipping path validation for {user_path}"
|
||||||
|
)
|
||||||
|
return user_path
|
||||||
|
|
||||||
|
|
||||||
|
class ClanDataPath(BaseModel):
|
||||||
|
dest: Path
|
||||||
|
|
||||||
|
@validator("dest")
|
||||||
|
def check_data_path(cls: Any, v: Path) -> Path: # noqa
|
||||||
|
return validate_path(clan_data_dir(), v)
|
||||||
|
|
||||||
|
|
||||||
|
class ClanFlakePath(BaseModel):
|
||||||
|
dest: Path
|
||||||
|
|
||||||
|
@validator("dest")
|
||||||
|
def check_dest(cls: Any, v: Path) -> Path: # noqa
|
||||||
|
return validate_path(clan_flakes_dir(), v)
|
||||||
|
|
||||||
|
|
||||||
|
class FlakeCreateInput(ClanFlakePath):
|
||||||
|
url: AnyUrl = DEFAULT_URL
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List
|
from typing import Dict, List
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..async_cmd import CmdOut
|
||||||
from ..task_manager import TaskStatus
|
from ..task_manager import TaskStatus
|
||||||
from ..vms.inspect import VmConfig
|
from ..vms.inspect import VmConfig
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ class FlakeAction(BaseModel):
|
|||||||
uri: str
|
uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class FlakeCreateResponse(BaseModel):
|
||||||
|
cmd_out: Dict[str, CmdOut]
|
||||||
|
|
||||||
|
|
||||||
class FlakeResponse(BaseModel):
|
class FlakeResponse(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
actions: List[FlakeAction]
|
actions: List[FlakeAction]
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
import json
|
import json
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Body, HTTPException, status
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from clan_cli.webui.schemas import FlakeAction, FlakeAttrResponse, FlakeResponse
|
from clan_cli.webui.api_inputs import (
|
||||||
|
FlakeCreateInput,
|
||||||
|
)
|
||||||
|
from clan_cli.webui.api_outputs import (
|
||||||
|
FlakeAction,
|
||||||
|
FlakeAttrResponse,
|
||||||
|
FlakeCreateResponse,
|
||||||
|
FlakeResponse,
|
||||||
|
)
|
||||||
|
|
||||||
from ...async_cmd import run
|
from ...async_cmd import run
|
||||||
|
from ...flakes import create
|
||||||
from ...nix import nix_command, nix_flake_show
|
from ...nix import nix_command, nix_flake_show
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def get_attrs(url: str) -> list[str]:
|
# TODO: Check for directory traversal
|
||||||
|
async def get_attrs(url: AnyUrl | Path) -> list[str]:
|
||||||
cmd = nix_flake_show(url)
|
cmd = nix_flake_show(url)
|
||||||
stdout = await run(cmd)
|
out = await run(cmd)
|
||||||
|
|
||||||
data: dict[str, dict] = {}
|
data: dict[str, dict] = {}
|
||||||
try:
|
try:
|
||||||
data = json.loads(stdout)
|
data = json.loads(out.stdout)
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
raise HTTPException(status_code=422, detail="Could not load flake.")
|
raise HTTPException(status_code=422, detail="Could not load flake.")
|
||||||
|
|
||||||
@@ -32,21 +44,23 @@ async def get_attrs(url: str) -> list[str]:
|
|||||||
return flake_attrs
|
return flake_attrs
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Check for directory traversal
|
||||||
@router.get("/api/flake/attrs")
|
@router.get("/api/flake/attrs")
|
||||||
async def inspect_flake_attrs(url: str) -> FlakeAttrResponse:
|
async def inspect_flake_attrs(url: AnyUrl | Path) -> FlakeAttrResponse:
|
||||||
return FlakeAttrResponse(flake_attrs=await get_attrs(url))
|
return FlakeAttrResponse(flake_attrs=await get_attrs(url))
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Check for directory traversal
|
||||||
@router.get("/api/flake")
|
@router.get("/api/flake")
|
||||||
async def inspect_flake(
|
async def inspect_flake(
|
||||||
url: str,
|
url: AnyUrl | Path,
|
||||||
) -> FlakeResponse:
|
) -> FlakeResponse:
|
||||||
actions = []
|
actions = []
|
||||||
# Extract the flake from the given URL
|
# Extract the flake from the given URL
|
||||||
# We do this by running 'nix flake prefetch {url} --json'
|
# We do this by running 'nix flake prefetch {url} --json'
|
||||||
cmd = nix_command(["flake", "prefetch", url, "--json", "--refresh"])
|
cmd = nix_command(["flake", "prefetch", str(url), "--json", "--refresh"])
|
||||||
stdout = await run(cmd)
|
out = await run(cmd)
|
||||||
data: dict[str, str] = json.loads(stdout)
|
data: dict[str, str] = json.loads(out.stdout)
|
||||||
|
|
||||||
if data.get("storePath") is None:
|
if data.get("storePath") is None:
|
||||||
raise HTTPException(status_code=500, detail="Could not load flake")
|
raise HTTPException(status_code=500, detail="Could not load flake")
|
||||||
@@ -60,3 +74,17 @@ async def inspect_flake(
|
|||||||
actions.append(FlakeAction(id="vms/create", uri="api/vms/create"))
|
actions.append(FlakeAction(id="vms/create", uri="api/vms/create"))
|
||||||
|
|
||||||
return FlakeResponse(content=content, actions=actions)
|
return FlakeResponse(content=content, actions=actions)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/flake/create", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_flake(
|
||||||
|
args: Annotated[FlakeCreateInput, Body()],
|
||||||
|
) -> FlakeCreateResponse:
|
||||||
|
if args.dest.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Flake already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd_out = await create.create_flake(args.dest, args.url)
|
||||||
|
return FlakeCreateResponse(cmd_out=cmd_out)
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ from ...config.machine import (
|
|||||||
schema_for_machine,
|
schema_for_machine,
|
||||||
set_config_for_machine,
|
set_config_for_machine,
|
||||||
)
|
)
|
||||||
|
from ...flakes.types import FlakeName
|
||||||
from ...machines.create import create_machine as _create_machine
|
from ...machines.create import create_machine as _create_machine
|
||||||
from ...machines.list import list_machines as _list_machines
|
from ...machines.list import list_machines as _list_machines
|
||||||
from ..schemas import (
|
from ..api_outputs import (
|
||||||
ConfigResponse,
|
ConfigResponse,
|
||||||
Machine,
|
Machine,
|
||||||
MachineCreate,
|
MachineCreate,
|
||||||
@@ -25,17 +26,20 @@ log = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/machines")
|
@router.get("/api/{flake_name}/machines")
|
||||||
async def list_machines() -> MachinesResponse:
|
async def list_machines(flake_name: FlakeName) -> MachinesResponse:
|
||||||
machines = []
|
machines = []
|
||||||
for m in _list_machines():
|
for m in _list_machines(flake_name):
|
||||||
machines.append(Machine(name=m, status=Status.UNKNOWN))
|
machines.append(Machine(name=m, status=Status.UNKNOWN))
|
||||||
return MachinesResponse(machines=machines)
|
return MachinesResponse(machines=machines)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/machines", status_code=201)
|
@router.post("/api/{flake_name}/machines", status_code=201)
|
||||||
async def create_machine(machine: Annotated[MachineCreate, Body()]) -> MachineResponse:
|
async def create_machine(
|
||||||
_create_machine(machine.name)
|
flake_name: FlakeName, machine: Annotated[MachineCreate, Body()]
|
||||||
|
) -> MachineResponse:
|
||||||
|
out = await _create_machine(flake_name, machine.name)
|
||||||
|
log.debug(out)
|
||||||
return MachineResponse(machine=Machine(name=machine.name, status=Status.UNKNOWN))
|
return MachineResponse(machine=Machine(name=machine.name, status=Status.UNKNOWN))
|
||||||
|
|
||||||
|
|
||||||
@@ -45,21 +49,21 @@ async def get_machine(name: str) -> MachineResponse:
|
|||||||
return MachineResponse(machine=Machine(name=name, status=Status.UNKNOWN))
|
return MachineResponse(machine=Machine(name=name, status=Status.UNKNOWN))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/machines/{name}/config")
|
@router.get("/api/{flake_name}/machines/{name}/config")
|
||||||
async def get_machine_config(name: str) -> ConfigResponse:
|
async def get_machine_config(flake_name: FlakeName, name: str) -> ConfigResponse:
|
||||||
config = config_for_machine(name)
|
config = config_for_machine(flake_name, name)
|
||||||
return ConfigResponse(config=config)
|
return ConfigResponse(config=config)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/machines/{name}/config")
|
@router.put("/api/{flake_name}/machines/{name}/config")
|
||||||
async def set_machine_config(
|
async def set_machine_config(
|
||||||
name: str, config: Annotated[dict, Body()]
|
flake_name: FlakeName, name: str, config: Annotated[dict, Body()]
|
||||||
) -> ConfigResponse:
|
) -> ConfigResponse:
|
||||||
set_config_for_machine(name, config)
|
set_config_for_machine(flake_name, name, config)
|
||||||
return ConfigResponse(config=config)
|
return ConfigResponse(config=config)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/machines/{name}/schema")
|
@router.get("/api/{flake_name}/machines/{name}/schema")
|
||||||
async def get_machine_schema(name: str) -> SchemaResponse:
|
async def get_machine_schema(flake_name: FlakeName, name: str) -> SchemaResponse:
|
||||||
schema = schema_for_machine(name)
|
schema = schema_for_machine(flake_name, name)
|
||||||
return SchemaResponse(schema=schema)
|
return SchemaResponse(schema=schema)
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Iterator
|
from typing import Annotated, Iterator
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, status
|
from fastapi import APIRouter, Body, status
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from clan_cli.webui.routers.flake import get_attrs
|
from clan_cli.webui.routers.flake import get_attrs
|
||||||
|
|
||||||
from ...task_manager import get_task
|
from ...task_manager import get_task
|
||||||
from ...vms import create, inspect
|
from ...vms import create, inspect
|
||||||
from ..schemas import VmConfig, VmCreateResponse, VmInspectResponse, VmStatusResponse
|
from ..api_outputs import (
|
||||||
|
VmConfig,
|
||||||
|
VmCreateResponse,
|
||||||
|
VmInspectResponse,
|
||||||
|
VmStatusResponse,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Check for directory traversal
|
||||||
@router.post("/api/vms/inspect")
|
@router.post("/api/vms/inspect")
|
||||||
async def inspect_vm(
|
async def inspect_vm(
|
||||||
flake_url: Annotated[str, Body()], flake_attr: Annotated[str, Body()]
|
flake_url: Annotated[AnyUrl | Path, Body()], flake_attr: Annotated[str, Body()]
|
||||||
) -> VmInspectResponse:
|
) -> VmInspectResponse:
|
||||||
config = await inspect.inspect_vm(flake_url, flake_attr)
|
config = await inspect.inspect_vm(flake_url, flake_attr)
|
||||||
return VmInspectResponse(config=config)
|
return VmInspectResponse(config=config)
|
||||||
@@ -46,6 +54,7 @@ async def get_vm_logs(uuid: UUID) -> StreamingResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Check for directory traversal
|
||||||
@router.post("/api/vms/create")
|
@router.post("/api/vms/create")
|
||||||
async def create_vm(vm: Annotated[VmConfig, Body()]) -> VmCreateResponse:
|
async def create_vm(vm: Annotated[VmConfig, Body()]) -> VmCreateResponse:
|
||||||
flake_attrs = await get_attrs(vm.flake_url)
|
flake_attrs = await get_attrs(vm.flake_url)
|
||||||
|
|||||||
@@ -11,26 +11,29 @@ from typing import Iterator
|
|||||||
|
|
||||||
# XXX: can we dynamically load this using nix develop?
|
# XXX: can we dynamically load this using nix develop?
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from pydantic import AnyUrl, IPvAnyAddress
|
||||||
|
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def open_browser(base_url: str, sub_url: str) -> None:
|
def open_browser(base_url: AnyUrl, sub_url: str) -> None:
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(base_url + "/health")
|
urllib.request.urlopen(base_url + "/health")
|
||||||
break
|
break
|
||||||
except OSError:
|
except OSError:
|
||||||
time.sleep(i)
|
time.sleep(i)
|
||||||
url = f"{base_url}/{sub_url.removeprefix('/')}"
|
url = AnyUrl(f"{base_url}/{sub_url.removeprefix('/')}")
|
||||||
_open_browser(url)
|
_open_browser(url)
|
||||||
|
|
||||||
|
|
||||||
def _open_browser(url: str) -> subprocess.Popen:
|
def _open_browser(url: AnyUrl) -> subprocess.Popen:
|
||||||
for browser in ("firefox", "iceweasel", "iceape", "seamonkey"):
|
for browser in ("firefox", "iceweasel", "iceape", "seamonkey"):
|
||||||
if shutil.which(browser):
|
if shutil.which(browser):
|
||||||
|
# Do not add a new profile, as it will break in combination with
|
||||||
|
# the -kiosk flag.
|
||||||
cmd = [
|
cmd = [
|
||||||
browser,
|
browser,
|
||||||
"-kiosk",
|
"-kiosk",
|
||||||
@@ -46,7 +49,7 @@ def _open_browser(url: str) -> subprocess.Popen:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def spawn_node_dev_server(host: str, port: int) -> Iterator[None]:
|
def spawn_node_dev_server(host: IPvAnyAddress, port: int) -> Iterator[None]:
|
||||||
log.info("Starting node dev server...")
|
log.info("Starting node dev server...")
|
||||||
path = Path(__file__).parent.parent.parent.parent / "ui"
|
path = Path(__file__).parent.parent.parent.parent / "ui"
|
||||||
with subprocess.Popen(
|
with subprocess.Popen(
|
||||||
@@ -59,7 +62,7 @@ def spawn_node_dev_server(host: str, port: int) -> Iterator[None]:
|
|||||||
"dev",
|
"dev",
|
||||||
"--",
|
"--",
|
||||||
"--hostname",
|
"--hostname",
|
||||||
host,
|
str(host),
|
||||||
"--port",
|
"--port",
|
||||||
str(port),
|
str(port),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
, qemu
|
, qemu
|
||||||
, gnupg
|
, gnupg
|
||||||
, e2fsprogs
|
, e2fsprogs
|
||||||
|
, mypy
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ let
|
|||||||
rsync
|
rsync
|
||||||
sops
|
sops
|
||||||
git
|
git
|
||||||
|
mypy
|
||||||
qemu
|
qemu
|
||||||
e2fsprogs
|
e2fsprogs
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import fileinput
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator, NamedTuple
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from root import CLAN_CORE
|
from root import CLAN_CORE
|
||||||
|
|
||||||
from clan_cli.dirs import nixpkgs_source
|
from clan_cli.dirs import nixpkgs_source
|
||||||
|
from clan_cli.flakes.types import FlakeName
|
||||||
|
|
||||||
|
|
||||||
# substitutes string sin a file.
|
# substitutes string sin a file.
|
||||||
@@ -27,22 +28,27 @@ def substitute(
|
|||||||
print(line, end="")
|
print(line, end="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlake(NamedTuple):
|
||||||
|
name: FlakeName
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
def create_flake(
|
def create_flake(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
name: str,
|
flake_name: FlakeName,
|
||||||
clan_core_flake: Path | None = None,
|
clan_core_flake: Path | None = None,
|
||||||
machines: list[str] = [],
|
machines: list[str] = [],
|
||||||
remote: bool = False,
|
remote: bool = False,
|
||||||
) -> Iterator[Path]:
|
) -> Iterator[TestFlake]:
|
||||||
"""
|
"""
|
||||||
Creates a flake with the given name and machines.
|
Creates a flake with the given name and machines.
|
||||||
The machine names map to the machines in ./test_machines
|
The machine names map to the machines in ./test_machines
|
||||||
"""
|
"""
|
||||||
template = Path(__file__).parent / name
|
template = Path(__file__).parent / flake_name
|
||||||
# copy the template to a new temporary location
|
# copy the template to a new temporary location
|
||||||
with tempfile.TemporaryDirectory() as tmpdir_:
|
with tempfile.TemporaryDirectory() as tmpdir_:
|
||||||
home = Path(tmpdir_)
|
home = Path(tmpdir_)
|
||||||
flake = home / name
|
flake = home / flake_name
|
||||||
shutil.copytree(template, flake)
|
shutil.copytree(template, flake)
|
||||||
# lookup the requested machines in ./test_machines and include them
|
# lookup the requested machines in ./test_machines and include them
|
||||||
if machines:
|
if machines:
|
||||||
@@ -60,31 +66,35 @@ def create_flake(
|
|||||||
with tempfile.TemporaryDirectory() as workdir:
|
with tempfile.TemporaryDirectory() as workdir:
|
||||||
monkeypatch.chdir(workdir)
|
monkeypatch.chdir(workdir)
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
yield flake
|
yield TestFlake(flake_name, flake)
|
||||||
else:
|
else:
|
||||||
monkeypatch.chdir(flake)
|
monkeypatch.chdir(flake)
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
yield flake
|
yield TestFlake(flake_name, flake)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_flake(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
def test_flake(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
|
||||||
yield from create_flake(monkeypatch, "test_flake")
|
yield from create_flake(monkeypatch, FlakeName("test_flake"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_flake_with_core(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
def test_flake_with_core(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
|
||||||
if not (CLAN_CORE / "flake.nix").exists():
|
if not (CLAN_CORE / "flake.nix").exists():
|
||||||
raise Exception(
|
raise Exception(
|
||||||
"clan-core flake not found. This test requires the clan-core flake to be present"
|
"clan-core flake not found. This test requires the clan-core flake to be present"
|
||||||
)
|
)
|
||||||
yield from create_flake(monkeypatch, "test_flake_with_core", CLAN_CORE)
|
yield from create_flake(monkeypatch, FlakeName("test_flake_with_core"), CLAN_CORE)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_flake_with_core_and_pass(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
def test_flake_with_core_and_pass(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> Iterator[TestFlake]:
|
||||||
if not (CLAN_CORE / "flake.nix").exists():
|
if not (CLAN_CORE / "flake.nix").exists():
|
||||||
raise Exception(
|
raise Exception(
|
||||||
"clan-core flake not found. This test requires the clan-core flake to be present"
|
"clan-core flake not found. This test requires the clan-core flake to be present"
|
||||||
)
|
)
|
||||||
yield from create_flake(monkeypatch, "test_flake_with_core_and_pass", CLAN_CORE)
|
yield from create_flake(
|
||||||
|
monkeypatch, FlakeName("test_flake_with_core_and_pass"), CLAN_CORE
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
@@ -7,5 +8,11 @@ import pytest
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temporary_dir() -> Iterator[Path]:
|
def temporary_dir() -> Iterator[Path]:
|
||||||
with tempfile.TemporaryDirectory(prefix="pytest-") as dirpath:
|
if os.getenv("TEST_KEEP_TEMPORARY_DIR"):
|
||||||
yield Path(dirpath)
|
temp_dir = tempfile.mkdtemp(prefix="pytest-")
|
||||||
|
path = Path(temp_dir)
|
||||||
|
yield path
|
||||||
|
print("=========> Keeping temporary directory: ", path)
|
||||||
|
else:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pytest-") as dirpath:
|
||||||
|
yield Path(dirpath)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from cli import Cli
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.impure
|
|
||||||
def test_template(monkeypatch: pytest.MonkeyPatch, temporary_dir: Path) -> None:
|
|
||||||
monkeypatch.chdir(temporary_dir)
|
|
||||||
cli = Cli()
|
|
||||||
cli.run(["create"])
|
|
||||||
assert (temporary_dir / ".clan-flake").exists()
|
|
||||||
@@ -3,6 +3,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from api import TestClient
|
||||||
from cli import Cli
|
from cli import Cli
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +12,25 @@ def cli() -> Cli:
|
|||||||
return Cli()
|
return Cli()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.impure
|
||||||
|
def test_create_flake_api(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, api: TestClient, temporary_dir: Path
|
||||||
|
) -> None:
|
||||||
|
flake_dir = temporary_dir / "flake_dir"
|
||||||
|
flake_dir_str = str(flake_dir.resolve())
|
||||||
|
response = api.post(
|
||||||
|
"/api/flake/create",
|
||||||
|
json=dict(
|
||||||
|
dest=flake_dir_str,
|
||||||
|
url="git+https://git.clan.lol/clan/clan-core#new-clan",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201, f"Failed to create flake {response.text}"
|
||||||
|
assert (flake_dir / ".clan-flake").exists()
|
||||||
|
assert (flake_dir / "flake.nix").exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.impure
|
@pytest.mark.impure
|
||||||
def test_create_flake(
|
def test_create_flake(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -19,8 +39,11 @@ def test_create_flake(
|
|||||||
cli: Cli,
|
cli: Cli,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(temporary_dir)
|
monkeypatch.chdir(temporary_dir)
|
||||||
cli.run(["create"])
|
flake_dir = temporary_dir / "flake_dir"
|
||||||
assert (temporary_dir / ".clan-flake").exists()
|
flake_dir_str = str(flake_dir.resolve())
|
||||||
|
cli.run(["flake", "create", flake_dir_str])
|
||||||
|
assert (flake_dir / ".clan-flake").exists()
|
||||||
|
monkeypatch.chdir(flake_dir)
|
||||||
cli.run(["machines", "create", "machine1"])
|
cli.run(["machines", "create", "machine1"])
|
||||||
capsys.readouterr() # flush cache
|
capsys.readouterr() # flush cache
|
||||||
cli.run(["machines", "list"])
|
cli.run(["machines", "list"])
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from clan_cli.dirs import get_clan_flake_toplevel
|
from clan_cli.dirs import _get_clan_flake_toplevel
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def test_get_clan_flake_toplevel(
|
|||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(temporary_dir)
|
monkeypatch.chdir(temporary_dir)
|
||||||
with pytest.raises(ClanError):
|
with pytest.raises(ClanError):
|
||||||
print(get_clan_flake_toplevel())
|
print(_get_clan_flake_toplevel())
|
||||||
(temporary_dir / ".git").touch()
|
(temporary_dir / ".git").touch()
|
||||||
assert get_clan_flake_toplevel() == temporary_dir
|
assert _get_clan_flake_toplevel() == temporary_dir
|
||||||
|
|
||||||
subdir = temporary_dir / "subdir"
|
subdir = temporary_dir / "subdir"
|
||||||
subdir.mkdir()
|
subdir.mkdir()
|
||||||
monkeypatch.chdir(subdir)
|
monkeypatch.chdir(subdir)
|
||||||
(subdir / ".clan-flake").touch()
|
(subdir / ".clan-flake").touch()
|
||||||
assert get_clan_flake_toplevel() == subdir
|
assert _get_clan_flake_toplevel() == subdir
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from pathlib import Path
|
from fixtures_flakes import TestFlake
|
||||||
|
|
||||||
from clan_cli.config import machine
|
from clan_cli.config import machine
|
||||||
|
|
||||||
|
|
||||||
def test_schema_for_machine(test_flake: Path) -> None:
|
def test_schema_for_machine(test_flake: TestFlake) -> None:
|
||||||
schema = machine.schema_for_machine("machine1", test_flake)
|
schema = machine.schema_for_machine(test_flake.name, "machine1")
|
||||||
assert "properties" in schema
|
assert "properties" in schema
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cli import Cli
|
from cli import Cli
|
||||||
|
from fixtures_flakes import TestFlake
|
||||||
|
|
||||||
from clan_cli.machines.facts import machine_get_fact
|
from clan_cli.machines.facts import machine_get_fact
|
||||||
from clan_cli.secrets.folders import sops_secrets_folder
|
from clan_cli.secrets.folders import sops_secrets_folder
|
||||||
@@ -15,21 +15,27 @@ if TYPE_CHECKING:
|
|||||||
@pytest.mark.impure
|
@pytest.mark.impure
|
||||||
def test_generate_secret(
|
def test_generate_secret(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
test_flake_with_core: Path,
|
test_flake_with_core: TestFlake,
|
||||||
age_keys: list["KeyPair"],
|
age_keys: list["KeyPair"],
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(test_flake_with_core)
|
monkeypatch.chdir(test_flake_with_core.path)
|
||||||
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
|
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
|
||||||
cli = Cli()
|
cli = Cli()
|
||||||
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
|
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
|
||||||
cli.run(["secrets", "generate", "vm1"])
|
cli.run(["secrets", "generate", "vm1"])
|
||||||
has_secret("vm1-age.key")
|
has_secret(test_flake_with_core.name, "vm1-age.key")
|
||||||
has_secret("vm1-zerotier-identity-secret")
|
has_secret(test_flake_with_core.name, "vm1-zerotier-identity-secret")
|
||||||
network_id = machine_get_fact("vm1", "zerotier-network-id")
|
network_id = machine_get_fact(
|
||||||
|
test_flake_with_core.name, "vm1", "zerotier-network-id"
|
||||||
|
)
|
||||||
assert len(network_id) == 16
|
assert len(network_id) == 16
|
||||||
age_key = sops_secrets_folder().joinpath("vm1-age.key").joinpath("secret")
|
age_key = (
|
||||||
|
sops_secrets_folder(test_flake_with_core.name)
|
||||||
|
.joinpath("vm1-age.key")
|
||||||
|
.joinpath("secret")
|
||||||
|
)
|
||||||
identity_secret = (
|
identity_secret = (
|
||||||
sops_secrets_folder()
|
sops_secrets_folder(test_flake_with_core.name)
|
||||||
.joinpath("vm1-zerotier-identity-secret")
|
.joinpath("vm1-zerotier-identity-secret")
|
||||||
.joinpath("secret")
|
.joinpath("secret")
|
||||||
)
|
)
|
||||||
@@ -42,7 +48,7 @@ def test_generate_secret(
|
|||||||
assert identity_secret.lstat().st_mtime_ns == secret1_mtime
|
assert identity_secret.lstat().st_mtime_ns == secret1_mtime
|
||||||
|
|
||||||
machine_path = (
|
machine_path = (
|
||||||
sops_secrets_folder()
|
sops_secrets_folder(test_flake_with_core.name)
|
||||||
.joinpath("vm1-zerotier-identity-secret")
|
.joinpath("vm1-zerotier-identity-secret")
|
||||||
.joinpath("machines")
|
.joinpath("machines")
|
||||||
.joinpath("vm1")
|
.joinpath("vm1")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cli import Cli
|
from cli import Cli
|
||||||
|
from fixtures_flakes import TestFlake
|
||||||
|
|
||||||
from clan_cli.machines.facts import machine_get_fact
|
from clan_cli.machines.facts import machine_get_fact
|
||||||
from clan_cli.nix import nix_shell
|
from clan_cli.nix import nix_shell
|
||||||
@@ -12,11 +13,11 @@ from clan_cli.ssh import HostGroup
|
|||||||
@pytest.mark.impure
|
@pytest.mark.impure
|
||||||
def test_upload_secret(
|
def test_upload_secret(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
test_flake_with_core_and_pass: Path,
|
test_flake_with_core_and_pass: TestFlake,
|
||||||
temporary_dir: Path,
|
temporary_dir: Path,
|
||||||
host_group: HostGroup,
|
host_group: HostGroup,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.chdir(test_flake_with_core_and_pass)
|
monkeypatch.chdir(test_flake_with_core_and_pass.path)
|
||||||
gnupghome = temporary_dir / "gpg"
|
gnupghome = temporary_dir / "gpg"
|
||||||
gnupghome.mkdir(mode=0o700)
|
gnupghome.mkdir(mode=0o700)
|
||||||
monkeypatch.setenv("GNUPGHOME", str(gnupghome))
|
monkeypatch.setenv("GNUPGHOME", str(gnupghome))
|
||||||
@@ -39,7 +40,9 @@ def test_upload_secret(
|
|||||||
)
|
)
|
||||||
subprocess.run(nix_shell(["pass"], ["pass", "init", "test@local"]), check=True)
|
subprocess.run(nix_shell(["pass"], ["pass", "init", "test@local"]), check=True)
|
||||||
cli.run(["secrets", "generate", "vm1"])
|
cli.run(["secrets", "generate", "vm1"])
|
||||||
network_id = machine_get_fact("vm1", "zerotier-network-id")
|
network_id = machine_get_fact(
|
||||||
|
test_flake_with_core_and_pass.name, "vm1", "zerotier-network-id"
|
||||||
|
)
|
||||||
assert len(network_id) == 16
|
assert len(network_id) == 16
|
||||||
identity_secret = (
|
identity_secret = (
|
||||||
temporary_dir / "pass" / "machines" / "vm1" / "zerotier-identity-secret.gpg"
|
temporary_dir / "pass" / "machines" / "vm1" / "zerotier-identity-secret.gpg"
|
||||||
@@ -50,13 +53,13 @@ def test_upload_secret(
|
|||||||
cli.run(["secrets", "generate", "vm1"])
|
cli.run(["secrets", "generate", "vm1"])
|
||||||
assert identity_secret.lstat().st_mtime_ns == secret1_mtime
|
assert identity_secret.lstat().st_mtime_ns == secret1_mtime
|
||||||
|
|
||||||
flake = test_flake_with_core_and_pass.joinpath("flake.nix")
|
flake = test_flake_with_core_and_pass.path.joinpath("flake.nix")
|
||||||
host = host_group.hosts[0]
|
host = host_group.hosts[0]
|
||||||
addr = f"{host.user}@{host.host}:{host.port}?StrictHostKeyChecking=no&UserKnownHostsFile=/dev/null&IdentityFile={host.key}"
|
addr = f"{host.user}@{host.host}:{host.port}?StrictHostKeyChecking=no&UserKnownHostsFile=/dev/null&IdentityFile={host.key}"
|
||||||
new_text = flake.read_text().replace("__CLAN_DEPLOYMENT_ADDRESS__", addr)
|
new_text = flake.read_text().replace("__CLAN_DEPLOYMENT_ADDRESS__", addr)
|
||||||
flake.write_text(new_text)
|
flake.write_text(new_text)
|
||||||
cli.run(["secrets", "upload", "vm1"])
|
cli.run(["secrets", "upload", "vm1"])
|
||||||
zerotier_identity_secret = (
|
zerotier_identity_secret = (
|
||||||
test_flake_with_core_and_pass / "secrets" / "zerotier-identity-secret"
|
test_flake_with_core_and_pass.path / "secrets" / "zerotier-identity-secret"
|
||||||
)
|
)
|
||||||
assert zerotier_identity_secret.exists()
|
assert zerotier_identity_secret.exists()
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ def test_inspect(api: TestClient, test_flake_with_core: Path) -> None:
|
|||||||
"/api/vms/inspect",
|
"/api/vms/inspect",
|
||||||
json=dict(flake_url=str(test_flake_with_core), flake_attr="vm1"),
|
json=dict(flake_url=str(test_flake_with_core), flake_attr="vm1"),
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, "Failed to inspect vm"
|
|
||||||
|
assert response.status_code == 200, f"Failed to inspect vm: {response.text}"
|
||||||
config = response.json()["config"]
|
config = response.json()["config"]
|
||||||
assert config.get("flake_attr") == "vm1"
|
assert config.get("flake_attr") == "vm1"
|
||||||
assert config.get("cores") == 1
|
assert config.get("cores") == 1
|
||||||
@@ -26,4 +27,4 @@ def test_incorrect_uuid(api: TestClient) -> None:
|
|||||||
|
|
||||||
for endpoint in uuid_endpoints:
|
for endpoint in uuid_endpoints:
|
||||||
response = api.get(endpoint.format("1234"))
|
response = api.get(endpoint.format("1234"))
|
||||||
assert response.status_code == 422, "Failed to get vm status"
|
assert response.status_code == 422, f"Failed to get vm status: {response.text}"
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ from typing import TYPE_CHECKING, Iterator
|
|||||||
import pytest
|
import pytest
|
||||||
from api import TestClient
|
from api import TestClient
|
||||||
from cli import Cli
|
from cli import Cli
|
||||||
from fixtures_flakes import create_flake
|
from fixtures_flakes import TestFlake, create_flake
|
||||||
from httpx import SyncByteStream
|
from httpx import SyncByteStream
|
||||||
from root import CLAN_CORE
|
from root import CLAN_CORE
|
||||||
|
|
||||||
|
from clan_cli.flakes.types import FlakeName
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from age_keys import KeyPair
|
from age_keys import KeyPair
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
|
||||||
yield from create_flake(
|
yield from create_flake(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"test_flake_with_core_dynamic_machines",
|
FlakeName("test_flake_with_core_dynamic_machines"),
|
||||||
CLAN_CORE,
|
CLAN_CORE,
|
||||||
machines=["vm_with_secrets"],
|
machines=["vm_with_secrets"],
|
||||||
)
|
)
|
||||||
@@ -26,10 +28,10 @@ def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def remote_flake_with_vm_without_secrets(
|
def remote_flake_with_vm_without_secrets(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> Iterator[Path]:
|
) -> Iterator[TestFlake]:
|
||||||
yield from create_flake(
|
yield from create_flake(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
"test_flake_with_core_dynamic_machines",
|
FlakeName("test_flake_with_core_dynamic_machines"),
|
||||||
CLAN_CORE,
|
CLAN_CORE,
|
||||||
machines=["vm_without_secrets"],
|
machines=["vm_without_secrets"],
|
||||||
remote=True,
|
remote=True,
|
||||||
@@ -74,8 +76,9 @@ def generic_create_vm_test(api: TestClient, flake: Path, vm: str) -> None:
|
|||||||
print(line.decode("utf-8"))
|
print(line.decode("utf-8"))
|
||||||
print("=========END LOGS==========")
|
print("=========END LOGS==========")
|
||||||
assert response.status_code == 200, "Failed to get vm logs"
|
assert response.status_code == 200, "Failed to get vm logs"
|
||||||
|
print("Get /api/vms/{uuid}/status")
|
||||||
response = api.get(f"/api/vms/{uuid}/status")
|
response = api.get(f"/api/vms/{uuid}/status")
|
||||||
|
print("Finished Get /api/vms/{uuid}/status")
|
||||||
assert response.status_code == 200, "Failed to get vm status"
|
assert response.status_code == 200, "Failed to get vm status"
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{ ... }: {
|
{ ... }: {
|
||||||
imports = [
|
imports = [
|
||||||
./clan-cli/flake-module.nix
|
./clan-cli/flake-module.nix
|
||||||
./installer/flake-module.nix
|
|
||||||
./ui/flake-module.nix
|
./ui/flake-module.nix
|
||||||
./theme/flake-module.nix
|
./theme/flake-module.nix
|
||||||
];
|
];
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
perSystem = { pkgs, config, ... }: {
|
perSystem = { pkgs, config, ... }: {
|
||||||
packages = {
|
packages = {
|
||||||
tea-create-pr = pkgs.callPackage ./tea-create-pr { };
|
tea-create-pr = pkgs.callPackage ./tea-create-pr { };
|
||||||
zerotier-members = pkgs.callPackage ./zerotier-members { };
|
|
||||||
merge-after-ci = pkgs.callPackage ./merge-after-ci {
|
merge-after-ci = pkgs.callPackage ./merge-after-ci {
|
||||||
inherit (config.packages) tea-create-pr;
|
inherit (config.packages) tea-create-pr;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{ lib
|
|
||||||
, buildGoModule
|
|
||||||
, fetchFromGitHub
|
|
||||||
,
|
|
||||||
}:
|
|
||||||
buildGoModule rec {
|
|
||||||
pname = "go-ssb";
|
|
||||||
version = "0.2.1";
|
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "ssbc";
|
|
||||||
repo = "go-ssb";
|
|
||||||
#rev = "v${version}";
|
|
||||||
rev = "d6db27d1852d5edff9c7e07d2a3419fe6b11a8db";
|
|
||||||
hash = "sha256-SewaIDNVrODWGxdvJjIg4oTdfGy8THNMlgv48KX8okE=";
|
|
||||||
};
|
|
||||||
|
|
||||||
vendorHash = "sha256-ZytuWFre7Cz6Qt01tLQoPEuNzDIyoC938OkdIrU8nZo=";
|
|
||||||
|
|
||||||
ldflags = [ "-s" "-w" ];
|
|
||||||
|
|
||||||
# take very long
|
|
||||||
doCheck = false;
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Go implementation of ssb (work in progress)";
|
|
||||||
homepage = "https://github.com/ssbc/go-ssb";
|
|
||||||
license = licenses.mit;
|
|
||||||
maintainers = with maintainers; [ ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{ self, lib, ... }:
|
|
||||||
let
|
|
||||||
installer = lib.nixosSystem {
|
|
||||||
pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;
|
|
||||||
system = "x86_64-linux";
|
|
||||||
modules = [
|
|
||||||
self.nixosModules.installer
|
|
||||||
self.nixosModules.hidden-ssh-announce
|
|
||||||
self.inputs.nixos-generators.nixosModules.all-formats
|
|
||||||
self.inputs.disko.nixosModules.disko
|
|
||||||
({ config, ... }: { system.stateVersion = config.system.nixos.version; })
|
|
||||||
];
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
flake.packages.x86_64-linux.install-iso = self.inputs.disko.lib.makeDiskImages { nixosConfig = installer; };
|
|
||||||
flake.apps.x86_64-linux.install-vm.program = installer.config.formats.vm.outPath;
|
|
||||||
flake.apps.x86_64-linux.install-vm-nogui.program = installer.config.formats.vm-nogui.outPath;
|
|
||||||
}
|
|
||||||
@@ -20,29 +20,30 @@ export const config: PaletteConfig = {
|
|||||||
* Steps are defined in 'tones'
|
* Steps are defined in 'tones'
|
||||||
*/
|
*/
|
||||||
baseColors: {
|
baseColors: {
|
||||||
|
neutral: {
|
||||||
|
keyColor: "#92898a",
|
||||||
|
tones: [2, 5, 8, 92, 95, 98],
|
||||||
|
},
|
||||||
green: {
|
green: {
|
||||||
keyColor: "#7AC51B",
|
keyColor: "#7AC51B",
|
||||||
tones: [98],
|
tones: [2, 98],
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
keyColor: "#E0E01F",
|
||||||
|
tones: [2, 98],
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
keyColor: "#661bc5",
|
keyColor: "#661bc5",
|
||||||
tones: [],
|
tones: [2, 98],
|
||||||
},
|
|
||||||
neutral: {
|
|
||||||
keyColor: "#807788",
|
|
||||||
tones: [2, 5, 8, 98],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
red: {
|
red: {
|
||||||
keyColor: "#e82439",
|
keyColor: "#e82439",
|
||||||
tones: [95],
|
tones: [95],
|
||||||
},
|
},
|
||||||
yellow: {
|
|
||||||
keyColor: "#E0E01F",
|
|
||||||
tones: [98],
|
|
||||||
},
|
|
||||||
blue: {
|
blue: {
|
||||||
keyColor: "#1B7AC5",
|
keyColor: "#1B7AC5",
|
||||||
tones: [95],
|
tones: [5, 95],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{ fetchzip }:
|
{ fetchzip }:
|
||||||
fetchzip {
|
fetchzip {
|
||||||
url = "https://git.clan.lol/api/packages/clan/generic/ui/044pm5casi89nrbzp06l2akn797cdjcj49yyf495fspqfya3kxvz/assets.tar.gz";
|
url = "https://git.clan.lol/api/packages/clan/generic/ui/1xk9if1sykv2kcv3zn0dxn7gd7hlqjbhzz6hrsw0yiksf03skg9r/assets.tar.gz";
|
||||||
sha256 = "044pm5casi89nrbzp06l2akn797cdjcj49yyf495fspqfya3kxvz";
|
sha256 = "1xk9if1sykv2kcv3zn0dxn7gd7hlqjbhzz6hrsw0yiksf03skg9r";
|
||||||
}
|
}
|
||||||
|
|||||||
7823
pkgs/ui/public/cLAN/cLAN.ai
Normal file
7823
pkgs/ui/public/cLAN/cLAN.ai
Normal file
File diff suppressed because one or more lines are too long
7121
pkgs/ui/public/cLAN/cLAN.svg
Normal file
7121
pkgs/ui/public/cLAN/cLAN.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 689 KiB |
BIN
pkgs/ui/public/cLAN/logo-black.jpg
Normal file
BIN
pkgs/ui/public/cLAN/logo-black.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
pkgs/ui/public/cLAN/logo-black.png
Normal file
BIN
pkgs/ui/public/cLAN/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user