Compare commits

...

21 Commits

Author SHA1 Message Date
545d389df0 Initial commit 2023-10-15 16:41:25 +02:00
6def19b4c8 Added new type FlakeName 2023-10-14 15:17:58 +02:00
718f647774 Added flake_name:str argument everywhere, nix fmt doesn't complain anymore 2023-10-14 14:57:36 +02:00
06d6edbfa7 API|CLI: Added argument 'flake_name' to all CLI and API endpoints. Tests missing. 2023-10-13 22:29:55 +02:00
740e5e2ebc Added state directory. 2023-10-13 19:56:10 +02:00
4ab4832d41 API: Added Path validators. api/flake/create inits git repo. Fixed vscode interpreter problem 2023-10-12 22:46:32 +02:00
DavHau
2f9ec882b2 Merge pull request 'vms.create: don't generate secrets if clan is remote' (#424) from lassulus-vm_generate into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/424
2023-10-11 10:10:26 +00:00
lassulus
3dad667f97 vms.create: don't generate secrets if clan is remote 2023-10-11 12:01:42 +02:00
clan-bot
1cca1faedd Merge pull request 'API: Added /api/flake/create. Fixed vscode search settings. Moved clan create to clan flake create' (#423) from Qubasa-main into main 2023-10-09 12:04:02 +00:00
b49433958b API: Added /api/flake/create. Fixed vscode search settings. Moved clan create to clan flake create 2023-10-09 14:01:34 +02:00
clan-bot
3650ab491d Merge pull request 'Automatic flake update - 2023-10-09T00:00+00:00' (#422) from flake-update-2023-10-09 into main 2023-10-09 00:04:23 +00:00
Clan Merge Bot
603b48a0fe update flake lock - 2023-10-09T00:00+00:00
Flake lock file updates:

• Updated input 'disko':
    'github:nix-community/disko/646ee25c25fffee122a66282861f5f56ad3e0fd9' (2023-10-02)
  → 'github:nix-community/disko/cde886a1c97ef2399b4f91409db045785020291f' (2023-10-05)
• Updated input 'flake-parts':
    'github:hercules-ci/flake-parts/7f53fdb7bdc5bb237da7fefef12d099e4fd611ca' (2023-09-01)
  → 'github:hercules-ci/flake-parts/c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4' (2023-10-03)
• Updated input 'nixos-generators':
    'github:nix-community/nixos-generators/8ee78470029e641cddbd8721496da1316b47d3b4' (2023-09-04)
  → 'github:nix-community/nixos-generators/150f38bd1e09e20987feacb1b0d5991357532fb5' (2023-09-30)
• Updated input 'nixpkgs':
    'github:Mic92/nixpkgs/bc160df717ed1e9defe6044092ea66950976e3ed' (2023-09-26)
  → 'github:Mic92/nixpkgs/c3bd4f19ef0062d4462444aa413e26c917187ae9' (2023-09-30)
• Updated input 'sops-nix':
    'github:Mic92/sops-nix/2f375ed8702b0d8ee2430885059d5e7975e38f78' (2023-09-21)
  → 'github:Mic92/sops-nix/d7380c38d407eaf06d111832f4368ba3486b800e' (2023-10-08)
• Updated input 'treefmt-nix':
    'github:numtide/treefmt-nix/e951529be2e7c669487de78f5aef8597bbae5fca' (2023-09-21)
  → 'github:numtide/treefmt-nix/720bd006d855b08e60664e4683ccddb7a9ff614a' (2023-09-27)
2023-10-09 00:00:16 +00:00
ui-asset-bot
78758319f3 update ui-assets.nix 2023-10-08 15:27:26 +00:00
clan-bot
4fa2056834 Merge pull request 'cleanup work' (#421) from chore/fixes into main 2023-10-08 15:26:08 +00:00
ui-asset-bot
2874cf3bdb update ui-assets.nix 2023-10-08 15:24:32 +00:00
clan-bot
ded13b2da5 Merge pull request 'add coporate theme color variables' (#419) from feat/theme into main 2023-10-08 15:23:54 +00:00
Johannes Kirschbauer
5483018783 cleanup work 2023-10-08 17:20:43 +02:00
clan-bot
26b7effe99 Merge pull request 'CLI: Fixed bug in firefox opening addon page because of new profile' (#420) from Qubasa-main into main 2023-10-08 14:42:49 +00:00
Johannes Kirschbauer
6312f47545 remove: tailwindcss/plugin until we find solution 2023-10-08 16:31:16 +02:00
Johannes Kirschbauer
9ea71c90a6 resolve conflicts 2023-10-08 15:53:43 +02:00
Johannes Kirschbauer
d3310f861b add coporate theme color variables 2023-10-08 15:46:33 +02:00
149 changed files with 16177 additions and 3296 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
.direnv
.coverage.*
**/qubeclan
**/testdir
democlan
result*
/pkgs/clan-cli/clan_cli/nixpkgs

View File

@@ -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 = "/";
};
};
};
};
};
};
};
}

View File

@@ -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);
};
}

View File

@@ -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
```

View File

@@ -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 Cores `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.

View File

@@ -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.

View 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
...

View File

@@ -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
...

View File

@@ -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
...

View File

@@ -1,17 +0,0 @@
# (TITLE)
## General Description
## Stories
### Story 1: Some Description
Alice...
### Story 2: Some Description
Bob...
## Challenges
...

View File

@@ -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
View File

