Compare commits

..

10 commits

Author SHA1 Message Date
7cfacc3306 Save before deployment 2025-03-26 12:56:52 +01:00
b4fbc457a6 Progress Indicator (#259)
closes #74
Show progress indicator to track deployment

- Disable deploy button when deployment is in progress.

Co-authored-by: kevin <kevin@procolix.com>
Reviewed-on: Fediversity/Fediversity#259
Reviewed-by: kiara Grouwstra <kiara@procolix.eu>
2025-03-26 10:14:06 +01:00
d78995b34c
make re-exports explicit again
Fediversity/Fediversity#269 (comment)
2025-03-25 08:43:45 +01:00
a5c310ad03 refactor variables (#269)
Reviewed-on: Fediversity/Fediversity#269
Reviewed-by: Valentin Gagarin <valentin.gagarin@tweag.io>
Co-authored-by: Kiara Grouwstra <kiara@procolix.eu>
Co-committed-by: Kiara Grouwstra <kiara@procolix.eu>
2025-03-24 10:04:43 +01:00
f8ac63853c source htmx by nix (#268)
Reviewed-on: Fediversity/Fediversity#268
Reviewed-by: Valentin Gagarin <valentin.gagarin@tweag.io>
Co-authored-by: Kiara Grouwstra <kiara@procolix.eu>
Co-committed-by: Kiara Grouwstra <kiara@procolix.eu>
2025-03-24 08:41:16 +01:00
af18b39b63 clean up shebang of manage.py (#271)
Reviewed-on: Fediversity/Fediversity#271
Reviewed-by: Valentin Gagarin <valentin.gagarin@tweag.io>
Co-authored-by: Kiara Grouwstra <kiara@procolix.eu>
Co-committed-by: Kiara Grouwstra <kiara@procolix.eu>
2025-03-24 08:38:13 +01:00
de33e888c7 fix typo 2025-03-20 13:11:18 +01:00
658fa7ff60 add TODO, reformat 2025-03-20 13:09:46 +01:00
ee70a0026d
restore env vars in dev shell 2025-03-20 13:06:39 +01:00
1caf95dde1
fix CI 2025-03-20 12:56:25 +01:00
15 changed files with 165 additions and 63 deletions

View file

@ -1,5 +1,20 @@
{ {
"pins": { "pins": {
"htmx": {
"type": "GitRelease",
"repository": {
"type": "GitHub",
"owner": "bigskysoftware",
"repo": "htmx"
},
"pre_releases": false,
"version_upper_bound": null,
"release_prefix": null,
"version": "v2.0.4",
"revision": "b82cf843e47e575dd8c2ad8fee547d8e2c3bb87f",
"url": "https://api.github.com/repos/bigskysoftware/htmx/tarball/v2.0.4",
"hash": "1c4zm3b7ym01ijydiss4amd14mv5fbgp1n71vqjk4alc35jlnqy2"
},
"nix-unit": { "nix-unit": {
"type": "Git", "type": "Git",
"repository": { "repository": {

1
panel/.gitignore vendored
View file

@ -10,4 +10,5 @@ __pycache__
db.sqlite3 db.sqlite3
src/db.sqlite3 src/db.sqlite3
src/static src/static
src/panel/static/htmx*
.credentials .credentials

View file

@ -8,6 +8,7 @@
}, },
}: }:
let let
inherit (pkgs) lib;
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} $@
''; '';
@ -19,13 +20,13 @@ in
pkgs.npins pkgs.npins
manage manage
]; ];
env = { env = import ./env.nix { inherit lib pkgs; } // {
NPINS_DIRECTORY = toString ../npins; NPINS_DIRECTORY = toString ../npins;
# explicitly use nix, as e.g. lix does not have configurable-impure-env CREDENTIALS_DIRECTORY = toString ./.credentials;
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
DATABASE_URL = "sqlite:///${toString ./src}/db.sqlite3"; DATABASE_URL = "sqlite:///${toString ./src}/db.sqlite3";
}; };
shellHook = '' shellHook = ''
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
mkdir -p $CREDENTIALS_DIRECTORY mkdir -p $CREDENTIALS_DIRECTORY

18
panel/env.nix Normal file
View file

@ -0,0 +1,18 @@
{
lib,
pkgs,
...
}:
let
inherit (builtins) toString;
in
{
REPO_DIR = toString ../.;
# explicitly use nix, as e.g. lix does not have configurable-impure-env
BIN_PATH = lib.makeBinPath [
# explicitly use nix, as e.g. lix does not have configurable-impure-env
pkgs.nix
# nixops error maybe due to our flake git hook: executing 'git': No such file or directory
pkgs.git
];
}

View file

@ -23,7 +23,13 @@ let
cfg = config.services.${name}; cfg = config.services.${name};
package = pkgs.callPackage ./package.nix { }; package = pkgs.callPackage ./package.nix { };
database-url = "sqlite:////var/lib/${name}/db.sqlite3"; environment = import ../env.nix { inherit lib pkgs; } // {
DATABASE_URL = "sqlite:////var/lib/${name}/db.sqlite3";
USER_SETTINGS_FILE = pkgs.concatText "configuration.py" [
((pkgs.formats.pythonVars { }).generate "settings.py" cfg.settings)
(builtins.toFile "extra-settings.py" cfg.extra-settings)
];
};
python-environment = pkgs.python3.withPackages ( python-environment = pkgs.python3.withPackages (
ps: with ps; [ ps: with ps; [
@ -32,11 +38,6 @@ let
] ]
); );
configFile = pkgs.concatText "configuration.py" [
((pkgs.formats.pythonVars { }).generate "settings.py" cfg.settings)
(builtins.toFile "extra-settings.py" cfg.extra-settings)
];
manage-service = writeShellApplication { manage-service = writeShellApplication {
name = "manage"; name = "manage";
text = ''exec ${package}/bin/manage.py "$@"''; text = ''exec ${package}/bin/manage.py "$@"'';
@ -56,8 +57,9 @@ let
--property "User=${name}" \ --property "User=${name}" \
--property "Group=${name}" \ --property "Group=${name}" \
--property "WorkingDirectory=/var/lib/${name}" \ --property "WorkingDirectory=/var/lib/${name}" \
--property "Environment=DATABASE_URL=${database-url} USER_SETTINGS_FILE=${configFile}" \ --property "Environment=''
'' + (toString (lib.mapAttrsToList (name: value: "${name}=${value}") environment))
+ "\" \\\n"
+ optionalString (credentials != [ ]) ( + optionalString (credentials != [ ]) (
(concatStringsSep " \\\n" (map (cred: "--property 'LoadCredential=${cred}'") credentials)) + " \\\n" (concatStringsSep " \\\n" (map (cred: "--property 'LoadCredential=${cred}'") credentials)) + " \\\n"
) )
@ -190,12 +192,25 @@ in
RuntimeDirectory = name; RuntimeDirectory = name;
LogsDirectory = name; LogsDirectory = name;
} // lib.optionalAttrs (credentials != [ ]) { LoadCredential = credentials; }; } // lib.optionalAttrs (credentials != [ ]) { LoadCredential = credentials; };
environment = {
USER_SETTINGS_FILE = "${configFile}"; # TODO(@fricklerhandwerk):
DATABASE_URL = database-url; # Unify handling of runtime settings.
NIX_BIN = lib.getExe pkgs.nix; # Right now we have four(!) places where we need to set environment variables, each in its own format:
REPO_DIR = ../..; # - Django's `settings.py` declaring the setting
}; # - the development environment
# - the `manage` command
# - here, the service configuration
# Ideally we'd set them in two places (development environment and service configuration) but in the same format.
#
# For that we need to take into account
# - the different types of settings
# - secrets, which must not end up in the store
# - other values, which can be world-readable
# - ergonomics
# - manipulation should be straightforward in both places; e.g. dumping secrets to a directory that is not git-tracked and adding values to an attrset otherwise
# - error detection and correction; it should be clear where and why one messed up so it can be fixed immediately
# We may also want to test the development environment in CI in order to make sure that we don't break it inadvertently, because misconfiguration due to multiplpe sources of truth wastes a lot of time.
inherit environment;
}; };
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [

View file

@ -2,6 +2,7 @@
lib, lib,
sqlite, sqlite,
python3, python3,
sources ? import ../../npins,
}: }:
let let
src = src =
@ -58,5 +59,6 @@ python3.pkgs.buildPythonPackage {
cp -v ${src}/manage.py $out/bin/manage.py cp -v ${src}/manage.py $out/bin/manage.py
chmod +x $out/bin/manage.py chmod +x $out/bin/manage.py
wrapProgram $out/bin/manage.py --prefix PYTHONPATH : "$PYTHONPATH" wrapProgram $out/bin/manage.py --prefix PYTHONPATH : "$PYTHONPATH"
cp ${sources.htmx}/dist/htmx.min.js* $out/${python3.sitePackages}/panel/static/
''; '';
} }

