Compare commits

..

5 Commits

134 changed files with 877 additions and 1879 deletions

View File

@ -3,5 +3,5 @@ export db=""
export STRIPE_SECRET_KEY="" export STRIPE_SECRET_KEY=""
export STRIPE_PUBLIC_KEY="" export STRIPE_PUBLIC_KEY=""
export HOSTEA_INFRA_HOSTEA_REPO_REMOTE="ssh://git@localhost:22/hostea/fleet.git" export HOSTEA_INFRA_HOSTEA_REPO_REMOTE="ssh://git@localhost:22/hostea/fleet.git"
export HOSTEA_META_FORGEJO_INSTANCE="http://localhost:3000" export HOSTEA_META_GITEA_INSTANCE="http://localhost:3000"
export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)" export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"

1
.gitignore vendored
View File

@ -154,4 +154,3 @@ cython_debug/
keys keys
htmlcov/ htmlcov/
tmp/ tmp/
static/

View File

@ -4,8 +4,8 @@ pipeline:
environment: environment:
- DATABSE_URL=postgres://postgres:password@database:5432/postgres - DATABSE_URL=postgres://postgres:password@database:5432/postgres
- EMAIL_URL=smtp://admin:password@smtp:10025 - EMAIL_URL=smtp://admin:password@smtp:10025
- HOSTEA_INFRA_HOSTEA_REPO_REMOTE=ssh://git@forgejo:22/hostea/ - HOSTEA_INFRA_HOSTEA_REPO_REMOTE=ssh://git@gitea:22/hostea/fleet.git
- HOSTEA_META_FORGEJO_INSTANCE=http://forgejo:3000 - HOSTEA_META_GITEA_INSTANCE=http://gitea:3000
commands: commands:
- export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)" - export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"
- pip install virtualenv - pip install virtualenv
@ -21,9 +21,9 @@ services:
environment: environment:
- POSTGRES_PASSWORD=password - POSTGRES_PASSWORD=password
forgejo: gitea:
image: codeberg.org/forgejo/forgejo:1.18.0-1 image: gitea/gitea:1.16.5
container_name: hostea-dash-forgejo container_name: hostea-dash-gitea
smtp: smtp:
image: maildev/maildev:latest image: maildev/maildev:latest

View File

@ -1,6 +1,6 @@
FROM python FROM python
LABEL org.opencontainers.image.source https://forgejo.hostea.org/Hostea/dashboard LABEL org.opencontainers.image.source https://gitea.hostea.org/Hostea/dashboard
RUN useradd -ms /bin/bash -u 1001 hostea RUN useradd -ms /bin/bash -u 1001 hostea
RUN apt-get update && apt-get install -y ca-certificates git RUN apt-get update && apt-get install -y ca-certificates git

View File

@ -9,8 +9,7 @@ endef
default: ## Run app default: ## Run app
$(call run_migrations) $(call run_migrations)
. ./venv/bin/activate && yes yes | python manage.py collectstatic . ./venv/bin/activate && python manage.py runserver 0.0.0.0:7000
. ./venv/bin/activate && python manage.py runserver
coverage: ## Generate test coverage report coverage: ## Generate test coverage report
. ./venv/bin/activate && coverage run manage.py test . ./venv/bin/activate && coverage run manage.py test
@ -27,7 +26,6 @@ env: ## Install all dependencies
@-virtualenv venv @-virtualenv venv
. ./venv/bin/activate && pip install -r requirements.txt . ./venv/bin/activate && pip install -r requirements.txt
. ./venv/bin/activate && ./integration/ci.sh init . ./venv/bin/activate && ./integration/ci.sh init
. ./venv/bin/activate && yes yes | python manage.py collectstatic
freeze: ## Freeze python dependencies freeze: ## Freeze python dependencies
@. ./venv/bin/activate && pip freeze > requirements.txt @. ./venv/bin/activate && pip freeze > requirements.txt
@ -39,7 +37,13 @@ integration-test: ## run integration tests
. ./venv/bin/activate && integration/tests.sh . ./venv/bin/activate && integration/tests.sh
lint: ## Run linter lint: ## Run linter
@./venv/bin/black dashboard accounts dash support billing infrastructure integration @./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 ./integration/
migrate: ## Run migrations migrate: ## Run migrations
$(call run_migrations) $(call run_migrations)

View File

@ -2,7 +2,7 @@
# Hostea dashboard # Hostea dashboard
[![status-badge](https://woodpecker.gna.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.gna.org/Hostea/dashboard) [![status-badge](https://woodpecker.hostea.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.hostea.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) [![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) [![Chat](https://img.shields.io/badge/matrix-hostea:matrix.batsense.net-purple?style=flat-square)](https://matrix.to/#/#hostea:matrix.batsense.net)

View File

@ -1,52 +0,0 @@
# 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,
),
),
],
),
]

View File

@ -52,36 +52,3 @@ class AccountConfirmChallenge(models.Model):
def pending_url(self): def pending_url(self):
return reverse("accounts.verify.pending", args=(self.public_ref,)) 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,))

View File

@ -1,23 +1,24 @@
{% extends "common/components/base.html" %} {% extends "common/components/base.html" %}
{% block title %}{% block title_name %} {% endblock %} | Gna! Dashboard{% endblock %} {% block title %}{% block title_name %} {% endblock %} | Hostea Dashbaord{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} {% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %} {% block main %}
<main class="auth__main"> <main class="auth__main">
<section class="main"> <section class="main">
<div class="title"> <div class="title">
<h1><a href="https://forgejo.org">Forgejo</a> hosting and <a href="/forgejo-clinic/">service</a></h1> <h1>Free Forge Ecosystem for Free Developers</h1>
<p class="welcome"> <p class="welcome">
A free forge ecosystem for free developers. Hostea is a self-hostable libre software development suite comprising Gitea, Woodpecker CI, Librepages and GitPad with payments integration.
</p> </p>
<ul class="index-banner__features-list"> <ul class="index-banner__features-list">
<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">Fully managed</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% Free Software</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">Fully Self-Hostable</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">Observable and reliable</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">Federation when available</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">Radically transparent</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> <li class="index-banner__features">Horizontal community</li>
<li class="index-banner__features">Run Hostea and become a service provider!</li>
</ul> </ul>
</div> </div>
</section> </section>

View File

@ -34,12 +34,12 @@
/> />
</label> </label>
<div class="form__action-container"> <div class="form__action-container">
<a href="{% url 'accounts.password.reset.new' %}">Forgot password?</a> <a href="/forgot-password">Forgot password?</a>
<button class="form__submit" type="submit">Login</button> <button class="form__submit" type="submit">Login</button>
</div> </div>
</form> </form>
<p class="form__alt-action"> <p class="form__alt-action">
New to Gna!? New to Hostea?
<a href="{% url 'accounts.register' %}">Create an account</a> <a href="{% url 'accounts.register' %}">Create an account</a>
</p> </p>
{% endblock %} {% endblock %}

View File

@ -1,28 +0,0 @@
{% 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 %}

View File

@ -1,20 +0,0 @@
{% 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 %}

View File

@ -1,40 +0,0 @@
{% 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 %}

View File

@ -1,5 +1,5 @@
{% extends "common/components/base.html" %} {% extends "common/components/base.html" %}
{% block title %} Confirm Access | Gna! Dashboard{% endblock %} {% block title %} Confirm Access | Hostea Dashbaord{% endblock %}
{% block nav %} {% include "dash/common/components/primary-nav.html" %} {% endblock %} {% block nav %} {% include "dash/common/components/primary-nav.html" %} {% endblock %}
{% block main %} {% block main %}

View File

@ -1,5 +1,5 @@
{% extends "common/components/base.html" %} {% extends "common/components/base.html" %}
{% block title %} Confirm Account | Gna! Dashboard{% endblock %} {% block title %} Confirm Account | Hostea Dashbaord{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} {% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %} {% block main %}
<div class="dialogue-box__container"> <div class="dialogue-box__container">

View File

@ -1,5 +1,5 @@
{% extends "common/components/base.html" %} {% extends "common/components/base.html" %}
{% block title %} Confirm Account | Gna! Dashboard{% endblock %} {% block title %} Confirm Account | Hostea Dashbaord{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} {% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %} {% block main %}
<div class="dialogue-box__container"> <div class="dialogue-box__container">

View File

@ -1,14 +0,0 @@
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

View File

@ -1,9 +0,0 @@
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

View File

@ -1,9 +0,0 @@
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

View File

@ -2,6 +2,20 @@
<div class="footer__container"> <div class="footer__container">
<div class="footer__column"> <div class="footer__column">
<span class="license__conatiner"> <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 <a
href="/" href="/"
class="footer__link" class="footer__link"
@ -9,24 +23,19 @@
rel="noopener" rel="noopener"
title="RSS" title="RSS"
>Home</a> >Home</a>
<span class="footer__column-divider--mobile-visible">|</span> <div class="footer__column-divider">|</div>
<a class="license__link" rel="noreferrer" href="https://gna.org/about" target="_blank"
>&nbsp; About</a
>
</span>
</div>
<a href="mailto:{{ footer.admin_email }}" class="footer__link" <a href="mailto:{{ footer.admin_email }}" class="footer__link"
>Contact Instance Maintainer</a >Contact Instance Maintainer</a
> >
<div class="footer__column-divider">|</div> <div class="footer__column-divider">|</div>
<a <a
class="footer__link" class="footer__link"
href="{{ footer.source_code.link }}" href="{{ footer.source_code }}"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
title="Source Code" title="Source Code"
> >
{{ footer.source_code.text }} v{{ footer.version }}-{{ footer.git_hash }}
</a> </a>
</div> </div>
</div> </div>

View File

@ -3,8 +3,11 @@
<input type="checkbox" class="nav__toggle" id="nav__toggle" /> <input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header"> <div class="nav__header">
<a class="nav__logo-container" href="/"> <a class="nav__logo-container" href="/">
<img class="nav__logo-img" src="{% static 'img/logo.png' %}" <img src="{% static 'img/android-icon-48x48.png' %}"
alt="Gna! logo"/> alt="Hostea temporary logo"/>
<p class="nav__home-btn">
ostea
</p>
</a> </a>
<label class="nav__hamburger-menu" for="nav__toggle"> <label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span> <span class="nav__hamburger-inner"></span>

View File

@ -33,7 +33,7 @@ from django.conf import settings
from oauth2_provider.models import get_application_model from oauth2_provider.models import get_application_model
from .models import AccountConfirmChallenge, PasswordResetChallenge from .models import AccountConfirmChallenge
from .management.commands.rm_unverified_users import ( from .management.commands.rm_unverified_users import (
Command as CleanUnverifiedUsersCommand, Command as CleanUnverifiedUsersCommand,
) )
@ -77,9 +77,7 @@ class LoginTest(TestCase):
Tests if login template renders Tests if login template renders
""" """
resp = self.client.get(reverse("accounts.login")) resp = self.client.get(reverse("accounts.login"))
self.assertEqual( self.assertEqual(b"Free Forge Ecosystem" in resp.content, True)
b"A free forge ecosystem for free developers" in resp.content, True
)
def test_login_works(self): def test_login_works(self):
""" """
@ -160,71 +158,6 @@ class LoginTest(TestCase):
self.assertEqual(resp.headers["location"], reverse("dash.instances.list")) 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): class RegistrationTest(TestCase):
def setUp(self): def setUp(self):
self.username = "register_user" self.username = "register_user"
@ -236,9 +169,7 @@ class RegistrationTest(TestCase):
Tests if register template renders Tests if register template renders
""" """
resp = self.client.get(reverse("accounts.register")) resp = self.client.get(reverse("accounts.register"))
self.assertEqual( self.assertEqual(b"Free Forge Ecosystem" in resp.content, True)
b"A free forge ecosystem for free developers." in resp.content, True
)
def test_register_works(self): def test_register_works(self):
""" """

View File

@ -25,9 +25,6 @@ from .views import (
resend_verification_email_view, resend_verification_email_view,
verification_pending_view, verification_pending_view,
sudo, sudo,
password_reset_send_verificaiton_link,
password_resend_verification_link_pending,
reset_password,
) )
urlpatterns = [ urlpatterns = [
@ -47,20 +44,5 @@ urlpatterns = [
), ),
path("accounts/verify/<str:challenge>/", verify_account, name="accounts.verify"), path("accounts/verify/<str:challenge>/", verify_account, name="accounts.verify"),
path("accounts/sudo/", sudo, name="accounts.sudo"), 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"), path("", protected_view, name="accounts.home"),
] ]

