generate-module-options-rebase #1

Closed
kiara wants to merge 10 commits from generate-module-options-rebase into generate-module-options
15 changed files with 802 additions and 240 deletions

View file

@ -29,153 +29,177 @@
## From the hosting provider's perspective, the function is meant to be ## From the hosting provider's perspective, the function is meant to be
## partially applied only until here. ## partially applied only until here.
## Information on the specific deployment that we request. This is the ## Information on the specific deployment that we request.
## information coming from the FediPanel. ## This is the information coming from the FediPanel.
## deployment-configuration:
## FIXME: lock step the interface with the definitions in the FediPanel
panelConfig:
let let
inherit (lib) mkIf; inherit (lib) mkIf;
in in
## Regular arguments of a NixOps4 deployment module. ## Regular arguments of a NixOps4 deployment module.
{ providers, ... }: { config, providers, ... }:
let
panelConfig = config.deployment;
in
{ {
providers = { inherit (nixops4.modules.nixops4Provider) local; }; options = {
deployment = lib.mkOption {
resources = description = ''
let Configuration to be deployed
## NOTE: All of these secrets are publicly available in this source file '';
## and will end up in the Nix store. We don't care as they are only ever # XXX(@fricklerhandwerk):
## used for testing anyway. # misusing this will produce obscure errors that will be truncated by NixOps4
## type = lib.types.submodule ./options.nix;
## FIXME: Generate and store in NixOps4's state.
mastodonS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK3515373e4c851ebaad366558";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34";
};
peertubeS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK1f9feea9960f6f95ff404c9b";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7295c4201966a02c2c3d25b5cea4a5ff782966a2415e3a196f91924631191395";
};
pixelfedS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GKb5615457d44214411e673b7b";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "5be6799a88ca9b9d813d1a806b64f15efa49482dbe15339ddfaf7f19cf434987";
};
makeConfigurationResource = resourceModule: config: {
type = providers.local.exec;
imports = [
nixops4-nixos.modules.nixops4Resource.nixos
resourceModule
{
## NOTE: With NixOps4, there are several levels and all of them live
## in the NixOS module system:
##
## 1. Each NixOps4 deployment is a module.
## 2. Each NixOps4 resource is a module. This very comment is
## inside an attrset imported as a module in a resource.
## 3. Each NixOps4 'configuration' resource contains an attribute
## 'nixos.module', itself a NixOS configuration module.
nixos.module =
{ ... }:
{
imports = [
config
fediversity
];
};
}
];
};
in
{
garage-configuration = makeConfigurationResource garageConfigurationResource (
{ pkgs, ... }:
mkIf (panelConfig.mastodon.enable || panelConfig.peertube.enable || panelConfig.pixelfed.enable) {
fediversity = {
inherit (panelConfig) domain;
garage.enable = true;
pixelfed = pixelfedS3KeyConfig { inherit pkgs; };
mastodon = mastodonS3KeyConfig { inherit pkgs; };
peertube = peertubeS3KeyConfig { inherit pkgs; };
};
}
);
mastodon-configuration = makeConfigurationResource mastodonConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.mastodon.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
mastodon = mastodonS3KeyConfig { inherit pkgs; } // {
enable = true;
};
temp.cores = 1; # FIXME: should come from NixOps4 eventually
};
}
);
peertube-configuration = makeConfigurationResource peertubeConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.peertube.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
peertube = peertubeS3KeyConfig { inherit pkgs; } // {
enable = true;
## NOTE: Only ever used for testing anyway.
##
## FIXME: Generate and store in NixOps4's state.
secretsFile = pkgs.writeText "secret" "574e093907d1157ac0f8e760a6deb1035402003af5763135bae9cbd6abe32b24";
};
};
}
);
pixelfed-configuration = makeConfigurationResource pixelfedConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.pixelfed.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
pixelfed = pixelfedS3KeyConfig { inherit pkgs; } // {
enable = true;
};
};
}
);
}; };
};
config = {
deployment = deployment-configuration;
#// {
# TODO(@fricklerhandwerk):
# the Clan JSON schema converter always marks "object" types as optional,
# which means we need to deal with that *somewhere*, and as a hack it's done here.
# right now, since initial form contents are produced from Pydantic default values, we get None for those nested objects, which translates to `null` here.
#mastodon = optionalAttrs (isNull deployment-configuration.mastodon) { };
#peertube = optionalAttrs (isNull deployment-configuration.peertube) { };
#pixelfed = optionalAttrs (isNull deployment-configuration.pixelfed) { };
#};
providers = {
inherit (nixops4.modules.nixops4Provider) local;
};
resources =
let
## NOTE: All of these secrets are publicly available in this source file
## and will end up in the Nix store. We don't care as they are only ever
## used for testing anyway.
##
## FIXME: Generate and store in NixOps4's state.
mastodonS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK3515373e4c851ebaad366558";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34";
};
peertubeS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK1f9feea9960f6f95ff404c9b";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7295c4201966a02c2c3d25b5cea4a5ff782966a2415e3a196f91924631191395";
};
pixelfedS3KeyConfig =
{ pkgs, ... }:
{
s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GKb5615457d44214411e673b7b";
s3SecretKeyFile = pkgs.writeText "s3SecretKey" "5be6799a88ca9b9d813d1a806b64f15efa49482dbe15339ddfaf7f19cf434987";
};
makeConfigurationResource = resourceModule: deployment-configuration: {
type = providers.local.exec;
imports = [
nixops4-nixos.modules.nixops4Resource.nixos
resourceModule
{
## NOTE: With NixOps4, there are several levels and all of them live
## in the NixOS module system:
##
## 1. Each NixOps4 deployment is a module.
## 2. Each NixOps4 resource is a module. This very comment is
## inside an attrset imported as a module in a resource.
## 3. Each NixOps4 'configuration' resource contains an attribute
## 'nixos.module', itself a NixOS configuration module.
nixos.module =
{ ... }:
{
imports = [
deployment-configuration
fediversity
];
};
}
];
};
in
{
garage-configuration = makeConfigurationResource garageConfigurationResource (
{ pkgs, ... }:
mkIf (panelConfig.mastodon.enable || panelConfig.peertube.enable || panelConfig.pixelfed.enable) {
fediversity = {
inherit (panelConfig) domain;
garage.enable = true;
pixelfed = pixelfedS3KeyConfig { inherit pkgs; };
mastodon = mastodonS3KeyConfig { inherit pkgs; };
peertube = peertubeS3KeyConfig { inherit pkgs; };
};
}
);
mastodon-configuration = makeConfigurationResource mastodonConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.mastodon.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
mastodon = mastodonS3KeyConfig { inherit pkgs; } // {
enable = true;
};
temp.cores = 1; # FIXME: should come from NixOps4 eventually
};
}
);
peertube-configuration = makeConfigurationResource peertubeConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.peertube.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
peertube = peertubeS3KeyConfig { inherit pkgs; } // {
enable = true;
## NOTE: Only ever used for testing anyway.
##
## FIXME: Generate and store in NixOps4's state.
secretsFile = pkgs.writeText "secret" "574e093907d1157ac0f8e760a6deb1035402003af5763135bae9cbd6abe32b24";
};
};
}
);
pixelfed-configuration = makeConfigurationResource pixelfedConfigurationResource (
{ pkgs, ... }:
mkIf panelConfig.pixelfed.enable {
fediversity = {
inherit (panelConfig) domain;
temp.initialUser = {
inherit (panelConfig.initialUser) username email displayName;
# FIXME: disgusting, but nvm, this is going to be replaced by
# proper central authentication at some point
passwordFile = pkgs.writeText "password" panelConfig.initialUser.password;
};
pixelfed = pixelfedS3KeyConfig { inherit pkgs; } // {
enable = true;
};
};
}
);
};
};
} }