View file

@ -1,4 +1,4 @@
#!/nix/store/px2nj16i5gc3d4mnw5l1nclfdxhry61p-python3-3.12.7/bin/python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys

View file

@ -192,8 +192,8 @@ if user_settings_file is not None:
# The correct thing to do here would be using a helper function such as with `get_secret()` that will catch the exception and explain what's wrong and where to put the right values. # The correct thing to do here would be using a helper function such as with `get_secret()` that will catch the exception and explain what's wrong and where to put the right values.
# Replacing the `USER_SETTINGS_FILE` mechanism following the comment there would probably be a good thing. # Replacing the `USER_SETTINGS_FILE` mechanism following the comment there would probably be a good thing.
# a dir of nix supporting experimental feature `configurable-impure-env`. # PATH to expose to launch button
nix_bin=env['NIX_BIN'] bin_path=env['BIN_PATH']
# path of the root flake to trigger nixops from, see #94. # path of the root flake to trigger nixops from, see #94.
# to deploy this should be specified, for dev just use a relative path. # to deploy this should be specified, for dev just use a relative path.
repo_dir = env["REPO_DIR"] repo_dir = env["REPO_DIR"]

1
panel/src/panel/static/htmx.min.js vendored Symbolic link
View file

@ -0,0 +1 @@
/nix/store/mwqqk0qmldzvv4xj9kq2lbah2flhc44z-source/dist/htmx.js