View File

@ -17,7 +17,6 @@ from datetime import datetime, timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.core.mail import send_mail from django.core.mail import send_mail
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from django.conf import settings from django.conf import settings
@ -30,78 +29,17 @@ def gen_secret() -> str:
return get_random_string(32) 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): def send_verification_email(request, challenge):
verification_link = ( verification_link = (
f"{request.scheme}://{request.get_host()}{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 email = challenge.owned_by.email
sender = settings.DEFAULT_FROM_EMAIL
send_mail( send_mail(
subject="[Gna!] Please confirm your email address", subject="[Hostea] Please confirm your email address",
message=body, message=f"Please confirm your email address {email}.\n {verification_link}",
from_email=f"No reply Gna!<{sender}>", from_email="No reply Hostea<no-reply@exampl.org>", # TODO read from settings.py
recipient_list=[email], recipient_list=[email],
) )

View File

@ -23,10 +23,9 @@ from django.http import HttpResponse
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.urls import reverse from django.urls import reverse
from dash.utils import footer_ctx
from .models import AccountConfirmChallenge, PasswordResetChallenge from .models import AccountConfirmChallenge
from .utils import send_verification_email, ConfirmAccess, send_password_reset_email from .utils import send_verification_email, ConfirmAccess
from .decorators import redirect_if_authenticated from .decorators import redirect_if_authenticated
@ -36,7 +35,6 @@ def login_view(request):
def default_login_ctx(): def default_login_ctx():
return { return {
"title": "Login", "title": "Login",
"footer": footer_ctx(),
} }
if request.method == "GET": if request.method == "GET":
@ -104,7 +102,6 @@ def register_view(request):
"title": "Register", "title": "Register",
"username": username, "username": username,
"email": username, "email": username,
"footer": footer_ctx(),
} }
if request.method == "GET": if request.method == "GET":
@ -216,7 +213,6 @@ def sudo(request):
def default_login_ctx(): def default_login_ctx():
return { return {
"title": "Confirm Access", "title": "Confirm Access",
"footer": footer_ctx(),
} }
if request.method == "GET": if request.method == "GET":
@ -242,107 +238,3 @@ def sudo(request):
ConfirmAccess.set(request=request) ConfirmAccess.set(request=request)
return redirect(request.POST["next"]) 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")

View File

@ -1,69 +0,0 @@
# 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")

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -67,8 +67,6 @@ class Payment(BasePayment):
date = models.DateTimeField(auto_now_add=True, blank=True) date = models.DateTimeField(auto_now_add=True, blank=True)
objects = PaymentModelManager() objects = PaymentModelManager()
vm_deleted = models.BooleanField(default=False, null=False)
def get_failure_url(self) -> str: def get_failure_url(self) -> str:
url = urlparse(settings.PAYMENT_HOST) url = urlparse(settings.PAYMENT_HOST)
return urlunparse( return urlunparse(

View File

@ -1,14 +0,0 @@
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

View File

@ -1,22 +0,0 @@
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

View File

@ -1,17 +0,0 @@
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

View File

@ -10,7 +10,7 @@
<li class="list-instance__item"><strong>Instance Name:</strong> {{payment.instance_name}}</li> <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>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>Invoice generated on:</strong> {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}</li>
<li class="list-instance__item"><strong>Total Amount</strong>: {{payment.total}} {{payment.currency|upper}}</li> <li class="list-instance__item"><strong>Total Ammount</strong>: {{payment.total}} {{payment.currency|upper}}</li>
{% if payment.status == "confirmed" %} {% if payment.status == "confirmed" %}
<li class="list-instance__item"><strong>Paid on</strong>: {{payment.date}}</li> <li class="list-instance__item"><strong>Paid on</strong>: {{payment.date}}</li>
{% endif %} {% endif %}

View File

@ -16,19 +16,17 @@ import time
from io import StringIO from io import StringIO
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import mail
from django.core.management import call_command from django.core.management import call_command
from django.urls import reverse from django.urls import reverse
from django.test import TestCase, Client, override_settings from django.test import TestCase, Client, override_settings
from django.utils.http import urlencode from django.utils.http import urlencode
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.core.management import call_command
from django.conf import settings from django.conf import settings
from payments import get_payment_model, RedirectNeeded, PaymentStatus from payments import get_payment_model, RedirectNeeded, PaymentStatus
from accounts.tests import register_util, login_util from accounts.tests import register_util, login_util
from dash.tests import create_configurations, create_instance_util, infra_custom_config from dash.tests import create_configurations, create_instance_util
from dash.models import Instance from dash.models import Instance
from .models import Payment from .models import Payment
@ -45,21 +43,55 @@ class BillingTest(TestCase):
register_util(t=self, username=self.username) register_util(t=self, username=self.username)
create_configurations(t=self) create_configurations(t=self)
@override_settings(HOSTEA=infra_custom_config(test_name="test_payments"))
def test_payments(self): def test_payments(self):
c = Client() c = Client()
login_util(self, c, "accounts.home") login_util(self, c, "accounts.home")
instance_name = "tpayments" instance_name = "test_create_instance_renders"
create_instance_util( create_instance_util(
t=self, c=c, instance_name=instance_name, config=self.instance_config[0] t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
) )
instance = Instance.objects.get(name=instance_name) 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) 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 # check if paid invoice is listed in paid invoice list view
resp = c.get(reverse("billing.invoice.paid")) resp = c.get(reverse("billing.invoice.paid"))
@ -79,7 +111,6 @@ class BillingTest(TestCase):
# try to generate an invoice for the second time on the same VM # 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 # 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) resp = c.get(payment_uri)
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
@ -91,135 +122,7 @@ class BillingTest(TestCase):
reverse("infra.create", args=(payment.instance_name,)), 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 ## payment failure page; no real functionality but user is redirected here
# by stripe if payment is successful # by stripe if payment is successful
resp = c.get(reverse("billing.invoice.fail", args=(payment.public_ref,))) resp = c.get(reverse("billing.invoice.fail", args=(payment.public_ref,)))
self.assertEqual(b"failed" in resp.content, True) 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))

View File

@ -12,90 +12,22 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 datetime import datetime, timedelta, timezone
from payments import get_payment_model, RedirectNeeded, PaymentStatus 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.contrib.auth.models import User
from django.conf import settings
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from dash.models import Instance from dash.models import Instance
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: def payment_fullfilled(instance: Instance) -> bool:
delta = __get_delta() Payment = get_payment_model()
now = datetime.now(tz=timezone.utc)
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
payment = None payment = None
for p in Payment.objects.filter( for p in Payment.objects.filter(date__gt=(delta), instance_name=instance.name):
date__gt=(delta), instance_name=instance.name, vm_deleted=False
):
if p.status == PaymentStatus.CONFIRMED: if p.status == PaymentStatus.CONFIRMED:
return True return True
return False 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

View File

@ -16,25 +16,14 @@ from datetime import datetime, timedelta, timezone
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.core.mail import send_mail
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.template.loader import render_to_string
from django.urls import reverse 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 payments import get_payment_model, RedirectNeeded, PaymentStatus
from dash.models import Instance from dash.models import Instance
from django.db.models import Q
from infrastructure.utils import create_vm_if_not_exists 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): def default_ctx(title: str, username: str):
@ -44,7 +33,6 @@ def default_ctx(title: str, username: str):
return { return {
"title": title, "title": title,
"username": username, "username": username,
"footer": footer_ctx(),
} }
@ -76,13 +64,29 @@ def paid_invoices(request):
def generate_invoice(request, instance_name: str): def generate_invoice(request, instance_name: str):
instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user) instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user)
try: Payment = get_payment_model()
payment = generate_invoice_util(instance=instance) now = datetime.now(tz=timezone.utc)
return redirect(reverse("billing.invoice.details", args=(payment.public_ref,))) delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
except GenerateInvoiceException as e:
if e.code == GenerateInvoiceErrorCode.ALREADY_PAID: 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) return HttpResponse("BAD REQUEST: Already paid", status=400)
return redirect(reverse("dash.home")) 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,
)
return redirect(reverse("billing.invoice.details", args=(payment.public_ref,)))
@login_required @login_required
@ -112,28 +116,6 @@ def payment_success(request, payment_public_id):
payment = get_object_or_404( payment = get_object_or_404(
get_payment_model(), public_ref=payment_public_id, paid_by=request.user get_payment_model(), public_ref=payment_public_id, paid_by=request.user
) )
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,))) return redirect(reverse("infra.create", args=(payment.instance_name,)))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="{% static 'css/main.css' %}" /> <link rel="stylesheet" href="{% static 'css/main.css' %}" />
<title>{{ title }}| Gna! Dashboard</title> <title>{{ title }}| Hostea Dashbaord</title>
{% include "common/components/meta.html" %} {% include "common/components/meta.html" %}
</head> </head>
<body> <body>

View File

@ -2,8 +2,12 @@
<nav class="nav__container"> <nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" /> <input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header"> <div class="nav__header">
<a class="nav__logo-container" href="https://gna.org"> <a class="nav__logo-container" href="/">
<img alt="Gna! logo" class="nav__logo-img" src="{% static 'img/logo.png' %}" /> <img
src="{% static 'img/android-icon-48x48.png' %}"
alt="Hostea temporary logo"
/>
<p class="nav__home-btn">ostea</p>
</a> </a>
<label class="nav__hamburger-menu" for="nav__toggle"> <label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span> <span class="nav__hamburger-inner"></span>
@ -14,26 +18,17 @@
<div class="nav__link-group"> <div class="nav__link-group">
<div class="nav__link-container"> <div class="nav__link-container">
<a <a class="nav__link" rel="noreferrer" href="{% url 'accounts.login' %}"
class="nav__link"
rel="noreferrer"
href="{% url 'dash.instances.new' %}"
>Add Instance</a >Add Instance</a
> >
</div> </div>
<div class="nav__link-container"> <div class="nav__link-container">
<a <a class="nav__link" rel="noreferrer" href="{% url 'support.home' %}"
class="nav__link"
rel="noreferrer"
href="{% url 'support.home' %}"
>Support</a >Support</a
> >
</div> </div>
<div class="nav__link-container"> <div class="nav__link-container">
<a <a class="nav__link" rel="noreferrer" href="{% url 'accounts.logout' %}"
class="nav__link"
rel="noreferrer"
href="{% url 'accounts.logout' %}"
>Logout</a >Logout</a
> >
</div> </div>

View File

@ -45,10 +45,8 @@
</details> </details>
</div> </div>
<!--
<div class="secondary-nav__options"> <div class="secondary-nav__options">
<a href="/foo" class="secondary-nav__option-link">Manage Account</a> <a href="/foo" class="secondary-nav__option-link">Manage Account</a>
</div> </div>
-->
</nav> </nav>
</aside> </aside>

View File

@ -12,7 +12,6 @@
</ul> </ul>
<p>Created On: {{ instance.created_at }}</p> <p>Created On: {{ instance.created_at }}</p>
<p><a href="{{forgejo_uri}}">Forgejo Instance</a>|<a href="{{woodpecker}}">Woodpecker CI</a></p>
<form <form
action="{% url 'dash.instances.delete' name=instance.name %}" action="{% url 'dash.instances.delete' name=instance.name %}"

View File

