Compare commits
142 Commits
wip-enough
...
master
Author | SHA1 | Date |
---|---|---|
Loïc Dachary | eaff09efd4 | |
Loïc Dachary | d07a644552 | |
Loïc Dachary | 93c38fc1f9 | |
Loïc Dachary | 788e025b98 | |
Loïc Dachary | 1c165fcea3 | |
Aravinth Manivannan | 2e36a186ee | |
Loïc Dachary | ad925cddfc | |
Loïc Dachary | 2725b9b1f6 | |
Loïc Dachary | 0f1003dbe8 | |
Aravinth Manivannan | 187d22118f | |
Hostea dashboard | 9b9fb8362d | |
Loïc Dachary | 498b95848e | |
Loïc Dachary | b5afc49f5d | |
Hostea dashboard | 86fe9a2c59 | |
Hostea dashboard | 9d2a53f2e2 | |
Loïc Dachary | c7def47215 | |
Hostea dashboard | 9f5032bd28 | |
Hostea dashboard | 669a22a004 | |
Loïc Dachary | 0ab82fe0b4 | |
Hostea dashboard | 0e0b8db940 | |
Hostea dashboard | ac0775e075 | |
Hostea dashboard | 51e7ea4d5e | |
Loïc Dachary | 98973301c3 | |
Hostea dashboard | 3ebc518e1f | |
Aravinth Manivannan | 25db390e4d | |
Hostea dashboard | 6365b84d45 | |
Hostea dashboard | 41ca63244e | |
Hostea dashboard | 0e2112e30f | |
Loïc Dachary | 5b7cf06c7a | |
Hostea dashboard | 1792713952 | |
Loïc Dachary | 3019d9d739 | |
Hostea dashboard | bcb2e26f61 | |
Hostea dashboard | 011fb4816f | |
Hostea dashboard | 6c31555a52 | |
Hostea dashboard | 060e9b84d4 | |
Hostea dashboard | 418bb7dec0 | |
Loïc Dachary | c4375a43b2 | |
Loïc Dachary | 705c3a282b | |
Loïc Dachary | cfd5518518 | |
Aravinth Manivannan | a95158f3df | |
Aravinth Manivannan | b12cc044da | |
Aravinth Manivannan | cc12d1a77d | |
Aravinth Manivannan | cb6bce0c44 | |
Aravinth Manivannan | bebf18946a | |
Loïc Dachary | 6e84746a2c | |
Aravinth Manivannan | 2c8a5909cb | |
Aravinth Manivannan | ce0498b013 | |
Aravinth Manivannan | 809322d245 | |
Aravinth Manivannan | 2ee54a71e3 | |
Aravinth Manivannan | 438e34f7d6 | |
Aravinth Manivannan | 9c239ad78b | |
Aravinth Manivannan | 147eead388 | |
Aravinth Manivannan | f2f2fadae4 | |
Aravinth Manivannan | 5f6c3c459e | |
Loïc Dachary | 8d02fe107f | |
Aravinth Manivannan | b4183c1790 | |
Aravinth Manivannan | 22abe08f68 | |
Aravinth Manivannan | 4c51eb77b0 | |
Aravinth Manivannan | 9303ea59ed | |
Aravinth Manivannan | eb68b1e984 | |
Aravinth Manivannan | 5e5ce02759 | |
Aravinth Manivannan | 63f4f987a9 | |
Aravinth Manivannan | 4a1c0a5cdc | |
Aravinth Manivannan | 280807d96c | |
Aravinth Manivannan | e79fb65cdf | |
Aravinth Manivannan | c72773fc9e | |
Aravinth Manivannan | 8e7a11b9a4 | |
Loïc Dachary | ed0186912d | |
Loïc Dachary | 7c045b12d5 | |
Aravinth Manivannan | 5397e38d22 | |
Loïc Dachary | eefa2120a9 | |
Aravinth Manivannan | ccfa81ce2c | |
Aravinth Manivannan | 7464604928 | |
Aravinth Manivannan | 4a47543a0f | |
Aravinth Manivannan | e5ebdc29ce | |
Aravinth Manivannan | f86dd2ff37 | |
Aravinth Manivannan | 8fc20d16be | |
Aravinth Manivannan | bbcd373fe4 | |
Aravinth Manivannan | 412230bd99 | |
Aravinth Manivannan | 8be1e02a21 | |
Aravinth Manivannan | faca7286b7 | |
Aravinth Manivannan | 9d89bc071c | |
Aravinth Manivannan | f00746a36d | |
Aravinth Manivannan | 53ec0a3982 | |
Aravinth Manivannan | fc5a23e60a | |
Aravinth Manivannan | e4c418b45b | |
Aravinth Manivannan | 1bab17193c | |
Aravinth Manivannan | b123bfa582 | |
Aravinth Manivannan | 5ec87c83ec | |
Aravinth Manivannan | e63719764a | |
Aravinth Manivannan | f7c0e8e296 | |
Aravinth Manivannan | 49ae2189d4 | |
Aravinth Manivannan | 8baefeb413 | |
Aravinth Manivannan | e4a7310c79 | |
Aravinth Manivannan | d4ab0156a7 | |
Loïc Dachary | 1eaa22b330 | |
Aravinth Manivannan | 947479fc31 | |
Aravinth Manivannan | f68d051432 | |
Aravinth Manivannan | caadd0783a | |
Aravinth Manivannan | 71d4f793ba | |
Aravinth Manivannan | 927c2a7703 | |
Aravinth Manivannan | 0606c4ade0 | |
Aravinth Manivannan | d84021915f | |
Aravinth Manivannan | 026a1a4c12 | |
Aravinth Manivannan | 2dc1740aac | |
Aravinth Manivannan | 3318ca8da2 | |
Aravinth Manivannan | 8640fcf449 | |
Aravinth Manivannan | f99420f51a | |
Aravinth Manivannan | 8997d0ff0f | |
Aravinth Manivannan | ca8ffba55e | |
Aravinth Manivannan | 89d8206c34 | |
Aravinth Manivannan | 9d6c33f194 | |
Aravinth Manivannan | 0c0bb1bed7 | |
Aravinth Manivannan | 51b047fe40 | |
Aravinth Manivannan | 6388e4de10 | |
Aravinth Manivannan | 243880f6cc | |
Aravinth Manivannan | 9c64690c12 | |
Aravinth Manivannan | a4a34194f3 | |
Aravinth Manivannan | 0dac5121fd | |
Aravinth Manivannan | 6115e734e6 | |
Aravinth Manivannan | 442dd921a8 | |
Aravinth Manivannan | 4996e33cad | |
Aravinth Manivannan | b3ffe8c739 | |
Aravinth Manivannan | 28fe03b861 | |
Aravinth Manivannan | fb5267a13b | |
Aravinth Manivannan | f019039497 | |
Aravinth Manivannan | 3c33d10ca0 | |
Aravinth Manivannan | da318beb58 | |
Aravinth Manivannan | 997e772195 | |
Aravinth Manivannan | 798a2f03d9 | |
Aravinth Manivannan | 3fb756bd12 | |
Aravinth Manivannan | 2defc2d804 | |
Aravinth Manivannan | 08457c8bb2 | |
Aravinth Manivannan | 4b20f9a439 | |
Aravinth Manivannan | 4542389df8 | |
Aravinth Manivannan | 3378e61606 | |
Aravinth Manivannan | e7446dea2b | |
Loïc Dachary | adcdd00179 | |
Aravinth Manivannan | b387e44f49 | |
Aravinth Manivannan | 0d6968ff0a | |
Aravinth Manivannan | a39bcdb7b8 | |
Aravinth Manivannan | f0ee46e045 |
|
@ -0,0 +1,148 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
databse.db
|
||||
.env
|
||||
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
tmp/
|
||||
northstar.db
|
||||
instance
|
||||
northstar/static/docs/openapi
|
|
@ -1,5 +1,7 @@
|
|||
export DATABASE_URL=""
|
||||
export db=""
|
||||
export OIDC_RSA_PRIVATE_KEY=""
|
||||
export STRIPE_SECRET_KEY=""
|
||||
export STRIPE_PUBLIC_KEY=""
|
||||
export HOSTEA_INFRA_HOSTEA_REPO_REMOTE="ssh://git@localhost:22/hostea/fleet.git"
|
||||
export HOSTEA_META_FORGEJO_INSTANCE="http://localhost:3000"
|
||||
export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"
|
||||
|
|
|
@ -153,3 +153,5 @@ cython_debug/
|
|||
#.idea/
|
||||
keys
|
||||
htmlcov/
|
||||
tmp/
|
||||
static/
|
||||
|
|
|
@ -3,13 +3,16 @@ pipeline:
|
|||
image: python
|
||||
environment:
|
||||
- DATABSE_URL=postgres://postgres:password@database:5432/postgres
|
||||
- EMAIL_URL=smtp://admin:password@localhost:10025
|
||||
- EMAIL_URL=smtp://admin:password@smtp:10025
|
||||
- HOSTEA_INFRA_HOSTEA_REPO_REMOTE=ssh://git@forgejo:22/hostea/
|
||||
- HOSTEA_META_FORGEJO_INSTANCE=http://forgejo:3000
|
||||
commands:
|
||||
- export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"
|
||||
- pip install virtualenv
|
||||
- make env
|
||||
- make lint
|
||||
- make test
|
||||
- make coverage
|
||||
# - make integration-test
|
||||
secrets: [ STRIPE_PUBLIC_KEY, STRIPE_SECRET_KEY ]
|
||||
|
||||
services:
|
||||
|
@ -18,9 +21,15 @@ services:
|
|||
environment:
|
||||
- POSTGRES_PASSWORD=password
|
||||
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:1.18.0-1
|
||||
container_name: hostea-dash-forgejo
|
||||
|
||||
smtp:
|
||||
image: maildev/maildev
|
||||
image: maildev/maildev:latest
|
||||
container_name: hostea-dash-maildev
|
||||
environment:
|
||||
- MAILDEV_SMTP_PORT=10025
|
||||
- MAILDEV_WEB_PORT=1080
|
||||
- MAILDEV_INCOMING_USER=admin
|
||||
- MAILDEV_INCOMING_PASS=password
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
FROM python
|
||||
|
||||
LABEL org.opencontainers.image.source https://forgejo.hostea.org/Hostea/dashboard
|
||||
|
||||
RUN useradd -ms /bin/bash -u 1001 hostea
|
||||
RUN apt-get update && apt-get install -y ca-certificates git
|
||||
USER hostea
|
||||
|
||||
WORKDIR /home/hostea
|
||||
run mkdir app/
|
||||
WORKDIR /home/hostea/app/
|
||||
RUN pip3 install virtualenv
|
||||
RUN python3 -m virtualenv venv
|
||||
COPY requirements.txt .
|
||||
# See https://github.com/pypa/pip/issues/9819
|
||||
RUN ./venv/bin/pip install --use-feature=in-tree-build -r requirements.txt
|
||||
COPY . .
|
||||
#ENV . ./venv/bin/activate && make env
|
||||
CMD [ "./venv/bin/python3", "manage.py", "runserver", "0.0.0.0:8000" ]
|
13
Makefile
|
@ -9,6 +9,7 @@ endef
|
|||
|
||||
default: ## Run app
|
||||
$(call run_migrations)
|
||||
. ./venv/bin/activate && yes yes | python manage.py collectstatic
|
||||
. ./venv/bin/activate && python manage.py runserver
|
||||
|
||||
coverage: ## Generate test coverage report
|
||||
|
@ -25,6 +26,8 @@ docker: ## Build Docker image from source
|
|||
env: ## Install all dependencies
|
||||
@-virtualenv venv
|
||||
. ./venv/bin/activate && pip install -r requirements.txt
|
||||
. ./venv/bin/activate && ./integration/ci.sh init
|
||||
. ./venv/bin/activate && yes yes | python manage.py collectstatic
|
||||
|
||||
freeze: ## Freeze python dependencies
|
||||
@. ./venv/bin/activate && pip freeze > requirements.txt
|
||||
|
@ -32,13 +35,11 @@ freeze: ## Freeze python dependencies
|
|||
help: ## Prints help for targets with comments
|
||||
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
integration-test: ## run integration tests
|
||||
. ./venv/bin/activate && integration/tests.sh
|
||||
|
||||
lint: ## Run linter
|
||||
@./venv/bin/black ./dashboard/
|
||||
@./venv/bin/black ./accounts/
|
||||
@./venv/bin/black ./dash/
|
||||
@./venv/bin/black ./support/
|
||||
@./venv/bin/black ./billing/
|
||||
@./venv/bin/black ./infrastructure/
|
||||
@./venv/bin/black dashboard accounts dash support billing infrastructure integration
|
||||
|
||||
migrate: ## Run migrations
|
||||
$(call run_migrations)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Hostea dashboard
|
||||
|
||||
[![status-badge](https://woodpecker.hostea.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.hostea.org/Hostea/dashboard)
|
||||
[![status-badge](https://woodpecker.gna.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.gna.org/Hostea/dashboard)
|
||||
[![AGPL License](https://img.shields.io/badge/license-AGPL-blue.svg?style=flat-square)](http://www.gnu.org/licenses/agpl-3.0)
|
||||
[![Chat](https://img.shields.io/badge/matrix-hostea:matrix.batsense.net-purple?style=flat-square)](https://matrix.to/#/#hostea:matrix.batsense.net)
|
||||
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 4.0.3 on 2022-07-10 06:14
|
||||
|
||||
import accounts.utils
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("accounts", "0005_accountconfirmchallenge_created_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PasswordResetChallenge",
|
||||
fields=[
|
||||
(
|
||||
"public_ref",
|
||||
models.CharField(
|
||||
default=accounts.utils.gen_secret,
|
||||
editable=False,
|
||||
max_length=32,
|
||||
unique=True,
|
||||
verbose_name="Public referece to challenge text",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"challenge_text",
|
||||
models.CharField(
|
||||
default=accounts.utils.gen_secret,
|
||||
editable=False,
|
||||
max_length=32,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
verbose_name="Challenge text",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owned_by",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -52,3 +52,36 @@ class AccountConfirmChallenge(models.Model):
|
|||
|
||||
def pending_url(self):
|
||||
return reverse("accounts.verify.pending", args=(self.public_ref,))
|
||||
|
||||
|
||||
class PasswordResetChallenge(models.Model):
|
||||
owned_by = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
public_ref = models.CharField(
|
||||
"Public referece to challenge text",
|
||||
unique=True,
|
||||
max_length=32,
|
||||
default=gen_secret,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
|
||||
challenge_text = models.CharField(
|
||||
"Challenge text",
|
||||
unique=True,
|
||||
max_length=32,
|
||||
default=gen_secret,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.challenge_text}"
|
||||
|
||||
def verification_link(self):
|
||||
"""
|
||||
Get verification link
|
||||
"""
|
||||
return reverse("accounts.password.reset", args=(self.challenge_text,))
|
||||
|
||||
def pending_url(self):
|
||||
return reverse("accounts.password.reset.resend", args=(self.public_ref,))
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %}{% block title_name %} {% endblock %} | Hostea Dashbaord{% endblock %}
|
||||
{% block title %}{% block title_name %} {% endblock %} | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<main class="auth__main">
|
||||
<section class="main">
|
||||
<div class="title">
|
||||
<h1>Free Forge Ecosystem for Free Developers</h1>
|
||||
<h1><a href="https://forgejo.org">Forgejo</a> hosting and <a href="/forgejo-clinic/">service</a></h1>
|
||||
<p class="welcome">
|
||||
Hostea is a self-hostable libre software development suite comprising Gitea, Woodpecker CI, Librepages and GitPad with payments integration.
|
||||
A free forge ecosystem for free developers.
|
||||
</p>
|
||||
<ul class="index-banner__features-list">
|
||||
<li class="index-banner__features">Fully managed</li>
|
||||
<li class="index-banner__features">100% Free Software</li>
|
||||
<li class="index-banner__features">Fully Self-Hostable</li>
|
||||
<li class="index-banner__features">Observable and reliable</li>
|
||||
<li class="index-banner__features">Federation when available</li>
|
||||
<li class="index-banner__features">Radically transparent</li>
|
||||
<li class="index-banner__features">Horizontal community</li>
|
||||
<li class="index-banner__features">Run Hostea and become a service provider!</li>
|
||||
<li class="index-banner__features">Dedicated <a href="https://hosteadashboard.gna.org/register/">Forgejo hosting</a> and <a href="https://woodpecker-ci.org/">Woodpecker CI</a> from 10€/month</li>
|
||||
<li class="index-banner__features">Clinic to <a href="https://gna.org/forgejo-clinic/">heal sick Forgejo</a> instances</li>
|
||||
<li class="index-banner__features">100% <a href="https://www.gnu.org/philosophy/free-sw.html">Free Software</a></li>
|
||||
<li class="index-banner__features">Radically <a href="https://forum.gna.org/t/about-governance-and-decisions-in-hostea/55">Transparent</a></li>
|
||||
<li class="index-banner__features">Run by a <a href="https://forum.gna.org/t/about-governance-and-decisions-in-hostea/55">horizontal collective</a></li>
|
||||
<li class="index-banner__features">25% of the income <a href="https://forum.gna.org/t/decision-revenue-sharing-model/92">dedicated to sustain Free Software dependencies</a></li>
|
||||
<li class="index-banner__features">Committed to <a href="https://forgefriends.org/blog/2022/06/30/2022-06-state-forge-federation/">further forge federation</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -34,12 +34,12 @@
|
|||
/>
|
||||
</label>
|
||||
<div class="form__action-container">
|
||||
<a href="/forgot-password">Forgot password?</a>
|
||||
<a href="{% url 'accounts.password.reset.new' %}">Forgot password?</a>
|
||||
<button class="form__submit" type="submit">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="form__alt-action">
|
||||
New to Hostea?
|
||||
New to Gna!?
|
||||
<a href="{% url 'accounts.register' %}">Create an account</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Reset Password| Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
{% block main %}
|
||||
<div class="dialogue-box__container">
|
||||
<h2>Reset password</h2>
|
||||
<form
|
||||
action="{% url 'accounts.password.reset.new' %}"
|
||||
method="POST"
|
||||
class="form"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% include "common/components/error.html" %} {% csrf_token %}
|
||||
<label class="form__label" for="email">
|
||||
Email
|
||||
<input
|
||||
class="form__input"
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
/>
|
||||
</label>
|
||||
<div class="form__action-container">
|
||||
<button class="form__submit" type="submit">Send Password Reset Link</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Reset Password | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
{% block main %}
|
||||
<div class="dialogue-box__container">
|
||||
<h2>Reset password</h2>
|
||||
<p>Verification link is sent to email address: {{email}}</p>
|
||||
<form
|
||||
action="{% url 'accounts.password.reset.resend' public_ref=public_ref %}"
|
||||
method="POST"
|
||||
class="form"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% include "common/components/error.html" %} {% csrf_token %}
|
||||
<div class="form__action-container">
|
||||
<button class="form__submit" type="submit">Click here to resend email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,40 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Reset Password | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
{% block main %}
|
||||
<div class="dialogue-box__container">
|
||||
<h2>Reset Password</h2>
|
||||
<form
|
||||
action="{% url 'accounts.password.reset' challenge=challenge %}"
|
||||
method="POST"
|
||||
class="form"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% include "common/components/error.html" %} {% csrf_token %}
|
||||
<label class="form__label" for="password">
|
||||
password
|
||||
<input
|
||||
class="form__input"
|
||||
name="password"
|
||||
required
|
||||
id="password"
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form__label" for="confirm_password">
|
||||
Re-enter Password
|
||||
<input
|
||||
class="form__input"
|
||||
name="confirm_password"
|
||||
required
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
<div class="form__action-container">
|
||||
<button class="form__submit" type="submit">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Confirm Access | Hostea Dashbaord{% endblock %}
|
||||
{% block title %} Confirm Access | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "dash/common/components/primary-nav.html" %} {% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Confirm Account | Hostea Dashbaord{% endblock %}
|
||||
{% block title %} Confirm Account | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
{% block main %}
|
||||
<div class="dialogue-box__container">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "common/components/base.html" %}
|
||||
{% block title %} Confirm Account | Hostea Dashbaord{% endblock %}
|
||||
{% block title %} Confirm Account | Gna! Dashboard{% endblock %}
|
||||
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
|
||||
{% block main %}
|
||||
<div class="dialogue-box__container">
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
Hello {{ username }},
|
||||
|
||||
You have a new password!
|
||||
|
||||
Your password for signing in to Gna! was recently changed. If you made this change, then we're all set.
|
||||
|
||||
If you did not make this change, please reset your password to secure your account.
|
||||
|
||||
{% url 'accounts.password.reset.new' %}
|
||||
|
||||
Either way, feel free to reach out with any questions you might have. We're here to help.
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,9 @@
|
|||
Hello {{ email }},
|
||||
|
||||
Please click on the link below to reset your password:
|
||||
{{ link }}
|
||||
|
||||
If you don't recognise this activity, please delete this mail.
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,9 @@
|
|||
Hello {{ username }},
|
||||
|
||||
Please click on the link below to verify your email.
|
||||
{{ link }}
|
||||
|
||||
If you don't recognise this activity, please delete this mail.
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -2,20 +2,6 @@
|
|||
<div class="footer__container">
|
||||
<div class="footer__column">
|
||||
<span class="license__conatiner">
|
||||
<a class="license__link" rel="noreferrer" href="/docs" target="_blank"
|
||||
>Docs</a
|
||||
>
|
||||
<span class="footer__column-divider--mobile-visible">|</span>
|
||||
<a
|
||||
class="license__link"
|
||||
rel="noreferrer"
|
||||
href="https://www.eff.org/issues/do-not-track/amp/"
|
||||
target="_blank"
|
||||
>No AMP</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="footer__column">
|
||||
<a
|
||||
href="/"
|
||||
class="footer__link"
|
||||
|
@ -23,19 +9,24 @@
|
|||
rel="noopener"
|
||||
title="RSS"
|
||||
>Home</a>
|
||||
<div class="footer__column-divider">|</div>
|
||||
<span class="footer__column-divider--mobile-visible">|</span>
|
||||
<a class="license__link" rel="noreferrer" href="https://gna.org/about" target="_blank"
|
||||
> About</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<a href="mailto:{{ footer.admin_email }}" class="footer__link"
|
||||
>Contact Instance Maintainer</a
|
||||
>
|
||||
<div class="footer__column-divider">|</div>
|
||||
<a
|
||||
class="footer__link"
|
||||
href="{{ footer.source_code }}"
|
||||
href="{{ footer.source_code.link }}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Source Code"
|
||||
>
|
||||
v{{ footer.version }}-{{ footer.git_hash }}
|
||||
{{ footer.source_code.text }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,11 +3,8 @@
|
|||
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
|
||||
<div class="nav__header">
|
||||
<a class="nav__logo-container" href="/">
|
||||
<img src="{% static 'img/android-icon-48x48.png' %}"
|
||||
alt="Hostea temporary logo"/>
|
||||
<p class="nav__home-btn">
|
||||
ostea
|
||||
</p>
|
||||
<img class="nav__logo-img" src="{% static 'img/logo.png' %}"
|
||||
alt="Gna! logo"/>
|
||||
</a>
|
||||
<label class="nav__hamburger-menu" for="nav__toggle">
|
||||
<span class="nav__hamburger-inner"></span>
|
||||
|
|
|
@ -33,7 +33,7 @@ from django.conf import settings
|
|||
|
||||
from oauth2_provider.models import get_application_model
|
||||
|
||||
from .models import AccountConfirmChallenge
|
||||
from .models import AccountConfirmChallenge, PasswordResetChallenge
|
||||
from .management.commands.rm_unverified_users import (
|
||||
Command as CleanUnverifiedUsersCommand,
|
||||
)
|
||||
|
@ -77,7 +77,9 @@ class LoginTest(TestCase):
|
|||
Tests if login template renders
|
||||
"""
|
||||
resp = self.client.get(reverse("accounts.login"))
|
||||
self.assertEqual(b"Free Forge Ecosystem" in resp.content, True)
|
||||
self.assertEqual(
|
||||
b"A free forge ecosystem for free developers" in resp.content, True
|
||||
)
|
||||
|
||||
def test_login_works(self):
|
||||
"""
|
||||
|
@ -158,6 +160,71 @@ class LoginTest(TestCase):
|
|||
self.assertEqual(resp.headers["location"], reverse("dash.instances.list"))
|
||||
|
||||
|
||||
class ResetPasswordTest(TestCase):
|
||||
def setUp(self):
|
||||
self.username = "reset_password_user"
|
||||
register_util(t=self, username=self.username)
|
||||
|
||||
def reset_password(self):
|
||||
c = Client()
|
||||
payload = {
|
||||
"email": self.email,
|
||||
}
|
||||
resp = c.get(reverse("accounts.password.reset.new"))
|
||||
self.assertEqual(resp.status_code == 200)
|
||||
|
||||
resp = c.post(reverse("accounts.password.reset.new"), payload)
|
||||
self.assertEqual(resp.status_code == 302)
|
||||
challenge = PasswordResetChallenge.objects.filter(owned_by=self.user)
|
||||
self.assertEqual(resp.headers["location"] == challenge.pending_url(), True)
|
||||
|
||||
password_reset_mail = mail.outbox.pop()
|
||||
self.assertEqual("reset your password" in password_reset_mail, True)
|
||||
self.assertEqual(challenge.verification_link() in password_reset_mail, True)
|
||||
|
||||
resp = c.get(self.challenge.verification_link())
|
||||
self.assertEqual(resp.status_code == 200)
|
||||
|
||||
new_password = "newpasdasdf234234"
|
||||
|
||||
# passwords don't match
|
||||
payload = {
|
||||
"password": new_password,
|
||||
"confirm_password": self.password,
|
||||
}
|
||||
resp = c.post(self.challenge.verification_link(), payload)
|
||||
self.assertEqual(resp.status_code == 400)
|
||||
|
||||
# change password
|
||||
payload["confirm_password"] = new_password
|
||||
resp = c.post(self.challenge.verification_link(), payload)
|
||||
self.assertEqual(resp.status_code == 302)
|
||||
self.assertEqual(resp.headers["location"], reverse("accounts.login"))
|
||||
|
||||
# verify password changed notification email was sent
|
||||
password_updated_email = mail.outbox.pop()
|
||||
self.assertEqual(
|
||||
"Your password for signing in to Hostea was recently changed. If you made this change, then we're all set."
|
||||
in password_updated_email,
|
||||
True,
|
||||
)
|
||||
self.assertEqual(reverse("accounts.reset.new") in password_updated_email, True)
|
||||
|
||||
# trying to login with old password
|
||||
payload = {
|
||||
"login": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
resp = self.client.post(reverse("accounts.login"), payload)
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertEqual(b"Login Failed" in resp.content, True)
|
||||
|
||||
payload["password"] = new_password
|
||||
resp = c.post(reverse("accounts.login"), payload)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(resp.headers["location"], reverse("accounts.home"))
|
||||
|
||||
|
||||
class RegistrationTest(TestCase):
|
||||
def setUp(self):
|
||||
self.username = "register_user"
|
||||
|
@ -169,7 +236,9 @@ class RegistrationTest(TestCase):
|
|||
Tests if register template renders
|
||||
"""
|
||||
resp = self.client.get(reverse("accounts.register"))
|
||||
self.assertEqual(b"Free Forge Ecosystem" in resp.content, True)
|
||||
self.assertEqual(
|
||||
b"A free forge ecosystem for free developers." in resp.content, True
|
||||
)
|
||||
|
||||
def test_register_works(self):
|
||||
"""
|
||||
|
|
|
@ -25,6 +25,9 @@ from .views import (
|
|||
resend_verification_email_view,
|
||||
verification_pending_view,
|
||||
sudo,
|
||||
password_reset_send_verificaiton_link,
|
||||
password_resend_verification_link_pending,
|
||||
reset_password,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
@ -44,5 +47,20 @@ urlpatterns = [
|
|||
),
|
||||
path("accounts/verify/<str:challenge>/", verify_account, name="accounts.verify"),
|
||||
path("accounts/sudo/", sudo, name="accounts.sudo"),
|
||||
path(
|
||||
"accounts/password/reset/challenge/",
|
||||
password_reset_send_verificaiton_link,
|
||||
name="accounts.password.reset.new",
|
||||
),
|
||||
path(
|
||||
"accounts/password/reset/<str:challenge>/",
|
||||
reset_password,
|
||||
name="accounts.password.reset",
|
||||
),
|
||||
path(
|
||||
"accounts/password/reset/challenge/<str:public_ref>/",
|
||||
password_resend_verification_link_pending,
|
||||
name="accounts.password.reset.resend",
|
||||
),
|
||||
path("", protected_view, name="accounts.home"),
|
||||
]
|
||||
|
|
|
@ -17,6 +17,7 @@ from datetime import datetime, timezone
|
|||
from django.utils.crypto import get_random_string
|
||||
from django.core.mail import send_mail
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.conf import settings
|
||||
|
@ -29,17 +30,78 @@ def gen_secret() -> str:
|
|||
return get_random_string(32)
|
||||
|
||||
|
||||
def send_password_changed_email(request):
|
||||
ctx = {
|
||||
"username": request.user.username,
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"accounts/emails/password-changed.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
email = request.user.email
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Password changed",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>",
|
||||
recipient_list=[email],
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(request, challenge):
|
||||
verification_link = (
|
||||
f"{request.scheme}://{request.get_host()}{challenge.verification_link()}"
|
||||
)
|
||||
|
||||
ctx = {
|
||||
"link": verification_link,
|
||||
"email": challenge.owned_by.email,
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"accounts/emails/password-reset-link.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
email = challenge.owned_by.email
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Password reset link",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>",
|
||||
recipient_list=[email],
|
||||
)
|
||||
|
||||
|
||||
def send_verification_email(request, challenge):
|
||||
verification_link = (
|
||||
f"{request.scheme}://{request.get_host()}{challenge.verification_link()}"
|
||||
)
|
||||
|
||||
ctx = {
|
||||
"link": verification_link,
|
||||
"username": challenge.owned_by.username,
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"accounts/emails/verification-link.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
email = challenge.owned_by.email
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Hostea] Please confirm your email address",
|
||||
message=f"Please confirm your email address {email}.\n {verification_link}",
|
||||
from_email="No reply Hostea<ro-reply@exampl.org>", # TODO read from settings.py
|
||||
subject="[Gna!] Please confirm your email address",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>",
|
||||
recipient_list=[email],
|
||||
)
|
||||
|
||||
|
|
|
@ -23,9 +23,10 @@ from django.http import HttpResponse
|
|||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.urls import reverse
|
||||
|
||||
from dash.utils import footer_ctx
|
||||
|
||||
from .models import AccountConfirmChallenge
|
||||
from .utils import send_verification_email, ConfirmAccess
|
||||
from .models import AccountConfirmChallenge, PasswordResetChallenge
|
||||
from .utils import send_verification_email, ConfirmAccess, send_password_reset_email
|
||||
from .decorators import redirect_if_authenticated
|
||||
|
||||
|
||||
|
@ -35,6 +36,7 @@ def login_view(request):
|
|||
def default_login_ctx():
|
||||
return {
|
||||
"title": "Login",
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
|
@ -102,6 +104,7 @@ def register_view(request):
|
|||
"title": "Register",
|
||||
"username": username,
|
||||
"email": username,
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
|
@ -213,6 +216,7 @@ def sudo(request):
|
|||
def default_login_ctx():
|
||||
return {
|
||||
"title": "Confirm Access",
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
|
@ -238,3 +242,107 @@ def sudo(request):
|
|||
|
||||
ConfirmAccess.set(request=request)
|
||||
return redirect(request.POST["next"])
|
||||
|
||||
|
||||
@redirect_if_authenticated
|
||||
@csrf_protect
|
||||
def password_reset_send_verificaiton_link(request):
|
||||
def default_ctx():
|
||||
return {
|
||||
"title": "Reset Password",
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
ctx = default_ctx()
|
||||
return render(request, "accounts/auth/password-reset-form.html", ctx)
|
||||
|
||||
challenge = None
|
||||
|
||||
email = request.POST["email"]
|
||||
User = get_user_model()
|
||||
user = get_object_or_404(User, email=email)
|
||||
if not PasswordResetChallenge.objects.filter(owned_by=user).exists():
|
||||
challenge = PasswordResetChallenge(owned_by=user)
|
||||
challenge.save()
|
||||
send_password_reset_email(request, challenge=challenge)
|
||||
else:
|
||||
challenge = PasswordResetChallenge.objects.get(owned_by=user)
|
||||
return redirect(challenge.pending_url())
|
||||
|
||||
|
||||
@redirect_if_authenticated
|
||||
@csrf_protect
|
||||
def password_resend_verification_link_pending(request, public_ref):
|
||||
challenge = get_object_or_404(PasswordResetChallenge, public_ref=public_ref)
|
||||
|
||||
if request.method == "GET":
|
||||
ctx = {
|
||||
"email": challenge.owned_by.email,
|
||||
"public_ref": challenge.public_ref,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/auth/password-reset-resend-verification.html",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
send_password_reset_email(request, challenge=challenge)
|
||||
ctx = {
|
||||
"email": challenge.owned_by.email,
|
||||
"public_ref": challenge.public_ref,
|
||||
}
|
||||
|
||||
return render(
|
||||
request, "accounts/auth/password-reset-resend-verification.html", context=ctx
|
||||
)
|
||||
|
||||
|
||||
@csrf_protect
|
||||
def reset_password(request, challenge):
|
||||
def default_ctx(challenge):
|
||||
return {
|
||||
"title": "Reset Password",
|
||||
"footer": footer_ctx(),
|
||||
"challenge": challenge,
|
||||
}
|
||||
|
||||
challenge = get_object_or_404(PasswordResetChallenge, challenge_text=challenge)
|
||||
|
||||
if request.method == "GET":
|
||||
ctx = default_ctx(challenge=challenge)
|
||||
return render(request, "accounts/auth/password-reset.html", ctx)
|
||||
|
||||
confirm_password = request.POST["confirm_password"]
|
||||
password = request.POST["password"]
|
||||
|
||||
if password != confirm_password:
|
||||
ctx = default_ctx(challenge=challenge)
|
||||
ctx["error"] = {
|
||||
"title": "Reset Password Failed",
|
||||
"reason": "Passwords don't match, please verify input",
|
||||
}
|
||||
return render(
|
||||
request, "accounts/auth/password-reset.html", status=400, context=ctx
|
||||
)
|
||||
|
||||
user = challenge.owned_by
|
||||
try:
|
||||
validate_password(password, user=user)
|
||||
except ValidationError as err:
|
||||
ctx = default_ctx(challenge=challenge)
|
||||
reason = ""
|
||||
for r in err:
|
||||
reason += r + " "
|
||||
|
||||
ctx["error"] = {"title": "Reset Password Failed", "reason": reason}
|
||||
return render(
|
||||
request, "accounts/auth/password-reset.html", status=400, context=ctx
|
||||
)
|
||||
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
challenge.delete()
|
||||
send_password_reset_email(request)
|
||||
return redirect("accounts.login")
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from oauth2_provider.models import get_application_model
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
|
||||
from accounts.utils import gen_secret
|
||||
from dash.models import Instance
|
||||
from infrastructure.models import InstanceCreated
|
||||
from billing.utils import generate_invoice, payment_fullfilled, get_invoice_link
|
||||
|
||||
Application = get_application_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate invoices, should be run from cronjob scheduled for daily execution"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
instances = Instance.objects.all()
|
||||
if instances:
|
||||
for paid_instance in InstanceCreated.objects.all():
|
||||
self.stdout.write(f"Found instance: {paid_instance.instance}")
|
||||
if not payment_fullfilled(instance=paid_instance.instance):
|
||||
self.stdout.write(
|
||||
f"Payment not fulfilled for instance: {paid_instance.instance}"
|
||||
)
|
||||
payment = generate_invoice(instance=paid_instance.instance)
|
||||
owner = paid_instance.instance.owned_by
|
||||
ctx = {
|
||||
"username": owner.username,
|
||||
"payment": payment,
|
||||
"link": get_invoice_link(payment=payment),
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"billing/emails/renew-subscription.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
email = owner.email
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Payment receipt for your Gna! VM",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
||||
recipient_list=[email],
|
||||
)
|
||||
|
||||
else:
|
||||
self.stdout.write("No instances available")
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.0.3 on 2022-07-08 13:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("billing", "0004_payment_billing_pay_paid_by_77f57c_idx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="vm_deleted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -67,6 +67,8 @@ class Payment(BasePayment):
|
|||
date = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
objects = PaymentModelManager()
|
||||
|
||||
vm_deleted = models.BooleanField(default=False, null=False)
|
||||
|
||||
def get_failure_url(self) -> str:
|
||||
url = urlparse(settings.PAYMENT_HOST)
|
||||
return urlunparse(
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
Hello {{ username }}!
|
||||
|
||||
An invoice is generated for your Gna! VM {{ payment.instance_name }}.
|
||||
|
||||
- Configuration: {{payment.instance_configuration_id.name}}
|
||||
- Invoice generated on: {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}
|
||||
- Total Amount: {{payment.total}} {{payment.currency|upper}}
|
||||
|
||||
To pay, please click the link below:
|
||||
|
||||
{{ link }}
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,22 @@
|
|||
Hello {{ username }}!
|
||||
|
||||
|
||||
This is a receipt for your latest Gna! payment.
|
||||
|
||||
-----------------------------------------------------
|
||||
|
||||
Gna! Receipt - {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}
|
||||
|
||||
- Instance Name: {{ payment.instance_name }}
|
||||
- Configuration: {{payment.instance_configuration_id.name}}
|
||||
- Total Amount: {{payment.total}} {{payment.currency|upper}}
|
||||
|
||||
|
||||
To view the receipt online, please see the following link:
|
||||
|
||||
{{link}}
|
||||
|
||||
We appreciate your business!
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,17 @@
|
|||
Hello {{ username }}!
|
||||
|
||||
Your Gna! VM subscription is due for renewal. Please click the link
|
||||
below to renew your subscription:
|
||||
|
||||
{{link}}
|
||||
|
||||
-----------------------------------------------------
|
||||
|
||||
- Instance Name: {{ payment.instance_name }}
|
||||
- Configuration: {{payment.instance_configuration_id.name}}
|
||||
- Total Amount: {{payment.total}} {{payment.currency|upper}}
|
||||
|
||||
We appreciate your business!
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -10,7 +10,7 @@
|
|||
<li class="list-instance__item"><strong>Instance Name:</strong> {{payment.instance_name}}</li>
|
||||
<li class="list-instance__item"><strong>Configuration:</strong> {{payment.instance_configuration_id.name}}</li>
|
||||
<li class="list-instance__item"><strong>Invoice generated on:</strong> {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}</li>
|
||||
<li class="list-instance__item"><strong>Total Ammount</strong>: {{payment.total}} {{payment.currency|upper}}</li>
|
||||
<li class="list-instance__item"><strong>Total Amount</strong>: {{payment.total}} {{payment.currency|upper}}</li>
|
||||
{% if payment.status == "confirmed" %}
|
||||
<li class="list-instance__item"><strong>Paid on</strong>: {{payment.date}}</li>
|
||||
{% endif %}
|
||||
|
|
180
billing/tests.py
|
@ -16,17 +16,19 @@ import time
|
|||
from io import StringIO
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase, Client, override_settings
|
||||
from django.utils.http import urlencode
|
||||
from django.contrib.auth import authenticate
|
||||
from django.core.management import call_command
|
||||
from django.conf import settings
|
||||
|
||||
from payments import get_payment_model, RedirectNeeded, PaymentStatus
|
||||
|
||||
from accounts.tests import register_util, login_util
|
||||
from dash.tests import create_configurations, create_instance_util
|
||||
from dash.tests import create_configurations, create_instance_util, infra_custom_config
|
||||
from dash.models import Instance
|
||||
|
||||
from .models import Payment
|
||||
|
@ -43,55 +45,21 @@ class BillingTest(TestCase):
|
|||
register_util(t=self, username=self.username)
|
||||
create_configurations(t=self)
|
||||
|
||||
@override_settings(HOSTEA=infra_custom_config(test_name="test_payments"))
|
||||
def test_payments(self):
|
||||
c = Client()
|
||||
login_util(self, c, "accounts.home")
|
||||
instance_name = "test_create_instance_renders"
|
||||
instance_name = "tpayments"
|
||||
create_instance_util(
|
||||
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
|
||||
)
|
||||
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
|
||||
payment_uri = reverse("billing.invoice.generate", args=(instance_name,))
|
||||
|
||||
# generate invoice
|
||||
resp = c.get(payment_uri)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
invoice_uri = resp.headers["Location"]
|
||||
self.assertEqual("invoice/payment/" in invoice_uri, True)
|
||||
|
||||
# try to generate duplicate invoice, but should get redirected to previous invoice
|
||||
resp = c.get(payment_uri)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(invoice_uri == resp.headers["Location"], True)
|
||||
|
||||
# check if invoice details page is displaying the invoice
|
||||
# if payment is yet to be made:
|
||||
# template will show payment button
|
||||
# else:
|
||||
# template will show payment date
|
||||
resp = c.get(invoice_uri)
|
||||
self.assertEqual(str.encode(instance_name) in resp.content, True)
|
||||
self.assertEqual(
|
||||
str.encode(str(self.instance_config[0].rent)) in resp.content, True
|
||||
)
|
||||
self.assertEqual(str.encode("Paid on") in resp.content, False)
|
||||
|
||||
# check if the unpaid invoice is displayed in the pending invoice view
|
||||
resp = c.get(reverse("billing.invoice.pending"))
|
||||
self.assertEqual(str.encode(invoice_uri) in resp.content, True)
|
||||
|
||||
self.assertEqual(payment_fullfilled(instance=instance), False)
|
||||
|
||||
# simulate payment. There's probably a better way to do this
|
||||
payment = get_payment_model().objects.get(paid_by=self.user)
|
||||
payment.status = PaymentStatus.CONFIRMED
|
||||
payment.save()
|
||||
|
||||
self.assertEqual(payment_fullfilled(instance=instance), True)
|
||||
|
||||
#
|
||||
payment = get_payment_model().objects.get(paid_by=self.user)
|
||||
invoice_uri = reverse("billing.invoice.details", args=(payment.public_ref,))
|
||||
|
||||
# check if paid invoice is listed in paid invoice list view
|
||||
resp = c.get(reverse("billing.invoice.paid"))
|
||||
|
@ -111,15 +79,147 @@ class BillingTest(TestCase):
|
|||
|
||||
# try to generate an invoice for the second time on the same VM
|
||||
# shouldn't be possible since payment is already made for the duration
|
||||
payment_uri = reverse("billing.invoice.generate", args=(instance.name,))
|
||||
resp = c.get(payment_uri)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
## payment success page; no real functionality but user is redirected here
|
||||
# by stripe if payment is successful
|
||||
resp = c.get(reverse("billing.invoice.success", args=(payment.public_ref,)))
|
||||
self.assertEqual(b"success" in resp.content, True)
|
||||
self.assertEqual(
|
||||
resp.headers["Location"],
|
||||
reverse("infra.create", args=(payment.instance_name,)),
|
||||
)
|
||||
|
||||
# create_instance_util creates an instance and pays for it. An email is
|
||||
# sent when the invoice is generated and one after payment is made
|
||||
#
|
||||
# So we are first checking for the last email that was sent(receipt)
|
||||
# and then the Forgejo instance credentials notification followed by the
|
||||
# invoice generation email.
|
||||
receipt_mail = mail.outbox.pop()
|
||||
print(receipt_mail.body)
|
||||
self.assertEqual(
|
||||
all(
|
||||
[
|
||||
receipt_mail.to[0] == self.email,
|
||||
"This is a receipt for your latest Gna! payment"
|
||||
in receipt_mail.body,
|
||||
]
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
instance_notificaiton = mail.outbox.pop()
|
||||
self.assertEqual(
|
||||
all(
|
||||
[
|
||||
instance_notificaiton.to[0] == self.email,
|
||||
"Congratulations on your new Gna! instance!"
|
||||
in instance_notificaiton.body,
|
||||
]
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
## payment failure page; no real functionality but user is redirected here
|
||||
# by stripe if payment is successful
|
||||
resp = c.get(reverse("billing.invoice.fail", args=(payment.public_ref,)))
|
||||
self.assertEqual(b"failed" in resp.content, True)
|
||||
|
||||
|
||||
class GenerateInvoiceCommand(TestCase):
|
||||
"""
|
||||
Test command: manage.py generate_invoice
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.username = "test_generate_invoice_cmd_user"
|
||||
register_util(t=self, username=self.username)
|
||||
create_configurations(t=self)
|
||||
|
||||
@override_settings(
|
||||
HOSTEA=infra_custom_config(
|
||||
test_name="test_dont_send_invoices_to_not_created_vms"
|
||||
)
|
||||
)
|
||||
def test_dont_send_invoices_to_not_created_vms(self):
|
||||
|
||||
c = Client()
|
||||
login_util(self, c, "accounts.home")
|
||||
|
||||
instance_name = "tnoinvonocrevm"
|
||||
|
||||
payload = {"name": instance_name, "configuration": self.instance_config[0].name}
|
||||
|
||||
resp = c.post(reverse("dash.instances.new"), payload)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(
|
||||
resp.headers["location"],
|
||||
reverse("billing.invoice.generate", args=(instance_name,)),
|
||||
)
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
self.assertEqual(payment_fullfilled(instance=instance), False)
|
||||
prev_len = len(mail.outbox)
|
||||
|
||||
# username exists
|
||||
call_command(
|
||||
"generate_invoice",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
out = stdout.getvalue()
|
||||
print(out)
|
||||
self.assertEqual(instance_name not in out, True)
|
||||
|
||||
@override_settings(
|
||||
HOSTEA=infra_custom_config(test_name="test_generate_invoice_cmd")
|
||||
)
|
||||
def test_cmd(self):
|
||||
c = Client()
|
||||
login_util(self, c, "accounts.home")
|
||||
instance_name = "tgeninvmd"
|
||||
create_instance_util(
|
||||
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
|
||||
)
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
self.assertEqual(payment_fullfilled(instance=instance), True)
|
||||
prev_len = len(mail.outbox)
|
||||
|
||||
# username exists
|
||||
call_command(
|
||||
"generate_invoice",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
out = stdout.getvalue()
|
||||
print(out)
|
||||
self.assertEqual(instance_name in out, True)
|
||||
|
||||
self.assertEqual(prev_len, len(mail.outbox))
|
||||
|
||||
# delete payment and re-generate with command
|
||||
get_payment_model().objects.get(instance_name=instance_name).delete()
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
|
||||
call_command(
|
||||
"generate_invoice",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
out = stdout.getvalue()
|
||||
print("out")
|
||||
print(out)
|
||||
self.assertEqual(instance_name in out, True)
|
||||
self.assertEqual(f"Payment not fulfilled for instance: {instance}" in out, True)
|
||||
self.assertEqual(prev_len + 1, len(mail.outbox))
|
||||
|
|
|
@ -12,22 +12,90 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from enum import Enum, unique
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from payments import get_payment_model, RedirectNeeded, PaymentStatus
|
||||
from django.core.mail import send_mail
|
||||
from django.urls import reverse
|
||||
from django.template.loader import render_to_string
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from dash.models import Instance
|
||||
|
||||
Payment = get_payment_model()
|
||||
|
||||
def payment_fullfilled(instance: Instance) -> bool:
|
||||
Payment = get_payment_model()
|
||||
|
||||
def __get_delta():
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
|
||||
return delta
|
||||
|
||||
|
||||
def get_invoice_link(payment: Payment):
|
||||
invoice_link = reverse("billing.invoice.details", args=(payment.public_ref,))
|
||||
parsed = urlparse(settings.PAYMENT_HOST)
|
||||
return urlunparse((parsed.scheme, parsed.netloc, invoice_link, "", "", ""))
|
||||
|
||||
|
||||
def payment_fullfilled(instance: Instance) -> bool:
|
||||
delta = __get_delta()
|
||||
|
||||
payment = None
|
||||
for p in Payment.objects.filter(date__gt=(delta), instance_name=instance.name):
|
||||
for p in Payment.objects.filter(
|
||||
date__gt=(delta), instance_name=instance.name, vm_deleted=False
|
||||
):
|
||||
if p.status == PaymentStatus.CONFIRMED:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@unique
|
||||
class GenerateInvoiceErrorCode(Enum):
|
||||
ALREADY_PAID = "already paid"
|
||||
DUPLICATE_PAYMENT = "DUPLICATE PAYMENT"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class GenerateInvoiceException(Exception):
|
||||
error: str
|
||||
code: GenerateInvoiceErrorCode
|
||||
|
||||
def __init__(self, code: GenerateInvoiceErrorCode):
|
||||
self.error = str(code)
|
||||
self.code = code
|
||||
|
||||
def __str__(self):
|
||||
return self.error
|
||||
|
||||
|
||||
def generate_invoice(instance: Instance) -> Payment:
|
||||
delta = __get_delta()
|
||||
|
||||
payment = None
|
||||
for p in Payment.objects.filter(
|
||||
date__gt=(delta), instance_name=instance.name, vm_deleted=False
|
||||
):
|
||||
if p.status == PaymentStatus.CONFIRMED:
|
||||
raise GenerateInvoiceException(code=GenerateInvoiceErrorCode.ALREADY_PAID)
|
||||
if any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]):
|
||||
if payment is None:
|
||||
payment = p
|
||||
else:
|
||||
print(f"Duplicate payment {p}, deleting in favor of {payment}")
|
||||
p.delete()
|
||||
|
||||
if payment is None:
|
||||
print("Payment not found, generating new payment")
|
||||
payment = Payment.objects.create(
|
||||
variant="stripe", # this is the variant from PAYMENT_VARIANTS
|
||||
instance=instance,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
|
|
@ -16,13 +16,25 @@ from datetime import datetime, timedelta, timezone
|
|||
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.response import TemplateResponse
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
|
||||
from payments import get_payment_model, RedirectNeeded, PaymentStatus
|
||||
from dash.models import Instance
|
||||
from django.db.models import Q
|
||||
from infrastructure.utils import create_vm_if_not_exists
|
||||
from dash.utils import footer_ctx
|
||||
|
||||
from .utils import (
|
||||
generate_invoice as generate_invoice_util,
|
||||
GenerateInvoiceErrorCode,
|
||||
GenerateInvoiceException,
|
||||
get_invoice_link,
|
||||
)
|
||||
|
||||
|
||||
def default_ctx(title: str, username: str):
|
||||
|
@ -32,6 +44,7 @@ def default_ctx(title: str, username: str):
|
|||
return {
|
||||
"title": title,
|
||||
"username": username,
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
|
||||
|
@ -63,29 +76,13 @@ def paid_invoices(request):
|
|||
def generate_invoice(request, instance_name: str):
|
||||
instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user)
|
||||
|
||||
Payment = get_payment_model()
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
|
||||
|
||||
payment = None
|
||||
for p in Payment.objects.filter(date__gt=(delta), instance_name=instance_name):
|
||||
if p.status == PaymentStatus.CONFIRMED:
|
||||
return HttpResponse("BAD REQUEST: Already paid", status=400)
|
||||
elif any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]):
|
||||
if payment is None:
|
||||
payment = p
|
||||
else:
|
||||
print(f"Duplicate payment {p}, deleting in favor of {payment}")
|
||||
p.delete()
|
||||
|
||||
if payment is None:
|
||||
print("Payment not found, generating new payment")
|
||||
payment = Payment.objects.create(
|
||||
variant="stripe", # this is the variant from PAYMENT_VARIANTS
|
||||
instance=instance,
|
||||
)
|
||||
|
||||
try:
|
||||
payment = generate_invoice_util(instance=instance)
|
||||
return redirect(reverse("billing.invoice.details", args=(payment.public_ref,)))
|
||||
except GenerateInvoiceException as e:
|
||||
if e.code == GenerateInvoiceErrorCode.ALREADY_PAID:
|
||||
return HttpResponse("BAD REQUEST: Already paid", status=400)
|
||||
return redirect(reverse("dash.home"))
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -115,10 +112,30 @@ def payment_success(request, payment_public_id):
|
|||
payment = get_object_or_404(
|
||||
get_payment_model(), public_ref=payment_public_id, paid_by=request.user
|
||||
)
|
||||
return HttpResponse(
|
||||
f"{payment.description} worth {payment.total}{payment.currency} paid via {payment.variant} is success"
|
||||
|
||||
ctx = {
|
||||
"username": request.user.username,
|
||||
"payment": payment,
|
||||
"link": get_invoice_link(payment=payment),
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"billing/emails/payment-receipt.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
email = request.user.email
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Payment receipt for your Gna! VM",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
||||
recipient_list=[email],
|
||||
)
|
||||
|
||||
return redirect(reverse("infra.create", args=(payment.instance_name,)))
|
||||
|
||||
|
||||
@login_required
|
||||
def payment_failure(request, payment_public_id):
|
||||
|
|
|
@ -34,11 +34,9 @@ h2 {
|
|||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
/*
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
*/
|
||||
}
|
||||
|
||||
a:hover {
|
||||
|
@ -129,6 +127,10 @@ header {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav__logo-img {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.nav__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
@ -244,6 +246,8 @@ footer {
|
|||
display: block;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer__container {
|
||||
|
@ -391,6 +395,8 @@ footer {
|
|||
font-size: 0.7rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 260px;
|
||||
width: calc(100vw - 260px);
|
||||
}
|
||||
|
||||
.footer__container {
|
||||
|
@ -607,8 +613,6 @@ fieldset {
|
|||
background-color: #e11d21;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
.form__label {
|
||||
margin: 5px 0;
|
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 7.6 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 4.0 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 955 B |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 3.8 KiB |
|
@ -4,7 +4,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
||||
<title>{{ title }}| Hostea Dashbaord</title>
|
||||
<title>{{ title }}| Gna! Dashboard</title>
|
||||
{% include "common/components/meta.html" %}
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -2,12 +2,8 @@
|
|||
<nav class="nav__container">
|
||||
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
|
||||
<div class="nav__header">
|
||||
<a class="nav__logo-container" href="/">
|
||||
<img
|
||||
src="{% static 'img/android-icon-48x48.png' %}"
|
||||
alt="Hostea temporary logo"
|
||||
/>
|
||||
<p class="nav__home-btn">ostea</p>
|
||||
<a class="nav__logo-container" href="https://gna.org">
|
||||
<img alt="Gna! logo" class="nav__logo-img" src="{% static 'img/logo.png' %}" />
|
||||
</a>
|
||||
<label class="nav__hamburger-menu" for="nav__toggle">
|
||||
<span class="nav__hamburger-inner"></span>
|
||||
|
@ -18,17 +14,26 @@
|
|||
|
||||
<div class="nav__link-group">
|
||||
<div class="nav__link-container">
|
||||
<a class="nav__link" rel="noreferrer" href="{% url 'accounts.login' %}"
|
||||
<a
|
||||
class="nav__link"
|
||||
rel="noreferrer"
|
||||
href="{% url 'dash.instances.new' %}"
|
||||
>Add Instance</a
|
||||
>
|
||||
</div>
|
||||
<div class="nav__link-container">
|
||||
<a class="nav__link" rel="noreferrer" href="{% url 'support.home' %}"
|
||||
<a
|
||||
class="nav__link"
|
||||
rel="noreferrer"
|
||||
href="{% url 'support.home' %}"
|
||||
>Support</a
|
||||
>
|
||||
</div>
|
||||
<div class="nav__link-container">
|
||||
<a class="nav__link" rel="noreferrer" href="{% url 'accounts.logout' %}"
|
||||
<a
|
||||
class="nav__link"
|
||||
rel="noreferrer"
|
||||
href="{% url 'accounts.logout' %}"
|
||||
>Logout</a
|
||||
>
|
||||
</div>
|
||||
|
|
|
@ -45,8 +45,10 @@
|
|||
</details>
|
||||
|
||||
</div>
|
||||
<!--
|
||||
<div class="secondary-nav__options">
|
||||
<a href="/foo" class="secondary-nav__option-link">Manage Account</a>
|
||||
</div>
|
||||
-->
|
||||
</nav>
|
||||
</aside>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
</ul>
|
||||
|
||||
<p>Created On: {{ instance.created_at }}</p>
|
||||
<p><a href="{{forgejo_uri}}">Forgejo Instance</a>|<a href="{{woodpecker}}">Woodpecker CI</a></p>
|
||||
|
||||
<form
|
||||
action="{% url 'dash.instances.delete' name=instance.name %}"
|
||||
|
|
|
@ -12,16 +12,25 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
from time import sleep
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.http import urlencode
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase, Client, override_settings
|
||||
from django.conf import settings
|
||||
from django.db.utils import IntegrityError
|
||||
from payments import get_payment_model, RedirectNeeded, PaymentStatus
|
||||
|
||||
from accounts.tests import login_util, register_util
|
||||
|
||||
from .models import InstanceConfiguration, Instance
|
||||
from .utils import create_instance, sanitize_vm_name, VmErrors, VmException
|
||||
|
||||
|
||||
def create_configurations(t: TestCase):
|
||||
|
@ -44,6 +53,28 @@ def create_configurations(t: TestCase):
|
|||
)
|
||||
|
||||
|
||||
def infra_custom_config(test_name: str):
|
||||
def create_fleet_repo(test_name: str):
|
||||
subprocess.run(
|
||||
["./integration/ci.sh", "new_fleet_repo", test_name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
sleep(10)
|
||||
|
||||
create_fleet_repo(test_name=test_name)
|
||||
|
||||
c = settings.HOSTEA
|
||||
path = Path(f"/tmp/hostea/dashboard/{test_name}/repo")
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
c["INFRA"]["HOSTEA_REPO"]["PATH"] = str(path)
|
||||
remote_base = os.environ.get("HOSTEA_INFRA_HOSTEA_REPO_REMOTE")
|
||||
c["INFRA"]["HOSTEA_REPO"]["REMOTE"] = f"{remote_base}{test_name}.git"
|
||||
print(c["INFRA"]["HOSTEA_REPO"]["REMOTE"])
|
||||
return c
|
||||
|
||||
|
||||
def create_instance_util(
|
||||
t: TestCase, c: Client, instance_name: str, config: InstanceConfiguration
|
||||
):
|
||||
|
@ -55,6 +86,22 @@ def create_instance_util(
|
|||
resp.headers["location"],
|
||||
reverse("billing.invoice.generate", args=(instance_name,)),
|
||||
)
|
||||
# generate invoice
|
||||
payment_uri = reverse("billing.invoice.generate", args=(instance_name,))
|
||||
resp = c.get(payment_uri)
|
||||
t.assertEqual(resp.status_code, 302)
|
||||
invoice_uri = resp.headers["Location"]
|
||||
t.assertEqual("invoice/payment/" in invoice_uri, True)
|
||||
|
||||
# simulate payment. There's probably a better way to do this
|
||||
payment = get_payment_model().objects.get(
|
||||
paid_by=t.user, instance_name=instance_name
|
||||
)
|
||||
payment.status = PaymentStatus.CONFIRMED
|
||||
payment.save()
|
||||
|
||||
resp = c.get(reverse("infra.create", args=(instance_name,)))
|
||||
t.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
class DashHome(TestCase):
|
||||
|
@ -124,7 +171,7 @@ class InstancesConfig(TestCase):
|
|||
"""
|
||||
Expects InstancesConfig titled "s1-2", "s1-4" and "s1-8"
|
||||
|
||||
ref: https://gitea.hostea.org/Hostea/july-mvp/issues/10#issuecomment-639
|
||||
ref: https://forgejo.gna.org/Hostea/july-mvp/issues/10#issuecomment-639
|
||||
"""
|
||||
self.assertEqual(
|
||||
InstanceConfiguration.objects.filter(
|
||||
|
@ -151,6 +198,33 @@ class CreateInstance(TestCase):
|
|||
register_util(t=self, username="createinstance_user")
|
||||
create_configurations(t=self)
|
||||
|
||||
|
||||
def test_sanitize_vm_name(self):
|
||||
self.assertEqual(sanitize_vm_name(vm_name="LOWERname"), "lowername")
|
||||
|
||||
with self.assertRaises(VmException):
|
||||
sanitize_vm_name(vm_name="12345452131324234234234234")
|
||||
|
||||
with self.assertRaises(VmException):
|
||||
sanitize_vm_name(vm_name="122342$#34234")
|
||||
|
||||
|
||||
@override_settings(
|
||||
HOSTEA=infra_custom_config(test_name="test_create_instance_util")
|
||||
)
|
||||
def test_create_instance_util(self):
|
||||
configuration = self.instance_config[0].name
|
||||
|
||||
with self.assertRaises(VmException):
|
||||
create_instance(
|
||||
vm_name="12345452131324234234234234",
|
||||
configuration_name=configuration,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
HOSTEA=infra_custom_config(test_name="test_create_instance_renders")
|
||||
)
|
||||
def test_create_instance_renders(self):
|
||||
c = Client()
|
||||
login_util(self, c, "accounts.home")
|
||||
|
@ -165,14 +239,14 @@ class CreateInstance(TestCase):
|
|||
self.assertEqual(str.encode(test) in resp.content, True)
|
||||
|
||||
# create instance
|
||||
instance_name = "testirenrs"
|
||||
payload = {
|
||||
"name": "test_create_instance_renders",
|
||||
"name": instance_name,
|
||||
"configuration": self.instance_config[0].name,
|
||||
}
|
||||
|
||||
self.assertEqual(Instance.objects.filter(name=payload["name"]).exists(), False)
|
||||
|
||||
instance_name = "test_create_instance_renders"
|
||||
create_instance_util(
|
||||
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
|
||||
)
|
||||
|
@ -236,12 +310,16 @@ class CreateInstance(TestCase):
|
|||
|
||||
resp = c.post(delete_uri)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(resp.headers["location"], reverse("dash.home"))
|
||||
self.assertEqual(
|
||||
resp.headers["location"], reverse("infra.rm", args=(instance.name,))
|
||||
)
|
||||
resp = c.get(resp.headers["location"])
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertEqual(resp.headers["location"], reverse("dash.instances.list"))
|
||||
self.assertEqual(
|
||||
Instance.objects.filter(
|
||||
name=instance.name,
|
||||
name=instance_name,
|
||||
owned_by=self.user,
|
||||
configuration_id=self.instance_config[0],
|
||||
).exists(),
|
||||
False,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from enum import Enum, unique
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Instance, InstanceConfiguration
|
||||
|
||||
|
||||
@unique
|
||||
class VmErrors(Enum):
|
||||
NAME_EXISTS = "Instance name exists, please try again with a different name"
|
||||
ILLEGAL_NAME = "Only alphanumeric characters are allowed in instance name"
|
||||
NAME_TOO_LONG = "Instance name must be less than 20 characters"
|
||||
NO_CONFIG = "Configuration doesn't exist, please try again."
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class VmException(Exception):
|
||||
error: str
|
||||
code: VmErrors
|
||||
|
||||
def __init__(self, code: VmErrors):
|
||||
self.error = str(code)
|
||||
self.code = code
|
||||
|
||||
def __str__(self):
|
||||
return self.error
|
||||
|
||||
|
||||
def sanitize_vm_name(vm_name: str) -> str:
|
||||
"""
|
||||
Sanity checks and normalization of the vm name
|
||||
"""
|
||||
|
||||
vm_name = vm_name.lower()
|
||||
|
||||
if len(vm_name) > 20:
|
||||
raise VmException(code=VmErrors.NAME_TOO_LONG)
|
||||
if not str.isalnum(vm_name):
|
||||
raise VmException(code=VmErrors.ILLEGAL_NAME)
|
||||
|
||||
if Instance.objects.filter(name=vm_name).exists():
|
||||
raise VmException(code=VmErrors.NAME_EXISTS)
|
||||
|
||||
return vm_name
|
||||
|
||||
|
||||
def create_instance(vm_name: str, configuration_name: str, user: User) -> Instance:
|
||||
"""
|
||||
Create instance view
|
||||
"""
|
||||
|
||||
vm_name = sanitize_vm_name(vm_name)
|
||||
|
||||
if not InstanceConfiguration.objects.filter(name=configuration_name).exists():
|
||||
raise VmException(code=VmErrors.NO_CONFIG)
|
||||
|
||||
configuration = InstanceConfiguration.objects.get(name=configuration_name)
|
||||
instance = Instance(name=vm_name, configuration_id=configuration, owned_by=user)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
source_code = None
|
||||
|
||||
|
||||
def footer_ctx():
|
||||
global source_code
|
||||
if source_code is None:
|
||||
if "SOURCE_CODE" in settings.HOSTEA:
|
||||
source_code = {
|
||||
"text": "Source Code",
|
||||
"link": settings.HOSTEA["SOURCE_CODE"],
|
||||
}
|
||||
else:
|
||||
link = "https://forgejo.gna.org/Hostea/dashboard"
|
||||
source_code = {"text": "Source Code", "link": link}
|
||||
try:
|
||||
r = Repo(".")
|
||||
commit = r.head.commit.hexsha
|
||||
source_code["text"] = f"v-{commit.hexsha[0:8]}"
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"source_code": source_code,
|
||||
"admin_email": settings.HOSTEA["INSTANCE_MAINTAINER_CONTACT"],
|
||||
}
|
|
@ -21,9 +21,16 @@ from django.http import HttpResponse
|
|||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import Instance, InstanceConfiguration
|
||||
|
||||
from accounts.decorators import confirm_access
|
||||
from infrastructure.utils import Infra
|
||||
|
||||
from .models import Instance, InstanceConfiguration
|
||||
from .utils import (
|
||||
create_instance as create_instance_util,
|
||||
VmErrors,
|
||||
VmException,
|
||||
footer_ctx,
|
||||
)
|
||||
|
||||
|
||||
def default_ctx(title: str, username: str):
|
||||
|
@ -34,6 +41,7 @@ def default_ctx(title: str, username: str):
|
|||
"title": title,
|
||||
"username": username,
|
||||
"open_instances": "open",
|
||||
"footer": footer_ctx(),
|
||||
}
|
||||
|
||||
|
||||
|
@ -73,32 +81,22 @@ def create_instance(request):
|
|||
return render(request, "dash/instances/new/index.html", context=ctx)
|
||||
|
||||
name = request.POST["name"]
|
||||
if Instance.objects.filter(name=name).exists():
|
||||
ctx = get_ctx()
|
||||
ctx["error"] = {
|
||||
"title": "Can't create instance",
|
||||
"reason": "Instance name exists, please try again with a different name",
|
||||
}
|
||||
print(ctx["error"]["reason"])
|
||||
return render(request, "dash/instances/new/index.html", status=400, context=ctx)
|
||||
|
||||
configuration = request.POST["configuration"]
|
||||
if not InstanceConfiguration.objects.filter(name=configuration).exists():
|
||||
try:
|
||||
instance = create_instance_util(
|
||||
vm_name=name, configuration_name=configuration, user=request.user
|
||||
)
|
||||
return redirect(reverse("billing.invoice.generate", args=(instance.name,)))
|
||||
except VmException as e:
|
||||
ctx = get_ctx()
|
||||
reason = e.code.value
|
||||
|
||||
ctx["error"] = {
|
||||
"title": "Can't create instance",
|
||||
"reason": "Configuration doesn't exist, please try again.",
|
||||
"reason": reason,
|
||||
}
|
||||
print(ctx["error"]["reason"])
|
||||
return render(request, "dash/instances/new/index.html", status=400, context=ctx)
|
||||
|
||||
configuration = get_object_or_404(InstanceConfiguration, name=configuration)
|
||||
instance = Instance(
|
||||
name=name, configuration_id=configuration, owned_by=request.user
|
||||
)
|
||||
instance.save()
|
||||
return redirect(reverse("billing.invoice.generate", args=(instance.name,)))
|
||||
|
||||
|
||||
@login_required
|
||||
def list_instances(request):
|
||||
|
@ -119,7 +117,12 @@ def view_instance(request, name: str):
|
|||
instance = get_object_or_404(Instance, owned_by=user, name=name)
|
||||
ctx = default_ctx(title=PAGE_TITLE, username=user.username)
|
||||
instance.configuration = instance.configuration_id
|
||||
forgejo_uri = Infra.get_forgejo_uri(instance=instance)
|
||||
woodpecker = Infra.get_woodpecker_uri(instance=instance)
|
||||
|
||||
ctx["instance"] = instance
|
||||
ctx["woodpecker"] = woodpecker
|
||||
ctx["forgejo_uri"] = forgejo_uri
|
||||
return render(request, "dash/instances/view/index.html", context=ctx)
|
||||
|
||||
|
||||
|
@ -136,5 +139,4 @@ def delete_instance(request, name):
|
|||
ctx["instance"] = instance
|
||||
return render(request, "dash/instances/delete/index.html", context=ctx)
|
||||
|
||||
instance.delete()
|
||||
return redirect(reverse("dash.home"))
|
||||
return redirect(reverse("infra.rm", args=(instance.name,)))
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
"""
|
||||
Django settings for dashboard project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.3.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
import environ
|
||||
import os
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
# A new SECRET_KEY can be generated by running the following command:
|
||||
# openssl rand -hex 32
|
||||
SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": env.db_url(
|
||||
"DATABSE_URL", default="postgres://postgres:password@localhost:5432/postgres"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
## django-payments configuration
|
||||
|
||||
PAYMENT_HOST = "http://localhost:8000"
|
||||
PAYMENT_VARIANTS = {
|
||||
"stripe": (
|
||||
"payments.stripe.StripeProvider", # please don't change this
|
||||
{
|
||||
"secret_key": env.get_value("STRIPE_SECRET_KEY", default="UNSET"),
|
||||
"public_key": env.get_value("STRIPE_PUBLIC_KEY", default="UNSET"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
### Dashbaord specific configuration options
|
||||
|
||||
HOSTEA = {
|
||||
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
|
||||
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
|
||||
"ACCOUNTS": {
|
||||
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
|
||||
"SUDO_TTL": 60 * 5,
|
||||
},
|
||||
"META": {
|
||||
"FORGEJO_INSTANCE": env.get_value(
|
||||
"HOSTEA_META_FORGEJO_INSTANCE"
|
||||
), # meta Forgejo insatnce
|
||||
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
# Repository dedicated for handling support
|
||||
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
|
||||
"SUPPORT_REPOSITORY": "support",
|
||||
},
|
||||
"INFRA": {
|
||||
"HOSTEA_REPO": {
|
||||
# where to clone the repository
|
||||
"PATH": "/tmp/hostea/dashboard/infrastructure",
|
||||
# Git remote URI of the repository
|
||||
"REMOTE": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_REMOTE"),
|
||||
# SSH key that can push to the Git repository remote mentioned above
|
||||
"SSH_KEY": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY"),
|
||||
},
|
||||
"HOSTEA_DOMAIN": "gna.org", # domain at which Hostea VMs will be spun up
|
||||
},
|
||||
}
|
||||
|
||||
# Please see EMAIL_* configuration options:
|
||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
|
||||
EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025")
|
||||
DEFAULT_FROM_EMAIL = "no-reply@gna.org"
|
||||
|
||||
vars().update(EMAIL_CONFIG)
|
||||
|
||||
print("Finished importing local_settings.ci.py")
|
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
Django settings for dashboard project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.3.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
# A new SECRET_KEY can be generated by running the following command:
|
||||
# openssl rand -hex 32
|
||||
SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "postgres",
|
||||
"USER": "postgres",
|
||||
"PASSWORD": "password",
|
||||
"HOST": "localhost",
|
||||
"PORT": "5432",
|
||||
}
|
||||
}
|
||||
|
||||
## django-payments configuration
|
||||
|
||||
PAYMENT_HOST = "http://localhost:8000"
|
||||
PAYMENT_VARIANTS = {
|
||||
"stripe": (
|
||||
"payments.stripe.StripeProvider", # please don't change this
|
||||
{
|
||||
"secret_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
### Dashbaord specific configuration options
|
||||
|
||||
HOSTEA = {
|
||||
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
|
||||
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
|
||||
"ACCOUNTS": {
|
||||
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
|
||||
"SUDO_TTL": 60 * 5,
|
||||
},
|
||||
"META": {
|
||||
"FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce
|
||||
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
# Repository dedicated for handling support
|
||||
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
|
||||
"SUPPORT_REPOSITORY": "support",
|
||||
},
|
||||
"INFRA": {
|
||||
"HOSTEA_REPO": {
|
||||
# where to clone the repository
|
||||
"PATH": "/srv/hostea/dashboard/infrastructure",
|
||||
# Git remote URI of the repository
|
||||
"REMOTE": "git@localhost:Hostea/enough.git",
|
||||
# SSH key that can push to the Git repository remote mentioned above
|
||||
"SSH_KEY": "/srv/hostea/deploy",
|
||||
},
|
||||
"HOSTEA_DOMAIN": "",
|
||||
},
|
||||
}
|
||||
|
||||
# Please see EMAIL_* configuration options:
|
||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
|
||||
EMAIL_HOST = "localhost"
|
||||
EMAIL_USE_TLS = False
|
||||
EMAIL_USE_SSL = False
|
||||
EMAIL_PORT = 10025
|
||||
EMAIL_HOST_USER = "admin"
|
||||
EMAIL_HOST_PASSWORD = "password"
|
||||
DEFAULT_FROM_EMAIL = "no-reply@gna.org"
|
|
@ -9,12 +9,8 @@ https://docs.djangoproject.com/en/4.0/topics/settings/
|
|||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
import environ
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
@ -28,13 +24,13 @@ SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y
|
|||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
|
@ -52,6 +48,7 @@ INSTALLED_APPS = [
|
|||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"oauth2_provider.middleware.OAuth2TokenMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
|
@ -86,9 +83,14 @@ WSGI_APPLICATION = "dashboard.wsgi.application"
|
|||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": env.db_url(
|
||||
"DATABSE_URL", default="postgres://postgres:password@localhost:5432/postgres"
|
||||
)
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "db-doesn't-exist",
|
||||
"USER": "postgres",
|
||||
"PASSWORD": "password",
|
||||
"HOST": "localhost",
|
||||
"PORT": "5432",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -118,7 +120,9 @@ LANGUAGE_CODE = "en-us"
|
|||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
# not yet implemented so disabling as it provides performance benefits
|
||||
# ref: https://docs.djangoproject.com/en/4.0/topics/i18n/translation/
|
||||
USE_I18N = False
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
@ -126,11 +130,13 @@ USE_TZ = True
|
|||
# https://docs.djangoproject.com/en/4.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static",
|
||||
BASE_DIR / "common-static",
|
||||
]
|
||||
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
|
||||
|
||||
|
@ -155,8 +161,8 @@ PAYMENT_VARIANTS = {
|
|||
"stripe": (
|
||||
"payments.stripe.StripeProvider",
|
||||
{
|
||||
"secret_key": env.get_value("STRIPE_SECRET_KEY"),
|
||||
"public_key": env.get_value("STRIPE_PUBLIC_KEY"),
|
||||
"secret_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -165,17 +171,18 @@ PAYMENT_VARIANTS = {
|
|||
### Dashbaord specific configuration options
|
||||
|
||||
HOSTEA = {
|
||||
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
|
||||
"RESTRICT_NEW_INTEGRATION_INSTALLATION": True,
|
||||
"INSTANCE_MAINTAINER_CONTACT": "contact@hostea.example.org",
|
||||
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
|
||||
"ACCOUNTS": {
|
||||
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
|
||||
"SUDO_TTL": 60 * 5,
|
||||
},
|
||||
"META": {
|
||||
"GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce
|
||||
"GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
"FORGEJO_INSTANCE": "http://localhost:3000", # meta Forgejo insatnce
|
||||
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
# Repository dedicated for handling support
|
||||
# ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
|
||||
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
|
||||
"SUPPORT_REPOSITORY": "support",
|
||||
},
|
||||
"INFRA": {
|
||||
|
@ -186,16 +193,23 @@ HOSTEA = {
|
|||
"REMOTE": "git@localhost:Hostea/enough.git",
|
||||
# SSH key that can push to the Git repository remote mentioned above
|
||||
"SSH_KEY": "/srv/hostea/deploy",
|
||||
}
|
||||
},
|
||||
"HOSTEA_DOMAIN": "vm.gna.org", # domain at which Hostea VMs will be spun up
|
||||
},
|
||||
}
|
||||
|
||||
EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025")
|
||||
|
||||
vars().update(EMAIL_CONFIG)
|
||||
# Please see EMAIL_* configuration options:
|
||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
|
||||
EMAIL_HOST = "localhost"
|
||||
EMAIL_USE_TLS = False
|
||||
EMAIL_USE_SSL = False
|
||||
EMAIL_PORT = 10025
|
||||
EMAIL_HOST_USER = "admin"
|
||||
EMAIL_HOST_PASSWORD = "password"
|
||||
DEFAULT_FROM_EMAIL: "no-reply@gna.org"
|
||||
|
||||
try:
|
||||
import dashboard.local_settings
|
||||
from dashboard.local_settings import *
|
||||
|
||||
print("Found local_settings")
|
||||
except ModuleNotFoundError:
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
version: "3"
|
||||
|
||||
#networks:
|
||||
# hostea-dash-forgejo:
|
||||
# external: false
|
||||
# hostea-dash-smtp:
|
||||
# external: false
|
||||
|
||||
|
||||
services:
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:1.18.0-1
|
||||
container_name: hostea-dash-forgejo
|
||||
network_mode: host
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
restart: always
|
||||
#networks:
|
||||
# - hostea-dash-forgejo
|
||||
volumes:
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
#ports:
|
||||
# - "8080:3000"
|
||||
# - "2221:22"
|
||||
|
||||
smtp:
|
||||
image: maildev/maildev:latest
|
||||
restart: always
|
||||
container_name: hostea-dash-maildev
|
||||
network_mode: host
|
||||
#networks:
|
||||
# - hostea-dash-smtp
|
||||
environment:
|
||||
- MAILDEV_SMTP_PORT=10025
|
||||
- MAILDEV_INCOMING_USER=admin
|
||||
- MAILDEV_INCOMING_PASS=password
|
||||
#ports:
|
||||
# - "10025:10025"
|
||||
# - "1080:1080"
|
|
@ -15,7 +15,7 @@
|
|||
1. Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hostea.org/Hostea/dashboard.git && cd dashboard
|
||||
git clone https://forgejo.gna.org/Hostea/dashboard.git && cd dashboard
|
||||
```
|
||||
|
||||
2. Create `virtualenv` and activate environment
|
||||
|
|
|
@ -1,5 +1,26 @@
|
|||
# Installation instructions
|
||||
|
||||
## Configuration template
|
||||
|
||||
[dashboard/local_settings.example.py](../dashboard/local_settings.example.py)
|
||||
contains a configuration template that may be used to create production
|
||||
configuration file to override [dashboard/settings.py](../dashboard/settings.py).
|
||||
|
||||
Please copy local_settings.example.py to local_settings.py and make
|
||||
changes to the newly copied file.
|
||||
|
||||
## Static files
|
||||
|
||||
In order to serve static files, please run the following command before
|
||||
running the Dashbaord server:
|
||||
|
||||
```bash
|
||||
yes yes | python manage.py collectstatic
|
||||
```
|
||||
|
||||
This command will gather all static assets from all the modules in
|
||||
Dashbaord and place them in `static/` in the base directory.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
Run cron job at an interval of your choosing with the following comamnd:
|
||||
|
@ -23,8 +44,8 @@ hence the current redundancy in configuration and cronjob duration.
|
|||
|
||||
## Support Platform Integration
|
||||
|
||||
Hostea Dashbaord delegates support to Hostea's meta Gitea instance, as
|
||||
discussed [here](https://gitea.hostea.org/Hostea/july-mvp/issues/17).
|
||||
Hostea Dashbaord delegates support to Hostea's meta Forgejo instance, as
|
||||
discussed [here](https://forgejo.gna.org/Hostea/july-mvp/issues/17).
|
||||
|
||||
To configure support platform integration , please set the following
|
||||
attributes in `settings.py`:
|
||||
|
@ -34,10 +55,10 @@ HOSTEA = {
|
|||
# <--snip--->
|
||||
"META": {
|
||||
# <--snip--->
|
||||
"GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce
|
||||
"GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
"FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce
|
||||
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
|
||||
# Repository dedicated for handling support
|
||||
# ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
|
||||
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
|
||||
"SUPPORT_REPOSITORY": "support",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
## Manage Virtual Machines
|
||||
|
||||
### 1. Create VM: Creates a new VM, bypasing payments
|
||||
|
||||
#### Pre-requisites:
|
||||
|
||||
1. A registered and active/email-verified user
|
||||
|
||||
#### Example:
|
||||
|
||||
```bash
|
||||
python manage.py vm create <VM-name> --owner=<owner-username> --flavor=<flavor>
|
||||
# flavor=[small,medium,large]
|
||||
```
|
||||
|
||||
This command is not idempotent. The command throws an error when a
|
||||
supplied flavor is not available or when a VM with the same name exists.
|
||||
|
||||
### 2. Delete VM:
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
python manage.py vm delete <VM-name>
|
||||
```
|
||||
|
||||
This command is not idempotent. The command throws an error when a
|
||||
a VM with the given name doesn't exist.
|
||||
|
||||
### 3. Generate Invoices: periodically generate invoices for VMs
|
||||
|
||||
```bash
|
||||
python manage.py generate_invoice
|
||||
```
|
||||
|
||||
Generates invoices for VMs which enter a new billing cycle and sends
|
||||
notification email to VM owners.
|
||||
|
||||
This command can be run as many times as desirable but running at least
|
||||
once in a day is advisable.
|
|
@ -1,3 +1,5 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import InstanceCreated
|
||||
|
||||
admin.site.register(InstanceCreated)
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from enum import Enum, unique
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from oauth2_provider.models import get_application_model
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
|
||||
from dash.models import InstanceConfiguration, Instance
|
||||
from dash.utils import create_instance, VmException, VmErrors
|
||||
from infrastructure.utils import create_vm_if_not_exists
|
||||
|
||||
|
||||
def translate_sizes(flavor: str):
|
||||
if flavor == "small":
|
||||
size = "s1-2"
|
||||
elif flavor == "medium":
|
||||
size = "s1-4"
|
||||
elif flavor == "large":
|
||||
size = "s1-8"
|
||||
else:
|
||||
print("flavour no match")
|
||||
size = flavor
|
||||
return size
|
||||
|
||||
|
||||
@unique
|
||||
class Actions(Enum):
|
||||
CREATE = "create"
|
||||
DELETE = "delete"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Get user ID from username"
|
||||
action_key = "action"
|
||||
vm_name_key = "vm_name"
|
||||
flavor_key = "flavor"
|
||||
owner_key = "owner"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
self.action_key,
|
||||
type=Actions,
|
||||
help="VM action: create/delete",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
self.vm_name_key,
|
||||
type=str,
|
||||
help="Name of the VM",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
f"--{self.owner_key}",
|
||||
type=str,
|
||||
help="Owner username",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
f"--{self.flavor_key}",
|
||||
type=str,
|
||||
help="Name of the VM flavor: small, medium, large",
|
||||
)
|
||||
|
||||
def create_vm(self, *args, **options):
|
||||
owner = options[self.owner_key]
|
||||
flavor = options[self.flavor_key]
|
||||
vm_name = options[self.vm_name_key]
|
||||
|
||||
size = translate_sizes(flavor)
|
||||
|
||||
user = get_user_model().objects.get(username=owner)
|
||||
try:
|
||||
instance = create_instance(
|
||||
vm_name=vm_name, configuration_name=size, user=user
|
||||
)
|
||||
(forgejo_password, _commit) = create_vm_if_not_exists(instance)
|
||||
print("Instance created")
|
||||
print(f"Forgejo admin password: {forgejo_password}")
|
||||
except VmException as e:
|
||||
if e.code == VmErrors.NAME_EXISTS:
|
||||
instance = Instance.objects.get(name=vm_name)
|
||||
if instance.configuration_id.name != size:
|
||||
instance.configuration_id = InstanceConfiguration.objects.get(
|
||||
name=size
|
||||
)
|
||||
instance.save()
|
||||
(forgejo_password, _commit) = create_vm_if_not_exists(instance)
|
||||
print("Instance created")
|
||||
print(f"Forgejo admin password: {forgejo_password}")
|
||||
|
||||
else:
|
||||
self.stderr.write(self.style.ERROR(f"error: {str(e)}"))
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"error: {str(e)}"))
|
||||
|
||||
def delete_vm(self, *args, **options):
|
||||
from infrastructure.utils import delete_vm
|
||||
|
||||
vm_name = options[self.vm_name_key]
|
||||
if Instance.objects.filter(name=vm_name).exists():
|
||||
instance = Instance.objects.get(name=vm_name)
|
||||
delete_vm(instance=instance)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for i in [self.action_key, self.vm_name_key]:
|
||||
if i not in options:
|
||||
self.stdout.write(self.style.ERROR(f"Please provide {i}"))
|
||||
return
|
||||
if options[self.action_key] == Actions.CREATE:
|
||||
for i in [self.flavor_key, self.owner_key]:
|
||||
if i not in options:
|
||||
self.stdout.write(self.style.ERROR(f"Please provide {i}"))
|
||||
return
|
||||
|
||||
self.create_vm(*args, **options)
|
||||
elif options[self.action_key] == Actions.DELETE:
|
||||
self.delete_vm(*args, **options)
|
||||
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Unknown action: {options[self.action_key]}")
|
||||
)
|
||||
return
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.0.3 on 2022-06-27 17:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("infrastructure", "0002_instancecreated_gitea_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="instancecreated",
|
||||
old_name="creted",
|
||||
new_name="created",
|
||||
),
|
||||
]
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 4.0.3 on 2022-06-28 09:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("infrastructure", "0003_rename_creted_instancecreated_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="instancecreated",
|
||||
name="gitea_password",
|
||||
field=models.CharField(
|
||||
default=None,
|
||||
max_length=32,
|
||||
verbose_name="Gitea password of this deployment",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.0.3 on 2022-06-28 14:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("infrastructure", "0004_alter_instancecreated_gitea_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="instancecreated",
|
||||
name="gitea_password",
|
||||
),
|
||||
]
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 4.0.3 on 2022-06-29 18:30
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("dash", "0006_auto_20220619_0800"),
|
||||
("infrastructure", "0005_remove_instancecreated_gitea_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Job",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("job_type", models.CharField(max_length=10, verbose_name="Job Type")),
|
||||
(
|
||||
"instance",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="dash.instance"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -12,16 +12,30 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from django.db import models
|
||||
from enum import Enum, unique
|
||||
|
||||
from django.db import models
|
||||
from dash.models import Instance
|
||||
|
||||
|
||||
@unique
|
||||
class JobType(Enum):
|
||||
PING = "ping"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class InstanceCreated(models.Model):
|
||||
instance = models.ForeignKey(Instance, on_delete=models.PROTECT)
|
||||
gitea_password = models.CharField(
|
||||
"Name of this configuration",
|
||||
default=None,
|
||||
max_length=32,
|
||||
created = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class Job(models.Model):
|
||||
instance = models.ForeignKey(Instance, on_delete=models.CASCADE)
|
||||
|
||||
job_type = models.CharField(
|
||||
"Job Type",
|
||||
max_length=10,
|
||||
null=False,
|
||||
)
|
||||
creted = models.BooleanField(default=False)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
Hello {{ username }},
|
||||
|
||||
Congratulations on your new Gna! instance!
|
||||
|
||||
Your Gna! instance is being prepared, you will receive an email
|
||||
notification when it is ready.
|
||||
|
||||
You can use the following credentials to log into an admin account on
|
||||
your new Gna! Forgejo instance. Great powers come with great
|
||||
responsibilities, so use the admin credentials wisely. When in doubt,
|
||||
consult the Forgejo docs or contact support!
|
||||
|
||||
- username : root
|
||||
- password: {{ forgejo_password }}
|
||||
- Forgejo {{ forgejo_uri }}
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,11 @@
|
|||
Hello {{ username }}!,
|
||||
|
||||
The deployment job has run to completion and your Gna! instance is now online!
|
||||
Credentials to admin account was sent in an earlier email, please contact
|
||||
support if didn't receive it.
|
||||
|
||||
Forgejo: {{ forgejo_uri }}
|
||||
Woodpecker CI: {{ woodpecker_uri }}
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,10 @@
|
|||
Hello {{ username }}!,
|
||||
|
||||
|
||||
A customer has purchased a new instance. Please find the details below:
|
||||
|
||||
Forgejo: {{ forgejo_uri }}
|
||||
Woodpecker CI: {{ woodpecker_uri }}
|
||||
|
||||
Cheers,
|
||||
Gna! team
|
|
@ -0,0 +1,11 @@
|
|||
{% extends 'dash/common/base.html' %} {% block dash %}
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<h2>Forgejo Admin Credentials</h2>
|
||||
|
||||
<ul>
|
||||
<li><b>Username:</b> root</li>
|
||||
<li><b>Password:</b> {{ forgejo_password }}</li>
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
|
@ -1,2 +1,2 @@
|
|||
enough --domain $domain host create {{subdomain}}-host
|
||||
enough --domain $domain service create --host {{subdomain}}-host gitea
|
||||
enough --domain $domain service create --host {{subdomain}}-host forgejo
|
||||
|
|
|
@ -1 +1 @@
|
|||
enough --domain $domain host delete hostea001-host
|
||||
enough --domain $domain host delete {{ subdomain }}-host
|
||||
|
|
|
@ -2,49 +2,49 @@
|
|||
#
|
||||
#######################################
|
||||
#
|
||||
# Public hostname of the Gitea instance
|
||||
# Public hostname of the Forgejo instance
|
||||
#
|
||||
#
|
||||
gitea_host: "{{ subdomain }}.{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
|
||||
forgejo_host: "{{ subdomain }}.{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# Mailer from
|
||||
#
|
||||
#
|
||||
gitea_mailer_from: "noreply@{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
|
||||
forgejo_mailer_from: "noreply@{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# SSH port of the Gitea instance
|
||||
# SSH port of the Forgejo instance
|
||||
#
|
||||
#
|
||||
gitea_ssh_port: "22"
|
||||
forgejo_ssh_port: "22"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# Gitea version
|
||||
# Forgejo version
|
||||
#
|
||||
#
|
||||
gitea_version: "1.16.8"
|
||||
#forgejo_version: "1.18.0-1"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# Admin user name
|
||||
#
|
||||
gitea_user: root
|
||||
forgejo_user: root
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# Admin user password
|
||||
#
|
||||
gitea_password: "{{ gitea_password }}"
|
||||
forgejo_password: "{{ forgejo_password }}"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
# Admin user email
|
||||
#
|
||||
gitea_email: "{{ gitea_email }}"
|
||||
forgejo_email: "{{ forgejo_email }}"
|
||||
#
|
||||
#######################################
|
||||
#
|
||||
|
@ -60,9 +60,9 @@ woodpecker_host: "{{ '{' }}{{ '{' }} woodpecker_hostname {{ '}' }}{{ '}' }}.{{ '
|
|||
#
|
||||
#######################################
|
||||
#
|
||||
# Gitea users with admin rights on woodpecker
|
||||
# Forgejo users with admin rights on woodpecker
|
||||
#
|
||||
woodpecker_admins: "{{ '{' }}{{ '{' }} gitea_user {{ '}' }}{{ '}' }}"
|
||||
woodpecker_admins: "{{ '{' }}{{ '{' }} forgejo_user {{ '}' }}{{ '}' }}"
|
||||
#
|
||||
#######################################
|
||||
#
|
|
@ -1 +1 @@
|
|||
openstack_flavor: {{ vm_size }}
|
||||
openstack_flavor: "{{ '{' }}{{ '{' }} {{ vm_size }} {{ '}' }}{{ '}' }}"
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
forgejo-service-group:
|
||||
hosts:
|
||||
{{ subdomain }}-host:
|
||||
ansible_port: 2222
|
|
@ -12,16 +12,22 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
from pathlib import Path
|
||||
import time
|
||||
from io import StringIO
|
||||
|
||||
from django.test import TestCase, Client, override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from dash.models import Instance
|
||||
from .utils import Infra
|
||||
from django.core.management import call_command
|
||||
from django.core import mail
|
||||
|
||||
from dash.models import Instance, InstanceConfiguration
|
||||
from accounts.tests import register_util, login_util
|
||||
from dash.tests import create_configurations, create_instance_util
|
||||
from dash.tests import create_configurations, create_instance_util, infra_custom_config
|
||||
from infrastructure.management.commands.vm import translate_sizes
|
||||
|
||||
from billing.utils import payment_fullfilled
|
||||
|
||||
from .utils import Infra, Worker, create_vm_if_not_exists, delete_vm
|
||||
from .models import InstanceCreated, Job, JobType
|
||||
|
||||
|
||||
class InfraUtilTest(TestCase):
|
||||
|
@ -32,73 +38,209 @@ class InfraUtilTest(TestCase):
|
|||
def setUp(self):
|
||||
self.username = "infrautil_user"
|
||||
register_util(t=self, username=self.username)
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
create_configurations(t=self)
|
||||
|
||||
@override_settings(
|
||||
HOSTEA={
|
||||
"INFRA": {
|
||||
"HOSTEA_REPO": {
|
||||
"PATH": "/tmp/hostea/dashboard/test_path_util/repo/",
|
||||
"REMOTE": "git@git.batsense.net:realaravinth/dummy-hostea-dash-test",
|
||||
"SSH_KEY": "/src/atm/.ssh/aravinth",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_path_utils(self):
|
||||
infra = Infra()
|
||||
subdomain = "foo"
|
||||
base = infra.repo_path
|
||||
# @override_settings(HOSTEA=infra_custom_config(test_name="test_path_util"))
|
||||
# def test_path_utils(self):
|
||||
# infra = Infra()
|
||||
# subdomain = "foo"
|
||||
# base = infra.repo_path
|
||||
#
|
||||
# self.assertEqual(
|
||||
# base.joinpath(f"inventory/host_vars/{subdomain}-host/"),
|
||||
# infra._host_vars_dir(subdomain=subdomain),
|
||||
# )
|
||||
#
|
||||
# self.assertEqual(
|
||||
# base.joinpath(f"inventory/host_vars/{subdomain}-host/forgejo.yml"),
|
||||
# infra._forgejo_path(subdomain=subdomain),
|
||||
# )
|
||||
#
|
||||
# self.assertEqual(
|
||||
# base.joinpath(f"inventory/host_vars/{subdomain}-host/provision.yml"),
|
||||
# infra._provision_path(subdomain=subdomain),
|
||||
# )
|
||||
#
|
||||
# self.assertEqual(
|
||||
# base.joinpath(f"inventory/{subdomain}-backup.yml"),
|
||||
# infra._backup_path(subdomain=subdomain),
|
||||
# )
|
||||
#
|
||||
# self.assertEqual(
|
||||
# base.joinpath(f"hosts-scripts/{subdomain}-host.sh"),
|
||||
# infra._hostscript_path(subdomain=subdomain),
|
||||
# )
|
||||
#
|
||||
# @override_settings(HOSTEA=infra_custom_config(test_name="test_add_vm"))
|
||||
# def test_add_vm(self):
|
||||
# c = Client()
|
||||
# login_util(self, c, "accounts.home")
|
||||
# subdomain = "add_vm"
|
||||
#
|
||||
# create_instance_util(
|
||||
# t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
|
||||
# )
|
||||
#
|
||||
# instance = Instance.objects.get(name=subdomain)
|
||||
#
|
||||
# infra = Infra()
|
||||
# before_add = infra._sha()
|
||||
# (password, after_add) = infra.add_vm(instance=instance)
|
||||
# self.assertNotEqual(before_add, after_add)
|
||||
#
|
||||
# before_rm = after_add
|
||||
# after_rm = infra.remove_vm(instance=instance)
|
||||
# self.assertNotEqual(before_rm, after_rm)
|
||||
#
|
||||
# @override_settings(HOSTEA=infra_custom_config(test_name="test_cmd"))
|
||||
# def test_cmd(self):
|
||||
# subdomain = "cmd_vm"
|
||||
# infra = Infra()
|
||||
# c = Client()
|
||||
# login_util(self, c, "accounts.home")
|
||||
#
|
||||
# self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
|
||||
# # username exists
|
||||
# call_command(
|
||||
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
|
||||
# )
|
||||
#
|
||||
# instance = Instance.objects.get(name=subdomain)
|
||||
#
|
||||
# self.assertEqual(infra.get_flavor(instance=instance), "openstack_flavor_medium")
|
||||
#
|
||||
# self.assertEqual(instance.owned_by, self.user)
|
||||
# self.assertEqual(
|
||||
# instance.configuration_id, InstanceConfiguration.objects.get(name="s1-4")
|
||||
# )
|
||||
#
|
||||
# instance_created = InstanceCreated.objects.get(instance=instance)
|
||||
# self.assertEqual(instance_created.instance, instance)
|
||||
#
|
||||
# self.assertEqual(instance_created.created, True)
|
||||
#
|
||||
# # run create vm command again with same configuration to crudely check idempotency
|
||||
# call_command(
|
||||
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
|
||||
# )
|
||||
#
|
||||
# # run create vm command again with different configuration but same name
|
||||
# # to crudely check idempotency
|
||||
# call_command(
|
||||
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=large"
|
||||
# )
|
||||
# instance.refresh_from_db()
|
||||
# # verify new size is updated in DB
|
||||
# self.assertEqual(
|
||||
# str.strip(instance.configuration_id.name)
|
||||
# == str.strip(translate_sizes("large")),
|
||||
# True,
|
||||
# )
|
||||
#
|
||||
# # verify new size is updated in repository
|
||||
# self.assertEqual(
|
||||
# str.strip(infra.translate_size(instance=instance))
|
||||
# == str.strip(infra.get_flavor(instance=instance)),
|
||||
# True,
|
||||
# )
|
||||
#
|
||||
# call_command("vm", "delete", subdomain)
|
||||
#
|
||||
# self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
|
||||
# host_vars_dir = infra._host_vars_dir(subdomain)
|
||||
# self.assertEqual(host_vars_dir.exists(), False)
|
||||
#
|
||||
# # run delete VM command to crudely check idempotency
|
||||
# call_command("vm", "delete", subdomain)
|
||||
#
|
||||
# def test_worker(self):
|
||||
# subdomain = "forgejo" # yes, forgejo.hostea.org exists. will use it till I
|
||||
# # figure out how to use requests_mock within django
|
||||
# c = Client()
|
||||
# login_util(self, c, "accounts.home")
|
||||
# create_instance_util(
|
||||
# t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
|
||||
# )
|
||||
#
|
||||
# instance = Instance.objects.get(name=subdomain)
|
||||
# job = Job.objects.create(instance=instance, job_type=JobType.PING)
|
||||
# forgejo_uri = Infra.get_forgejo_uri(instance=instance)
|
||||
# print(f"mocking {forgejo_uri}")
|
||||
#
|
||||
# w = Worker(job=job)
|
||||
# w.start()
|
||||
# time.sleep(15)
|
||||
# self.assertEqual(w.is_alive(), False)
|
||||
# w.join()
|
||||
# self.assertEqual(
|
||||
# Job.objects.filter(instance=instance, job_type=JobType.PING).exists(), True
|
||||
# )
|
||||
#
|
||||
@override_settings(HOSTEA=infra_custom_config(test_name="test_vm_delete_payments"))
|
||||
def test_vm_delete_payments(self):
|
||||
"""
|
||||
Test if the dashboard generates invoices for a VM crated with a name
|
||||
matching a VM name that was deleted that existed.
|
||||
|
||||
self.assertEqual(
|
||||
base.joinpath(f"inventory/host_vars/{subdomain}-host/"),
|
||||
infra._host_vars_dir(subdomain=subdomain),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
base.joinpath(f"inventory/host_vars/{subdomain}-host/gitea.yml"),
|
||||
infra._gitea_path(subdomain=subdomain),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
base.joinpath(f"inventory/host_vars/{subdomain}-host/provision.yml"),
|
||||
infra._provision_path(subdomain=subdomain),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
base.joinpath(f"inventory/{subdomain}-backup.yml"),
|
||||
infra._backup_path(subdomain=subdomain),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
base.joinpath(f"inventory/hosts-scripts/{subdomain}-host.sh"),
|
||||
infra._hostscript_path(subdomain=subdomain),
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
HOSTEA={
|
||||
"INFRA": {
|
||||
"HOSTEA_REPO": {
|
||||
"PATH": "/tmp/hostea/dashboard/test_add_vm/repo/",
|
||||
"REMOTE": "git@git.batsense.net:realaravinth/dummy-hostea-dash-test",
|
||||
"SSH_KEY": "/src/atm/.ssh/aravinth",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
def test_add_vm(self):
|
||||
infra = Infra()
|
||||
ref: https://forgejo.hostea.org/Hostea/dashboard/issues/38#issuecomment-1162
|
||||
"""
|
||||
c = Client()
|
||||
login_util(self, c, "accounts.home")
|
||||
subdomain = "add_vm"
|
||||
instance_name = "trmpayments"
|
||||
|
||||
base = infra.repo_path
|
||||
infra = Infra()
|
||||
|
||||
create_instance_util(
|
||||
t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
|
||||
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
|
||||
)
|
||||
|
||||
instance = Instance.objects.get(name=subdomain)
|
||||
woodpecker_agent_secret = infra.add_vm(instance=instance)
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
self.assertEqual(payment_fullfilled(instance=instance), True)
|
||||
create_vm_if_not_exists(instance=instance)
|
||||
|
||||
# infra.remove_vm(instance=instance)
|
||||
# delete VM
|
||||
delete_vm(instance=instance)
|
||||
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), False)
|
||||
|
||||
# re-create VM with management command as it bypasses payments. We
|
||||
# usually use create_instance_util but it will pay for the instance too
|
||||
call_command(
|
||||
"vm", "create", instance_name, f"--owner={self.username}", "--flavor=medium"
|
||||
)
|
||||
# verify VM is created
|
||||
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), True)
|
||||
# verify payment is unfulfilled
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
self.assertEqual(payment_fullfilled(instance=instance), False)
|
||||
|
||||
# generate invoice
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
|
||||
call_command(
|
||||
"generate_invoice",
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
out = stdout.getvalue()
|
||||
print("out")
|
||||
print(out)
|
||||
self.assertEqual(instance_name in out, True)
|
||||
self.assertEqual(f"Payment not fulfilled for instance: {instance}" in out, True)
|
||||
|
||||
staff_notification = None
|
||||
for m in mail.outbox:
|
||||
if "New instance alert" in m.subject:
|
||||
staff_notification = m
|
||||
break
|
||||
self.assertEqual(staff_notification.to[0], self.email)
|
||||
self.assertEqual(
|
||||
"[Gna!] New instance alert" in staff_notification.subject, True
|
||||
)
|
||||
self.assertEqual(
|
||||
"A customer has purchased a new instance. Please find the details below:"
|
||||
in staff_notification.body,
|
||||
True,
|
||||
)
|
||||
|
|
|
@ -12,18 +12,137 @@
|
|||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
import os
|
||||
import sh
|
||||
import shutil
|
||||
import yaml
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.template.loader import render_to_string
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from git import Repo
|
||||
from git.exc import InvalidGitRepositoryError
|
||||
from payments import get_payment_model
|
||||
|
||||
from dash.models import Instance
|
||||
|
||||
from infrastructure.models import InstanceCreated, JobType, Job
|
||||
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Worker(Thread):
|
||||
def __init__(self, job: Job):
|
||||
self.job = job
|
||||
super().__init__()
|
||||
|
||||
def run(self):
|
||||
forgejo_uri = Infra.get_forgejo_uri(instance=self.job.instance)
|
||||
woodpecker = Infra.get_woodpecker_uri(instance=self.job.instance)
|
||||
while True:
|
||||
try:
|
||||
print(f"[ping] Trying to reach {forgejo_uri}")
|
||||
resp = requests.get(forgejo_uri)
|
||||
if resp.status_code == 200:
|
||||
break
|
||||
except Exception:
|
||||
return False
|
||||
sleep(10)
|
||||
|
||||
job = self.job
|
||||
self.job = None
|
||||
email = job.instance.owned_by.email
|
||||
|
||||
ctx = {
|
||||
"forgejo_uri": forgejo_uri,
|
||||
"woodpecker_uri": woodpecker,
|
||||
"username": job.instance.owned_by.username,
|
||||
}
|
||||
body = render_to_string(
|
||||
"infrastructure/emails/instance-created.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Your Gna! instance is now online!",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
||||
recipient_list=[email],
|
||||
)
|
||||
job.delete()
|
||||
|
||||
|
||||
def create_vm_if_not_exists(instance: Instance) -> (str, str):
|
||||
"""
|
||||
Create VM utility. Forgejo password is returned
|
||||
"""
|
||||
|
||||
def notify_staff(instance: Instance):
|
||||
infra = Infra()
|
||||
User = get_user_model()
|
||||
forgejo_uri = Infra.get_forgejo_uri(instance=instance)
|
||||
woodpecker = Infra.get_woodpecker_uri(instance=instance)
|
||||
|
||||
for staff in User.objects.filter(is_staff=True):
|
||||
ctx = {
|
||||
"forgejo_uri": forgejo_uri,
|
||||
"woodpecker_uri": woodpecker,
|
||||
"username": staff.username,
|
||||
}
|
||||
body = render_to_string(
|
||||
"infrastructure/emails/staff-new-instance-alert.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] New instance alert",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
||||
recipient_list=[staff.email],
|
||||
)
|
||||
|
||||
infra = Infra()
|
||||
if not InstanceCreated.objects.filter(instance=instance).exists():
|
||||
(forgejo_password, commit) = infra.add_vm(instance=instance)
|
||||
InstanceCreated.objects.create(instance=instance, created=True)
|
||||
notify_staff(instance=instance)
|
||||
job = Job.objects.create(instance=instance, job_type=str(JobType.PING))
|
||||
Worker(job=job).start()
|
||||
return (forgejo_password, commit)
|
||||
else:
|
||||
if str.strip(infra.get_flavor(instance=instance)) != str.strip(
|
||||
infra.translate_size(instance=instance)
|
||||
):
|
||||
# Worker.init_global()
|
||||
notify_staff(instance=instance)
|
||||
return infra.add_vm(instance=instance)
|
||||
return None
|
||||
|
||||
|
||||
def delete_vm(instance: Instance):
|
||||
infra = Infra()
|
||||
Payment = get_payment_model()
|
||||
for payment in Payment.objects.filter(
|
||||
paid_by=instance.owned_by, instance_name=instance.name
|
||||
):
|
||||
payment.vm_deleted = True
|
||||
payment.save()
|
||||
|
||||
infra.remove_vm(instance=instance)
|
||||
if InstanceCreated.objects.filter(instance=instance).exists():
|
||||
InstanceCreated.objects.get(instance=instance).delete()
|
||||
instance.delete()
|
||||
|
||||
|
||||
class Infra:
|
||||
"""
|
||||
|
@ -33,16 +152,17 @@ class Infra:
|
|||
def __init__(self):
|
||||
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
||||
self.repo_path = Path(conf["PATH"])
|
||||
if not self.repo_path.exists():
|
||||
os.makedirs(self.repo_path)
|
||||
self._clone()
|
||||
|
||||
self.ssh_cmd = f"ssh -i {conf['SSH_KEY']}"
|
||||
try:
|
||||
self.repo = Repo(path=self.repo_path)
|
||||
except InvalidGitRepositoryError:
|
||||
self.repo = Repo.clone_from(
|
||||
conf["REMOTE"], self.repo_path, env={"GIT_SSH_COMMAND": self.ssh_cmd}
|
||||
)
|
||||
def _clone(self):
|
||||
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
||||
ssh_cmd = f"/usr/bin/ssh -oStrictHostKeyChecking=no -i {conf['SSH_KEY']}"
|
||||
self.git = sh.git.bake(_env={"GIT_SSH_COMMAND": ssh_cmd})
|
||||
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
||||
if os.path.exists(self.repo_path):
|
||||
shutil.rmtree(self.repo_path)
|
||||
self.git.clone(conf["REMOTE"], self.repo_path)
|
||||
self.git = self.git.bake("-C", self.repo_path)
|
||||
|
||||
def _host_vars_dir(self, subdomain: str) -> Path:
|
||||
"""
|
||||
|
@ -54,15 +174,49 @@ class Infra:
|
|||
"""
|
||||
utility method: get provision file pay for a subdomain
|
||||
"""
|
||||
|
||||
return self._host_vars_dir(subdomain=subdomain).joinpath("provision.yml")
|
||||
|
||||
def _gitea_path(self, subdomain: str) -> Path:
|
||||
@classmethod
|
||||
def get_forgejo_uri(cls, instance: Instance) -> str:
|
||||
"""
|
||||
utility method: get gitea file for a subdomain
|
||||
Get an instance's Forgejo URI
|
||||
"""
|
||||
base = settings.HOSTEA["INFRA"]["HOSTEA_DOMAIN"]
|
||||
return f"https://{instance.name}.{base}"
|
||||
|
||||
@classmethod
|
||||
def _gen_woodpecker_hostname(cls, instance: Instance) -> str:
|
||||
"""
|
||||
Get Woodpecker hostname of an instance
|
||||
"""
|
||||
return f"{instance.name}-ci"
|
||||
|
||||
@classmethod
|
||||
def get_woodpecker_uri(cls, instance: Instance) -> str:
|
||||
"""
|
||||
Get an instance's Forgejo URI
|
||||
"""
|
||||
base = settings.HOSTEA["INFRA"]["HOSTEA_DOMAIN"]
|
||||
return f"https://{cls._gen_woodpecker_hostname(instance=instance)}.{base}"
|
||||
|
||||
def get_flavor(self, instance: Instance):
|
||||
"""
|
||||
Get VM flavour/size/configuration from the fleet repository
|
||||
"""
|
||||
subdomain = instance.name
|
||||
provision = self._provision_path(subdomain)
|
||||
with open(provision, "r", encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
if "openstack_flavor" in config:
|
||||
return config["openstack_flavor"].split("{{ ")[1].split(" }}")[0]
|
||||
return None
|
||||
|
||||
def _forgejo_path(self, subdomain: str) -> Path:
|
||||
"""
|
||||
utility method: get forgejo file for a subdomain
|
||||
"""
|
||||
|
||||
return self._host_vars_dir(subdomain=subdomain).joinpath("gitea.yml")
|
||||
return self._host_vars_dir(subdomain=subdomain).joinpath("forgejo.yml")
|
||||
|
||||
def _backup_path(self, subdomain: str) -> Path:
|
||||
"""
|
||||
|
@ -71,11 +225,18 @@ class Infra:
|
|||
|
||||
return self.repo_path.joinpath(f"inventory/{subdomain}-backup.yml")
|
||||
|
||||
def _service_path(self, subdomain: str) -> Path:
|
||||
"""
|
||||
utility method: get service file for a subdomain
|
||||
"""
|
||||
|
||||
return self.repo_path.joinpath(f"inventory/{subdomain}-service.yml")
|
||||
|
||||
def _hostscript_path(self, subdomain: str) -> Path:
|
||||
"""
|
||||
utility method: hostscript file for a subdomain
|
||||
"""
|
||||
return self.repo_path.joinpath(f"inventory/hosts-scripts/{subdomain}-host.sh")
|
||||
return self.repo_path.joinpath(f"hosts-scripts/{subdomain}-host.sh")
|
||||
|
||||
def write_hostscript(self, subdomain: str, content: str):
|
||||
"""
|
||||
|
@ -89,32 +250,42 @@ class Infra:
|
|||
f.write(content)
|
||||
f.write("\n")
|
||||
|
||||
def _add_files(self, subdomain: str):
|
||||
"""
|
||||
Add all relevant files of a VM
|
||||
"""
|
||||
self.repo.git.add(str(self._host_vars_dir(subdomain=subdomain)))
|
||||
self.repo.git.add(str(self._backup_path(subdomain=subdomain)))
|
||||
self.repo.git.add(str(self._hostscript_path(subdomain=subdomain)))
|
||||
def _push(self, message):
|
||||
self.git.add(".")
|
||||
self.git.config("user.email", settings.HOSTEA["INSTANCE_MAINTAINER_CONTACT"])
|
||||
self.git.config("user.name", "Hostea dashboard")
|
||||
try:
|
||||
self.git.commit("-m", f"dashboard: {message}")
|
||||
except sh.ErrorReturnCode_1:
|
||||
logger.debug("no change")
|
||||
else:
|
||||
self.git.push("origin", "master")
|
||||
return self._sha()
|
||||
|
||||
def _commit(self, action: str, subdomain: str):
|
||||
"""
|
||||
Commit changes to a VM configuration
|
||||
"""
|
||||
def _sha(self):
|
||||
sha = self.git("rev-parse", "origin/master")
|
||||
return str(sha).strip()
|
||||
|
||||
self._add_files(subdomain=subdomain)
|
||||
self.repo.git.commit(
|
||||
message=f"{action} VM {subdomain}",
|
||||
author="Dashboard Bot <bot@dashboard.hostea.org>",
|
||||
)
|
||||
@staticmethod
|
||||
def translate_size(instance: Instance) -> str:
|
||||
"""
|
||||
Translate openstack(I think OVH-specific) sizes to enough.community
|
||||
normalized sizes
|
||||
"""
|
||||
if instance.configuration_id.name == "s1-2":
|
||||
return "openstack_flavor_small"
|
||||
if instance.configuration_id.name == "s1-4":
|
||||
return "openstack_flavor_medium"
|
||||
if instance.configuration_id.name == "s1-8":
|
||||
return "openstack_flavor_large"
|
||||
return instance.configuration_id.name
|
||||
|
||||
def add_vm(self, instance: Instance) -> str:
|
||||
def add_vm(self, instance: Instance) -> (str, str):
|
||||
"""
|
||||
Add new VM to infrastructure repository
|
||||
|
||||
The gitea user password is returned
|
||||
The forgejo user password is returned
|
||||
"""
|
||||
self.repo.git.pull()
|
||||
|
||||
subdomain = instance.name
|
||||
host_vars_dir = self._host_vars_dir(subdomain)
|
||||
|
@ -122,67 +293,60 @@ class Infra:
|
|||
if not host_vars_dir.exists():
|
||||
os.makedirs(host_vars_dir)
|
||||
|
||||
hostscript_path = self.repo_path.joinpath("inventory/hosts-scripts/")
|
||||
hostscript_path = self.repo_path.joinpath("hosts-scripts/")
|
||||
if not hostscript_path.exists():
|
||||
os.makedirs(hostscript_path)
|
||||
|
||||
woodpecker_agent_secret = get_random_string(64)
|
||||
gitea_password = get_random_string(20)
|
||||
forgejo_password = get_random_string(20)
|
||||
|
||||
ctx = {
|
||||
"woodpecker_agent_secret": woodpecker_agent_secret,
|
||||
"woodpecker_hostname": f"{subdomain}-ci",
|
||||
"woodpecker_hostname": self._gen_woodpecker_hostname(instance=instance),
|
||||
"woodpecker_admins": f"{instance.owned_by.username}",
|
||||
"gitea_email": instance.owned_by.email,
|
||||
"gitea_password": gitea_password,
|
||||
"forgejo_email": instance.owned_by.email,
|
||||
"forgejo_password": forgejo_password,
|
||||
"subdomain": subdomain,
|
||||
}
|
||||
|
||||
gitea = self._gitea_path(subdomain)
|
||||
with open(gitea, "w", encoding="utf-8") as f:
|
||||
forgejo = self._forgejo_path(subdomain)
|
||||
with open(forgejo, "w+", encoding="utf-8") as f:
|
||||
f.write(
|
||||
render_to_string(
|
||||
"infrastructure/yml/gitea.yml",
|
||||
"infrastructure/yml/forgejo.yml",
|
||||
context=ctx,
|
||||
)
|
||||
)
|
||||
|
||||
# provision_template = "./templates/infrastructure/yml/provision.yml"
|
||||
size = self.translate_size(instance=instance)
|
||||
provision = self._provision_path(subdomain)
|
||||
# TODO: instance config names are different the flavours expected:
|
||||
# ```
|
||||
# openstack_flavor: ‘{{ openstack_flavor_medium }}’ * openstack_flavor: ‘{{ openstack_flavor_large }}’
|
||||
# ```
|
||||
# check with @dachary about this
|
||||
|
||||
size = None
|
||||
if instance.configuration_id.name == "s1-2":
|
||||
size = "openstack_flavor_small"
|
||||
elif instance.configuration_id.name == "s1-4":
|
||||
size = "openstack_flavor_medium"
|
||||
elif instance.configuration_id.name == "s1-8":
|
||||
size = "openstack_flavor_large"
|
||||
else:
|
||||
size = instance.configuration_id.name
|
||||
|
||||
with open(provision, "w", encoding="utf-8") as f:
|
||||
with open(provision, "w+", encoding="utf-8") as f:
|
||||
f.write(
|
||||
render_to_string(
|
||||
"infrastructure/yml/provision.yml", context={"vm_size": size}
|
||||
)
|
||||
)
|
||||
|
||||
# backup = self.repo_path.joinpath(f"inventory/{instance.name}-backup.yml")
|
||||
backup = self._backup_path(subdomain)
|
||||
# backup_template = "./templates/infrastructure/yml/provision.yml"
|
||||
with open(backup, "w", encoding="utf-8") as f:
|
||||
with open(backup, "w+", encoding="utf-8") as f:
|
||||
f.write(
|
||||
render_to_string(
|
||||
"infrastructure/yml/backups.yml", context={"subdomain": subdomain}
|
||||
)
|
||||
)
|
||||
|
||||
# hostscript = self.repo_path.join("inventory/hosts-scripts/{instance.name}-host.sh")
|
||||
service = self._service_path(subdomain)
|
||||
with open(service, "w+", encoding="utf-8") as f:
|
||||
f.write(
|
||||
render_to_string(
|
||||
"infrastructure/yml/service.yml", context={"subdomain": subdomain}
|
||||
)
|
||||
)
|
||||
|
||||
hostscript = self._hostscript_path(subdomain)
|
||||
with open(hostscript, "w+", encoding="utf-8") as f:
|
||||
f.write("\n")
|
||||
|
||||
self.write_hostscript(
|
||||
subdomain=subdomain,
|
||||
|
@ -192,23 +356,31 @@ class Infra:
|
|||
),
|
||||
)
|
||||
|
||||
self._commit(action="add", subdomain=subdomain)
|
||||
self.repo.git.push()
|
||||
return gitea_password
|
||||
commit = self._push(f"add vm {subdomain}")
|
||||
return (forgejo_password, commit)
|
||||
|
||||
def remove_vm(self, instance: Instance):
|
||||
"""
|
||||
Remove a VM from infrastructure repository
|
||||
"""
|
||||
self.repo.git.pull()
|
||||
subdomain = instance.name
|
||||
|
||||
host_vars_dir = self._host_vars_dir(subdomain)
|
||||
if os.path.exists(host_vars_dir):
|
||||
shutil.rmtree(host_vars_dir)
|
||||
|
||||
backup = self._backup_path(subdomain)
|
||||
if os.path.exists(backup):
|
||||
os.remove(backup)
|
||||
|
||||
service = self._service_path(subdomain)
|
||||
if os.path.exists(service):
|
||||
os.remove(service)
|
||||
|
||||
hostscript = self._hostscript_path(subdomain)
|
||||
with open(hostscript, "w+", encoding="utf-8") as f:
|
||||
f.write("\n")
|
||||
|
||||
self.write_hostscript(
|
||||
subdomain=subdomain,
|
||||
content=render_to_string(
|
||||
|
@ -216,5 +388,4 @@ class Infra:
|
|||
context={"subdomain": subdomain},
|
||||
),
|
||||
)
|
||||
self._commit(action="rm", subdomain=subdomain)
|
||||
self.repo.git.push()
|
||||
return self._push(f"rm vm {subdomain}")
|
||||
|
|
|
@ -18,15 +18,17 @@ from django.contrib.auth import authenticate, login, logout
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.decorators import confirm_access
|
||||
from dash.models import Instance
|
||||
from billing.utils import payment_fullfilled
|
||||
|
||||
from .utils import Infra
|
||||
from .models import InstanceCreated
|
||||
from .utils import create_vm_if_not_exists, Infra, delete_vm
|
||||
|
||||
|
||||
def default_ctx(title: str, username: str):
|
||||
|
@ -49,14 +51,31 @@ def create_instance(request, instance_name: str):
|
|||
if not payment_fullfilled(instance=instance):
|
||||
return redirect(reverse("billing.invoice.generate", args=(instance_name,)))
|
||||
|
||||
infra = Infra()
|
||||
if not InstanceCreated.objects.filter(instance=instance).exists():
|
||||
instance = InstanceCreated.objects.create(instance=instance, created=True)
|
||||
instance.save()
|
||||
gitea_password = infra.add_vm(instance=instance)
|
||||
instance.gitea_password = gitea_password
|
||||
instance.save()
|
||||
res = create_vm_if_not_exists(instance=instance)
|
||||
if res is not None:
|
||||
(forgejo_password, commit) = res
|
||||
|
||||
ctx = {
|
||||
"username": request.user.username,
|
||||
"forgejo_password": forgejo_password,
|
||||
"forgejo_uri": Infra.get_forgejo_uri(instance=instance),
|
||||
}
|
||||
|
||||
body = render_to_string(
|
||||
"infrastructure/emails/forgejo-creds.txt",
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
sender = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
send_mail(
|
||||
subject="[Gna!] Forgejo admin credentials",
|
||||
message=body,
|
||||
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
||||
recipient_list=[request.user.email],
|
||||
)
|
||||
|
||||
return render(request, "infrastructure/html/create.html", ctx)
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
|
@ -67,7 +86,5 @@ def delete_instance(request, instance_name: str):
|
|||
Dashboard homepage view
|
||||
"""
|
||||
instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user)
|
||||
infra = Infra()
|
||||
infra.remove_vm(instance=instance)
|
||||
# TODO: push isn't implemented yet
|
||||
return HttpResponse()
|
||||
delete_vm(instance=instance)
|
||||
return redirect(reverse("dash.instances.list"))
|
||||
|
|