View file

@ -3,3 +3,24 @@ body
margin: 0 margin: 0
font-family: sans-serif font-family: sans-serif
box-sizing: border-box box-sizing: border-box
.loader
width: 48px
height: 48px
border: 5px solid #000
border-bottom-color: #F34508
border-radius: 50%
box-sizing: border-box
animation: rotation 1s linear infinite
display: inline-block
@keyframes rotation
0% { transform: rotate(0deg) }
100% { transform: rotate(360deg) }
#spinner-container
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
display: block

View file

@ -5,6 +5,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/static/htmx.min.js"></script>
{% load compress %} {% load compress %}
{% compress css %} {% compress css %}

View file

@ -1,11 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data" action="{% url 'configuration_form' %}"> <form method="post" enctype="multipart/form-data" action="{% url 'save' %}">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button id="deploy-button" class="button"
hx-post="{% url 'deployment_status' %}"
hx-trigger="click"
hx-indicator="#spinner-container"
hx-disabled-elt="this"
hx-swap="none"
name="deploy">
Deploy
</button>
<button class="button" type="submit" name="deploy">Deploy</button>
<button class="button" type="submit" name="save">Save</button> <button class="button" type="submit" name="save">Save</button>
<div id="spinner-container" class="htmx-indicator">
<span class="loader"></span>
</div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -13,7 +13,7 @@ class ConfigurationForm(TestCase):
password=self.password password=self.password
) )
self.config_url = reverse('configuration_form') self.config_url = reverse('save')
def test_configuration_form_submission(self): def test_configuration_form_submission(self):
config = Configuration.objects.create( config = Configuration.objects.create(
@ -36,12 +36,13 @@ class ConfigurationForm(TestCase):
enable=True, enable=True,
mastodon_enable=True, mastodon_enable=True,
) )
print(form_data)
response = self.client.post(self.config_url, data=form_data) response = self.client.post(self.config_url, data=form_data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
config.refresh_from_db() config.refresh_from_db()
print(config.parsed_value)
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

View file

@ -26,4 +26,6 @@ urlpatterns = [
path("account/", views.AccountDetail.as_view(), name='account_detail'), path("account/", views.AccountDetail.as_view(), name='account_detail'),
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("save/", views.Save.as_view(), name='save'),
] ]