@ -12,25 +12,16 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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.contrib.auth import get_user_model
from django.utils.http import urlencode from django.utils.http import urlencode
from django.urls import reverse from django.urls import reverse
from django.test import TestCase, Client, override_settings from django.test import TestCase, Client, override_settings
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from payments import get_payment_model, RedirectNeeded, PaymentStatus
from accounts.tests import login_util, register_util from accounts.tests import login_util, register_util
from .models import InstanceConfiguration, Instance from .models import InstanceConfiguration, Instance
from .utils import create_instance, sanitize_vm_name, VmErrors, VmException
def create_configurations(t: TestCase): def create_configurations(t: TestCase):
@ -53,28 +44,6 @@ 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( def create_instance_util(
t: TestCase, c: Client, instance_name: str, config: InstanceConfiguration t: TestCase, c: Client, instance_name: str, config: InstanceConfiguration
): ):
@ -86,22 +55,6 @@ def create_instance_util(
resp.headers["location"], resp.headers["location"],
reverse("billing.invoice.generate", args=(instance_name,)), 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): class DashHome(TestCase):
@ -171,7 +124,7 @@ class InstancesConfig(TestCase):
""" """
Expects InstancesConfig titled "s1-2", "s1-4" and "s1-8" Expects InstancesConfig titled "s1-2", "s1-4" and "s1-8"
ref: https://forgejo.gna.org/Hostea/july-mvp/issues/10#issuecomment-639 ref: https://gitea.hostea.org/Hostea/july-mvp/issues/10#issuecomment-639
""" """
self.assertEqual( self.assertEqual(
InstanceConfiguration.objects.filter( InstanceConfiguration.objects.filter(
@ -198,33 +151,6 @@ class CreateInstance(TestCase):
register_util(t=self, username="createinstance_user") register_util(t=self, username="createinstance_user")
create_configurations(t=self) 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): def test_create_instance_renders(self):
c = Client() c = Client()
login_util(self, c, "accounts.home") login_util(self, c, "accounts.home")
@ -239,14 +165,14 @@ class CreateInstance(TestCase):
self.assertEqual(str.encode(test) in resp.content, True) self.assertEqual(str.encode(test) in resp.content, True)
# create instance # create instance
instance_name = "testirenrs"
payload = { payload = {
"name": instance_name, "name": "test_create_instance_renders",
"configuration": self.instance_config[0].name, "configuration": self.instance_config[0].name,
} }
self.assertEqual(Instance.objects.filter(name=payload["name"]).exists(), False) self.assertEqual(Instance.objects.filter(name=payload["name"]).exists(), False)
instance_name = "test_create_instance_renders"
create_instance_util( create_instance_util(
t=self, c=c, instance_name=instance_name, config=self.instance_config[0] t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
) )
@ -310,16 +236,12 @@ class CreateInstance(TestCase):
resp = c.post(delete_uri) resp = c.post(delete_uri)
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)
self.assertEqual( self.assertEqual(resp.headers["location"], reverse("dash.home"))
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( self.assertEqual(
Instance.objects.filter( Instance.objects.filter(
name=instance_name, name=instance.name,
owned_by=self.user, owned_by=self.user,
configuration_id=self.instance_config[0],
).exists(), ).exists(),
False, False,
) )

View File

@ -15,7 +15,6 @@
from enum import Enum, unique from enum import Enum, unique
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings
from .models import Instance, InstanceConfiguration from .models import Instance, InstanceConfiguration
@ -23,8 +22,6 @@ from .models import Instance, InstanceConfiguration
@unique @unique
class VmErrors(Enum): class VmErrors(Enum):
NAME_EXISTS = "Instance name exists, please try again with a different name" 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." NO_CONFIG = "Configuration doesn't exist, please try again."
def __str__(self) -> str: def __str__(self) -> str:
@ -43,30 +40,13 @@ class VmException(Exception):
return self.error 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: def create_instance(vm_name: str, configuration_name: str, user: User) -> Instance:
""" """
Create instance view Create instance view
""" """
vm_name = sanitize_vm_name(vm_name) if Instance.objects.filter(name=vm_name).exists():
raise VmException(code=VmErrors.NAME_EXISTS)
if not InstanceConfiguration.objects.filter(name=configuration_name).exists(): if not InstanceConfiguration.objects.filter(name=configuration_name).exists():
raise VmException(code=VmErrors.NO_CONFIG) raise VmException(code=VmErrors.NO_CONFIG)
@ -75,30 +55,3 @@ def create_instance(vm_name: str, configuration_name: str, user: User) -> Instan
instance = Instance(name=vm_name, configuration_id=configuration, owned_by=user) instance = Instance(name=vm_name, configuration_id=configuration, owned_by=user)
instance.save() instance.save()
return instance 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"],
}

View File

@ -22,15 +22,9 @@ from django.views.decorators.csrf import csrf_protect
from django.urls import reverse from django.urls import reverse
from accounts.decorators import confirm_access from accounts.decorators import confirm_access
from infrastructure.utils import Infra
from .models import Instance, InstanceConfiguration from .models import Instance, InstanceConfiguration
from .utils import ( from .utils import create_instance as create_instance_util, VmErrors, VmException
create_instance as create_instance_util,
VmErrors,
VmException,
footer_ctx,
)
def default_ctx(title: str, username: str): def default_ctx(title: str, username: str):
@ -41,7 +35,6 @@ def default_ctx(title: str, username: str):
"title": title, "title": title,
"username": username, "username": username,
"open_instances": "open", "open_instances": "open",
"footer": footer_ctx(),
} }
@ -89,7 +82,10 @@ def create_instance(request):
return redirect(reverse("billing.invoice.generate", args=(instance.name,))) return redirect(reverse("billing.invoice.generate", args=(instance.name,)))
except VmException as e: except VmException as e:
ctx = get_ctx() ctx = get_ctx()
reason = e.code.value if e.code == VmErrors.NAME_EXISTS:
reason = ("Instance name exists, please try again with a different name",)
elif e.code == VmErrors.NO_CONFIG:
reason = "Configuration doesn't exist, please try again."
ctx["error"] = { ctx["error"] = {
"title": "Can't create instance", "title": "Can't create instance",
@ -117,12 +113,7 @@ def view_instance(request, name: str):
instance = get_object_or_404(Instance, owned_by=user, name=name) instance = get_object_or_404(Instance, owned_by=user, name=name)
ctx = default_ctx(title=PAGE_TITLE, username=user.username) ctx = default_ctx(title=PAGE_TITLE, username=user.username)
instance.configuration = instance.configuration_id instance.configuration = instance.configuration_id
forgejo_uri = Infra.get_forgejo_uri(instance=instance)
woodpecker = Infra.get_woodpecker_uri(instance=instance)
ctx["instance"] = instance ctx["instance"] = instance
ctx["woodpecker"] = woodpecker
ctx["forgejo_uri"] = forgejo_uri
return render(request, "dash/instances/view/index.html", context=ctx) return render(request, "dash/instances/view/index.html", context=ctx)
@ -139,4 +130,5 @@ def delete_instance(request, name):
ctx["instance"] = instance ctx["instance"] = instance
return render(request, "dash/instances/delete/index.html", context=ctx) return render(request, "dash/instances/delete/index.html", context=ctx)
return redirect(reverse("infra.rm", args=(instance.name,))) instance.delete()
return redirect(reverse("dash.home"))

View File

@ -44,8 +44,8 @@ PAYMENT_VARIANTS = {
"stripe": ( "stripe": (
"payments.stripe.StripeProvider", # please don't change this "payments.stripe.StripeProvider", # please don't change this
{ {
"secret_key": env.get_value("STRIPE_SECRET_KEY", default="UNSET"), "secret_key": env.get_value("STRIPE_SECRET_KEY"),
"public_key": env.get_value("STRIPE_PUBLIC_KEY", default="UNSET"), "public_key": env.get_value("STRIPE_PUBLIC_KEY"),
}, },
) )
} }
@ -54,20 +54,23 @@ PAYMENT_VARIANTS = {
### Dashbaord specific configuration options ### Dashbaord specific configuration options
HOSTEA = { HOSTEA = {
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard", "INSTANCE_MAINTAINER_CONTACT": "contact@hostea.example.org",
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
"ACCOUNTS": { "ACCOUNTS": {
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds "MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
"SUDO_TTL": 60 * 5, "SUDO_TTL": 60 * 5,
}, },
"META": { "META": {
"FORGEJO_INSTANCE": env.get_value( "GITEA_INSTANCE": env.get_value(
"HOSTEA_META_FORGEJO_INSTANCE" "HOSTEA_META_GITEA_INSTANCE"
), # meta Forgejo insatnce ), # meta Gitea insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17 # ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
"WOODPECKER": {
"HOST": "https://woodpecker.hostea.org", # meta Woodpecker CI instance
"TOKEN": "woodpecker_ci_token", # WOodpecker authentication token
},
}, },
"INFRA": { "INFRA": {
"HOSTEA_REPO": { "HOSTEA_REPO": {
@ -77,15 +80,13 @@ HOSTEA = {
"REMOTE": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_REMOTE"), "REMOTE": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_REMOTE"),
# SSH key that can push to the Git repository remote mentioned above # SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY"), "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: # Please see EMAIL_* configuration options:
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host # https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025") EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025")
DEFAULT_FROM_EMAIL = "no-reply@gna.org"
vars().update(EMAIL_CONFIG) vars().update(EMAIL_CONFIG)

View File

@ -53,17 +53,16 @@ PAYMENT_VARIANTS = {
### Dashbaord specific configuration options ### Dashbaord specific configuration options
HOSTEA = { HOSTEA = {
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard", "INSTANCE_MAINTAINER_CONTACT": "contact@hostea.example.org",
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
"ACCOUNTS": { "ACCOUNTS": {
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds "MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
"SUDO_TTL": 60 * 5, "SUDO_TTL": 60 * 5,
}, },
"META": { "META": {
"FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce "GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17 # ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
}, },
"INFRA": { "INFRA": {
@ -74,8 +73,7 @@ HOSTEA = {
"REMOTE": "git@localhost:Hostea/enough.git", "REMOTE": "git@localhost:Hostea/enough.git",
# SSH key that can push to the Git repository remote mentioned above # SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": "/srv/hostea/deploy", "SSH_KEY": "/srv/hostea/deploy",
}, }
"HOSTEA_DOMAIN": "",
}, },
} }
@ -87,4 +85,3 @@ EMAIL_USE_SSL = False
EMAIL_PORT = 10025 EMAIL_PORT = 10025
EMAIL_HOST_USER = "admin" EMAIL_HOST_USER = "admin"
EMAIL_HOST_PASSWORD = "password" EMAIL_HOST_PASSWORD = "password"
DEFAULT_FROM_EMAIL = "no-reply@gna.org"

View File

@ -24,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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"whitenoise.runserver_nostatic",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@ -48,7 +48,6 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@ -130,12 +129,10 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.0/howto/static-files/ # https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static"
STATICFILES_DIRS = [
BASE_DIR / "common-static",
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_DIRS = [
BASE_DIR / "static",
]
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
@ -171,19 +168,22 @@ PAYMENT_VARIANTS = {
### Dashbaord specific configuration options ### Dashbaord specific configuration options
HOSTEA = { HOSTEA = {
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
"RESTRICT_NEW_INTEGRATION_INSTALLATION": True, "RESTRICT_NEW_INTEGRATION_INSTALLATION": True,
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org", "INSTANCE_MAINTAINER_CONTACT": "contact@hostea.example.org",
"ACCOUNTS": { "ACCOUNTS": {
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds "MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
"SUDO_TTL": 60 * 5, "SUDO_TTL": 60 * 5,
}, },
"META": { "META": {
"FORGEJO_INSTANCE": "http://localhost:3000", # meta Forgejo insatnce "GITEA_INSTANCE": "http://localhost:3000", # meta Gitea insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17 # ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
"WOODPECKER": {
"HOST": "https://woodpecker.hostea.org", # meta Woodpecker CI instance
"TOKEN": "woodpecker_ci_token", # WOodpecker authentication token
},
}, },
"INFRA": { "INFRA": {
"HOSTEA_REPO": { "HOSTEA_REPO": {
@ -193,8 +193,7 @@ HOSTEA = {
"REMOTE": "git@localhost:Hostea/enough.git", "REMOTE": "git@localhost:Hostea/enough.git",
# SSH key that can push to the Git repository remote mentioned above # SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": "/srv/hostea/deploy", "SSH_KEY": "/srv/hostea/deploy",
}, }
"HOSTEA_DOMAIN": "vm.gna.org", # domain at which Hostea VMs will be spun up
}, },
} }
@ -206,7 +205,6 @@ EMAIL_USE_SSL = False
EMAIL_PORT = 10025 EMAIL_PORT = 10025
EMAIL_HOST_USER = "admin" EMAIL_HOST_USER = "admin"
EMAIL_HOST_PASSWORD = "password" EMAIL_HOST_PASSWORD = "password"
DEFAULT_FROM_EMAIL: "no-reply@gna.org"
try: try:
from dashboard.local_settings import * from dashboard.local_settings import *

View File

@ -1,23 +1,23 @@
version: "3" version: "3"
#networks: #networks:
# hostea-dash-forgejo: # hostea-dash-gitea:
# external: false # external: false
# hostea-dash-smtp: # hostea-dash-smtp:
# external: false # external: false
services: services:
forgejo: gitea:
image: codeberg.org/forgejo/forgejo:1.18.0-1 image: gitea/gitea:1.16.5
container_name: hostea-dash-forgejo container_name: hostea-dash-gitea
network_mode: host network_mode: host
environment: environment:
- USER_UID=1000 - USER_UID=1000
- USER_GID=1000 - USER_GID=1000
restart: always restart: always
#networks: #networks:
# - hostea-dash-forgejo # - hostea-dash-gitea
volumes: volumes:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
@ -39,3 +39,6 @@ services:
#ports: #ports:
# - "10025:10025" # - "10025:10025"
# - "1080:1080" # - "1080:1080"
volumes:
woodpecker-server-data:

View File

@ -15,7 +15,7 @@
1. Clone the project 1. Clone the project
```bash ```bash
git clone https://forgejo.gna.org/Hostea/dashboard.git && cd dashboard git clone https://gitea.hostea.org/Hostea/dashboard.git && cd dashboard
``` ```
2. Create `virtualenv` and activate environment 2. Create `virtualenv` and activate environment

View File

@ -9,18 +9,6 @@ configuration file to override [dashboard/settings.py](../dashboard/settings.py)
Please copy local_settings.example.py to local_settings.py and make Please copy local_settings.example.py to local_settings.py and make
changes to the newly copied file. 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 ## Cron jobs
Run cron job at an interval of your choosing with the following comamnd: Run cron job at an interval of your choosing with the following comamnd:
@ -44,8 +32,8 @@ hence the current redundancy in configuration and cronjob duration.
## Support Platform Integration ## Support Platform Integration
Hostea Dashbaord delegates support to Hostea's meta Forgejo instance, as Hostea Dashbaord delegates support to Hostea's meta Gitea instance, as
discussed [here](https://forgejo.gna.org/Hostea/july-mvp/issues/17). discussed [here](https://gitea.hostea.org/Hostea/july-mvp/issues/17).
To configure support platform integration , please set the following To configure support platform integration , please set the following
attributes in `settings.py`: attributes in `settings.py`:
@ -55,10 +43,10 @@ HOSTEA = {
# <--snip---> # <--snip--->
"META": { "META": {
# <--snip---> # <--snip--->
"FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce "GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17 # ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
}, },
} }

View File

@ -26,15 +26,3 @@ python manage.py vm delete <VM-name>
This command is not idempotent. The command throws an error when a This command is not idempotent. The command throws an error when a
a VM with the given name doesn't exist. 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.

75
gitea/app.ini Normal file
View File

@ -0,0 +1,75 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_USER = atm
RUN_MODE = prod
[database]
DB_TYPE = sqlite3
HOST = 127.0.0.1:3306
NAME = gitea
USER = gitea
PASSWD =
SCHEMA =
SSL_MODE = disable
CHARSET = utf8
PATH = ./tmp/gitea/db/gitea.db
LOG_SQL = false
[repository]
ROOT = ./tmp/gitea/repos/
[server]
SSH_DOMAIN = localhost
DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL = http://localhost:3000/
DISABLE_SSH = false
SSH_PORT = 2222
LFS_START_SERVER = true
LFS_JWT_SECRET = MilbUZw4BbeFsnOWBDGzYrgBINrkJIcoOPivE9IPNAQ
OFFLINE_MODE = false
[lfs]
PATH = ./tmp/gitea/lfs/
[mailer]
ENABLED = false
[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[picture]
DISABLE_GRAVATAR = false
ENABLE_FEDERATED_AVATAR = true
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[session]
PROVIDER = file
[log]
MODE = console
LEVEL = debug
ROOT_PATH = ./tmp/gitea/log
ROUTER = console
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2NTQ2MDY2ODV9.WbIw4n8M_MXy594pqgmEMD3NUtTpL8hcUC_uhoSc5ec
PASSWORD_HASH_ALGO = pbkdf2

View File

@ -93,9 +93,9 @@ class Command(BaseCommand):
instance = create_instance( instance = create_instance(
vm_name=vm_name, configuration_name=size, user=user vm_name=vm_name, configuration_name=size, user=user
) )
(forgejo_password, _commit) = create_vm_if_not_exists(instance) (gitea_password, _commit) = create_vm_if_not_exists(instance)
print("Instance created") print("Instance created")
print(f"Forgejo admin password: {forgejo_password}") print(f"Gitea admin password: {gitea_password}")
except VmException as e: except VmException as e:
if e.code == VmErrors.NAME_EXISTS: if e.code == VmErrors.NAME_EXISTS:
instance = Instance.objects.get(name=vm_name) instance = Instance.objects.get(name=vm_name)
@ -104,9 +104,9 @@ class Command(BaseCommand):
name=size name=size
) )
instance.save() instance.save()
(forgejo_password, _commit) = create_vm_if_not_exists(instance) (gitea_password, _commit) = create_vm_if_not_exists(instance)
print("Instance created") print("Instance created")
print(f"Forgejo admin password: {forgejo_password}") print(f"Gitea admin password: {gitea_password}")
else: else:
self.stderr.write(self.style.ERROR(f"error: {str(e)}")) self.stderr.write(self.style.ERROR(f"error: {str(e)}"))
@ -119,7 +119,7 @@ class Command(BaseCommand):
vm_name = options[self.vm_name_key] vm_name = options[self.vm_name_key]
if Instance.objects.filter(name=vm_name).exists(): if Instance.objects.filter(name=vm_name).exists():
instance = Instance.objects.get(name=vm_name) instance = Instance.objects.get(name=vm_name)
delete_vm(instance=instance) delete_vm(instance=instance, owner=instance.owned_by.username)
def handle(self, *args, **options): def handle(self, *args, **options):
for i in [self.action_key, self.vm_name_key]: for i in [self.action_key, self.vm_name_key]:

View File

@ -1,36 +0,0 @@
# 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"
),
),
],
),
]

View File

@ -12,30 +12,11 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum, unique
from django.db import models from django.db import models
from dash.models import Instance from dash.models import Instance
@unique
class JobType(Enum):
PING = "ping"
def __str__(self):
return self.name
class InstanceCreated(models.Model): class InstanceCreated(models.Model):
instance = models.ForeignKey(Instance, on_delete=models.PROTECT) instance = models.ForeignKey(Instance, on_delete=models.PROTECT)
created = models.BooleanField(default=False) 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,
)

View File

@ -1,18 +0,0 @@
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

View File

@ -1,11 +0,0 @@
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

View File

@ -1,10 +0,0 @@
Hello {{ username }}!,
A customer has purchased a new instance. Please find the details below:
Forgejo: {{ forgejo_uri }}
Woodpecker CI: {{ woodpecker_uri }}
Cheers,
Gna! team

View File

@ -1,11 +1,11 @@
{% extends 'dash/common/base.html' %} {% block dash %} {% extends 'dash/common/base.html' %} {% block dash %}
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<h2>Forgejo Admin Credentials</h2> <h2>Gitea Admin Credentials</h2>
<ul> <ul>
<li><b>Username:</b> root</li> <li><b>Username:</b> root</li>
<li><b>Password:</b> {{ forgejo_password }}</li> <li><b>Password:</b> {{ gitea_password }}</li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -1,2 +1,2 @@
enough --domain $domain host create {{subdomain}}-host enough --domain $domain host create {{subdomain}}-host
enough --domain $domain service create --host {{subdomain}}-host forgejo enough --domain $domain service create --host {{subdomain}}-host gitea

View File

@ -2,49 +2,49 @@
# #
####################################### #######################################
# #
# Public hostname of the Forgejo instance # Public hostname of the Gitea instance
# #
# #
forgejo_host: "{{ subdomain }}.{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}" gitea_host: "{{ subdomain }}.{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
# #
####################################### #######################################
# #
# Mailer from # Mailer from
# #
# #
forgejo_mailer_from: "noreply@{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}" gitea_mailer_from: "noreply@{{ '{' }}{{ '{' }} domain {{ '}' }}{{ '}' }}"
# #
####################################### #######################################
# #
# SSH port of the Forgejo instance # SSH port of the Gitea instance
# #
# #
forgejo_ssh_port: "22" gitea_ssh_port: "22"
# #
####################################### #######################################
# #
# Forgejo version # Gitea version
# #
# #
#forgejo_version: "1.18.0-1" gitea_version: "1.16.8"
# #
####################################### #######################################
# #
# Admin user name # Admin user name
# #
forgejo_user: root gitea_user: root
# #
####################################### #######################################
# #
# Admin user password # Admin user password
# #
forgejo_password: "{{ forgejo_password }}" gitea_password: "{{ gitea_password }}"
# #
####################################### #######################################
# #
# Admin user email # Admin user email
# #
forgejo_email: "{{ forgejo_email }}" gitea_email: "{{ gitea_email }}"
# #
####################################### #######################################
# #
@ -60,9 +60,9 @@ woodpecker_host: "{{ '{' }}{{ '{' }} woodpecker_hostname {{ '}' }}{{ '}' }}.{{ '
# #
####################################### #######################################
# #
# Forgejo users with admin rights on woodpecker # Gitea users with admin rights on woodpecker
# #
woodpecker_admins: "{{ '{' }}{{ '{' }} forgejo_user {{ '}' }}{{ '}' }}" woodpecker_admins: "{{ '{' }}{{ '{' }} gitea_user {{ '}' }}{{ '}' }}"
# #
####################################### #######################################
# #

View File

@ -1,4 +1,4 @@
forgejo-service-group: gitea-service-group:
hosts: hosts:
{{ subdomain }}-host: {{ subdomain }}-host:
ansible_port: 2222 ansible_port: 2222

View File

@ -12,22 +12,36 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import shutil
import time import time
import os
import requests
from io import StringIO from io import StringIO
from urllib.parse import urlparse, urlunparse
from pathlib import Path
from django.test import TestCase, Client, override_settings from django.test import TestCase, Client, override_settings
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core import mail
from git import Repo
from dash.models import Instance, InstanceConfiguration from dash.models import Instance, InstanceConfiguration
from accounts.tests import register_util, login_util from accounts.tests import register_util, login_util
from dash.tests import create_configurations, create_instance_util, infra_custom_config from dash.tests import create_configurations, create_instance_util
from infrastructure.management.commands.vm import translate_sizes from infrastructure.management.commands.vm import translate_sizes
from billing.utils import payment_fullfilled from .utils import Infra
from .models import InstanceCreated
from .utils import Infra, Worker, create_vm_if_not_exists, delete_vm
from .models import InstanceCreated, Job, JobType def custom_config(test_name: str):
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)
return c
class InfraUtilTest(TestCase): class InfraUtilTest(TestCase):
@ -38,209 +52,138 @@ class InfraUtilTest(TestCase):
def setUp(self): def setUp(self):
self.username = "infrautil_user" self.username = "infrautil_user"
register_util(t=self, username=self.username) register_util(t=self, username=self.username)
self.user.is_staff = True
self.user.save()
create_configurations(t=self) create_configurations(t=self)
# @override_settings(HOSTEA=infra_custom_config(test_name="test_path_util")) @override_settings(HOSTEA=custom_config(test_name="test_path_util"))
# def test_path_utils(self): 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.
ref: https://forgejo.hostea.org/Hostea/dashboard/issues/38#issuecomment-1162
"""
c = Client()
login_util(self, c, "accounts.home")
instance_name = "trmpayments"
infra = Infra() 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/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"hosts-scripts/{subdomain}-host.sh"),
infra._hostscript_path(subdomain=subdomain),
)
@override_settings(HOSTEA=custom_config(test_name="test_add_vm"))
def test_add_vm(self):
infra = Infra()
c = Client()
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
login_util(self, c, "accounts.home")
subdomain = "add_vm"
base = infra.repo_path
create_instance_util( create_instance_util(
t=self, c=c, instance_name=instance_name, config=self.instance_config[0] t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
) )
instance = Instance.objects.get(name=instance_name) before_add = infra.repo.head.commit.hexsha
self.assertEqual(payment_fullfilled(instance=instance), True) instance = Instance.objects.get(name=subdomain)
create_vm_if_not_exists(instance=instance) woodpecker_agent_secret = infra.add_vm(instance=instance)
after_add = infra.repo.head.commit.hexsha
self.assertEqual(before_add is not after_add, True)
# delete VM c = custom_config(test_name="test_add_vm--get-head")
delete_vm(instance=instance) path = c["INFRA"]["HOSTEA_REPO"]["PATH"]
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), False) repo = Repo.clone_from(conf["REMOTE"], path, env=infra.env)
self.assertEqual(repo.head.commit.hexsha == after_add, True)
# re-create VM with management command as it bypasses payments. We before_rm = infra.repo.head.commit.hexsha
# usually use create_instance_util but it will pay for the instance too infra.remove_vm(instance=instance)
call_command( after_rm = infra.repo.head.commit.hexsha
"vm", "create", instance_name, f"--owner={self.username}", "--flavor=medium" self.assertEqual(before_add is not after_add, True)
)
# verify VM is created repo.git.pull()
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), True) self.assertEqual(repo.head.commit.hexsha == after_rm, True)
# verify payment is unfulfilled
instance = Instance.objects.get(name=instance_name) @override_settings(HOSTEA=custom_config(test_name="test_cmd"))
self.assertEqual(payment_fullfilled(instance=instance), False) def test_cmd(self):
subdomain = "cmd_vm"
infra = Infra()
c = Client()
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
login_util(self, c, "accounts.home")
base = infra.repo_path
# generate invoice
stdout = StringIO() stdout = StringIO()
stderr = StringIO() stderr = StringIO()
self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
# username exists
call_command( call_command(
"generate_invoice", "vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
stdout=stdout,
stderr=stderr,
) )
out = stdout.getvalue() 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 instance = Instance.objects.get(name=subdomain)
for m in mail.outbox:
if "New instance alert" in m.subject: self.assertEqual(infra.get_flavor(instance=instance), "openstack_flavor_medium")
staff_notification = m
break self.assertEqual(instance.owned_by, self.user)
self.assertEqual(staff_notification.to[0], self.email)
self.assertEqual( self.assertEqual(
"[Gna!] New instance alert" in staff_notification.subject, True 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
old_size = instance.configuration_id
call_command(
"vm", "create", subdomain, f"--owner={self.username}", "--flavor=large"
)
instance.refresh_from_db()
# verify new size is updated in DB
self.assertEqual( self.assertEqual(
"A customer has purchased a new instance. Please find the details below:" str.strip(instance.configuration_id.name)
in staff_notification.body, == str.strip(translate_sizes("large")),
True, 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)
out = stdout.getvalue()
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)

View File

@ -12,132 +12,42 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import os import os
import sh
import shutil import shutil
import yaml import yaml
import requests
from pathlib import Path from pathlib import Path
from threading import Thread
from time import sleep
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.template.loader import render_to_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 django.conf import settings
from payments import get_payment_model from git import Repo, Commit
from git.exc import InvalidGitRepositoryError
from dash.models import Instance from dash.models import Instance
from infrastructure.models import InstanceCreated, JobType, Job from .models import InstanceCreated
logging.basicConfig()
logger = logging.getLogger(__name__)
class Worker(Thread): def create_vm_if_not_exists(instance: Instance) -> (str, Commit):
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 Create VM utility. Gitea 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() infra = Infra()
if not InstanceCreated.objects.filter(instance=instance).exists(): if not InstanceCreated.objects.filter(instance=instance).exists():
(forgejo_password, commit) = infra.add_vm(instance=instance) (gitea_password, commit) = infra.add_vm(instance=instance)
InstanceCreated.objects.create(instance=instance, created=True) instance = InstanceCreated.objects.create(instance=instance, created=True)
notify_staff(instance=instance) instance.save()
job = Job.objects.create(instance=instance, job_type=str(JobType.PING)) return (gitea_password, commit)
Worker(job=job).start()
return (forgejo_password, commit)
else: else:
if str.strip(infra.get_flavor(instance=instance)) != str.strip( if str.strip(infra.get_flavor(instance=instance)) != str.strip(
infra.translate_size(instance=instance) infra.translate_size(instance=instance)
): ):
# Worker.init_global()
notify_staff(instance=instance)
return infra.add_vm(instance=instance) return infra.add_vm(instance=instance)
return None return None
def delete_vm(instance: Instance): def delete_vm(instance: Instance, owner: str):
infra = Infra() 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) infra.remove_vm(instance=instance)
if InstanceCreated.objects.filter(instance=instance).exists(): if InstanceCreated.objects.filter(instance=instance).exists():
InstanceCreated.objects.get(instance=instance).delete() InstanceCreated.objects.get(instance=instance).delete()
@ -152,17 +62,15 @@ class Infra:
def __init__(self): def __init__(self):
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"] conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
self.repo_path = Path(conf["PATH"]) self.repo_path = Path(conf["PATH"])
self._clone() if not self.repo_path.exists():
os.makedirs(self.repo_path)
def _clone(self):
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
ssh_cmd = f"/usr/bin/ssh -oStrictHostKeyChecking=no -i {conf['SSH_KEY']}" ssh_cmd = f"/usr/bin/ssh -oStrictHostKeyChecking=no -i {conf['SSH_KEY']}"
self.git = sh.git.bake(_env={"GIT_SSH_COMMAND": ssh_cmd}) self.env = {"GIT_SSH_COMMAND": ssh_cmd}
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"] try:
if os.path.exists(self.repo_path): self.repo = Repo(path=self.repo_path)
shutil.rmtree(self.repo_path) except InvalidGitRepositoryError:
self.git.clone(conf["REMOTE"], self.repo_path) self.repo = Repo.clone_from(conf["REMOTE"], self.repo_path, env=self.env)
self.git = self.git.bake("-C", self.repo_path)
def _host_vars_dir(self, subdomain: str) -> Path: def _host_vars_dir(self, subdomain: str) -> Path:
""" """
@ -176,47 +84,20 @@ class Infra:
""" """
return self._host_vars_dir(subdomain=subdomain).joinpath("provision.yml") return self._host_vars_dir(subdomain=subdomain).joinpath("provision.yml")
@classmethod
def get_forgejo_uri(cls, instance: Instance) -> str:
"""
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): def get_flavor(self, instance: Instance):
"""
Get VM flavour/size/configuration from the fleet repository
"""
subdomain = instance.name subdomain = instance.name
provision = self._provision_path(subdomain) provision = self._provision_path(subdomain)
with open(provision, "r", encoding="utf-8") as f: with open(provision, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
if "openstack_flavor" in config: if "openstack_flavor" in config:
return config["openstack_flavor"].split("{{ ")[1].split(" }}")[0] return config["openstack_flavor"].split("{{ ")[1].split(" }}")[0]
return None
def _forgejo_path(self, subdomain: str) -> Path: def _gitea_path(self, subdomain: str) -> Path:
""" """
utility method: get forgejo file for a subdomain utility method: get gitea file for a subdomain
""" """
return self._host_vars_dir(subdomain=subdomain).joinpath("forgejo.yml") return self._host_vars_dir(subdomain=subdomain).joinpath("gitea.yml")
def _backup_path(self, subdomain: str) -> Path: def _backup_path(self, subdomain: str) -> Path:
""" """
@ -250,42 +131,46 @@ class Infra:
f.write(content) f.write(content)
f.write("\n") f.write("\n")
def _push(self, message): def _add_files(self, subdomain: str):
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 _sha(self):
sha = self.git("rev-parse", "origin/master")
return str(sha).strip()
@staticmethod
def translate_size(instance: Instance) -> str:
""" """
Translate openstack(I think OVH-specific) sizes to enough.community Add all relevant files of a VM
normalized sizes
""" """
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._service_path(subdomain=subdomain)))
self.repo.git.add(str(self._hostscript_path(subdomain=subdomain)))
def _commit(self, action: str, subdomain: str) -> Commit:
"""
Commit changes to a VM configuration
"""
self._add_files(subdomain=subdomain)
return self.repo.git.commit(
message=f"{action} VM {subdomain}",
author="Dashboard Bot <bot@dashboard.hostea.org>",
)
def _pull(self):
self.repo.git.pull(env=self.env, rebase="true")
def translate_size(self, instance: Instance) -> str:
if instance.configuration_id.name == "s1-2": if instance.configuration_id.name == "s1-2":
return "openstack_flavor_small" return "openstack_flavor_small"
if instance.configuration_id.name == "s1-4": elif instance.configuration_id.name == "s1-4":
return "openstack_flavor_medium" return "openstack_flavor_medium"
if instance.configuration_id.name == "s1-8": elif instance.configuration_id.name == "s1-8":
return "openstack_flavor_large" return "openstack_flavor_large"
else:
return instance.configuration_id.name return instance.configuration_id.name
def add_vm(self, instance: Instance) -> (str, str): def add_vm(self, instance: Instance) -> (str, Commit):
""" """
Add new VM to infrastructure repository Add new VM to infrastructure repository
The forgejo user password is returned The gitea user password is returned
""" """
self._pull()
subdomain = instance.name subdomain = instance.name
host_vars_dir = self._host_vars_dir(subdomain) host_vars_dir = self._host_vars_dir(subdomain)
@ -298,28 +183,34 @@ class Infra:
os.makedirs(hostscript_path) os.makedirs(hostscript_path)
woodpecker_agent_secret = get_random_string(64) woodpecker_agent_secret = get_random_string(64)
forgejo_password = get_random_string(20) gitea_password = get_random_string(20)
ctx = { ctx = {
"woodpecker_agent_secret": woodpecker_agent_secret, "woodpecker_agent_secret": woodpecker_agent_secret,
"woodpecker_hostname": self._gen_woodpecker_hostname(instance=instance), "woodpecker_hostname": f"{subdomain}-ci",
"woodpecker_admins": f"{instance.owned_by.username}", "woodpecker_admins": f"{instance.owned_by.username}",
"forgejo_email": instance.owned_by.email, "gitea_email": instance.owned_by.email,
"forgejo_password": forgejo_password, "gitea_password": gitea_password,
"subdomain": subdomain, "subdomain": subdomain,
} }
forgejo = self._forgejo_path(subdomain) gitea = self._gitea_path(subdomain)
with open(forgejo, "w+", encoding="utf-8") as f: with open(gitea, "w+", encoding="utf-8") as f:
f.write( f.write(
render_to_string( render_to_string(
"infrastructure/yml/forgejo.yml", "infrastructure/yml/gitea.yml",
context=ctx, context=ctx,
) )
) )
# provision_template = "./templates/infrastructure/yml/provision.yml"
size = self.translate_size(instance=instance) size = self.translate_size(instance=instance)
provision = self._provision_path(subdomain) 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
with open(provision, "w+", encoding="utf-8") as f: with open(provision, "w+", encoding="utf-8") as f:
f.write( f.write(
@ -328,7 +219,10 @@ class Infra:
) )
) )
assert provision.exists()
# backup = self.repo_path.joinpath(f"inventory/{instance.name}-backup.yml")
backup = self._backup_path(subdomain) 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( f.write(
render_to_string( render_to_string(
@ -344,6 +238,8 @@ class Infra:
) )
) )
# hostscript = self.repo_path.join("inventory/hosts-scripts/{instance.name}-host.sh")
hostscript = self._hostscript_path(subdomain) hostscript = self._hostscript_path(subdomain)
with open(hostscript, "w+", encoding="utf-8") as f: with open(hostscript, "w+", encoding="utf-8") as f:
f.write("\n") f.write("\n")
@ -356,26 +252,29 @@ class Infra:
), ),
) )
commit = self._push(f"add vm {subdomain}") commit = self._commit(action="add", subdomain=subdomain)
return (forgejo_password, commit) self.repo.git.push(env=self.env)
return (gitea_password, commit)
def remove_vm(self, instance: Instance): def remove_vm(self, instance: Instance):
""" """
Remove a VM from infrastructure repository Remove a VM from infrastructure repository
""" """
self._pull()
subdomain = instance.name subdomain = instance.name
try:
host_vars_dir = self._host_vars_dir(subdomain) host_vars_dir = self._host_vars_dir(subdomain)
if os.path.exists(host_vars_dir):
shutil.rmtree(host_vars_dir) shutil.rmtree(host_vars_dir)
backup = self._backup_path(subdomain) backup = self._backup_path(subdomain)
if os.path.exists(backup):
os.remove(backup) os.remove(backup)
service = self._service_path(subdomain) service = self._service_path(subdomain)
if os.path.exists(service):
os.remove(service) os.remove(service)
except FileNotFoundError:
pass
hostscript = self._hostscript_path(subdomain) hostscript = self._hostscript_path(subdomain)
with open(hostscript, "w+", encoding="utf-8") as f: with open(hostscript, "w+", encoding="utf-8") as f:
@ -388,4 +287,5 @@ class Infra:
context={"subdomain": subdomain}, context={"subdomain": subdomain},
), ),
) )
return self._push(f"rm vm {subdomain}") self._commit(action="rm", subdomain=subdomain)
self.repo.git.push(env=self.env)