@@ -1,25 +1,5 @@
{
"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": {
"inputs": {
"nixpkgs-lib": [
@@ -27,11 +7,11 @@
]
},
"locked": {
"lastModified": 1693611461,
"narHash": "sha256-aPODl8vAgGQ0ZYFIRisxYG5MOGSkIczvu2Cd8Gb9+1Y=",
"lastModified": 1696343447,
"narHash": "sha256-B2xAZKLkkeRFG5XcHHSXXcP7To9Xzr59KXeZiRf4vdQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "7f53fdb7bdc5bb237da7fefef12d099e4fd611ca",
"rev": "c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4",
"type": "github"
},
"original": {
@@ -60,90 +40,30 @@
"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": {
"locked": {
"lastModified": 1695741452,
"narHash": "sha256-pDIQmCR0fyb6FKjvURaD6yC5YnE/+rxs5iFQQGgcoNE=",
"owner": "Mic92",
"lastModified": 1697059129,
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "bc160df717ed1e9defe6044092ea66950976e3ed",
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"type": "github"
},
"original": {
"owner": "Mic92",
"ref": "fakeroot",
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"disko": "disko",
"flake-parts": "flake-parts",
"floco": "floco",
"nixos-generators": "nixos-generators",
"nixpkgs": "nixpkgs",
"sops-nix": "sops-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": {
"inputs": {
"nixpkgs": [
@@ -151,11 +71,11 @@
]
},
"locked": {
"lastModified": 1695290086,
"narHash": "sha256-ol6licpIAzc9oMsEai/9YZhgSMcrnlnD/3ulMLGNKL0=",
"lastModified": 1695822946,
"narHash": "sha256-IQU3fYo0H+oGlqX5YrgZU3VRhbt2Oqe6KmslQKUO4II=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e951529be2e7c669487de78f5aef8597bbae5fca",
"rev": "720bd006d855b08e60664e4683ccddb7a9ff614a",
"type": "github"
},
"original": {

View File

@@ -1,22 +1,12 @@
{
description = "clan.lol base operating system";
nixConfig.extra-substituters = [ "https://cache.clan.lol" ];
nixConfig.extra-trusted-public-keys = [ "cache.clan.lol-1:3KztgSAB5R1M+Dz7vzkBGzXdodizbgLXGXKXlcQLA28=" ];
description = "Consulting Website";
inputs = {
#nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# 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.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.inputs.nixpkgs-lib.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix";
@@ -34,14 +24,7 @@
./checks/flake-module.nix
./devShell.nix
./formatter.nix
./templates/flake-module.nix
./clanModules/flake-module.nix
./pkgs/flake-module.nix
./lib/flake-module.nix
./nixosModules/flake-module.nix
./nixosModules/clanCore/flake-module.nix
];
});
}

View File

@@ -11,9 +11,10 @@
treefmt.flakeFormatter = true;
treefmt.programs.shellcheck.enable = true;
treefmt.programs.prettier.enable = true;
treefmt.programs.prettier.settings.plugins = [
"${self'.packages.prettier-plugin-tailwindcss}/lib/node_modules/prettier-plugin-tailwindcss/dist/index.mjs"
];
# TODO: add custom prettier package, that uses our ui/node_modules
# treefmt.programs.prettier.settings.plugins = [
# "${self'.packages.prettier-plugin-tailwindcss}/lib/node_modules/prettier-plugin-tailwindcss/dist/index.mjs"
# ];
treefmt.settings.formatter.prettier.excludes = [
"secrets.yaml"
"key.json"

View File

@@ -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;
};
}

View File

@@ -1,6 +0,0 @@
{ lib, self, nixpkgs, ... }:
{
jsonschema = import ./jsonschema { inherit lib; };
buildClan = import ./build-clan { inherit lib self nixpkgs; };
}

View File

@@ -1,14 +0,0 @@
{ lib
, inputs
, self
, ...
}: {
imports = [
./jsonschema/flake-module.nix
];
flake.lib = import ./default.nix {
inherit lib;
inherit self;
inherit (inputs) nixpkgs;
};
}

View File

@@ -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;
}

View File

@@ -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"
}
}

View File

@@ -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";
};
};
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -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
'';
};
};
}

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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; };
}

View File

@@ -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;
};
};
}

View File

@@ -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";
};
};
}

View File

@@ -1,9 +0,0 @@
{ lib, ... }: {
options.clan.bloatware = lib.mkOption {
type = lib.types.submodule {
imports = [
../../../lib/jsonschema/example-interface.nix
];
};
};
}

View File

@@ -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);
};
};
}

View File

@@ -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}";
};
};
}

View File