View file

@ -1,6 +1,7 @@
from enum import Enum from enum import Enum
import json import json
import subprocess import subprocess
import os
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -9,9 +10,9 @@ from django.views.generic import TemplateView, DetailView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from panel import models, settings from panel import models, settings
from panel import models
from panel.configuration import forms from panel.configuration import forms
class Index(TemplateView): class Index(TemplateView):
template_name = 'index.html' template_name = 'index.html'
@ -33,50 +34,12 @@ class ConfigurationForm(LoginRequiredMixin, FormView):
success_url = reverse_lazy('configuration_form') success_url = reverse_lazy('configuration_form')
form_class = forms.Form form_class = forms.Form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
def get_object(self): def get_object(self):
"""Get or create the configuration object for the current user""" """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,
) )
# Check for deploy button
if "deploy" in self.request.POST.keys():
submission = obj.parsed_value.model_dump_json()
# FIXME: let the user specify these from the form (#190)
dummy_user = {
"initialUser": {
"displayName": "Testy McTestface",
"username": "test",
"password": "testtest",
"email": "test@test.com",
},
}
# serialize back and forth now we still need to manually inject the dummy user
deployment = json.dumps(dummy_user | json.loads(submission))
env = {
# pass in form info to our deployment
"DEPLOYMENT": deployment,
}
cmd = [
settings.nix_bin,
"develop",
# workaround to pass in info to nixops4 thru env vars, tho impure :(
"--extra-experimental-features",
"configurable-impure-env",
"--command",
"nixops4",
"apply",
"test",
]
subprocess.run(
cmd,
cwd=settings.repo_dir,
env=env,
)
return obj return obj
# TODO(@fricklerhandwerk): # TODO(@fricklerhandwerk):
@ -119,9 +82,58 @@ class ConfigurationForm(LoginRequiredMixin, FormView):
initial.update(self.convert_enums_to_names(config_dict)) initial.update(self.convert_enums_to_names(config_dict))
return initial return initial
class Save(ConfigurationForm):
def form_valid(self, form): def form_valid(self, form):
obj = self.get_object() obj = self.get_object()
obj.value = form.to_python().model_dump_json() obj.value = form.to_python().model_dump_json()
obj.save() obj.save()
return super().form_valid(form) return super().form_valid(form)
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
if "deploy" in self.request.POST.keys():
self.deployment(obj)
return super().form_valid(form)
def deployment(self, obj):
submission = obj.parsed_value.model_dump_json()
# FIXME: let the user specify these from the form (#190)
dummy_user = {
"initialUser": {
"displayName": "Testy McTestface",
"username": "test",
"password": "testtest",
"email": "test@test.com",
},
}
# serialize back and forth now we still need to manually inject the dummy user
deployment = json.dumps(dummy_user | json.loads(submission))
env = {
"PATH": settings.bin_path,
# pass in form info to our deployment
"DEPLOYMENT": deployment,
}
cmd = [
"nix",
"develop",
"--extra-experimental-features",
"configurable-impure-env",
"--command",
"nixops4",
"apply",
"test",
]
deployment_result = subprocess.run(
cmd,
cwd=settings.repo_dir,
env=env,
)
print(deployment_result)
return deployment_result