View File

@ -18,17 +18,15 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from accounts.decorators import confirm_access from accounts.decorators import confirm_access
from dash.models import Instance from dash.models import Instance
from billing.utils import payment_fullfilled from billing.utils import payment_fullfilled
from .utils import create_vm_if_not_exists, Infra, delete_vm from .utils import create_vm_if_not_exists
def default_ctx(title: str, username: str): def default_ctx(title: str, username: str):
@ -53,28 +51,27 @@ def create_instance(request, instance_name: str):
res = create_vm_if_not_exists(instance=instance) res = create_vm_if_not_exists(instance=instance)
if res is not None: if res is not None:
(forgejo_password, commit) = res (gitea_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( send_mail(
subject="[Gna!] Forgejo admin credentials", subject="[Hostea] Gitea admin credentials",
message=body, message=f"""
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py Congratulations on your new Gitea instance!\n
You can use the following credentials to log into an admin account on
your new Gitea instance. Great powers come with great responsibilities,
so use the admin credentials wisely. When in doubt, consult the Gitea
docs or contact support!\n
-username : root
- password: {gitea_password}
""",
from_email="No reply Hostea<no-reply@exampl.org>", # TODO read from settings.py
recipient_list=[request.user.email], recipient_list=[request.user.email],
) )
ctx = {
"gitea_password": gitea_password,
}
return render(request, "infrastructure/html/create.html", ctx) return render(request, "infrastructure/html/create.html", ctx)
return HttpResponse() return HttpResponse()
@ -86,5 +83,7 @@ def delete_instance(request, instance_name: str):
Dashboard homepage view Dashboard homepage view
""" """
instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user) instance = get_object_or_404(Instance, name=instance_name, owned_by=request.user)
delete_vm(instance=instance) infra = Infra()
return redirect(reverse("dash.instances.list")) infra.remove_vm(instance=instance)
# TODO: push isn't implemented yet
return HttpResponse()