64
deployment/options.nix Normal file
View file

@ -0,0 +1,64 @@
/**
Deployment options as to be presented in the front end.
These are converted to JSON schema in order to generate front-end forms etc.
For this to work, options must not have types `functionTo` or `package`, and must not access `config` for their default values.
*/
{
lib,
...
}:
let
inherit (lib) types mkOption;
in
{
options = {
enable = lib.mkEnableOption "Fediversity configuration";
domain = mkOption {
type =
with types;
enum [
"fediversity.net"
];
description = ''
Apex domain under which the services will be deployed.
'';
default = "fediversity.net";
};
pixelfed = {
enable = lib.mkEnableOption "Pixelfed";
};
peertube = {
enable = lib.mkEnableOption "Peertube";
};
mastodon = {
enable = lib.mkEnableOption "Mastodon";
};
initialUser = mkOption {
description = ''
Some services require an initial user to access them.
This option sets the credentials for such an initial user.
'';
type = types.submodule {
options = {
displayName = mkOption {
type = types.str;
description = "Display name of the user";
};
username = mkOption {
type = types.str;
description = "Username for login";
};
email = mkOption {
type = types.str;
description = "User's email address";
};
password = mkOption {
type = types.str;
description = "Password for login";
};
};
};
};
};
}

View file

