├── .gitignore
├── README.md
├── contrib
└── pve-configure.sh
├── default.nix
├── examples
├── proxmox-info-example.nix
├── proxmox-profile.nix
├── proxmox-uefi.nix
├── srht-example.nix
└── trivial-proxmox.nix
├── images
├── configuration.nix
├── nixpart-print-format.patch
└── nixpart-silence-sanity-check.patch
├── meta
└── nixcon_2020
│ ├── Makefile
│ ├── talk.md
│ ├── talk.md.handout.pdf
│ └── talk.md.slides.pdf
├── nixops_proxmox
├── __init__.py
├── backends
│ ├── __init__.py
│ ├── options.py
│ └── proxmox.py
├── nix
│ ├── default.nix
│ └── proxmox.nix
├── plugin.py
└── proxmox_utils.py
├── overrides.nix
├── poetry.lock
├── pyproject.toml
├── release.nix
├── setup.cfg
└── shell.nix
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | .coverage
3 | coverage.xml
4 | examples/*.json
5 | examples/*.lock
6 | examples/*.nixops*
7 | html/
8 | result
9 | tags
10 | tests/test.nixops*
11 | examples/proxmox-info.nix
12 | images/image
13 | .mypy_cache
14 |
15 | syntax: glob
16 | .idea
17 | *egg-info
18 | *.log
19 | *.txt
20 | *.pyc
21 | *.swp
22 | *.csv
23 | *.out
24 | *.tar
25 | *.bkp
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NixOps plugin for Proxmox (DEPRECATED)
2 |
3 | **2023 update** : This project is now deprecated. I moved away from Proxmox and NixOps because it is difficult to maintain the desired guarantees in those ecosystems with the way Nixpkgs is moving.
4 | If you are still interested into professional solutions for Proxmox with NixOS, please reach out to me, I have much better ideas on how to achieve interesting results with Proxmox and declarative state, unfortunately, it is hard to work on nixops-proxmox *and* NixOps (and sometimes even *Proxmox*!) at the same time. I decided to go for https://github.com/astro/microvm.nix for the future of my infrastructure while taking the problem in the other direction: make Nix expressions easy to manipulate from a web UI rather than making state be manipulated by Nix expression and an diffing engine.
5 |
6 | This plugin enable you to deploy and provision NixOS machines on a Proxmox node, with full control over the parameters of the virtual machine.
7 |
8 | **Warning** : It is highly unstable and being developed right now. You can see what's lacking at the bottom of this README. Do not use in production.
9 |
10 | **2020 NixCon** : A demo will be available shorty after the talk, here and on the talk URL:
11 |
12 | # Instructions
13 |
14 | You would have to copy a `proxmox-info-example.nix` file to tailor to your Proxmox cluster.
15 |
16 | Then, you can try any example which relies on a `proxmox-info.nix` using classical `nixops deploy`.
17 |
18 | Destroy is mostly safe in the sense it will destroy only the created VMID.
19 |
20 | # Hacking on it
21 |
22 | ```shell
23 | nix-shell shell.nix # Get you in a shell with most of the dependencies.
24 | poetry shell # Get you what you need.
25 | nixops list-plugins # Ensure, proxmox is listed here.
26 | # Go hack on it!
27 | ```
28 |
29 | # TODO
30 |
31 | **Nice to have but unknown** : Skip the install phase and copy closure on `/mnt` directly from the live CD, so that we directly reboot on NixOS.
32 |
33 | **High priority** :
34 |
35 | - Investigate broken / dead states in post-installation phase (mostly due to SSH stuff)
36 | - Better debugging
37 | - Fix SSH authentication: broken `pvesh` when giving too much arguments.
38 | - A better partitioning mechanism (nixpart is broken atm, blivet is hard to package in NixOS, etc.)
39 | - Automatic `fileSystems` generation through a better partitioning mechanism (<3)
40 | - Support for full disk encryption (through `autoLuks` for example).
41 | - Better IPv6/IPv4 selection: currently, it prefers IPv6 over IPv4 but it'd be nice to attempt to reach the VM.
42 | - A real physical specification.
43 | - Sound RESCUE/UP state machine.
44 | - RESCUE operations: resize partitions, repair bootloader, etc.
45 |
46 | **Medium priority** :
47 |
48 | - Ensure that all authentication methods are working fine.
49 | - Backups & snapshots.
50 |
51 | **Low priority** :
52 |
53 | - Containers support (`proxmox-ct` machine definition)
54 | - Testing over a Proxmox test node.
55 |
--------------------------------------------------------------------------------
/contrib/pve-configure.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env nix-shell
2 | #! nix-shell -i bash -p dasel
3 | # TODO: finish me.
4 | CONFIGURATION_FILE=${PROXMOX_CREDENTIALS_FILE:-$XDG_CONFIG_HOME/proxmox/credentials}
5 | function put_string() {
6 | dasel put string -p toml -f "${CONFIGURATION_FILE}" "$@"
7 | }
8 |
9 | function configure_username_and_password() {
10 | local profile_name="$1"
11 | read -p "Enter the username: " username
12 | put_string ".${profile_name}.username" $username
13 | read -p "Enter the password: " -s password
14 | put_string ".${profile_name}.password" $password
15 | }
16 |
17 | function configure_node() {
18 | echo "TODO"
19 | }
20 |
21 | function configure_pool() {
22 | echo "TODO"
23 | }
24 |
25 | function configure_token() {
26 | echo "test"
27 | }
28 |
29 | function configure_server_url() {
30 | local profile_name="$1"
31 | echo -ne "Enter the server URL in the format: protocol://url:port\n"
32 | read server_url
33 | put_string ".${profile_name}.server_url" $server_url
34 | }
35 |
36 | function ensure_credentials_file_exists() {
37 | local config_dir=$(dirname "${CONFIGURATION_FILE}")
38 | if [ ! -d $config_dir ] ;
39 | then
40 | echo "Credentials directory did not exist, creating the directories..."
41 | mkdir -p $config_dir
42 | chmod -R 700 $config_dir
43 | fi
44 |
45 | if [ ! -f $CONFIGURATION_FILE ] ;
46 | then
47 | echo "Credentials file do not exist, creating the file..."
48 | touch $CONFIGURATION_FILE
49 | chmod 600 $CONFIGURATION_FILE
50 | fi
51 | }
52 |
53 | function ensure_right_permissions_for_credentials() {
54 | # test if credentials file is rw for user only.
55 | echo "TODO: verify permissions"
56 | }
57 |
58 | function profile_exists() {
59 | echo "TODO: check if profile exists"
60 | false
61 | }
62 |
63 | function configure_profile() {
64 | ensure_credentials_file_exists
65 | ensure_right_permissions_for_credentials
66 | read -p "Enter the profile name: " profile_name
67 | if profile_exists $profile_name ;
68 | then
69 | echo "This profile already exists! Please delete it."
70 | exit 1
71 | fi
72 | configure_server_url $profile_name
73 | echo -n "
74 | Which authentication method do you want to use?
75 | 1) Username and password
76 | 2) Token
77 | 3) SSH
78 | Choose an option: "
79 | read choice
80 | case $choice in
81 | 1) configure_username_and_password $profile_name ;;
82 | 2) configure_token $profile_name ;;
83 | 3) configure_ssh $profile_name ;;
84 | *) echo -e "Wrong option." ;;
85 | esac
86 | echo "Profile configured in ${CONFIGURATION_FILE}!"
87 | }
88 |
89 | configure_profile
90 |
--------------------------------------------------------------------------------
/default.nix:
--------------------------------------------------------------------------------
1 | { pkgs ? import {} }:
2 | let
3 | overrides = import ./overrides.nix { inherit pkgs; };
4 | in pkgs.poetry2nix.mkPoetryApplication {
5 | projectDir = ./.;
6 | overrides = pkgs.poetry2nix.overrides.withDefaults overrides;
7 | meta.description = "Nix package for ${pkgs.stdenv.system}";
8 | }
9 |
--------------------------------------------------------------------------------
/examples/proxmox-info-example.nix:
--------------------------------------------------------------------------------
1 | { config, pkgs, ... }:
2 | {
3 | deployment.proxmox = {
4 | serverUrl = "proxmox.example.com";
5 | username = "root@pam";
6 | password = "...";
7 | node = "node1";
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/examples/proxmox-profile.nix:
--------------------------------------------------------------------------------
1 | { config, pkgs, ... }:
2 | {
3 | imports = [ ];
4 | deployment.proxmox = {
5 | profile = "default";
6 | node = "askeladd"; # TODO: move me in profile.
7 | pool = "demo-pool"; # TODO: move me in profile.
8 | uefi = {
9 | enable = true;
10 | volume = "sata-vmdata";
11 | };
12 | network = [
13 | ({ bridge = "vmbr2"; tag = 400; })
14 | ({ bridge = "vmbr2"; tag = 300; })
15 | ];
16 | installISO = "local:iso/Raito-NixOS.iso";
17 | };
18 |
19 | boot.loader.systemd-boot.enable = true;
20 | boot.loader.efi.canTouchEfiVariables = true;
21 | }
22 |
--------------------------------------------------------------------------------
/examples/proxmox-uefi.nix:
--------------------------------------------------------------------------------
1 | { config, pkgs, ... }:
2 | {
3 | boot.loader.systemd-boot.enable = true;
4 | boot.loader.efi.canTouchEfiVariables = true;
5 | }
6 |
--------------------------------------------------------------------------------
/examples/srht-example.nix:
--------------------------------------------------------------------------------
1 | {
2 | network.description = "sr.ht network on Proxmox"; # This is mainly inspired and built on the top of the work eadwu, please thank him !!
3 |
4 | git = { ... }: {
5 | imports = [ ./sourcehut/git.nix ];
6 | };
7 |
8 | hg = { ... }: {
9 | imports = [ ./sourcehut/hg.nix ];
10 | };
11 |
12 | man = { ... }: {
13 | imports = [ ./sourcehut/meta.nix ];
14 | };
15 |
16 | paste = { ... }: {
17 | imports = [ ./sourcehut/paste.nix ];
18 | };
19 |
20 | todo = { ... }: {
21 | imports = [ ./sourcehut/todo.nix ];
22 | };
23 |
24 | meta = { ... }: {
25 | imports = [ ./sourcehut/meta.nix ];
26 | services.sourcehut.settings = {
27 | "meta.sr.ht::settings".registration = "no";
28 | };
29 | };
30 |
31 | defaults = { config, pkgs, ... }: {
32 | imports = [ ./proxmox-info.nix ./proxmox-uefi.nix ];
33 | deployment.targetEnv = "proxmox";
34 |
35 | deployment.proxmox = {
36 | nbCores = mkDefault 2;
37 | memory = mkDefault 512;
38 | disks = [
39 | ({ volume = "sata-vmdata"; size="15G"; }) # Change it to your preferred volume.
40 | ];
41 | partitions = ''
42 | set -x
43 | wipefs -f /dev/sda
44 |
45 | parted --script /dev/sda -- mklabel gpt
46 | parted --script /dev/sda -- mkpart primary fat32 1MiB 1024MiB
47 | parted --script /dev/sda -- mkpart primary btrfs 1024MiB -1GiB
48 | parted --script /dev/sda -- mkpart primary linux-swap -1GiB 100%
49 | parted --script /dev/sda -- set 1 boot on
50 |
51 | sleep 0.5
52 |
53 | mkfs.vfat /dev/sda1 -n NIXBOOT
54 | mkfs.btrfs /dev/sda2 -f -L nixroot
55 | mkswap /dev/sda3 -L nixswap
56 |
57 | swapon /dev/sda3
58 | mount -t btrfs -o defaults,compress=zstd /dev/sda2 /mnt
59 | mkdir -p /mnt/boot
60 | mount /dev/sda1 /mnt/boot
61 | '';
62 | };
63 |
64 | fileSystems = {
65 | "/" = {
66 | device = "/dev/sda2";
67 | fsType = "btrfs";
68 | options = [ "compress=zstd" "space_cache" "noatime" ];
69 | };
70 | "/boot" = {
71 | device = "/dev/sda1";
72 | fsType = "vfat";
73 | };
74 | swapDevices = [ { device = "/dev/sda3"; } ];
75 | };
76 |
77 | networking.firewall.allowedTCPPorts = [ 80 443 ];
78 |
79 | services.nginx = {
80 | enable = true;
81 | recommendedTlsSettings = true;
82 | recommendedOptimisation = true;
83 | recommendedGzipSettings = true;
84 | recommendedProxySettings = true;
85 | };
86 |
87 | services.sourcehut.enable = true;
88 | services.sourcehut.settings = {
89 | "sr.ht".site-name = "sourcehut demo on Proxmox";
90 | "sr.ht".site-info = config.services.sourcehut.settings."meta.sr.ht".origin;
91 | "sr.ht".site-blurb = "the demonstration";
92 | "sr.ht".environment = "production";
93 | "sr.ht".owner-name = "Raito";
94 | "sr.ht".owner-email = "";
95 |
96 | # nix run nixos.pwgen -c "pwgen -s 32 1"
97 | "sr.ht".secret-key = "IAmNotASecretKey";
98 | webhooks.private-key = "";
99 |
100 | "git.sr.ht".origin = "";
101 | "hg.sr.ht".origin = "";
102 | "man.sr.ht".origin = "";
103 | "paste.sr.ht".origin = "";
104 | "todo.sr.ht".origin = "";
105 | "meta.sr.ht".origin = "";
106 | };
107 | };
108 | }
109 |
--------------------------------------------------------------------------------
/examples/trivial-proxmox.nix:
--------------------------------------------------------------------------------
1 | {
2 | network.description = "Proxmox deployment test";
3 | machine =
4 | let
5 | mkSubvolume = mp: name: "btrfs subvolume create ${mp}/${name}";
6 | in
7 | { imports = [ ./proxmox-profile.nix ./proxmox-uefi.nix ];
8 |
9 | networking.hostName = "nixops-success";
10 |
11 | deployment.targetEnv = "proxmox";
12 | deployment.proxmox.disks = [
13 | ({
14 | volume = "local-lvm";
15 | size = "20G";
16 | enableSSDEmulation = true;
17 | enableDiscard = true;
18 | })
19 | ];
20 | fileSystems = {
21 | "/" = {
22 | device = "/dev/sda2";
23 | fsType = "btrfs";
24 | options = [ "subvol=root" "ssd" "compress=zstd" "discard" "space_cache" "noatime" ];
25 | };
26 | "/boot" = {
27 | device = "/dev/sda1";
28 | fsType = "vfat";
29 | };
30 | };
31 | swapDevices = [
32 | { device = "/dev/sda3"; }
33 | ];
34 | deployment.proxmox.partitions = ''
35 | set -x
36 | set -e
37 | wipefs -f /dev/sda
38 |
39 | parted --script /dev/sda -- mklabel gpt
40 | parted --script /dev/sda -- mkpart primary fat32 1MiB 1024MiB
41 | parted --script /dev/sda -- mkpart primary btrfs 1024MiB -2GiB
42 | parted --script /dev/sda -- mkpart primary linux-swap -2GiB 100%
43 | parted --script /dev/sda -- set 1 boot on
44 |
45 | sleep 0.5
46 |
47 | mkfs.vfat /dev/sda1 -n NIXBOOT
48 | mkfs.btrfs /dev/sda2 -f -L nixroot
49 | mkswap /dev/sda3 -L nixswap
50 | swapon /dev/sda3
51 |
52 | MOUNTDIR=$(mktemp -d)
53 | mount -t btrfs -o defaults,ssd,compress=zstd /dev/sda2 $MOUNTDIR
54 |
55 | ${mkSubvolume "$MOUNTDIR" "root"}
56 | umount -R $MOUNTDIR
57 | mount -t btrfs -o defaults,ssd,compress=zstd,subvol=root /dev/sda2 /mnt
58 |
59 | mkdir -p /mnt/boot
60 | mount /dev/sda1 /mnt/boot
61 |
62 | ${mkSubvolume "/mnt" "nix"}
63 | ${mkSubvolume "/mnt" "home"}
64 | ${mkSubvolume "/mnt" "var"}
65 | ${mkSubvolume "/mnt" "etc"}
66 | '';
67 | deployment.proxmox.memory = 2048;
68 | };
69 | }
70 |
--------------------------------------------------------------------------------
/images/configuration.nix:
--------------------------------------------------------------------------------
1 | { pkgs, lib, ... }:
2 | {
3 | imports = [
4 |
5 |
6 | ];
7 | boot.kernelParams = [
8 | "console=ttyS0"
9 | ];
10 |
11 | # TODO: if nixpart project gets ready, then well.
12 | # environment.systemPackages = with pkgs; [ (python2Packages.nixpart0.overrideAttrs (attrs: {
13 | # patches = [ ./nixpart-silence-sanity-check.patch ./nixpart-print-format.patch ];
14 | # }))
15 | # ];
16 |
17 | # Enable QEMU Agent
18 | # FIXME(Ryan): Replace it by upstream once #113909 is fixed.
19 | services.udev.extraRules = ''
20 | SUBSYSTEM=="virtio-ports", ATTR{name}=="org.qemu.guest_agent.0", TAG+="systemd" ENV{SYSTEMD_WANTS}="qemu-guest-agent.service"
21 | '';
22 | systemd.services.qemu-guest-agent = {
23 | description = "Run the QEMU Guest Agent";
24 | serviceConfig = {
25 | RuntimeDirectory = "qemu-ga";
26 | ExecStart = "${pkgs.qemu.ga}/bin/qemu-ga -t /var/run/qemu-ga";
27 | Restart = "always";
28 | RestartSec = 0;
29 | };
30 | };
31 |
32 | services.sshd.enable = true;
33 | networking.firewall.allowedTCPPorts = [ 22 ];
34 | services.getty.autologinUser = lib.mkDefault "root";
35 | }
36 |
--------------------------------------------------------------------------------
/images/nixpart-print-format.patch:
--------------------------------------------------------------------------------
1 | diff --git a/nixkickstart.py b/nixkickstart.py
2 | index 7d58d69..c13e138 100644
3 | --- a/nixkickstart.py
4 | +++ b/nixkickstart.py
5 | @@ -755,6 +755,7 @@ class RaidData(commands.raid.F18_RaidData):
6 | fsprofile=self.fsprofile,
7 | mountpoint=self.mountpoint,
8 | mountopts=self.fsopts)
9 | + print(kwargs["format"])
10 | if not kwargs["format"].type:
11 | raise KickstartValueError, formatErrorMsg(self.lineno, msg="The \"%s\" filesystem type is not supported." % type)
12 |
13 | --
14 |
--------------------------------------------------------------------------------
/images/nixpart-silence-sanity-check.patch:
--------------------------------------------------------------------------------
1 | diff -u nixpart-0.4.1/nixkickstart.py nixpart-0.4.1b/nixkickstart.py
2 | --- nixpart-0.4.1/nixkickstart.py 2013-08-02 13:18:40.000000000 +0800
3 | +++ nixpart-0.4.1b/nixkickstart.py 2015-11-26 10:40:35.000000000 +0800
4 | @@ -987,9 +987,6 @@
5 | self.handler.btrfs.execute(self.storage, self.handler)
6 |
7 | def partition(self):
8 | - errors, warnings = self.storage.sanityCheck()
9 | - if errors:
10 | - raise PartitioningError("\n".join(errors))
11 | self.storage.doIt()
12 |
13 | def force_device_exists(self, device, child=None):
14 |
--------------------------------------------------------------------------------
/meta/nixcon_2020/Makefile:
--------------------------------------------------------------------------------
1 | SLIDES := $(patsubst %.md,%.md.slides.pdf,$(wildcard *.md))
2 | HANDOUTS := $(patsubst %.md,%.md.handout.pdf,$(wildcard *.md))
3 |
4 | all : $(SLIDES) $(HANDOUTS)
5 |
6 | %.md.slides.pdf : %.md
7 | pandoc $^ -t beamer --slide-level 2 -o $@
8 |
9 | %.md.handout.pdf : %.md
10 | pandoc $^ -t beamer --slide-level 2 -V handout -o $@
11 | pdfjam $@ --nup 1x2 --no-landscape --keepinfo \
12 | --paper letterpaper --frame true --scale 0.9 \
13 | --suffix "nup"
14 | mv $*.md.handout-nup.pdf $@
15 |
16 |
17 | clobber :
18 | rm -f $(SLIDES)
19 | rm -f $(HANDOUTS)
20 |
--------------------------------------------------------------------------------
/meta/nixcon_2020/talk.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: NixOps for Proxmox
3 | author: \textsc{Ryan Lahfa} (ryan at lahfa dot xyz)
4 | theme: metropolis
5 | date: "17 October 2020"
6 | ---
7 |
8 | # Plan
9 |
10 | Before everything, the talk will be uploaded on ^[With the code of this piece of software!]
11 |
12 | > - What is Proxmox?
13 | > - What is NixOps and what are backends in NixOps?
14 | > - A Proxmox backend for NixOps: `nixops_proxmox`
15 | > - Challenges encountered
16 | > - Future work
17 | > - Demonstration: deploying your sr.ht on multiple virtual machines on Proxmox^[The demonstration will be available separately and it is not part of the main talk for length reasons]
18 |
19 | # What is Proxmox?
20 |
21 | ## Overview
22 |
23 | Proxmox is a Linux distribution tailored to host virtual machines and containers on the top of a bare-metal machine, using KVM/QEMU.
24 | It comes with a nice web UI and CLI/API, handles **a part of** the networking and storage for you. It supports highly available (multi-cluster) or hyper-converged (Ceph) design.
25 |
26 | It can be effectively used as a replacement for managing a (very) small to large ^[I have no exact numbers on who uses Proxmox beyond 100 physical machines and 1000 VMs] inventories.
27 |
28 | # What is NixOps?
29 |
30 | ## Overview
31 |
32 | NixOps is a Python application which aims to provide deployment primitives based on Nix expressions, external APIs, external resources, and NixOS. It is designed to be modular.
33 |
34 | For example, NixOps has support for AWS (virtual private network, elastic IP, EC2, …) or Hetzner (machines). Those plugins which NixOps can use are called 'backends' — we will see more about this in the next slide.
35 |
36 | ## Backends
37 |
38 | As said, there are multiple backends in NixOps currently:
39 |
40 | - AWS
41 | - GCP
42 | - Azure (broken in the newest versions)
43 | - Hetzner
44 | - libvirtd (wrapper around QEMU/KVM)
45 | - VirtualBox
46 | - None (it is a trivial backend which consist in supposing that the target is a NixOS machine and ignoring the meta-details, you still need to provide a baseline configuration for networking, disks, etc.)
47 |
48 | ## What does a backend?
49 |
50 | A backend implements a part of a NixOps interface and bring a definition (think a Nix expression) into reality. For example, if you add a disk in your definition, the backend will notice this change and use the API to create a disk, then attach it whether you specified to attach it.
51 |
52 | . . .
53 |
54 | ```nix
55 | {
56 | machine =
57 | { imports = [ ./ec2-info.nix ];
58 | deployment.targetEnv = "ec2";
59 | deployment.ec2.region = "us-east-1";
60 | deployment.ec2.instanceType = "t2.medium";
61 | };
62 | }
63 | ```
64 |
65 |
66 | # NixOps + Proxmox = <3
67 |
68 | ## Self-hosted mini-cloud
69 |
70 | As NixOps has no real backend for private infrastructure^[None does not solve the problem of provisioning at the start your virtual machine.], I grown tired of installing NixOS manually myself all the time and decided to write a NixOps backend in 48 hours^[Hence, the alpha quality!].
71 |
72 | As a result, the initial proof of concept offered me a way to trivially deploy NixOS nodes on my Proxmox cluster:
73 |
74 | - private actual mini-cloud ;
75 | - more or less declarative (see the 'future work' part) ;
76 | - extensions are possible using NixOps extra plugins: handle storage backend as resources, handle disks as resources and even more.
77 |
78 | ## How it works: part 1
79 |
80 | It will perform those tasks (in order):
81 |
82 | - Create the selected disks with their size and storage backend (ZFS, NFS, etc.) ;
83 | - Create a virtual machine in Proxmox with the selected parameters (network interfaces, RAM, vCores, see `nixops_proxmox/nix/proxmox.nix` for details) ;
84 | - Attach the custom NixOS ISO (if it's the first startup) ;
85 | - Start it and wait until QEMU Agent is up ;
86 | - If it's in live CD, provision a temporary SSH key through the QEMU agent (which enable arbitrary read/write/exec without any real network stack) ;
87 | - It will wait for an (reachable) IP address ;
88 |
89 | ## How it works: part 2
90 |
91 | - If it's in live CD, it will wait for SSH, check if the partitioning script has changed and offer to repartition or not, then proceed to partitioning, then reboot if necessary ;
92 | - If it's in live CD and it has been partitioned after a reboot, it will write the initial configuration.nix after a `nixos-generate-config` and some changes and mounting all the partitions, then install NixOS, reboot ;
93 | - If it was not marked as installed yet, it will wait for the post-installation phase, reinstall a host key in our known hosts through QEMU Agent, then wait for SSH and mark it as installed ;
94 | - Then, it gives the hand to NixOps to do its magic.
95 |
96 |
97 | ## Challenge 1: IPv4 vs IPv6
98 |
99 | NixOps has no real native support for IPv6 as far as I know, moreover, it hardcodes stuff like this:
100 |
101 | . . .
102 |
103 | So I just decided to implement IPv6 support in my plugin (which is not really good, but shows that it is possible/usable and NixOps is indeed modular).
104 |
105 | ## Challenge 1: Extract from `nixops_proxmox`
106 |
107 | ```python
108 | ...
109 | ip_addresses = list(chain.from_iterable(
110 | map(lambda i: ip_address(i['ip-address']), if_['ip-addresses'])
111 | for name, if_ in net_ifs.items()
112 | if if_['ip-addresses'] and name != "lo"))
113 | private_ips = {str(ip) for ip in ip_addresses
114 | if ip.is_private and not ip.is_link_local}
115 | public_ips = {str(ip) for ip in ip_addresses
116 | if not ip.is_private}
117 | ip_v6 = {str(ip) for ip in ip_addresses
118 | if isinstance(ip, IPv6Address)}
119 | ip_v4 = {str(ip) for ip in ip_addresses
120 | if isinstance(ip, IPv4Address)}
121 | ...
122 | ```
123 |
124 | ## Challenge 1: How to get actual IPs
125 |
126 | ```python
127 | ...
128 | self.private_ipv4 = first_reachable_or_none(
129 | private_ips & ip_v4)
130 | self.public_ipv4 = first_reachable_or_none(
131 | public_ips & ip_v4)
132 | self.private_ipv6 = first_reachable_or_none(
133 | private_ips & ip_v6)
134 | self.public_ipv6 = first_reachable_or_none(
135 | public_ips & ip_v6)
136 | ...
137 | ```
138 |
139 | ## Challenge 2: How to select the right endpoint ?
140 |
141 | One issue with having private/public IPv4/IPv6 is to know which one you can reach and which one you cannot:
142 |
143 | . . .
144 |
145 | - Imagine you're in a café, no IPv6, you want to deploy or SSH into one of your machine, fortunately, you're on the VPN so you can reach the private IP, the backend has to guess it and select the appropriate IP ;
146 | - Imagine you're in a café, with IPv6, you're actually also on the VPN, the backend should determine the fastest endpoint, as the IPv6 could be also a tunnel in reality, and the VPN might be faster^[This is not a solved problem in my backend, but I'm open to comments regarding those ideas.] ;
147 |
148 | ## Challenge 3: (Not Very Declarative) Partitioning
149 |
150 | If you use Proxmox and not AWS/GCP/Azure/yourself, you will have to effectively encode your partitioning script, which could be for the worst or the better, a bit annoying and error-prone.
151 |
152 | . . .
153 |
154 | I looked for ways to simplify this, using Nixpart (which I will mention later) and other tooling but found out no reasonable and acceptable tool so I fall back on classical Bash and let user decide how he wants to do his thing.
155 |
156 | . . .
157 |
158 | Actually, this is not that bad, you just need to read the manual page until you understand you forget to set the right type for the EFI partition. :-)
159 |
160 | # Future work
161 |
162 | ## Current state: buggy but not that much
163 |
164 | The resulting plugin is usable, I use it, but is alpha-quality and has a lot of issue:
165 |
166 | - the partitioning is bothersome ;
167 | - the state machine between "rescue mode" and running/stopped is messy ;
168 | - the backend requires a custom NixOS image which enables the QEMU Agent (and sshd) ;
169 | - no tags support ;
170 | - no backups ;
171 | - no automatic node selection (you have to explicitly choose the node on which you deploy)
172 |
173 | ## Extending the work: Routing
174 |
175 | Proxmox is not a router, for example, it's quite difficult to add new VLAN, etc. It depends highly on your infrastructure behind your Proxmox nodes.
176 |
177 | . . .
178 |
179 | In my infrastructure, I use VyOS, which has an HTTP API and could be transformed into a NixOps resource, bringing the routing table to my Nix expressions !
180 | This is an experimentation that I'd very much like to perform.
181 |
182 | ## Extending the work: Declarative partitioning
183 |
184 | Declarative partitioning in the Nix community is not a solved problem as far as I know, some efforts have been made in the direction with .
185 |
186 | . . .
187 |
188 | Also, it is a required step to enable **easy** LUKS automatic decryption, which is the next point.
189 |
190 | ## Extending the work: Automatic full disk decryption
191 |
192 | Indeed, without declarative partitioning^[And also (proper) secrets management à la Vault by HashiCorp.], it's not super meaningful to reproduce full disk encryption and manage its lock/unlock lifecycle.
193 |
194 | . . .
195 |
196 | Thus, I want to experiment with fully encrypted guests with automatic decryption, using ^[Which I'm trying to package in NixOS, but didn't have time these months…].
197 |
198 | . . .
199 |
200 | It enables automatic full disk encryption by receiving PGP-encrypted passphrases by local **trusted** peers for which only you have the key, and can be deployed in a fully-meshed fashion to ensure reliability across a datacenter, without removing the manual passphrase entry feature^[a.k.a. escape hatch.].
201 |
202 | ## Conclusion : what can be learnt, what can be used
203 |
204 | One of my personal objectives is to see more offerings of private flexible infrastructures à la AWS, without having to use these infrastructures. I think that Nix(OS) is an important key towards achieving this goal.
205 |
206 | . . .
207 |
208 | NixOps is well-known to be the odd piece of software of the Nix community^[I can definitely agree with some pain points.], but I believe that it's definitely salvageable and can become a versatile piece of software.
209 |
210 | . . .
211 |
212 | If most of the future work would be implemented, it would enable IPv6-only, NixOS-only, fully declarative micro or mini datacenter, which would be definitely exciting for a first step to become independent from public clouds.
213 |
214 | # Thank you for watching, I will be available for your questions and don't hesitate to watch the demo if you find this interesting!
215 |
--------------------------------------------------------------------------------
/meta/nixcon_2020/talk.md.handout.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RaitoBezarius/nixops-proxmox/0f2375cadd7e3e66f4f20f4644e7132cddc49dbf/meta/nixcon_2020/talk.md.handout.pdf
--------------------------------------------------------------------------------
/meta/nixcon_2020/talk.md.slides.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RaitoBezarius/nixops-proxmox/0f2375cadd7e3e66f4f20f4644e7132cddc49dbf/meta/nixcon_2020/talk.md.slides.pdf
--------------------------------------------------------------------------------
/nixops_proxmox/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RaitoBezarius/nixops-proxmox/0f2375cadd7e3e66f4f20f4644e7132cddc49dbf/nixops_proxmox/__init__.py
--------------------------------------------------------------------------------
/nixops_proxmox/backends/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RaitoBezarius/nixops-proxmox/0f2375cadd7e3e66f4f20f4644e7132cddc49dbf/nixops_proxmox/backends/__init__.py
--------------------------------------------------------------------------------
/nixops_proxmox/backends/options.py:
--------------------------------------------------------------------------------
1 | from nixops.backends import MachineOptions
2 | from typing import Mapping, Union, Optional, Sequence
3 | from nixops.resources import ResourceOptions
4 | from typing_extensions import Literal
5 |
6 | class IPOptions(ResourceOptions):
7 | gateway: Optional[str]
8 | address: str
9 | prefixLength: Optional[int]
10 |
11 | class NetworkOptions(ResourceOptions):
12 | model: str
13 | bridge: str
14 | tag: Optional[int]
15 | trunks: Sequence[str]
16 | ip: Mapping[Union[Literal["v4"], Literal["v6"]],
17 | IPOptions]
18 |
19 | class DiskOptions(ResourceOptions):
20 | volume: str
21 | size: str
22 | aio: Optional[str]
23 | enableSSDEmulation: bool
24 | enableDiscard: bool
25 |
26 | class UefiOptions(ResourceOptions):
27 | enable: bool
28 | volume: str
29 |
30 | class ProxmoxOptions(ResourceOptions):
31 | profile: Optional[str]
32 | serverUrl: Optional[str]
33 | username: Optional[str]
34 | password: Optional[str]
35 | tokenName: Optional[str]
36 | tokenValue: Optional[str]
37 | useSSH: bool
38 | node: Optional[str]
39 | pool: Optional[str]
40 |
41 | partitions: str # Kickstart format.
42 | postPartitioningLocalCommands: Optional[str] # Fix up nixpart.
43 | network: Sequence[NetworkOptions]
44 | disks: Sequence[DiskOptions]
45 | uefi: UefiOptions
46 |
47 | nbCpus: int
48 | nbCores: int
49 | memory: int
50 |
51 | startOnBoot: bool
52 | protectVM: bool
53 | hotplugFeatures: Optional[str]
54 | cpuLimit: Optional[int]
55 | cpuUnits: Optional[int]
56 | cpuType: str
57 | arch: Optional[str]
58 | expertArgs: Optional[str]
59 |
60 | usePrivateIPAddress: bool
61 |
62 |
63 | class ProxmoxMachineOptions(MachineOptions):
64 | proxmox: ProxmoxOptions
65 |
--------------------------------------------------------------------------------
/nixops_proxmox/backends/proxmox.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import time
3 | from nixops.backends import MachineDefinition, MachineState
4 | from nixops.nix_expr import Function, Call, RawValue, py2nix
5 | from typing import Optional
6 | import nixops.known_hosts
7 | from ipaddress import ip_address, IPv4Address, IPv6Address
8 | from itertools import dropwhile, takewhile, chain
9 | import nixops_proxmox.proxmox_utils
10 | from proxmoxer.core import ResourceException
11 | from nixops.ssh_util import SSHCommandFailed, SSH
12 | import nixops.util
13 | from urllib.parse import quote
14 | from functools import partial
15 | from collections import defaultdict
16 | from .options import ProxmoxMachineOptions, DiskOptions, NetworkOptions, UefiOptions
17 |
18 | def to_prox_bool(b):
19 | return 1 if b else 0
20 |
21 | # TODO: remove the dependency on the machine logger
22 | # I'd like to code-reuse SSH/SSHMaster but that requires decoupling the logger.
23 | def try_ssh(user, ip, logger) -> bool:
24 | ssh = SSH(logger)
25 | ssh.register_host_fun(lambda: ip)
26 | ssh.register_flag_fun(lambda: ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"])
27 | # TODO: such hacky, wow.
28 | ssh.register_passwd_fun(lambda: "")
29 | try:
30 | ssh.run_command("true", user, logged=False, timeout=3)
31 | return True
32 | except Exception:
33 | return False
34 |
35 | def can_reach(logger, ip, user: str = "root", timeout: int = 10, callback = None):
36 | # TODO: in that case, we need to determine the correct link, is there a way?
37 | if ip_address(ip).is_link_local:
38 | return False
39 |
40 | return nixops.util.wait_for_success(partial(try_ssh, user, ip, logger), timeout, callback)
41 |
42 | def first_reachable_or_none(logger, S, user: str = "root", timeout_per_ip: int = 10, callback = None):
43 | for ip in S:
44 | logger.log("testing {}".format(ip))
45 | if can_reach(logger, ip, user, timeout_per_ip, callback):
46 | return ip
47 |
48 | profile_fields_mapping = {
49 | 'server_url': 'serverUrl',
50 | 'token_name': 'tokenName',
51 | 'token_value': 'tokenValue',
52 | 'use_ssh': 'useSSH'
53 | }
54 |
55 | class VirtualMachineDefinition(MachineDefinition):
56 | """Definition of a Proxmox VM"""
57 |
58 | config: ProxmoxMachineOptions
59 | profile: Optional[str]
60 | serverUrl: Optional[str]
61 |
62 | @classmethod
63 | def get_type(cls):
64 | return "proxmox"
65 |
66 | def __init__(self, name, config):
67 | super().__init__(name, config)
68 |
69 | for key in ('profile', 'serverUrl', 'username', 'tokenName',
70 | 'tokenValue', 'password', 'useSSH', 'disks',
71 | 'node', 'pool', 'nbCpus', 'nbCores', 'memory',
72 | 'startOnBoot', 'protectVM', 'hotplugFeatures',
73 | 'cpuLimit', 'cpuUnits', 'cpuType', 'arch',
74 | 'vmid',
75 | 'postPartitioningLocalCommands',
76 | 'partitions', 'expertArgs', 'installISO', 'network',
77 | 'uefi', 'useSSH', 'usePrivateIPAddress'):
78 | setattr(self, key, getattr(self.config.proxmox, key))
79 |
80 | def show_type(self):
81 | return "{0} [{1}]".format(self.get_type(), self.serverUrl)
82 |
83 | def host_key_type(self):
84 | return (
85 | "ed25519"
86 | if nixops.util.parse_nixos_version(self.config.nixosRelease) >= ["15", "09"]
87 | else "dsa"
88 | )
89 |
90 | class VirtualMachineState(MachineState[VirtualMachineDefinition]):
91 | """State of a Proxmox VM"""
92 |
93 | @classmethod
94 | def get_type(cls):
95 | return "proxmox"
96 |
97 | state = nixops.util.attr_property("state", MachineState.MISSING, int)
98 |
99 | public_ipv4 = nixops.util.attr_property("publicIPv4", None)
100 | public_ipv6 = nixops.util.attr_property("publicIPv6", None)
101 | private_ipv4 = nixops.util.attr_property("privateIPv4", None)
102 | private_ipv6 = nixops.util.attr_property("privateIPv6", None)
103 |
104 | public_dns_name = nixops.util.attr_property("publicDNSName", None)
105 |
106 | use_private_ip_address = nixops.util.attr_property(
107 | "proxmox.usePrivateIPAddress",
108 | False,
109 | type=bool
110 | )
111 |
112 | serverUrl = nixops.util.attr_property("proxmox.serverUrl", None)
113 | node = nixops.util.attr_property("proxmox.node", None)
114 | username = nixops.util.attr_property("proxmox.username", None)
115 | password = nixops.util.attr_property("proxmox.password", None)
116 |
117 | tokenName = nixops.util.attr_property("proxmox.tokenName", None)
118 | tokenValue = nixops.util.attr_property("proxmox.tokenValue", None)
119 |
120 | useSSH = nixops.util.attr_property("proxmox.useSSH", False)
121 |
122 | verifySSL = nixops.util.attr_property("proxmox.verifySSL", False)
123 |
124 | partitions = nixops.util.attr_property("proxmox.partitions", None)
125 |
126 | public_host_key = nixops.util.attr_property("proxmox.publicHostKey", None)
127 | private_host_key = nixops.util.attr_property("proxmox.privateHostKey", None)
128 |
129 | first_boot = nixops.util.attr_property(
130 | "proxmox.firstBoot",
131 | True,
132 | type=bool
133 | )
134 | installed = nixops.util.attr_property(
135 | "proxmox.installed",
136 | False,
137 | type=bool)
138 | partitioned = nixops.util.attr_property(
139 | "proxmox.partitioned",
140 | False,
141 | type=bool)
142 |
143 |
144 | def __init__(self, depl, name, id):
145 | super().__init__(depl, name, id)
146 | self._conn = None
147 | self._node = None
148 | self._vm = None
149 | self._cached_instance = None
150 |
151 | def _reset_state(self):
152 | with self.depl._db:
153 | self.state = MachineState.MISSING
154 | self.vm_id = None
155 | self._reset_network_knowledge()
156 | self.public_host_key = None
157 | self.private_host_key = None
158 | self._conn = None
159 | self._node = None
160 | self._vm = None
161 | self._cached_instance = None
162 |
163 | def _reset_network_knowledge(self):
164 | for ip in (self.public_ipv4,
165 | self.public_ipv6,
166 | self.private_ipv4,
167 | self.private_ipv6):
168 | if ip and self.public_host_key:
169 | nixops.known_hosts.remove(
170 | ip,
171 | self.public_host_key)
172 |
173 | with self.depl._db:
174 | self.public_ipv4 = None
175 | self.public_ipv6 = None
176 | self.private_ipv4 = None
177 | self.private_ipv6 = None
178 |
179 | def _learn_known_hosts(self, public_key: Optional[str] = None):
180 | if public_key is None:
181 | public_key = self.public_host_key
182 | for ip in (self.public_ipv4, self.public_ipv6,
183 | self.private_ipv4, self.private_ipv6):
184 | if ip:
185 | nixops.known_hosts.add(ip, public_key)
186 |
187 | def get_ssh_name(self):
188 | if self.use_private_ip_address:
189 | if not self.private_ipv4 and not self.private_ipv6:
190 | raise Exception(
191 | f"Proxmox machine '{self.name}' does not have a private (v4 or v6) address (yet)")
192 | return self.private_ipv6 or self.private_ipv4
193 | else:
194 | if not self.public_ipv4 and not self.public_ipv6:
195 | raise Exception(
196 | f"Proxmox machine '{self.name}' does not have a public (v4 or v6) address (yet)")
197 | return self.public_ipv6 or self.public_ipv4
198 |
199 | def get_ssh_private_key_file(self):
200 | if self._ssh_private_key_file:
201 | return self._ssh_private_key_file
202 |
203 | def get_ssh_flags(self, *args, **kwargs):
204 | file = self.get_ssh_private_key_file()
205 | super_flags = super(VirtualMachineState, self).get_ssh_flags(*args, **kwargs)
206 |
207 | return super_flags + (["-i", file] if file else []) + (["-o", "StrictHostKeyChecking=accept-new"] if self.has_temporary_key() else [])
208 |
209 | def get_physical_spec(self):
210 | return {
211 | }
212 |
213 | def get_keys(self):
214 | keys = super().get_keys()
215 |
216 | return keys
217 |
218 | @property
219 | def public_ip(self):
220 | return self.public_ipv6 or self.public_ipv4
221 |
222 | @property
223 | def private_ip(self):
224 | return self.private_ipv6 or self.public_ipv6
225 |
226 | def show_type(self):
227 | s = super(VirtualMachineState, self).show_type()
228 | return f"{s}"
229 |
230 | @property
231 | def resource_id(self):
232 | return self.vm_id
233 |
234 | def address_to(self, m):
235 | if isinstance(m, VirtualMachineState):
236 | return self.public_ipv6 or self.public_ipv4 # TODO: compute the shared optimal IP.
237 |
238 | return self.public_ipv6 or self.public_ipv4
239 |
240 | def _connect(self):
241 | if self._conn:
242 | return self._conn
243 | self._conn = nixops_proxmox.proxmox_utils.connect(
244 | self.serverUrl, self.username,
245 | password=self.password,
246 | token_name=self.tokenName, token_value=self.tokenValue,
247 | use_ssh=self.useSSH,
248 | verify_ssl=self.verifySSL)
249 | return self._conn
250 |
251 | def _connect_node(self, node: Optional[str] = None):
252 | self._node = self._connect().nodes(node or self.node)
253 | return self._node
254 |
255 | def _connect_vm(self, vm_id: Optional[int] = None):
256 | self._vm = self._connect_node().qemu(vm_id or self.resource_id)
257 | return self._vm
258 |
259 | def _get_instance(self, instance_id: Optional[int] = None, *, allow_missing: bool = False, update: bool = False):
260 | if not instance_id:
261 | instance_id = self.resource_id
262 |
263 | assert instance_id, "Cannot get instance of a non-created virtual machine!"
264 | if not self._cached_instance:
265 | try:
266 | instance = self._connect_vm(instance_id).status.current.get()
267 | except Exception as e:
268 | if allow_missing:
269 | instance = None
270 | else:
271 | raise
272 |
273 | self._cached_instance = instance
274 | elif update:
275 | self._cached_instance = self._connect_vm(instance_id).status.current.get()
276 |
277 | # TODO: Set start time.
278 | return self._cached_instance
279 |
280 | def _get_network_interfaces(self, instance_id: Optional[int] = None):
281 | if not instance_id:
282 | instance_id = self.resource_id
283 |
284 | assert instance_id, "Cannot get instance of a non-created virtual machine!"
285 | ins = self._get_instance(instance_id, update=True)
286 |
287 | assert bool(ins['agent']), "Cannot get network interfaces without QEMU Agent!"
288 | try:
289 | net_interfaces = {if_["name"]: if_ for if_ in self._connect_vm().agent.get("network-get-interfaces").get("result")}
290 | assert net_interfaces.get("lo") is not None, "No loopback interface in the result!"
291 | except Exception as e:
292 | return {}
293 |
294 | return net_interfaces
295 |
296 | def _execute_command_with_agent(self, command, stdin_data: str='', *, instance_id: Optional[int] = None):
297 | res = self._connect_vm(instance_id).agent.exec.post(**{
298 | "command": command,
299 | "input-data": stdin_data
300 | })
301 |
302 | get_status = lambda: self._connect_vm(instance_id).agent("exec-status").get(pid=int(res['pid']))
303 | current_status = get_status()
304 | while not current_status["exited"]:
305 | current_status = get_status()
306 |
307 | return current_status["exitcode"], current_status.get("out-data", "")
308 |
309 | def _file_write_through_agent(self, content, filename, *, instance_id: Optional[int] = None):
310 | self._connect_vm(instance_id).agent("file-write").post(
311 | content=content,
312 | file=filename
313 | )
314 | # TODO: ensure file exists.
315 |
316 | def _provision_ssh_key_through_agent(self, instance_id: Optional[int] = None):
317 | self.log_start("provisionning SSH key through QEMU Agent... ")
318 | self._execute_command_with_agent("mkdir -p /root/.ssh")
319 | self._file_write_through_agent(f"""# This was generated by NixOps during initial installation phase.
320 | # Do not edit.
321 | {self.public_host_key}""", "/root/.ssh/authorized_keys")
322 | self._execute_command_with_agent("chown -R root /root/.ssh")
323 | self._execute_command_with_agent("chmod 755 /root/.ssh/authorized_keys")
324 | self.log_end("provisionned")
325 |
326 | def _partition_disks(self, partitions, postPartitionHook: Optional[str] = None, instance_id: Optional[int] = None):
327 | self.log_start("partitioning disks... ")
328 | try:
329 | # Ensure /mnt is umounted.
330 | self.run_command("umount -R /mnt || true")
331 | # out = self.run_command("nixpart -L -p -", capture_stdout=True,
332 | # stdin_string=partitions)
333 | #if postPartitionHook:
334 | # out_posthook = self.run_command(postPartitionHook)
335 | self._file_write_through_agent(f"#!/run/current-system/sw/bin/bash\n{partitions}", "/tmp/partition.sh")
336 | self.run_command(f"chmod +x /tmp/partition.sh")
337 | out = self.run_command(f"/tmp/partition.sh", capture_stdout=True)
338 | except SSHCommandFailed as failed_command:
339 | # Require a reboot.
340 | if failed_command.exitcode == 100:
341 | self.log(failed_command.message)
342 | self.reboot()
343 | return
344 | else:
345 | raise
346 |
347 | self.log_end("partitioned")
348 | with self.depl._db:
349 | self.partitions = partitions
350 | self.fs_info = out
351 | self.partitioned = True
352 |
353 | # self._mount_disks(partitions, instance_id)
354 | return out
355 |
356 | def _mount_disks(self, partitions, instance_id: Optional[int] = None):
357 | assert self.partitioned, "The system has not been partitioned yet!"
358 | self.log_start("mounting disks... ")
359 | try:
360 | # Ensure /mnt is umounted.
361 | self.run_command("umount -R /mnt || true")
362 | out = self.run_command("nixpart -m -", capture_stdout=True,
363 | stdin_string=partitions)
364 | except SSHCommandFailed as failed_command:
365 | # Require a reboot.
366 | if failed_command.exitcode == 100:
367 | self.log(failed_command.message)
368 | self.reboot()
369 | return
370 | else:
371 | raise
372 |
373 | self.log_end("disk mounted")
374 | return out
375 |
376 | def _configure_initial_nix(self, uefi: bool, instance_id: Optional[int] = None):
377 | self.log_start("generating the initial configuration... ")
378 | # 1. We generate the HW configuration and the standard configuration.
379 | out = self.run_command("nixos-generate-config --root /mnt", capture_stdout=True)
380 | # 2. We will override the configuration.nix
381 | nixos_cfg = {
382 | "imports": [
383 | RawValue("./hardware-configuration.nix")
384 | ],
385 | ("boot", "kernelParams"): [
386 | "console=ttyS0"
387 | ],
388 | ("services", "openssh", "enable"): True,
389 | ("services", "qemuGuest", "enable"): True,
390 | ("systemd", "services", "qemu-guest-agent", "serviceConfig", "RuntimeDirectory"): "qemu-ga",
391 | ("systemd", "services", "qemu-guest-agent", "serviceConfig", "ExecStart"): RawValue("lib.mkForce \"\\${pkgs.qemu.ga}/bin/qemu-ga -t /var/run/qemu-ga\""),
392 | ("services", "getty", "autologinUser"): "root",
393 | ("networking", "firewall", "allowedTCPPorts"): [ 22 ],
394 | ("users", "users", "root"): {
395 | ("openssh", "authorizedKeys", "keys"): [ self.public_host_key ],
396 | ("initialPassword"): ""
397 | },
398 | ("users", "mutableUsers"): False
399 | }
400 |
401 | if uefi:
402 | nixos_cfg[("boot", "loader")] = {
403 | ("efi", "canTouchEfiVariables"): True,
404 | ("systemd-boot", "enable"): True
405 | }
406 | else:
407 | # Use nix2py to read self.fs_info.
408 | nixos_cfg[("boot", "loader", "grub", "devices")] = [ "/dev/sda" ];
409 |
410 | nixos_initial_postinstall_conf = py2nix(Function("{ config, pkgs, lib, ... }", nixos_cfg))
411 | self.run_command(f"cat < /mnt/etc/nixos/configuration.nix\n{nixos_initial_postinstall_conf}\nEOF")
412 | self.run_command("echo preinstall > /mnt/.install_status")
413 | self.log_end("initial configuration generated")
414 | self.log_start("installing NixOS... ")
415 | out = self.run_command("nixos-install --no-root-passwd", capture_stdout=True)
416 | self.log_end("NixOS installed")
417 | self.run_command("echo installed > /mnt/.install_status")
418 |
419 | def _wait_for_ip(self):
420 | self.log_start("waiting for at least a reachable IP address... ")
421 |
422 | def _instance_ip_ready(net_ifs):
423 | potential_ips = []
424 | for name, if_ in net_ifs.items():
425 | if name == "lo":
426 | continue
427 |
428 | potential_ips.extend(if_.get('ip-addresses', []))
429 |
430 | if not potential_ips:
431 | return False
432 |
433 | return any((can_reach(self.logger, i['ip-address'], self.ssh_user) for i in potential_ips))
434 |
435 | while True:
436 | instance = self._get_instance(update=True)
437 | self.log_continue(f"[{instance['status']}]")
438 |
439 | if instance['status'] == 'running':
440 | net_ifs = self._get_network_interfaces()
441 | if net_ifs:
442 | self.log_continue(f"[{', '.join(net_ifs.keys())}]")
443 |
444 | if instance['status'] == "stopped":
445 | raise Exception(
446 | f"Proxmox VM '{self.resource_id}' failed to start (state is '{instance['status']}')"
447 | )
448 |
449 | if _instance_ip_ready(net_ifs):
450 | break
451 |
452 | time.sleep(3)
453 |
454 | ip_addresses = list(chain.from_iterable(map(lambda i: ip_address(i['ip-address']), if_['ip-addresses']) for name, if_ in net_ifs.items() if if_['ip-addresses'] and name != "lo"))
455 | private_ips = {str(ip) for ip in ip_addresses if ip.is_private and not ip.is_link_local}
456 | public_ips = {str(ip) for ip in ip_addresses if not ip.is_private}
457 | ip_v6 = {str(ip) for ip in ip_addresses if isinstance(ip, IPv6Address)}
458 | ip_v4 = {str(ip) for ip in ip_addresses if isinstance(ip, IPv4Address)}
459 |
460 | with self.depl._db:
461 | self.private_ipv4 = first_reachable_or_none(self.logger, private_ips & ip_v4)
462 | self.public_ipv4 = first_reachable_or_none(self.logger, public_ips & ip_v4)
463 | self.private_ipv6 = first_reachable_or_none(self.logger, private_ips & ip_v6)
464 | self.public_ipv6 = first_reachable_or_none(self.logger, public_ips & ip_v6)
465 | self.ssh_pinged = False
466 |
467 | self.log_end(
468 | f"[IPv4: {self.public_ipv4} / {self.private_ipv4}][IPv6: {self.public_ipv6} / {self.private_ipv6}]")
469 |
470 | if not self.has_temporary_key():
471 | self._learn_known_hosts()
472 |
473 | def _ip_for_ssh_key(self):
474 | if self.use_private_ip_address:
475 | return self.private_ipv6 or self.private_ipv4
476 | else:
477 | return self.public_ipv6 or self.private_ipv6
478 |
479 | def has_temporary_key(self):
480 | return "NixOps auto-generated key" in self.public_host_key
481 |
482 | def _reinstall_host_key(self, key_type):
483 | self.log_start("reinstalling new host keys... ")
484 | attempts = 0
485 |
486 | while True:
487 | try:
488 | exitcode, new_key = self._execute_command_with_agent(f"cat /etc/ssh/ssh_host_{key_type}_key.pub")
489 | new_key = str(new_key).rstrip()
490 | if exitcode != 0:
491 | raise Exception(f"Failed to read SSH host key of type '{key_type}' from Proxmox VM '{self.name}' during reinstallation")
492 | break
493 | except Exception as e:
494 | # TODO: backoff exp should be used here.
495 | attempts += 1
496 | if attempts >= 10:
497 | raise e # bubble the error.
498 | self.log(f"failed to read SSH host key (attempt {attempts + 1}/10), retrying...")
499 | time.sleep(1)
500 |
501 | self._learn_known_hosts(new_key)
502 | self.log_end("installed")
503 |
504 | def create_after(self, resources, defn):
505 | return {}
506 |
507 | def _get_free_vmid(self):
508 | return self._connect().cluster.nextid.get()
509 |
510 | def _allocate_disk_image(self, filename, size, storage, vmid):
511 | try:
512 | return self._connect_node().storage(storage).content.post(
513 | filename=filename,
514 | size=size,
515 | vmid=vmid)
516 | except ResourceException as e:
517 | if "already exists" in str(e):
518 | return f'{storage}:{filename}'
519 | else:
520 | raise e
521 |
522 | def create_instance(self, defn, vmid):
523 | tags = [f'{name}={value}' for name, value in {"Name": f"{self.depl.description} [{self.name}]"}.items()]
524 | # tags.update(defn.tags)
525 | # tags.update(self.get_common_tags())
526 |
527 | if not self.public_host_key or self.provision_ssh_key:
528 | self.log_start("generating new SSH key pair... ")
529 | (private, public) = nixops.util.create_key_pair(
530 | type=defn.host_key_type()
531 | )
532 |
533 | with self.depl._db:
534 | self.public_host_key = public
535 | self.private_host_key = private
536 |
537 | self.log_end("done")
538 |
539 | options = {
540 | 'vmid': vmid,
541 | 'name': defn.name,
542 | # 'tags': (','.join(tags)),
543 | 'agent': "enabled=1,type=virtio",
544 | 'vga': 'qxl',
545 | 'args': defn.expertArgs,
546 | 'bios': ("ovmf" if defn.uefi.enable else "seabios"),
547 | 'cores': defn.nbCores or 1,
548 | 'cpu': defn.cpuType or "cputype=kvm64",
549 | 'cpulimit': defn.cpuLimit or 0,
550 | 'cpuunits': defn.cpuUnits or 1024,
551 | 'description': "NixOps-managed VM",
552 | 'pool': defn.pool,
553 | 'hotplug': defn.hotplugFeatures or "1",
554 | 'memory': defn.memory,
555 | 'onboot': to_prox_bool(defn.startOnBoot),
556 | 'ostype': "l26", # Linux kernel 2.6 - 5.X
557 | 'protection': to_prox_bool(defn.protectVM),
558 | 'cdrom': defn.installISO,
559 | 'serial0': 'socket',
560 | 'scsihw': 'virtio-scsi-pci',
561 | 'start': 1,
562 | 'unique': 1,
563 | 'archive': 0,
564 | }
565 |
566 | if defn.arch is not None:
567 | options[f"arch"] = defn.arch
568 |
569 | for index, net in enumerate(defn.network):
570 | options[f"net{index}"] = (",".join(
571 | [
572 | f"model={net.model}",
573 | f"bridge={net.bridge}"
574 | ]
575 | + ([f"tag={net.tag}"] if net.tag else [])
576 | + ([f"trunks={';'.join(net.trunks)}"] if net.trunks else [])))
577 |
578 | if net.ip:
579 | ipConfig = []
580 | if net.ip.v4:
581 | ipConfig.append(f"gw={net.ip.v4.gateway}")
582 | ipConfig.append(f"ip={net.ip.v4.address}/{net.ip.v4.prefixLength}")
583 | if net.ip.v6:
584 | ipConfig.append(f"gw6={net.ip.v6.gateway}")
585 | ipConfig.append(f"ip6={net.ip.v6.address}/{net.ip.v6.prefixLength}")
586 | if ipConfig:
587 | options[f"ipconfig{index}"] = ",".join(ipConfig)
588 |
589 |
590 | max_indexes = defaultdict(lambda: 0)
591 | for index, disk in enumerate(defn.disks):
592 | filename = f"vm-{vmid}-disk-{index}"
593 | options[f"scsi{index}"] = (",".join([
594 | f"file={disk.volume}:{filename}",
595 | f"size={disk.size}",
596 | f"ssd={1 if disk.enableSSDEmulation else 0}",
597 | f"discard={'on' if disk.enableDiscard else 'ignore'}"
598 | ]
599 | + ([f"aio={disk.aio}"] if disk.aio else [])))
600 | self._allocate_disk_image(filename, disk.size, disk.volume, vmid)
601 | max_indexes[disk.volume] += 1
602 |
603 | if defn.uefi and defn.uefi.enable:
604 | filename = f'vm-{vmid}-disk-{max_indexes[defn.uefi.volume] + 1}'
605 | options['efidisk0'] = f'{defn.uefi.volume}:{filename}'
606 | self._allocate_disk_image(filename,
607 | '4M',
608 | defn.uefi.volume,
609 | vmid)
610 |
611 | return vmid, self._connect_node().qemu.post(**options)
612 |
613 | def _qemu_agent_is_running(self):
614 | try:
615 | self._execute_command_with_agent("true")
616 | return True
617 | except Exception as e:
618 | if "not running" in str(e):
619 | return False
620 | else:
621 | raise e
622 |
623 | def is_in_live_cd(self):
624 | return bool(self._execute_command_with_agent("test -e /.install_status")[0])
625 |
626 | def wait_for_running(self):
627 | instance = self._get_instance(update=True)
628 | if instance['status'] == 'running':
629 | return
630 |
631 | self.log_start("waiting for the VM to be running... ")
632 | while instance['status'] != 'running':
633 | time.sleep(1)
634 | instance = self._get_instance(update=True)
635 | self.log_end("running.")
636 |
637 | def wait_for_qemu_agent(self, callback=None):
638 | self.wait_for_running()
639 | if self._qemu_agent_is_running():
640 | return
641 |
642 | if not callback:
643 | self.log_start("waiting for the QEMU agent to be ready... ")
644 |
645 | while not self._qemu_agent_is_running():
646 | if callback:
647 | callback()
648 | time.sleep(1)
649 |
650 | if not callback:
651 | self.log_end("ready.")
652 |
653 | def _postinstall(self, key_type, check):
654 | # Re-compute current addresses.
655 | self._wait_for_ip()
656 | # Re-install new host key.
657 | self._reinstall_host_key(key_type)
658 | self.write_ssh_private_key(self.private_host_key)
659 | # Ensure we have SSH.
660 | self.wait_for_ssh(check=check)
661 | self.run_command("echo postinstall > /.install_status")
662 | self.installed = True
663 | self.state = self.UP
664 |
665 | def after_activation(self, defn):
666 | pass
667 |
668 | def read_from_profile(self, defn: VirtualMachineDefinition) -> bool:
669 | if self.profile is not None:
670 | credentials = nixops_proxmox.proxmox_utils.read_proxmox_profile(self.profile)
671 | for attr in ('server_url', 'username', 'password', 'token_name', 'token_value', 'use_ssh'):
672 | local_attr_name = profile_fields_mapping.get(attr, attr)
673 | if local_attr_name in credentials and getattr(defn, local_attr_name, None) is not None:
674 | self.warn(f'`{local_attr_name}` is already set in the `{self.profile}` profile, its Nix expression value will be ignored.')
675 | if attr in credentials:
676 | setattr(self, local_attr_name, credentials[attr])
677 |
678 | return True
679 | else:
680 | return False
681 |
682 | def create(self, defn: VirtualMachineDefinition, check, allow_reboot, allow_recreate):
683 | if self.state != self.UP:
684 | check = True
685 |
686 | self.set_common_state(defn)
687 |
688 | self.profile = defn.profile
689 | self.serverUrl = defn.serverUrl
690 |
691 | self.username = defn.username
692 | self.password = defn.password
693 |
694 | self.tokenName = defn.tokenName
695 | self.tokenValue = defn.tokenValue
696 |
697 | self.useSSH = defn.useSSH
698 |
699 | has_profile = self.read_from_profile(defn)
700 | assert self.serverUrl is not None, "There is no Proxmox server URL set{0}, set `deployment.proxmox.serverUrl` or a valid `deployment.proxmox.profile`".format(' (using a profile)' if has_profile else '')
701 |
702 | self.use_private_ip_address = defn.usePrivateIPAddress
703 |
704 | nodes = self._connect().nodes.get()
705 | assert len(nodes) == 1 or defn.node is not None, "There is no node or multiple nodes, ensure you set 'deployment.proxmox.node' or verify your Proxmox cluster."
706 | self.node = defn.node or nodes[0]['node']
707 |
708 | # check if there is actually the right pool
709 | pools = self._connect().pools.get()
710 | assert defn.pool is None or defn.pool in [pool['poolid'] for pool in pools], "There is no pool named `{0}`, ensure you set `deployment.proxmox.pool` to a valid value or verify your Proxmox user permissions or cluster.".format(defn.pool)
711 |
712 | # self.private_key_file = defn.private_key or None
713 |
714 | if self.resource_id and allow_reboot:
715 | self.stop()
716 | check = True
717 |
718 | if self.vm_id and check:
719 | instance = self._get_instance(allow_missing=True)
720 |
721 | if instance is None:
722 | if not allow_recreate:
723 | raise Exception(
724 | f"Proxmox VM '{self.name}' went away; use '--allow-recreate' to create a new one")
725 |
726 | self.log(
727 | f"Proxmox VM '{self.name}' went away (state: '{instance['status'] if instance else 'gone'}', will recreate")
728 | self._reset_state()
729 | elif instance.get("status") == "stopped":
730 | self.log(f"Proxmox VM '{self.name}' was stopped, restarting...")
731 | # Change the memory allocation.
732 | self._reset_network_knowledge()
733 | self.start()
734 |
735 | # Create the QEMU.
736 | if not self.resource_id:
737 | created = False
738 | has_user_vmid = defn.vmid is not None
739 | while not created:
740 | vmid = defn.vmid or self._get_free_vmid()
741 | self.log(
742 | f"creating the Proxmox VM (in node {self.node}, free supposedly VM id: {vmid}, memory {defn.memory} MiB)...")
743 | try:
744 | vmid, instance = self.create_instance(defn, vmid)
745 | created = True
746 | except Exception as e:
747 | if "already exist" in str(e):
748 | if has_user_vmid:
749 | raise Exception(
750 | f"user provided vmid is not free, fatal error.")
751 | else:
752 | self.log(
753 | f"vmid collision, trying another one.")
754 | else:
755 | print('Failure', e)
756 |
757 |
758 | with self.depl._db:
759 | self.vm_id = int(vmid)
760 | self.memory = defn.memory
761 | self.cpus = defn.nbCpus
762 | self.cores = defn.nbCores
763 | self.state = self.RESCUE
764 |
765 | if self.state not in (self.UP, self.RESCUE) or check:
766 | while True:
767 | if self._get_instance(allow_missing=True):
768 | break
769 | self.log(
770 | f"Proxmox VM instance '{self.vm_id}' not known yet, waiting...")
771 | time.sleep(3)
772 |
773 | instance = self._get_instance()
774 | # common_tags = dict(defn.tags)
775 | #if defn.owners:
776 | # common_tags["Owners"] = ", ".join(defn.owners)
777 | # self.update_tags(self.vm_id, user_tags=common_tags, check=check)
778 |
779 | self.wait_for_qemu_agent()
780 | self.state = self.RESCUE if self.is_in_live_cd() else self.UP
781 |
782 | # provision ourselves through agent only if we are in a live CD.
783 | if self.state == self.RESCUE:
784 | self.log("In live CD (rescue mode)")
785 | self._provision_ssh_key_through_agent()
786 | self.write_ssh_private_key(self.private_host_key)
787 | time.sleep(1) # give some time to SSH/IP to be ready.
788 |
789 | if self.public_ip or (self.use_private_ip_address and not self.private_ip) or check:
790 | self._wait_for_ip()
791 | time.sleep(1)
792 |
793 | if self.state == self.RESCUE:
794 | self.log("Initial installation in rescue mode")
795 | old_ssh_user = self.ssh_user
796 | self.ssh_user = "root"
797 | self.wait_for_ssh(check=check)
798 | # Partition table changed.
799 | if self.partitions and self.partitions != defn.partitions:
800 | # TODO: use remapper.
801 | if self.depl.logger.confirm("Partition table changed, do you want to re-run the partitionning phase?"):
802 | self.partitioned = False
803 |
804 | if self.partitioned:
805 | self._partition_disks(defn.partitions, defn.postPartitioningLocalCommands)
806 | else:
807 | rebooted = self._partition_disks(defn.partitions, defn.postPartitioningLocalCommands)
808 | if rebooted:
809 | time.sleep(1)
810 | self._provision_ssh_key_through_agent()
811 | self.write_ssh_private_key(self.private_host_key)
812 | self.wait_for_ssh(check=check)
813 | self._configure_initial_nix(defn.uefi.enable)
814 | self.reboot()
815 | self.wait_for_qemu_agent()
816 | self._postinstall(defn.host_key_type(), check)
817 | self.ssh_user = old_ssh_user
818 |
819 | # Maybe, we installed but the process has crashed before.
820 | if self.state != self.RESCUE and not self.installed:
821 | self.log_start("Resuming the post-installation... ")
822 | old_ssh_user = self.ssh_user
823 | self.ssh_user = "root"
824 | self._postinstall(defn.host_key_type(), check)
825 | self.log_end("Post-installed")
826 | self.ssh_user = old_ssh_user
827 |
828 | if self.first_boot and self.installed:
829 | self.first_boot = False
830 |
831 | self.write_ssh_private_key(self.private_host_key)
832 |
833 | def destroy(self, wipe=False):
834 | if not self.vm_id:
835 | return True
836 |
837 | if not self.depl.logger.confirm(
838 | f"Are you sure you want to destroy Proxmox VM '{self.name}'?"):
839 | return False
840 |
841 | if wipe:
842 | self.warn("wipe is not supported on Proxmox")
843 |
844 | self.log_start("destroying Proxmox VM...")
845 |
846 | instance = None
847 | if self.vm_id:
848 | instance = self._get_instance(allow_missing=True)
849 |
850 | if instance:
851 | self._connect_vm().status.stop.post()
852 |
853 | instance = self._get_instance(update=True)
854 | while instance['status'] != 'stopped':
855 | self.log_continue(f"[{instance['status']}]")
856 | time.sleep(3)
857 | instance = self._get_instance(update=True)
858 |
859 | self._connect_vm().delete(purge=1)
860 |
861 | self.log_end("")
862 | self._reset_network_knowledge()
863 |
864 | return True
865 |
866 | def stop(self, hard: bool = False):
867 | if not self.depl.logger.confirm(
868 | f"are you sure you want to stop machine '{self.name}'?"):
869 | return
870 |
871 | self.log_start("stopping Proxmox VM...")
872 |
873 | self._connect_vm().status.shutdown.post()
874 | self.state = self.STOPPING
875 |
876 | def check_stopped():
877 | instance = self._get_instance(update=True)
878 | self.log_continue(f"[{instance['state']}]")
879 |
880 | if instance['state'] == 'stopped':
881 | return True
882 |
883 | if instance['state'] != "running":
884 | raise Exception(
885 | f"Proxmox VM '{self.vm_id}' failed to stop (state is '{instance['state']}')"
886 | )
887 |
888 | return False
889 |
890 | if not nixops.util.check_wait(
891 | check_stopped, initial=3, max_tries=300, exception=False):
892 | self.log_end("(timed out)")
893 | self.log_start("force-stopping Proxmox VM... ")
894 | self._connect_vm().status.stop.post()
895 | nixops.util.check_wait(
896 | check_stopped, initial=3, max_tries=100
897 | )
898 |
899 | self.log_end("")
900 | self.state = self.STOPPED
901 | self.ssh_master = None
902 |
903 | def start(self):
904 | self.log("starting Proxmox VM machine...")
905 |
906 | self._connect_vm().status.start()
907 | self.state = self.STARTING
908 | with self._check_ip_changes() as old_addresses:
909 | self._wait_for_ip()
910 | self._warn_for_ip_changes(old_addresses)
911 | self.wait_for_ssh(check=True)
912 | self.send_keys()
913 |
914 |
915 | def _check(self, res):
916 | if not self.vm_id:
917 | res.exists = False
918 | return
919 |
920 | instance = self._get_instance(allow_missing=True)
921 |
922 | if instance is None:
923 | self.state = self.MISSING
924 | self.vm_id = None
925 | return
926 |
927 | res.exists = True
928 |
929 | if instance["status"] == "running":
930 | res.is_up = True
931 | res.disks_ok = True
932 | #with self._check_ip_changes() as addresses:
933 | # self._wait_for_ip()
934 | # self._warn_for_ip_changes(addresses)
935 | super()._check(res)
936 | elif instance["status"] == "stopped":
937 | res.is_up = False
938 | self.state = self.STOPPED
939 |
940 | def reboot_sync(self, hard: bool = False):
941 | self.reboot(hard=hard)
942 |
943 | self.log_start("waiting for the machine to finish rebooting... ")
944 | def progress_cb() -> None:
945 | self.log_continue(".")
946 |
947 | self.wait_for_down(callback=progress_cb)
948 | self.log_continue("[down] ")
949 | self.wait_for_qemu_agent(callback=progress_cb)
950 | self.log_end("[qemu agent up]")
951 |
952 |
953 | def reboot(self, hard: bool = False):
954 | self.log("rebooting Proxmox VM machine...")
955 | status = self._connect_vm().status
956 | if hard:
957 | status.reset.post()
958 | else:
959 | status.reboot.post()
960 | self.state = self.STARTING
961 |
962 | def get_console_output(self):
963 | if not self.vm_id:
964 | raise Exception(
965 | f"Cannot get console output of non-existant machine '{self.name}'"
966 | )
967 |
968 | # TODO: connect to serial if available.
969 | return "(not available)"
970 |
--------------------------------------------------------------------------------
/nixops_proxmox/nix/default.nix:
--------------------------------------------------------------------------------
1 | {
2 | config_exporters = { optionalAttrs, ... }: [
3 | (config: { proxmox = optionalAttrs (config.deployment.targetEnv == "proxmox") config.deployment.proxmox; })
4 | ];
5 |
6 | options = [
7 | ./proxmox.nix
8 | ];
9 |
10 | resources = { evalResources, zipAttrs, resourcesByType, ... }: {
11 | # TODO: storage.
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/nixops_proxmox/nix/proxmox.nix:
--------------------------------------------------------------------------------
1 | { config, pkgs, lib, utils, ... }:
2 | with lib;
3 | let
4 | cfg = config.deployment.proxmox;
5 | ipOptions = { config, ... }: {
6 | options = {
7 | gateway = mkOption {
8 | example = "192.168.1.254";
9 | type = types.nullOr types.str;
10 | description = "Gateway for this interface (optional)";
11 | };
12 | address = mkOption {
13 | example = "192.168.1.10";
14 | type = types.str;
15 | description = ''
16 | Static address for this interface.
17 | If dynamic addressing is desired, you can set:
18 | - dhcp for DHCP (valid for IPv4/IPv6)
19 | - auto for SLAAC (valid only for IPv6)
20 |
21 | Mandatory.
22 | '';
23 | };
24 | prefixLength = mkOption {
25 | example = 48;
26 | default = null;
27 | type = types.nullOr types.str;
28 | description = "Prefix length for the static address (optional)";
29 | };
30 | };
31 | };
32 | networkOptions = { config, ... }: {
33 | options = {
34 | model = mkOption {
35 | default = "virtio";
36 | example = "e1000";
37 | type = types.str;
38 | description = ''Network interface model.
39 | By default, virtio is the most optimal one and used.
40 | e1000 is an acceptable alternative.
41 | '';
42 | };
43 | bridge = mkOption {
44 | type = types.str;
45 | example = "vmbr0";
46 | description = ''Proxmox's bridge.
47 | It will be bridged with the virtual machine interface.
48 | Proxmox's bridges always starts with vmbr.
49 |
50 | Mandatory.
51 | '';
52 | };
53 | tag = mkOption {
54 | type = types.nullOr types.int;
55 | example = 100;
56 | default = null;
57 | description = "VLAN tag for this interface (optional)";
58 | };
59 | trunks = mkOption {
60 | default = [];
61 | type = types.listOf types.int;
62 | example = [ 100 200 300 ];
63 | description = "VLAN trunks for this interface (optional)";
64 | };
65 | ip.v4 = mkOption {
66 | type = types.nullOr (types.submodule ipOptions);
67 | default = null;
68 | };
69 | ip.v6 = mkOption {
70 | type = types.nullOr (types.submodule ipOptions);
71 | default = null;
72 | };
73 | };
74 | };
75 | disksOptions = { config, ... }: {
76 | options = {
77 | volume = mkOption {
78 | type = types.str;
79 | example = "local";
80 | description = "Storage volume where to store the disk";
81 | };
82 | size = mkOption {
83 | type = types.either types.int types.str;
84 | example = "2G";
85 | description = "Disk size in kilobytes (suffixes available: M, G)";
86 | };
87 | aio = mkOption {
88 | type = types.nullOr types.str;
89 | example = "native";
90 | default = null;
91 | description = "Asynchronous IO mode (native or thread)";
92 | };
93 | enableSSDEmulation = mkEnableOption "Enable SSD emulation";
94 | enableDiscard = mkEnableOption "Enable Discard feature";
95 | };
96 | };
97 | uefiOptions = { config, ... }: {
98 | options = {
99 | enable = mkEnableOption "Enable UEFI on the machine";
100 | volume = mkOption {
101 | type = types.str;
102 | example = "local";
103 | description = "Storage volume where to store the EFI disk";
104 | };
105 | };
106 | };
107 | in
108 | {
109 | options = {
110 | deployment.proxmox.profile = mkOption {
111 | example = "production1";
112 | type = types.nullOr types.str;
113 | description = ''
114 | A Proxmox profile, contained in $PROXMOX_CREDENTIALS_FILE
115 | which defaults to $XDG_CONFIG_FILE/.proxmox/credentials.
116 |
117 | A mechanism similar to ~/.aws/credentials
118 | You can have a TOML containing server_url, username, token_name, token_value, use_ssh.
119 | In a way that makes sense and it will be used, this avoids pushing secrets.
120 | '';
121 | default = null;
122 | };
123 | deployment.proxmox.serverUrl = mkOption {
124 | example = "https://my-proxmox-ip:8006/api/…";
125 | type = types.nullOr types.str;
126 | description = ''
127 | The Proxmox API endpoint URL.
128 | Mandatory.
129 | '';
130 | default = null;
131 | };
132 | deployment.proxmox.username = mkOption {
133 | type = types.nullOr types.str;
134 | description = ''
135 | The Proxmox account username.
136 | Must have the correct rights to perform the operations.
137 | '';
138 | default = null;
139 | };
140 | deployment.proxmox.tokenName = mkOption {
141 | type = types.nullOr types.str;
142 | default = null;
143 | description = ''
144 | Proxmox token name (API token)
145 | '';
146 | };
147 | deployment.proxmox.tokenValue = mkOption {
148 | type = types.nullOr types.str;
149 | default = null;
150 | description = ''
151 | Proxmox token value (API token)
152 | '';
153 | };
154 | deployment.proxmox.password = mkOption {
155 | type = types.nullOr types.str;
156 | default = null;
157 | description = ''
158 | Proxmox password (username/password authentication)
159 |
160 | It is better to use an API token or SSH authentication!
161 | '';
162 | };
163 | deployment.proxmox.verifySSL = mkOption {
164 | default = false;
165 | type = types.bool;
166 | description = ''
167 | Whether to verify the SSL certificate of the Proxmox node.
168 | '';
169 | };
170 | deployment.proxmox.usePrivateIPAddress = mkOption {
171 | type = types.bool;
172 | default = false;
173 | description = ''
174 | Whether to use the VM private IP address for management.
175 | '';
176 | };
177 | deployment.proxmox.useSSH = mkOption {
178 | default = false;
179 | type = types.bool;
180 | description = ''
181 | Use SSH authentication to manipulate Proxmox API.
182 | Require that the host is configured to SSH to Proxmox host.
183 | '';
184 | };
185 | deployment.proxmox.node = mkOption {
186 | type = types.nullOr types.str;
187 | default = null;
188 | description = ''
189 | Node name for Proxmox host (optional)
190 | By default, it will select the first one found.
191 | '';
192 | };
193 | deployment.proxmox.pool = mkOption {
194 | type = types.nullOr types.str;
195 | default = null;
196 | description = ''
197 | Attach this virtual machine to the designed pool (optional)
198 | '';
199 | };
200 | deployment.proxmox.network = mkOption {
201 | type = types.listOf (types.submodule networkOptions);
202 | description = ''
203 | Network description (in order) of the virtual machine.
204 | At least one *reachable* network interface should be configured, otherwise NixOps will fail.
205 | '';
206 | };
207 | deployment.proxmox.partitions = mkOption {
208 | default = "";
209 | type = types.str;
210 | example = ''
211 | wipefs -f /dev/sda
212 |
213 | parted --script /dev/sda -- mklabel gpt
214 | parted --script /dev/sda -- mkpart primary fat32 1MiB 1024MiB
215 | parted --script /dev/sda -- mkpart primary btrfs 1024MiB 100%
216 |
217 | parted --script /dev/sda -- set 1 boot on
218 |
219 | mkfs.vfat /dev/sda1 -n NIXBOOT
220 | mkfs.btrfs /dev/sda2 -f -L nixroot
221 |
222 | mount -t btrfs /dev/sda2 /mnt
223 | mkdir -p /mnt/boot && mount /dev/sda1 /mnt/boot
224 | '';
225 | description = ''
226 | Bash partitioning script.
227 | '';
228 | };
229 | deployment.proxmox.disks = mkOption {
230 | type = types.listOf (types.submodule disksOptions);
231 | description = ''
232 | Disk description (in order) of the virtual machine.
233 | At least one usable disk should be configure, otherwise NixOps will fail.
234 | '';
235 | };
236 | deployment.proxmox.uefi = mkOption {
237 | type = types.submodule uefiOptions;
238 | description = ''
239 | UEFI configuration for the virtual machine (optional)
240 | '';
241 | };
242 | deployment.proxmox.nbCpus = mkOption {
243 | type = types.int;
244 | default = 1;
245 | description = "Amount of CPU allocated";
246 | };
247 | deployment.proxmox.nbCores = mkOption {
248 | type = types.int;
249 | default = 1;
250 | description = "Amount of cores allocated";
251 | };
252 | deployment.proxmox.memory = mkOption {
253 | type = types.int;
254 | default = 1024;
255 | description = "Amount of memory allocated in MB (note it will use the ballooning device)";
256 | };
257 | deployment.proxmox.startOnBoot = mkOption {
258 | type = types.bool;
259 | default = false;
260 | description = "This will make the virtual machine start at boot of the Proxmox host";
261 | };
262 | deployment.proxmox.protectVM = mkOption {
263 | type = types.bool;
264 | default = false;
265 | description = "This will prevent the virtual machine from accidental deletion (disk and VM)";
266 | };
267 | deployment.proxmox.hotplugFeatures = mkOption {
268 | type = types.nullOr types.str;
269 | default = null;
270 | description = "Hotplug features string for QEMU";
271 | };
272 | deployment.proxmox.cpuLimit = mkOption {
273 | type = types.nullOr types.int;
274 | default = null;
275 | description = "CPU-time rate-limits";
276 | };
277 | deployment.proxmox.cpuUnits = mkOption {
278 | type = types.nullOr types.int;
279 | default = null;
280 | description = "CPU-units rate-limits";
281 | };
282 | deployment.proxmox.cpuType = mkOption {
283 | type = types.str;
284 | default = "kvm64";
285 | description = "CPU type string";
286 | };
287 | deployment.proxmox.arch = mkOption {
288 | type = types.nullOr (types.enum [ "aarch64" "x86_64" ]);
289 | default = null;
290 | description = ''
291 | QEMU architecture.
292 |
293 | The default value will not pass anything in the Proxmox API request.
294 | Usage of this option is only permitted to the root
295 | user by the Proxmox API and only when using username/password
296 | authentication.
297 | '';
298 | };
299 | deployment.proxmox.expertArgs = mkOption {
300 | type = types.nullOr types.str;
301 | default = null;
302 | description = "Raw QEMU options, for experts only!";
303 | };
304 | deployment.proxmox.vmid = mkOption {
305 | type = types.nullOr types.int;
306 | default = null;
307 | description = "Virtual machine ID for Proxmox, if not provided, an attempt to grab a free VM id is performed.";
308 | };
309 | deployment.proxmox.installISO = mkOption {
310 | type = types.str;
311 | description = ''
312 | Install ISO for NixOS.
313 | This ISO must support cloud-init initialization and QEMU agent.
314 | So that Proxmox can run the partitionning phase then the NixOS install.
315 | '';
316 | };
317 | };
318 |
319 | config = mkIf (config.deployment.targetEnv == "proxmox") {
320 | # TODO: assert that there is at least a valid option for authentication.
321 | nixpkgs.system = mkOverride 900 (if cfg.arch == "aarch64" then "aarch64-linux" else "x86_64-linux");
322 | };
323 | }
324 |
--------------------------------------------------------------------------------
/nixops_proxmox/plugin.py:
--------------------------------------------------------------------------------
1 | import os.path
2 | import nixops.plugins
3 | from nixops.plugins import Plugin
4 |
5 |
6 | class NixopsProxmoxPlugin(Plugin):
7 | @staticmethod
8 | def nixexprs():
9 | return [os.path.dirname(os.path.abspath(__file__)) + "/nix"]
10 |
11 | @staticmethod
12 | def load():
13 | return [
14 | "nixops_proxmox.backends.proxmox"
15 | ]
16 |
17 |
18 | @nixops.plugins.hookimpl
19 | def plugin():
20 | return NixopsProxmoxPlugin()
21 |
--------------------------------------------------------------------------------
/nixops_proxmox/proxmox_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import proxmoxer.backends.https
4 | from proxmoxer import ProxmoxAPI
5 | from typing import Optional, List, Dict
6 | import socket
7 | import os
8 | import toml
9 |
10 |
11 | def get_xdg_config_home() -> str:
12 | home = os.environ.get('HOME', None)
13 | if home is None:
14 | raise RuntimeError("This platform is not POSIX compliant, "
15 | "cannot get $HOME.")
16 |
17 | return os.environ.get('XDG_CONFIG_HOME',
18 | os.path.join(home, '.config'))
19 |
20 |
21 | def read_proxmox_profile(profile_name: str) -> Dict[str, str]:
22 | credentials_file_path = os.environ.get('PROXMOX_CREDENTIALS_FILE',
23 | os.path.join(
24 | get_xdg_config_home(),
25 | 'proxmox', 'credentials'))
26 |
27 | try:
28 | with open(credentials_file_path, 'r') as cred_file:
29 | profiles = toml.load(cred_file)
30 | except OSError as exc:
31 | print(f'Failed to open credentials file ({credentials_file_path}) '
32 | 'for profile `{profile_name}`, verify if the file exists and/or'
33 | 'permissions.')
34 | raise exc
35 | if profile_name not in profiles:
36 | raise RuntimeError(
37 | f"{credentials_file_path} has no such profile `{profile_name}`")
38 | return profiles[profile_name]
39 |
40 | def connect(
41 | server_url: str,
42 | username: str,
43 | *,
44 | password: Optional[str] = None,
45 | token_name: Optional[str] = None,
46 | token_value: Optional[str] = None,
47 | verify_ssl: bool = False,
48 | use_ssh: bool = False):
49 |
50 | kwargs = {
51 | "host": server_url,
52 | "user": username,
53 | "password": password,
54 | "backend": "ssh_paramiko" if use_ssh else "https"
55 | }
56 |
57 | if token_name and token_value and not use_ssh:
58 | kwargs["token_name"] = token_name
59 | kwargs["token_value"] = token_value
60 |
61 | if not use_ssh:
62 | kwargs['verify_ssl'] = verify_ssl
63 |
64 |
65 | api = ProxmoxAPI(**kwargs)
66 |
67 | # check if API is working.
68 | try:
69 | nodes = api.nodes().get()
70 | if not nodes:
71 | raise Exception(f"Failed to connect to Proxmox server '{server_url}@{username}' OR empty Proxmox cluster (no nodes found), verify credentials")
72 | except proxmoxer.backends.https.AuthenticationError:
73 | raise Exception(f"Failed to connect to Proxmox server '{server_url}@{username}', verify credentials (authentication error)")
74 |
75 | return api
76 |
77 | def tcp_ping(host, port: int = 22, max_count: int = 20, timeout: int = 3):
78 | failed = 0
79 | count = 0
80 | rtt = []
81 | successes = []
82 | while count < max_count:
83 | success = False
84 | count += 1
85 |
86 | s = socket.socket(
87 | socket.AF_INET, socket.SOCK_STREAM)
88 |
89 | s.settimeout(timeout)
90 |
91 | start = time.time()
92 | try:
93 | s.connect(host, port)
94 | s.shutdown(socket.SHUT_RD)
95 | success = True
96 | except socket.timeout:
97 | failed += 1
98 | except OSError as e:
99 | failed += 1
100 |
101 | elapsed = time.time() - start
102 | successes.append(success)
103 | rtt.append(elapsed)
104 |
105 | if count < max_count:
106 | time.sleep(1)
107 |
108 | return rtt, successes
109 |
110 | def select_fastest_ip_address(ips: List[str]):
111 | """
112 | Select the fastest & reachable IP address based on TCP ping.
113 | """
114 | delays = {}
115 | successes = {}
116 | for ip in ips:
117 | rtts, succ = tcp_ping(ip)
118 | nbSuccess = succ.count(True)
119 | delays[ip] = rtts
120 | successes[ip] = nbSuccess
121 |
122 | return sorted(ips, key=lambda ip: (successes[ip], avg(delays[ip])))
123 |
--------------------------------------------------------------------------------
/overrides.nix:
--------------------------------------------------------------------------------
1 | { pkgs, lib ? pkgs.lib, stdenv ? pkgs.stdenv }:
2 |
3 | self: super: {
4 | nixops = super.nixops.overridePythonAttrs (
5 | { nativeBuildInputs ? [], ... }: {
6 | format = "pyproject";
7 | nativeBuildInputs = nativeBuildInputs ++ [ self.poetry ];
8 | }
9 | );
10 | nixos-modules-contrib = super.nixos-modules-contrib.overridePythonAttrs (
11 | { nativeBuildInputs ? [], ... }: {
12 | format = "pyproject";
13 | nativeBuildInputs = nativeBuildInputs ++ [ self.poetry ];
14 | }
15 | );
16 |
17 | cryptography = super.cryptography.overridePythonAttrs (
18 | old: {
19 | nativeBuildInputs = (old.nativeBuildInputs or [ ])
20 | ++ lib.optional (lib.versionAtLeast old.version "3.4") [ self.setuptools-rust ]
21 | ++ lib.optional (stdenv.buildPlatform != stdenv.hostPlatform) self.python.pythonForBuild.pkgs.cffi
22 | ++ lib.optional (lib.versionAtLeast old.version "3.5")
23 | (with pkgs.rustPlatform; [ cargoSetupHook rust.cargo rust.rustc ]);
24 | buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.openssl ];
25 | } // lib.optionalAttrs (lib.versionAtLeast old.version "3.4" && lib.versionOlder old.version "3.5") {
26 | CRYPTOGRAPHY_DONT_BUILD_RUST = "1";
27 | } // lib.optionalAttrs (lib.versionAtLeast old.version "3.5") rec {
28 | cargoDeps =
29 | let
30 | getCargoHash = version:
31 | if lib.versionOlder version "3.6" then "sha256-tQoQfo+TAoqAea86YFxyj/LNQCiViu5ij/3wj7ZnYLI="
32 | # This hash could no longer be valid for cryptography versions
33 | # different from 3.6.0
34 | else "sha256-Y6TuW7AryVgSvZ6G8WNoDIvi+0tvx8ZlEYF5qB0jfNk=";
35 | in
36 | pkgs.rustPlatform.fetchCargoTarball {
37 | src = old.src;
38 | sourceRoot = "${old.pname}-${old.version}/${cargoRoot}";
39 | name = "${old.pname}-${old.version}";
40 | sha256 = getCargoHash old.version;
41 | };
42 | cargoRoot = "src/rust";
43 | }
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | [[package]]
2 | name = "appdirs"
3 | version = "1.4.4"
4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
5 | category = "dev"
6 | optional = false
7 | python-versions = "*"
8 |
9 | [[package]]
10 | name = "attrs"
11 | version = "21.2.0"
12 | description = "Classes Without Boilerplate"
13 | category = "dev"
14 | optional = false
15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
16 |
17 | [package.extras]
18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
22 |
23 | [[package]]
24 | name = "bcrypt"
25 | version = "3.2.0"
26 | description = "Modern password hashing for your software and your servers"
27 | category = "main"
28 | optional = false
29 | python-versions = ">=3.6"
30 |
31 | [package.dependencies]
32 | cffi = ">=1.1"
33 | six = ">=1.4.1"
34 |
35 | [package.extras]
36 | tests = ["pytest (>=3.2.1,!=3.3.0)"]
37 | typecheck = ["mypy"]
38 |
39 | [[package]]
40 | name = "black"
41 | version = "19.10b0"
42 | description = "The uncompromising code formatter."
43 | category = "dev"
44 | optional = false
45 | python-versions = ">=3.6"
46 |
47 | [package.dependencies]
48 | appdirs = "*"
49 | attrs = ">=18.1.0"
50 | click = ">=6.5"
51 | pathspec = ">=0.6,<1"
52 | regex = "*"
53 | toml = ">=0.9.4"
54 | typed-ast = ">=1.4.0"
55 |
56 | [package.extras]
57 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
58 |
59 | [[package]]
60 | name = "certifi"
61 | version = "2021.10.8"
62 | description = "Python package for providing Mozilla's CA Bundle."
63 | category = "main"
64 | optional = false
65 | python-versions = "*"
66 |
67 | [[package]]
68 | name = "cffi"
69 | version = "1.15.0"
70 | description = "Foreign Function Interface for Python calling C code."
71 | category = "main"
72 | optional = false
73 | python-versions = "*"
74 |
75 | [package.dependencies]
76 | pycparser = "*"
77 |
78 | [[package]]
79 | name = "charset-normalizer"
80 | version = "2.0.9"
81 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
82 | category = "main"
83 | optional = false
84 | python-versions = ">=3.5.0"
85 |
86 | [package.extras]
87 | unicode_backport = ["unicodedata2"]
88 |
89 | [[package]]
90 | name = "click"
91 | version = "8.0.3"
92 | description = "Composable command line interface toolkit"
93 | category = "dev"
94 | optional = false
95 | python-versions = ">=3.6"
96 |
97 | [package.dependencies]
98 | colorama = {version = "*", markers = "platform_system == \"Windows\""}
99 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
100 |
101 | [[package]]
102 | name = "colorama"
103 | version = "0.4.4"
104 | description = "Cross-platform colored terminal text."
105 | category = "dev"
106 | optional = false
107 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
108 |
109 | [[package]]
110 | name = "cryptography"
111 | version = "36.0.0"
112 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
113 | category = "main"
114 | optional = false
115 | python-versions = ">=3.6"
116 |
117 | [package.dependencies]
118 | cffi = ">=1.12"
119 |
120 | [package.extras]
121 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
122 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
123 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
124 | sdist = ["setuptools_rust (>=0.11.4)"]
125 | ssh = ["bcrypt (>=3.1.5)"]
126 | test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
127 |
128 | [[package]]
129 | name = "flake8"
130 | version = "3.9.2"
131 | description = "the modular source code checker: pep8 pyflakes and co"
132 | category = "dev"
133 | optional = false
134 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
135 |
136 | [package.dependencies]
137 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
138 | mccabe = ">=0.6.0,<0.7.0"
139 | pycodestyle = ">=2.7.0,<2.8.0"
140 | pyflakes = ">=2.3.0,<2.4.0"
141 |
142 | [[package]]
143 | name = "idna"
144 | version = "3.3"
145 | description = "Internationalized Domain Names in Applications (IDNA)"
146 | category = "main"
147 | optional = false
148 | python-versions = ">=3.5"
149 |
150 | [[package]]
151 | name = "importlib-metadata"
152 | version = "4.8.2"
153 | description = "Read metadata from Python packages"
154 | category = "main"
155 | optional = false
156 | python-versions = ">=3.6"
157 |
158 | [package.dependencies]
159 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
160 | zipp = ">=0.5"
161 |
162 | [package.extras]
163 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
164 | perf = ["ipython"]
165 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
166 |
167 | [[package]]
168 | name = "mccabe"
169 | version = "0.6.1"
170 | description = "McCabe checker, plugin for flake8"
171 | category = "dev"
172 | optional = false
173 | python-versions = "*"
174 |
175 | [[package]]
176 | name = "mypy"
177 | version = "0.910"
178 | description = "Optional static typing for Python"
179 | category = "dev"
180 | optional = false
181 | python-versions = ">=3.5"
182 |
183 | [package.dependencies]
184 | mypy-extensions = ">=0.4.3,<0.5.0"
185 | toml = "*"
186 | typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""}
187 | typing-extensions = ">=3.7.4"
188 |
189 | [package.extras]
190 | dmypy = ["psutil (>=4.0)"]
191 | python2 = ["typed-ast (>=1.4.0,<1.5.0)"]
192 |
193 | [[package]]
194 | name = "mypy-extensions"
195 | version = "0.4.3"
196 | description = "Experimental type system extensions for programs checked with the mypy typechecker."
197 | category = "dev"
198 | optional = false
199 | python-versions = "*"
200 |
201 | [[package]]
202 | name = "nixops"
203 | version = "2.0.0"
204 | description = "NixOS cloud provisioning and deployment tool"
205 | category = "main"
206 | optional = false
207 | python-versions = "^3.7"
208 | develop = false
209 |
210 | [package.dependencies]
211 | pluggy = "^0.13.1"
212 | PrettyTable = "^0.7.2"
213 | typeguard = "^2.7.1"
214 | typing-extensions = "^3.7.4"
215 |
216 | [package.source]
217 | type = "git"
218 | url = "https://github.com/NixOS/nixops.git"
219 | reference = "master"
220 | resolved_reference = "0c989d79c9052ebf52f12964131f4fc31ac20a18"
221 |
222 | [[package]]
223 | name = "nixos-modules-contrib"
224 | version = "0.1.0"
225 | description = "NixOS modules that don't quite belong in NixOS."
226 | category = "main"
227 | optional = false
228 | python-versions = "^3.7"
229 | develop = false
230 |
231 | [package.dependencies]
232 | nixops = {git = "https://github.com/NixOS/nixops.git", rev = "master"}
233 |
234 | [package.source]
235 | type = "git"
236 | url = "https://github.com/nix-community/nixos-modules-contrib.git"
237 | reference = "master"
238 | resolved_reference = "81a1c2ef424dcf596a97b2e46a58ca73a1dd1ff8"
239 |
240 | [[package]]
241 | name = "nose"
242 | version = "1.3.7"
243 | description = "nose extends unittest to make testing easier"
244 | category = "dev"
245 | optional = false
246 | python-versions = "*"
247 |
248 | [[package]]
249 | name = "paramiko"
250 | version = "2.8.1"
251 | description = "SSH2 protocol library"
252 | category = "main"
253 | optional = false
254 | python-versions = "*"
255 |
256 | [package.dependencies]
257 | bcrypt = ">=3.1.3"
258 | cryptography = ">=2.5"
259 | pynacl = ">=1.0.1"
260 |
261 | [package.extras]
262 | all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
263 | ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"]
264 | gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
265 | invoke = ["invoke (>=1.3)"]
266 |
267 | [[package]]
268 | name = "pathspec"
269 | version = "0.9.0"
270 | description = "Utility library for gitignore style pattern matching of file paths."
271 | category = "dev"
272 | optional = false
273 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
274 |
275 | [[package]]
276 | name = "pluggy"
277 | version = "0.13.1"
278 | description = "plugin and hook calling mechanisms for python"
279 | category = "main"
280 | optional = false
281 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
282 |
283 | [package.dependencies]
284 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
285 |
286 | [package.extras]
287 | dev = ["pre-commit", "tox"]
288 |
289 | [[package]]
290 | name = "prettytable"
291 | version = "0.7.2"
292 | description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format."
293 | category = "main"
294 | optional = false
295 | python-versions = "*"
296 |
297 | [[package]]
298 | name = "proxmoxer"
299 | version = "1.2.0"
300 | description = "Python Wrapper for the Proxmox 2.x API (HTTP and SSH)"
301 | category = "main"
302 | optional = false
303 | python-versions = "*"
304 |
305 | [[package]]
306 | name = "pycodestyle"
307 | version = "2.7.0"
308 | description = "Python style guide checker"
309 | category = "dev"
310 | optional = false
311 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
312 |
313 | [[package]]
314 | name = "pycparser"
315 | version = "2.21"
316 | description = "C parser in Python"
317 | category = "main"
318 | optional = false
319 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
320 |
321 | [[package]]
322 | name = "pyflakes"
323 | version = "2.3.1"
324 | description = "passive checker of Python programs"
325 | category = "dev"
326 | optional = false
327 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
328 |
329 | [[package]]
330 | name = "pynacl"
331 | version = "1.4.0"
332 | description = "Python binding to the Networking and Cryptography (NaCl) library"
333 | category = "main"
334 | optional = false
335 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
336 |
337 | [package.dependencies]
338 | cffi = ">=1.4.1"
339 | six = "*"
340 |
341 | [package.extras]
342 | docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
343 | tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]
344 |
345 | [[package]]
346 | name = "regex"
347 | version = "2021.11.10"
348 | description = "Alternative regular expression module, to replace re."
349 | category = "dev"
350 | optional = false
351 | python-versions = "*"
352 |
353 | [[package]]
354 | name = "requests"
355 | version = "2.26.0"
356 | description = "Python HTTP for Humans."
357 | category = "main"
358 | optional = false
359 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
360 |
361 | [package.dependencies]
362 | certifi = ">=2017.4.17"
363 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
364 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
365 | urllib3 = ">=1.21.1,<1.27"
366 |
367 | [package.extras]
368 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
369 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
370 |
371 | [[package]]
372 | name = "six"
373 | version = "1.16.0"
374 | description = "Python 2 and 3 compatibility utilities"
375 | category = "main"
376 | optional = false
377 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
378 |
379 | [[package]]
380 | name = "toml"
381 | version = "0.10.2"
382 | description = "Python Library for Tom's Obvious, Minimal Language"
383 | category = "main"
384 | optional = false
385 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
386 |
387 | [[package]]
388 | name = "typed-ast"
389 | version = "1.4.3"
390 | description = "a fork of Python 2 and 3 ast modules with type comment support"
391 | category = "dev"
392 | optional = false
393 | python-versions = "*"
394 |
395 | [[package]]
396 | name = "typeguard"
397 | version = "2.13.3"
398 | description = "Run-time type checker for Python"
399 | category = "main"
400 | optional = false
401 | python-versions = ">=3.5.3"
402 |
403 | [package.extras]
404 | doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
405 | test = ["pytest", "typing-extensions", "mypy"]
406 |
407 | [[package]]
408 | name = "typing-extensions"
409 | version = "3.10.0.2"
410 | description = "Backported and Experimental Type Hints for Python 3.5+"
411 | category = "main"
412 | optional = false
413 | python-versions = "*"
414 |
415 | [[package]]
416 | name = "urllib3"
417 | version = "1.26.7"
418 | description = "HTTP library with thread-safe connection pooling, file post, and more."
419 | category = "main"
420 | optional = false
421 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
422 |
423 | [package.extras]
424 | brotli = ["brotlipy (>=0.6.0)"]
425 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
426 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
427 |
428 | [[package]]
429 | name = "zipp"
430 | version = "3.6.0"
431 | description = "Backport of pathlib-compatible object wrapper for zip files"
432 | category = "main"
433 | optional = false
434 | python-versions = ">=3.6"
435 |
436 | [package.extras]
437 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
438 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
439 |
440 | [metadata]
441 | lock-version = "1.1"
442 | python-versions = "^3.7"
443 | content-hash = "6cb9cd9cc598893081f851e2909c3afceb6121d1d898ce8a0316357cafc99880"
444 |
445 | [metadata.files]
446 | appdirs = [
447 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
448 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
449 | ]
450 | attrs = [
451 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
452 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
453 | ]
454 | bcrypt = [
455 | {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"},
456 | {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"},
457 | {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"},
458 | {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"},
459 | {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"},
460 | {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"},
461 | {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"},
462 | ]
463 | black = [
464 | {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
465 | {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
466 | ]
467 | certifi = [
468 | {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
469 | {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
470 | ]
471 | cffi = [
472 | {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
473 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
474 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
475 | {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
476 | {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
477 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
478 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
479 | {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
480 | {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
481 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
482 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
483 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
484 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
485 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
486 | {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
487 | {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
488 | {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
489 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
490 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
491 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
492 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
493 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
494 | {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
495 | {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
496 | {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
497 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
498 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
499 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
500 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
501 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
502 | {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
503 | {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
504 | {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
505 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
506 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
507 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
508 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
509 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
510 | {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
511 | {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
512 | {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
513 | {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
514 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
515 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
516 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
517 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
518 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
519 | {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
520 | {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
521 | {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
522 | ]
523 | charset-normalizer = [
524 | {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"},
525 | {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"},
526 | ]
527 | click = [
528 | {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
529 | {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
530 | ]
531 | colorama = [
532 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
533 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
534 | ]
535 | cryptography = [
536 | {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"},
537 | {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"},
538 | {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"},
539 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"},
540 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"},
541 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"},
542 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"},
543 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"},
544 | {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"},
545 | {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"},
546 | {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"},
547 | {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"},
548 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"},
549 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"},
550 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"},
551 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"},
552 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"},
553 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"},
554 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"},
555 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"},
556 | {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"},
557 | ]
558 | flake8 = [
559 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
560 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
561 | ]
562 | idna = [
563 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
564 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
565 | ]
566 | importlib-metadata = [
567 | {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"},
568 | {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"},
569 | ]
570 | mccabe = [
571 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
572 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
573 | ]
574 | mypy = [
575 | {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"},
576 | {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"},
577 | {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"},
578 | {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"},
579 | {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"},
580 | {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"},
581 | {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"},
582 | {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"},
583 | {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"},
584 | {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"},
585 | {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"},
586 | {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"},
587 | {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"},
588 | {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"},
589 | {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"},
590 | {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"},
591 | {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"},
592 | {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"},
593 | {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"},
594 | {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"},
595 | {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"},
596 | {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"},
597 | {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"},
598 | ]
599 | mypy-extensions = [
600 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
601 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
602 | ]
603 | nixops = []
604 | nixos-modules-contrib = []
605 | nose = [
606 | {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"},
607 | {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"},
608 | {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"},
609 | ]
610 | paramiko = [
611 | {file = "paramiko-2.8.1-py2.py3-none-any.whl", hash = "sha256:7b5910f5815a00405af55da7abcc8a9e0d9657f57fcdd9a89894fdbba1c6b8a8"},
612 | {file = "paramiko-2.8.1.tar.gz", hash = "sha256:85b1245054e5d7592b9088cc6d08da22445417912d3a3e48138675c7a8616438"},
613 | ]
614 | pathspec = [
615 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
616 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
617 | ]
618 | pluggy = [
619 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
620 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
621 | ]
622 | prettytable = [
623 | {file = "prettytable-0.7.2.tar.bz2", hash = "sha256:853c116513625c738dc3ce1aee148b5b5757a86727e67eff6502c7ca59d43c36"},
624 | {file = "prettytable-0.7.2.tar.gz", hash = "sha256:2d5460dc9db74a32bcc8f9f67de68b2c4f4d2f01fa3bd518764c69156d9cacd9"},
625 | {file = "prettytable-0.7.2.zip", hash = "sha256:a53da3b43d7a5c229b5e3ca2892ef982c46b7923b51e98f0db49956531211c4f"},
626 | ]
627 | proxmoxer = [
628 | {file = "proxmoxer-1.2.0.tar.gz", hash = "sha256:d1261c1cefd4d4faa6c654922c8db72cfee51e811e5ede4eb38a48cc62dac80e"},
629 | ]
630 | pycodestyle = [
631 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
632 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
633 | ]
634 | pycparser = [
635 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
636 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
637 | ]
638 | pyflakes = [
639 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
640 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
641 | ]
642 | pynacl = [
643 | {file = "PyNaCl-1.4.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff"},
644 | {file = "PyNaCl-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514"},
645 | {file = "PyNaCl-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574"},
646 | {file = "PyNaCl-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"},
647 | {file = "PyNaCl-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7"},
648 | {file = "PyNaCl-1.4.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122"},
649 | {file = "PyNaCl-1.4.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d"},
650 | {file = "PyNaCl-1.4.0-cp35-abi3-win32.whl", hash = "sha256:4e10569f8cbed81cb7526ae137049759d2a8d57726d52c1a000a3ce366779634"},
651 | {file = "PyNaCl-1.4.0-cp35-abi3-win_amd64.whl", hash = "sha256:c914f78da4953b33d4685e3cdc7ce63401247a21425c16a39760e282075ac4a6"},
652 | {file = "PyNaCl-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4"},
653 | {file = "PyNaCl-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25"},
654 | {file = "PyNaCl-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4"},
655 | {file = "PyNaCl-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6"},
656 | {file = "PyNaCl-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f"},
657 | {file = "PyNaCl-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f"},
658 | {file = "PyNaCl-1.4.0-cp38-cp38-win32.whl", hash = "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96"},
659 | {file = "PyNaCl-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420"},
660 | {file = "PyNaCl-1.4.0.tar.gz", hash = "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505"},
661 | ]
662 | regex = [
663 | {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"},
664 | {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"},
665 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"},
666 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"},
667 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"},
668 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"},
669 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"},
670 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"},
671 | {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"},
672 | {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"},
673 | {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"},
674 | {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"},
675 | {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"},
676 | {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"},
677 | {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"},
678 | {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"},
679 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"},
680 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"},
681 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"},
682 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"},
683 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"},
684 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"},
685 | {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"},
686 | {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"},
687 | {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"},
688 | {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"},
689 | {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"},
690 | {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"},
691 | {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"},
692 | {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"},
693 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"},
694 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"},
695 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"},
696 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"},
697 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"},
698 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"},
699 | {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"},
700 | {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"},
701 | {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"},
702 | {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"},
703 | {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"},
704 | {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"},
705 | {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"},
706 | {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"},
707 | {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"},
708 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"},
709 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"},
710 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"},
711 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"},
712 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"},
713 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"},
714 | {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"},
715 | {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"},
716 | {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"},
717 | {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"},
718 | {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"},
719 | {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"},
720 | {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"},
721 | {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"},
722 | {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"},
723 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"},
724 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"},
725 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"},
726 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"},
727 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"},
728 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"},
729 | {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"},
730 | {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"},
731 | {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"},
732 | {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"},
733 | {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"},
734 | {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"},
735 | {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"},
736 | {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"},
737 | ]
738 | requests = [
739 | {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
740 | {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
741 | ]
742 | six = [
743 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
744 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
745 | ]
746 | toml = [
747 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
748 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
749 | ]
750 | typed-ast = [
751 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
752 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
753 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
754 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
755 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
756 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
757 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
758 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
759 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
760 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
761 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
762 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
763 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
764 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
765 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
766 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
767 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
768 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
769 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
770 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
771 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
772 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
773 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
774 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
775 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
776 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
777 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
778 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
779 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
780 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
781 | ]
782 | typeguard = [
783 | {file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"},
784 | {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"},
785 | ]
786 | typing-extensions = [
787 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
788 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
789 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
790 | ]
791 | urllib3 = [
792 | {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
793 | {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
794 | ]
795 | zipp = [
796 | {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"},
797 | {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"},
798 | ]
799 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "nixops_proxmox"
3 | version = "1.0"
4 | description = "NixOps Proxmox plugin"
5 | authors = ["Ryan Lahfa "]
6 | license = "LGPL-3.0-only"
7 | include = [ "nixops_proxmox/nix/*.nix" ]
8 |
9 | [tool.poetry.dependencies]
10 | python = "^3.7"
11 | nixops = {git = "https://github.com/NixOS/nixops.git", rev = "master"}
12 | typing-extensions = "^3.7.4"
13 | nixos-modules-contrib = {git = "https://github.com/nix-community/nixos-modules-contrib.git", rev = "master"}
14 | proxmoxer = "^1.1.1"
15 | requests = "^2.24.0"
16 | paramiko = "^2.7.1"
17 | toml = "^0.10.2"
18 |
19 | [tool.poetry.dev-dependencies]
20 | nose = "^1.3.7"
21 | mypy = "^0.910"
22 | black = "^19.10b0"
23 | flake8 = "^3.8.2"
24 |
25 | [tool.poetry.plugins."nixops"]
26 | proxmox = "nixops_proxmox.plugin"
27 |
28 | [build-system]
29 | requires = ["poetry>=0.12"]
30 | build-backend = "poetry.masonry.api"
31 |
--------------------------------------------------------------------------------
/release.nix:
--------------------------------------------------------------------------------
1 | { nixpkgs ?
2 | }:
3 |
4 | let
5 | pkgs = import nixpkgs { config = {}; overlays = []; };
6 |
7 | in rec {
8 |
9 | nixops-proxmox = pkgs.lib.genAttrs [ "x86_64-linux" "i686-linux" "x86_64-darwin" ] (system:
10 | let
11 | pkgs = import nixpkgs { inherit system; };
12 | nixops-proxmox = import ./default.nix { inherit pkgs; };
13 | in nixops-proxmox);
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version = 3.7
3 | no_implicit_optional = True
4 | strict_optional = True
5 | check_untyped_defs = True
6 |
--------------------------------------------------------------------------------
/shell.nix:
--------------------------------------------------------------------------------
1 | { pkgs ? import {} }:
2 | let
3 | overrides = import ./overrides.nix { inherit pkgs; };
4 | in pkgs.mkShell {
5 | buildInputs = [
6 | (pkgs.poetry2nix.mkPoetryEnv {
7 | projectDir = ./.;
8 | overrides = pkgs.poetry2nix.overrides.withDefaults overrides;
9 | })
10 | pkgs.poetry
11 | ];
12 | }
--------------------------------------------------------------------------------