View File

@ -0,0 +1,98 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
# Copyright © 2022 enough.community https://lab.enough.community/main/infrastructure/-/blob/master/AUTHORS
#
# 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 urillib.parse import urlparse, urlunparse
from django.conf import settings
class CI:
def __init__(self):
self.host = settings.HOSTEA["META"]["WOODPECKER"]["HOST"]
# checking if woodpecker host is URL
_ = urlparse(self.host)
token = settings.HOSTEA["META"]["WOODPECKER"]["TOKEN"]
self.auth_header = {"Authorization": f"Bearer {token}"}
self.w = requests.Session()
self.w.url = f"https://{self.host}"
r = self.w.get(self.w.url + "/authorize", allow_redirects=False)
location = self.gitea_browser.confirm_oauth(
r.headers["Location"], f"https://{self.hostname}/authorize"
)
r = self.w.get(location, allow_redirects=False)
r.raise_for_status()
#
# Woodpecker CSRF
#
r = self.w.get(self.w.url + "/web-config.js", allow_redirects=False)
r.raise_for_status()
csrf = re.findall('window.WOODPECKER_CSRF = "(.*?)"', r.text)[0]
#
# Woodpecker token
#
r = self.w.post(
self.w.url + "/api/user/token",
headers={"X-CSRF-TOKEN": csrf},
allow_redirects=False,
)
r.raise_for_status()
self.token = r.text
self.w.headers = {"Authorization": f"Bearer {self.token}"}
def confirm_oauth(self, url, redirect):
logger.info(f"confirm oauth {url} redirect {redirect}")
r = self.g.get(url, allow_redirects=False)
r.raise_for_status()
if r.status_code == 200:
soup = BeautifulSoup(r.text, "html.parser")
data = {
"redirect_uri": redirect,
}
for input in soup.select(
'form[action="/login/oauth/grant"] input[type="hidden"]'
):
if (
input.get("name") is None
or input.get("value") is None
or input.get("value") == ""
):
continue
logger.info(f"collected hidden input {input['name']} {input['value']}")
data[input["name"]] = input["value"]
assert len(data) > 1, f"{data} has only one field, more are expected"
r = self.g.post(
self.g.url + "/login/oauth/grant", data=data, allow_redirects=False
)
r.raise_for_status()
logger.info("oauth confirmed")
elif r.status_code == 302:
logger.info("no confirmation required")
location = r.headers["Location"]
logger.info(f"going back to {location}")
assert location.startswith(redirect)
return location
def _build_list_url(self):
parsed = urlparse(self.host)
list_builds = "/api/repos/Hostea/dashboard/builds"
return (urlunparse((parsed.scheme, parsed.netloc, list_builds, "", "", "")),)
def logs(self):
resp = request.get(self._build_list_url(), headers=self.auth_header)
print(resp.json())