@ -1,5 +1,16 @@
{ {
"pins": { "pins": {
"clan-core": {
"type": "Git",
"repository": {
"type": "Git",
"url": "https://git.clan.lol/clan/clan-core"
},
"branch": "main",
"revision": "dcb2231332bb4ebc91663914a3bb05ffb875b6d9",
"url": null,
"hash": "15zvwbzkbm0m2zar3vf698sqk7s84vvprjkfl9hy43jk911qsdgh"
},
"htmx": { "htmx": {
"type": "GitRelease", "type": "GitRelease",
"repository": { "repository": {
@ -30,8 +41,8 @@
"nixpkgs": { "nixpkgs": {
"type": "Channel", "type": "Channel",
"name": "nixpkgs-unstable", "name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre711046.8edf06bea5bc/nixexprs.tar.xz", "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre776128.eb0e0f21f15c/nixexprs.tar.xz",
"hash": "1mwsn0rvfm603svrq3pca4c51zlix5gkyr4gl6pxhhq3q6xs5s8y" "hash": "0l04lkdi3slwwlgwyr8x0argzxcxm16a4hkijfxbjhlj44y1bkif"
} }
}, },
"version": 3 "version": 3

1
panel/.gitignore vendored
View file

@ -1,6 +1,7 @@
# Nix # Nix
.direnv .direnv
result* result*
src/panel/configuration/schema.py
# Python # Python
*.pyc *.pyc

View file

@ -12,12 +12,27 @@ let
manage = pkgs.writeScriptBin "manage" '' manage = pkgs.writeScriptBin "manage" ''
exec ${pkgs.lib.getExe pkgs.python3} ${toString ./src/manage.py} $@ exec ${pkgs.lib.getExe pkgs.python3} ${toString ./src/manage.py} $@
''; '';
jsonschema = pkgs.callPackage ./jsonschema.nix { } {
};
frontend-options = jsonschema.parseModule ../deployment/options.nix;
schema = with builtins; toFile "schema.json" (toJSON frontend-options);
codegen = "${pkgs.python3Packages.datamodel-code-generator}/bin/datamodel-codegen";
pydantic =
pkgs.runCommand "schema.py"
{
}
''
${codegen} --input ${schema} | sed '/from pydantic/a\
from drf_pydantic import BaseModel' > $out
'';
in in
{ {
inherit frontend-options;
shell = pkgs.mkShellNoCC { shell = pkgs.mkShellNoCC {
inputsFrom = [ (pkgs.callPackage ./nix/package.nix { }) ]; inputsFrom = [ (pkgs.callPackage ./nix/package.nix { }) ];
packages = [ packages = [
pkgs.npins pkgs.npins
pkgs.jq
manage manage
]; ];
env = import ./env.nix { inherit lib pkgs; } // { env = import ./env.nix { inherit lib pkgs; } // {
@ -26,6 +41,8 @@ in
DATABASE_URL = "sqlite:///${toString ./src}/db.sqlite3"; DATABASE_URL = "sqlite:///${toString ./src}/db.sqlite3";
}; };
shellHook = '' shellHook = ''
install -m 644 ${pydantic} ${builtins.toString ./src/panel/configuration/schema.py}
ln -sf ${sources.htmx}/dist/htmx.js src/panel/static/htmx.min.js ln -sf ${sources.htmx}/dist/htmx.js src/panel/static/htmx.min.js
# in production, secrets are passed via CREDENTIALS_DIRECTORY by systemd. # in production, secrets are passed via CREDENTIALS_DIRECTORY by systemd.
# use this directory for testing with local secrets # use this directory for testing with local secrets

434
panel/jsonschema.nix Normal file
View file

@ -0,0 +1,434 @@
# TODO(@fricklerhandwerk):
# temporarily copied from
# https://git.clan.lol/clan/clan-core/src/branch/main/lib/jsonschema/default.nix
# to work around a bug in JSON Schema generation; this needs a PR to upstream
{
lib,
}:
{
excludedTypes ? [
"functionTo"
"package"
],
includeDefaults ? true,
header ? {
"$schema" = "http://json-schema.org/draft-07/schema#";
},
specialArgs ? { },
}:
let
# remove _module attribute from options
clean = opts: builtins.removeAttrs opts [ "_module" ];
# throw error if option type is not supported
notSupported =
option:
lib.trace option throw ''
option type '${option.type.name}' ('${option.type.description}') not supported by jsonschema converter
location: ${lib.concatStringsSep "." option.loc}
'';
# Exclude the option if its type is in the excludedTypes list
# or if the option has a defaultText attribute
isExcludedOption = option: (lib.elem (option.type.name or null) excludedTypes);
filterExcluded = lib.filter (opt: !isExcludedOption opt);
excludedOptionNames = [ "_freeformOptions" ];
filterExcludedAttrs = lib.filterAttrs (
name: opt: !isExcludedOption opt && !builtins.elem name excludedOptionNames
);
# Filter out options where the visible attribute is set to false
filterInvisibleOpts = lib.filterAttrs (_name: opt: opt.visible or true);
# Constant: Used for the 'any' type
allBasicTypes = [
"boolean"
"integer"
"number"
"string"
"array"
"object"
"null"
];
in
rec {
# parses a nixos module to a jsonschema
parseModule =
module:
let
evaled = lib.evalModules {
modules = [ module ];
inherit specialArgs;
};
in
parseOptions evaled.options { };
# get default value from option
# Returns '{ default = Value; }'
# - '{}' if no default is present.
# - Value is "<thunk>" (string literal) if the option has a defaultText attribute. This means we cannot evaluate default safely
getDefaultFrom =
opt:
if !includeDefaults then
{ }
else if opt ? defaultText then
{
# dont add default to jsonschema. It seems to alter the type
# default = "<thunk>";
}
else
lib.optionalAttrs (opt ? default) {
default = opt.default;
};
parseSubOptions =
{
option,
prefix ? [ ],
}:
let
subOptions = option.type.getSubOptions option.loc;
in
parseOptions subOptions {
addHeader = false;
path = option.loc ++ prefix;
};
makeModuleInfo =
{
path,
defaultText ? null,
}:
{
"$exportedModuleInfo" =
{
inherit path;
}
// lib.optionalAttrs (defaultText != null) {
inherit defaultText;
};
};
# parses a set of evaluated nixos options to a jsonschema
parseOptions =
options:
{
# The top-level header object should specify at least the schema version
# Can be customized if needed
# By default the header is not added to the schema
addHeader ? true,
path ? [ ],
}:
let
options' = filterInvisibleOpts (filterExcludedAttrs (clean options));
# parse options to jsonschema properties
properties = lib.mapAttrs (_name: option: (parseOption' (path ++ [ _name ]) option)) options';
# TODO: figure out how to handle if prop.anyOf is used
isRequired = prop: !(prop ? default || prop."$exportedModuleInfo".required or false);
requiredProps = lib.filterAttrs (_: prop: isRequired prop) properties;
required = lib.optionalAttrs (requiredProps != { }) { required = lib.attrNames requiredProps; };
header' = if addHeader then header else { };
# freeformType is a special type
freeformDefs = options._module.freeformType.definitions or [ ];
checkFreeformDefs =
defs:
if (builtins.length defs) != 1 then
throw "parseOptions: freeformType definitions not supported"
else
defs;
# It seems that freeformType has [ null ]
freeformProperties =
if freeformDefs != [ ] && builtins.head freeformDefs != null then
# freeformType has only one definition
parseOption {
# options._module.freeformType.definitions
type = builtins.head (checkFreeformDefs freeformDefs);
_type = "option";
loc = path;
}
else
{ };
# Metadata about the module that is made available to the schema via '$propagatedModuleInfo'
exportedModuleInfo = makeModuleInfo {
inherit path;
};
in
# return jsonschema
header'
// exportedModuleInfo
// required
// {
type = "object";
inherit properties;
additionalProperties = false;
}
// freeformProperties;
# parses and evaluated nixos option to a jsonschema property definition
parseOption = parseOption' [ ];
parseOption' =
currentPath: option:
let
default = getDefaultFrom option;
example = lib.optionalAttrs (option ? example) {
examples =
if (builtins.typeOf option.example) == "list" then option.example else [ option.example ];
};
description = lib.optionalAttrs (option ? description) {
description = option.description.text or option.description;
};
exposedModuleInfo = lib.optionalAttrs true (makeModuleInfo {
path = option.loc;
defaultText = option.defaultText or null;
});
in
# either type
# TODO: if all nested options are excluded, the parent should be excluded too
if
option.type.name or null == "either" || option.type.name or null == "coercedTo"
# return jsonschema property definition for either
then
let
optionsList' = [
{
type = option.type.nestedTypes.left or option.type.nestedTypes.coercedType;
_type = "option";
loc = option.loc;
}
{
type = option.type.nestedTypes.right or option.type.nestedTypes.finalType;
_type = "option";
loc = option.loc;
}
];
optionsList = filterExcluded optionsList';
in
exposedModuleInfo // default // example // description // { oneOf = map parseOption optionsList; }
# handle nested options (not a submodule)
# foo.bar = mkOption { type = str; };
else if !option ? _type then
(parseOptions option {
addHeader = false;
path = currentPath;
})
# throw if not an option
else if option._type != "option" && option._type != "option-type" then
throw "parseOption: not an option"
# parse nullOr
else if
option.type.name == "nullOr"
# return jsonschema property definition for nullOr
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
oneOf = [
{ type = "null"; }
] ++ (lib.optional (!isExcludedOption nestedOption) (parseOption nestedOption));
}
# parse bool
else if
option.type.name == "bool"
# return jsonschema property definition for bool
then
exposedModuleInfo // default // example // description // { type = "boolean"; }
# parse float
else if
option.type.name == "float"
# return jsonschema property definition for float
then
exposedModuleInfo // default // example // description // { type = "number"; }
# parse int
else if
(option.type.name == "int" || option.type.name == "positiveInt")
# return jsonschema property definition for int
then
exposedModuleInfo // default // example // description // { type = "integer"; }
# TODO: Add support for intMatching in jsonschema
# parse port type aka. "unsignedInt16"
else if
option.type.name == "unsignedInt16"
|| option.type.name == "unsignedInt"
|| option.type.name == "pkcs11"
|| option.type.name == "intBetween"
then
exposedModuleInfo // default // example // description // { type = "integer"; }
# parse string
# TODO: parse more precise string types
else if
option.type.name == "str"
|| option.type.name == "singleLineStr"
|| option.type.name == "passwdEntry str"
|| option.type.name == "passwdEntry path"
# return jsonschema property definition for string
then
exposedModuleInfo // default // example // description // { type = "string"; }
# TODO: Add support for stringMatching in jsonschema
# parse stringMatching
else if lib.strings.hasPrefix "strMatching" option.type.name then
exposedModuleInfo // default // example // description // { type = "string"; }
# TODO: Add support for separatedString in jsonschema
else if lib.strings.hasPrefix "separatedString" option.type.name then
exposedModuleInfo // default // example // description // { type = "string"; }
# parse string
else if
option.type.name == "path"
# return jsonschema property definition for path
then
exposedModuleInfo // default // example // description // { type = "string"; }
# parse anything
else if
option.type.name == "anything"
# return jsonschema property definition for anything
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse unspecified
else if
option.type.name == "unspecified"
# return jsonschema property definition for unspecified
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse raw
else if
option.type.name == "raw"
# return jsonschema property definition for raw
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse enum
else if
option.type.name == "enum"
# return jsonschema property definition for enum
then
exposedModuleInfo
// default
// example
// description
// {
enum = option.type.functor.payload.values;
}
# parse listOf submodule
else if
option.type.name == "listOf" && option.type.nestedTypes.elemType.name == "submodule"
# return jsonschema property definition for listOf submodule
then
default
// exposedModuleInfo
// example
// description
// {
type = "array";
items = parseSubOptions { inherit option; };
}
# parse list
else if
(option.type.name == "listOf")
# return jsonschema property definition for list
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
type = "array";
}
// (lib.optionalAttrs (!isExcludedOption nestedOption) { items = parseOption nestedOption; })
# parse list of unspecified
else if
(option.type.name == "listOf") && (option.type.nestedTypes.elemType.name == "unspecified")
# return jsonschema property definition for list
then
exposedModuleInfo // default // example // description // { type = "array"; }
# parse attrsOf submodule
else if
option.type.name == "attrsOf" && option.type.nestedTypes.elemType.name == "submodule"
# return jsonschema property definition for attrsOf submodule
then
default
// exposedModuleInfo
// example
// description
// {
type = "object";
additionalProperties = parseSubOptions {
inherit option;
prefix = [ "<name>" ];
};
}
# parse attrs
else if
option.type.name == "attrs"
# return jsonschema property definition for attrs
then
default
// (lib.reqursiveUpdate exposedModuleInfo {
"$exportedModuleInfo" = {
required = true;
};
})
// example
// description
// {
type = "object";
additionalProperties = true;
}
# parse attrsOf
# TODO: if nested option is excluded, the parent should be excluded too
else if
option.type.name == "attrsOf" || option.type.name == "lazyAttrsOf"
# return jsonschema property definition for attrs
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
type = "object";
additionalProperties =
if !isExcludedOption nestedOption then
parseOption {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
}
else
false;
}
# parse submodule
else if
option.type.name == "submodule"
# return jsonschema property definition for submodule
# then (lib.attrNames (option.type.getSubOptions option.loc).opt)
then
exposedModuleInfo // example // description // parseSubOptions { inherit option; }
# throw error if option type is not supported
else
notSupported option;
}

