Compare commits

...

156 Commits

Author SHA1 Message Date
Loïc Dachary eaff09efd4 Merge pull request 'do not rename past migrations' (#73) from dachary/dashboard:wip-forgejo into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: #73
2023-01-09 12:59:42 -05:00
Loïc Dachary d07a644552
upgrade greenlet 1.1.3.post0
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline failed Details
2023-01-09 18:51:22 +01:00
Loïc Dachary 93c38fc1f9
Forgejo is codeberg.org/forgejo/forgejo:1.18.0-1
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline failed Details
2023-01-09 18:39:35 +01:00
Loïc Dachary 788e025b98
do not rename past migrations
ci/woodpecker/pr/woodpecker Pipeline failed Details
it is not possible to go back in time
2023-01-09 18:24:10 +01:00
Loïc Dachary 1c165fcea3
upgrade gitea to forgejo
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline failed Details
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-12-23 22:34:38 +01:00
Aravinth Manivannan 2e36a186ee Merge pull request 'convert instance names to lowercase' (#70) from dachary/dashboard:wip-lowercase into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/70
2022-10-04 04:39:04 -04:00
Loïc Dachary ad925cddfc
convert instance names to lowercase
ci/woodpecker/pr/woodpecker Pipeline was successful Details
Fixes: https://gitea.gna.org/Hostea/dashboard/issues/69

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-10-04 09:14:50 +02:00
Loïc Dachary 2725b9b1f6
avoid crash in CI when STRIPE is not set
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-10-04 09:14:50 +02:00
Loïc Dachary 0f1003dbe8 Merge pull request 'fix: receipt email subject' (#68) from fix-email-typo into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/68
2022-10-01 12:01:08 -04:00
Aravinth Manivannan 187d22118f Merge pull request 'do not pin Gitea version' (#67) from dachary/dashboard:wip-gitea into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/67
2022-10-01 07:55:12 -04:00
Hostea dashboard 9b9fb8362d
fix: receipt email subject
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-09-30 18:36:56 +05:30
Loïc Dachary 498b95848e
do not pin Gitea version
ci/woodpecker/pr/woodpecker Pipeline failed Details
So that it is upgraded when Enough is upgraded.

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-09-30 14:54:50 +02:00
Loïc Dachary b5afc49f5d Merge pull request 'notify staff when new instance is deployed' (#61) from alert-staff-new-instance into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/61
2022-09-14 02:48:21 -04:00
Hostea dashboard 86fe9a2c59
feat: alert staff on new instance creation
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
closes: https://gitea.gna.org/Hostea/dashboard/issues/52
2022-09-13 19:57:37 +05:30
Hostea dashboard 9d2a53f2e2
fix: more email project renaming 2022-09-13 19:57:26 +05:30
Loïc Dachary c7def47215 Merge pull request 'fix: don't send pre-payment notification email' (#56) from fix-rm-not-invoice into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/56
2022-09-12 15:48:14 -04:00
Hostea dashboard 9f5032bd28
feat: send subscription renewal notification emails
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-09-12 21:01:24 +05:30
Hostea dashboard 669a22a004
fix: don't send pre-payment notification email
closes: https://gitea.gna.org/Hostea/dashboard/issues/49
2022-09-12 21:01:21 +05:30
Loïc Dachary 0ab82fe0b4 Merge pull request 'fix: rename Gna! in emails' (#60) from fix-rename-gna-in-emails into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/60
2022-09-12 08:36:17 -04:00
Hostea dashboard 0e0b8db940
fix: test for Gna! in emails
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-09-12 17:36:24 +05:30
Hostea dashboard ac0775e075
fix: rename to Gna! in email templates 2022-09-12 17:36:04 +05:30
Hostea dashboard 51e7ea4d5e
fix: rename Gna! in emails
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-09-12 16:36:29 +05:30
Loïc Dachary 98973301c3 Merge pull request 'fix: don't send emails to VMs that were requested but not created' (#54) from fix-dead-instance-invoices into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/54
2022-09-11 19:07:41 -04:00
Hostea dashboard 3ebc518e1f
fix: don't send emails to VMs that were requested but not created
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
SUMMARY
    dash.models.Instance is created upon request and
    infrastructure.models.InstanceCreated when the instance is created.
    Using data from InstanceCreated to send invoices should solve this
    issue.
2022-09-12 04:05:41 +05:30
Aravinth Manivannan 25db390e4d Merge pull request 'Rename hostea to gna!' (#59) from wip-rename-hostea-to-gna into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/59
2022-09-11 18:25:58 -04:00
Hostea dashboard 6365b84d45
feat: run collectstatic before launching development server to update static assets
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-09-12 03:53:19 +05:30
Hostea dashboard 41ca63244e
feat: update name and web links to Gna! 2022-09-12 03:53:19 +05:30
Hostea dashboard 0e2112e30f
feat: change hostea logo to gna! 2022-09-12 03:53:19 +05:30
Loïc Dachary 5b7cf06c7a Merge pull request 'fix: instance_names in tests must be alphanumeric and be < 20 chars' (#58) from fix-hostname-validation into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/58
2022-09-11 18:13:12 -04:00
Hostea dashboard 1792713952
fix: instance_names in tests must be alphanumeric and be < 20 chars
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
closes: https://gitea.gna.org/Hostea/dashboard/issues/57
2022-09-12 03:33:20 +05:30
Loïc Dachary 3019d9d739 Merge pull request 'fix: add hostname validation rules' (#55) from fix-hostname-validation into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.gna.org/Hostea/dashboard/pulls/55
2022-09-11 16:11:58 -04:00
Hostea dashboard bcb2e26f61
feat: test vm name validation 2022-09-04 18:04:37 +05:30
Hostea dashboard 011fb4816f
fix: validate VM names
fixes: https://gitea.gna.org/Hostea/dashboard/issues/51
2022-09-04 17:49:53 +05:30
Hostea dashboard 6c31555a52
feat: password reset workflow
ci/woodpecker/push/woodpecker Pipeline was successful Details
fixes: https://gitea.hostea.org/Hostea/support/issues/2
2022-07-10 12:42:16 +05:30
Hostea dashboard 060e9b84d4
fix: update password reset link 2022-07-10 12:40:00 +05:30
Hostea dashboard 418bb7dec0
fix: tests rely on a string from login page. Login page update reflects
ci/woodpecker/push/woodpecker Pipeline was successful Details
in tests
2022-07-10 12:39:33 +05:30
Loïc Dachary c4375a43b2 Merge pull request 'Update the page content' (#48) from dachary/dashboard:wip-site into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/48
2022-07-10 02:43:43 -04:00
Loïc Dachary 705c3a282b
copy/paste the hostea.org home page content
ci/woodpecker/pr/woodpecker Pipeline failed Details
So they are consistent with each other.

Fixes: #47

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-07-10 08:42:38 +02:00
Loïc Dachary cfd5518518
Add link to https://hostea.org
So that the user can conveniently go back to hostea.org without
editing with the URL manually.

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-07-10 08:20:24 +02:00
Aravinth Manivannan a95158f3df
fix: Gite credentials email: fix subject and let user know instance is
ci/woodpecker/push/woodpecker Pipeline was successful Details
being provisioned

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/44
2022-07-09 10:54:45 +05:30
Aravinth Manivannan b12cc044da
fix: Invoice generation must not consider deleted VMs' names for
ci/woodpecker/push/woodpecker Pipeline was successful Details
checking if payment is already fulfilled

DESCRIPTION
    Invoice generation is dependent on instance_name. Deleting a VM
    doesn't delete the corresponding payments record since payment
    receipts should be preserved for accounting purposes.

    But being heavily dependent on instance_name, without taking deleted
    VMs into account produces incorrect behavior under certain
    circumstances: if a VM named 'foo' is paid for and is deleted before
    its billing cycle is competed and a new VM is created with the same
    name, either by the same user or a different user, invoice won't be
    generated for the new VM since a payment record already exists for
    that billing cycle for the VM named 'foo'.

    Marking deleted VMs' payment records unsuitable for checking if a VM
    is already paid for will result in correct behavior.

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/38
2022-07-08 22:28:39 +05:30
Aravinth Manivannan cc12d1a77d
fix: hard-code CI_COMMIT_AUTHOR_* details to avoid failures in PR builds
ci/woodpecker/push/woodpecker Pipeline was successful Details
ref: https://gitea.hostea.org/Hostea/dashboard/pulls/42
2022-07-08 19:51:06 +05:30
Aravinth Manivannan cb6bce0c44 Merge pull request 'git config before push' (#43) from dachary/dashboard:wip-config-3 into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/43
2022-07-08 10:18:00 -04:00
Aravinth Manivannan bebf18946a
fix: re-enable billing app tests
ci/woodpecker/push/woodpecker Pipeline was successful Details
Commented out for debugging, forgot to enable(!!)
2022-07-08 19:31:06 +05:30
Loïc Dachary 6e84746a2c
git config before push
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-07-08 15:47:53 +02:00
Aravinth Manivannan 2c8a5909cb
fix: generate absolute URI when attaching links in invoice and payment
ci/woodpecker/push/woodpecker Pipeline was successful Details
notification

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/37
2022-07-08 18:51:04 +05:30
Aravinth Manivannan ce0498b013
fix: add instance link in primary nav bar points to right page 2022-07-08 18:43:04 +05:30
Aravinth Manivannan 809322d245
feat: docs: generate_invoice management cmd
ci/woodpecker/push/woodpecker Pipeline was successful Details
fixes: https://gitea.hostea.org/Hostea/dashboard/issues/29
2022-07-08 01:51:22 +05:30
Aravinth Manivannan 2ee54a71e3
feat: management command to periodically generate invoices
ci/woodpecker/push/woodpecker Pipeline was successful Details
SUMMARY
    `python manage.py generate_invoice` generates invoices for VMs when
    it enters a new billing cycle and sends a notification email to
    VM owners.

    This command should be run as frequently as desirable. Running daily
    is recommended.

BILLING CYCLE
    By default, a billing cycle is 30 days.
2022-07-08 01:46:17 +05:30
Aravinth Manivannan 438e34f7d6
chore: refactor invoice generation into a util fn 2022-07-08 00:51:11 +05:30
Aravinth Manivannan 9c239ad78b
feat: send invoice generated notification email and payments receipt mail
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-07-07 20:51:33 +05:30
Aravinth Manivannan 147eead388 Merge pull request 'clone instead of fetch' (#19) from dachary/dashboard:wip-pull into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/19
2022-07-07 04:42:46 -04:00
Aravinth Manivannan f2f2fadae4
feat: use whitenoise for static file in development too for uniform Behavior
ci/woodpecker/push/woodpecker Pipeline was successful Details
> it opens up the possibility for differences in behaviour between development and production environments. For this reason it’s a good idea to use WhiteNoise in development as well.

source: http://whitenoise.evans.io/en/stable/django.html#using-whitenoise-in-development
2022-07-07 13:59:06 +05:30
Aravinth Manivannan 5f6c3c459e
chore: mv common static files to common-static 2022-07-07 13:58:53 +05:30
Loïc Dachary 8d02fe107f
always clone the fleet repository
ci/woodpecker/pr/woodpecker Pipeline failed Details
It is small and not worth the trouble of dealing with fetch/pull

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-07-07 10:05:18 +02:00
Aravinth Manivannan b4183c1790
feat & fix: install and configure whitenoise to serve static files in
ci/woodpecker/push/woodpecker Pipeline failed Details
prod

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/24
2022-07-07 10:59:23 +05:30
Aravinth Manivannan 22abe08f68
feat: mv static files to dash/static/dash and migrate load static template tags 2022-07-07 10:41:22 +05:30
Aravinth Manivannan 4c51eb77b0 chore: rm gitea/app.in
ci/woodpecker/push/woodpecker Pipeline was successful Details
SUMMARY
    This file was used to spawn a Gitea instance from binary for
    integration testing. That strategy was abandoned long ago but this
    file wasn't cleaned up.

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/34
2022-07-05 01:59:26 -04:00
Aravinth Manivannan 9303ea59ed fix: s/EMAIL_SENDER_ADDRESS/DEFAULT_FROM_EMAIL/
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-07-04 12:42:02 -04:00
Aravinth Manivannan eb68b1e984 feat: Gitea root creds email with nicer content 2022-07-04 12:42:02 -04:00
Aravinth Manivannan 5e5ce02759 feat: instance created notification email template with nicer body 2022-07-04 12:42:02 -04:00
Aravinth Manivannan 63f4f987a9 feat: verification link email template with polished email body 2022-07-04 12:42:02 -04:00
Aravinth Manivannan 4a1c0a5cdc feat: add EMAIL_SENDER_ADDRESS to settings.py 2022-07-04 12:42:02 -04:00
Aravinth Manivannan 280807d96c
fix: rm dummy section
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
NOTE
    The dummy section is commented out for future reference.

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/27
2022-07-04 13:55:28 +05:30
Aravinth Manivannan e79fb65cdf
fix: rm WIP services from homepage
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline was successful Details
fixes: https://gitea.hostea.org/Hostea/dashboard/issues/28
2022-07-04 13:44:43 +05:30
Aravinth Manivannan c72773fc9e Merge pull request 's/ammount/amount/' (#22) from dachary/dashboard:wip-typo into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/22
2022-07-03 16:39:49 -04:00
Aravinth Manivannan 8e7a11b9a4 Merge pull request 'Revert "enough hoste delete is not idempotent"' (#21) from dachary/dashboard:wip-delete into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/21
2022-07-03 16:38:34 -04:00
Loïc Dachary ed0186912d
s/ammount/amount/
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-07-03 15:01:09 +02:00
Loïc Dachary 7c045b12d5
Revert "enough hoste delete is not idempotent"
ci/woodpecker/pr/woodpecker Pipeline failed Details
Fixes: #18

This reverts commit ccec1262f0.
2022-07-03 14:58:27 +02:00
Aravinth Manivannan 5397e38d22 Merge pull request 'enough host delete is not idempotent' (#20) from dachary/dashboard:wip-delete into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/20
2022-07-02 10:48:01 -04:00
Loïc Dachary eefa2120a9
enough host delete is not idempotent
ci/woodpecker/pr/woodpecker Pipeline failed Details
pretend it is until
https://lab.enough.community/main/infrastructure/-/issues/359 is resolved
2022-07-02 16:43:51 +02:00
Aravinth Manivannan ccfa81ce2c Merge pull request 'feat: notify user on instance creation' (#14) from wip-instance-ready-notify into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/14
2022-07-02 03:42:34 -04:00
Aravinth Manivannan 7464604928 Merge pull request 'feat: configurable VM base domain and customer Gitea and Woodpecker URI generators' (#12) from wip-hostea-domain into master
ci/woodpecker/push/woodpecker Pipeline was successful Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/12
2022-07-02 03:42:15 -04:00
Aravinth Manivannan 4a47543a0f
hostfix: use default source code link when settings.py doesn't provide one
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-07-01 20:59:10 +05:30
Aravinth Manivannan e5ebdc29ce
feat: CI: replace fleet repo remote URI with remote URI template
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-07-01 19:57:22 +05:30
Aravinth Manivannan f86dd2ff37
feat: delete_vm takes only one parameter 2022-07-01 19:54:20 +05:30
Aravinth Manivannan 8fc20d16be
feat: ues separate fleet repo for each unit test 2022-07-01 19:54:15 +05:30
Aravinth Manivannan bbcd373fe4
feat: redirect to VM deletion post sudo authentication 2022-07-01 19:53:00 +05:30
Aravinth Manivannan 412230bd99
feat: create repo and add deploy key util 2022-07-01 19:52:10 +05:30
Aravinth Manivannan 8be1e02a21
feat: load footer ctx in all templates
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-30 14:37:49 +05:30
Aravinth Manivannan faca7286b7
feat: load Dashboard source code repository URL from settings 2022-06-30 14:33:56 +05:30
Aravinth Manivannan 9d89bc071c
fet: show Gitea and woodpecker URIs in view instance page 2022-06-30 13:05:57 +05:30
Aravinth Manivannan f00746a36d
feat: notify user on instance creation
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-30 01:10:55 +05:30
Aravinth Manivannan 53ec0a3982
fix: woodpecker and gitea construction typo
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-30 01:08:22 +05:30
Aravinth Manivannan fc5a23e60a
feat: configurable VM base domain and customer Gitea and Woodpecker URI generators
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
SUMMARY

    The hostnames are partially generated by the Dashboard and
    enough.community.

    The stuff between angle brackets(`<>`) are substituted and filled-in
    by the Dashboard and the stuff between parenthesis(`{}`) are
    substituted and filled-in by enough.

URI FORMAT

    Gitea URI
	<vm-name>.{{ domain }}

    Woodpecker URI
	<vm-name>-ci.{{ domain }}

    Where domain is agreed to be equal to HOSTEA_DOMAIN

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/11
2022-06-29 11:05:33 +05:30
Aravinth Manivannan e4c418b45b
feat: show Gitea admin's login credentials and send creds via email to admin
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-29 00:49:58 +05:30
Aravinth Manivannan 1bab17193c
fix: sender's email addres typo in send_verification_email 2022-06-29 00:49:35 +05:30
Aravinth Manivannan b123bfa582
feat: grab commit ID after add_vm execution
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-29 00:27:47 +05:30
Aravinth Manivannan 5ec87c83ec
feat: redirect user after successful payments for VM creation 2022-06-29 00:22:49 +05:30
Aravinth Manivannan e63719764a
fix: idempotency: change configuration in fleet repository too, when vm
ci/woodpecker/push/woodpecker Pipeline was successful Details
create is re-run for the same VM with different configuration

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/8
2022-06-28 23:57:02 +05:30
Aravinth Manivannan f7c0e8e296
chore: auto-cleanup ~/.ssh/known_hosts 2022-06-28 23:49:24 +05:30
Aravinth Manivannan 49ae2189d4
feat & fix: make vm create/rm commands idempotent
SUMMARY
    Commands are now tolerant to being invoked twice.

    Command: vm create
	Doesn't fail if VM of same name exists with the same
	configuration

	Doesn't fail if VM of the same name and different configuration
	exist. Updates configuration and deploys(pushes to Hostea/fleet
	repository) new configuration.

    Command: vm delete
	Doesn't fail if VM of given name doesn't exist
2022-06-28 20:54:21 +05:30
Aravinth Manivannan 8baefeb413
fix: don't save Gitea admin passwords in DB 2022-06-28 20:53:56 +05:30
Aravinth Manivannan e4a7310c79
feat & fix: don't append enough commands to hostscript and fix service.yml path
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-28 14:47:21 +05:30
Aravinth Manivannan d4ab0156a7 Merge pull request 'gitea runs on port 22, ssh on port 2222' (#10) from dachary/dashboard:wip-service into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/10
2022-06-28 04:53:23 -04:00
Loïc Dachary 1eaa22b330
gitea runs on port 22, ssh on port 2222
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-28 10:50:17 +02:00
Aravinth Manivannan 947479fc31
fix: enough remove command was hardcoded; fixed with customizable param
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-28 13:49:43 +05:30
Aravinth Manivannan f68d051432
fix: provision.yml template error
ci/woodpecker/push/woodpecker Pipeline failed Details
ref: https://gitea.hostea.org/Hostea/dashboard/issues/9
2022-06-28 13:37:02 +05:30
Aravinth Manivannan caadd0783a
fix: hostscript path
ci/woodpecker/push/woodpecker Pipeline failed Details
fixes: https://gitea.hostea.org/Hostea/dashboard/issues/7
2022-06-28 12:00:47 +05:30
Aravinth Manivannan 71d4f793ba
feat: docs: vm management commands
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-28 01:31:42 +05:30
Aravinth Manivannan 927c2a7703
feat: vm delete management command
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-28 01:24:43 +05:30
Aravinth Manivannan 0606c4ade0
feat: vm create management command 2022-06-28 00:57:25 +05:30
Aravinth Manivannan d84021915f
fix: typo 2022-06-28 00:56:34 +05:30
Aravinth Manivannan 026a1a4c12
chore: refactor infrastructure/views.py; move VM creation to utility fn 2022-06-27 20:59:24 +05:30
Aravinth Manivannan 2dc1740aac
chore: refactor instance creation view 2022-06-27 20:43:02 +05:30
Aravinth Manivannan 3318ca8da2
feat: infrastructure tests: check if commits are pushed to remote
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-27 04:53:52 +05:30
Aravinth Manivannan 8640fcf449
fix: ssh deploy key perms
ci/woodpecker/push/woodpecker Pipeline was successful Details
2022-06-27 04:43:28 +05:30
Aravinth Manivannan f99420f51a
fix: ci gitea port
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-27 04:40:41 +05:30
Aravinth Manivannan 8997d0ff0f
chore: cleanup config override in infrastructure/tests.py
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-27 04:37:56 +05:30
Aravinth Manivannan ca8ffba55e
feat: setup CI to use CI gitea 2022-06-27 04:36:56 +05:30
Aravinth Manivannan 89d8206c34
feat: CI: load deploy key from oenv var 2022-06-27 04:36:23 +05:30
Aravinth Manivannan 9d6c33f194
feat: add infrastructure env vars 2022-06-27 04:35:45 +05:30
Aravinth Manivannan 0c0bb1bed7
feat: refactor and create commit 0 on fleet repository 2022-06-27 04:30:25 +05:30
Aravinth Manivannan 51b047fe40
fix: ssh port. server is started at 22 2022-06-27 04:30:12 +05:30
Aravinth Manivannan 6388e4de10
feat: CI local_settings.py loads config from env vars
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-27 02:44:49 +05:30
Aravinth Manivannan 243880f6cc
feat: re-enable gitea service in docker-compose and CI env setup 2022-06-27 02:43:16 +05:30
Aravinth Manivannan 9c64690c12
feat: add test deployment keys 2022-06-26 04:52:01 +05:30
Aravinth Manivannan a4a34194f3
feat: add deploy key client implementation 2022-06-26 04:51:29 +05:30
Aravinth Manivannan 0dac5121fd
feat: enable gitea service and mv vars to separate script 2022-06-26 04:19:02 +05:30
Aravinth Manivannan 6115e734e6
feat: pkg docker 2022-06-26 04:19:02 +05:30
Aravinth Manivannan 442dd921a8
debug: is dashboard accessible? 2022-06-26 04:19:02 +05:30
Aravinth Manivannan 4996e33cad
debug: is maildev accessible? 2022-06-26 04:19:02 +05:30
Aravinth Manivannan b3ffe8c739
fix: run only tests only once 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 28fe03b861
fix: run wget in quiet mode and adapt MAILDEV_URL based on environment 2022-06-26 04:19:01 +05:30
Aravinth Manivannan fb5267a13b
hotfix: run gitea on host 2022-06-26 04:19:01 +05:30
Aravinth Manivannan f019039497
hot fix: disable gitea service 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 3c33d10ca0
fix: mv test dependency services to woodpecker config file 2022-06-26 04:19:01 +05:30
Aravinth Manivannan da318beb58
chore: install docker-compose in CI 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 997e772195
fix: woodpecker config file syntax 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 798a2f03d9
feat: integration testing
SUMMARY
    integration/__main__.py is a CLI-based HTTP client that can interact
    with Hostea Dashboard and Gitea.

    Integration tests are run via integration/tests.sh, which is a
    driver for the HTTP client at integration/__main__.py. The script is
    capable of spinning up a test environment consisting of services
    defined in docker-compose-dev-deps.yml and the Hostea Dashboard and
    tearing it down after a successful run.

    The credentials used to create various accounts and other parameters
    are all defined in integration/tests.sh script it self. So it is
    self contained.

CLIENT FUNCTIONALITY:

    HOSTEA DASHBOARD:
	 - register user with email verification
	 - login
	 - create OIDC app
	 - visit support page

    GITEA:
	 - Install Gitea(DB configuration, etc. The first form that's
	 presented to the visitor after a new instance is deployed)
	 - Register User
	 - Login User
	 - Create repository
	 - Configure OIDC SSO
	 - Login via SSO
2022-06-26 04:19:01 +05:30
Aravinth Manivannan 3fb756bd12
feat: use local gitea instance for hostea support in dev 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 2defc2d804
chore: run dependency services in test script via docker-compose-dev-deps 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 08457c8bb2
feat: define dev env with smtp and gitea services 2022-06-26 04:19:01 +05:30
Aravinth Manivannan 4b20f9a439
feat: Dashboard-Gitea SSO integration test script
STEPS
    1. Register new user on dashboard

    2. Confirm user email, link is received from email. maildev/maildev
       is an SMTP server specifically built for testing emails locally.
       It comes with a REST API[0], which is used to access emails

    3. Sign in to Dashboard

    4. Visit /support/new/ on dashboard to raise new support request

    5. Redirection to Hostea Gitea support repository is done via
       JavaScript, so we simply test to see if the support repository's
       new issue page is present in the Dashboard response

    6. Go to support repository's new issue page. Gitea will redirect to
       sign in page

    7. Parse sign in page, find OIDC SSO link in sign in page

    8. Visit OIDC SSO link in sign in page, to be redirected to
       authorization page

    9. If OIDC integration on Dashboard is setup via `create_oidc`
       management command, then auto-authorization will be enabled for
       the integration. So user will be redirected to Gitea

    10. For new OIDC logins, Gitea will present a form to choose
	preferred username and enter email address. So fill that form
	and submit it.

	Please note the form submits to a different URL than the one at
	which the form is available. See `Gitea.__link_acount` and
	`Gitea.__link_acount_signup` and its usage in `Gitea._sso_login`

    11. Verify user creation by GET /{username}, should respond HTTP 200

    12. Visit new issue on support repository, should respond HTTP 200

RESOURCES
    [0]: https://github.com/maildev/maildev/blob/master/docs/rest.md
2022-06-26 04:19:01 +05:30
Aravinth Manivannan 4542389df8
fix: import vars defined in local_settings.py
ci/woodpecker/push/woodpecker Pipeline failed Details
fixes https://gitea.hostea.org/Hostea/dashboard/issues/5
2022-06-26 04:17:26 +05:30
Aravinth Manivannan 3378e61606
hotfix: use dashboard/local_settings.py instead of env vars
ci/woodpecker/pr/woodpecker Pipeline failed Details
ci/woodpecker/push/woodpecker Pipeline failed Details
My env var loading technique is not allowing local_settings.py to
override settings.py. This hotfix disables env vars in favor of
local_settings.py.

fixes https://gitea.hostea.org/Hostea/dashboard/issues/3
2022-06-26 01:49:32 +05:30
Aravinth Manivannan e7446dea2b Merge pull request 'allow overriding STRIPE_* with local_settings.py' (#2) from dachary/dashboard:wip-settings into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/2
2022-06-25 15:59:53 -04:00
Loïc Dachary adcdd00179
allow overriding STRIPE_* with local_settings.py
ci/woodpecker/pr/woodpecker Pipeline failed Details
Without a default value it will fail before it gets a chance to be
overriden by local_settings.py

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-06-25 21:29:04 +02:00
Aravinth Manivannan b387e44f49
feat: docs: local_settings.example.py
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-25 18:56:54 +05:30
Aravinth Manivannan 0d6968ff0a
feat: custom parameters template
ci/woodpecker/push/woodpecker Pipeline failed Details
2022-06-25 18:42:42 +05:30
Aravinth Manivannan a39bcdb7b8
feat: disable i8ln as it's not implemented yet
> Django’s internationalization hooks are on by default, and that means
there’s a bit of i18n-related overhead in certain places of the
framework. If you don’t use internationalization, you should take the
two seconds to set USE_I18N = False in your settings file. Then Django
will make some optimizations so as not to load the internationalization
machinery. # Please enter the commit message for your changes. Lines
starting

ref: https://docs.djangoproject.com/en/4.0/topics/i18n/translation/
2022-06-25 18:42:41 +05:30
Aravinth Manivannan f0ee46e045 Merge pull request 'Backend integration' (#1) from wip-enough into master
ci/woodpecker/push/woodpecker Pipeline failed Details
Reviewed-on: https://gitea.hostea.org/Hostea/dashboard/pulls/1

Merge approval received on chat
2022-06-25 09:11:56 -04:00
Aravinth Manivannan 26b7ea3ef2
fix & rm: create/rm hostscripts, rm HOSTEA_DOMAIN
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-25 18:24:52 +05:30
Aravinth Manivannan 871a05ddd3
feat: payment check before creation and save gitea passwd in DB
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-25 18:03:04 +05:30
Aravinth Manivannan beb4b29c49
feat: pass template configuration, map VM sizes, generate secrets
return gitea passwd, git pull before writing and push after add/rm
2022-06-25 18:02:03 +05:30
Aravinth Manivannan 9af5361f63
feat: load local settings 2022-06-25 18:01:39 +05:30
Aravinth Manivannan 922d0c5f81
fix: don't create additional configuration opts 2022-06-25 18:01:08 +05:30
Aravinth Manivannan 94aad8e6ea
fix: templates: load user credentials and pass dynamic configuration.
Also escape curly braces
2022-06-25 18:00:13 +05:30
Aravinth Manivannan 80d6664f0d
feat: load VM domain from settings 2022-06-25 17:57:23 +05:30
Aravinth Manivannan ec49caa973
feat: payment status checking util 2022-06-25 16:27:43 +05:30
Aravinth Manivannan e688528fa3
fix: check payment status on instance level, used to be user level 2022-06-25 16:27:26 +05:30
Aravinth Manivannan 9f55a8ced7
feat: docs: hostea infrastructure config parameters
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-06-24 20:38:37 +05:30
Aravinth Manivannan f3324579c9
feat: utilities to add and remove VM on the Hostea repo 2022-06-24 20:35:32 +05:30
Aravinth Manivannan 1a234d402f
feat: init templates from enough docs[0]
[0]: https://enough-community.readthedocs.io/en/latest/services/hostea.html
2022-06-24 20:35:00 +05:30
Aravinth Manivannan ff8a21d9dc
feat: bootstrap infrastructure app with create_instance delete_instance
views
2022-06-24 20:34:11 +05:30
Aravinth Manivannan 04ec4037a9
feat: init gitpython and infrastructure app 2022-06-24 20:33:32 +05:30
153 changed files with 4213 additions and 225 deletions

148
.dockerignore Normal file
View File

@ -0,0 +1,148 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
databse.db
.env
node_modules/
dist/
tmp/
northstar.db
instance
northstar/static/docs/openapi

View File

@ -1,5 +1,7 @@
export DATABASE_URL="" export DATABASE_URL=""
export db="" export db=""
export OIDC_RSA_PRIVATE_KEY=""
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_META_FORGEJO_INSTANCE="http://localhost:3000"
export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"

2
.gitignore vendored
View File

@ -153,3 +153,5 @@ cython_debug/
#.idea/ #.idea/
keys keys
htmlcov/ htmlcov/
tmp/
static/

View File

@ -3,13 +3,16 @@ pipeline:
image: python image: python
environment: environment:
- DATABSE_URL=postgres://postgres:password@database:5432/postgres - DATABSE_URL=postgres://postgres:password@database:5432/postgres
- EMAIL_URL=smtp://admin:password@localhost:10025 - EMAIL_URL=smtp://admin:password@smtp:10025
- HOSTEA_INFRA_HOSTEA_REPO_REMOTE=ssh://git@forgejo:22/hostea/
- HOSTEA_META_FORGEJO_INSTANCE=http://forgejo:3000
commands: commands:
- export HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY="$(realpath ./tests/fleet-deploy-key)"
- pip install virtualenv - pip install virtualenv
- make env - make env
- make lint - make lint
- make test
- make coverage - make coverage
# - make integration-test
secrets: [ STRIPE_PUBLIC_KEY, STRIPE_SECRET_KEY ] secrets: [ STRIPE_PUBLIC_KEY, STRIPE_SECRET_KEY ]
services: services:
@ -18,9 +21,15 @@ services:
environment: environment:
- POSTGRES_PASSWORD=password - POSTGRES_PASSWORD=password
forgejo:
image: codeberg.org/forgejo/forgejo:1.18.0-1
container_name: hostea-dash-forgejo
smtp: smtp:
image: maildev/maildev image: maildev/maildev:latest
container_name: hostea-dash-maildev
environment: environment:
- MAILDEV_SMTP_PORT=10025 - MAILDEV_SMTP_PORT=10025
- MAILDEV_WEB_PORT=1080
- MAILDEV_INCOMING_USER=admin - MAILDEV_INCOMING_USER=admin
- MAILDEV_INCOMING_PASS=password - MAILDEV_INCOMING_PASS=password

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python
LABEL org.opencontainers.image.source https://forgejo.hostea.org/Hostea/dashboard
RUN useradd -ms /bin/bash -u 1001 hostea
RUN apt-get update && apt-get install -y ca-certificates git
USER hostea
WORKDIR /home/hostea
run mkdir app/
WORKDIR /home/hostea/app/
RUN pip3 install virtualenv
RUN python3 -m virtualenv venv
COPY requirements.txt .
# See https://github.com/pypa/pip/issues/9819
RUN ./venv/bin/pip install --use-feature=in-tree-build -r requirements.txt
COPY . .
#ENV . ./venv/bin/activate && make env
CMD [ "./venv/bin/python3", "manage.py", "runserver", "0.0.0.0:8000" ]

View File

@ -9,6 +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 . ./venv/bin/activate && python manage.py runserver
coverage: ## Generate test coverage report coverage: ## Generate test coverage report
@ -25,6 +26,8 @@ docker: ## Build Docker image from source
env: ## Install all dependencies 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 && 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
@ -32,12 +35,11 @@ freeze: ## Freeze python dependencies
help: ## Prints help for targets with comments help: ## Prints help for targets with comments
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
integration-test: ## run integration tests
. ./venv/bin/activate && integration/tests.sh
lint: ## Run linter lint: ## Run linter
@./venv/bin/black ./dashboard/* @./venv/bin/black dashboard accounts dash support billing infrastructure integration
@./venv/bin/black ./accounts/*
@./venv/bin/black ./dash/*
@./venv/bin/black ./support/*
@./venv/bin/black ./billing/*
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.hostea.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.hostea.org/Hostea/dashboard) [![status-badge](https://woodpecker.gna.org/api/badges/Hostea/dashboard/status.svg)](https://woodpecker.gna.org/Hostea/dashboard)
[![AGPL License](https://img.shields.io/badge/license-AGPL-blue.svg?style=flat-square)](http://www.gnu.org/licenses/agpl-3.0) [![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

@ -0,0 +1,52 @@
# Generated by Django 4.0.3 on 2022-07-10 06:14
import accounts.utils
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("accounts", "0005_accountconfirmchallenge_created_at"),
]
operations = [
migrations.CreateModel(
name="PasswordResetChallenge",
fields=[
(
"public_ref",
models.CharField(
default=accounts.utils.gen_secret,
editable=False,
max_length=32,
unique=True,
verbose_name="Public referece to challenge text",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"challenge_text",
models.CharField(
default=accounts.utils.gen_secret,
editable=False,
max_length=32,
primary_key=True,
serialize=False,
unique=True,
verbose_name="Challenge text",
),
),
(
"owned_by",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -52,3 +52,36 @@ 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,24 +1,23 @@
{% extends "common/components/base.html" %} {% extends "common/components/base.html" %}
{% block title %}{% block title_name %} {% endblock %} | Hostea Dashbaord{% endblock %} {% block title %}{% block title_name %} {% endblock %} | Gna! Dashboard{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} {% block 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>Free Forge Ecosystem for Free Developers</h1> <h1><a href="https://forgejo.org">Forgejo</a> hosting and <a href="/forgejo-clinic/">service</a></h1>
<p class="welcome"> <p class="welcome">
Hostea is a self-hostable libre software development suite comprising Gitea, Woodpecker CI, Librepages and GitPad with payments integration. A free forge ecosystem for free developers.
</p> </p>
<ul class="index-banner__features-list"> <ul class="index-banner__features-list">
<li class="index-banner__features">Fully managed</li> <li class="index-banner__features">Dedicated <a href="https://hosteadashboard.gna.org/register/">Forgejo hosting</a> and <a href="https://woodpecker-ci.org/">Woodpecker CI</a> from 10€/month</li>
<li class="index-banner__features">100% Free Software</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">Fully Self-Hostable</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">Observable and reliable</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">Federation when available</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">Radically transparent</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">Horizontal community</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">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="/forgot-password">Forgot password?</a> <a href="{% url 'accounts.password.reset.new' %}">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 Hostea? New to Gna!?
<a href="{% url 'accounts.register' %}">Create an account</a> <a href="{% url 'accounts.register' %}">Create an account</a>
</p> </p>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "common/components/base.html" %}
{% block title %} Reset Password| Gna! Dashboard{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %}
<div class="dialogue-box__container">
<h2>Reset password</h2>
<form
action="{% url 'accounts.password.reset.new' %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<label class="form__label" for="email">
Email
<input
class="form__input"
name="email"
id="email"
type="email"
/>
</label>
<div class="form__action-container">
<button class="form__submit" type="submit">Send Password Reset Link</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "common/components/base.html" %}
{% block title %} Reset Password | Gna! Dashboard{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %}
<div class="dialogue-box__container">
<h2>Reset password</h2>
<p>Verification link is sent to email address: {{email}}</p>
<form
action="{% url 'accounts.password.reset.resend' public_ref=public_ref %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<div class="form__action-container">
<button class="form__submit" type="submit">Click here to resend email</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "common/components/base.html" %}
{% block title %} Reset Password | Gna! Dashboard{% endblock %}
{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %}
{% block main %}
<div class="dialogue-box__container">
<h2>Reset Password</h2>
<form
action="{% url 'accounts.password.reset' challenge=challenge %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<label class="form__label" for="password">
password
<input
class="form__input"
name="password"
required
id="password"
type="password"
/>
</label>
<label class="form__label" for="confirm_password">
Re-enter Password
<input
class="form__input"
name="confirm_password"
required
id="confirm_password"
type="password"
/>
</label>
<div class="form__action-container">
<button class="form__submit" type="submit">Reset Password</button>
</div>
</form>
</div>
{% endblock %}

View File

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

@ -0,0 +1,14 @@
Hello {{ username }},
You have a new password!
Your password for signing in to Gna! was recently changed. If you made this change, then we're all set.
If you did not make this change, please reset your password to secure your account.
{% url 'accounts.password.reset.new' %}
Either way, feel free to reach out with any questions you might have. We're here to help.
Cheers,
Gna! team

View File

@ -0,0 +1,9 @@
Hello {{ email }},
Please click on the link below to reset your password:
{{ link }}
If you don't recognise this activity, please delete this mail.
Cheers,
Gna! team

View File

@ -0,0 +1,9 @@
Hello {{ username }},
Please click on the link below to verify your email.
{{ link }}
If you don't recognise this activity, please delete this mail.
Cheers,
Gna! team

View File

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

View File

@ -3,11 +3,8 @@
<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 src="{% static 'img/android-icon-48x48.png' %}" <img class="nav__logo-img" src="{% static 'img/logo.png' %}"
alt="Hostea temporary logo"/> alt="Gna! 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 from .models import AccountConfirmChallenge, PasswordResetChallenge
from .management.commands.rm_unverified_users import ( from .management.commands.rm_unverified_users import (
Command as CleanUnverifiedUsersCommand, Command as CleanUnverifiedUsersCommand,
) )
@ -77,7 +77,9 @@ 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(b"Free Forge Ecosystem" in resp.content, True) self.assertEqual(
b"A free forge ecosystem for free developers" in resp.content, True
)
def test_login_works(self): def test_login_works(self):
""" """
@ -158,6 +160,71 @@ 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"
@ -169,7 +236,9 @@ 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(b"Free Forge Ecosystem" in resp.content, True) self.assertEqual(
b"A free forge ecosystem for free developers." in resp.content, True
)
def test_register_works(self): def test_register_works(self):
""" """

View File

@ -25,6 +25,9 @@ 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 = [
@ -44,5 +47,20 @@ 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,6 +17,7 @@ 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
@ -29,17 +30,78 @@ 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="[Hostea] Please confirm your email address", subject="[Gna!] Please confirm your email address",
message=f"Please confirm your email address {email}.\n {verification_link}", message=body,
from_email="No reply Hostea<ro-reply@exampl.org>", # TODO read from settings.py from_email=f"No reply Gna!<{sender}>",
recipient_list=[email], recipient_list=[email],
) )

View File

@ -23,9 +23,10 @@ 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 from .models import AccountConfirmChallenge, PasswordResetChallenge
from .utils import send_verification_email, ConfirmAccess from .utils import send_verification_email, ConfirmAccess, send_password_reset_email
from .decorators import redirect_if_authenticated from .decorators import redirect_if_authenticated
@ -35,6 +36,7 @@ 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":
@ -102,6 +104,7 @@ 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":
@ -213,6 +216,7 @@ 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":
@ -238,3 +242,107 @@ 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

@ -0,0 +1,69 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
from django.core.exceptions import ValidationError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.contrib.auth import get_user_model
from oauth2_provider.models import get_application_model
from oauth2_provider.generators import generate_client_id, generate_client_secret
from accounts.utils import gen_secret
from dash.models import Instance
from infrastructure.models import InstanceCreated
from billing.utils import generate_invoice, payment_fullfilled, get_invoice_link
Application = get_application_model()
class Command(BaseCommand):
help = "Generate invoices, should be run from cronjob scheduled for daily execution"
def handle(self, *args, **options):
instances = Instance.objects.all()
if instances:
for paid_instance in InstanceCreated.objects.all():
self.stdout.write(f"Found instance: {paid_instance.instance}")
if not payment_fullfilled(instance=paid_instance.instance):
self.stdout.write(
f"Payment not fulfilled for instance: {paid_instance.instance}"
)
payment = generate_invoice(instance=paid_instance.instance)
owner = paid_instance.instance.owned_by
ctx = {
"username": owner.username,
"payment": payment,
"link": get_invoice_link(payment=payment),
}
body = render_to_string(
"billing/emails/renew-subscription.txt",
context=ctx,
)
email = owner.email
sender = settings.DEFAULT_FROM_EMAIL
send_mail(
subject="[Gna!] Payment receipt for your Gna! VM",
message=body,
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
recipient_list=[email],
)
else:
self.stdout.write("No instances available")

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.3 on 2022-07-08 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0004_payment_billing_pay_paid_by_77f57c_idx"),
]
operations = [
migrations.AddField(
model_name="payment",
name="vm_deleted",
field=models.BooleanField(default=False),
),
]

View File

@ -67,6 +67,8 @@ 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

@ -0,0 +1,14 @@
Hello {{ username }}!
An invoice is generated for your Gna! VM {{ payment.instance_name }}.
- Configuration: {{payment.instance_configuration_id.name}}
- Invoice generated on: {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}
- Total Amount: {{payment.total}} {{payment.currency|upper}}
To pay, please click the link below:
{{ link }}
Cheers,
Gna! team

View File

@ -0,0 +1,22 @@
Hello {{ username }}!
This is a receipt for your latest Gna! payment.
-----------------------------------------------------
Gna! Receipt - {{payment.date.month}}/{{payment.date.day}}/{{payment.date.year}}
- Instance Name: {{ payment.instance_name }}
- Configuration: {{payment.instance_configuration_id.name}}
- Total Amount: {{payment.total}} {{payment.currency|upper}}
To view the receipt online, please see the following link:
{{link}}
We appreciate your business!
Cheers,
Gna! team

View File

@ -0,0 +1,17 @@
Hello {{ username }}!
Your Gna! VM subscription is due for renewal. Please click the link
below to renew your subscription:
{{link}}
-----------------------------------------------------
- Instance Name: {{ payment.instance_name }}
- Configuration: {{payment.instance_configuration_id.name}}
- Total Amount: {{payment.total}} {{payment.currency|upper}}
We appreciate your business!
Cheers,
Gna! team

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 Ammount</strong>: {{payment.total}} {{payment.currency|upper}}</li> <li class="list-instance__item"><strong>Total Amount</strong>: {{payment.total}} {{payment.currency|upper}}</li>
{% if payment.status == "confirmed" %} {% 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,23 @@ 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 from dash.tests import create_configurations, create_instance_util, infra_custom_config
from dash.models import Instance
from .models import Payment from .models import Payment
from .utils import payment_fullfilled
class BillingTest(TestCase): class BillingTest(TestCase):
@ -41,47 +45,21 @@ 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 = "test_create_instance_renders" instance_name = "tpayments"
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]
) )
payment_uri = reverse("billing.invoice.generate", args=(instance_name,)) instance = Instance.objects.get(name=instance_name)
# generate invoice self.assertEqual(payment_fullfilled(instance=instance), True)
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)
# simulate payment. There's probably a better way to do this
payment = get_payment_model().objects.get(paid_by=self.user) payment = get_payment_model().objects.get(paid_by=self.user)
payment.status = PaymentStatus.CONFIRMED invoice_uri = reverse("billing.invoice.details", args=(payment.public_ref,))
payment.save()
# 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"))
@ -101,15 +79,147 @@ 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)
## payment success page; no real functionality but user is redirected here ## payment success 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.success", args=(payment.public_ref,))) resp = c.get(reverse("billing.invoice.success", args=(payment.public_ref,)))
self.assertEqual(b"success" in resp.content, True) self.assertEqual(
resp.headers["Location"],
reverse("infra.create", args=(payment.instance_name,)),
)
# create_instance_util creates an instance and pays for it. An email is
# sent when the invoice is generated and one after payment is made
#
# So we are first checking for the last email that was sent(receipt)
# and then the Forgejo instance credentials notification followed by the
# invoice generation email.
receipt_mail = mail.outbox.pop()
print(receipt_mail.body)
self.assertEqual(
all(
[
receipt_mail.to[0] == self.email,
"This is a receipt for your latest Gna! payment"
in receipt_mail.body,
]
),
True,
)
instance_notificaiton = mail.outbox.pop()
self.assertEqual(
all(
[
instance_notificaiton.to[0] == self.email,
"Congratulations on your new Gna! instance!"
in instance_notificaiton.body,
]
),
True,
)
## payment failure page; no real functionality but user is redirected here ## 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))

101
billing/utils.py Normal file
View File

@ -0,0 +1,101 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum, unique
from urllib.parse import urlparse, urlunparse
from datetime import datetime, timedelta, timezone
from payments import get_payment_model, RedirectNeeded, PaymentStatus
from django.core.mail import send_mail
from django.urls import reverse
from django.template.loader import render_to_string
from django.contrib.auth.models import User
from django.conf import settings
from django.shortcuts import get_object_or_404
from dash.models import Instance
Payment = get_payment_model()
def __get_delta():
now = datetime.now(tz=timezone.utc)
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month
return delta
def get_invoice_link(payment: Payment):
invoice_link = reverse("billing.invoice.details", args=(payment.public_ref,))
parsed = urlparse(settings.PAYMENT_HOST)
return urlunparse((parsed.scheme, parsed.netloc, invoice_link, "", "", ""))
def payment_fullfilled(instance: Instance) -> bool:
delta = __get_delta()
payment = None
for p in Payment.objects.filter(
date__gt=(delta), instance_name=instance.name, vm_deleted=False
):
if p.status == PaymentStatus.CONFIRMED:
return True
return False
@unique
class GenerateInvoiceErrorCode(Enum):
ALREADY_PAID = "already paid"
DUPLICATE_PAYMENT = "DUPLICATE PAYMENT"
def __str__(self) -> str:
return self.name
class GenerateInvoiceException(Exception):
error: str
code: GenerateInvoiceErrorCode
def __init__(self, code: GenerateInvoiceErrorCode):
self.error = str(code)
self.code = code
def __str__(self):
return self.error
def generate_invoice(instance: Instance) -> Payment:
delta = __get_delta()
payment = None
for p in Payment.objects.filter(
date__gt=(delta), instance_name=instance.name, vm_deleted=False
):
if p.status == PaymentStatus.CONFIRMED:
raise GenerateInvoiceException(code=GenerateInvoiceErrorCode.ALREADY_PAID)
if any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]):
if payment is None:
payment = p
else:
print(f"Duplicate payment {p}, deleting in favor of {payment}")
p.delete()
if payment is None:
print("Payment not found, generating new payment")
payment = Payment.objects.create(
variant="stripe", # this is the variant from PAYMENT_VARIANTS
instance=instance,
)
return payment

View File

@ -16,13 +16,25 @@ 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 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):
@ -32,6 +44,7 @@ def default_ctx(title: str, username: str):
return { return {
"title": title, "title": title,
"username": username, "username": username,
"footer": footer_ctx(),
} }
@ -63,29 +76,13 @@ 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)
Payment = get_payment_model() try:
now = datetime.now(tz=timezone.utc) payment = generate_invoice_util(instance=instance)
delta = now - timedelta(seconds=(60 * 60 * 24 * 30)) # one month return redirect(reverse("billing.invoice.details", args=(payment.public_ref,)))
except GenerateInvoiceException as e:
payment = None if e.code == GenerateInvoiceErrorCode.ALREADY_PAID:
for p in Payment.objects.filter(date__gt=(delta)):
if p.status == PaymentStatus.CONFIRMED:
return HttpResponse("BAD REQUEST: Already paid", status=400) return HttpResponse("BAD REQUEST: Already paid", status=400)
elif any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]): return redirect(reverse("dash.home"))
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
@ -115,10 +112,30 @@ 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
) )
return HttpResponse(
f"{payment.description} worth {payment.total}{payment.currency} paid via {payment.variant} is success" ctx = {
"username": request.user.username,
"payment": payment,
"link": get_invoice_link(payment=payment),
}
body = render_to_string(
"billing/emails/payment-receipt.txt",
context=ctx,
) )
email = request.user.email
sender = settings.DEFAULT_FROM_EMAIL
send_mail(
subject="[Gna!] Payment receipt for your Gna! VM",
message=body,
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
recipient_list=[email],
)
return redirect(reverse("infra.create", args=(payment.instance_name,)))
@login_required @login_required
def payment_failure(request, payment_public_id): def payment_failure(request, payment_public_id):

View File

@ -34,11 +34,9 @@ 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 {
@ -129,6 +127,10 @@ header {
text-decoration: underline; text-decoration: underline;
} }
.nav__logo-img {
height: 48px;
}
.nav__toggle { .nav__toggle {
display: none; display: none;
} }
@ -244,6 +246,8 @@ 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 {
@ -391,6 +395,8 @@ 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 {
@ -607,8 +613,6 @@ fieldset {
background-color: #e11d21; background-color: #e11d21;
} }
/* /*
.form__label { .form__label {
margin: 5px 0; margin: 5px 0;

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
common-static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

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 }}| Hostea Dashbaord</title> <title>{{ title }}| Gna! Dashboard</title>
{% include "common/components/meta.html" %} {% include "common/components/meta.html" %}
</head> </head>
<body> <body>

View File

@ -1,36 +1,41 @@
{% load static %} {% load static %}
<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="/"> <a class="nav__logo-container" href="https://gna.org">
<img <img alt="Gna! logo" class="nav__logo-img" src="{% static 'img/logo.png' %}" />
src="{% static 'img/android-icon-48x48.png' %}" </a>
alt="Hostea temporary logo" <label class="nav__hamburger-menu" for="nav__toggle">
/> <span class="nav__hamburger-inner"></span>
<p class="nav__home-btn">ostea</p> </label>
</a> </div>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div> <div class="nav__spacer"></div>
<div class="nav__link-group"> <div class="nav__link-group">
<div class="nav__link-container"> <div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{% url 'accounts.login' %}" <a
>Add Instance</a class="nav__link"
> rel="noreferrer"
</div> href="{% url 'dash.instances.new' %}"
<div class="nav__link-container"> >Add Instance</a
<a class="nav__link" rel="noreferrer" href="{% url 'support.home' %}" >
>Support</a </div>
> <div class="nav__link-container">
</div> <a
<div class="nav__link-container"> class="nav__link"
<a class="nav__link" rel="noreferrer" href="{% url 'accounts.logout' %}" rel="noreferrer"
>Logout</a href="{% url 'support.home' %}"
> >Support</a
</div> >
</div> </div>
<div class="nav__link-container">
<a
class="nav__link"
rel="noreferrer"
href="{% url 'accounts.logout' %}"
>Logout</a
>
</div>
</div>
</nav> </nav>

View File

@ -45,8 +45,10 @@
</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,6 +12,7 @@
</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,38 +12,69 @@
# #
# 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):
t.instance_config = [ t.instance_config = [
InstanceConfiguration(name="Personal", rent=5.0, ram=0.5, cpu=1, storage=25), InstanceConfiguration.objects.get(
InstanceConfiguration(name="Enthusiast", rent=10.0, ram=2, cpu=2, storage=50), name="s1-2", rent=10, ram=2, cpu=1, storage=10
InstanceConfiguration(
name="Small Business", rent=20.0, ram=8, cpu=4, storage=64
), ),
InstanceConfiguration( InstanceConfiguration.objects.get(
name="Enterprise", rent=100.0, ram=64, cpu=24, storage=1024 name="s1-4", rent=20, ram=4, cpu=1, storage=20
),
InstanceConfiguration.objects.get(
name="s1-8", rent=40, ram=8, cpu=2, storage=40
), ),
] ]
for instance in t.instance_config: for instance in t.instance_config:
instance.save()
print(f"[*][init] Instance {instance.name} is saved") print(f"[*][init] Instance {instance.name} is saved")
t.assertEqual( t.assertEqual(
InstanceConfiguration.objects.filter(name=instance.name).exists(), True InstanceConfiguration.objects.filter(name=instance.name).exists(), True
) )
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
): ):
@ -55,6 +86,22 @@ 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):
@ -124,7 +171,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://gitea.hostea.org/Hostea/july-mvp/issues/10#issuecomment-639 ref: https://forgejo.gna.org/Hostea/july-mvp/issues/10#issuecomment-639
""" """
self.assertEqual( self.assertEqual(
InstanceConfiguration.objects.filter( InstanceConfiguration.objects.filter(
@ -151,6 +198,33 @@ 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")
@ -165,14 +239,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": "test_create_instance_renders", "name": instance_name,
"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]
) )
@ -236,12 +310,16 @@ 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(resp.headers["location"], reverse("dash.home")) self.assertEqual(
resp.headers["location"], reverse("infra.rm", args=(instance.name,))
)
resp = c.get(resp.headers["location"])
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.headers["location"], reverse("dash.instances.list"))
self.assertEqual( 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,
) )

104
dash/utils.py Normal file
View File

@ -0,0 +1,104 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum, unique
from django.contrib.auth.models import User
from django.conf import settings
from .models import Instance, InstanceConfiguration
@unique
class VmErrors(Enum):
NAME_EXISTS = "Instance name exists, please try again with a different name"
ILLEGAL_NAME = "Only alphanumeric characters are allowed in instance name"
NAME_TOO_LONG = "Instance name must be less than 20 characters"
NO_CONFIG = "Configuration doesn't exist, please try again."
def __str__(self) -> str:
return self.name
class VmException(Exception):
error: str
code: VmErrors
def __init__(self, code: VmErrors):
self.error = str(code)
self.code = code
def __str__(self):
return self.error
def sanitize_vm_name(vm_name: str) -> str:
"""
Sanity checks and normalization of the vm name
"""
vm_name = vm_name.lower()
if len(vm_name) > 20:
raise VmException(code=VmErrors.NAME_TOO_LONG)
if not str.isalnum(vm_name):
raise VmException(code=VmErrors.ILLEGAL_NAME)
if Instance.objects.filter(name=vm_name).exists():
raise VmException(code=VmErrors.NAME_EXISTS)
return vm_name
def create_instance(vm_name: str, configuration_name: str, user: User) -> Instance:
"""
Create instance view
"""
vm_name = sanitize_vm_name(vm_name)
if not InstanceConfiguration.objects.filter(name=configuration_name).exists():
raise VmException(code=VmErrors.NO_CONFIG)
configuration = InstanceConfiguration.objects.get(name=configuration_name)
instance = Instance(name=vm_name, configuration_id=configuration, owned_by=user)
instance.save()
return instance
source_code = None
def footer_ctx():
global source_code
if source_code is None:
if "SOURCE_CODE" in settings.HOSTEA:
source_code = {
"text": "Source Code",
"link": settings.HOSTEA["SOURCE_CODE"],
}
else:
link = "https://forgejo.gna.org/Hostea/dashboard"
source_code = {"text": "Source Code", "link": link}
try:
r = Repo(".")
commit = r.head.commit.hexsha
source_code["text"] = f"v-{commit.hexsha[0:8]}"
except:
pass
return {
"source_code": source_code,
"admin_email": settings.HOSTEA["INSTANCE_MAINTAINER_CONTACT"],
}

View File

@ -21,9 +21,16 @@ 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 .models import Instance, InstanceConfiguration
from accounts.decorators import confirm_access from accounts.decorators import confirm_access
from infrastructure.utils import Infra
from .models import Instance, InstanceConfiguration
from .utils import (
create_instance as create_instance_util,
VmErrors,
VmException,
footer_ctx,
)
def default_ctx(title: str, username: str): def default_ctx(title: str, username: str):
@ -34,6 +41,7 @@ def default_ctx(title: str, username: str):
"title": title, "title": title,
"username": username, "username": username,
"open_instances": "open", "open_instances": "open",
"footer": footer_ctx(),
} }
@ -73,32 +81,22 @@ def create_instance(request):
return render(request, "dash/instances/new/index.html", context=ctx) return render(request, "dash/instances/new/index.html", context=ctx)
name = request.POST["name"] name = request.POST["name"]
if Instance.objects.filter(name=name).exists():
ctx = get_ctx()
ctx["error"] = {
"title": "Can't create instance",
"reason": "Instance name exists, please try again with a different name",
}
print(ctx["error"]["reason"])
return render(request, "dash/instances/new/index.html", status=400, context=ctx)
configuration = request.POST["configuration"] configuration = request.POST["configuration"]
if not InstanceConfiguration.objects.filter(name=configuration).exists(): try:
instance = create_instance_util(
vm_name=name, configuration_name=configuration, user=request.user
)
return redirect(reverse("billing.invoice.generate", args=(instance.name,)))
except VmException as e:
ctx = get_ctx() ctx = get_ctx()
reason = e.code.value
ctx["error"] = { ctx["error"] = {
"title": "Can't create instance", "title": "Can't create instance",
"reason": "Configuration doesn't exist, please try again.", "reason": reason,
} }
print(ctx["error"]["reason"])
return render(request, "dash/instances/new/index.html", status=400, context=ctx) return render(request, "dash/instances/new/index.html", status=400, context=ctx)
configuration = get_object_or_404(InstanceConfiguration, name=configuration)
instance = Instance(
name=name, configuration_id=configuration, owned_by=request.user
)
instance.save()
return redirect(reverse("billing.invoice.generate", args=(instance.name,)))
@login_required @login_required
def list_instances(request): def list_instances(request):
@ -119,7 +117,12 @@ def view_instance(request, name: str):
instance = get_object_or_404(Instance, owned_by=user, name=name) 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)
@ -136,5 +139,4 @@ 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)
instance.delete() return redirect(reverse("infra.rm", args=(instance.name,)))
return redirect(reverse("dash.home"))

View File

@ -0,0 +1,92 @@
"""
Django settings for dashboard project.
Generated by 'django-admin startproject' using Django 4.0.3.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import environ
import os
env = environ.Env()
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# A new SECRET_KEY can be generated by running the following command:
# openssl rand -hex 32
SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
"default": env.db_url(
"DATABSE_URL", default="postgres://postgres:password@localhost:5432/postgres"
)
}
## django-payments configuration
PAYMENT_HOST = "http://localhost:8000"
PAYMENT_VARIANTS = {
"stripe": (
"payments.stripe.StripeProvider", # please don't change this
{
"secret_key": env.get_value("STRIPE_SECRET_KEY", default="UNSET"),
"public_key": env.get_value("STRIPE_PUBLIC_KEY", default="UNSET"),
},
)
}
### Dashbaord specific configuration options
HOSTEA = {
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
"ACCOUNTS": {
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
"SUDO_TTL": 60 * 5,
},
"META": {
"FORGEJO_INSTANCE": env.get_value(
"HOSTEA_META_FORGEJO_INSTANCE"
), # meta Forgejo insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support",
},
"INFRA": {
"HOSTEA_REPO": {
# where to clone the repository
"PATH": "/tmp/hostea/dashboard/infrastructure",
# Git remote URI of the repository
"REMOTE": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_REMOTE"),
# SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": env.get_value("HOSTEA_INFRA_HOSTEA_REPO_SSH_KEY"),
},
"HOSTEA_DOMAIN": "gna.org", # domain at which Hostea VMs will be spun up
},
}
# Please see EMAIL_* configuration options:
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025")
DEFAULT_FROM_EMAIL = "no-reply@gna.org"
vars().update(EMAIL_CONFIG)
print("Finished importing local_settings.ci.py")

View File

@ -0,0 +1,90 @@
"""
Django settings for dashboard project.
Generated by 'django-admin startproject' using Django 4.0.3.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
"""
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# A new SECRET_KEY can be generated by running the following command:
# openssl rand -hex 32
SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "postgres",
"USER": "postgres",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "5432",
}
}
## django-payments configuration
PAYMENT_HOST = "http://localhost:8000"
PAYMENT_VARIANTS = {
"stripe": (
"payments.stripe.StripeProvider", # please don't change this
{
"secret_key": "",
"public_key": "",
},
)
}
### Dashbaord specific configuration options
HOSTEA = {
"SOURCE_CODE": "https://forgejo.gna.org/Hostea/dashboard",
"INSTANCE_MAINTAINER_CONTACT": "contact@gna.example.org",
"ACCOUNTS": {
"MAX_VERIFICATION_TOLERANCE_PERIOD": 60 * 60 * 24, # in seconds
"SUDO_TTL": 60 * 5,
},
"META": {
"FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce
"FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support
# ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support",
},
"INFRA": {
"HOSTEA_REPO": {
# where to clone the repository
"PATH": "/srv/hostea/dashboard/infrastructure",
# Git remote URI of the repository
"REMOTE": "git@localhost:Hostea/enough.git",
# SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": "/srv/hostea/deploy",
},
"HOSTEA_DOMAIN": "",
},
}
# Please see EMAIL_* configuration options:
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
EMAIL_HOST = "localhost"
EMAIL_USE_TLS = False
EMAIL_USE_SSL = False
EMAIL_PORT = 10025
EMAIL_HOST_USER = "admin"
EMAIL_HOST_PASSWORD = "password"
DEFAULT_FROM_EMAIL = "no-reply@gna.org"

View File

@ -9,12 +9,8 @@ https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/ https://docs.djangoproject.com/en/4.0/ref/settings/
""" """
import environ
import os
from pathlib import Path from pathlib import Path
env = environ.Env()
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -28,13 +24,13 @@ SECRET_KEY = "django-insecure-44zt@)$td7_yh(01q^hrce%h(311n!djn%%#s1b7$cvfy!pf7y
# SECURITY WARNING: don't run with debug turned on in production! # 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",
@ -47,10 +43,12 @@ INSTALLED_APPS = [
"oauth2_provider", "oauth2_provider",
"payments", "payments",
"billing", "billing",
"infrastructure",
] ]
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",
@ -85,9 +83,14 @@ WSGI_APPLICATION = "dashboard.wsgi.application"
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases # https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = { DATABASES = {
"default": env.db_url( "default": {
"DATABSE_URL", default="postgres://postgres:password@localhost:5432/postgres" "ENGINE": "django.db.backends.postgresql",
) "NAME": "db-doesn't-exist",
"USER": "postgres",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "5432",
}
} }
@ -117,7 +120,9 @@ LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC" TIME_ZONE = "UTC"
USE_I18N = True # not yet implemented so disabling as it provides performance benefits
# ref: https://docs.djangoproject.com/en/4.0/topics/i18n/translation/
USE_I18N = False
USE_TZ = True USE_TZ = True
@ -125,11 +130,13 @@ 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 = [ STATICFILES_DIRS = [
BASE_DIR / "static", BASE_DIR / "common-static",
] ]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# 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
@ -154,8 +161,8 @@ PAYMENT_VARIANTS = {
"stripe": ( "stripe": (
"payments.stripe.StripeProvider", "payments.stripe.StripeProvider",
{ {
"secret_key": env.get_value("STRIPE_SECRET_KEY"), "secret_key": "",
"public_key": env.get_value("STRIPE_PUBLIC_KEY"), "public_key": "",
}, },
) )
} }
@ -164,21 +171,46 @@ 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@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": {
"GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce "FORGEJO_INSTANCE": "http://localhost:3000", # meta Forgejo insatnce
"GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17 # ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
}, },
"INFRA": {
"HOSTEA_REPO": {
# where to clone the repository
"PATH": "/srv/hostea/dashboard/infrastructure",
# Git remote URI of the repository
"REMOTE": "git@localhost:Hostea/enough.git",
# SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": "/srv/hostea/deploy",
},
"HOSTEA_DOMAIN": "vm.gna.org", # domain at which Hostea VMs will be spun up
},
} }
EMAIL_CONFIG = env.email("EMAIL_URL", default="smtp://admin:password@localhost:10025") # Please see EMAIL_* configuration options:
# https://docs.djangoproject.com/en/4.1/ref/settings/#email-host
EMAIL_HOST = "localhost"
EMAIL_USE_TLS = False
EMAIL_USE_SSL = False
EMAIL_PORT = 10025
EMAIL_HOST_USER = "admin"
EMAIL_HOST_PASSWORD = "password"
DEFAULT_FROM_EMAIL: "no-reply@gna.org"
vars().update(EMAIL_CONFIG) try:
from dashboard.local_settings import *
print("Found local_settings")
except ModuleNotFoundError:
pass

View File

@ -23,5 +23,6 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("dash/", include("dash.urls")), path("dash/", include("dash.urls")),
path("support/", include("support.urls")), path("support/", include("support.urls")),
path("infra/", include("infrastructure.urls")),
path("", include("accounts.urls")), path("", include("accounts.urls")),
] ]

View File

@ -0,0 +1,41 @@
version: "3"
#networks:
# hostea-dash-forgejo:
# external: false
# hostea-dash-smtp:
# external: false
services:
forgejo:
image: codeberg.org/forgejo/forgejo:1.18.0-1
container_name: hostea-dash-forgejo
network_mode: host
environment:
- USER_UID=1000
- USER_GID=1000
restart: always
#networks:
# - hostea-dash-forgejo
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
#ports:
# - "8080:3000"
# - "2221:22"
smtp:
image: maildev/maildev:latest
restart: always
container_name: hostea-dash-maildev
network_mode: host
#networks:
# - hostea-dash-smtp
environment:
- MAILDEV_SMTP_PORT=10025
- MAILDEV_INCOMING_USER=admin
- MAILDEV_INCOMING_PASS=password
#ports:
# - "10025:10025"
# - "1080:1080"

View File

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

View File

@ -1,5 +1,26 @@
# Installation instructions # Installation instructions
## Configuration template
[dashboard/local_settings.example.py](../dashboard/local_settings.example.py)
contains a configuration template that may be used to create production
configuration file to override [dashboard/settings.py](../dashboard/settings.py).
Please copy local_settings.example.py to local_settings.py and make
changes to the newly copied file.
## Static files
In order to serve static files, please run the following command before
running the Dashbaord server:
```bash
yes yes | python manage.py collectstatic
```
This command will gather all static assets from all the modules in
Dashbaord and place them in `static/` in the base directory.
## Cron jobs ## 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:
@ -23,8 +44,8 @@ hence the current redundancy in configuration and cronjob duration.
## Support Platform Integration ## Support Platform Integration
Hostea Dashbaord delegates support to Hostea's meta Gitea instance, as Hostea Dashbaord delegates support to Hostea's meta Forgejo instance, as
discussed [here](https://gitea.hostea.org/Hostea/july-mvp/issues/17). discussed [here](https://forgejo.gna.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`:
@ -34,10 +55,10 @@ HOSTEA = {
# <--snip---> # <--snip--->
"META": { "META": {
# <--snip---> # <--snip--->
"GITEA_INSTANCE": "https://gitea.hostea.org", # meta Gitea insatnce "FORGEJO_INSTANCE": "https://forgejo.gna.org", # meta Forgejo insatnce
"GITEA_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance "FORGEJO_ORG_NAME": "Hostea", # Organisation name on Hostea meta instance
# Repository dedicated for handling support # Repository dedicated for handling support
# ref: https://gitea.hostea.org/Hostea/july-mvp/issues/17 # ref: https://forgejo.gna.org/Hostea/july-mvp/issues/17
"SUPPORT_REPOSITORY": "support", "SUPPORT_REPOSITORY": "support",
}, },
} }
@ -72,3 +93,24 @@ PAYMENT_VARIANTS = {
) )
} }
``` ```
## Infrastructure(via [Enough](https://enough.community))
```python
HOSTEA = {
# <------snip-------->
"INFRA": {
"HOSTEA_REPO": {
# where to clone the repository
"PATH": "/srv/hostea/dashboard/infrastructure",
# Git remote URI of the repository
"REMOTE": "git@localhost:Hostea/enough.git",
# SSH key that can push to the Git repository remote mentioned above
"SSH_KEY": "/srv/hostea/deploy",
},
},
```
### References:
https://enough-community.readthedocs.io/en/latest/services/hostea.html

40
docs/management-cmd.md Normal file
View File

@ -0,0 +1,40 @@
## Manage Virtual Machines
### 1. Create VM: Creates a new VM, bypasing payments
#### Pre-requisites:
1. A registered and active/email-verified user
#### Example:
```bash
python manage.py vm create <VM-name> --owner=<owner-username> --flavor=<flavor>
# flavor=[small,medium,large]
```
This command is not idempotent. The command throws an error when a
supplied flavor is not available or when a VM with the same name exists.
### 2. Delete VM:
#### Example
```bash
python manage.py vm delete <VM-name>
```
This command is not idempotent. The command throws an error when a
a VM with the given name doesn't exist.
### 3. Generate Invoices: periodically generate invoices for VMs
```bash
python manage.py generate_invoice
```
Generates invoices for VMs which enter a new billing cycle and sends
notification email to VM owners.
This command can be run as many times as desirable but running at least
once in a day is advisable.

View File

5
infrastructure/admin.py Normal file
View File

@ -0,0 +1,5 @@
from django.contrib import admin
from .models import InstanceCreated
admin.site.register(InstanceCreated)

6
infrastructure/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class InfrastructureConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "infrastructure"

View File

@ -0,0 +1,143 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum, unique
from django.core.management.base import BaseCommand
from django.core.exceptions import ValidationError
from django.conf import settings
from django.contrib.auth import get_user_model
from oauth2_provider.models import get_application_model
from oauth2_provider.generators import generate_client_id, generate_client_secret
from dash.models import InstanceConfiguration, Instance
from dash.utils import create_instance, VmException, VmErrors
from infrastructure.utils import create_vm_if_not_exists
def translate_sizes(flavor: str):
if flavor == "small":
size = "s1-2"
elif flavor == "medium":
size = "s1-4"
elif flavor == "large":
size = "s1-8"
else:
print("flavour no match")
size = flavor
return size
@unique
class Actions(Enum):
CREATE = "create"
DELETE = "delete"
def __str__(self) -> str:
return self.name
class Command(BaseCommand):
help = "Get user ID from username"
action_key = "action"
vm_name_key = "vm_name"
flavor_key = "flavor"
owner_key = "owner"
def add_arguments(self, parser):
parser.add_argument(
self.action_key,
type=Actions,
help="VM action: create/delete",
)
parser.add_argument(
self.vm_name_key,
type=str,
help="Name of the VM",
)
parser.add_argument(
f"--{self.owner_key}",
type=str,
help="Owner username",
)
parser.add_argument(
f"--{self.flavor_key}",
type=str,
help="Name of the VM flavor: small, medium, large",
)
def create_vm(self, *args, **options):
owner = options[self.owner_key]
flavor = options[self.flavor_key]
vm_name = options[self.vm_name_key]
size = translate_sizes(flavor)
user = get_user_model().objects.get(username=owner)
try:
instance = create_instance(
vm_name=vm_name, configuration_name=size, user=user
)
(forgejo_password, _commit) = create_vm_if_not_exists(instance)
print("Instance created")
print(f"Forgejo admin password: {forgejo_password}")
except VmException as e:
if e.code == VmErrors.NAME_EXISTS:
instance = Instance.objects.get(name=vm_name)
if instance.configuration_id.name != size:
instance.configuration_id = InstanceConfiguration.objects.get(
name=size
)
instance.save()
(forgejo_password, _commit) = create_vm_if_not_exists(instance)
print("Instance created")
print(f"Forgejo admin password: {forgejo_password}")
else:
self.stderr.write(self.style.ERROR(f"error: {str(e)}"))
except Exception as e:
self.stderr.write(self.style.ERROR(f"error: {str(e)}"))
def delete_vm(self, *args, **options):
from infrastructure.utils import delete_vm
vm_name = options[self.vm_name_key]
if Instance.objects.filter(name=vm_name).exists():
instance = Instance.objects.get(name=vm_name)
delete_vm(instance=instance)
def handle(self, *args, **options):
for i in [self.action_key, self.vm_name_key]:
if i not in options:
self.stdout.write(self.style.ERROR(f"Please provide {i}"))
return
if options[self.action_key] == Actions.CREATE:
for i in [self.flavor_key, self.owner_key]:
if i not in options:
self.stdout.write(self.style.ERROR(f"Please provide {i}"))
return
self.create_vm(*args, **options)
elif options[self.action_key] == Actions.DELETE:
self.delete_vm(*args, **options)
else:
self.stdout.write(
self.style.ERROR(f"Unknown action: {options[self.action_key]}")
)
return

View File

@ -0,0 +1,37 @@
# Generated by Django 4.0.3 on 2022-06-25 10:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("dash", "0006_auto_20220619_0800"),
]
operations = [
migrations.CreateModel(
name="InstanceCreated",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("creted", models.BooleanField(default=False)),
(
"instance",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="dash.instance"
),
),
],
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.3 on 2022-06-25 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("infrastructure", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="instancecreated",
name="gitea_password",
field=models.CharField(
default=None, max_length=32, verbose_name="Name of this configuration"
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.3 on 2022-06-27 17:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("infrastructure", "0002_instancecreated_gitea_password"),
]
operations = [
migrations.RenameField(
model_name="instancecreated",
old_name="creted",
new_name="created",
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.0.3 on 2022-06-28 09:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("infrastructure", "0003_rename_creted_instancecreated_created"),
]
operations = [
migrations.AlterField(
model_name="instancecreated",
name="gitea_password",
field=models.CharField(
default=None,
max_length=32,
verbose_name="Gitea password of this deployment",
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.0.3 on 2022-06-28 14:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("infrastructure", "0004_alter_instancecreated_gitea_password"),
]
operations = [
migrations.RemoveField(
model_name="instancecreated",
name="gitea_password",
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.0.3 on 2022-06-29 18:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("dash", "0006_auto_20220619_0800"),
("infrastructure", "0005_remove_instancecreated_gitea_password"),
]
operations = [
migrations.CreateModel(
name="Job",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("job_type", models.CharField(max_length=10, verbose_name="Job Type")),
(
"instance",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="dash.instance"
),
),
],
),
]

View File

41
infrastructure/models.py Normal file
View File

@ -0,0 +1,41 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum, unique
from django.db import models
from dash.models import Instance
@unique
class JobType(Enum):
PING = "ping"
def __str__(self):
return self.name
class InstanceCreated(models.Model):
instance = models.ForeignKey(Instance, on_delete=models.PROTECT)
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

@ -0,0 +1,18 @@
Hello {{ username }},
Congratulations on your new Gna! instance!
Your Gna! instance is being prepared, you will receive an email
notification when it is ready.
You can use the following credentials to log into an admin account on
your new Gna! Forgejo instance. Great powers come with great
responsibilities, so use the admin credentials wisely. When in doubt,
consult the Forgejo docs or contact support!
- username : root
- password: {{ forgejo_password }}
- Forgejo {{ forgejo_uri }}
Cheers,
Gna! team

View File

@ -0,0 +1,11 @@
Hello {{ username }}!,
The deployment job has run to completion and your Gna! instance is now online!
Credentials to admin account was sent in an earlier email, please contact
support if didn't receive it.
Forgejo: {{ forgejo_uri }}
Woodpecker CI: {{ woodpecker_uri }}
Cheers,
Gna! team

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
enough --domain $domain host delete {{ subdomain }}-host

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