View File

@ -11,12 +11,11 @@ init() {
else else
docker_compose_down || true docker_compose_down || true
docker_compose_up docker_compose_up
sed -i /localhost.*/d ~/.ssh/known_hosts
setup_env setup_env
sleep 5 sleep 5
# wait_for_env # wait_for_env
fi fi
forgejo_root gitea_root
support_repo_init support_repo_init
fleet_repo_init fleet_repo_init
} }
@ -30,8 +29,4 @@ teardown() {
fi fi
} }
new_fleet_repo() { $1
new_fleet_repo_init $2
}
$1 $@

View File

@ -3,10 +3,10 @@ import argparse
from requests import Session from requests import Session
def forgejo_from_args(args, c: Session): def gitea_from_args(args, c: Session):
from .forgejo import Forgejo from .gitea import Gitea
return Forgejo( return Gitea(
host=args.host, host=args.host,
username=args.username, username=args.username,
password=args.password, password=args.password,
@ -15,7 +15,7 @@ def forgejo_from_args(args, c: Session):
) )
class Forgejo: class Gitea:
def __init__(self, parser, c: Session): def __init__(self, parser, c: Session):
self.c = c self.c = c
self.parser = parser self.parser = parser
@ -26,59 +26,60 @@ class Forgejo:
self.create_repository() self.create_repository()
self.install_sso() self.install_sso()
self.add_deploy_key() self.add_deploy_key()
self.new_oauth2_app()
def __add_credentials_parser(self, parser): def __add_credentials_parser(self, parser):
group = parser.add_argument_group("credentials", "User credentials") group = parser.add_argument_group("credentials", "User credentials")
group.add_argument("username", type=str, help="Forgejo user's username") group.add_argument("username", type=str, help="Gitea user's username")
group.add_argument("password", type=str, help="Forgejo user's password") group.add_argument("password", type=str, help="Gitea user's password")
group.add_argument("email", type=str, help="Forgejo user's email") group.add_argument("email", type=str, help="Gitea user's email")
group.add_argument("host", type=str, help="URI at which Forgejo is running") group.add_argument("host", type=str, help="URI at which Gitea is running")
def install(self): def install(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.install() gitea.install()
self.install_parser = self.subparser.add_parser( self.install_parser = self.subparser.add_parser(
name="install", description="Install Forgejo", help="Install Forgejo" name="install", description="Install Gitea", help="Install Gitea"
) )
self.__add_credentials_parser(self.install_parser) self.__add_credentials_parser(self.install_parser)
self.install_parser.set_defaults(func=run) self.install_parser.set_defaults(func=run)
def register(self): def register(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.register() gitea.register()
self.register_parser = self.subparser.add_parser( self.register_parser = self.subparser.add_parser(
name="register", name="register",
description="Forgejo user registration", description="Gitea user registration",
help="Register a user on Forgejo", help="Register a user on Gitea",
) )
self.__add_credentials_parser(self.register_parser) self.__add_credentials_parser(self.register_parser)
self.register_parser.set_defaults(func=run) self.register_parser.set_defaults(func=run)
def login(self): def login(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.login() gitea.login()
self.login_parser = self.subparser.add_parser( self.login_parser = self.subparser.add_parser(
name="login", description="Forgejo user login", help="Login on Forgejo" name="login", description="Gitea user login", help="Login on Gitea"
) )
self.__add_credentials_parser(self.login_parser) self.__add_credentials_parser(self.login_parser)
self.login_parser.set_defaults(func=run) self.login_parser.set_defaults(func=run)
def create_repository(self): def create_repository(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.login() gitea.login()
forgejo.create_repository(name=args.repo_name) gitea.create_repository(name=args.repo_name)
self.create_repository_parser = self.subparser.add_parser( self.create_repository_parser = self.subparser.add_parser(
name="create_repo", name="create_repo",
description="Create repository on Forgejo", description="Create repository on Gitea",
help="Create repository on Forgejo", help="Create repository on Gitea",
) )
self.__add_credentials_parser(self.create_repository_parser) self.__add_credentials_parser(self.create_repository_parser)
self.create_repository_parser.set_defaults(func=run) self.create_repository_parser.set_defaults(func=run)
@ -88,10 +89,10 @@ class Forgejo:
def install_sso(self): def install_sso(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.login() gitea.login()
print(f"CLIENT ID: {args.client_id}") print(f"CLIENT ID: {args.client_id}")
forgejo.install_sso( gitea.install_sso(
sso_name=args.sso_name, sso_name=args.sso_name,
client_id=args.client_id, client_id=args.client_id,
client_secret=args.client_secret, client_secret=args.client_secret,
@ -100,8 +101,8 @@ class Forgejo:
self.install_sso_parser = self.subparser.add_parser( self.install_sso_parser = self.subparser.add_parser(
name="install_sso", name="install_sso",
description="Install SSO on Forgejo", description="Install SSO on Gitea",
help="Install SSO on Forgejo", help="Install SSO on Gitea",
) )
self.__add_credentials_parser(self.install_sso_parser) self.__add_credentials_parser(self.install_sso_parser)
self.install_sso_parser.add_argument( self.install_sso_parser.add_argument(
@ -121,16 +122,43 @@ class Forgejo:
self.install_sso_parser.set_defaults(func=run) self.install_sso_parser.set_defaults(func=run)
def new_oauth2_app(self):
def run(args, c: Session):
gitea = gitea_from_args(args, c=c)
# checks if user exists
gitea.login()
creds = gitea.add_oauth_application(
name=args.app_name,
redirect_urls=[args.redirect_uri],
)
print(f"client_id: {creds[0]}client_secret: \n{creds[1]}")
self.install_oauth2_app = self.subparser.add_parser(
name="new_oauth2_app",
description="Create new oauth2 app on Gitea",
help="Create new oauth2 app on Gitea",
)
self.__add_credentials_parser(self.install_oauth2_app)
self.install_oauth2_app.add_argument(
"app_name", type=str, help="(Human readable)Name of the OAuth app"
)
self.install_oauth2_app.add_argument(
"redirect_uri",
type=str,
help="Redirect URI of the app",
)
self.install_oauth2_app.set_defaults(func=run)
def add_deploy_key(self): def add_deploy_key(self):
def run(args, c: Session): def run(args, c: Session):
forgejo = forgejo_from_args(args, c=c) gitea = gitea_from_args(args, c=c)
forgejo.login() gitea.login()
forgejo.add_deploy_key(repo=args.repo_name, key=args.key_file) gitea.add_deploy_key(repo=args.repo_name, key=args.key_file)
self.add_deploy_key_parser = self.subparser.add_parser( self.add_deploy_key_parser = self.subparser.add_parser(
name="add_deploy_key", name="add_deploy_key",
description="Create repository on Forgejo", description="Create repository on Gitea",
help="Add deploy key to a repository on Forgejo", help="Add deploy key to a repository on Gitea",
) )
self.__add_credentials_parser(self.add_deploy_key_parser) self.__add_credentials_parser(self.add_deploy_key_parser)
self.add_deploy_key_parser.add_argument( self.add_deploy_key_parser.add_argument(
@ -203,21 +231,21 @@ class Hostea:
def support(self): def support(self):
def run(args, c: Session): def run(args, c: Session):
from .forgejo import ForgejoSSO from .gitea import GiteaSSO
dash = dash_from_args(args, c=c) dash = dash_from_args(args, c=c)
dash.login() dash.login()
forgejo = ForgejoSSO( gitea = GiteaSSO(
username=dash.username, username=dash.username,
email=dash.email, email=dash.email,
forgejo_host=args.forgejo_host, gitea_host=args.gitea_host,
hostea_org=args.forgejo_hostea_org, hostea_org=args.gitea_hostea_org,
support_repo=args.support_repo, support_repo=args.support_repo,
c=c, c=c,
) )
dash.new_ticket(forgejo.new_issues_uri) dash.new_ticket(gitea.new_issues_uri)
forgejo.new_issue() gitea.new_issue()
self.support_parser = self.subparser.add_parser( self.support_parser = self.subparser.add_parser(
name="support", name="support",
@ -226,12 +254,12 @@ class Hostea:
) )
self.__add_credentials_parser(self.support_parser) self.__add_credentials_parser(self.support_parser)
self.support_parser.add_argument( self.support_parser.add_argument(
"forgejo_host", type=str, help="URI at which Forgejo is running" "gitea_host", type=str, help="URI at which Gitea is running"
) )
self.support_parser.add_argument( self.support_parser.add_argument(
"forgejo_hostea_org", "gitea_hostea_org",
type=str, type=str,
help="Hostea namespace(username/org) on Forgejo, where support repository is hosted", help="Hostea namespace(username/org) on Gitea, where support repository is hosted",
) )
self.support_parser.add_argument( self.support_parser.add_argument(
"support_repo", type=str, help="support repository name" "support_repo", type=str, help="support repository name"
@ -245,28 +273,28 @@ class Cli:
c = Session() c = Session()
self.c = c self.c = c
self.parser = argparse.ArgumentParser( self.parser = argparse.ArgumentParser(
description="Install and Bootstrap Forgejo and Hostea Dashboard" description="Install and Bootstrap Gitea and Hostea Dashboard"
) )
self.subparser = self.parser.add_subparsers() self.subparser = self.parser.add_subparsers()
self.check_env() self.check_env()
self.forgejo() self.gitea()
self.hostea() self.hostea()
def __add_credentials_parser(self, parser): def __add_credentials_parser(self, parser):
group = parser.add_argument_group("credentials", "User credentials") group = parser.add_argument_group("credentials", "User credentials")
group.add_argument("username", type=str, help="Forgejo user's username") group.add_argument("username", type=str, help="Gitea user's username")
group.add_argument("password", type=str, help="Forgejo user's password") group.add_argument("password", type=str, help="Gitea user's password")
group.add_argument("email", type=str, help="Forgejo user's email") group.add_argument("email", type=str, help="Gitea user's email")
def check_env(self): def check_env(self):
def run(args, c: Session): def run(args, c: Session):
from .forgejo import Forgejo from .gitea import Gitea
from .hostea import Hostea from .hostea import Hostea
Hostea.check_online( Hostea.check_online(
dashboard_host=args.hostea_host, maildev_host=args.maildev_host dashboard_host=args.hostea_host, maildev_host=args.maildev_host
) )
Forgejo.check_online(host=args.forgejo_host) Gitea.check_online(host=args.gitea_host)
self.check_env_parser = self.subparser.add_parser( self.check_env_parser = self.subparser.add_parser(
name="check_env", name="check_env",
@ -275,7 +303,7 @@ class Cli:
) )
self.check_env_parser.add_argument( self.check_env_parser.add_argument(
"forgejo_host", type=str, help="URI at which Forgejo is running" "gitea_host", type=str, help="URI at which Gitea is running"
) )
self.check_env_parser.add_argument( self.check_env_parser.add_argument(
@ -295,13 +323,13 @@ class Cli:
) )
Hostea(parser=self.hostea, c=self.c) Hostea(parser=self.hostea, c=self.c)
def forgejo(self): def gitea(self):
self.forgejo = self.subparser.add_parser( self.gitea = self.subparser.add_parser(
name="forgejo", name="gitea",
description="Forgejo", description="Gitea",
help="Forgejo-related functionality", help="Gitea-related functionality",
) )
Forgejo(parser=self.forgejo, c=self.c) Gitea(parser=self.gitea, c=self.c)
def parse(self): def parse(self):
return self.parser.parse_args() return self.parser.parse_args()

View File

@ -13,7 +13,7 @@ class ParseCSRF(HTMLParser):
# return cls(name="csrfmiddlewaretoken") # return cls(name="csrfmiddlewaretoken")
# #
# @classmethod # @classmethod
# def forgejo_parser(cls) -> "ParseCSRF": # def gitea_parser(cls) -> "ParseCSRF":
# return cls(name="_csrf") # return cls(name="_csrf")
# #
def handle_starttag(self, tag: str, attrs: (str, str)): def handle_starttag(self, tag: str, attrs: (str, str)):

View File

@ -10,15 +10,15 @@ import requests
from .csrf import ParseCSRF from .csrf import ParseCSRF
# FORGEJO_USER = "root" # GITEA_USER = "root"
# FORGEJO_EMAIL = "root@example.com" # GITEA_EMAIL = "root@example.com"
# FORGEJO_PASSWORD = "foobarpassword" # GITEA_PASSWORD = "foobarpassword"
# HOST = "http://localhost:8080" # HOST = "http://localhost:8080"
# #
# REPOS = [] # REPOS = []
class Forgejo: class Gitea:
def __init__(self, host: str, username: str, password: str, email: str, c: Session): def __init__(self, host: str, username: str, password: str, email: str, c: Session):
self.host = host self.host = host
self.username = username self.username = username
@ -48,7 +48,7 @@ class Forgejo:
@staticmethod @staticmethod
def check_online(host: str): def check_online(host: str):
""" """
Check if Forgejo instance is online Check if Gitea instance is online
""" """
count = 0 count = 0
parsed = urlparse(host) parsed = urlparse(host)
@ -67,7 +67,7 @@ class Forgejo:
def install(self): def install(self):
""" """
Install Forgejo, first form that a user sees when a new instance is Install Gitea, first form that a user sees when a new instance is
deployed deployed
""" """
# cwd = os.environ.get("PWD") # cwd = os.environ.get("PWD")
@ -77,12 +77,12 @@ class Forgejo:
"db_host": "localhost:3306", "db_host": "localhost:3306",
"db_user": "root", "db_user": "root",
"db_passwd": "", "db_passwd": "",
"db_name": "forgejo", "db_name": "gitea",
"ssl_mode": "disable", "ssl_mode": "disable",
"db_schema": "", "db_schema": "",
"charset": "utf8", "charset": "utf8",
"db_path": "/data/gitea/gitea.db", "db_path": "/data/gitea/gitea.db",
"app_name": "Forgejo:+Beyond+Coding+We+Forge", "app_name": "Gitea:+Git+with+a+cup+of+tea",
"repo_root_path": "/data/git/repositories", "repo_root_path": "/data/git/repositories",
"lfs_root_path": "/data/git/lfs", "lfs_root_path": "/data/git/lfs",
"run_user": "git", "run_user": "git",
@ -297,6 +297,15 @@ class Forgejo:
resp = self.c.post(url, json=payload) resp = self.c.post(url, json=payload)
assert resp.status_code == 201 assert resp.status_code == 201
def add_oauth_application(self, name: str, redirect_urls: [str]):
url = self.get_api_uri(f"/api/v1/user/applicaitons/oauth2")
payload = {"name": name, "redirect_urls": redirect_urls}
resp = self.c.post(url, json=payload)
print(f"new oauth status code: {resp.status_code}")
assert resp.status_code == 201
data = resp.json()
return (data["client_id"], data["client_secret"])
class ParseSSOLogin(HTMLParser): class ParseSSOLogin(HTMLParser):
url: str = None url: str = None
@ -316,26 +325,26 @@ class ParseSSOLogin(HTMLParser):
return return
class ForgejoSSO: class GiteaSSO:
def __init__( def __init__(
self, self,
username: str, username: str,
email: str, email: str,
forgejo_host: str, gitea_host: str,
hostea_org: str, hostea_org: str,
support_repo: str, support_repo: str,
c: Session, c: Session,
): ):
self.c = c self.c = c
self.username = username self.username = username
self.forgejo_host = forgejo_host self.gitea_host = gitea_host
self.hostea_org = hostea_org self.hostea_org = hostea_org
self.support_repo = support_repo self.support_repo = support_repo
self.email = email self.email = email
self.__csrf_key = "_csrf" self.__csrf_key = "_csrf"
url = urlparse(self.forgejo_host) url = urlparse(self.gitea_host)
repo = f"{self.hostea_org}/{self.support_repo}" repo = f"{self.hostea_org}/{self.support_repo}"
issues = f"{repo}/issues" issues = f"{repo}/issues"
new_issues = f"{issues}/new" new_issues = f"{issues}/new"
@ -371,8 +380,8 @@ class ForgejoSSO:
parser = ParseSSOLogin() parser = ParseSSOLogin()
parser.feed(resp.text) parser.feed(resp.text)
url = urlparse(self.forgejo_host) url = urlparse(self.gitea_host)
## SSO URL in Forgejo login page ## SSO URL in Gitea login page
sso = urlunparse((url.scheme, url.netloc, parser.url, "", "", "")) sso = urlunparse((url.scheme, url.netloc, parser.url, "", "", ""))
# redirects are enabled to for a cleaner implementation. Commented out # redirects are enabled to for a cleaner implementation. Commented out

View File

@ -11,15 +11,17 @@ is_ci(){
if is_ci if is_ci
then then
MAILDEV_URL="http://smtp:1080" MAILDEV_URL="http://smtp:1080"
FORGEJO_URL="http://forgejo:3000" GITEA_URL="http://gitea:3000"
FORGEJO_SSH_URL="ssh://git@forgejo:22" GITEA_SSH_URL="ssh://git@gitea:22"
WOODPECKER_URL="http://woodpecker:8000"
else else
MAILDEV_URL="http://localhost:1080" MAILDEV_URL="http://localhost:1080"
FORGEJO_URL="http://localhost:3000" GITEA_URL="http://localhost:3000"
FORGEJO_SSH_URL="ssh://git@localhost:22" GITEA_SSH_URL="ssh://git@localhost:22"
WOODPECKER_URL="http://localhost:8000"
fi fi
readonly DASHBOARD_URL="http://localhost:8000" readonly DASHBOARD_URL="http://localhost:7000"
readonly DASHBOARD_OIDC_DISCOVERY_URL="$DASHBOARD_URL/o/.well-known/openid-configuration/" readonly DASHBOARD_OIDC_DISCOVERY_URL="$DASHBOARD_URL/o/.well-known/openid-configuration/"
@ -27,22 +29,26 @@ readonly DASHBOARD_OIDC_DISCOVERY_URL="$DASHBOARD_URL/o/.well-known/openid-confi
readonly DASHBOARD_ADMIN_USERNAME=root readonly DASHBOARD_ADMIN_USERNAME=root
readonly DASHBOARD_ADMIN_PASSWORD=supercomplicatedpassword readonly DASHBOARD_ADMIN_PASSWORD=supercomplicatedpassword
readonly DASHBOARD_ADMIN_EMAIL="$DASHBOARD_ADMIN_USERNAME@dash.example.org" readonly DASHBOARD_ADMIN_EMAIL="$DASHBOARD_ADMIN_USERNAME@dash.example.org"
readonly DASHBOARD_OIDC_APP_NAME=hostea-forgejo readonly DASHBOARD_OIDC_APP_NAME=hostea-gitea
readonly FORGEJO_ROOT_USERNAME=root readonly GITEA_ROOT_USERNAME=root
readonly FORGEJO_ROOT_EMAIL="$FORGEJO_ROOT_USERNAME@example.org" readonly GITEA_ROOT_EMAIL="$GITEA_ROOT_USERNAME@example.org"
readonly FORGEJO_ROOT_PASSOWRD=supercomplicatedpassword readonly GITEA_ROOT_PASSOWRD=supercomplicatedpassword
readonly FORGEJO_HOSTEA_SSO_NAME=hostea-sso readonly GITEA_HOSTEA_SSO_NAME=hostea-sso
readonly FORGEJO_OIDC_CALLBACK="$FORGEJO_URL/user/oauth2/$FORGEJO_HOSTEA_SSO_NAME/callback" readonly GITEA_OIDC_CALLBACK="$GITEA_URL/user/oauth2/$GITEA_HOSTEA_SSO_NAME/callback"
readonly FORGEJO_HOSTEA_FLEET_DEPLOY_KEY="$(realpath tests/fleet-deploy-key.pub)" readonly GITEA_HOSTEA_FLEET_DEPLOY_KEY="$(realpath tests/fleet-deploy-key.pub)"
readonly FORGEJO_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE="$(realpath tests/fleet-deploy-key)" readonly GITEA_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE="$(realpath tests/fleet-deploy-key)"
readonly GITEA_WOODPECKER_OAUTH_NAME="woodpecker"
readonly FORGEJO_HOSTEA_USERNAME=hostea readonly WOODPECKER_REDIRECT_URL="$WOODPECKER_URL/authorize"
readonly FORGEJO_HOSTEA_PASSWORD=supercomplicatedpassword
readonly FORGEJO_HOSTEA_EMAIL="$FORGEJO_HOSTEA_USERNAME@example.org"
readonly FORGEJO_HOSTEA_SUPPORT_REPO="support" readonly GITEA_HOSTEA_USERNAME=hostea
readonly FORGEJO_HOSTEA_FLEET_REPO="fleet" readonly GITEA_HOSTEA_PASSWORD=supercomplicatedpassword
readonly FORGEJO_HOSTEA_FLEET_REPO_REMOTE="$FORGEJO_SSH_URL/$FORGEJO_HOSTEA_USERNAME/$FORGEJO_HOSTEA_FLEET_REPO.git" readonly GITEA_HOSTEA_EMAIL="$GITEA_HOSTEA_USERNAME@example.org"
readonly GITEA_HOSTEA_SUPPORT_REPO="support"
readonly GITEA_HOSTEA_FLEET_REPO="fleet"
readonly GITEA_HOSTEA_FLEET_REPO_REMOTE="$GITEA_SSH_URL/$GITEA_HOSTEA_USERNAME/$GITEA_HOSTEA_FLEET_REPO.git"
readonly HOSTEA_CUSTOMER_USERNAME=batman readonly HOSTEA_CUSTOMER_USERNAME=batman
readonly HOSTEA_CUSTOMER_PASSWORD=supercomplicatedpassword readonly HOSTEA_CUSTOMER_PASSWORD=supercomplicatedpassword
@ -53,7 +59,7 @@ OIDC_CLIENT_SECRET=""
wait_for_env() { wait_for_env() {
python -m integration \ python -m integration \
check_env $FORGEJO_URL $DASHBOARD_URL $MAILDEV_URL check_env $GITEA_URL $DASHBOARD_URL $MAILDEV_URL
} }
# create OIDC app on Hostea Dashboard # create OIDC app on Hostea Dashboard
@ -67,114 +73,113 @@ oidc_dashboard_init() {
resp=$(python manage.py create_oidc \ resp=$(python manage.py create_oidc \
$DASHBOARD_OIDC_APP_NAME $DASHBOARD_ADMIN_USERNAME \ $DASHBOARD_OIDC_APP_NAME $DASHBOARD_ADMIN_USERNAME \
$FORGEJO_OIDC_CALLBACK) $GITEA_OIDC_CALLBACK)
OIDC_CLIENT_ID=$(echo $resp | cut -d ":" -f 2 | cut -d " " -f 2) OIDC_CLIENT_ID=$(echo $resp | cut -d ":" -f 2 | cut -d " " -f 2)
OIDC_CLIENT_SECRET=$(echo $resp | cut -d ":" -f 3 | cut -d " " -f 2) OIDC_CLIENT_SECRET=$(echo $resp | cut -d ":" -f 3 | cut -d " " -f 2)
} }
# register root user on Forgejo to simulate Hoste admin and integrate SSO # register root user on Gitea to simulate Hoste admin and integrate SSO
forgejo_root(){ gitea_root(){
python -m integration \ python -m integration \
forgejo install \ gitea install \
$FORGEJO_ROOT_USERNAME $FORGEJO_ROOT_PASSOWRD \ $GITEA_ROOT_USERNAME $GITEA_ROOT_PASSOWRD \
$FORGEJO_ROOT_EMAIL \ $GITEA_ROOT_EMAIL \
$FORGEJO_URL $GITEA_URL
python -m integration \ python -m integration \
forgejo register \ gitea register \
$FORGEJO_ROOT_USERNAME $FORGEJO_ROOT_PASSOWRD \ $GITEA_ROOT_USERNAME $GITEA_ROOT_PASSOWRD \
$FORGEJO_ROOT_EMAIL \ $GITEA_ROOT_EMAIL \
$FORGEJO_URL $GITEA_URL
python -m integration \ python -m integration \
forgejo login \ gitea login \
$FORGEJO_ROOT_USERNAME $FORGEJO_ROOT_PASSOWRD \ $GITEA_ROOT_USERNAME $GITEA_ROOT_PASSOWRD \
$FORGEJO_ROOT_EMAIL \ $GITEA_ROOT_EMAIL \
$FORGEJO_URL $GITEA_URL
python -m integration \
gitea new_oauth2_app \
$GITEA_ROOT_USERNAME $GITEA_ROOT_PASSOWRD \
$GITEA_ROOT_EMAIL \
$GITEA_URL \
$GITEA_WOODPECKER_OAUTH_NAME \
$WOODPECKER_REDIRECT_URL
# python -m integration \ # python -m integration \
# forgejo install_sso \ # gitea install_sso \
# $FORGEJO_ROOT_USERNAME $FORGEJO_ROOT_PASSOWRD \ # $GITEA_ROOT_USERNAME $GITEA_ROOT_PASSOWRD \
# $FORGEJO_ROOT_EMAIL \ # $GITEA_ROOT_EMAIL \
# $FORGEJO_URL \ # $GITEA_URL \
# $FORGEJO_HOSTEA_SSO_NAME \ # $GITEA_HOSTEA_SSO_NAME \
# $OIDC_CLIENT_ID $OIDC_CLIENT_SECRET \ # $OIDC_CLIENT_ID $OIDC_CLIENT_SECRET \
# $DASHBOARD_OIDC_DISCOVERY_URL # $DASHBOARD_OIDC_DISCOVERY_URL
} }
# register user "Hostea" on Forgejo and create support repository # register user "Hostea" on Gitea and create support repository
support_repo_init() { support_repo_init() {
python -m integration \ python -m integration \
forgejo register \ gitea register \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \ $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \ $GITEA_HOSTEA_EMAIL \
$FORGEJO_URL $GITEA_URL
python -m integration \ python -m integration \
forgejo login \ gitea login \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \ $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \ $GITEA_HOSTEA_EMAIL \
$FORGEJO_URL $GITEA_URL
python -m integration \ python -m integration \
forgejo create_repo \ gitea create_repo \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \ $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \ $GITEA_HOSTEA_EMAIL \
$FORGEJO_URL \ $GITEA_URL \
$FORGEJO_HOSTEA_SUPPORT_REPO $GITEA_HOSTEA_SUPPORT_REPO
} }
new_fleet_repo_init() { # register user "Hostea" on Gitea and create support repository
fleet_repo_init() {
python -m integration \ python -m integration \
forgejo create_repo \ gitea register \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \ $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \ $GITEA_HOSTEA_EMAIL \
$FORGEJO_URL \ $GITEA_URL || true
$1 python -m integration \
gitea login \
$GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$GITEA_HOSTEA_EMAIL \
$GITEA_URL
python -m integration \
gitea create_repo \
$GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$GITEA_HOSTEA_EMAIL \
$GITEA_URL \
$GITEA_HOSTEA_FLEET_REPO
python -m integration \ python -m integration \
forgejo add_deploy_key \ gitea add_deploy_key \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \ $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \ $GITEA_HOSTEA_EMAIL \
$FORGEJO_URL \ $GITEA_URL \
$1 \ $GITEA_HOSTEA_FLEET_REPO \
$FORGEJO_HOSTEA_FLEET_DEPLOY_KEY $GITEA_HOSTEA_FLEET_DEPLOY_KEY
tmp_dir=$(mktemp -d) tmp_dir=$(mktemp -d)
pushd $tmp_dir pushd $tmp_dir
echo "init" >> README echo "init" >> README
git init
if is_ci if is_ci
then then
git config user.email "hostea-dashobard-test@example.org" git config --global user.email "${CI_COMMIT_AUTHOR_EMAIL}"
git config user.name "hoste-dashobard-test" git config --global user.name "${CI_COMMIT_AUTHOR}"
chmod 600 $FORGEJO_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE chmod 600 $GITEA_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE
fi fi
git init
git add README git add README
git commit -m "init" git commit -m "init"
REMOTE="$FORGEJO_SSH_URL/$FORGEJO_HOSTEA_USERNAME/$1.git" git remote add origin $GITEA_HOSTEA_FLEET_REPO_REMOTE
git remote add origin $REMOTE GIT_SSH_COMMAND="/usr/bin/ssh -oStrictHostKeyChecking=no -i $GITEA_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE" \
GIT_SSH_COMMAND="/usr/bin/ssh -oStrictHostKeyChecking=no -i $FORGEJO_HOSTEA_FLEET_DEPLOY_KEY_PRIVATE" \
git push --set-upstream origin master git push --set-upstream origin master
popd popd
rm -rf $tmp_dir rm -rf $tmp_dir
} }
# register user "Hostea" on Forgejo and create support repository
fleet_repo_init() {
python -m integration \
forgejo register \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \
$FORGEJO_URL || true
python -m integration \
forgejo login \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_PASSWORD \
$FORGEJO_HOSTEA_EMAIL \
$FORGEJO_URL
new_fleet_repo_init $FORGEJO_HOSTEA_FLEET_REPO
}
# Create user on Hostea to simulate a Hostea customer # Create user on Hostea to simulate a Hostea customer
hostea_customer_simulation() { hostea_customer_simulation() {
python -m integration \ python -m integration \
@ -192,8 +197,8 @@ hostea_customer_simulation() {
$HOSTEA_CUSTOMER_USERNAME $HOSTEA_CUSTOMER_PASSWORD \ $HOSTEA_CUSTOMER_USERNAME $HOSTEA_CUSTOMER_PASSWORD \
$HOSTEA_CUSTOMER_EMAIL \ $HOSTEA_CUSTOMER_EMAIL \
$DASHBOARD_URL \ $DASHBOARD_URL \
$FORGEJO_URL \ $GITEA_URL \
$FORGEJO_HOSTEA_USERNAME $FORGEJO_HOSTEA_SUPPORT_REPO $GITEA_HOSTEA_USERNAME $GITEA_HOSTEA_SUPPORT_REPO
} }
setup_env() { setup_env() {

View File

@ -6,7 +6,7 @@ import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard.settings") os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dashboard.settings')
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@ -18,5 +18,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == "__main__": if __name__ == '__main__':
main() main()

View File

@ -1,7 +1,6 @@
asgiref==3.5.0 asgiref==3.5.0
astroid==2.9.3 astroid==2.9.3
black==22.1.0 black==22.1.0
Brotli==1.0.9
certifi==2022.5.18.1 certifi==2022.5.18.1
cffi==1.15.0 cffi==1.15.0
charset-normalizer==2.0.12 charset-normalizer==2.0.12
@ -15,9 +14,10 @@ django-oauth-toolkit==2.0.0
django-payments==1.0.0 django-payments==1.0.0
django-phonenumber-field==6.3.0 django-phonenumber-field==6.3.0
djangorestframework==3.13.1 djangorestframework==3.13.1
greenlet==1.1.3.post0 gitdb==4.0.9
GitPython==3.1.27
greenlet==1.1.2
idna==3.3 idna==3.3
install==1.3.5
isort==5.10.1 isort==5.10.1
jedi==0.18.1 jedi==0.18.1
jwcrypto==1.3.1 jwcrypto==1.3.1
@ -37,8 +37,6 @@ pynvim==0.4.3
pytz==2022.1 pytz==2022.1
PyYAML==6.0 PyYAML==6.0
requests==2.27.1 requests==2.27.1
six==1.16.0
sh==1.14.2
smmap==5.0.0 smmap==5.0.0
sqlparse==0.4.2 sqlparse==0.4.2
stripe==3.4.0 stripe==3.4.0
@ -46,5 +44,4 @@ tblib==1.7.0
toml==0.10.2 toml==0.10.2
tomli==2.0.1 tomli==2.0.1
urllib3==1.26.9 urllib3==1.26.9
whitenoise==6.2.0
wrapt==1.13.3 wrapt==1.13.3

View File

@ -34,9 +34,11 @@ h2 {
body { body {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
/*
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
*/
} }
a:hover { a:hover {
@ -127,10 +129,6 @@ header {
text-decoration: underline; text-decoration: underline;
} }
.nav__logo-img {
height: 48px;
}
.nav__toggle { .nav__toggle {
display: none; display: none;
} }
@ -246,8 +244,6 @@ footer {
display: block; display: block;
font-size: 0.7rem; font-size: 0.7rem;
margin-bottom: 5px; margin-bottom: 5px;
margin-left: 260px;
width: 100%;
} }
.footer__container { .footer__container {
@ -395,8 +391,6 @@ footer {
font-size: 0.7rem; font-size: 0.7rem;
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-left: 260px;
width: calc(100vw - 260px);
} }
.footer__container { .footer__container {
@ -613,6 +607,8 @@ fieldset {
background-color: #e11d21; background-color: #e11d21;
} }
/* /*
.form__label { .form__label {
margin: 5px 0; margin: 5px 0;

Some files were not shown because too many files have changed in this diff Show More