View file

@ -45,6 +45,7 @@ python3.pkgs.buildPythonPackage {
django-debug-toolbar django-debug-toolbar
django-libsass django-libsass
django-pydantic-field django-pydantic-field
drf-pydantic
django_4 django_4
setuptools setuptools
]; ];

View file

@ -0,0 +1,40 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
django,
pydantic,
hatchling,
djangorestframework,
}:
buildPythonPackage rec {
pname = "drf-pydantic";
version = "v2.7.1";
pyproject = true;
src = fetchFromGitHub {
owner = "georgebv";
repo = pname;
rev = version;
hash = "sha256-ABtSoxj/+HHq4hj4Yb6bEiyOl00TCO/9tvBzhv6afxM=";
};
nativeBuildInputs = [
setuptools
hatchling
];
propagatedBuildInputs = [
django
pydantic
djangorestframework
];
meta = with lib; {
description = "";
homepage = "https://github.com/${src.owner}/${pname}";
license = licenses.mit;
};
}

View file

@ -1,13 +1,13 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from panel.configuration import forms from panel.configuration import schema
from pydantic import BaseModel from pydantic import BaseModel
def get_default_config(): def get_default_config():
return forms.Configuration().model_dump_json() return schema.Model().model_dump_json()
class Configuration(models.Model): class Configuration(models.Model):
@ -26,4 +26,4 @@ class Configuration(models.Model):
@property @property
def parsed_value(self) -> BaseModel: def parsed_value(self) -> BaseModel:
return forms.Configuration.model_validate_json(self.value) return schema.Model.model_validate_json(self.value)

