Fediversity/deployment/data-model.nix
Kiara Grouwstra 860b4f44cc
factor out TF http back-end settings
Signed-off-by: Kiara Grouwstra <kiara@procolix.eu>
2025-10-30 20:03:10 +01:00

779 lines
28 KiB
Nix

{
pkgs,
lib,
config,
inputs,
sources ? import ../npins,
...
}:
let
inherit (lib) mkOption types;
inherit (lib.types)
attrTag
attrsOf
deferredModuleWith
functionTo
nullOr
optionType
raw
str
submodule
;
inherit (pkgs.callPackage ./utils.nix { }) toBash withPackages tfApply;
writeConfig =
{
system,
caller,
root-path,
deployment-type,
deployment-name,
args,
}:
# having a `caller` location and (serializable) `args`, we know
# enough to call it again to extract different info elsewhere later.
# we use this to make a deployment script using the desired nixos config,
# which would otherwise not be serializable, while nix also makes it hard to
# produce its derivation to pass thru without a `nix-instantiate` call,
# which in turn would need to be passed the (unserializable) nixos config.
builtins.toString (
pkgs.writers.writeText "configuration.nix" ''
import ${root-path}/deployment/nixos.nix {
system = "${system}";
configuration = (import "${root-path}/${caller}" (builtins.fromJSON "${
lib.replaceStrings [ "\"" ] [ "\\\"" ] (lib.strings.toJSON args)
}")).${deployment-name}.${deployment-type}.nixos-configuration;
}
''
);
functionType = submodule ./function.nix;
application-resources = submodule {
options.resources = mkOption {
# TODO: maybe transpose, and group the resources by type instead
type = attrsOf (
attrTag (
lib.mapAttrs (_name: resource: mkOption { type = submodule resource.request; }) config.resources
)
);
};
};
nixops4Deployment = types.deferredModuleWith {
staticModules = [
inputs.nixops4.modules.nixops4Deployment.default
{
_class = "nixops4Deployment";
_module.args = {
resourceProviderSystem = pkgs.system;
resources = { };
};
}
];
};
nixos-configuration = mkOption {
description = "A NixOS configuration.";
type = raw;
};
httpBackend = mkOption {
description = "environment variables to configure the TF HTTP back-end, see <https://developer.hashicorp.com/terraform/language/backend/http#configuration-variables>";
type = types.submodule (http-backend: {
options = {
value = mkOption {
readOnly = true;
default = lib.mapAttrs' (k: v: lib.nameValuePair "TF_HTTP_${lib.toUpper k}" (builtins.toString v)) {
inherit (http-backend.config)
address
update_method
lock_address
lock_method
unlock_address
unlock_method
username
password
skip_cert_verification
retry_max
retry_wait_min
retry_wait_max
;
};
};
address = mkOption {
description = "The address of the REST endpoint";
type = str;
};
update_method = mkOption {
description = "HTTP method to use when updating state.";
type = str;
default = "POST";
};
lock_address = mkOption {
description = "The address of the lock REST endpoint.";
type = str;
default = http-backend.config.address;
};
lock_method = mkOption {
description = "The HTTP method to use when locking.";
type = str;
default = "LOCK";
};
unlock_address = mkOption {
description = "The address of the unlock REST endpoint.";
type = str;
default = http-backend.config.address;
};
unlock_method = mkOption {
description = "The HTTP method to use when unlocking.";
type = str;
default = "UNLOCK";
};
username = mkOption {
description = "The username for HTTP basic authentication.";
type = str;
default = "basic";
};
password = mkOption {
description = "The password for HTTP basic authentication.";
type = str;
default = "fake-secret";
};
skip_cert_verification = mkOption {
description = "Whether to skip TLS verification.";
type = str;
default = "false";
};
retry_max = mkOption {
description = "The number of HTTP request retries.";
type = types.int;
default = 2;
};
retry_wait_min = mkOption {
description = "The minimum time in seconds to wait between HTTP request attempts.";
type = types.int;
default = 1;
};
retry_wait_max = mkOption {
description = "The maximum time in seconds to wait between HTTP request attempts.";
type = types.int;
default = 30;
};
};
});
};
host-ssh = mkOption {
description = "SSH connection info to connect to a single host.";
type = submodule {
options = {
host = mkOption {
description = "the host to access by SSH";
type = str;
};
username = mkOption {
description = "the SSH user to use";
type = nullOr str;
default = null;
};
key-file = mkOption {
description = "path to the user's SSH private key";
type = nullOr str;
example = "/root/.ssh/id_ed25519";
};
sshOpts = mkOption {
description = "Extra SSH options (`-o`) to use.";
type = types.listOf str;
default = [ ];
example = "ConnectTimeout=60";
};
};
};
};
# FIXME allow custom deployment types
# FIXME make deployments environment resources?
deployment-type = attrTag {
ssh-host = mkOption {
description = "A deployment by SSH to update a single existing NixOS host.";
type = submodule (ssh-host: {
options = {
system = mkOption {
description = "The architecture of the system to deploy to.";
type = types.str;
};
inherit nixos-configuration;
ssh = host-ssh;
caller = mkOption {
description = "The calling module to obtain the NixOS configuration from.";
type = types.str;
};
args = mkOption {
description = "The arguments with which to call the module to obtain the NixOS configuration.";
type = types.attrs;
};
deployment-name = mkOption {
description = "The name of the deployment for which to obtain the NixOS configuration.";
type = types.str;
default = "default";
};
root-path = mkOption {
description = "The path to the root of the repository.";
type = types.path;
};
run = mkOption {
type = types.package;
# error: The option `ssh-deployment.ssh-host.run' is read-only, but it's set multiple times.
# readOnly = true;
default =
let
inherit (ssh-host.config)
system
ssh
caller
args
deployment-name
root-path
;
inherit (ssh)
host
username
key-file
sshOpts
;
environment = {
key_file = key-file;
ssh_opts = sshOpts;
inherit
host
username
;
nixos_conf = writeConfig {
inherit
system
caller
args
deployment-name
root-path
;
deployment-type = "ssh-host";
};
};
in
pkgs.writers.writeBashBin "deploy-sh.sh"
(withPackages [
pkgs.jq
])
''
env ${
toString (lib.mapAttrsToList (k: v: "${k}=\"${toBash v}\"") environment)
} bash ./deployment/run/ssh-single-host/run.sh
'';
};
};
});
};
nixops4 = mkOption {
description = "A NixOps4 NixOS deployment. For an example, see https://github.com/nixops4/nixops4-nixos/blob/main/example/deployment.nix.";
type = nixops4Deployment;
};
tf-host = mkOption {
description = "A Terraform deployment by SSH to update a single existing NixOS host.";
type = submodule (tf-host: {
options = {
system = mkOption {
description = "The architecture of the system to deploy to.";
type = types.str;
};
inherit httpBackend nixos-configuration;
ssh = host-ssh;
caller = mkOption {
description = "The calling module to obtain the NixOS configuration from.";
type = types.str;
};
args = mkOption {
description = "The arguments with which to call the module to obtain the NixOS configuration.";
type = types.attrs;
};
deployment-name = mkOption {
description = "The name of the deployment for which to obtain the NixOS configuration.";
type = types.str;
};
root-path = mkOption {
description = "The path to the root of the repository.";
type = types.path;
};
run = mkOption {
type = types.package;
# error: The option `tf-deployment.tf-host.run' is read-only, but it's set multiple times.
# readOnly = true;
default =
let
inherit (tf-host.config)
system
ssh
caller
args
deployment-name
root-path
httpBackend
;
inherit (ssh)
host
username
key-file
sshOpts
;
in
tfApply {
inherit httpBackend;
directory = "tf-single-host";
environment = {
key_file = key-file;
ssh_opts = sshOpts;
inherit
host
username
;
nixos_conf = writeConfig {
inherit
system
caller
args
deployment-name
root-path
;
deployment-type = "tf-host";
};
};
};
};
};
});
};
tf-proxmox-template = mkOption {
description = ''
A Terraform deployment to upload a virtual machine template to ProxmoX VE.
Proxmox credentials should be set using [environment variables]
(https://registry.terraform.io/providers/bpg/proxmox/latest/docs#environment-variables-summary)
with role `PVEDatastoreAdmin`.
'';
type = submodule (tf-host: {
options = {
system = mkOption {
description = "The architecture of the system to deploy to.";
type = types.str;
};
inherit httpBackend nixos-configuration;
ssh = host-ssh;
node-name = mkOption {
description = "the name of the ProxmoX node to use.";
type = types.str;
};
imageDatastoreId = mkOption {
description = "ID of the datastore of the image.";
type = types.str;
default = "local";
};
run = mkOption {
type = types.package;
# error: The option `tf-deployment.tf-host.run' is read-only, but it's set multiple times.
# readOnly = true;
default =
let
inherit (tf-host.config)
system
ssh
httpBackend
node-name
imageDatastoreId
;
inherit (ssh)
host
;
machine = import ./nixos.nix {
inherit sources system;
configuration = tf-host.config.nixos-configuration;
};
name = "fediversity-template";
# worse for cross-compilation, better for pre-/post-processing, needs manual `imageSize`, random failures: https://github.com/nix-community/disko/issues/550#issuecomment-2503736973
raw = "${machine.config.system.build.diskoImages}/main.raw";
environment = {
inherit
host
;
node_name = node-name;
image_datastore_id = imageDatastoreId;
};
in
lib.trace (lib.strings.toJSON environment) pkgs.writers.writeBashBin "deploy-tf-proxmox-template.sh"
(withPackages [
pkgs.qemu
])
''
set -e
# nixos-generate gives the burden of building revisions, while systemd-repart handles partitioning ~~at the burden of version revisions~~
# .qcow2 is around half the size of .raw, on top of supporting backups - be it apparently at the cost of performance
qemu-img convert -f raw -O qcow2 -C "${raw}" /tmp/${name}.qcow2
ls -l ${raw} >&2
ls -l /tmp/${name}.qcow2 >&2
checksum="$(sha256sum /tmp/${name}.qcow2 | cut -d " " -f1)"
env \
TF_VAR_image=/tmp/${name}.qcow2 \
TF_VAR_checksum="$checksum" \
${lib.getExe (tfApply {
inherit httpBackend environment;
directory = "tf-proxmox-template";
})}
'';
};
};
});
};
tf-proxmox-vm = mkOption {
description = ''
A Terraform deployment to provision and update a virtual machine on ProxmoX VE.
Proxmox credentials should be set using [environment variables]
(https://registry.terraform.io/providers/bpg/proxmox/latest/docs#environment-variables-summary)
with roles `PVEVMAdmin PVEDatastoreAdmin PVESDNUser`.
'';
type = submodule (tf-host: {
options = {
system = mkOption {
description = "The architecture of the system to deploy to.";
type = types.str;
};
inherit httpBackend nixos-configuration;
ssh = host-ssh;
caller = mkOption {
description = "The calling module to obtain the NixOS configuration from.";
type = types.str;
};
args = mkOption {
description = "The arguments with which to call the module to obtain the NixOS configuration.";
type = types.attrs;
};
deployment-name = mkOption {
description = "The name of the deployment for which to obtain the NixOS configuration.";
type = types.str;
};
root-path = mkOption {
description = "The path to the root of the repository.";
type = types.path;
};
node-name = mkOption {
description = "the name of the ProxmoX node to use.";
type = types.str;
};
bridge = mkOption {
description = "The name of the network bridge (defaults to vmbr0).";
type = types.str;
default = "vmbr0";
};
vlanId = mkOption {
description = "The VLAN identifier.";
type = types.int;
default = 0;
};
imageDatastoreId = mkOption {
description = "ID of the datastore of the image.";
type = types.str;
default = "local";
};
templateId = mkOption {
description = "ID of the template file from which to clone the VM.";
type = types.nullOr types.str;
example = "local:import/template.qcow2";
};
vmDatastoreId = mkOption {
description = "ID of the datastore of the VM.";
type = types.str;
default = "local";
};
cdDatastoreId = mkOption {
description = "ID of the datastore of the virtual CD-rom drive to use for cloud-init.";
type = types.str;
default = "local";
};
ipv4Gateway = mkOption {
description = "Gateway for IPv4.";
type = types.str;
default = "";
};
ipv4Address = mkOption {
description = "IPv4 address.";
type = types.str;
default = "";
};
ipv6Gateway = mkOption {
description = "Gateway for IPv6.";
type = types.str;
default = "";
};
ipv6Address = mkOption {
description = "IPv6 address.";
type = types.str;
default = "";
};
run = mkOption {
type = types.package;
# error: The option `tf-deployment.tf-host.run' is read-only, but it's set multiple times.
# readOnly = true;
default =
let
inherit (tf-host.config)
system
ssh
caller
args
deployment-name
httpBackend
root-path
node-name
bridge
vlanId
imageDatastoreId
templateId
vmDatastoreId
cdDatastoreId
ipv4Gateway
ipv4Address
ipv6Gateway
ipv6Address
;
inherit (ssh)
host
username
key-file
sshOpts
;
deployment-type = "tf-proxmox-vm";
nixos_conf = writeConfig {
inherit
system
caller
args
deployment-name
root-path
deployment-type
;
};
environment = {
key_file = key-file;
ssh_opts = sshOpts;
inherit
host
nixos_conf
bridge
;
node_name = node-name;
ssh_user = username;
vlan_id = vlanId;
image_datastore_id = imageDatastoreId;
template_id = templateId;
vm_datastore_id = vmDatastoreId;
cd_datastore_id = cdDatastoreId;
ipv4_gateway = ipv4Gateway;
ipv4_address = ipv4Address;
ipv6_gateway = ipv6Gateway;
ipv6_address = ipv6Address;
};
in
lib.trace (lib.strings.toJSON environment) (tfApply {
inherit httpBackend environment;
directory = "tf-proxmox-vm";
dependentDirs = [ "tf-single-host" ];
});
};
};
});
};
};
in
{
options = {
resources = mkOption {
description = "Collection of deployment resources that can be required by applications and policed by hosting providers";
type = attrsOf (
submodule (
{ ... }:
{
_class = "fediversity-resource";
options = {
description = mkOption {
description = "Description of the resource to help application module authors and hosting providers to work with it";
type = types.str;
};
request = mkOption {
description = "Options for declaring resource requirements by an application, a description of how the resource is consumed or accessed";
type = deferredModuleWith { staticModules = [ { _class = "fediversity-resource-request"; } ]; };
};
policy = mkOption {
description = "Options for configuring the resource policy for the hosting provider, a description of how the resource is made available";
type = deferredModuleWith {
staticModules = [
(policy: {
_class = "fediversity-resource-policy";
options.resource-type = mkOption {
description = "The type of resource this policy configures";
type = types.optionType;
};
# TODO(@fricklerhandwerk): we may want to make the function type explicit here: `application-resources -> resource-type`
# and then also rename this to be consistent with the application's resource mapping
options.apply = mkOption {
description = "Apply the policy to a request";
type = functionTo policy.config.resource-type;
};
})
];
};
};
};
}
)
);
};
applications = mkOption {
description = "Collection of Fediversity applications";
type = attrsOf (
submodule (application: {
_class = "fediversity-application";
options = {
description = mkOption {
description = "Description to be shown in the application overview";
type = types.str;
};
module = mkOption {
description = "Operator-facing configuration options for the application";
type = deferredModuleWith { staticModules = [ { _class = "fediversity-application-config"; } ]; };
};
implementation = mkOption {
description = "Mapping of application configuration to deployment resources, a description of what an application needs to run";
type = application.config.config-mapping.function-type;
};
resources = mkOption {
description = "Compute resources required by an application";
type = application.config.config-mapping.function-type;
readOnly = true;
default = application.config.config-mapping.apply;
};
# TODO(@fricklerhandwerk): this needs a better name
config-mapping = mkOption {
description = "Function type for the mapping from application configuration to required resources";
type = functionType;
readOnly = true;
default = {
input-type = submodule application.config.module;
output-type = application-resources;
implementation = application.config.implementation;
};
};
};
})
);
};
environments = mkOption {
description = "Run-time environments for Fediversity applications to be deployed to";
type = attrsOf (
submodule (environment: {
_class = "fediversity-environment";
options = {
resources = mkOption {
description = ''
Resources made available by the hosting provider, and their policies.
Setting this is optional, but provides a place to declare that information for programmatic use in the resource mapping.
'';
# TODO: maybe transpose, and group the resources by type instead
type = attrsOf (
attrTag (
lib.mapAttrs (_name: resource: mkOption { type = submodule resource.policy; }) config.resources
)
);
};
implementation = mkOption {
description = "Mapping of resources required by applications to available resources; the result can be deployed";
type = environment.config.resource-mapping.function-type;
};
resource-mapping = mkOption {
description = "Function type for the mapping from resources to a deployment";
type = functionType;
readOnly = true;
default = {
input-type = submodule {
options = {
deployment-name = mkOption {
type = types.str;
};
required-resources = mkOption {
type = attrsOf application-resources;
};
};
};
output-type = deployment-type;
implementation = environment.config.implementation;
};
};
config-mapping = mkOption {
description = "Mapping from a configuration to a deployment";
type = functionType;
readOnly = true;
default = {
input-type = submodule {
options = {
deployment-name = mkOption {
type = types.str;
};
configuration = mkOption {
type = config.configuration;
};
};
};
output-type = deployment-type;
implementation =
{
deployment-name,
configuration,
}:
# TODO: check cfg.enable.true
let
required-resources = lib.mapAttrs (
name: application-settings: config.applications.${name}.resources application-settings
) configuration.applications;
in
environment.config.resource-mapping.apply { inherit required-resources deployment-name; };
};
};
# TODO(@fricklerhandwerk): maybe this should be a separate thing such as `fediversity-setup`,
# which makes explicit which applications and environments are available.
# then the deployments can simply be the result of the function application baked into this module.
deployment = mkOption {
description = "Generate a deployment from a configuration, by applying an environment's resource policies to the applications' resource mappings";
type = environment.config.config-mapping.function-type;
readOnly = true;
default = environment.config.config-mapping.apply;
};
};
})
);
};
configuration = mkOption {
description = "Configuration type declaring options to be set by operators";
type = optionType;
readOnly = true;
default = submodule {
options = {
enable = lib.mkEnableOption "your Fediversity configuration";
applications = lib.mapAttrs (
_name: application:
mkOption {
description = application.description;
type = submodule application.module;
default = { };
}
) config.applications;
};
};
};
};
}