@@ -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`
'';
};
}

View File

@@ -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
];
}

View File

@@ -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
'';
};
}

View File

@@ -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";
};
}

View File

@@ -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;
};
};
}

View File

@@ -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}
''}"
];
})
];
}

View File

@@ -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()

View File

@@ -1,6 +0,0 @@
{ ... }: {
flake.nixosModules = {
hidden-ssh-announce.imports = [ ./hidden-ssh-announce.nix ];
installer.imports = [ ./installer ];
};
}

View File

@@ -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";
};
};
};
}

View File

@@ -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 = "/";
};
};
};
};
};
};
};
}

View File

@@ -12,4 +12,11 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"search.exclude": {
"**/.direnv": true
},
"python.linting.mypyPath": "mypy",
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
"python.defaultInterpreterPath": "python"
}

View File

@@ -3,7 +3,7 @@ import sys
from types import ModuleType
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
argcomplete: Optional[ModuleType] = None
@@ -24,10 +24,10 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser:
subparsers = parser.add_subparsers()
parser_create = subparsers.add_parser(
"create", help="create a clan flake inside the current directory"
parser_flake = subparsers.add_parser(
"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")
join.register_parser(parser_join)

View File

@@ -1,18 +1,35 @@
import asyncio
import logging
import shlex
from pathlib import Path
from typing import Any, Callable, Coroutine, Dict, NamedTuple, Optional
from .errors import ClanError
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)}")
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(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd_res,
)
stdout, stderr = await proc.communicate()
@@ -20,9 +37,30 @@ async def run(cmd: list[str]) -> bytes:
raise ClanError(
f"""
command: {shlex.join(cmd)}
working directory: {cwd_res}
exit code: {proc.returncode}
command output:
stderr:
{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)

View File

@@ -9,10 +9,10 @@ import sys
from pathlib import Path
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.flakes.types import FlakeName
from clan_cli.git import commit_file
from clan_cli.machines.folders import machine_settings_file
from clan_cli.nix import nix_eval
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:
clan_dir = get_clan_flake_toplevel()
def options_for_machine(
flake_name: FlakeName, machine_name: str, show_trace: bool = False
) -> dict:
clan_dir = specific_flake_dir(flake_name)
flags = []
if 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(
machine_name: str, option: str, show_trace: bool = False
flake_name: FlakeName, machine_name: str, option: str, show_trace: bool = False
) -> str:
clan_dir = get_clan_flake_toplevel()
clan_dir = specific_flake_dir(flake_name)
# use nix eval to read from .#nixosConfigurations.default.config.{option}
# this will give us the evaluated config with the options attribute
cmd = nix_eval(
@@ -164,19 +166,19 @@ def get_or_set_option(args: argparse.Namespace) -> None:
# load options
if args.options_file is None:
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:
with open(args.options_file) as f:
options = json.load(f)
# compute settings json file location
if args.settings_file is None:
get_clan_flake_toplevel()
settings_file = machine_settings_file(args.machine)
settings_file = machine_settings_file(args.flake, args.machine)
else:
settings_file = args.settings_file
# set the option with the given value
set_option(
flake_name=args.flake,
option=args.option,
value=args.value,
options=options,
@@ -185,7 +187,7 @@ def get_or_set_option(args: argparse.Namespace) -> None:
show_trace=args.show_trace,
)
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(new_value)
@@ -242,6 +244,7 @@ def find_option(
def set_option(
flake_name: FlakeName,
option: str,
value: Any,
options: dict,
@@ -290,7 +293,7 @@ def set_option(
json.dump(new_config, f, indent=2)
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}")
@@ -305,7 +308,11 @@ def register_parser(
# inject callback function to process the input later
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(
"--machine",
"-m",

View File

@@ -2,38 +2,45 @@ import json
import subprocess
import sys
from pathlib import Path
from typing import Optional
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.machines.folders import machine_folder, machine_settings_file
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
if not machine_folder(machine_name).exists():
if not specific_machine_dir(flake_name, machine_name).exists():
raise HTTPException(
status_code=404,
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():
return {}
with open(settings_path) as 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
if not machine_folder(machine_name).exists():
if not specific_machine_dir(flake_name, machine_name).exists():
raise HTTPException(
status_code=404,
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)
with open(settings_path, "w") as 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)
def schema_for_machine(machine_name: str, flake: Optional[Path] = None) -> dict:
if flake is None:
flake = get_clan_flake_toplevel()
def schema_for_machine(flake_name: FlakeName, machine_name: str) -> dict:
flake = specific_flake_dir(flake_name)
# use nix eval to lib.evalModules .#nixosModules.machine-{machine_name}
proc = subprocess.run(
nix_eval(

View File

@@ -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)

View File

@@ -4,9 +4,10 @@ from pathlib import Path
from typing import Optional
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"])
@@ -38,6 +39,55 @@ def user_config_dir() -> Path:
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:
return Path(__file__).parent

View 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)

View 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)

View 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)

View File

@@ -0,0 +1,3 @@
from typing import NewType
FlakeName = NewType("FlakeName", str)

View File

@@ -23,8 +23,8 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
create_parser = subparser.add_parser("create", help="Create a machine")
register_create_parser(create_parser)
remove_parser = subparser.add_parser("remove", help="Remove a machine")
register_delete_parser(remove_parser)
delete_parser = subparser.add_parser("delete", help="Delete a machine")
register_delete_parser(delete_parser)
list_parser = subparser.add_parser("list", help="List machines")
register_list_parser(list_parser)

View File

@@ -1,20 +1,52 @@
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:
folder = machine_folder(name)
async def create_machine(flake_name: FlakeName, machine_name: str) -> Dict[str, CmdOut]:
folder = specific_machine_dir(flake_name, machine_name)
folder.mkdir(parents=True, exist_ok=True)
# create empty settings.json file inside the folder
with open(folder / "settings.json", "w") as f:
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:
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:
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)

View File

@@ -1,12 +1,12 @@
import argparse
import shutil
from ..dirs import specific_machine_dir
from ..errors import ClanError
from .folders import machine_folder
def delete_command(args: argparse.Namespace) -> None:
folder = machine_folder(args.host)
folder = specific_machine_dir(args.flake, args.host)
if folder.exists():
shutil.rmtree(folder)
else:
@@ -15,4 +15,9 @@ def delete_command(args: argparse.Namespace) -> None:
def register_delete_parser(parser: argparse.ArgumentParser) -> None:
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)

View File

@@ -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:
return (machine_folder(machine) / "facts" / fact).exists()
def machine_has_fact(flake_name: FlakeName, machine: str, fact: str) -> bool:
return (specific_machine_dir(flake_name, machine) / "facts" / fact).exists()
def machine_get_fact(machine: str, fact: str) -> str:
return (machine_folder(machine) / "facts" / fact).read_text()
def machine_get_fact(flake_name: FlakeName, machine: str, fact: str) -> str:
return (specific_machine_dir(flake_name, machine) / "facts" / fact).read_text()

View File

@@ -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"

View File

@@ -3,6 +3,7 @@ import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from ..dirs import specific_flake_dir
from ..machines.machines import Machine
from ..nix import nix_shell
from ..secrets.generate import generate_secrets
@@ -26,7 +27,7 @@ def install_nixos(machine: Machine) -> None:
[
"nixos-anywhere",
"-f",
f"{machine.clan_dir}#{flake_attr}",
f"{machine.flake_dir}#{flake_attr}",
"-t",
"--no-reboot",
"--extra-files",
@@ -39,7 +40,7 @@ def install_nixos(machine: Machine) -> 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
install_nixos(machine)
@@ -56,5 +57,9 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
type=str,
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)

View File

@@ -2,14 +2,15 @@ import argparse
import logging
import os
from .folders import machines_folder
from ..dirs import machines_dir
from ..flakes.types import FlakeName
from .types import validate_hostname
log = logging.getLogger(__name__)
def list_machines() -> list[str]:
path = machines_folder()
def list_machines(flake_name: FlakeName) -> list[str]:
path = machines_dir(flake_name)
log.debug(f"Listing machines in {path}")
if not path.exists():
return []
@@ -21,9 +22,14 @@ def list_machines() -> list[str]:
def list_command(args: argparse.Namespace) -> None:
for machine in list_machines():
for machine in list_machines(args.flake):
print(machine)
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)

View File

@@ -5,7 +5,6 @@ import sys
from pathlib import Path
from typing import Optional
from ..dirs import get_clan_flake_toplevel
from ..nix import nix_build, nix_config, nix_eval
from ..ssh import Host, parse_deployment_address
@@ -31,7 +30,7 @@ class Machine:
def __init__(
self,
name: str,
clan_dir: Optional[Path] = None,
flake_dir: Path,
machine_data: Optional[dict] = 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
"""
self.name = name
if clan_dir is None:
self.clan_dir = get_clan_flake_toplevel()
else:
self.clan_dir = clan_dir
self.flake_dir = flake_dir
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:
self.machine_data = machine_data
@@ -68,7 +64,7 @@ class Machine:
@secrets_dir: the directory to store the secrets in
"""
env = os.environ.copy()
env["CLAN_DIR"] = str(self.clan_dir)
env["CLAN_DIR"] = str(self.flake_dir)
env["PYTHONPATH"] = str(
":".join(sys.path)
) # TODO do this in the clanCore module
@@ -95,7 +91,7 @@ class Machine:
@attr: the attribute to get
"""
output = subprocess.run(
nix_eval([f"path:{self.clan_dir}#{attr}"]),
nix_eval([f"path:{self.flake_dir}#{attr}"]),
stdout=subprocess.PIPE,
check=True,
text=True,
@@ -108,7 +104,7 @@ class Machine:
@attr: the attribute to get
"""
outpath = subprocess.run(
nix_build([f"path:{self.clan_dir}#{attr}"]),
nix_build([f"path:{self.flake_dir}#{attr}"]),
stdout=subprocess.PIPE,
check=True,
text=True,

View File

@@ -4,7 +4,7 @@ import os
import subprocess
from pathlib import Path
from ..dirs import get_clan_flake_toplevel
from ..dirs import specific_flake_dir
from ..machines.machines import Machine
from ..nix import nix_build, nix_command, nix_config
from ..secrets.generate import generate_secrets
@@ -95,25 +95,29 @@ def get_all_machines(clan_dir: Path) -> HostGroup:
host = parse_deployment_address(
name,
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)
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 = []
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)
return HostGroup(hosts)
# FIXME: we want some kind of inventory here.
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:
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
host = parse_deployment_address(
args.machines[0],
@@ -127,11 +131,11 @@ def update(args: argparse.Namespace) -> None:
exit(1)
else:
if len(args.machines) == 0:
machines = get_all_machines(clan_dir)
machines = get_all_machines(flake_dir)
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:
@@ -142,6 +146,11 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None:
nargs="*",
default=[],
)
parser.add_argument(
"flake",
type=str,
help="name of the flake to update machine for",
)
parser.add_argument(
"--target-host",
type=str,

View File

@@ -2,8 +2,11 @@ import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from pydantic import AnyUrl
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
def nix_flake_show(flake_url: str) -> list[str]:
def nix_flake_show(flake_url: AnyUrl | Path) -> list[str]:
return nix_command(
[
"flake",

View File

@@ -3,17 +3,18 @@ import shutil
from pathlib import Path
from typing import Callable
from ..dirs import get_clan_flake_toplevel
from ..dirs import specific_flake_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
def get_sops_folder() -> Path:
return get_clan_flake_toplevel() / "sops"
def get_sops_folder(flake_name: FlakeName) -> Path:
return specific_flake_dir(flake_name) / "sops"
def gen_sops_subfolder(subdir: str) -> Callable[[], Path]:
def folder() -> Path:
return get_clan_flake_toplevel() / "sops" / subdir
def gen_sops_subfolder(subdir: str) -> Callable[[FlakeName], Path]:
def folder(flake_name: FlakeName) -> Path:
return specific_flake_dir(flake_name) / "sops" / subdir
return folder

View File

@@ -6,6 +6,7 @@ import sys
from clan_cli.errors import ClanError
from ..dirs import specific_flake_dir
from ..machines.machines import Machine
log = logging.getLogger(__name__)
@@ -13,7 +14,7 @@ log = logging.getLogger(__name__)
def generate_secrets(machine: Machine) -> None:
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
print(f"generating secrets... {machine.generate_secrets}")
@@ -29,7 +30,7 @@ def generate_secrets(machine: Machine) -> 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)
@@ -38,4 +39,9 @@ def register_generate_parser(parser: argparse.ArgumentParser) -> None:
"machine",
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)

View File

@@ -3,6 +3,7 @@ import os
from pathlib import Path
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..machines.types import machine_name_type, validate_hostname
from . import secrets
from .folders import (
@@ -20,24 +21,27 @@ from .types import (
)
def machines_folder(group: str) -> Path:
return sops_groups_folder() / group / "machines"
def machines_folder(flake_name: FlakeName, group: str) -> Path:
return sops_groups_folder(flake_name) / group / "machines"
def users_folder(group: str) -> Path:
return sops_groups_folder() / group / "users"
def users_folder(flake_name: FlakeName, group: str) -> Path:
return sops_groups_folder(flake_name) / group / "users"
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.machines = machines
self.users = users
self.flake_name = flake_name
def list_groups() -> list[Group]:
def list_groups(flake_name: FlakeName) -> list[Group]:
groups: list[Group] = []
folder = sops_groups_folder()
folder = sops_groups_folder(flake_name)
if not folder.exists():
return groups
@@ -45,24 +49,24 @@ def list_groups() -> list[Group]:
group_folder = folder / name
if not group_folder.is_dir():
continue
machines_path = machines_folder(name)
machines_path = machines_folder(flake_name, name)
machines = []
if machines_path.is_dir():
for f in machines_path.iterdir():
if validate_hostname(f.name):
machines.append(f.name)
users_path = users_folder(name)
users_path = users_folder(flake_name, name)
users = []
if users_path.is_dir():
for f in users_path.iterdir():
if VALID_USER_NAME.match(f.name):
users.append(f.name)
groups.append(Group(name, machines, users))
groups.append(Group(flake_name, name, machines, users))
return groups
def list_command(args: argparse.Namespace) -> None:
for group in list_groups():
for group in list_groups(args.flake):
print(group.name)
if group.machines:
print("machines:")
@@ -84,9 +88,9 @@ def list_directory(directory: Path) -> str:
return msg
def update_group_keys(group: str) -> None:
for secret_ in secrets.list_secrets():
secret = sops_secrets_folder() / secret_
def update_group_keys(flake_name: FlakeName, group: str) -> None:
for secret_ in secrets.list_secrets(flake_name):
secret = sops_secrets_folder(flake_name) / secret_
if (secret / "groups" / group).is_symlink():
update_keys(
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
if not source.exists():
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)
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
if not target.exists():
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)
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:
os.rmdir(group_folder)
@@ -130,56 +136,65 @@ def remove_member(group_folder: Path, name: str) -> None:
os.rmdir(group_folder.parent)
def add_user(group: str, name: str) -> None:
add_member(users_folder(group), sops_users_folder(), name)
def add_user(flake_name: FlakeName, group: str, name: str) -> None:
add_member(
flake_name, users_folder(flake_name, group), sops_users_folder(flake_name), name
)
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:
remove_member(users_folder(group), name)
def remove_user(flake_name: FlakeName, group: str, name: str) -> None:
remove_member(flake_name, users_folder(flake_name, group), name)
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:
add_member(machines_folder(group), sops_machines_folder(), name)
def add_machine(flake_name: FlakeName, group: str, name: str) -> None:
add_member(
flake_name,
machines_folder(flake_name, group),
sops_machines_folder(flake_name),
name,
)
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:
remove_member(machines_folder(group), name)
def remove_machine(flake_name: FlakeName, group: str, name: str) -> None:
remove_member(flake_name, machines_folder(flake_name, group), name)
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:
parser.add_argument("group", help="the name of the secret", type=group_name_type)
def add_secret(group: str, name: str) -> None:
secrets.allow_member(secrets.groups_folder(name), sops_groups_folder(), group)
def add_secret(flake_name: FlakeName, group: str, name: str) -> None:
secrets.allow_member(
secrets.groups_folder(flake_name, name), sops_groups_folder(flake_name), group
)
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:
secrets.disallow_member(secrets.groups_folder(name), group)
def remove_secret(flake_name: FlakeName, group: str, name: str) -> None:
secrets.disallow_member(secrets.groups_folder(flake_name, name), group)
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:

View File

@@ -36,14 +36,15 @@ def import_sops(args: argparse.Namespace) -> None:
file=sys.stderr,
)
continue
if (sops_secrets_folder() / k / "secret").exists():
if (sops_secrets_folder(args.flake) / k / "secret").exists():
print(
f"WARNING: {k} already exists, skipping",
file=sys.stderr,
)
continue
encrypt_secret(
sops_secrets_folder() / k,
args.flake,
sops_secrets_folder(args.flake) / k,
v,
add_groups=args.group,
add_machines=args.machine,

View File

@@ -1,5 +1,6 @@
import argparse
from ..flakes.types import FlakeName
from ..machines.types import machine_name_type, validate_hostname
from . import secrets
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
def add_machine(name: str, key: str, force: bool) -> None:
write_key(sops_machines_folder() / name, key, force)
def add_machine(flake_name: FlakeName, name: str, key: str, force: bool) -> None:
write_key(sops_machines_folder(flake_name) / name, key, force)
def remove_machine(name: str) -> None:
remove_object(sops_machines_folder(), name)
def remove_machine(flake_name: FlakeName, name: str) -> None:
remove_object(sops_machines_folder(flake_name), name)
def get_machine(name: str) -> str:
return read_key(sops_machines_folder() / name)
def get_machine(flake_name: FlakeName, name: str) -> str:
return read_key(sops_machines_folder(flake_name) / name)
def has_machine(name: str) -> bool:
return (sops_machines_folder() / name / "key.json").exists()
def has_machine(flake_name: FlakeName, name: str) -> bool:
return (sops_machines_folder(flake_name) / name / "key.json").exists()
def list_machines() -> list[str]:
path = sops_machines_folder()
def list_machines(flake_name: FlakeName) -> list[str]:
path = sops_machines_folder(flake_name)
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)
def add_secret(machine: str, secret: str) -> None:
def add_secret(flake_name: FlakeName, machine: str, secret: str) -> None:
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:
secrets.disallow_member(secrets.machines_folder(secret), machine)
def remove_secret(flake_name: FlakeName, machine: str, secret: str) -> None:
secrets.disallow_member(secrets.machines_folder(flake_name, secret), machine)
def list_command(args: argparse.Namespace) -> None:
lst = list_machines()
lst = list_machines(args.flake)
if len(lst) > 0:
print("\n".join(lst))
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:
print(get_machine(args.machine))
print(get_machine(args.flake, args.machine))
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:
add_secret(args.machine, args.secret)
add_secret(args.flake, args.machine, args.secret)
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:
@@ -75,9 +78,16 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
help="the command to run",
required=True,
)
# Parser
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)
# Parser
add_parser = subparser.add_parser("add", help="add a machine")
add_parser.add_argument(
"-f",
@@ -86,6 +96,11 @@ def register_machines_parser(parser: argparse.ArgumentParser) -> None:
action="store_true",
default=False,
)
add_parser.add_argument(
"flake",
type=str,
help="name of the flake to create machine for",
)
add_parser.add_argument(
"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)
# Parser
get_parser = subparser.add_parser("get", help="get a machine public key")
get_parser.add_argument(
"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)
# Parser
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(
"machine", help="the name of the machine", type=machine_name_type
)
remove_parser.set_defaults(func=remove_command)
# Parser
add_secret_parser = subparser.add_parser(
"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(
"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)
# Parser
remove_secret_parser = subparser.add_parser(
"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(
"machine", help="the name of the group", type=machine_name_type
)

View File

@@ -8,6 +8,7 @@ from typing import IO
from .. import tty
from ..errors import ClanError
from ..flakes.types import FlakeName
from .folders import (
list_objects,
sops_groups_folder,
@@ -53,62 +54,79 @@ def collect_keys_for_path(path: Path) -> set[str]:
def encrypt_secret(
flake_name: FlakeName,
secret: Path,
value: IO[str] | str | None,
add_users: list[str] = [],
add_machines: list[str] = [],
add_groups: list[str] = [],
) -> None:
key = ensure_sops_key()
key = ensure_sops_key(flake_name)
keys = set([])
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:
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:
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)
if key.pubkey not in keys:
keys.add(key.pubkey)
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)))
def remove_secret(secret: str) -> None:
path = sops_secrets_folder() / secret
def remove_secret(flake_name: FlakeName, secret: str) -> None:
path = sops_secrets_folder(flake_name) / secret
if not path.exists():
raise ClanError(f"Secret '{secret}' does not exist")
shutil.rmtree(path)
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:
parser.add_argument("secret", help="the name of the secret", type=secret_name_type)
def machines_folder(group: str) -> Path:
return sops_secrets_folder() / group / "machines"
def machines_folder(flake_name: FlakeName, group: str) -> Path:
return sops_secrets_folder(flake_name) / group / "machines"
def users_folder(group: str) -> Path:
return sops_secrets_folder() / group / "users"
def users_folder(flake_name: FlakeName, group: str) -> Path:
return sops_secrets_folder(flake_name) / group / "users"
def groups_folder(group: str) -> Path:
return sops_secrets_folder() / group / "groups"
def groups_folder(flake_name: FlakeName, group: str) -> Path:
return sops_secrets_folder(flake_name) / group / "groups"
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:
return (sops_secrets_folder() / secret / "secret").exists()
def has_secret(flake_name: FlakeName, secret: str) -> bool:
return (sops_secrets_folder(flake_name) / secret / "secret").exists()
def list_secrets() -> list[str]:
path = sops_secrets_folder()
def list_secrets(flake_name: FlakeName) -> list[str]:
path = sops_secrets_folder(flake_name)
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)
def list_command(args: argparse.Namespace) -> None:
lst = list_secrets()
lst = list_secrets(args.flake)
if len(lst) > 0:
print("\n".join(lst))
def decrypt_secret(secret: str) -> str:
ensure_sops_key()
secret_path = sops_secrets_folder() / secret / "secret"
def decrypt_secret(flake_name: FlakeName, secret: str) -> str:
ensure_sops_key(flake_name)
secret_path = sops_secrets_folder(flake_name) / secret / "secret"
if not secret_path.exists():
raise ClanError(f"Secret '{secret}' does not exist")
return decrypt_file(secret_path)
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:
@@ -212,7 +232,8 @@ def set_command(args: argparse.Namespace) -> None:
elif tty.is_interactive():
secret_value = getpass.getpass(prompt="Paste your secret: ")
encrypt_secret(
sops_secrets_folder() / args.secret,
args.flake,
sops_secrets_folder(args.flake) / args.secret,
secret_value,
args.user,
args.machine,
@@ -221,8 +242,8 @@ def set_command(args: argparse.Namespace) -> None:
def rename_command(args: argparse.Namespace) -> None:
old_path = sops_secrets_folder() / args.secret
new_path = sops_secrets_folder() / args.new_name
old_path = sops_secrets_folder(args.flake) / args.secret
new_path = sops_secrets_folder(args.flake) / args.new_name
if not old_path.exists():
raise ClanError(f"Secret '{args.secret}' does not exist")
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")
add_secret_argument(parser_get)
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")
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(
"--group",
type=str,

View File

@@ -9,6 +9,7 @@ from typing import IO, Iterator
from ..dirs import user_config_dir
from ..errors import ClanError
from ..flakes.types import FlakeName
from ..nix import nix_shell
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
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."""
while True:
name = input(
@@ -59,14 +60,14 @@ def get_user_name(user: str) -> str:
)
if name:
user = name
if not (sops_users_folder() / user).exists():
if not (sops_users_folder(flake_name) / user).exists():
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="")
folders = [sops_users_folder(), sops_machines_folder()]
folders = [sops_users_folder(flake_name), sops_machines_folder(flake_name)]
for folder in folders:
if folder.exists():
for user in folder.iterdir():
@@ -90,13 +91,13 @@ def default_sops_key_path() -> Path:
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")
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()
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:
raise ClanError(
"No sops key found. Please generate one with 'clan secrets key generate'."

View File

@@ -9,29 +9,38 @@ from typing import Any
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 ..flakes.types import FlakeName
from .folders import sops_secrets_folder
from .machines import add_machine, has_machine
from .secrets import decrypt_secret, encrypt_secret, has_secret
from .sops import generate_private_key
def generate_host_key(machine_name: str) -> None:
if has_machine(machine_name):
def generate_host_key(flake_name: FlakeName, machine_name: str) -> None:
if has_machine(flake_name, machine_name):
return
priv_key, pub_key = generate_private_key()
encrypt_secret(sops_secrets_folder() / f"{machine_name}-age.key", priv_key)
add_machine(machine_name, pub_key, False)
encrypt_secret(
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(
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:
clan_dir = get_clan_flake_toplevel()
clan_dir = specific_flake_dir(flake_name)
secrets = secret_options["secrets"]
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()
)
generator = secret_options["generator"]
@@ -62,7 +71,8 @@ export secrets={shlex.quote(str(secrets_dir))}
msg += text
raise ClanError(msg)
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(),
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
def generate_secrets_from_nix(
flake_name: FlakeName,
machine_name: str,
secret_submodules: dict[str, Any],
) -> None:
generate_host_key(machine_name)
generate_host_key(flake_name, machine_name)
errors = {}
with TemporaryDirectory() as d:
# if any of the secrets are missing, we regenerate all connected facts/secrets
for secret_group, secret_options in secret_submodules.items():
try:
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:
errors[secret_group] = e
@@ -102,12 +113,15 @@ def generate_secrets_from_nix(
# this is called by the sops.nix clan core module
def upload_age_key_from_nix(
flake_name: FlakeName,
machine_name: str,
) -> None:
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
secret = decrypt_secret(secret_name)
secret = decrypt_secret(flake_name, secret_name)
secrets_dir = Path(os.environ["SECRETS_DIR"])
(secrets_dir / "key.txt").write_text(secret)

View File

@@ -4,6 +4,7 @@ import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from ..dirs import specific_flake_dir
from ..machines.machines import Machine
from ..nix import nix_shell
@@ -37,7 +38,7 @@ def upload_secrets(machine: Machine) -> 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)
@@ -46,4 +47,9 @@ def register_upload_parser(parser: argparse.ArgumentParser) -> None:
"machine",
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)

View File

@@ -1,5 +1,6 @@
import argparse
from ..flakes.types import FlakeName
from . import secrets
from .folders import list_objects, remove_object, sops_users_folder
from .sops import read_key, write_key
@@ -11,20 +12,20 @@ from .types import (
)
def add_user(name: str, key: str, force: bool) -> None:
write_key(sops_users_folder() / name, key, force)
def add_user(flake_name: FlakeName, name: str, key: str, force: bool) -> None:
write_key(sops_users_folder(flake_name) / name, key, force)
def remove_user(name: str) -> None:
remove_object(sops_users_folder(), name)
def remove_user(flake_name: FlakeName, name: str) -> None:
remove_object(sops_users_folder(flake_name), name)
def get_user(name: str) -> str:
return read_key(sops_users_folder() / name)
def get_user(flake_name: FlakeName, name: str) -> str:
return read_key(sops_users_folder(flake_name) / name)
def list_users() -> list[str]:
path = sops_users_folder()
def list_users(flake_name: FlakeName) -> list[str]:
path = sops_users_folder(flake_name)
def validate(name: str) -> bool:
return (
@@ -35,38 +36,40 @@ def list_users() -> list[str]:
return list_objects(path, validate)
def add_secret(user: str, secret: str) -> None:
secrets.allow_member(secrets.users_folder(secret), sops_users_folder(), user)
def add_secret(flake_name: FlakeName, user: str, secret: str) -> None:
secrets.allow_member(
secrets.users_folder(flake_name, secret), sops_users_folder(flake_name), user
)
def remove_secret(user: str, secret: str) -> None:
secrets.disallow_member(secrets.users_folder(secret), user)
def remove_secret(flake_name: FlakeName, user: str, secret: str) -> None:
secrets.disallow_member(secrets.users_folder(flake_name, secret), user)
def list_command(args: argparse.Namespace) -> None:
lst = list_users()
lst = list_users(args.flake)
if len(lst) > 0:
print("\n".join(lst))
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:
print(get_user(args.user))
print(get_user(args.flake, args.user))
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:
add_secret(args.user, args.secret)
add_secret(args.flake, args.user, args.secret)
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:
@@ -77,6 +80,11 @@ def register_users_parser(parser: argparse.ArgumentParser) -> None:
required=True,
)
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)
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,
)
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.add_argument("user", help="the name of the user", type=user_name_type)
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.add_argument("user", help="the name of the user", type=user_name_type)
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", help="allow a user to access a secret"

View File

@@ -8,6 +8,7 @@ import sys
import threading
import traceback
from enum import Enum
from pathlib import Path
from typing import Any, Iterator, Optional, Type, TypeVar
from uuid import UUID, uuid4
@@ -30,14 +31,30 @@ class Command:
self._output.put(None)
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.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(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
cwd=cwd_res,
env=env,
)
assert self.p.stdout is not None and self.p.stderr is not None
@@ -106,7 +123,7 @@ class BaseTask:
def run(self) -> None:
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]:
with self.logs_lock:
for proc in self.procs:

View File

@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Iterator
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 ..task_manager import BaseTask, Command, create_task
from .inspect import VmConfig, inspect_vm
@@ -64,10 +64,13 @@ class BuildVmTask(BaseTask):
env["SECRETS_DIR"] = str(secrets_dir)
cmd = next(cmds)
cmd.run(
[vm_config["generateSecrets"]],
env=env,
)
if Path(self.vm.flake_url).is_dir():
cmd.run(
[vm_config["generateSecrets"]],
env=env,
)
else:
cmd.run(["echo", "won't generate secrets for non local clan"])
cmd = next(cmds)
cmd.run(
@@ -144,7 +147,7 @@ def create_vm(vm: VmConfig) -> BuildVmTask:
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))
task = create_vm(vm)
@@ -154,4 +157,9 @@ def create_command(args: argparse.Namespace) -> None:
def register_create_parser(parser: argparse.ArgumentParser) -> None:
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)

View File

@@ -1,16 +1,17 @@
import argparse
import asyncio
import json
from pathlib import Path
from pydantic import BaseModel
from pydantic import AnyUrl, BaseModel
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
class VmConfig(BaseModel):
flake_url: str
flake_url: AnyUrl | Path
flake_attr: str
cores: int
@@ -18,7 +19,7 @@ class VmConfig(BaseModel):
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()
system = config["system"]
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'
]
)
stdout = await run(cmd)
data = json.loads(stdout)
out = await run(cmd)
data = json.loads(out.stdout)
return VmConfig(flake_url=flake_url, flake_attr=flake_attr, **data)
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))
print("Cores:", res.cores)
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:
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)

View 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

View File

@@ -1,8 +1,9 @@
from enum import Enum
from typing import List
from typing import Dict, List
from pydantic import BaseModel, Field
from ..async_cmd import CmdOut
from ..task_manager import TaskStatus
from ..vms.inspect import VmConfig
@@ -60,6 +61,10 @@ class FlakeAction(BaseModel):
uri: str
class FlakeCreateResponse(BaseModel):
cmd_out: Dict[str, CmdOut]
class FlakeResponse(BaseModel):
content: str
actions: List[FlakeAction]

View File

@@ -1,24 +1,36 @@
import json
from json.decoder import JSONDecodeError
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 ...flakes import create
from ...nix import nix_command, nix_flake_show
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)
stdout = await run(cmd)
out = await run(cmd)
data: dict[str, dict] = {}
try:
data = json.loads(stdout)
data = json.loads(out.stdout)
except JSONDecodeError:
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
# TODO: Check for directory traversal
@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))
# TODO: Check for directory traversal
@router.get("/api/flake")
async def inspect_flake(
url: str,
url: AnyUrl | Path,
) -> FlakeResponse:
actions = []
# Extract the flake from the given URL
# We do this by running 'nix flake prefetch {url} --json'
cmd = nix_command(["flake", "prefetch", url, "--json", "--refresh"])
stdout = await run(cmd)
data: dict[str, str] = json.loads(stdout)
cmd = nix_command(["flake", "prefetch", str(url), "--json", "--refresh"])
out = await run(cmd)
data: dict[str, str] = json.loads(out.stdout)
if data.get("storePath") is None:
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"))
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)

View File

@@ -9,9 +9,10 @@ from ...config.machine import (
schema_for_machine,
set_config_for_machine,
)
from ...flakes.types import FlakeName
from ...machines.create import create_machine as _create_machine
from ...machines.list import list_machines as _list_machines
from ..schemas import (
from ..api_outputs import (
ConfigResponse,
Machine,
MachineCreate,
@@ -25,17 +26,20 @@ log = logging.getLogger(__name__)
router = APIRouter()
@router.get("/api/machines")
async def list_machines() -> MachinesResponse:
@router.get("/api/{flake_name}/machines")
async def list_machines(flake_name: FlakeName) -> MachinesResponse:
machines = []
for m in _list_machines():
for m in _list_machines(flake_name):
machines.append(Machine(name=m, status=Status.UNKNOWN))
return MachinesResponse(machines=machines)
@router.post("/api/machines", status_code=201)
async def create_machine(machine: Annotated[MachineCreate, Body()]) -> MachineResponse:
_create_machine(machine.name)
@router.post("/api/{flake_name}/machines", status_code=201)
async def create_machine(
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))
@@ -45,21 +49,21 @@ async def get_machine(name: str) -> MachineResponse:
return MachineResponse(machine=Machine(name=name, status=Status.UNKNOWN))
@router.get("/api/machines/{name}/config")
async def get_machine_config(name: str) -> ConfigResponse:
config = config_for_machine(name)
@router.get("/api/{flake_name}/machines/{name}/config")
async def get_machine_config(flake_name: FlakeName, name: str) -> ConfigResponse:
config = config_for_machine(flake_name, name)
return ConfigResponse(config=config)
@router.put("/api/machines/{name}/config")
@router.put("/api/{flake_name}/machines/{name}/config")
async def set_machine_config(
name: str, config: Annotated[dict, Body()]
flake_name: FlakeName, name: str, config: Annotated[dict, Body()]
) -> ConfigResponse:
set_config_for_machine(name, config)
set_config_for_machine(flake_name, name, config)
return ConfigResponse(config=config)
@router.get("/api/machines/{name}/schema")
async def get_machine_schema(name: str) -> SchemaResponse:
schema = schema_for_machine(name)
@router.get("/api/{flake_name}/machines/{name}/schema")
async def get_machine_schema(flake_name: FlakeName, name: str) -> SchemaResponse:
schema = schema_for_machine(flake_name, name)
return SchemaResponse(schema=schema)

View File

@@ -1,24 +1,32 @@
import logging
from pathlib import Path
from typing import Annotated, Iterator
from uuid import UUID
from fastapi import APIRouter, Body, status
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from pydantic import AnyUrl
from clan_cli.webui.routers.flake import get_attrs
from ...task_manager import get_task
from ...vms import create, inspect
from ..schemas import VmConfig, VmCreateResponse, VmInspectResponse, VmStatusResponse
from ..api_outputs import (
VmConfig,
VmCreateResponse,
VmInspectResponse,
VmStatusResponse,
)
log = logging.getLogger(__name__)
router = APIRouter()
# TODO: Check for directory traversal
@router.post("/api/vms/inspect")
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:
config = await inspect.inspect_vm(flake_url, flake_attr)
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")
async def create_vm(vm: Annotated[VmConfig, Body()]) -> VmCreateResponse:
flake_attrs = await get_attrs(vm.flake_url)

View File

@@ -11,26 +11,29 @@ from typing import Iterator
# XXX: can we dynamically load this using nix develop?
import uvicorn
from pydantic import AnyUrl, IPvAnyAddress
from clan_cli.errors import ClanError
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):
try:
urllib.request.urlopen(base_url + "/health")
break
except OSError:
time.sleep(i)
url = f"{base_url}/{sub_url.removeprefix('/')}"
url = AnyUrl(f"{base_url}/{sub_url.removeprefix('/')}")
_open_browser(url)
def _open_browser(url: str) -> subprocess.Popen:
def _open_browser(url: AnyUrl) -> subprocess.Popen:
for browser in ("firefox", "iceweasel", "iceape", "seamonkey"):
if shutil.which(browser):
# Do not add a new profile, as it will break in combination with
# the -kiosk flag.
cmd = [
browser,
"-kiosk",
@@ -46,7 +49,7 @@ def _open_browser(url: str) -> subprocess.Popen:
@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...")
path = Path(__file__).parent.parent.parent.parent / "ui"
with subprocess.Popen(
@@ -59,7 +62,7 @@ def spawn_node_dev_server(host: str, port: int) -> Iterator[None]:
"dev",
"--",
"--hostname",
host,
str(host),
"--port",
str(port),
],

View File

@@ -31,6 +31,7 @@
, qemu
, gnupg
, e2fsprogs
, mypy
}:
let
@@ -65,6 +66,7 @@ let
rsync
sops
git
mypy
qemu
e2fsprogs
];

View File

@@ -2,12 +2,13 @@ import fileinput
import shutil
import tempfile
from pathlib import Path
from typing import Iterator
from typing import Iterator, NamedTuple
import pytest
from root import CLAN_CORE
from clan_cli.dirs import nixpkgs_source
from clan_cli.flakes.types import FlakeName
# substitutes string sin a file.
@@ -27,22 +28,27 @@ def substitute(
print(line, end="")
class TestFlake(NamedTuple):
name: FlakeName
path: Path
def create_flake(
monkeypatch: pytest.MonkeyPatch,
name: str,
flake_name: FlakeName,
clan_core_flake: Path | None = None,
machines: list[str] = [],
remote: bool = False,
) -> Iterator[Path]:
) -> Iterator[TestFlake]:
"""
Creates a flake with the given name and 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
with tempfile.TemporaryDirectory() as tmpdir_:
home = Path(tmpdir_)
flake = home / name
flake = home / flake_name
shutil.copytree(template, flake)
# lookup the requested machines in ./test_machines and include them
if machines:
@@ -60,31 +66,35 @@ def create_flake(
with tempfile.TemporaryDirectory() as workdir:
monkeypatch.chdir(workdir)
monkeypatch.setenv("HOME", str(home))
yield flake
yield TestFlake(flake_name, flake)
else:
monkeypatch.chdir(flake)
monkeypatch.setenv("HOME", str(home))
yield flake
yield TestFlake(flake_name, flake)
@pytest.fixture
def test_flake(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
yield from create_flake(monkeypatch, "test_flake")
def test_flake(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestFlake]:
yield from create_flake(monkeypatch, FlakeName("test_flake"))
@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():
raise Exception(
"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
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():
raise Exception(
"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
)

View File

@@ -1,3 +1,4 @@
import os
import tempfile
from pathlib import Path
from typing import Iterator
@@ -7,5 +8,11 @@ import pytest
@pytest.fixture
def temporary_dir() -> Iterator[Path]:
with tempfile.TemporaryDirectory(prefix="pytest-") as dirpath:
yield Path(dirpath)
if os.getenv("TEST_KEEP_TEMPORARY_DIR"):
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)

View File

@@ -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()

View File

@@ -3,6 +3,7 @@ import subprocess
from pathlib import Path
import pytest
from api import TestClient
from cli import Cli
@@ -11,6 +12,25 @@ def cli() -> 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
def test_create_flake(
monkeypatch: pytest.MonkeyPatch,
@@ -19,8 +39,11 @@ def test_create_flake(
cli: Cli,
) -> None:
monkeypatch.chdir(temporary_dir)
cli.run(["create"])
assert (temporary_dir / ".clan-flake").exists()
flake_dir = temporary_dir / "flake_dir"
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"])
capsys.readouterr() # flush cache
cli.run(["machines", "list"])

View File

@@ -2,7 +2,7 @@ from pathlib import Path
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
@@ -11,12 +11,12 @@ def test_get_clan_flake_toplevel(
) -> None:
monkeypatch.chdir(temporary_dir)
with pytest.raises(ClanError):
print(get_clan_flake_toplevel())
print(_get_clan_flake_toplevel())
(temporary_dir / ".git").touch()
assert get_clan_flake_toplevel() == temporary_dir
assert _get_clan_flake_toplevel() == temporary_dir
subdir = temporary_dir / "subdir"
subdir.mkdir()
monkeypatch.chdir(subdir)
(subdir / ".clan-flake").touch()
assert get_clan_flake_toplevel() == subdir
assert _get_clan_flake_toplevel() == subdir

View File

@@ -1,8 +1,8 @@
from pathlib import Path
from fixtures_flakes import TestFlake
from clan_cli.config import machine
def test_schema_for_machine(test_flake: Path) -> None:
schema = machine.schema_for_machine("machine1", test_flake)
def test_schema_for_machine(test_flake: TestFlake) -> None:
schema = machine.schema_for_machine(test_flake.name, "machine1")
assert "properties" in schema

View File

@@ -1,8 +1,8 @@
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from cli import Cli
from fixtures_flakes import TestFlake
from clan_cli.machines.facts import machine_get_fact
from clan_cli.secrets.folders import sops_secrets_folder
@@ -15,21 +15,27 @@ if TYPE_CHECKING:
@pytest.mark.impure
def test_generate_secret(
monkeypatch: pytest.MonkeyPatch,
test_flake_with_core: Path,
test_flake_with_core: TestFlake,
age_keys: list["KeyPair"],
) -> None:
monkeypatch.chdir(test_flake_with_core)
monkeypatch.chdir(test_flake_with_core.path)
monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey)
cli = Cli()
cli.run(["secrets", "users", "add", "user1", age_keys[0].pubkey])
cli.run(["secrets", "generate", "vm1"])
has_secret("vm1-age.key")
has_secret("vm1-zerotier-identity-secret")
network_id = machine_get_fact("vm1", "zerotier-network-id")
has_secret(test_flake_with_core.name, "vm1-age.key")
has_secret(test_flake_with_core.name, "vm1-zerotier-identity-secret")
network_id = machine_get_fact(
test_flake_with_core.name, "vm1", "zerotier-network-id"
)
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 = (
sops_secrets_folder()
sops_secrets_folder(test_flake_with_core.name)
.joinpath("vm1-zerotier-identity-secret")
.joinpath("secret")
)
@@ -42,7 +48,7 @@ def test_generate_secret(
assert identity_secret.lstat().st_mtime_ns == secret1_mtime
machine_path = (
sops_secrets_folder()
sops_secrets_folder(test_flake_with_core.name)
.joinpath("vm1-zerotier-identity-secret")
.joinpath("machines")
.joinpath("vm1")

View File

@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from cli import Cli
from fixtures_flakes import TestFlake
from clan_cli.machines.facts import machine_get_fact
from clan_cli.nix import nix_shell
@@ -12,11 +13,11 @@ from clan_cli.ssh import HostGroup
@pytest.mark.impure
def test_upload_secret(
monkeypatch: pytest.MonkeyPatch,
test_flake_with_core_and_pass: Path,
test_flake_with_core_and_pass: TestFlake,
temporary_dir: Path,
host_group: HostGroup,
) -> None:
monkeypatch.chdir(test_flake_with_core_and_pass)
monkeypatch.chdir(test_flake_with_core_and_pass.path)
gnupghome = temporary_dir / "gpg"
gnupghome.mkdir(mode=0o700)
monkeypatch.setenv("GNUPGHOME", str(gnupghome))
@@ -39,7 +40,9 @@ def test_upload_secret(
)
subprocess.run(nix_shell(["pass"], ["pass", "init", "test@local"]), check=True)
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
identity_secret = (
temporary_dir / "pass" / "machines" / "vm1" / "zerotier-identity-secret.gpg"
@@ -50,13 +53,13 @@ def test_upload_secret(
cli.run(["secrets", "generate", "vm1"])
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]
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)
flake.write_text(new_text)
cli.run(["secrets", "upload", "vm1"])
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()

View File

@@ -10,7 +10,8 @@ def test_inspect(api: TestClient, test_flake_with_core: Path) -> None:
"/api/vms/inspect",
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"]
assert config.get("flake_attr") == "vm1"
assert config.get("cores") == 1
@@ -26,4 +27,4 @@ def test_incorrect_uuid(api: TestClient) -> None:
for endpoint in uuid_endpoints:
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}"

View File

@@ -5,19 +5,21 @@ from typing import TYPE_CHECKING, Iterator
import pytest
from api import TestClient
from cli import Cli
from fixtures_flakes import create_flake
from fixtures_flakes import TestFlake, create_flake
from httpx import SyncByteStream
from root import CLAN_CORE
from clan_cli.flakes.types import FlakeName
if TYPE_CHECKING:
from age_keys import KeyPair
@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(
monkeypatch,
"test_flake_with_core_dynamic_machines",
FlakeName("test_flake_with_core_dynamic_machines"),
CLAN_CORE,
machines=["vm_with_secrets"],
)
@@ -26,10 +28,10 @@ def flake_with_vm_with_secrets(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path
@pytest.fixture
def remote_flake_with_vm_without_secrets(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Path]:
) -> Iterator[TestFlake]:
yield from create_flake(
monkeypatch,
"test_flake_with_core_dynamic_machines",
FlakeName("test_flake_with_core_dynamic_machines"),
CLAN_CORE,
machines=["vm_without_secrets"],
remote=True,
@@ -74,8 +76,9 @@ def generic_create_vm_test(api: TestClient, flake: Path, vm: str) -> None:
print(line.decode("utf-8"))
print("=========END 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")
print("Finished Get /api/vms/{uuid}/status")
assert response.status_code == 200, "Failed to get vm status"
data = response.json()
assert (

View File

@@ -1,7 +1,7 @@
{ ... }: {
imports = [
./clan-cli/flake-module.nix
./installer/flake-module.nix
./ui/flake-module.nix
./theme/flake-module.nix
];
@@ -9,7 +9,7 @@
perSystem = { pkgs, config, ... }: {
packages = {
tea-create-pr = pkgs.callPackage ./tea-create-pr { };
zerotier-members = pkgs.callPackage ./zerotier-members { };
merge-after-ci = pkgs.callPackage ./merge-after-ci {
inherit (config.packages) tea-create-pr;
};

View File

@@ -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; [ ];
};
}

View File

@@ -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;
}

View File

@@ -20,29 +20,30 @@ export const config: PaletteConfig = {
* Steps are defined in 'tones'
*/
baseColors: {
neutral: {
keyColor: "#92898a",
tones: [2, 5, 8, 92, 95, 98],
},
green: {
keyColor: "#7AC51B",
tones: [98],
tones: [2, 98],
},
yellow: {
keyColor: "#E0E01F",
tones: [2, 98],
},
purple: {
keyColor: "#661bc5",
tones: [],
},
neutral: {
keyColor: "#807788",
tones: [2, 5, 8, 98],
tones: [2, 98],
},
red: {
keyColor: "#e82439",
tones: [95],
},
yellow: {
keyColor: "#E0E01F",
tones: [98],
},
blue: {
keyColor: "#1B7AC5",
tones: [95],
tones: [5, 95],
},
},

View File

@@ -1,5 +1,5 @@
{ fetchzip }:
fetchzip {
url = "https://git.clan.lol/api/packages/clan/generic/ui/044pm5casi89nrbzp06l2akn797cdjcj49yyf495fspqfya3kxvz/assets.tar.gz";
sha256 = "044pm5casi89nrbzp06l2akn797cdjcj49yyf495fspqfya3kxvz";
url = "https://git.clan.lol/api/packages/clan/generic/ui/1xk9if1sykv2kcv3zn0dxn7gd7hlqjbhzz6hrsw0yiksf03skg9r/assets.tar.gz";
sha256 = "1xk9if1sykv2kcv3zn0dxn7gd7hlqjbhzz6hrsw0yiksf03skg9r";
}

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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

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