View file

@ -61,6 +61,7 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django_pydantic_field', 'django_pydantic_field',
'rest_framework',
'debug_toolbar', 'debug_toolbar',
'compressor', 'compressor',
] ]

View file

@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load rest_framework %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data" action="{% url 'save' %}"> <form method="post" enctype="multipart/form-data" action="{% url 'configuration_form' %}">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {% render_form serializer %}
<button id="deploy-button" class="button" <button id="deploy-button" class="button"
hx-post="{% url 'deployment_status' %}" hx-post="{% url 'deployment_status' %}"
hx-trigger="click" hx-trigger="click"

View file

@ -5,7 +5,7 @@
{% for service_name, service_meta in services.items %} {% for service_name, service_meta in services.items %}
{% if service_meta.enable %} {% if service_meta.enable %}
<li> <li>
<a target="_blank" href={{ service_meta.url }}>{{ service_name }}</a> <a target="_blank" href=https://{{ service_name }}.{{ services.domain }}>{{ service_name }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -13,7 +13,7 @@ class ConfigurationForm(TestCase):
password=self.password password=self.password
) )
self.config_url = reverse('save') self.config_url = reverse('configuration_form')
def test_configuration_form_submission(self): def test_configuration_form_submission(self):
config = Configuration.objects.create( config = Configuration.objects.create(
@ -27,15 +27,13 @@ class ConfigurationForm(TestCase):
context = response.context[0] context = response.context[0]
# configuration should be disabled by default # configuration should be disabled by default
self.assertFalse(context['view'].get_object().parsed_value.enable) self.assertFalse(context['serializer'].instance.enable)
# ...and be displayed as such
self.assertFalse(context['form'].initial["enable"])
form_data = context['form'].initial.copy() form_data = context['serializer'].instance.dict().copy()
form_data.update( form_data = {
enable=True, "enable": True,
mastodon_enable=True, "mastodon.enable": True,
) }
print(form_data) print(form_data)
response = self.client.post(self.config_url, data=form_data) response = self.client.post(self.config_url, data=form_data)
@ -46,4 +44,4 @@ class ConfigurationForm(TestCase):
self.assertTrue(config.parsed_value.enable) self.assertTrue(config.parsed_value.enable)
self.assertTrue(config.parsed_value.mastodon.enable) self.assertTrue(config.parsed_value.mastodon.enable)
# this should not have changed # this should not have changed
self.assertFalse(config.parsed_value.peertube.enable) self.assertIsNone(config.parsed_value.peertube)

View file

@ -27,5 +27,4 @@ urlpatterns = [
path("services/", views.ServiceList.as_view(), name='service_list'), path("services/", views.ServiceList.as_view(), name='service_list'),
path("configuration/", views.ConfigurationForm.as_view(), name='configuration_form'), path("configuration/", views.ConfigurationForm.as_view(), name='configuration_form'),
path("deployment/status/", views.DeploymentStatus.as_view(), name='deployment_status'), path("deployment/status/", views.DeploymentStatus.as_view(), name='deployment_status'),
path("save/", views.Save.as_view(), name='save'),
] ]

View file

@ -8,11 +8,16 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.views.generic import TemplateView, DetailView from django.views.generic import TemplateView, DetailView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from django.shortcuts import render, redirect
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.response import Response
from rest_framework.views import APIView
from pydantic import BaseModel
from django.shortcuts import render
from panel import models, settings from panel import models, settings
from panel.configuration import forms from panel.configuration import schema
class Index(TemplateView): class Index(TemplateView):
template_name = 'index.html' template_name = 'index.html'
@ -30,100 +35,68 @@ class ServiceList(TemplateView):
template_name = 'service_list.html' template_name = 'service_list.html'
class ConfigurationForm(LoginRequiredMixin, FormView): class ConfigurationForm(LoginRequiredMixin, APIView):
renderer_classes = [TemplateHTMLRenderer]
template_name = 'configuration_form.html' template_name = 'configuration_form.html'
success_url = reverse_lazy('configuration_form') success_url = reverse_lazy('configuration_form')
form_class = forms.Form
def get_object(self): def get_object(self):
"""Get or create the configuration object for the current user"""
obj, created = models.Configuration.objects.get_or_create( obj, created = models.Configuration.objects.get_or_create(
operator=self.request.user, operator=self.request.user,
) )
return obj return obj
# TODO(@fricklerhandwerk): def get(self, request):
# this should probably live somewhere else
def convert_enums_to_names(self, data_dict):
"""
Recursively convert all enum values in a dictionary to their string names.
This handles nested dictionaries and lists as well.
Needed for converting a Pydantic `BaseModel` instance to a `Form` input.
"""
if isinstance(data_dict, dict):
result = {}
for key, value in data_dict.items():
if isinstance(value, Enum):
# Convert Enum to its name
result[key] = value.name
elif isinstance(value, (dict, list)):
# Recursively process nested structures
result[key] = self.convert_enums_to_names(value)
else:
# Keep other values as is
result[key] = value
return result
elif isinstance(data_dict, list):
# Process each item in the list
return [self.convert_enums_to_names(item) for item in data_dict]
elif isinstance(data_dict, Enum):
# Convert single Enum value
return data_dict.name
else:
# Return non-dict, non-list, non-Enum values as is
return data_dict
def get_initial(self):
initial = super().get_initial()
config = self.get_object() config = self.get_object()
config_dict = config.parsed_value.model_dump() serializer = schema.Model.drf_serializer(instance=config.parsed_value)
return Response({
'serializer': serializer,
})
initial.update(self.convert_enums_to_names(config_dict)) def post(self, request):
return initial config = self.get_object()
serializer = schema.Model.drf_serializer(
instance=config.parsed_value,
data=request.data
)
if not serializer.is_valid():
return Response({'serializer': serializer})
class Save(ConfigurationForm): config.value = json.dumps(serializer.validated_data)
def form_valid(self, form): print(request.data)
obj = self.get_object() print(config.value)
obj.value = form.to_python().model_dump_json() config.save()
obj.save() return redirect(self.success_url)
return super().form_valid(form)
# TODO(@fricklerhandwerk):
# this is broken after changing the form view.
# but if there's no test for it, how do we know it ever worked in the first place?
class DeploymentStatus(ConfigurationForm): class DeploymentStatus(ConfigurationForm):
def form_valid(self, form):
obj = self.get_object()
obj.value = form.to_python().model_dump_json()
obj.save()
# Check for deploy button def post(self, request):
if "deploy" in self.request.POST.keys(): config = self.get_object()
deployment_result, deployment_params = self.deployment(obj) serializer = schema.Model.drf_serializer(
deployment_succeeded = deployment_result.returncode == 0 instance=config.parsed_value,
data=request.data
)
if not serializer.is_valid():
return Response({'serializer': serializer})
config.value = json.dumps(serializer.validated_data)
config.save()
deployment_result, deployment_params = self.deployment(config.parsed_value)
deployment_succeeded = deployment_result.returncode == 0
return render(self.request, "partials/deployment_result.html", { return render(self.request, "partials/deployment_result.html", {
"deployment_succeeded": deployment_succeeded, "deployment_succeeded": deployment_succeeded,
"services": { "services": deployment_params.json(),
"peertube": { })
"enable": deployment_params['peertube']['enable'],
"url": f"https://peertube.{deployment_params['domain']}",
},
"pixelfed":{
"enable": deployment_params['pixelfed']['enable'],
"url": f"https://pixelfed.{deployment_params['domain']}",
},
"mastodon": {
"enable": deployment_params['mastodon']['enable'],
"url": f"https://mastodon.{deployment_params['domain']}",
},
},
})
def deployment(self, obj): def deployment(self, config: BaseModel):
submission = obj.parsed_value.model_dump_json()
# FIXME: let the user specify these from the form (#190) # FIXME: let the user specify these from the form (#190)
dummy_user = { dummy_user = {
"initialUser": { "initialUser": {
@ -133,12 +106,10 @@ class DeploymentStatus(ConfigurationForm):
"email": "test@test.com", "email": "test@test.com",
}, },
} }
# serialize back and forth now we still need to manually inject the dummy user
deployment_params = json.dumps(dummy_user | json.loads(submission))
env = { env = {
"PATH": settings.bin_path, "PATH": settings.bin_path,
# pass in form info to our deployment # pass in form info to our deployment
"DEPLOYMENT": deployment_params, "DEPLOYMENT": config.json()
} }
cmd = [ cmd = [
"nix", "nix",
@ -155,4 +126,4 @@ class DeploymentStatus(ConfigurationForm):
cwd=settings.repo_dir, cwd=settings.repo_dir,
env=env, env=env,
) )
return deployment_result, json.loads(deployment_params) return deployment_result, config