diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9dca038 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/__pycache__ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +README.md \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..e11240e --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,61 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + branches-ignore: [master] + schedule: + - cron: '0 21 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['javascript', 'python'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..e6a4139 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,89 @@ +########################### +########################### +## Linter GitHub Actions ## +########################### +########################### + +name: Lint Code Base + +# +# Documentation: +# https://help.github.com/en/articles/workflow-syntax-for-github-actions +# + +############################# +# Start the job on all push # +############################# +on: + push: + branches: [master] + pull_request: + branches-ignore: [master] + + +############### +# Set the Job # +############### +jobs: + build: + name: Lint Code Base + # Set the agent to run on + runs-on: ubuntu-latest + + ################## + # Load all steps # + ################### + steps: + ########################## + # Checkout the code base # + ########################## + - name: Checkout Code + uses: actions/checkout@v2 + with: + # Full git history is needed to get a proper list of changed files within `super-linter` + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install Required packages + run: | + sudo apt-get install -y python3-virtualenv libvirt-dev python3-lxml zlib1g-dev libxslt1-dev + + - name: Create & Activate VENV + run: | + python3 -m venv venv + source venv/bin/activate + + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + pip3 install wheel + if [ -f dev/requirements.txt ]; then pip3 install -r dev/requirements.txt; else pip3 install -r conf/requirements.txt; fi + ################################ + # Run Linter against code base # + ################################ + - name: Lint Code Base + uses: docker://github/super-linter:latest + env: + FILTER_REGEX_EXCLUDE: .*(static|scss|venv|locale)/.* + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_ALL_CODEBASE: false + VALIDATE_ANSIBLE: false + VALIDATE_CLOJURE: false + VALIDATE_COFFEE: false + VALIDATE_DART: false + VALIDATE_GO: false + VALIDATE_JSX: false + VALIDATE_KOTLIN: false + VALIDATE_POWERSHELL: false + VALIDATE_PERL: false + VALIDATE_PHP: false + VALIDATE_RAKU: false + VALIDATE_RUBY: false + VALIDATE_TSX: false + VALIDATE_TERRAFORM: false + diff --git a/.gitignore b/.gitignore index 7d921d2..8860fff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,19 @@ .vagrant +.venv venv +venv2 +.vscode .idea .DS_* +.webvirtcloud *.pyc -db.sqlite3 +db.sqlite3* +console/cert.pem* +tags +dhcpd.* +webvirtcloud/settings.py +*migrations/* +.coverage +htmlcov +*.log +templates/webvirtcloud.code-workspace diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..024baab --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,5 @@ +image: gitpod/workspace-full + +tasks: + - init: 'echo "TODO: Replace with init/build command"' + command: 'echo "TODO: Replace with command to start project"' diff --git a/.travis.yml b/.travis.yml index aa532e7..24fcf3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,17 @@ +--- language: python python: - - "2.7" + - "3.9" env: - - DJANGO=1.8 + - DJANGO=4.2.4 install: - - pip install -r dev/requirements.txt --use-mirrors + - pip install -r dev/requirements.txt script: - - pep8 --exclude=IPy.py --ignore=E501 vrtManager accounts computes \ - console create instances interfaces \ - networks secrets storages - - pyflakes vrtManager accounts computes console create instances interfaces \ - networks secrets storages + - pep8 --exclude=IPy.py --ignore=E501 vrtManager accounts admin appsettings \ + computesconsole create datasource instances \ + interfaceslogs networks nwfilters storages \ + virtsecrets + - pyflakes vrtManager accounts admin appsettings computes console create datasource \ + instances interfaces logs networks nwfilters storages virtsecrets - python manage.py migrate - python manage.py test --settings=webvirtcloud.settings-dev diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e246127 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +FROM phusion/baseimage:noble-1.0.2 + +EXPOSE 80 +EXPOSE 6080 + +# Use baseimage-docker's init system. +CMD ["/sbin/my_init"] + + +RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean + +RUN apt-get update -qqy \ + && DEBIAN_FRONTEND=noninteractive apt-get -qyy install \ + --no-install-recommends \ + git \ + python3-venv \ + python3-dev \ + python3-lxml \ + libvirt-dev \ + zlib1g-dev \ + nginx \ + pkg-config \ + gcc \ + libldap2-dev \ + libssl-dev \ + libsasl2-dev \ + libsasl2-modules \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY . /srv/webvirtcloud +RUN chown -R www-data:www-data /srv/webvirtcloud + +# Setup webvirtcloud +WORKDIR /srv/webvirtcloud +RUN python3 -m venv venv && \ + . venv/bin/activate && \ + pip3 install -U pip && \ + pip3 install wheel && \ + pip3 install -r conf/requirements.txt && \ + pip3 cache purge && \ + chown -R www-data:www-data /srv/webvirtcloud + +RUN . venv/bin/activate && \ + python3 manage.py makemigrations && \ + python3 manage.py migrate && \ + python3 manage.py collectstatic --noinput && \ + chown -R www-data:www-data /srv/webvirtcloud + +# Setup Nginx +RUN printf "\n%s" "daemon off;" >> /etc/nginx/nginx.conf && \ + rm /etc/nginx/sites-enabled/default && \ + chown -R www-data:www-data /var/lib/nginx + +COPY conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ + +# Register services to runit +RUN mkdir /etc/service/nginx && \ + mkdir /etc/service/nginx-log-forwarder && \ + mkdir /etc/service/webvirtcloud && \ + mkdir /etc/service/novnc +COPY conf/runit/nginx /etc/service/nginx/run +COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run +COPY conf/runit/novncd.sh /etc/service/novnc/run +COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run + +# Define mountable directories. +#VOLUME [] + +WORKDIR /srv/webvirtcloud diff --git a/README.md b/README.md index f5e90c6..1ee4c9d 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,78 @@ -## WebVirtCloud Beta +[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/retspen/webvirtcloud) +# WebVirtCloud +###### Python >=3.11 & Django 4.2 LTS ## Features - +* QEMU/KVM Hypervisor Management +* QEMU/KVM Instance Management - Create, Delete, Update +* Hypervisor & Instance web based stats +* Manage Multiple QEMU/KVM Hypervisor +* Manage Hypervisor Datastore pools +* Manage Hypervisor Networks +* Instance Console Access with Browsers +* Libvirt API based web management UI +* User Based Authorization and Authentication * User can add SSH public key to root in Instance (Tested only Ubuntu) * User can change root password in Instance (Tested only Ubuntu) +* Supports cloud-init datasource interface ### Warning!!! How to update gstfsd daemon on hypervisor: ```bash -wget -O - https://clck.ru/9VMRH | sudo tee -a /usr/local/bin/gstfsd +wget -O - https://bit.ly/2NAaWXG | sudo tee -a /usr/local/bin/gstfsd sudo service supervisor restart ``` -### Description +## Description WebVirtCloud is a virtualization web interface for admins and users. It can delegate Virtual Machine's to users. A noVNC viewer presents a full graphical console to the guest domain. KVM is currently the only hypervisor supported. -### Install WebVirtCloud panel (Ubuntu) +## Quick Install with Installer (Beta) + +Install an OS and run specified commands. Installer supported OSes: Ubuntu 20.04/22.04, Debian 10/11, Rocky/Alma/OEL/RHEL 10. +It can be installed on a virtual machine, physical host or on a KVM host. ```bash -sudo apt-get -y install git python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev nginx supervisor +wget https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh +chmod 744 install.sh +# run with sudo or root user +./install.sh +``` + +## Manual Installation + +### Generate secret key + +You should generate SECRET_KEY after cloning repository. Then put it into webvirtcloud/settings.py. + +```python3 +import random, string +haystack = string.ascii_letters + string.digits + string.punctuation +print(''.join([random.SystemRandom().choice(haystack) for _ in range(50)])) +``` + +### Install WebVirtCloud panel (Ubuntu 18.04+ LTS) + +```bash +sudo apt-get -y install git virtualenv python3-virtualenv python3-dev python3-lxml libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs libsasl2-dev libldap2-dev libssl-dev git clone https://github.com/retspen/webvirtcloud cd webvirtcloud +cp webvirtcloud/settings.py.template webvirtcloud/settings.py +# now put secret key to webvirtcloud/settings.py sudo cp conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d cd .. sudo mv webvirtcloud /srv sudo chown -R www-data:www-data /srv/webvirtcloud cd /srv/webvirtcloud -virtualenv venv +virtualenv -p python3 venv source venv/bin/activate pip install -r conf/requirements.txt -python manage.py migrate +python3 manage.py migrate +python3 manage.py collectstatic --noinput sudo chown -R www-data:www-data /srv/webvirtcloud sudo rm /etc/nginx/sites-enabled/default ``` @@ -49,33 +87,45 @@ sudo service supervisor restart Setup libvirt and KVM on server ```bash -wget -O - https://clck.ru/9V9fH | sudo sh +wget -O - https://bit.ly/36baWUu | sudo sh ``` -### Install WebVirtCloud panel (CentOS) +Done!! + +Go to http://serverip and you should see the login screen. + +### Install WebVirtCloud panel (RHEL Based OS 8/9/10) ```bash -sudo yum -y install python-virtualenv python-devel libvirt-devel glibc gcc nginx supervisor libxml2 libxml2-devel git +sudo yum -y install epel-release +sudo yum -y install python3-virtualenv python3-devel libvirt-devel glibc gcc nginx supervisor python3-lxml git python3-libguestfs iproute-tc cyrus-sasl-md5 python3-libguestfs libsasl2-dev libldap2-dev libssl-dev ``` -#### Creating directories and cloning repo +#### Creating directories and cloning repository ```bash sudo mkdir /srv && cd /srv sudo git clone https://github.com/retspen/webvirtcloud && cd webvirtcloud +cp webvirtcloud/settings.py.template webvirtcloud/settings.py +# now put secret key to webvirtcloud/settings.py +# create secret key manually or use that command +sudo sed -r "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py ``` #### Start installation webvirtcloud -``` -sudo virtualenv venv -sudo source venv/bin/activate -sudo pip install -r conf/requirements.txt -sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -sudo python manage.py migrate + +```bash +virtualenv-3 venv +source venv/bin/activate +pip3 install -r conf/requirements.txt +cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +python3 manage.py migrate +python3 manage.py collectstatic --noinput ``` -#### Configure the supervisor for CentOS -Add the following after the [include] line (after **files = ... ** actually): +#### Configure the supervisor for RHEL Based OS + +Add the following after the [include] line (after **files = ...** actually): ```bash sudo vim /etc/supervisord.conf @@ -88,7 +138,7 @@ autorestart=true redirect_stderr=true [program:novncd] -command=/srv/webvirtcloud/venv/bin/python /srv/webvirtcloud/console/novncd +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/novncd directory=/srv/webvirtcloud user=nginx autostart=true @@ -97,9 +147,10 @@ redirect_stderr=true ``` #### Edit the nginx.conf file + You will need to edit the main nginx.conf file as the one that comes from the rpm's will not work. Comment the following lines: -``` +```bash # server { # listen 80 default_server; # listen [::]:80 default_server; @@ -124,7 +175,12 @@ You will need to edit the main nginx.conf file as the one that comes from the rp ``` Also make sure file in **/etc/nginx/conf.d/webvirtcloud.conf** has the proper paths: -``` + +```bash +upstream gunicorn_server { + #server unix:/srv/webvirtcloud/venv/wvcloud.socket fail_timeout=0; + server 127.0.0.1:8000 fail_timeout=0; +} server { listen 80; @@ -137,14 +193,14 @@ server { } location / { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://gunicorn_server; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; proxy_set_header Host $host:$server_port; proxy_set_header X-Forwarded-Proto $remote_addr; - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; + proxy_connect_timeout 1800; + proxy_read_timeout 1800; + proxy_send_timeout 1800; client_max_body_size 1024M; } } @@ -160,28 +216,48 @@ Change permission for selinux: ```bash sudo semanage fcontext -a -t httpd_sys_content_t "/srv/webvirtcloud(/.*)" +sudo setsebool -P httpd_can_network_connect on -P ``` -Add required user to the kvm group: +Add required user to the kvm group(if you not install with root): + ```bash -sudo usermod -G kvm -a webvirtmgr +sudo usermod -G kvm -a +``` + +Allow http ports on firewall: + +```bash +sudo firewall-cmd --add-service=http +sudo firewall-cmd --add-service=http --permanent +sudo firewall-cmd --add-port=6080/tcp +sudo firewall-cmd --add-port=6080/tcp --permanent ``` Let's restart nginx and the supervisord services: + ```bash sudo systemctl restart nginx && systemctl restart supervisord ``` And finally, check everything is running: + ```bash sudo supervisorctl status +gstfsd RUNNING pid 24662, uptime 6:01:40 +novncd RUNNING pid 24661, uptime 6:01:40 +webvirtcloud RUNNING pid 24660, uptime 6:01:40 +``` -novncd RUNNING pid 24186, uptime 2:59:14 -webvirtcloud RUNNING pid 24185, uptime 2:59:14 +#### Apache mod_wsgi configuration +```bash +WSGIDaemonProcess webvirtcloud threads=2 maximum-requests=1000 display-name=webvirtcloud +WSGIScriptAlias / /srv/webvirtcloud/webvirtcloud/wsgi_custom.py ``` #### Install final required packages for libvirtd and others on Host Server + ```bash wget -O - https://clck.ru/9V9fH | sudo sh ``` @@ -190,19 +266,215 @@ Done!! Go to http://serverip and you should see the login screen. +### Alternative running novncd via runit(Debian) + +Alternative to running nonvcd via supervisor is runit. + +On Debian systems install runit and configure novncd service: + +```bash +apt install runit runit-systemd +mkdir /etc/service/novncd/ +ln -s /srv/webvirtcloud/conf/runit/novncd.sh /etc/service/novncd/run +systemctl start runit.service +``` + ### Default credentials -
+
+```html
 login: admin
 password: admin
-
+``` + +### Configuring Compute SSH connection + +This is a short example of configuring cloud and compute side of the ssh connection. + +On the webvirtcloud machine you need to generate ssh keys and optionally disable StrictHostKeyChecking. -### How To Update ```bash +chown www-data -R ~www-data +sudo -u www-data ssh-keygen +cat > ~www-data/.ssh/config << EOF +Host * +StrictHostKeyChecking no +EOF +chown www-data -R ~www-data/.ssh/config +``` + +You need to put cloud public key into authorized keys on the compute node. Simpliest way of doing this is to use ssh tool from the webvirtcloud server. + +```bash +sudo -u www-data ssh-copy-id root@compute1 +``` + +### Host SMBIOS information is not available + +If you see warning + +```bash +Unsupported configuration: Host SMBIOS information is not available +``` + +Then you need to install `dmidecode` package on your host using your package manager and restart libvirt daemon. + +Debian/Ubuntu like: + +```bash +sudo apt-get install dmidecode +sudo service libvirt-bin restart +``` + +Arch Linux + +```bash +sudo pacman -S dmidecode +systemctl restart libvirtd +``` + +### Cloud-init + +Currently supports only root ssh authorized keys and hostname. Example configuration of the cloud-init client follows. + +```bash +datasource: + OpenStack: + metadata_urls: [ "http://webvirtcloud.domain.com/datasource" ] +``` + +### Reverse-Proxy + +Edit WS_PUBLIC_PORT at settings.py file to expose redirect to 80 or 443. Default: 6080 + +```bash +WS_PUBLIC_PORT = 80 +``` + +## How To Update + +```bash +# Go to Installation Directory +cd /srv/webvirtcloud +source venv/bin/activate git pull -python manage.py migrate +pip3 install -U -r conf/requirements.txt +python3 manage.py migrate +python3 manage.py collectstatic --noinput sudo service supervisor restart ``` -### License +### Running tests + +Server on which tests will be performed must have libvirt up and running. +It must not contain vms. +It must have `default` storage which not contain any disk images. +It must have `default` network which must be on. +Setup venv + +```bash +python -m venv venv +source venv/bin/activate +pip install -r conf/requirements.txt +``` + +Run tests + +```bash +python manage.py test +``` + +## LDAP Configuration + +The config options below can be changed in `webvirtcloud/settings.py` file. Variants for Active Directory and OpenLDAP are shown. This is a minimal config to get LDAP running, for further info read the [django-auth-ldap documentation](https://django-auth-ldap.readthedocs.io). + +Enable LDAP + +```bash +sudo sed -i "s~#\"django_auth_ldap.backend.LDAPBackend\",~\"django_auth_ldap.backend.LDAPBackend\",~g" /srv/webvirtcloud/webvirtcloud/settings.py +``` + +Set the LDAP server name and bind DN + +```python +# Active Directory +AUTH_LDAP_SERVER_URI = "ldap://example.com" +AUTH_LDAP_BIND_DN = "username@example.com" +AUTH_LDAP_BIND_PASSWORD = "password" + +# OpenLDAP +AUTH_LDAP_SERVER_URI = "ldap://example.com" +AUTH_LDAP_BIND_DN = "CN=username,CN=Users,OU=example,OU=com" +AUTH_LDAP_BIND_PASSWORD = "password" +``` + +Set the user filter and user and group search base and filter + +```python +# Active Directory +AUTH_LDAP_USER_SEARCH = LDAPSearch( + "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(sAMAccountName=%(user)s)" +) +AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(objectClass=group)" +) +AUTH_LDAP_GROUP_TYPE = NestedActiveDirectoryGroupType() + +# OpenLDAP +AUTH_LDAP_USER_SEARCH = LDAPSearch( + "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(cn=%(user)s)" +) +AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(objectClass=groupOfUniqueNames)" +) +AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType() # import needs to be changed at the top of settings.py +``` + +Set group which is required to access WebVirtCloud. You may set this to `False` to disable this filter. + +```python +AUTH_LDAP_REQUIRE_GROUP = "CN=WebVirtCloud Access,CN=Users,DC=example,DC=com" +``` + +Populate user fields with values from LDAP + +```python +AUTH_LDAP_USER_FLAGS_BY_GROUP = { + "is_staff": "CN=WebVirtCloud Staff,CN=Users,DC=example,DC=com", + "is_superuser": "CN=WebVirtCloud Admins,CN=Users,DC=example,DC=com", +} +AUTH_LDAP_USER_ATTR_MAP = { + "first_name": "givenName", + "last_name": "sn", + "email": "mail", +} +``` + +Now when you login with an LDAP user it will be assigned the rights defined. The user will be authenticated then with LDAP and authorized through the WebVirtCloud permissions. + +If you'd like to move a user from ldap to WebVirtCloud, just change its password from the UI and (eventually) remove from the group in LDAP. + + +## REST API / BETA +Webvirtcloud provides a REST API for programmatic access. +To access API methods open your browser and check them with Swagger interface +```bash +http:///swagger +``` +```bash +http:///redoc +``` + +## Screenshots + +Instance Detail: + +Instance List:
+ + +Other:
+ + + +## License WebVirtCloud is licensed under the [Apache Licence, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html). diff --git a/Vagrantfile b/Vagrantfile index 539468d..51522bb 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,17 +2,55 @@ # vi: set ft=ruby : Vagrant.configure(2) do |config| - config.vm.box = "ubuntu/trusty64" - config.vm.hostname = "webvirtcloud" - config.vm.network "private_network", ip: "192.168.33.10" - config.vm.provision "shell", inline: <<-SHELL + # Default machine, if name not specified... + config.vm.define "dev", primary: true do |dev| + dev.vm.box = "ubuntu/bionic64" + dev.vm.hostname = "webvirtcloud" + dev.vm.network "private_network", ip: "192.168.33.10" + dev.vm.provision "shell", inline: <<-SHELL sudo sh /vagrant/dev/libvirt-bootstrap.sh sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf sudo service libvirt-bin restart sudo adduser vagrant libvirtd - sudo apt-get -y install python-virtualenv python-dev libxml2-dev libvirt-dev zlib1g-dev - virtualenv /vagrant/venv + sudo apt-get -y install python3-virtualenv virtualenv python3-pip python3-dev python3-lxml libvirt-dev zlib1g-dev python3-guestfs + virtualenv -p python3 /vagrant/venv source /vagrant/venv/bin/activate - pip install -r /vagrant/dev/requirements.txt - SHELL + pip3 install -r /vagrant/dev/requirements.txt + SHELL + end + # To start this machine run "vagrant up prod" + # To enter this machine run "vagrant ssh prod" + config.vm.define "prod", autostart: false do |prod| + prod.vm.box = "ubuntu/bionic64" + prod.vm.hostname = "webvirtcloud" + prod.vm.network "private_network", ip: "192.168.33.11" + prod.vm.network "forwarded_port", guest: 80, host: 8081 + #prod.vm.synced_folder ".", "/srv/webvirtcloud" + prod.vm.provision "shell", inline: <<-SHELL + sudo mkdir /srv/webvirtcloud + sudo cp -R /vagrant/* /srv/webvirtcloud + sudo sh /srv/webvirtcloud/dev/libvirt-bootstrap.sh + sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf + sudo service libvirt-bin restart + sudo adduser vagrant libvirtd + sudo chown -R vagrant:vagrant /srv/webvirtcloud + sudo apt-get -y install python3-virtualenv python3-dev python3-lxml python3-pip virtualenv libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs + virtualenv -p python3 /srv/webvirtcloud/venv + source /srv/webvirtcloud/venv/bin/activate + pip3 install -r /srv/webvirtcloud/requirements.txt + sudo cp /srv/webvirtcloud/conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d + sudo cp /srv/webvirtcloud/conf/nginx/webvirtcloud.conf /etc/nginx/conf.d + sudo cp /srv/webvirtcloud/webvirtcloud/settings.py.template /srv/webvirtcloud/webvirtcloud/settings.py + sudo sed "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py + python3 /srv/webvirtcloud/manage.py makemigrations + python3 /srv/webvirtcloud/manage.py migrate + python3 /srv/webvirtcloud/manage.py collectstatic --noinput + sudo rm /etc/nginx/sites-enabled/default + sudo chown -R www-data:www-data /srv/webvirtcloud + sudo service nginx restart + sudo service supervisor restart + SHELL + end end + + diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..c419263 --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-cayman \ No newline at end of file diff --git a/accounts/__init__.py b/accounts/__init__.py index e69de29..8319823 100644 --- a/accounts/__init__.py +++ b/accounts/__init__.py @@ -0,0 +1 @@ +default_app_config = 'accounts.apps.AccountsConfig' diff --git a/accounts/admin.py b/accounts/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/accounts/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..2a6f631 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,63 @@ +from django.apps import AppConfig +from django.db.models.signals import post_migrate + + +def apply_change_password(sender, **kwargs): + """ + Apply new change_password permission for all users + Depending on settings SHOW_PROFILE_EDIT_PASSWORD + """ + from django.conf import settings + from django.contrib.auth.models import Permission, User + + if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"): + print("\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m") + print("\033[1m* \033[92mApplying permission can_change_password for all users\033[0m") + users = User.objects.all() + permission = Permission.objects.get(codename="change_password") + if settings.SHOW_PROFILE_EDIT_PASSWORD: + print("\033[1m! \033[91mWarning!!! Setting to True for all users\033[0m") + for user in users: + user.user_permissions.add(permission) + else: + print("\033[1m* \033[91mWarning!!! Setting to False for all users\033[0m") + for user in users: + user.user_permissions.remove(permission) + print("\033[1m! Don`t forget to remove the option from settings.py\033[0m") + + +def create_admin(sender, **kwargs): + """ + Create initial admin user + """ + from django.contrib.auth.models import User + + from accounts.models import UserAttributes + + plan = kwargs.get("plan", []) + for migration, rolled_back in plan: + if ( + migration.app_label == "accounts" + and migration.name == "0001_initial" + and not rolled_back + ): + if User.objects.count() == 0: + print("\033[1m* \033[92mCreating default admin user\033[0m") + admin = User.objects.create_superuser("admin", None, "admin") + UserAttributes( + user=admin, + max_instances=-1, + max_cpus=-1, + max_memory=-1, + max_disk_size=-1, + ).save() + break + + +class AccountsConfig(AppConfig): + name = "accounts" + verbose_name = "Accounts" + + def ready(self): + post_migrate.connect(create_admin, sender=self) + post_migrate.connect(apply_change_password, sender=self) diff --git a/accounts/forms.py b/accounts/forms.py index 55d5c29..d4c7e56 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,24 +1,75 @@ -import re -from django import forms -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth.models import User +from appsettings.settings import app_settings +from django.contrib.auth import get_user_model +from django.forms import EmailField, Form, ModelForm, ValidationError +from django.utils.translation import gettext_lazy as _ + +from .models import UserInstance, UserSSHKey +from .utils import validate_ssh_key -class UserAddForm(forms.Form): - name = forms.CharField(label="Name", - error_messages={'required': _('No User name has been entered')}, - max_length=20) - password = forms.CharField(required=True, error_messages={'required': _('No password has been entered')},) +class UserInstanceForm(ModelForm): + def __init__(self, *args, **kwargs): + super(UserInstanceForm, self).__init__(*args, **kwargs) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('^[a-z0-9]+$', name) - if not have_symbol: - raise forms.ValidationError(_('The flavor name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The flavor name must not exceed 20 characters')) - try: - User.objects.get(username=name) - except User.DoesNotExist: - return name - raise forms.ValidationError(_('Flavor name is already use')) + # Make user and instance fields not editable after creation + instance = getattr(self, "instance", None) + if instance and instance.id is not None: + self.fields["user"].disabled = True + self.fields["instance"].disabled = True + + def clean_instance(self): + instance = self.cleaned_data["instance"] + if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False": + exists = UserInstance.objects.filter(instance=instance) + if exists: + raise ValidationError(_("Instance owned by another user")) + + return instance + + class Meta: + model = UserInstance + fields = "__all__" + + +class ProfileForm(ModelForm): + class Meta: + model = get_user_model() + fields = ("first_name", "last_name", "email") + + +class UserSSHKeyForm(ModelForm): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + self.publickeys = UserSSHKey.objects.filter(user=self.user) + super().__init__(*args, **kwargs) + + def clean_keyname(self): + for key in self.publickeys: + if self.cleaned_data["keyname"] == key.keyname: + raise ValidationError(_("Key name already exist")) + + return self.cleaned_data["keyname"] + + def clean_keypublic(self): + for key in self.publickeys: + if self.cleaned_data["keypublic"] == key.keypublic: + raise ValidationError(_("Public key already exist")) + + if not validate_ssh_key(self.cleaned_data["keypublic"]): + raise ValidationError(_("Invalid key")) + return self.cleaned_data["keypublic"] + + def save(self, commit=True): + ssh_key = super().save(commit=False) + ssh_key.user = self.user + if commit: + ssh_key.save() + return ssh_key + + class Meta: + model = UserSSHKey + fields = ("keyname", "keypublic") + + +class EmailOTPForm(Form): + email = EmailField(label=_("Email")) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 3989532..6c41032 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,29 +1,50 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations +# Generated by Django 2.2.10 on 2020-01-28 07:01 +import django.core.validators +import django.db.models.deletion from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('instances', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='UserSSHKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('keyname', models.CharField(max_length=25)), + ('keypublic', models.CharField(max_length=500)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='UserInstance', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_change', models.BooleanField(default=False)), ('is_delete', models.BooleanField(default=False)), - ('instance', models.ForeignKey(to='instances.Instance')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('is_vnc', models.BooleanField(default=False)), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='instances.Instance')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserAttributes', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('can_clone_instances', models.BooleanField(default=True)), + ('max_instances', models.IntegerField(default=1, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_cpus', models.IntegerField(default=1, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_memory', models.IntegerField(default=2048, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('max_disk_size', models.IntegerField(default=20, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], - options={ - }, - bases=(models.Model,), ), ] diff --git a/accounts/migrations/0002_auto_20150325_0846.py b/accounts/migrations/0002_auto_20150325_0846.py deleted file mode 100644 index 8780f97..0000000 --- a/accounts/migrations/0002_auto_20150325_0846.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations - - -def add_useradmin(apps, schema_editor): - from django.utils import timezone - from django.contrib.auth.models import User - - User.objects.create_superuser('admin', None, 'admin', - last_login=timezone.now() - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.RunPython(add_useradmin), - ] diff --git a/accounts/migrations/0002_permissionset.py b/accounts/migrations/0002_permissionset.py new file mode 100644 index 0000000..5b36210 --- /dev/null +++ b/accounts/migrations/0002_permissionset.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.12 on 2020-05-27 12:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PermissionSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'permissions': (('change_password', 'Can change password'), ), + 'managed': False, + 'default_permissions': (), + }, + ), + ] diff --git a/accounts/migrations/0003_auto_20200604_0930.py b/accounts/migrations/0003_auto_20200604_0930.py new file mode 100644 index 0000000..05c9232 --- /dev/null +++ b/accounts/migrations/0003_auto_20200604_0930.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.12 on 2020-06-04 09:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_permissionset'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='max_cpus', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)]), + ), + migrations.AlterField( + model_name='userattributes', + name='max_instances', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)]), + ), + ] diff --git a/accounts/migrations/0003_usersshkey.py b/accounts/migrations/0003_usersshkey.py deleted file mode 100644 index b00bc62..0000000 --- a/accounts/migrations/0003_usersshkey.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('accounts', '0002_auto_20150325_0846'), - ] - - operations = [ - migrations.CreateModel( - name='UserSSHKey', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('keyname', models.CharField(max_length=25)), - ('keypublic', models.CharField(max_length=500)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/accounts/migrations/0004_auto_20200615_0637.py b/accounts/migrations/0004_auto_20200615_0637.py new file mode 100644 index 0000000..c24bd98 --- /dev/null +++ b/accounts/migrations/0004_auto_20200615_0637.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_auto_20200604_0930'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='max_cpus', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max CPUs'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_disk_size', + field=models.IntegerField(default=20, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max disk size'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_instances', + field=models.IntegerField(default=2, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max instances'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_memory', + field=models.IntegerField(default=2048, help_text='-1 for unlimited. Any integer value', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max memory'), + ), + migrations.AlterField( + model_name='usersshkey', + name='keyname', + field=models.CharField(max_length=25, verbose_name='key name'), + ), + migrations.AlterField( + model_name='usersshkey', + name='keypublic', + field=models.CharField(max_length=500, verbose_name='public key'), + ), + ] diff --git a/accounts/migrations/0005_auto_20200616_1039.py b/accounts/migrations/0005_auto_20200616_1039.py new file mode 100644 index 0000000..bf48ddc --- /dev/null +++ b/accounts/migrations/0005_auto_20200616_1039.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.13 on 2020-06-16 10:39 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('instances', '0003_auto_20200615_0637'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0004_auto_20200615_0637'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userinstance', + unique_together={('user', 'instance')}, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 15cedee..88cbb23 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,22 +1,82 @@ -from django.db import models from django.contrib.auth.models import User +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ from instances.models import Instance +class UserInstanceManager(models.Manager): + def get_queryset(self): + return super().get_queryset().select_related("instance", "user") + + class UserInstance(models.Model): - user = models.ForeignKey(User) - instance = models.ForeignKey(Instance) + user = models.ForeignKey(User, on_delete=models.CASCADE) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE) is_change = models.BooleanField(default=False) is_delete = models.BooleanField(default=False) + is_vnc = models.BooleanField(default=False) - def __unicode__(self): - return self.instance.name + objects = UserInstanceManager() + + def __str__(self): + return _('Instance "%(inst)s" of user %(user)s') % { + "inst": self.instance, + "user": self.user, + } + + class Meta: + unique_together = ["user", "instance"] class UserSSHKey(models.Model): - user = models.ForeignKey(User) - keyname = models.CharField(max_length=25) - keypublic = models.CharField(max_length=500) + user = models.ForeignKey(User, on_delete=models.DO_NOTHING) + keyname = models.CharField(_("key name"), max_length=25) + keypublic = models.CharField(_("public key"), max_length=500) - def __unicode__(self): + def __str__(self): return self.keyname + + +class UserAttributes(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + can_clone_instances = models.BooleanField(default=True) + max_instances = models.IntegerField( + _("max instances"), + default=2, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + max_cpus = models.IntegerField( + _("max CPUs"), + default=2, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + max_memory = models.IntegerField( + _("max memory"), + default=2048, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + max_disk_size = models.IntegerField( + _("max disk size"), + default=20, + help_text=_("-1 for unlimited. Any integer value"), + validators=[MinValueValidator(-1)], + ) + + def __str__(self): + return self.user.username + + +class PermissionSet(models.Model): + """ + Dummy model for holding set of permissions we need to be automatically added by Django + """ + + class Meta: + default_permissions = () + permissions = (("change_password", _("Can change password")),) + + managed = False diff --git a/accounts/templates/account.html b/accounts/templates/account.html index b6a8b6c..3dc7768 100644 --- a/accounts/templates/account.html +++ b/accounts/templates/account.html @@ -1,104 +1,93 @@ {% extends "base.html" %} + {% load i18n %} -{% block title %}{% trans "User" %} - {{ user }}{% endblock %} +{% load bootstrap_icons %} +{% load qr_code %} + +{% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %} +{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %} + +{% block page_heading_extra %} +{% if otp_enabled %} + + {% bs_icon 'qr-code' %} + +{% endif %} + + {% bs_icon 'pencil' %} + + + {% bs_icon 'plus-circle-fill' %} + +{% endblock page_heading_extra %} + {% block content %} - -
-
- {% include 'create_user_inst_block.html' %} -

{{ user }}

-
-
- + - {% include 'errors_block.html' %} - -
-
- {% if not user_insts %} -
-
- - {% trans "Warning:" %} {% trans "User doesn't have any Instace" %} -
-
- {% else %} -
- - - - - - - - - - - - {% for inst in user_insts %} - - - - - - - - - {% endfor %} - -
#{% trans "Instance" %}{% trans "Resize" %}{% trans "Delete" %}{% trans "Action" %}
{{ forloop.counter }}{{ inst.instance.name }}{{ inst.is_change }}{{ inst.is_delete }} - - - - - - - -
{% csrf_token %} - - -
-
-
- {% endif %} -
-
-{% endblock %} \ No newline at end of file +
+
+ + + + + + + + + + + + + {% for inst in user_insts %} + + + + + + + + + + {% endfor %} + +
#{% trans "Instance" %}{% trans "VNC" %}{% trans "Resize" %}{% trans "Delete" %}{% trans "Action" %}
{{ forloop.counter }}{{ inst.instance.name }}{{ inst.is_vnc }}{{ inst.is_change }}{{ inst.is_delete }} + + {% bs_icon 'pencil' %} + + + + {% bs_icon 'trash' %} + +
+
+
+ + + + + + + + + {% for publickey in publickeys %} + + + + + {% endfor %} + +
{% trans "Key name" %}{% trans "Public key" %}
{{ publickey.keyname }}{{ publickey.keypublic|truncatechars:64 }}
+
+
+{% endblock content %} diff --git a/accounts/templates/accounts.html b/accounts/templates/accounts.html deleted file mode 100644 index 9ebff0b..0000000 --- a/accounts/templates/accounts.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block title %}{% trans "Users" %}{% endblock %} -{% block content %} - -
-
- {% include 'create_user_block.html' %} -

{% trans "Users" %}

-
-
- - - {% include 'errors_block.html' %} - -
- {% if not users %} -
-
- - {% trans "Warning:" %} {% trans "You don't have any User" %} -
-
- {% else %} - {% for user in users %} -
-
- -
-
-

{% trans "Status:" %}

-
-
- {% if user.is_active %} -

{% trans "Active" %}

- {% else %} -

{% trans "Blocked" %}

- {% endif %} -
-
-
-
- - - - {% endfor %} - {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/change_password_form.html b/accounts/templates/accounts/change_password_form.html new file mode 100644 index 0000000..3df2d0e --- /dev/null +++ b/accounts/templates/accounts/change_password_form.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% load django_bootstrap5 %} +{% load i18n %} +{% load bootstrap_icons %} + +{% block title %}{%trans "Change Password" %}{% endblock title %} + +{% block content %} +
+
+
+
+

{%trans "Change Password" %}: {{ user }}

+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/accounts/email/otp.html b/accounts/templates/accounts/email/otp.html new file mode 100644 index 0000000..c80ec14 --- /dev/null +++ b/accounts/templates/accounts/email/otp.html @@ -0,0 +1,9 @@ +{% load i18n %} +{% load qr_code %} +{% blocktrans %} +Scan this QR code to get OTP for account '{{ user }}' +{% endblocktrans %} +
+{% qr_from_text totp_url %} +

{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}

+{% qr_from_text totp_url size="s" image_format="png" error_correction="M" %} diff --git a/accounts/templates/accounts/email_otp_form.html b/accounts/templates/accounts/email_otp_form.html new file mode 100644 index 0000000..83ba2d9 --- /dev/null +++ b/accounts/templates/accounts/email_otp_form.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block content %} +
+ {% blocktrans %} + Enter email address OTP QR code will be sent to. + {% endblocktrans %} +
+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ +
+{% endblock content %} \ No newline at end of file diff --git a/accounts/templates/accounts/otp_login.html b/accounts/templates/accounts/otp_login.html new file mode 100644 index 0000000..5f54f36 --- /dev/null +++ b/accounts/templates/accounts/otp_login.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load django_bootstrap5 %} + +{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In with OTP" %}{% endblock title %} + +{% block style %} + +{% endblock style %} + +{% block content %} + +{% endblock content %} + +{% block script %} + +{% endblock script%} diff --git a/accounts/templates/base_auth.html b/accounts/templates/base_auth.html deleted file mode 100644 index a7680bb..0000000 --- a/accounts/templates/base_auth.html +++ /dev/null @@ -1,41 +0,0 @@ -{% load static %} - - - - - - - - - - - - {% block title %}{% endblock %} - - - - - - - - - - - - - - -
- {% block content %}{% endblock %} -
- - - - - - - - \ No newline at end of file diff --git a/accounts/templates/create_user_block.html b/accounts/templates/create_user_block.html deleted file mode 100644 index 7752f56..0000000 --- a/accounts/templates/create_user_block.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load i18n %} -{% if request.user.is_superuser %} - - - - - - -{% endif %} \ No newline at end of file diff --git a/accounts/templates/create_user_inst_block.html b/accounts/templates/create_user_inst_block.html deleted file mode 100644 index df5937e..0000000 --- a/accounts/templates/create_user_inst_block.html +++ /dev/null @@ -1,36 +0,0 @@ -{% load i18n %} -{% if request.user.is_superuser %} - - - - - - -{% endif %} \ No newline at end of file diff --git a/accounts/templates/login.html b/accounts/templates/login.html index 362fdf7..28e6769 100644 --- a/accounts/templates/login.html +++ b/accounts/templates/login.html @@ -1,25 +1,55 @@ -{% extends "base_auth.html" %} +{% extends "base.html" %} {% load i18n %} -{% block title %}{% trans "WebVirtCloud - Sign In" %}{% endblock %} +{% load static %} + + +{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock title %} + +{% block style %} + +{% endblock style %} + {% block content %} -
- -
- {% if form.errors %} -
- - {% trans "Incorrect username or password." %} -
- {% endif %} - -
+ +{% endblock %} + +{% block script %} + +{% endblock script%} diff --git a/accounts/templates/logout.html b/accounts/templates/logout.html index 83161d3..eb3b917 100644 --- a/accounts/templates/logout.html +++ b/accounts/templates/logout.html @@ -1,15 +1,17 @@ -{% extends "base_auth.html" %} +{% extends "base.html" %} {% load i18n %} -{% block title %}{% trans "WebVirtCloud - Sign Out" %}{% endblock %} +{% block title %} + {% trans "WebVirtCloud" %} - {% trans "Sign Out"%} +{% endblock %} {% block content %} -
+
-
+
-

{% trans "Successful log out" %}

+

{% trans "Successful log out" %}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/accounts/templates/profile.html b/accounts/templates/profile.html index 3d15393..6e1cd5b 100644 --- a/accounts/templates/profile.html +++ b/accounts/templates/profile.html @@ -1,115 +1,84 @@ {% extends "base.html" %} {% load i18n %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} {% load tags_fingerprint %} -{% block title %}{% trans "Profile" %}{% endblock %} + +{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %} + +{% block page_heading %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_heading %} + {% block content %} - -
-
-

{% trans "Profile" %}

-
-
- - - {% include 'errors_block.html' %} - -
-
- -
{% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
- -
{% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
- - {% if publickeys %} -
-
- - - {% for key in publickeys %} - - - - - {% endfor %} - -
{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %}) -
{% csrf_token %} - - -
-
-
-
+ +
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form profile_form layout='horizontal' %} + {% if perms.accounts.change_password %} + + {% bs_icon 'lock' %} {% trans "Change Password" %} + {% endif %} - {% csrf_token %} -
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
-
+
+ +
+
+
+
+
+ {% if publickeys %} +
+
+ + + {% for key in publickeys %} + + + + + {% endfor %} + +
{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %}) + + {% bs_icon 'trash' %} + +
+
+
+ {% endif %} +
+
+ {%trans "Add SSH Key" %} +
+
+
+ {% csrf_token %} + {% bootstrap_form ssh_key_form layout='horizontal' %} +
+ +
+
+
+
+
+
{% endblock %} \ No newline at end of file diff --git a/accounts/templatetags/tags_fingerprint.py b/accounts/templatetags/tags_fingerprint.py index d15c473..83cfe14 100644 --- a/accounts/templatetags/tags_fingerprint.py +++ b/accounts/templatetags/tags_fingerprint.py @@ -1,12 +1,16 @@ -from django import template import base64 import hashlib +from django import template + register = template.Library() @register.simple_tag def ssh_to_fingerprint(line): - key = base64.b64decode(line.strip().split()[1].encode('ascii')) - fp_plain = hashlib.md5(key).hexdigest() - return ':'.join(a+b for a, b in zip(fp_plain[::2], fp_plain[1::2])) + try: + key = base64.b64decode(line.strip().split()[1].encode('ascii')) + fp_plain = hashlib.md5(key).hexdigest() + return ':'.join(a + b for a, b in zip(fp_plain[::2], fp_plain[1::2])) + except Exception: + return 'Invalid key' diff --git a/accounts/tests.py b/accounts/tests.py index 7ce503c..3b7ed6b 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,3 +1,308 @@ -from django.test import TestCase +from appsettings.settings import app_settings +from computes.models import Compute +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.shortcuts import reverse +from django.test import Client, TestCase +from instances.models import Instance +from instances.utils import refr +from libvirt import VIR_DOMAIN_UNDEFINE_NVRAM +from vrtManager.create import wvmCreate -# Create your tests here. +from accounts.forms import UserInstanceForm, UserSSHKeyForm +from accounts.models import UserInstance, UserSSHKey +from accounts.utils import validate_ssh_key + + +class AccountsTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Add users for testing purposes + User = get_user_model() + cls.admin_user = User.objects.get(pk=1) + cls.test_user = User.objects.create_user(username="test", password="test") + + # Add localhost compute + cls.compute = Compute( + name="test-compute", + hostname="localhost", + login="", + password="", + details="local", + type=4, + ) + cls.compute.save() + + cls.connection = wvmCreate( + cls.compute.hostname, + cls.compute.login, + cls.compute.password, + cls.compute.type, + ) + + # Add disks for testing + cls.connection.create_volume( + "default", + "test-volume", + 1, + "qcow2", + False, + 0, + 0, + ) + + # XML for testing vm + with open("conf/test-vm.xml", "r") as f: + cls.xml = f.read() + + # Create testing vm from XML + cls.connection._defineXML(cls.xml) + refr(cls.compute) + cls.instance = Instance.objects.get(pk=1) + + @classmethod + def tearDownClass(cls): + # Destroy testing vm + cls.instance.proxy.delete_all_disks() + cls.instance.proxy.delete(VIR_DOMAIN_UNDEFINE_NVRAM) + super().tearDownClass() + + def setUp(self): + self.client.login(username="admin", password="admin") + permission = Permission.objects.get(codename="change_password") + self.test_user.user_permissions.add(permission) + self.rsa_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test" + self.ecdsa_key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJc5xpT3R0iFJYNZbmWgAiDlHquX/BcV1kVTsnBfiMsZgU3lGaqz2eb7IBcir/dxGnsVENTTmPQ6sNcxLxT9kkQ= realgecko@archlinux" + + def test_profile(self): + response = self.client.get(reverse("accounts:profile")) + self.assertEqual(response.status_code, 200) + + response = self.client.get( + reverse("accounts:account", args=[self.test_user.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_account_with_otp(self): + settings.OTP_ENABLED = True + response = self.client.get( + reverse("accounts:account", args=[self.test_user.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_login_logout(self): + client = Client() + + response = client.post( + reverse("accounts:login"), {"username": "test", "password": "test"} + ) + self.assertRedirects(response, reverse("accounts:profile")) + + response = client.get(reverse("accounts:logout")) + self.assertRedirects(response, reverse("accounts:login")) + + def test_change_password(self): + self.client.force_login(self.test_user) + + response = self.client.get(reverse("accounts:change_password")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:change_password"), + { + "old_password": "wrongpass", + "new_password1": "newpw", + "new_password2": "newpw", + }, + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:change_password"), + { + "old_password": "test", + "new_password1": "newpw", + "new_password2": "newpw", + }, + ) + self.assertRedirects(response, reverse("accounts:profile")) + + self.client.logout() + + logged_in = self.client.login(username="test", password="newpw") + self.assertTrue(logged_in) + + def test_user_instance_create_update_delete(self): + # create + response = self.client.get( + reverse("accounts:user_instance_create", args=[self.test_user.id]) + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:user_instance_create", args=[self.test_user.id]), + { + "user": self.test_user.id, + "instance": self.instance.id, + "is_change": False, + "is_delete": False, + "is_vnc": False, + }, + ) + self.assertRedirects( + response, reverse("accounts:account", args=[self.test_user.id]) + ) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, False) + self.assertEqual(user_instance.is_delete, False) + self.assertEqual(user_instance.is_vnc, False) + + # update + response = self.client.get( + reverse("accounts:user_instance_update", args=[user_instance.id]) + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:user_instance_update", args=[user_instance.id]), + { + "user": self.test_user.id, + "instance": self.instance.id, + "is_change": True, + "is_delete": True, + "is_vnc": True, + }, + ) + self.assertRedirects( + response, reverse("accounts:account", args=[self.test_user.id]) + ) + + user_instance: UserInstance = UserInstance.objects.get(pk=1) + self.assertEqual(user_instance.user, self.test_user) + self.assertEqual(user_instance.instance, self.instance) + self.assertEqual(user_instance.is_change, True) + self.assertEqual(user_instance.is_delete, True) + self.assertEqual(user_instance.is_vnc, True) + + # delete + response = self.client.get( + reverse("accounts:user_instance_delete", args=[user_instance.id]) + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:user_instance_delete", args=[user_instance.id]) + ) + self.assertRedirects( + response, reverse("accounts:account", args=[self.test_user.id]) + ) + + # test 'next' redirect during deletion + user_instance = UserInstance.objects.create( + user=self.test_user, instance=self.instance + ) + response = self.client.post( + reverse("accounts:user_instance_delete", args=[user_instance.id]) + + "?next=" + + reverse("index") + ) + self.assertRedirects(response, reverse("index")) + + def test_update_user_profile(self): + self.client.force_login(self.test_user) + + user = get_user_model().objects.get(username="test") + self.assertEqual(user.first_name, "") + self.assertEqual(user.last_name, "") + self.assertEqual(user.email, "") + + response = self.client.post( + reverse("accounts:profile"), + { + "first_name": "first name", + "last_name": "last name", + "email": "email@mail.mail", + }, + ) + self.assertRedirects(response, reverse("accounts:profile")) + + user = get_user_model().objects.get(username="test") + self.assertEqual(user.first_name, "first name") + self.assertEqual(user.last_name, "last name") + self.assertEqual(user.email, "email@mail.mail") + + def test_create_delete_ssh_key(self): + response = self.client.get(reverse("accounts:ssh_key_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("accounts:ssh_key_create"), + { + "keyname": "keyname", + "keypublic": self.rsa_key, + }, + ) + self.assertRedirects(response, reverse("accounts:profile")) + + key = UserSSHKey.objects.get(pk=1) + self.assertEqual(key.keyname, "keyname") + self.assertEqual(key.keypublic, self.rsa_key) + + response = self.client.get(reverse("accounts:ssh_key_delete", args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("accounts:ssh_key_delete", args=[1])) + self.assertRedirects(response, reverse("accounts:profile")) + + def test_validate_ssh_key(self): + self.assertFalse(validate_ssh_key("")) + self.assertFalse(validate_ssh_key("ssh-rsa ABBA test@test")) + self.assertFalse(validate_ssh_key("ssh-rsa AAAABwdzZGY= test@test")) + self.assertFalse(validate_ssh_key("ssh-rsa AAA test@test")) + # validate ecdsa key + self.assertTrue(validate_ssh_key(self.ecdsa_key)) + + def test_forms(self): + # raise available validation errors for maximum coverage + form = UserSSHKeyForm( + {"keyname": "keyname", "keypublic": self.rsa_key}, user=self.test_user + ) + form.save() + + form = UserSSHKeyForm( + {"keyname": "keyname", "keypublic": self.rsa_key}, user=self.test_user + ) + self.assertFalse(form.is_valid()) + + form = UserSSHKeyForm( + {"keyname": "keyname", "keypublic": "invalid key"}, user=self.test_user + ) + self.assertFalse(form.is_valid()) + + app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER = "False" + form = UserInstanceForm( + { + "user": self.admin_user.id, + "instance": self.instance.id, + "is_change": False, + "is_delete": False, + "is_vnc": False, + } + ) + form.save() + form = UserInstanceForm( + { + "user": self.test_user.id, + "instance": self.instance.id, + "is_change": False, + "is_delete": False, + "is_vnc": False, + } + ) + self.assertFalse(form.is_valid()) diff --git a/accounts/urls.py b/accounts/urls.py index f8c4a41..211b057 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,12 +1,55 @@ -from django.conf.urls import url +from django.conf import settings +from django.contrib.auth.views import LoginView, LogoutView +from django.urls import path +from django_otp.forms import OTPAuthenticationForm + from . import views +from .views import CustomLoginView + +app_name = "accounts" urlpatterns = [ - url(r'^login/$', 'django.contrib.auth.views.login', - {'template_name': 'login.html'}, name='login'), - url(r'^logout/$', 'django.contrib.auth.views.logout', - {'template_name': 'logout.html'}, name='logout'), - url(r'^profile/$', views.profile, name='profile'), - url(r'^$', views.accounts, name='accounts'), - url(r'^profile/(?P[0-9]+)/$', views.account, name='account'), + path("logout/", LogoutView.as_view(template_name="logout.html"), name="logout"), + path("profile/", views.profile, name="profile"), + path("profile//", views.account, name="account"), + path("change_password/", views.change_password, name="change_password"), + path( + "user_instance/create//", + views.user_instance_create, + name="user_instance_create", + ), + path( + "user_instance//update/", + views.user_instance_update, + name="user_instance_update", + ), + path( + "user_instance//delete/", + views.user_instance_delete, + name="user_instance_delete", + ), + path("ssh_key/create/", views.ssh_key_create, name="ssh_key_create"), + path("ssh_key//delete/", views.ssh_key_delete, name="ssh_key_delete"), ] + +if settings.OTP_ENABLED: + urlpatterns += [ + path( + "login/", + LoginView.as_view( + template_name="accounts/otp_login.html", + authentication_form=OTPAuthenticationForm, + ), + name="login", + ), + path("email_otp/", views.email_otp, name="email_otp"), + path( + "admin_email_otp//", + views.admin_email_otp, + name="admin_email_otp", + ), + ] +else: + urlpatterns += ( + path("login/", CustomLoginView.as_view(template_name="login.html"), name="login"), + ) diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..fbbdf81 --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,61 @@ +import base64 +import binascii +import struct + +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import gettext as _ +from django_otp import devices_for_user +from django_otp.plugins.otp_totp.models import TOTPDevice + + +def get_user_totp_device(user): + devices = devices_for_user(user) + for device in devices: + if isinstance(device, TOTPDevice): + return device + + device = user.totpdevice_set.create() + return device + + +def validate_ssh_key(key): + array = key.encode().split() + # Each rsa-ssh key has 3 different strings in it, first one being + # typeofkey second one being keystring third one being username . + if len(array) != 3: + return False + typeofkey = array[0] + string = array[1] + + # must have only valid rsa-ssh key characters ie binascii characters + try: + data = base64.decodebytes(string) + except binascii.Error: + return False + # unpack the contents of data, from data[:4] , property of ssh key . + try: + str_len = struct.unpack(">I", data[:4])[0] + except struct.error: + return False + # data[4:str_len] must have string which matches with the typeofkey, another ssh key property. + if data[4 : 4 + str_len] != typeofkey: + return False + return True + + +def send_email_with_otp(user, device): + send_mail( + _("OTP QR Code"), + _("Please view HTML version of this message."), + None, + [user.email], + html_message=render_to_string( + "accounts/email/otp.html", + { + "totp_url": device.config_url, + "user": user, + }, + ), + fail_silently=False, + ) diff --git a/accounts/views.py b/accounts/views.py index d2893bb..a7da98a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,172 +1,215 @@ -from django.shortcuts import render +from admin.decorators import superuser_only +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import get_user_model, update_session_auth_hash, login as auth_login +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.forms import PasswordChangeForm +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.http import HttpResponseRedirect -from django.core.urlresolvers import reverse -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth.models import User -from accounts.models import UserInstance, UserSSHKey +from django.utils.translation import gettext_lazy as _ from instances.models import Instance -from accounts.forms import UserAddForm +from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm +from accounts.models import * + +from . import forms +from .utils import get_user_totp_device, send_email_with_otp +from django.contrib.auth.views import LoginView +from logs.views import addlogmsg + +class CustomLoginView(LoginView): + def form_valid(self, form): + username = form.cleaned_data['username'] + addlogmsg(username, "-", "-", "Logged In") + auth_login(self.request, form.get_user()) + return HttpResponseRedirect(self.get_success_url()) + + def form_invalid(self, form): + username = form.cleaned_data['username'] + addlogmsg(username, "-", "-", "Failed Login Attempt") + return self.render_to_response(self.get_context_data(form=form)) def profile(request): - """ - :param request: - :return: - """ - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) - - error_messages = [] - user = User.objects.get(id=request.user.id) publickeys = UserSSHKey.objects.filter(user_id=request.user.id) + profile_form = ProfileForm(request.POST or None, instance=request.user) + ssh_key_form = UserSSHKeyForm() - if request.method == 'POST': - if 'username' in request.POST: - username = request.POST.get('username', '') - email = request.POST.get('email', '') - user.first_name = username - user.email = email - user.save() - return HttpResponseRedirect(request.get_full_path()) - if 'oldpasswd' in request.POST: - oldpasswd = request.POST.get('oldpasswd', '') - password1 = request.POST.get('passwd1', '') - password2 = request.POST.get('passwd2', '') - if not password1 or not password2: - error_messages.append("Passwords didn't enter") - if password1 and password2 and password1 != password2: - error_messages.append("Passwords don't match") - if not user.check_password(oldpasswd): - error_messages.append("Old password is wrong!") - if not error_messages: - user.set_password(password1) - user.save() - return HttpResponseRedirect(request.get_full_path()) - if 'keyname' in request.POST: - keyname = request.POST.get('keyname', '') - keypublic = request.POST.get('keypublic', '') - for key in publickeys: - if keyname == key.keyname: - msg = _("Key name already exist") - error_messages.append(msg) - if keypublic == key.keypublic: - msg = _("Public key already exist") - error_messages.append(msg) - if not error_messages: - addkeypublic = UserSSHKey(user_id=request.user.id, keyname=keyname, keypublic=keypublic) - addkeypublic.save() - return HttpResponseRedirect(request.get_full_path()) - if 'keydelete' in request.POST: - keyid = request.POST.get('keyid', '') - delkeypublic = UserSSHKey.objects.get(id=keyid) - delkeypublic.delete() - return HttpResponseRedirect(request.get_full_path()) - return render(request, 'profile.html', locals()) + if profile_form.is_valid(): + profile_form.save() + messages.success(request, _("Profile updated")) + return redirect("accounts:profile") + + return render( + request, + "profile.html", + { + "publickeys": publickeys, + "profile_form": profile_form, + "ssh_key_form": ssh_key_form, + }, + ) -def accounts(request): - """ - :param request: - :return: - """ +def ssh_key_create(request): + key_form = UserSSHKeyForm(request.POST or None, user=request.user) + if key_form.is_valid(): + key_form.save() + messages.success(request, _("SSH key added")) + return redirect("accounts:profile") - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) - - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) - - error_messages = [] - users = User.objects.filter(is_staff=False, is_superuser=False) - - if request.method == 'POST': - if 'create' in request.POST: - form = UserAddForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if not error_messages: - new_user = User.objects.create_user(data['name'], None, data['password']) - new_user.save() - return HttpResponseRedirect(request.get_full_path()) - if 'edit' in request.POST: - user_id = request.POST.get('user_id', '') - user_pass = request.POST.get('user_pass', '') - user_edit = User.objects.get(id=user_id) - user_edit.set_password(user_pass) - user_edit.save() - return HttpResponseRedirect(request.get_full_path()) - if 'block' in request.POST: - user_id = request.POST.get('user_id', '') - user_block = User.objects.get(id=user_id) - user_block.is_active = False - user_block.save() - return HttpResponseRedirect(request.get_full_path()) - if 'unblock' in request.POST: - user_id = request.POST.get('user_id', '') - user_unblock = User.objects.get(id=user_id) - user_unblock.is_active = True - user_unblock.save() - return HttpResponseRedirect(request.get_full_path()) - if 'delete' in request.POST: - user_id = request.POST.get('user_id', '') - try: - del_user_inst = UserInstance.objects.filter(user_id=user_id) - del_user_inst.delete() - finally: - user_delete = User.objects.get(id=user_id) - user_delete.delete() - return HttpResponseRedirect(request.get_full_path()) - - return render(request, 'accounts.html', locals()) + return render( + request, + "common/form.html", + { + "form": key_form, + "title": _("Add SSH key"), + }, + ) +def ssh_key_delete(request, pk): + ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) + if request.method == "POST": + ssh_key.delete() + messages.success(request, _("SSH key deleted")) + return redirect("accounts:profile") + + return render( + request, + "common/confirm_delete.html", + { + "object": ssh_key, + "title": _("Delete SSH key"), + }, + ) + + +@superuser_only def account(request, user_id): - """ - :param request: - :return: - """ - - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) - - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) - - error_messages = [] user = User.objects.get(id=user_id) user_insts = UserInstance.objects.filter(user_id=user_id) - instances = Instance.objects.all() + instances = Instance.objects.all().order_by("name") + publickeys = UserSSHKey.objects.filter(user_id=user_id) - if user.username == request.user.username: - return HttpResponseRedirect(reverse('profile')) + return render( + request, + "account.html", + { + "user": user, + "user_insts": user_insts, + "instances": instances, + "publickeys": publickeys, + "otp_enabled": settings.OTP_ENABLED, + }, + ) - if request.method == 'POST': - if 'delete' in request.POST: - user_inst = request.POST.get('user_inst', '') - del_user_inst = UserInstance.objects.get(id=user_inst) - del_user_inst.delete() - return HttpResponseRedirect(request.get_full_path()) - if 'permission' in request.POST: - user_inst = request.POST.get('user_inst', '') - inst_change = request.POST.get('inst_change', '') - inst_delete = request.POST.get('inst_delete', '') - edit_user_inst = UserInstance.objects.get(id=user_inst) - edit_user_inst.is_change = bool(inst_change) - edit_user_inst.is_delete = bool(inst_delete) - edit_user_inst.save() - return HttpResponseRedirect(request.get_full_path()) - if 'add' in request.POST: - inst_id = request.POST.get('inst_id', '') - try: - check_inst = UserInstance.objects.get(instance_id=int(inst_id)) - msg = _("Instance already added") - error_messages.append(msg) - except UserInstance.DoesNotExist: - add_user_inst = UserInstance(instance_id=int(inst_id), user_id=user_id) - add_user_inst.save() - return HttpResponseRedirect(request.get_full_path()) - return render(request, 'account.html', locals()) +@permission_required("accounts.change_password", raise_exception=True) +def change_password(request): + form = PasswordChangeForm(request.user, request.POST or None) + + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, _("Password Changed")) + return redirect("accounts:profile") + + return render(request, "accounts/change_password_form.html", {"form": form}) + + +@superuser_only +def user_instance_create(request, user_id): + user = get_object_or_404(User, pk=user_id) + + form = forms.UserInstanceForm(request.POST or None, initial={"user": user}) + if form.is_valid(): + form.save() + return redirect(reverse("accounts:account", args=[user.id])) + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Create User Instance"), + }, + ) + + +@superuser_only +def user_instance_update(request, pk): + user_instance = get_object_or_404(UserInstance, pk=pk) + form = forms.UserInstanceForm(request.POST or None, instance=user_instance) + if form.is_valid(): + form.save() + return redirect(reverse("accounts:account", args=[user_instance.user.id])) + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Update User Instance"), + }, + ) + + +@superuser_only +def user_instance_delete(request, pk): + user_instance = get_object_or_404(UserInstance, pk=pk) + if request.method == "POST": + user = user_instance.user + user_instance.delete() + next = request.GET.get("next", None) + if next: + return redirect(next) + else: + return redirect(reverse("accounts:account", args=[user.id])) + + return render( + request, + "common/confirm_delete.html", + {"object": user_instance}, + ) + + +def email_otp(request): + form = EmailOTPForm(request.POST or None) + if form.is_valid(): + UserModel = get_user_model() + try: + user = UserModel.objects.get(email=form.cleaned_data["email"]) + except UserModel.DoesNotExist: + pass + else: + device = get_user_totp_device(user) + send_email_with_otp(user, device) + + messages.success( + request, _("OTP Sent to %(email)s") % {"email": form.cleaned_data["email"]} + ) + return redirect("accounts:login") + + return render( + request, + "accounts/email_otp_form.html", + { + "form": form, + "title": _("Email OTP"), + }, + ) + + +@superuser_only +def admin_email_otp(request, user_id): + user = get_object_or_404(get_user_model(), pk=user_id) + device = get_user_totp_device(user) + if user.email != "": + send_email_with_otp(user, device) + messages.success( + request, _("OTP QR code was emailed to user %(user)s") % {"user": user} + ) + else: + messages.error(request, _("User email not set, failed to send QR code")) + return redirect("accounts:account", user.id) diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..052dc42 --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1 @@ +default_app_config = 'admin.apps.AdminConfig' diff --git a/admin/apps.py b/admin/apps.py new file mode 100644 index 0000000..86bb5bc --- /dev/null +++ b/admin/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AdminConfig(AppConfig): + name = "admin" diff --git a/admin/decorators.py b/admin/decorators.py new file mode 100644 index 0000000..ebe901f --- /dev/null +++ b/admin/decorators.py @@ -0,0 +1,10 @@ +from django.core.exceptions import PermissionDenied + + +def superuser_only(function): + def _inner(request, *args, **kwargs): + if not request.user.is_superuser: + raise PermissionDenied + return function(request, *args, **kwargs) + + return _inner diff --git a/admin/forms.py b/admin/forms.py new file mode 100644 index 0000000..140e6a8 --- /dev/null +++ b/admin/forms.py @@ -0,0 +1,117 @@ +from django import forms +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.contrib.auth.models import Group, User +from django.urls import reverse_lazy +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy as _ + +from accounts.models import UserAttributes + +from .models import Permission + + +class GroupForm(forms.ModelForm): + permissions = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Permission.objects.filter(content_type__model="permissionset"), + required=False, + ) + + users = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=User.objects.all(), + required=False, + ) + + def __init__(self, *args, **kwargs): + super(GroupForm, self).__init__(*args, **kwargs) + instance = getattr(self, "instance", None) + if instance and instance.id: + self.fields["users"].initial = self.instance.user_set.all() + + def save_m2m(self): + self.instance.user_set.set(self.cleaned_data["users"]) + + def save(self, *args, **kwargs): + instance = super(GroupForm, self).save() + self.save_m2m() + return instance + + class Meta: + model = Group + fields = "__all__" + + +class UserForm(forms.ModelForm): + user_permissions = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Permission.objects.filter(content_type__model="permissionset"), + label=_("Permissions"), + required=False, + ) + + groups = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + queryset=Group.objects.all(), + label=_("Groups"), + required=False, + ) + + class Meta: + model = User + fields = [ + "username", + "groups", + "first_name", + "last_name", + "email", + "user_permissions", + "is_staff", + "is_active", + "is_superuser", + ] + + def __init__(self, *args, **kwargs): + super(UserForm, self).__init__(*args, **kwargs) + if self.instance.id: + password = ReadOnlyPasswordHashField( + label=_("Password"), + help_text=format_lazy( + _( + """Raw passwords are not stored, so there is no way to see this user's password, + but you can change the password using this form.""" + ), + reverse_lazy( + "admin:user_update_password", + args=[ + self.instance.id, + ], + ), + ), + ) + self.fields["Password"] = password + + +class UserCreateForm(UserForm): + password = forms.CharField(widget=forms.PasswordInput) + + class Meta: + model = User + fields = [ + "username", + "password", + "groups", + "first_name", + "last_name", + "email", + "user_permissions", + "is_staff", + "is_active", + "is_superuser", + ] + + +class UserAttributesForm(forms.ModelForm): + class Meta: + model = UserAttributes + exclude = ["user", "can_clone_instances"] diff --git a/admin/migrations/0001_initial.py b/admin/migrations/0001_initial.py new file mode 100644 index 0000000..e558854 --- /dev/null +++ b/admin/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.12 on 2020-05-27 07:01 + +import django.contrib.auth.models +from django.db import migrations + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Permission', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.permission',), + managers=[ + ('objects', django.contrib.auth.models.PermissionManager()), + ], + ), + ] diff --git a/admin/migrations/0002_auto_20200609_0830.py b/admin/migrations/0002_auto_20200609_0830.py new file mode 100644 index 0000000..012a070 --- /dev/null +++ b/admin/migrations/0002_auto_20200609_0830.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.12 on 2020-06-09 08:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0001_initial'), + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + ] diff --git a/admin/migrations/0003_create_group_technicians.py b/admin/migrations/0003_create_group_technicians.py new file mode 100644 index 0000000..d5b1cae --- /dev/null +++ b/admin/migrations/0003_create_group_technicians.py @@ -0,0 +1,15 @@ +from django.db import models, migrations + +def apply_migration(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Group.objects.create(name='Technicians') + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0002_auto_20200609_0830'), + ] + + operations = [ + migrations.RunPython(apply_migration) + ] diff --git a/create/__init__.py b/admin/migrations/__init__.py similarity index 100% rename from create/__init__.py rename to admin/migrations/__init__.py diff --git a/admin/models.py b/admin/models.py new file mode 100644 index 0000000..67fcdf9 --- /dev/null +++ b/admin/models.py @@ -0,0 +1,13 @@ +from django.contrib.auth.models import Permission as P + + +class Permission(P): + """ + Proxy model to Django Permissions model allows us to override __str__ + """ + + def __str__(self): + return f"{self.content_type.app_label}: {self.name}" + + class Meta: + proxy = True diff --git a/admin/templates/admin/group_list.html b/admin/templates/admin/group_list.html new file mode 100644 index 0000000..3b9c95c --- /dev/null +++ b/admin/templates/admin/group_list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load bootstrap_icons %} +{% block title %}{% trans "Users" %}{% endblock %} +{% block content %} +
+
+ + {% bs_icon 'plus-circle-fill' %} + + {% include 'search_block.html' %} +

{% trans "Groups" %}

+
+
+
+ {% if not groups %} +
+
+ {% bs_icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any groups" %} +
+
+ {% else %} +
+ + + + + + + + + {% for group in groups %} + + + + + {% endfor %} + +
{% trans "Group Name" %}{% trans "Actions" %}
+ {{ group.name }} + + +
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + +{% endblock script %} diff --git a/admin/templates/admin/logs.html b/admin/templates/admin/logs.html new file mode 100644 index 0000000..74fc05b --- /dev/null +++ b/admin/templates/admin/logs.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% load i18n %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} + +{% block title %}{% trans "Logs" %}{% endblock %} + +{% block page_heading %}{% trans "Logs" %}{% endblock page_heading %} + +{% block page_heading_extra %} + {% include 'search_block.html' %} +{% endblock page_heading_extra %} + + + +{% block content %} +
+
+ {% if not logs %} +
+
+ {% bs_icon 'exclamation-triangle'%} {% trans "Warning" %}: {% trans "You don't have any Logs" %} +
+
+ {% else %} +
+ + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
#{% trans "Date" %}{% trans "User" %}{% trans "Host" %}{% trans "Instance" %}{% trans "Message" %}
{{ log.id }}{{ log.date|date:"M d H:i:s" }}{{ log.user }}{{ log.host }}{{ log.instance }}{{ log.message }}
+
+ {% bootstrap_pagination logs %} + {% endif %} +
+
+{% endblock %} +{% block script %} + +{% endblock script %} diff --git a/admin/templates/admin/user_form.html b/admin/templates/admin/user_form.html new file mode 100644 index 0000000..47d6b2c --- /dev/null +++ b/admin/templates/admin/user_form.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block content %} +
+
+
+ {% csrf_token %} + {% bootstrap_form user_form layout='horizontal' %} + {% bootstrap_form attributes_form layout='horizontal' %} +
+
+ + {% bs_icon 'x-square-fill' %} {% trans "Cancel" %} + +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html new file mode 100644 index 0000000..f0e0b27 --- /dev/null +++ b/admin/templates/admin/user_list.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load common_tags %} +{% load bootstrap_icons %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block page_heading_extra %} + + {% bs_icon 'plus-circle-fill' %} + +{% include 'search_block.html' %} +{% endblock page_heading_extra %} + +{% block content %} +
+ {% if not users %} +
+
+ {% bs_icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any user" %} +
+
+ {% else %} +
+ + + + + + + + + + + + + + {% for user in users %} + {% has_perm user 'instances.clone_instances' as can_clone %} + + + + + + + + + + {% endfor %} + +
{% trans "Username" %}{% trans "Status" %}{% trans "Last Login" %}{% trans "Staff" %}{% trans "Superuser" %}{% trans "Can Clone" %}{% trans "Actions" %}
+ {{ user.username }} + + {% if user.is_active %} + {% trans "Active" %} + {% else %} + {% trans "Blocked" %} + {% endif %} + {{ user.last_login }}{% if user.is_staff %}{% bs_icon 'check' %}{% endif %}{% if user.is_superuser %}{% bs_icon 'check' %}{% endif %}{% if can_clone %}{% bs_icon 'check' %}{% endif %} + +
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + +{% endblock script %} diff --git a/admin/tests.py b/admin/tests.py new file mode 100644 index 0000000..92d261c --- /dev/null +++ b/admin/tests.py @@ -0,0 +1,124 @@ +from django.contrib.auth.models import Group, User +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import reverse +from django.test import TestCase + +from accounts.models import UserAttributes + + +class AdminTestCase(TestCase): + def setUp(self): + self.client.login(username="admin", password="admin") + + def test_group_list(self): + response = self.client.get(reverse("admin:group_list")) + self.assertEqual(response.status_code, 200) + + def test_groups(self): + response = self.client.get(reverse("admin:group_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("admin:group_create"), {"name": "Test Group"} + ) + self.assertRedirects(response, reverse("admin:group_list")) + + group = Group.objects.get(name="Test Group") + self.assertEqual(group.id, 1) + + response = self.client.get(reverse("admin:group_update", args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("admin:group_update", args=[1]), {"name": "Updated Group Test"} + ) + self.assertRedirects(response, reverse("admin:group_list")) + + group = Group.objects.get(id=1) + self.assertEqual(group.name, "Updated Group Test") + + response = self.client.get(reverse("admin:group_delete", args=[1])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("admin:group_delete", args=[1])) + self.assertRedirects(response, reverse("admin:group_list")) + + with self.assertRaises(ObjectDoesNotExist): + Group.objects.get(id=1) + + def test_user_list(self): + response = self.client.get(reverse("admin:user_list")) + self.assertEqual(response.status_code, 200) + + def test_users(self): + response = self.client.get(reverse("admin:user_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("admin:user_create"), + { + "username": "test", + "password": "test", + "max_instances": 1, + "max_cpus": 1, + "max_memory": 1024, + "max_disk_size": 4, + }, + ) + self.assertRedirects(response, reverse("admin:user_list")) + + user = User.objects.get(username="test") + self.assertEqual(user.id, 2) + + ua: UserAttributes = UserAttributes.objects.get(id=2) + self.assertEqual(ua.user_id, 2) + self.assertEqual(ua.max_instances, 1) + self.assertEqual(ua.max_cpus, 1) + self.assertEqual(ua.max_memory, 1024) + self.assertEqual(ua.max_disk_size, 4) + + response = self.client.get(reverse("admin:user_update", args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("admin:user_update", args=[2]), + { + "username": "utest", + "max_instances": 2, + "max_cpus": 2, + "max_memory": 2048, + "max_disk_size": 8, + }, + ) + self.assertRedirects(response, reverse("admin:user_list")) + + user = User.objects.get(id=2) + self.assertEqual(user.username, "utest") + + ua: UserAttributes = UserAttributes.objects.get(id=2) + self.assertEqual(ua.user_id, 2) + self.assertEqual(ua.max_instances, 2) + self.assertEqual(ua.max_cpus, 2) + self.assertEqual(ua.max_memory, 2048) + self.assertEqual(ua.max_disk_size, 8) + + response = self.client.get(reverse("admin:user_block", args=[2])) + user = User.objects.get(id=2) + self.assertFalse(user.is_active) + + response = self.client.get(reverse("admin:user_unblock", args=[2])) + user = User.objects.get(id=2) + self.assertTrue(user.is_active) + + response = self.client.get(reverse("admin:user_delete", args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("admin:user_delete", args=[2])) + self.assertRedirects(response, reverse("admin:user_list")) + + with self.assertRaises(ObjectDoesNotExist): + User.objects.get(id=2) + + def test_logs(self): + response = self.client.get(reverse("admin:logs")) + self.assertEqual(response.status_code, 200) diff --git a/admin/urls.py b/admin/urls.py new file mode 100644 index 0000000..0b5ab41 --- /dev/null +++ b/admin/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("groups/", views.group_list, name="group_list"), + path("groups/create/", views.group_create, name="group_create"), + path("groups//update/", views.group_update, name="group_update"), + path("groups//delete/", views.group_delete, name="group_delete"), + path("users/", views.user_list, name="user_list"), + path("users/create/", views.user_create, name="user_create"), + path("users//update_password/", views.user_update_password, name="user_update_password"), + path("users//update/", views.user_update, name="user_update"), + path("users//delete/", views.user_delete, name="user_delete"), + path("users//block/", views.user_block, name="user_block"), + path("users//unblock/", views.user_unblock, name="user_unblock"), + path("logs/", views.logs, name="logs"), +] diff --git a/admin/views.py b/admin/views.py new file mode 100644 index 0000000..8b71baf --- /dev/null +++ b/admin/views.py @@ -0,0 +1,217 @@ +from accounts.models import Instance, UserAttributes, UserInstance +from appsettings.settings import app_settings +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.forms import AdminPasswordChangeForm +from django.contrib.auth.models import Group, User +from django.core.paginator import Paginator +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.translation import gettext_lazy as _ +from logs.models import Logs + +from . import forms +from .decorators import superuser_only + + +@superuser_only +def group_list(request): + groups = Group.objects.all() + return render( + request, + "admin/group_list.html", + { + "groups": groups, + }, + ) + + +@superuser_only +def group_create(request): + form = forms.GroupForm(request.POST or None) + if form.is_valid(): + form.save() + return redirect("admin:group_list") + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Create Group"), + }, + ) + + +@superuser_only +def group_update(request, pk): + group = get_object_or_404(Group, pk=pk) + form = forms.GroupForm(request.POST or None, instance=group) + if form.is_valid(): + form.save() + return redirect("admin:group_list") + + return render( + request, + "common/form.html", + { + "form": form, + "title": _("Update Group"), + }, + ) + + +@superuser_only +def group_delete(request, pk): + group = get_object_or_404(Group, pk=pk) + if request.method == "POST": + group.delete() + return redirect("admin:group_list") + + return render( + request, + "common/confirm_delete.html", + {"object": group}, + ) + + +@superuser_only +def user_list(request): + users = User.objects.all() + return render( + request, + "admin/user_list.html", + { + "users": users, + "title": _("Users"), + }, + ) + + +@superuser_only +def user_create(request): + user_form = forms.UserCreateForm(request.POST or None) + attributes_form = forms.UserAttributesForm(request.POST or None) + if user_form.is_valid() and attributes_form.is_valid(): + user = user_form.save() + password = user_form.cleaned_data["password"] + user.set_password(password) + user.save() + attributes = attributes_form.save(commit=False) + attributes.user = user + attributes.save() + add_default_instances(user) + return redirect("admin:user_list") + + return render( + request, + "admin/user_form.html", + { + "user_form": user_form, + "attributes_form": attributes_form, + "title": _("Create User"), + }, + ) + + +@superuser_only +def user_update(request, pk): + user = get_object_or_404(User, pk=pk) + attributes, attributes_created = UserAttributes.objects.get_or_create(user=user) + user_form = forms.UserForm(request.POST or None, instance=user) + attributes_form = forms.UserAttributesForm( + request.POST or None, instance=attributes + ) + if user_form.is_valid() and attributes_form.is_valid(): + user_form.save() + attributes_form.save() + next = request.GET.get("next") + return redirect(next or "admin:user_list") + + return render( + request, + "admin/user_form.html", + { + "user_form": user_form, + "attributes_form": attributes_form, + "title": _("Update User"), + }, + ) + + +@superuser_only +def user_update_password(request, pk): + user = get_object_or_404(User, pk=pk) + if request.method == "POST": + form = AdminPasswordChangeForm(user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success( + request, _("Password changed for %(user)s") % {"user": user.username} + ) + return redirect("admin:user_list") + else: + messages.error(request, _("Wrong Data Provided")) + else: + form = AdminPasswordChangeForm(user) + + return render( + request, + "accounts/change_password_form.html", + { + "form": form, + "user": user.username, + }, + ) + + +@superuser_only +def user_delete(request, pk): + user = get_object_or_404(User, pk=pk) + if request.method == "POST": + user.delete() + return redirect("admin:user_list") + + return render( + request, + "common/confirm_delete.html", + {"object": user}, + ) + + +@superuser_only +def user_block(request, pk): + user: User = get_object_or_404(User, pk=pk) + user.is_active = False + user.save() + return redirect("admin:user_list") + + +@superuser_only +def user_unblock(request, pk): + user: User = get_object_or_404(User, pk=pk) + user.is_active = True + user.save() + return redirect("admin:user_list") + + +@superuser_only +def logs(request): + l = Logs.objects.order_by("-date") + paginator = Paginator(l, int(app_settings.LOGS_PER_PAGE)) + page = request.GET.get("page", 1) + logs = paginator.page(page) + return render(request, "admin/logs.html", {"logs": logs}) + + +def add_default_instances(user): + """ + Adds instances listed in NEW_USER_DEFAULT_INSTANCES to user + """ + existing_instances = UserInstance.objects.filter(user=user) + if not existing_instances: + for instance_name in settings.NEW_USER_DEFAULT_INSTANCES: + instance = Instance.objects.get(name=instance_name) + user_instance = UserInstance(user=user, instance=instance) + user_instance.save() diff --git a/create/migrations/__init__.py b/appsettings/__init__.py similarity index 100% rename from create/migrations/__init__.py rename to appsettings/__init__.py diff --git a/appsettings/apps.py b/appsettings/apps.py new file mode 100644 index 0000000..2a61fca --- /dev/null +++ b/appsettings/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AppsettingsConfig(AppConfig): + name = "appsettings" diff --git a/appsettings/context_processors.py b/appsettings/context_processors.py new file mode 100644 index 0000000..5991630 --- /dev/null +++ b/appsettings/context_processors.py @@ -0,0 +1,13 @@ +from .settings import app_settings as settings + + +def app_settings(request): + """ + Simple context processor that puts the config into every\ + RequestContext. Just make sure you have a setting like this:: + TEMPLATE_CONTEXT_PROCESSORS = ( + # ... + 'appsettings.context_processors.app_settings', + ) + """ + return {"app_settings": settings} diff --git a/appsettings/middleware.py b/appsettings/middleware.py new file mode 100644 index 0000000..737477d --- /dev/null +++ b/appsettings/middleware.py @@ -0,0 +1,10 @@ +from .settings import app_settings, get_settings + + +class AppSettingsMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + get_settings() + return self.get_response(request) diff --git a/appsettings/migrations/0001_initial.py b/appsettings/migrations/0001_initial.py new file mode 100644 index 0000000..bcf4376 --- /dev/null +++ b/appsettings/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-05-27 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AppSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=25)), + ('key', models.CharField(db_index=True, max_length=50, unique=True)), + ('value', models.CharField(max_length=25)), + ('choices', models.CharField(max_length=70)), + ('description', models.CharField(max_length=100, null=True)), + ], + ), + ] diff --git a/appsettings/migrations/0002_auto_20200527_1603.py b/appsettings/migrations/0002_auto_20200527_1603.py new file mode 100644 index 0000000..f064c47 --- /dev/null +++ b/appsettings/migrations/0002_auto_20200527_1603.py @@ -0,0 +1,79 @@ +# Generated by Django 2.2.12 on 2020-05-23 12:05 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(1, _("Theme"), "BOOTSTRAP_THEME", "flaty", "", _("Bootstrap CSS & Bootswatch Theme")), + setting(2, _("Theme SASS Path"), "SASS_DIR", "dev/scss/", "", _("Bootstrap SASS & Bootswatch SASS Directory")), + setting(3, _("All Instances View Style"), "VIEW_INSTANCES_LIST_STYLE", "grouped", "grouped,nongrouped", _("All instances list style")), + setting(4, _("Logs per Page"), "LOGS_PER_PAGE", "100", "", _("Pagination for logs")), + setting(5, _("Multiple Owner for VM"), "ALLOW_INSTANCE_MULTIPLE_OWNER", "True", "True,False", _("Allow to have multiple owner for instance")), + setting(6, _("Quota Debug"), "QUOTA_DEBUG", "True", "True,False", _("Debug for user quotas")), + setting(7, _("Disk Format"), "INSTANCE_VOLUME_DEFAULT_FORMAT", "qcow2", "raw,qcow,qcow2", _("Instance disk format")), + setting(8, _("Disk Bus"), "INSTANCE_VOLUME_DEFAULT_BUS", "virtio", "virtio,scsi,ide,usb,sata", _("Instance disk bus type")), + setting(9, _("Disk SCSI Controller"), "INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER", "virtio-scsi", "virtio-scsi, lsilogic, virtio-blk", _("SCSI controller type")), + setting(10, _("Disk Cache"), "INSTANCE_VOLUME_DEFAULT_CACHE", "directsync", "default,directsync,none,unsafe,writeback,writethrough", _("Disk volume cache type")), + setting(11, _("Disk IO Type"), "INSTANCE_VOLUME_DEFAULT_IO", "default", "default,native,threads", _("Volume io modes")), + setting(12, _("Disk Detect Zeroes"), "INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES", "default", "default,on,off,unmap", _("Volume detect zeroes mode")), + setting(13, _("Disk Discard"), "INSTANCE_VOLUME_DEFAULT_DISCARD", "default", "default,unmap,ignore", _("Volume discard mode")), + setting(14, _("Disk Owner UID"), "INSTANCE_VOLUME_DEFAULT_OWNER_UID", "0", "", _("Owner UID: up to os, 0=root, 107=qemu or libvirt-bin(for ubuntu)")), + setting(15, _("Disk Owner GID"), "INSTANCE_VOLUME_DEFAULT_OWNER_GID", "0", "", _("Owner GID: up to os, 0=root, 107=qemu or libvirt-bin(for ubuntu)")), + setting(16, _("VM CPU Mode"), "INSTANCE_CPU_DEFAULT_MODE", "host-model", "no-model,host-model,host-passthrough,custom", _("Cpu modes")), + setting(17, _("VM Machine Type"), "INSTANCE_MACHINE_DEFAULT_TYPE", "q35", "q35,x86_64", _("Chipset/Machine type")), + setting(18, _("VM Firmware Type"), "INSTANCE_FIRMWARE_DEFAULT_TYPE", "BIOS", "BIOS,UEFI", _("Firmware type for x86_64")), + setting(19, _("VM Architecture Type"), "INSTANCE_ARCH_DEFAULT_TYPE", "x86_64", "x86_64,i686", _("Architecture type: x86_64, i686, etc")), + setting(20, _("VM Console Type"), "QEMU_CONSOLE_DEFAULT_TYPE", "vnc", "vnc,spice", _("Default console type")), + setting(21, _("VM Clone Name Prefix"), "CLONE_INSTANCE_DEFAULT_PREFIX", "instance", "True,False", _("Prefix for cloned instance name")), + setting(22, _("VM Clone Auto Name"), "CLONE_INSTANCE_AUTO_NAME", "False", "True,False", _("Generated name for cloned instance")), + setting(23, _("VM Clone Auto Migrate"), "CLONE_INSTANCE_AUTO_MIGRATE", "False", "True,False", _("Auto migrate instance after clone")), + setting(24, _("VM Bottom Bar"), "VIEW_INSTANCE_DETAIL_BOTTOM_BAR", "True", "True,False", _("Bottom navbar for instance details")), + setting(25, _("Show Access Root Pass"), "SHOW_ACCESS_ROOT_PASSWORD", "False", "True,False", _("Show access root password")), + setting(26, _("Show Access SSH Keys"), "SHOW_ACCESS_SSH_KEYS", "False", "True,False", _("Show access ssh keys")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="QEMU_CONSOLE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="ALLOW_INSTANCE_MULTIPLE_OWNER").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_DEFAULT_PREFIX").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_AUTO_NAME").delete() + setting.objects.using(db_alias).filter(key="CLONE_INSTANCE_AUTO_MIGRATE").delete() + setting.objects.using(db_alias).filter(key="LOGS_PER_PAGE").delete() + setting.objects.using(db_alias).filter(key="QUOTA_DEBUG").delete() + setting.objects.using(db_alias).filter(key="VIEW_INSTANCES_LIST_STYLE").delete() + setting.objects.using(db_alias).filter(key="VIEW_INSTANCE_DETAIL_BOTTOM_BAR").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_FORMAT").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_BUS").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_CACHE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_IO").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_DISCARD").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_OWNER_UID").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VOLUME_DEFAULT_OWNER_GID").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_CPU_DEFAULT_MODE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_MACHINE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_FIRMWARE_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_ARCH_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="BOOTSTRAP_THEME").delete() + setting.objects.using(db_alias).filter(key="SASS_DIR").delete() + setting.objects.using(db_alias).filter(key="SHOW_ACCESS_ROOT_PASSWORD").delete() + setting.objects.using(db_alias).filter(key="SHOW_ACCESS_SSH_KEYS").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0001_initial'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0003_auto_20200615_0637.py b/appsettings/migrations/0003_auto_20200615_0637.py new file mode 100644 index 0000000..914e031 --- /dev/null +++ b/appsettings/migrations/0003_auto_20200615_0637.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0002_auto_20200527_1603'), + ] + + operations = [ + migrations.AlterField( + model_name='appsettings', + name='choices', + field=models.CharField(max_length=70, verbose_name='choices'), + ), + migrations.AlterField( + model_name='appsettings', + name='description', + field=models.CharField(max_length=100, null=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='appsettings', + name='key', + field=models.CharField(db_index=True, max_length=50, unique=True, verbose_name='key'), + ), + migrations.AlterField( + model_name='appsettings', + name='name', + field=models.CharField(max_length=25, verbose_name='name'), + ), + migrations.AlterField( + model_name='appsettings', + name='value', + field=models.CharField(max_length=25, verbose_name='value'), + ), + ] diff --git a/appsettings/migrations/0004_auto_20200716_0637.py b/appsettings/migrations/0004_auto_20200716_0637.py new file mode 100644 index 0000000..5593e83 --- /dev/null +++ b/appsettings/migrations/0004_auto_20200716_0637.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.13 on 2020-07-16 06:37 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(27, _("Console Scale"), "CONSOLE_SCALE", "False", "True,False", _("Allow console to scaling view")), + setting(28, _("Console View-Only"), "CONSOLE_VIEW_ONLY", "False", "True,False", _("Allow only view not modify")), + setting(29, _("Console Resize Session"), "CONSOLE_RESIZE_SESSION", "False", "True,False", _("Allow to resize session for console")), + setting(30, _("Console Clip Viewport"), "CONSOLE_CLIP_VIEWPORT", "False", "True,False", _("Clip console viewport")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="CONSOLE_SCALE").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_VIEW_ONLY").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_RESIZE_SESSION").delete() + setting.objects.using(db_alias).filter(key="CONSOLE_CLIP_VIEWPORT").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0003_auto_20200615_0637'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0005_auto_20200911_1233.py b/appsettings/migrations/0005_auto_20200911_1233.py new file mode 100644 index 0000000..ac57da7 --- /dev/null +++ b/appsettings/migrations/0005_auto_20200911_1233.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.14 on 2020-09-11 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0004_auto_20200716_0637'), + ] + + operations = [ + migrations.AlterField( + model_name='appsettings', + name='choices', + field=models.CharField(max_length=70, verbose_name='choices'), + ), + ] diff --git a/appsettings/migrations/0006_auto_20220630_0717.py b/appsettings/migrations/0006_auto_20220630_0717.py new file mode 100644 index 0000000..9ffc860 --- /dev/null +++ b/appsettings/migrations/0006_auto_20220630_0717.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.13 on 2022-06-30 07:17 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(31, _("VM DRBD Status"), "VM_DRBD_STATUS", "False", "True,False", _("Show VM DRBD Status")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="VM_DRBD_STATUS").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0005_auto_20200911_1233'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0007_auto_20220905_0918.py b/appsettings/migrations/0007_auto_20220905_0918.py new file mode 100644 index 0000000..229e1cb --- /dev/null +++ b/appsettings/migrations/0007_auto_20220905_0918.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.13 on 2022-06-30 07:17 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + +def update_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="INSTANCE_MACHINE_DEFAULT_TYPE").update(choices="q35,x86_64,virt"), + setting.objects.using(db_alias).filter(key="INSTANCE_ARCH_DEFAULT_TYPE").update(choices="x86_64,i686,aarch64"), + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0006_auto_20220630_0717'), + ] + + operations = [ + migrations.RunPython(update_default_settings, None) + ] diff --git a/appsettings/migrations/0008_auto_20220905_1459.py b/appsettings/migrations/0008_auto_20220905_1459.py new file mode 100644 index 0000000..2390d5d --- /dev/null +++ b/appsettings/migrations/0008_auto_20220905_1459.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.13 on 2022-06-30 07:17 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(32, _("VM CD-ROM Device"), "INSTANCE_CDROM_ADD", "sata", "None,ide,sata,scsi,virtio", _("Add or not cdrom device while instance creating")), + setting(33, _("VM Video Type"), "INSTANCE_VIDEO_DEFAULT_TYPE", "vga", "None,virtio,vga,cirrus,vmvga,bochs,ramfb", _("Change instance default video type")), + setting(34, _("VM Input Device"), "INSTANCE_INPUT_DEFAULT_DEVICE", "default", "None,default,virtio,usb", _("Add or not input device with specify its type")), + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="INSTANCE_CDROM_ADD").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_VIDEO_DEFAULT_TYPE").delete() + setting.objects.using(db_alias).filter(key="INSTANCE_INPUT_DEFAULT_DEVICE").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0007_auto_20220905_0918'), + ] + + operations = [ + migrations.RunPython(add_default_settings, del_default_settings), + ] diff --git a/appsettings/migrations/0009_alter_appsettings_id.py b/appsettings/migrations/0009_alter_appsettings_id.py new file mode 100644 index 0000000..5e588dc --- /dev/null +++ b/appsettings/migrations/0009_alter_appsettings_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-10-30 17:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0008_auto_20220905_1459'), + ] + + operations = [ + migrations.AlterField( + model_name='appsettings', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/appsettings/migrations/0010_auto_20231030_1305.py b/appsettings/migrations/0010_auto_20231030_1305.py new file mode 100644 index 0000000..606909d --- /dev/null +++ b/appsettings/migrations/0010_auto_20231030_1305.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-10-30 17:05 + +from django.db import migrations +from django.utils.translation import gettext_lazy as _ + +def add_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).bulk_create([ + setting(35, _("VM NIC Type"), "INSTANCE_NIC_DEFAULT_TYPE", "default", "default,e1000,e1000e,rt18139,virtio", _("Change instance default NIC type")) + ]) + + +def del_default_settings(apps, schema_editor): + setting = apps.get_model("appsettings", "AppSettings") + db_alias = schema_editor.connection.alias + setting.objects.using(db_alias).filter(key="INSTANCE_NIC_DEFAULT_TYPE").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('appsettings', '0009_alter_appsettings_id') + ] + + operations = [ + migrations.RunPython(add_default_settings,del_default_settings) + ] diff --git a/appsettings/migrations/0011_alter_appsettings_id.py b/appsettings/migrations/0011_alter_appsettings_id.py new file mode 100644 index 0000000..dfbfe60 --- /dev/null +++ b/appsettings/migrations/0011_alter_appsettings_id.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.10 on 2024-02-14 11:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("appsettings", "0010_auto_20231030_1305"), + ] + + operations = [ + migrations.AlterField( + model_name="appsettings", + name="id", + field=models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/instances/templatetags/__init__.py b/appsettings/migrations/__init__.py similarity index 100% rename from instances/templatetags/__init__.py rename to appsettings/migrations/__init__.py diff --git a/appsettings/models.py b/appsettings/models.py new file mode 100644 index 0000000..0cfb4e7 --- /dev/null +++ b/appsettings/models.py @@ -0,0 +1,13 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class AppSettings(models.Model): + def choices_as_list(self): + return self.choices.split(",") + + name = models.CharField(_("name"), max_length=25, null=False) + key = models.CharField(_("key"), db_index=True, max_length=50, unique=True) + value = models.CharField(_("value"), max_length=25) + choices = models.CharField(_("choices"), max_length=70) + description = models.CharField(_("description"), max_length=100, null=True) diff --git a/appsettings/settings.py b/appsettings/settings.py new file mode 100644 index 0000000..d575f37 --- /dev/null +++ b/appsettings/settings.py @@ -0,0 +1,18 @@ +from .models import AppSettings + + +class Settings: + pass + + +app_settings = Settings() + + +def get_settings(): + try: + entries = AppSettings.objects.all() + except: + pass + + for entry in entries: + setattr(app_settings, entry.key, entry.value) diff --git a/appsettings/templates/appsettings.html b/appsettings/templates/appsettings.html new file mode 100644 index 0000000..4a684b5 --- /dev/null +++ b/appsettings/templates/appsettings.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Edit Settings" %}{% endblock title %} + +{% block page_heading %}{% trans "Edit Settings" %}{% endblock page_heading %} + +{% block content %} +
+
+ +
{% csrf_token %} +
+ + +
+ +
+
+
+ {% if request.user.is_superuser %} +
{% csrf_token %} +
+ +
+ +
+
+
+
{% csrf_token %} +
+ +
+ + {% trans "After change please full refresh page with 'Ctrl + F5' "%} +
+
+
+ {% endif %} + + {% for setting in appsettings %} +
{% csrf_token %} +
+ +
+ {% if setting.choices %} + + {% else %} + + {% endif%} +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/create/tests.py b/appsettings/tests.py similarity index 100% rename from create/tests.py rename to appsettings/tests.py diff --git a/appsettings/views.py b/appsettings/views.py new file mode 100644 index 0000000..a7b60f3 --- /dev/null +++ b/appsettings/views.py @@ -0,0 +1,103 @@ +import os + +import sass +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.utils.translation import gettext_noop as _ +from logs.views import addlogmsg + +from appsettings.models import AppSettings + + +@login_required +def appsettings(request): + """ + :param request: + :return: + """ + main_css = "wvc-main.min.css" + sass_dir = AppSettings.objects.get(key="SASS_DIR") + bootstrap_theme = AppSettings.objects.get(key="BOOTSTRAP_THEME") + try: + themes_list = os.listdir(sass_dir.value + "/wvc-themes") + except FileNotFoundError as err: + messages.error(request, err) + addlogmsg(request.user.username, "-", "", err) + + # Bootstrap settings related with filesystems, because of that they are excluded from other settings + appsettings = AppSettings.objects.exclude( + description__startswith="Bootstrap" + ).order_by("name") + + if request.method == "POST": + if "SASS_DIR" in request.POST: + try: + sass_dir.value = request.POST.get("SASS_DIR", "") + sass_dir.save() + + msg = _("SASS directory path is changed. Now: %(dir)s") % { + "dir": sass_dir.value + } + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "-", "", msg) + return HttpResponseRedirect(request.get_full_path()) + + if "BOOTSTRAP_THEME" in request.POST: + theme = request.POST.get("BOOTSTRAP_THEME", "") + scss_var = f"@import '{sass_dir.value}/wvc-themes/{theme}/variables';" + # scss_boot = f"@import '{sass_dir.value}/bootstrap/bootstrap.scss';" + scss_boot = f"@import '{sass_dir.value}/bootstrap-overrides.scss';" + scss_bootswatch = ( + f"@import '{sass_dir.value}/wvc-themes/{theme}/bootswatch';" + ) + + try: + with open(sass_dir.value + "/wvc-main.scss", "w") as main: + main.write( + scss_var + "\n" + scss_boot + "\n" + scss_bootswatch + "\n" + ) + + css_compressed = sass.compile( + string=scss_var + "\n" + scss_boot + "\n" + scss_bootswatch, + output_style="compressed", + ) + with open("static/css/" + main_css, "w") as css: + css.write(css_compressed) + + bootstrap_theme.value = theme + bootstrap_theme.save() + + msg = _("Theme is changed. Now: %(theme)s") % {"theme": theme} + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "-", "", msg) + return HttpResponseRedirect(request.get_full_path()) + + for setting in appsettings: + if setting.key in request.POST: + try: + setting.value = request.POST.get(setting.key, "") + setting.save() + + msg = _("%(setting)s is changed. Now: %(value)s") % { + "setting": setting.name, + "value": setting.value, + } + messages.success(request, msg) + except Exception as err: + msg = err + messages.error(request, msg) + + addlogmsg(request.user.username, "-", "", msg) + return HttpResponseRedirect(request.get_full_path()) + + return render(request, "appsettings.html", locals()) diff --git a/computes/admin.py b/computes/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/computes/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/computes/api/serializers.py b/computes/api/serializers.py new file mode 100644 index 0000000..d816934 --- /dev/null +++ b/computes/api/serializers.py @@ -0,0 +1,20 @@ +from computes.models import Compute +from rest_framework import serializers +from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS + + +class ComputeSerializer(serializers.ModelSerializer): + # Use for the input. + password = serializers.CharField(style={"input_type": "password"}) + # Use a radio input instead of a select input. + conn_types = ( + (CONN_SSH, "SSH"), + (CONN_TCP, "TCP"), + (CONN_TLS, "TLS"), + (CONN_SOCKET, "SOCK"), + ) + type = serializers.ChoiceField(choices=conn_types) + + class Meta: + model = Compute + fields = ["id", "name", "hostname", "login", "password", "type", "details"] diff --git a/computes/api/viewsets.py b/computes/api/viewsets.py new file mode 100644 index 0000000..4d0b1f9 --- /dev/null +++ b/computes/api/viewsets.py @@ -0,0 +1,56 @@ +from computes.models import Compute +from rest_framework import permissions, viewsets +from rest_framework.response import Response +from vrtManager.create import wvmCreate + +from .serializers import ComputeSerializer + + +class ComputeViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows computes to be viewed or edited. + """ + + queryset = Compute.objects.all().order_by("name") + serializer_class = ComputeSerializer + permission_classes = [permissions.IsAuthenticated] + + +class ComputeArchitecturesView(viewsets.ViewSet): + def list(self, request, compute_pk=None): + """ + Return a list of supported host architectures. + """ + compute = Compute.objects.get(pk=compute_pk) + conn = wvmCreate( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + return Response(conn.get_hypervisors_machines()) + + def retrieve(self, request, compute_pk=None, pk=None): + compute = Compute.objects.get(pk=compute_pk) + conn = wvmCreate( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + return Response(conn.get_machine_types(pk)) + + +class ComputeMachinesView(viewsets.ViewSet): + def list(self, request, compute_pk=None, archs_pk=None): + """ + Return a list of supported host architectures. + """ + compute = Compute.objects.get(pk=compute_pk) + conn = wvmCreate( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + return Response(conn.get_machine_types(archs_pk)) diff --git a/computes/forms.py b/computes/forms.py index a626106..1128695 100644 --- a/computes/forms.py +++ b/computes/forms.py @@ -1,164 +1,45 @@ -import re from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ +from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS + from computes.models import Compute - -class ComputeAddTcpForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(error_messages={'required': _('No password has been entered')}, - max_length=100) - - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-z0-9.-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) +from .validators import validate_hostname -class ComputeAddSshForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=20) +class TcpComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname]) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TCP) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The name of the host must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The name of the host must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + widgets = {"password": forms.PasswordInput()} + fields = "__all__" -class ComputeAddTlsForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(error_messages={'required': _('No password has been entered')}, - max_length=100) +class SshComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname], label=_("FQDN/IP")) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SSH) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-z0-9.-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - try: - Compute.objects.get(hostname=hostname) - except Compute.DoesNotExist: - return hostname - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + exclude = ["password"] -class ComputeEditHostForm(forms.Form): - host_id = forms.CharField() - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) - hostname = forms.CharField(error_messages={'required': _('No IP / Domain name has been entered')}, - max_length=100) - login = forms.CharField(error_messages={'required': _('No login has been entered')}, - max_length=100) - password = forms.CharField(max_length=100) +class TlsComputeForm(forms.ModelForm): + hostname = forms.CharField(validators=[validate_hostname]) + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TLS) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The name of the host must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The name of the host must not exceed 20 characters')) - return name - - def clean_hostname(self): - hostname = self.cleaned_data['hostname'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', hostname) - wrong_ip = re.match('^0.|^255.', hostname) - if have_symbol: - raise forms.ValidationError(_('Hostname must contain only numbers, or the domain name separated by "."')) - elif wrong_ip: - raise forms.ValidationError(_('Wrong IP address')) - return hostname + class Meta: + model = Compute + widgets = {"password": forms.PasswordInput()} + fields = "__all__" -class ComputeAddSocketForm(forms.Form): - name = forms.CharField(error_messages={'required': _('No hostname has been entered')}, - max_length=20) +class SocketComputeForm(forms.ModelForm): + hostname = forms.CharField(widget=forms.HiddenInput, initial="localhost") + type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SOCKET) - def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('[^a-zA-Z0-9._-]+', name) - if have_symbol: - raise forms.ValidationError(_('The host name must not contain any special characters')) - elif len(name) > 20: - raise forms.ValidationError(_('The host name must not exceed 20 characters')) - try: - Compute.objects.get(name=name) - except Compute.DoesNotExist: - return name - raise forms.ValidationError(_('This host is already connected')) + class Meta: + model = Compute + fields = ["name", "details", "hostname", "type"] diff --git a/computes/migrations/0001_initial.py b/computes/migrations/0001_initial.py index e8d6139..c8f9183 100644 --- a/computes/migrations/0001_initial.py +++ b/computes/migrations/0001_initial.py @@ -1,11 +1,12 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +# Generated by Django 2.2.10 on 2020-01-28 07:01 -from django.db import models, migrations +from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ ] @@ -13,15 +14,13 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Compute', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=20)), - ('hostname', models.CharField(max_length=20)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64)), + ('hostname', models.CharField(max_length=64)), ('login', models.CharField(max_length=20)), - ('password', models.CharField(max_length=14, null=True, blank=True)), + ('password', models.CharField(blank=True, max_length=14, null=True)), + ('details', models.CharField(blank=True, max_length=64, null=True)), ('type', models.IntegerField()), ], - options={ - }, - bases=(models.Model,), ), ] diff --git a/computes/migrations/0002_auto_20200529_1320.py b/computes/migrations/0002_auto_20200529_1320.py new file mode 100644 index 0000000..194d885 --- /dev/null +++ b/computes/migrations/0002_auto_20200529_1320.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-05-29 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('computes', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='compute', + name='name', + field=models.CharField(max_length=64, unique=True), + ), + ] diff --git a/computes/migrations/0003_auto_20200615_0637.py b/computes/migrations/0003_auto_20200615_0637.py new file mode 100644 index 0000000..34cd59a --- /dev/null +++ b/computes/migrations/0003_auto_20200615_0637.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.13 on 2020-06-15 06:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('computes', '0002_auto_20200529_1320'), + ] + + operations = [ + migrations.AlterField( + model_name='compute', + name='details', + field=models.CharField(blank=True, max_length=64, null=True, verbose_name='details'), + ), + migrations.AlterField( + model_name='compute', + name='hostname', + field=models.CharField(max_length=64, verbose_name='hostname'), + ), + migrations.AlterField( + model_name='compute', + name='login', + field=models.CharField(max_length=20, verbose_name='login'), + ), + migrations.AlterField( + model_name='compute', + name='name', + field=models.CharField(max_length=64, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='compute', + name='password', + field=models.CharField(blank=True, max_length=14, null=True, verbose_name='password'), + ), + ] diff --git a/computes/models.py b/computes/models.py index 6ee7de8..3f17254 100644 --- a/computes/models.py +++ b/computes/models.py @@ -1,12 +1,65 @@ -from django.db import models +from django.db.models import CharField, IntegerField, Model +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from libvirt import virConnect + +from vrtManager.connection import connection_manager +from vrtManager.hostdetails import wvmHostDetails -class Compute(models.Model): - name = models.CharField(max_length=20) - hostname = models.CharField(max_length=20) - login = models.CharField(max_length=20) - password = models.CharField(max_length=14, blank=True, null=True) - type = models.IntegerField() +class Compute(Model): + name = CharField(_("name"), max_length=64, unique=True) + hostname = CharField(_("hostname"), max_length=64) + login = CharField(_("login"), max_length=20) + password = CharField(_("password"), max_length=14, blank=True, null=True) + details = CharField(_("details"), max_length=64, null=True, blank=True) + type = IntegerField() - def __unicode__(self): - return self.hostname + @cached_property + def status(self): + # return connection_manager.host_is_up(self.type, self.hostname) + # TODO: looks like socket has problems connecting via VPN + if isinstance(self.connection, virConnect): + return True + else: + return self.connection + + @cached_property + def connection(self): + try: + return connection_manager.get_connection( + self.hostname, + self.login, + self.password, + self.type, + ) + except Exception as e: + return e + + @cached_property + def proxy(self): + return wvmHostDetails( + self.hostname, + self.login, + self.password, + self.type, + ) + + @cached_property + def cpu_count(self): + return self.proxy.get_node_info()[3] + + @cached_property + def cpu_usage(self): + return round(self.proxy.get_cpu_usage(diff=False).get('usage')) + + @cached_property + def ram_size(self): + return self.proxy.get_node_info()[2] + + @cached_property + def ram_usage(self): + return self.proxy.get_memory_usage()["percent"] + + def __str__(self): + return self.name diff --git a/computes/templates/computes.html b/computes/templates/computes.html deleted file mode 100644 index 7c1c28f..0000000 --- a/computes/templates/computes.html +++ /dev/null @@ -1,221 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block title %}{% trans "Computes" %}{% endblock %} -{% block content %} - -
-
- {% include 'create_comp_block.html' %} -

{% trans "Computes" %}

-
-
- - - {% include 'errors_block.html' %} - -
- {% if computes_info %} - {% for compute in computes_info %} -
-
-
- {% ifequal compute.status 1 %} -

- {{ compute.name }} - - - -

- {% else %} -

{{ compute.name }} - - - -

- {% endifequal %} -
-
-
-
-

{% trans "Status:" %}

-
-
- {% if compute.status %} -

{% trans "Connected" %}

- {% else %} -

{% trans "Not Connected" %}

- {% endif %} -
-
- - - - -
-
-
- {% endfor %} - {% else %} -
-
- - {% trans "Warning:" %} {% trans "Hypervisor doesn't have any Computes" %} -
-
- {% endif %} -
-{% endblock %} diff --git a/computes/templates/computes/form.html b/computes/templates/computes/form.html new file mode 100644 index 0000000..d867ab4 --- /dev/null +++ b/computes/templates/computes/form.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} +{% load i18n %} + +{% block title %}{% trans "Add Compute" %}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout='horizontal' %} +
+
+ + {% bs_icon 'x-square-fill' %} {% trans "Cancel" %} + +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html new file mode 100644 index 0000000..8a41bc9 --- /dev/null +++ b/computes/templates/computes/instances.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load bootstrap_icons %} +{% block title %}{% trans "Instances" %} - {{ compute.name }}{% endblock %} +{% block style %} + +{% endblock %} +{% block page_heading %}{{ compute.name }} - {% trans "Instances" %}{% endblock page_heading %} + +{% block page_heading_extra %} + + {% bs_icon 'plus-circle-fill' %} + +{% if instances %} + {% include 'search_block.html' %} +{% endif %} +{% endblock page_heading_extra %} + +{% block content %} + +
+
+ {% if not instances %} +
+ {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: + {% trans "Hypervisor doesn't have any Instances" %} +
+
+ {% else %} + + + + + + + + + + + + + {% for instance in instances %} + + + + + + + + + {% endfor %} + +
{% trans 'Name' %}
{% trans 'Description' %}
{% trans 'User' %}{% trans 'Status' %}{% trans 'VCPU' %}{% trans 'Memory' %}{% trans 'Actions' %}
+ {{ instance.name }} +
+

{{ instance.title }}

+
+ + {% if instance.userinstance_set.all.count > 0 %} + {{ instance.userinstance_set.all.0.user }} + {% if instance.userinstance_set.all.count > 1 %} + (+{{ instance.userinstance_set.all.count|add:"-1" }}) + {% endif %} + {% endif %} + + + {% if instance.proxy.instance.info.0 == 1 %} + {% trans "Active" %} + {% endif %} + {% if instance.proxy.instance.info.0 == 5 %} + {% trans "Off" %} + {% endif %} + {% if instance.proxy.instance.info.0 == 3 %} + {% trans "Suspended" %} + {% endif %} + {{ instance.proxy.instance.info.3 }}

{% widthratio instance.proxy.instance.info.1 1024 1 %} MiB

+ {% include 'instance_actions.html' %} +
+ {% endif %} +
+
+{% endblock %} +{% block script %} + + +{% endblock %} \ No newline at end of file diff --git a/computes/templates/computes/list.html b/computes/templates/computes/list.html new file mode 100644 index 0000000..3a5f171 --- /dev/null +++ b/computes/templates/computes/list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load common_tags %} +{% load bootstrap_icons %} +{% block title %}{% trans "Computes" %}{% endblock %} +{% block content %} +
+
+ {% include 'create_comp_block.html' %} + {% include 'search_block.html' %} + +
+
+
+ {% if not computes %} +
+
+ {% bs_icon 'exclamation-triangle'%} {% trans "Warning" %}: {% trans "You don't have any computes" %} +
+
+ {% else %} +
+ + + + + + + + + + + {% for compute in computes %} + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Status" %}{% trans "Details" %}{% trans "Actions" %}
+ {{ compute.name }} + + {% if compute.status is True %}{% trans "Connected" %}{% else %}{% trans "Not Connected" %}{% endif %} + + {{ compute.details|default:"" }} + +
+ {% if compute.status is True %} + {% bs_icon 'eye-fill' %} + {% else %} + {% bs_icon 'eye' %} + {% endif %} + {% bs_icon 'pencil-fill' %} + {% bs_icon 'x-circle-fill' %} +
+
+
+ {% endif %} +
+{% endblock content %} + +{% block script %} + + +{% endblock script %} diff --git a/computes/templates/create_comp_block.html b/computes/templates/create_comp_block.html index 57e327a..3d1b6b0 100644 --- a/computes/templates/create_comp_block.html +++ b/computes/templates/create_comp_block.html @@ -1,159 +1,11 @@ {% load i18n %} -{% if request.user.is_superuser %} - - - +{% load django_bootstrap5 %} +{% load bootstrap_icons %} - - -{% endif %} \ No newline at end of file + diff --git a/computes/templates/overview.html b/computes/templates/overview.html index 09d5d83..0837983 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -1,150 +1,247 @@ {% extends "base.html" %} {% load i18n %} -{% load staticfiles %} +{% load static %} +{% load bootstrap_icons %} + {% block title %}{% trans "Overview" %} - {{ compute.name }}{% endblock %} + +{% block page_heading %}{{ compute.name }}{% endblock page_heading %} + {% block content %} - -
-
-

{{ compute.name }}

- -
-
- + - {% include 'errors_block.html' %} - -
- -
-

{% trans "Hostname" %}

-

{% trans "Hypervisor" %}

-

{% trans "Memory" %}

-

{% trans "Architecture" %}

-

{% trans "Logical CPUs" %}

-

{% trans "Processor" %}

-

{% trans "Connection" %}

-
-
-

{{ hostname }}

-

{{ hypervisor }}

-

{{ host_memory|filesizeformat }}

-

{{ host_arch }}

-

{{ logical_cpu }}

-

{{ model_cpu }}

-

{{ uri_conn }}

-
-
-
-
- -
-
-

{% trans "CPU utilization" %}

-
-
-
-
- -
-
-
+
+ +
+
{% trans "Hostname" %}
+
{{ hostname }}
+
{% trans "Hypervisors" %}
+
+
+
{% trans "Emulator" %}
+
{{ emulator }}
+
{% trans "Version" %}
+
+ {% trans 'Qemu' %} + {{ version }}   + {% trans 'Libvirt' %} + {{ lib_version }}   +
+
{% trans "Memory" %}
+
{{ host_memory|filesizeformat }}
+
{% trans "Architecture" %}
+
{{ host_arch }}
+
{% trans "Logical CPUs" %}
+
{{ logical_cpu }}
+
{% trans "Processor" %}
+
{{ model_cpu }}
+
{% trans "Connection" %}
+
{{ uri_conn }}
+
{% trans "Details" %}
+
{{ compute.details }}
+
+ + +
+
+
+
+ {% bs_icon 'arrow-right' %} + {% trans "CPU Utilization" %} +
+ +
+
+ +
+
+
+ {% bs_icon 'arrow-right'%} {% trans "RAM Utilization" %} +
+
+
+
{% endblock %} {% block script %} - + {% endblock %} diff --git a/computes/tests.py b/computes/tests.py index 7ce503c..5f5cd67 100644 --- a/computes/tests.py +++ b/computes/tests.py @@ -1,3 +1,159 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import reverse from django.test import TestCase -# Create your tests here. +from .models import Compute + + +class ComputesTestCase(TestCase): + def setUp(self): + self.client.login(username="admin", password="admin") + Compute( + name="local", + hostname="localhost", + login="", + password="", + details="local", + type=4, + ).save() + + def test_index(self): + response = self.client.get(reverse("computes")) + self.assertEqual(response.status_code, 200) + + def test_create_update_delete(self): + response = self.client.get(reverse("add_socket_host")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("add_socket_host"), + { + "name": "l1", + "details": "Created", + "hostname": "localhost", + "type": 4, + }, + ) + self.assertRedirects(response, reverse("computes")) + + compute = Compute.objects.get(pk=2) + self.assertEqual(compute.name, "l1") + self.assertEqual(compute.details, "Created") + + response = self.client.get(reverse("compute_update", args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("compute_update", args=[2]), + { + "name": "l2", + "details": "Updated", + "hostname": "localhost", + "type": 4, + }, + ) + self.assertRedirects(response, reverse("computes")) + + compute = Compute.objects.get(pk=2) + self.assertEqual(compute.name, "l2") + self.assertEqual(compute.details, "Updated") + + response = self.client.get(reverse("compute_delete", args=[2])) + self.assertEqual(response.status_code, 200) + + response = self.client.post(reverse("compute_delete", args=[2])) + self.assertRedirects(response, reverse("computes")) + + with self.assertRaises(ObjectDoesNotExist): + Compute.objects.get(id=2) + + def test_overview(self): + response = self.client.get(reverse("overview", args=[1])) + self.assertEqual(response.status_code, 200) + + def test_graph(self): + response = self.client.get(reverse("compute_graph", args=[1])) + self.assertEqual(response.status_code, 200) + + def test_instances(self): + response = self.client.get(reverse("instances", args=[1])) + self.assertEqual(response.status_code, 200) + + def test_storages(self): + response = self.client.get(reverse("storages", args=[1])) + self.assertEqual(response.status_code, 200) + + def test_storage(self): + pass + + def test_default_storage_volumes(self): + response = self.client.get( + reverse("volumes", kwargs={"compute_id": 1, "pool": "default"}) + ) + self.assertEqual(response.status_code, 200) + + def test_default_storage(self): + response = self.client.get( + reverse("storage", kwargs={"compute_id": 1, "pool": "default"}) + ) + self.assertEqual(response.status_code, 200) + + def test_networks(self): + response = self.client.get(reverse("networks", args=[1])) + self.assertEqual(response.status_code, 200) + + def test_default_network(self): + response = self.client.get( + reverse("network", kwargs={"compute_id": 1, "pool": "default"}) + ) + self.assertEqual(response.status_code, 200) + + def test_interfaces(self): + response = self.client.get(reverse("interfaces", args=[1])) + self.assertEqual(response.status_code, 200) + + # TODO: add test for single interface + + def test_nwfilters(self): + response = self.client.get(reverse("nwfilters", args=[1])) + self.assertEqual(response.status_code, 200) + + # TODO: add test for single nwfilter + + def test_secrets(self): + response = self.client.get(reverse("virtsecrets", args=[1])) + self.assertEqual(response.status_code, 200) + + # def test_create_instance_select_type(self): + # response = self.client.get(reverse('create_instance_select_type', args=[1])) + # self.assertEqual(response.status_code, 200) + + # TODO: create_instance + + def test_machines(self): + response = self.client.get( + reverse("machines", kwargs={"compute_id": 1, "arch": "x86_64"}) + ) + self.assertEqual(response.status_code, 200) + + def test_compute_disk_buses(self): + response = self.client.get( + reverse( + "buses", + kwargs={ + "compute_id": 1, + "arch": "x86_64", + "machine": "pc", + "disk": "disk", + }, + ) + ) + self.assertEqual(response.status_code, 200) + + def test_dom_capabilities(self): + response = self.client.get( + reverse( + "domcaps", kwargs={"compute_id": 1, "arch": "x86_64", "machine": "pc"} + ) + ) + self.assertEqual(response.status_code, 200) diff --git a/computes/urls.py b/computes/urls.py index 58a0497..99a3743 100644 --- a/computes/urls.py +++ b/computes/urls.py @@ -1,9 +1,79 @@ -from django.conf.urls import url -from . import views +from virtsecrets.views import secrets + +from django.urls import include, path +from interfaces.views import interface, interfaces +from networks.views import network, networks +from nwfilters.views import nwfilter, nwfilters +from storages.views import create_volume, get_volumes, storage, storages + +from . import forms, views urlpatterns = [ - url(r'^$', views.computes, name='computes'), - url(r'^overview/(?P[0-9]+)/$', views.overview, name='overview'), - url(r'^statistics/(?P[0-9]+)/$', - views.compute_graph, name='compute_graph'), + path("", views.computes, name="computes"), + path( + "add_tcp_host/", + views.compute_create, + {"FormClass": forms.TcpComputeForm}, + name="add_tcp_host", + ), + path( + "add_ssh_host/", + views.compute_create, + {"FormClass": forms.SshComputeForm}, + name="add_ssh_host", + ), + path( + "add_tls_host/", + views.compute_create, + {"FormClass": forms.TlsComputeForm}, + name="add_tls_host", + ), + path( + "add_socket_host/", + views.compute_create, + {"FormClass": forms.SocketComputeForm}, + name="add_socket_host", + ), + path( + "/", + include( + [ + path("", views.overview, name="overview"), + path("update/", views.compute_update, name="compute_update"), + path("delete/", views.compute_delete, name="compute_delete"), + path("statistics", views.compute_graph, name="compute_graph"), + path("instances/", views.instances, name="instances"), + path("storages/", storages, name="storages"), + path("storage//volumes/", get_volumes, name="volumes"), + path("storage//", storage, name="storage"), + path( + "storage//create_volume/", + create_volume, + name="create_volume", + ), + path("networks/", networks, name="networks"), + path("network//", network, name="network"), + path("interfaces/", interfaces, name="interfaces"), + path("interface//", interface, name="interface"), + path("nwfilters/", nwfilters, name="nwfilters"), + path("nwfilter//", nwfilter, name="nwfilter"), + path("virtsecrets/", secrets, name="virtsecrets"), + path( + "archs//machines", + views.get_compute_machine_types, + name="machines", + ), + path( + "archs//machines//disks//buses", + views.get_compute_disk_buses, + name="buses", + ), + path( + "archs//machines//capabilities", + views.get_dom_capabilities, + name="domcaps", + ), + ] + ), + ), ] diff --git a/computes/utils.py b/computes/utils.py new file mode 100644 index 0000000..80fec5a --- /dev/null +++ b/computes/utils.py @@ -0,0 +1,17 @@ +from instances.models import Instance + + +def refresh_instance_database(compute): + domains = compute.proxy.wvm.listAllDomains() + domain_names = [d.name() for d in domains] + domain_uuids = [d.UUIDString() for d in domains] + # Delete instances that're not on host from DB + Instance.objects.filter(compute=compute).exclude(name__in=domain_names).delete() + Instance.objects.filter(compute=compute).exclude(uuid__in=domain_uuids).delete() + # Create instances that're on host but not in DB + names = Instance.objects.filter(compute=compute).values_list("name", flat=True) + for domain in domains: + if domain.name() not in names: + Instance( + compute=compute, name=domain.name(), uuid=domain.UUIDString() + ).save() diff --git a/computes/validators.py b/computes/validators.py new file mode 100644 index 0000000..dbb79ee --- /dev/null +++ b/computes/validators.py @@ -0,0 +1,26 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +have_symbol = re.compile("[^a-zA-Z0-9._-]+") +wrong_ip = re.compile("^0.|^255.") +wrong_name = re.compile("[^a-zA-Z0-9._-]+") + + +def validate_hostname(value): + sym = have_symbol.match(value) + wip = wrong_ip.match(value) + + if sym: + raise ValidationError( + _('Hostname must contain only numbers, or the domain name separated by "."') + ) + elif wip: + raise ValidationError(_("Wrong IP address")) + + +def validate_name(value): + have_symbol = wrong_name.match("[^a-zA-Z0-9._-]+") + if have_symbol: + raise ValidationError(_("The hostname must not contain any special characters")) diff --git a/computes/views.py b/computes/views.py index 39af4d0..22b7c3e 100644 --- a/computes/views.py +++ b/computes/views.py @@ -1,225 +1,278 @@ -import time import json -from django.http import HttpResponse, HttpResponseRedirect -from django.core.urlresolvers import reverse -from django.shortcuts import render, get_object_or_404 -from computes.models import Compute + +from admin.decorators import superuser_only +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone from instances.models import Instance -from accounts.models import UserInstance -from computes.forms import ComputeAddTcpForm, ComputeAddSshForm, ComputeEditHostForm, ComputeAddTlsForm, ComputeAddSocketForm -from vrtManager.hostdetails import wvmHostDetails -from vrtManager.connection import CONN_SSH, CONN_TCP, CONN_TLS, CONN_SOCKET, connection_manager from libvirt import libvirtError +from vrtManager.connection import ( + CONN_SOCKET, + CONN_SSH, + CONN_TCP, + CONN_TLS, + connection_manager, + wvmConnect, +) +from vrtManager.hostdetails import wvmHostDetails + +from computes.forms import ( + SocketComputeForm, + SshComputeForm, + TcpComputeForm, + TlsComputeForm, +) +from computes.models import Compute + +from . import utils +@superuser_only def computes(request): """ :param request: :return: """ - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) + computes = Compute.objects.filter().order_by("name") - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) - - def get_hosts_status(computes): - """ - Function return all hosts all vds on host - """ - compute_data = [] - for compute in computes: - compute_data.append({'id': compute.id, - 'name': compute.name, - 'hostname': compute.hostname, - 'status': connection_manager.host_is_up(compute.type, compute.hostname), - 'type': compute.type, - 'login': compute.login, - 'password': compute.password - }) - return compute_data - - error_messages = [] - computes = Compute.objects.filter() - computes_info = get_hosts_status(computes) - - if request.method == 'POST': - if 'host_del' in request.POST: - compute_id = request.POST.get('host_id', '') - try: - del_user_inst_on_host = UserInstance.objects.filter(instance__compute_id=compute_id) - del_user_inst_on_host.delete() - finally: - try: - del_inst_on_host = Instance.objects.filter(compute_id=compute_id) - del_inst_on_host.delete() - finally: - del_host = Compute.objects.get(id=compute_id) - del_host.delete() - return HttpResponseRedirect(request.get_full_path()) - if 'host_tcp_add' in request.POST: - form = ComputeAddTcpForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_tcp_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_TCP, - login=data['login'], - password=data['password']) - new_tcp_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_ssh_add' in request.POST: - form = ComputeAddSshForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_ssh_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_SSH, - login=data['login']) - new_ssh_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_tls_add' in request.POST: - form = ComputeAddTlsForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_tls_host = Compute(name=data['name'], - hostname=data['hostname'], - type=CONN_TLS, - login=data['login'], - password=data['password']) - new_tls_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_socket_add' in request.POST: - form = ComputeAddSocketForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - new_socket_host = Compute(name=data['name'], - hostname='localhost', - type=CONN_SOCKET, - login='', - password='') - new_socket_host.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - if 'host_edit' in request.POST: - form = ComputeEditHostForm(request.POST) - if form.is_valid(): - data = form.cleaned_data - compute_edit = Compute.objects.get(id=data['host_id']) - compute_edit.name = data['name'] - compute_edit.hostname = data['hostname'] - compute_edit.login = data['login'] - compute_edit.password = data['password'] - compute_edit.save() - return HttpResponseRedirect(request.get_full_path()) - else: - for msg_err in form.errors.values(): - error_messages.append(msg_err.as_text()) - return render(request, 'computes.html', locals()) + return render(request, "computes/list.html", {"computes": computes}) +@superuser_only def overview(request, compute_id): - """ - :param request: - :return: - """ + compute = get_object_or_404(Compute, pk=compute_id) + status = ( + "true" + if connection_manager.host_is_up(compute.type, compute.hostname) is True + else "false" + ) - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) + conn = wvmHostDetails( + compute.hostname, + compute.login, + compute.password, + compute.type, + ) + ( + hostname, + host_arch, + host_memory, + logical_cpu, + model_cpu, + uri_conn, + ) = conn.get_node_info() + hypervisor = conn.get_hypervisors_domain_types() + mem_usage = conn.get_memory_usage() + emulator = conn.get_emulator(host_arch) + version = conn.get_version() + lib_version = conn.get_lib_version() + conn.close() - if not request.user.is_superuser: - return HttpResponseRedirect(reverse('index')) + return render(request, "overview.html", locals()) - error_messages = [] + +@superuser_only +def instances(request, compute_id): compute = get_object_or_404(Compute, pk=compute_id) - try: - conn = wvmHostDetails(compute.hostname, - compute.login, - compute.password, - compute.type) - hostname, host_arch, host_memory, logical_cpu, model_cpu, uri_conn = conn.get_node_info() - hypervisor = conn.hypervisor_type() - mem_usage = conn.get_memory_usage() - conn.close() - except libvirtError as lib_err: - error_messages.append(lib_err) + utils.refresh_instance_database(compute) + instances = Instance.objects.filter(compute=compute).prefetch_related( + "userinstance_set" + ) - return render(request, 'overview.html', locals()) + return render( + request, "computes/instances.html", {"compute": compute, "instances": instances} + ) + + +@superuser_only +def compute_create(request, FormClass): + form = FormClass(request.POST or None) + if form.is_valid(): + form.save() + return redirect(reverse("computes")) + + return render(request, "computes/form.html", {"form": form}) + + +@superuser_only +def compute_update(request, compute_id): + compute = get_object_or_404(Compute, pk=compute_id) + + if compute.type == 1: + FormClass = TcpComputeForm + elif compute.type == 2: + FormClass = SshComputeForm + elif compute.type == 3: + FormClass = TlsComputeForm + elif compute.type == 4: + FormClass = SocketComputeForm + + form = FormClass(request.POST or None, instance=compute) + if form.is_valid(): + form.save() + return redirect(reverse("computes")) + + return render(request, "computes/form.html", {"form": form}) + + +@superuser_only +def compute_delete(request, compute_id): + compute = get_object_or_404(Compute, pk=compute_id) + if request.method == "POST": + compute.delete() + return redirect("computes") + + return render( + request, + "common/confirm_delete.html", + {"object": compute}, + ) def compute_graph(request, compute_id): """ :param request: + :param compute_id: :return: """ + comp_mgr = ComputeManager(compute_id) + data = comp_mgr.compute_graph() - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('login')) - - points = 5 - datasets = {} - cookies = {} - compute = get_object_or_404(Compute, pk=compute_id) - curent_time = time.strftime("%H:%M:%S") - - try: - conn = wvmHostDetails(compute.hostname, - compute.login, - compute.password, - compute.type) - cpu_usage = conn.get_cpu_usage() - mem_usage = conn.get_memory_usage() - conn.close() - except libvirtError: - cpu_usage = 0 - mem_usage = 0 - - try: - cookies['cpu'] = request.COOKIES['cpu'] - cookies['mem'] = request.COOKIES['mem'] - cookies['timer'] = request.COOKIES['timer'] - except KeyError: - cookies['cpu'] = None - cookies['mem'] = None - - if not cookies['cpu'] or not cookies['mem']: - datasets['cpu'] = [0] * points - datasets['mem'] = [0] * points - datasets['timer'] = [0] * points - else: - datasets['cpu'] = eval(cookies['cpu']) - datasets['mem'] = eval(cookies['mem']) - datasets['timer'] = eval(cookies['timer']) - - datasets['timer'].append(curent_time) - datasets['cpu'].append(int(cpu_usage['usage'])) - datasets['mem'].append(int(mem_usage['usage']) / 1048576) - - if len(datasets['timer']) > points: - datasets['timer'].pop(0) - if len(datasets['cpu']) > points: - datasets['cpu'].pop(0) - if len(datasets['mem']) > points: - datasets['mem'].pop(0) - - data = json.dumps({'cpudata': datasets['cpu'], 'memdata': datasets['mem'], 'timeline': datasets['timer']}) response = HttpResponse() - response['Content-Type'] = "text/javascript" - response.cookies['cpu'] = datasets['cpu'] - response.cookies['timer'] = datasets['timer'] - response.cookies['mem'] = datasets['mem'] + response["Content-Type"] = "text/javascript" response.write(data) return response + + +def get_compute_disk_buses(request, compute_id, arch, machine, disk): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :param disk: + :return: + """ + comp_mgr = ComputeManager(compute_id) + return HttpResponse(comp_mgr.get_disk_buses(arch, machine, disk)) + + +def get_compute_machine_types(request, compute_id, arch): + """ + :param request: + :param compute_id: + :param arch: + :return: + """ + comp_mgr = ComputeManager(compute_id) + return HttpResponse(comp_mgr.get_machine_types(arch)) + + +def get_compute_video_models(request, compute_id, arch, machine): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :return: + """ + comp_mgr = ComputeManager(compute_id) + return HttpResponse(comp_mgr.get_video_models(arch, machine)) + + +def get_dom_capabilities(request, compute_id, arch, machine): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :return: + """ + comp_mgr = ComputeManager(compute_id) + return HttpResponse(comp_mgr.get_dom_capabilities(arch, machine)) + + +class ComputeManager: + def __init__(self, compute_id): + self.compute = get_object_or_404(Compute, pk=compute_id) + self.conn = wvmConnect( + self.compute.hostname, + self.compute.login, + self.compute.password, + self.compute.type, + ) + + def get_video_models(self, arch, machine): + data = dict() + try: + data["videos"] = self.conn.get_video_models(arch, machine) + except libvirtError: + pass + + return json.dumps(data) + + def get_dom_capabilities(self, arch, machine): + data = dict() + try: + data["videos"] = self.conn.get_disk_device_types(arch, machine) + data["bus"] = self.conn.get_disk_device_types(arch, machine) + except libvirtError: + pass + + return json.dumps(data) + + def get_machine_types(self, arch): + data = dict() + try: + data["machines"] = self.conn.get_machine_types(arch) + except libvirtError: + pass + + return json.dumps(data) + + def get_disk_buses(self, arch, machine, disk): + data = dict() + try: + disk_device_types = self.conn.get_disk_device_types(arch, machine) + + if disk in disk_device_types: + if disk == "disk": + data["bus"] = sorted(disk_device_types) + elif disk == "cdrom": + data["bus"] = ["ide", "sata", "scsi"] + elif disk == "floppy": + data["bus"] = ["fdc"] + elif disk == "lun": + data["bus"] = ["scsi"] + except libvirtError: + pass + + return json.dumps(data) + + def compute_graph(self): + try: + conn = wvmHostDetails( + self.compute.hostname, + self.compute.login, + self.compute.password, + self.compute.type, + ) + current_time = timezone.now().strftime("%H:%M:%S") + cpu_usage = conn.get_cpu_usage() + mem_usage = conn.get_memory_usage() + conn.close() + except libvirtError: + cpu_usage = {"usage": 0} + mem_usage = {"usage": 0} + current_time = 0 + + return json.dumps( + { + "cpudata": cpu_usage["usage"], + "memdata": mem_usage, + "timeline": current_time, + } + ) diff --git a/conf/daemon/consolecallback b/conf/daemon/consolecallback new file mode 100755 index 0000000..a510fdf --- /dev/null +++ b/conf/daemon/consolecallback @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# consolecallback - provide a persistent console that survives guest reboots + +import os +import logging +import libvirt +import tty +import termios +import atexit +from argparse import ArgumentParser +from typing import Optional # noqa F401 + + +def reset_term() -> None: + termios.tcsetattr(0, termios.TCSADRAIN, attrs) + + +def error_handler(unused, error) -> None: + # The console stream errors on VM shutdown; we don't care + if error[0] == libvirt.VIR_ERR_RPC and error[1] == libvirt.VIR_FROM_STREAMS: + return + logging.warning(error) + + +class Console(object): + def __init__(self, uri: str, uuid: str) -> None: + self.uri = uri + self.uuid = uuid + self.connection = libvirt.open(uri) + self.domain = self.connection.lookupByUUIDString(uuid) + self.state = self.domain.state(0) + self.connection.domainEventRegister(lifecycle_callback, self) + self.stream = None # type: Optional[libvirt.virStream] + self.run_console = True + self.stdin_watch = -1 + logging.info("%s initial state %d, reason %d", + self.uuid, self.state[0], self.state[1]) + + +def check_console(console: Console) -> bool: + if (console.state[0] == libvirt.VIR_DOMAIN_RUNNING or console.state[0] == libvirt.VIR_DOMAIN_PAUSED): + if console.stream is None: + console.stream = console.connection.newStream(libvirt.VIR_STREAM_NONBLOCK) + console.domain.openConsole(None, console.stream, 0) + console.stream.eventAddCallback(libvirt.VIR_STREAM_EVENT_READABLE, stream_callback, console) + else: + if console.stream: + console.stream.eventRemoveCallback() + console.stream = None + + return console.run_console + + +def stdin_callback(watch: int, fd: int, events: int, console: Console) -> None: + readbuf = os.read(fd, 1024) + if readbuf.startswith(b""): + console.run_console = False + return + if console.stream: + console.stream.send(readbuf) + + +def stream_callback(stream: libvirt.virStream, events: int, console: Console) -> None: + try: + assert console.stream + received_data = console.stream.recv(1024) + except Exception: + return + os.write(0, received_data) + + +def lifecycle_callback(connection: libvirt.virConnect, domain: libvirt.virDomain, event: int, detail: int, console: Console) -> None: + console.state = console.domain.state(0) + logging.info("%s transitioned to state %d, reason %d", + console.uuid, console.state[0], console.state[1]) + + +# main +parser = ArgumentParser(epilog="Example: %(prog)s 'qemu:///system' '32ad945f-7e78-c33a-e96d-39f25e025d81'") +parser.add_argument("uri") +parser.add_argument("uuid") +args = parser.parse_args() + +print("Escape character is ^]") +logging.basicConfig(filename='msg.log', level=logging.DEBUG) +logging.info("URI: %s", args.uri) +logging.info("UUID: %s", args.uuid) + +libvirt.virEventRegisterDefaultImpl() +libvirt.registerErrorHandler(error_handler, None) + +atexit.register(reset_term) +attrs = termios.tcgetattr(0) +tty.setraw(0) + +console = Console(args.uri, args.uuid) +console.stdin_watch = libvirt.virEventAddHandle(0, libvirt.VIR_EVENT_HANDLE_READABLE, stdin_callback, console) + +while check_console(console): + libvirt.virEventRunDefaultImpl() diff --git a/conf/daemon/gstfsd b/conf/daemon/gstfsd index 2e68312..2129a99 100644 --- a/conf/daemon/gstfsd +++ b/conf/daemon/gstfsd @@ -3,24 +3,24 @@ # gstfsd - WebVirtCloud daemon for managing VM's filesystem # -import SocketServer +import socketserver import json import guestfs import re - PORT = 16510 ADDRESS = "0.0.0.0" -class MyTCPServer(SocketServer.ThreadingTCPServer): +class MyTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True -class MyTCPServerHandler(SocketServer.BaseRequestHandler): +class MyTCPServerHandler(socketserver.BaseRequestHandler): def handle(self): # recive data - data = json.loads(self.request.recv(1024).strip()) + d = self.request.recv(1024).strip() + data = json.loads(d) # GuestFS gfs = guestfs.GuestFS(python_return_dict=True) @@ -42,17 +42,18 @@ class MyTCPServerHandler(SocketServer.BaseRequestHandler): if data['action'] == 'publickey': if not gfs.is_dir('/root/.ssh'): gfs.mkdir('/root/.ssh') - gfs.chmod(0700, "/root/.ssh") + gfs.chmod(700, "/root/.ssh") gfs.write('/root/.ssh/authorized_keys', data['key']) - gfs.chmod(0600, '/root/.ssh/authorized_keys') + gfs.chmod(600, '/root/.ssh/authorized_keys') self.request.sendall(json.dumps({'return': 'success'})) gfs.umount(part) except RuntimeError: pass gfs.shutdown() gfs.close() - except RuntimeError, err: - self.request.sendall(json.dumps({'return': 'error', 'message': err.message})) + except Exception as err: + self.request.sendall(bytes(json.dumps({'return': 'error', 'message': str(err)}).encode())) + server = MyTCPServer((ADDRESS, PORT), MyTCPServerHandler) server.serve_forever() diff --git a/conf/nginx/centos_nginx.conf b/conf/nginx/centos_nginx.conf new file mode 100644 index 0000000..2327039 --- /dev/null +++ b/conf/nginx/centos_nginx.conf @@ -0,0 +1,37 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; +} diff --git a/conf/nginx/debian_nginx.conf b/conf/nginx/debian_nginx.conf new file mode 100644 index 0000000..5b107cb --- /dev/null +++ b/conf/nginx/debian_nginx.conf @@ -0,0 +1,85 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP5rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} diff --git a/conf/nginx/openEuler_nginx.conf b/conf/nginx/openEuler_nginx.conf new file mode 100644 index 0000000..dfa72fc --- /dev/null +++ b/conf/nginx/openEuler_nginx.conf @@ -0,0 +1,38 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; + +} diff --git a/conf/nginx/ubuntu_nginx.conf b/conf/nginx/ubuntu_nginx.conf new file mode 100644 index 0000000..5b107cb --- /dev/null +++ b/conf/nginx/ubuntu_nginx.conf @@ -0,0 +1,85 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP5rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} diff --git a/conf/nginx/uos_eagle_nginx.conf b/conf/nginx/uos_eagle_nginx.conf new file mode 100644 index 0000000..7914c8e --- /dev/null +++ b/conf/nginx/uos_eagle_nginx.conf @@ -0,0 +1,85 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP4rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} \ No newline at end of file diff --git a/conf/nginx/uos_nginx.conf b/conf/nginx/uos_nginx.conf new file mode 100644 index 0000000..5c956d6 --- /dev/null +++ b/conf/nginx/uos_nginx.conf @@ -0,0 +1,89 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; + +# server { +# listen 80 default_server; +# listen [::]:80 default_server; +# server_name _; +# root /usr/share/nginx/html; +# +# # Load configuration files for the default server block. +# include /etc/nginx/default.d/*.conf; +# +# location / { +# } +# +# error_page 404 /404.html; +# location = /40x.html { +# } +# +# error_page 500 502 503 504 /50x.html; +# location = /50x.html { +# } +# } +# +# Settings for a TLS enabled server. +# +# server { +# listen 443 ssl http2 default_server; +# listen [::]:443 ssl http2 default_server; +# server_name _; +# root /usr/share/nginx/html; +# +# ssl_certificate "/etc/pki/nginx/server.crt"; +# ssl_certificate_key "/etc/pki/nginx/private/server.key"; +# ssl_session_cache shared:SSL:1m; +# ssl_session_timeout 10m; +# ssl_ciphers PROFILE=SYSTEM; +# ssl_prefer_server_ciphers on; +# +# # Load configuration files for the default server block. +# include /etc/nginx/default.d/*.conf; +# +# location / { +# } +# +# error_page 404 /404.html; +# location = /40x.html { +# } +# +# error_page 500 502 503 504 /50x.html; +# location = /50x.html { +# } +# } + +} \ No newline at end of file diff --git a/conf/nginx/webvirtcloud.conf b/conf/nginx/webvirtcloud.conf index 70b8e0e..ae2edf8 100644 --- a/conf/nginx/webvirtcloud.conf +++ b/conf/nginx/webvirtcloud.conf @@ -14,10 +14,37 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; proxy_set_header Host $host:$server_port; - proxy_set_header X-Forwarded-Proto $remote_addr; - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Ssl off; + proxy_connect_timeout 1800; + proxy_read_timeout 1800; + proxy_send_timeout 1800; client_max_body_size 1024M; } + + location /novncd/ { + proxy_pass http://wsnovncd; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location /socket.io/ { + proxy_pass http://wssocketiod; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location /websockify { + proxy_pass http://wsnovncd; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +upstream wsnovncd { + server 127.0.0.1:6080; +} +upstream wssocketiod { + server 127.0.0.1:6081; } diff --git a/conf/requirements.txt b/conf/requirements.txt index 676876f..a745a5c 100644 --- a/conf/requirements.txt +++ b/conf/requirements.txt @@ -1,5 +1,28 @@ -Django==1.8.2 -websockify==0.6.0 -gunicorn==19.3.0 -libvirt-python==1.2.16 -http://github.com/retspen/retspen.github.io/raw/master/libxml2-python-2.9.1.tar.gz +Django==4.2.23 +django_bootstrap5==25.1 +django-bootstrap-icons==0.9.0 +django-login-required-middleware==0.9.0 +django-otp==1.6.0 +django-qr-code==4.2.0 +django-auth-ldap==5.2.0 +djangorestframework==3.16.0 +drf-nested-routers==0.94.2 +drf-yasg==1.21.10 +eventlet==0.40.1 +gunicorn==23.0.0 +libsass==0.23.0 +libvirt-python==11.4.0 +lxml==6.0.0 +ldap3==2.9.1 +markdown==3.8.2 +paramiko==3.4.0 +#psycopg2-binary +python-engineio==4.12.0 +python-socketio==5.13.0 +qrcode==8.2 +rwlock==0.0.7 +tzdata +websockify==0.13.0 +whitenoise==6.9.0 +zipp==3.23.0 +crypt-r==3.13.1 diff --git a/conf/runit/nginx b/conf/runit/nginx new file mode 100755 index 0000000..a7cd558 --- /dev/null +++ b/conf/runit/nginx @@ -0,0 +1,9 @@ +#!/bin/bash +set -e +if [[ ! -e /var/log/nginx/error.log ]]; then + # The Nginx log forwarder might be sleeping and waiting + # until the error log becomes available. We restart it in + # 1 second so that it picks up the new log file quickly. + (sleep 1 && sv restart /etc/service/nginx-log-forwarder) +fi +exec /usr/sbin/nginx diff --git a/conf/runit/nginx-log-forwarder b/conf/runit/nginx-log-forwarder new file mode 100755 index 0000000..07d63c6 --- /dev/null +++ b/conf/runit/nginx-log-forwarder @@ -0,0 +1,8 @@ +#!/bin/bash +# Forwards the Nginx error.log to the Docker logs. +set -e +if [[ -e /var/log/nginx/error.log ]]; then + exec tail -F /var/log/nginx/error.log +else + exec sleep 10 +fi diff --git a/conf/runit/novncd.sh b/conf/runit/novncd.sh new file mode 100755 index 0000000..b071864 --- /dev/null +++ b/conf/runit/novncd.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# `/sbin/setuser www-data` runs the given command as the user `www-data`. +RUNAS=$(which setuser) +[ -z "$RUNAS" ] && RUNAS="$(which sudo) -u" +USER=www-data + +DJANGO_PROJECT=/srv/webvirtcloud +PYTHON=$DJANGO_PROJECT/venv/bin/python3 +NOVNCD=$DJANGO_PROJECT/console/novncd + +# make novncd debug, verbose +#PARAMS="-d -v" + +LOG=/var/log/novncd.log + +cd $DJANGO_PROJECT || exit +exec "$RUNAS" "$USER" "$PYTHON" "$NOVNCD" "$PARAMS" >> $LOG 2>&1 diff --git a/conf/runit/secret_generator.py b/conf/runit/secret_generator.py new file mode 100644 index 0000000..70ee032 --- /dev/null +++ b/conf/runit/secret_generator.py @@ -0,0 +1,13 @@ +import secrets + +generated_key = secrets.token_urlsafe(50) + +print(''.join(generated_key)) + + +### Use for old python versions < 3.6 +##import random +##import string +# +##haystack = string.ascii_letters + string.digits + string.punctuation +##print(''.join([random.SystemRandom().choice(haystack.replace('/', '').replace('\'', '').replace('\"', '')) for _ in range(50)])) diff --git a/conf/runit/webvirtcloud.sh b/conf/runit/webvirtcloud.sh new file mode 100755 index 0000000..4cb81b0 --- /dev/null +++ b/conf/runit/webvirtcloud.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# `/sbin/setuser www-data` runs the given command as the user `www-data`. +cd /srv/webvirtcloud || exit +exec /sbin/setuser www-data /srv/webvirtcloud/venv/bin/gunicorn webvirtcloud.wsgi:application -c /srv/webvirtcloud/gunicorn.conf.py >> /var/log/webvirtcloud.log 2>&1 diff --git a/conf/supervisor/gstfsd.conf b/conf/supervisor/gstfsd.conf index 2834b30..71592aa 100644 --- a/conf/supervisor/gstfsd.conf +++ b/conf/supervisor/gstfsd.conf @@ -1,5 +1,5 @@ [program:gstfsd] -command=/usr/bin/python /usr/local/bin/gstfsd +command=/usr/bin/python3 /usr/local/bin/gstfsd directory=/usr/local/bin user=root autostart=true diff --git a/conf/supervisor/webvirtcloud.conf b/conf/supervisor/webvirtcloud.conf index 2994bc9..a554f0d 100644 --- a/conf/supervisor/webvirtcloud.conf +++ b/conf/supervisor/webvirtcloud.conf @@ -7,9 +7,17 @@ autorestart=true redirect_stderr=true [program:novncd] -command=/srv/webvirtcloud/venv/bin/python /srv/webvirtcloud/console/novncd +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/novncd directory=/srv/webvirtcloud user=www-data autostart=true autorestart=true -redirect_stderr=true \ No newline at end of file +redirect_stderr=true + +[program:socketiod] +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod -d +directory=/srv/webvirtcloud +user=www-data +autostart=true +autorestart=true +redirect_stderr=true diff --git a/conf/systemd/README.md b/conf/systemd/README.md new file mode 100644 index 0000000..4333f36 --- /dev/null +++ b/conf/systemd/README.md @@ -0,0 +1,15 @@ +# Ubuntu Service Files + +I choose to install this service on Ubuntu 22.04 + +The process is not fully documented but I wanted to contribute the service files + +The file webvirt is beeing installed into /etc/default/webvirt and allows you to easily change the location of the webvirtcloud installation path + +The PATH variable being set for these services includes /usr/bin which contains the ssh executable. That is being used by webvirt-cloud and webvirt-novncd + +Should the executable be located in another location please adjust the path + +The error you should be seeing is "ssh executable not found" + + diff --git a/conf/systemd/install.sh b/conf/systemd/install.sh new file mode 100755 index 0000000..5fc9d07 --- /dev/null +++ b/conf/systemd/install.sh @@ -0,0 +1,7 @@ +#!/bin/bash +FILEPATH=$(readlink -f "$0"); +SCRIPTPATH=$(dirname "$FILEPATH"); +cd "$SCRIPTPATH" +cp webvirt-* /lib/systemd/system/ +cp webvirt /etc/default/ +echo Run to start services \"systemctl daemon-reload\; systemctl enable --now $(ls webvirt-* | tr "\n" " ")\" diff --git a/conf/systemd/webvirt b/conf/systemd/webvirt new file mode 100644 index 0000000..c206062 --- /dev/null +++ b/conf/systemd/webvirt @@ -0,0 +1,2 @@ +WEBVIRT_PATH="/srv/webvirtcloud" +PATH="$WEBVIRT_PATH/venv/bin:/usr/bin" diff --git a/conf/systemd/webvirt-cloud.service b/conf/systemd/webvirt-cloud.service new file mode 100644 index 0000000..9fe3307 --- /dev/null +++ b/conf/systemd/webvirt-cloud.service @@ -0,0 +1,23 @@ +[Unit] +Description=Webvirt Cloud Webinterface +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +User=www-data +Group=www-data +LimitNOFILE=64000 +AmbientCapabilities=CAP_NET_BIND_SERVICE +EnvironmentFile=/etc/default/webvirt + +ExecStart=/bin/bash -c "cd ${WEBVIRT_PATH};${WEBVIRT_PATH}/venv/bin/gunicorn webvirtcloud.wsgi:application -c ${WEBVIRT_PATH}/gunicorn.conf.py" + +# Make sure stderr/stdout is captured in the systemd journal. +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/conf/systemd/webvirt-gstfsd.service b/conf/systemd/webvirt-gstfsd.service new file mode 100644 index 0000000..6e89e1c --- /dev/null +++ b/conf/systemd/webvirt-gstfsd.service @@ -0,0 +1,19 @@ +[Unit] +Description=Webvirt Cloud GSTFSD +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +LimitNOFILE=64000 + +ExecStart=/usr/bin/python3 /usr/local/bin/gstfsd + +# Make sure stderr/stdout is captured in the systemd journal. +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/conf/systemd/webvirt-novncd.service b/conf/systemd/webvirt-novncd.service new file mode 100644 index 0000000..0cac27d --- /dev/null +++ b/conf/systemd/webvirt-novncd.service @@ -0,0 +1,23 @@ +[Unit] +Description=Webvirt Cloud NOVNCD +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +User=www-data +Group=www-data +LimitNOFILE=64000 +AmbientCapabilities=CAP_NET_BIND_SERVICE +EnvironmentFile=/etc/default/webvirt + +ExecStart=/bin/bash -c "cd ${WEBVIRT_PATH};${WEBVIRT_PATH}/venv/bin/python3 ${WEBVIRT_PATH}/console/novncd" + +# Make sure stderr/stdout is captured in the systemd journal. +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/conf/systemd/webvirt-socketiod.service b/conf/systemd/webvirt-socketiod.service new file mode 100644 index 0000000..741f0dc --- /dev/null +++ b/conf/systemd/webvirt-socketiod.service @@ -0,0 +1,23 @@ +[Unit] +Description=Webvirt Cloud NOVNCD +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +User=www-data +Group=www-data +LimitNOFILE=64000 +AmbientCapabilities=CAP_NET_BIND_SERVICE +EnvironmentFile=/etc/default/webvirt + +ExecStart=/bin/bash -c "cd ${WEBVIRT_PATH};${WEBVIRT_PATH}/venv/bin/python3 ${WEBVIRT_PATH}/console/socketiod -d" + +# Make sure stderr/stdout is captured in the systemd journal. +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/conf/test-vm.xml b/conf/test-vm.xml new file mode 100644 index 0000000..d14f25f --- /dev/null +++ b/conf/test-vm.xml @@ -0,0 +1,119 @@ + + test-vm + 1bd3c1f2-dd12-4b8d-a298-dff387cb9f93 + 131072 + 131072 + 1 + + hvm + + + + + + + + + + + destroy + restart + restart + + /usr/bin/qemu-system-x86_64 + + + + +
+ + + + + +
+ + +
+ + +
+ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + +
+ + + + + + + + + + +
+ + +
+ + +
+ + + + + + +
+
+
+
diff --git a/instances/templates/create_instance_w1.html b/instances/templates/create_instance_w1.html new file mode 100644 index 0000000..93a915d --- /dev/null +++ b/instances/templates/create_instance_w1.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Create new instance" %} - {% trans "Select Type" %}{% endblock %} + +{% block page_heading%} +{% blocktrans with host=compute.name %}New instance on {{ host }} {% endblocktrans %} +{% endblock page_heading %} + +{% block content %} + {% include 'pleasewaitdialog.html' %} + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} +
+ {{ field.label }}: {{ error|escape }} +
+ {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
+ {{ error|escape }} +
+ {% endfor %} + {% endif %} + +
+
+
+ + + +
+
+
{% csrf_token %} +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
{% csrf_token %} +
+ + +
+ +
+
+
+
+
+
+
+
+{% endblock %} +{% block script %} + + + + +{% if request.user.is_superuser %} + +{% endif %} +{% endblock %} diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html new file mode 100644 index 0000000..1cf7d62 --- /dev/null +++ b/instances/templates/create_instance_w2.html @@ -0,0 +1,1003 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} +{% load bootstrap_icons %} +{% block title %}{% trans "Create new instance" %}{% endblock %} + +{% block style %} + +{% endblock %} + +{% block page_heading %} +{% blocktrans with host=compute.name %}New instance on {{ host }} {% endblocktrans %} +{% endblock page_heading %} + +{% block content %} + {% include 'pleasewaitdialog.html' %} + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} +
+ {{ field.label }}: {{ error|escape }} +
+ {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
+ {{ error|escape }} +
+ {% endfor %} + {% endif %} + +
+
+
+ + + +
+
+ {% include 'create_flav_block.html' %} + + {% if not flavors %} +
+
+ {% bs_icon 'exclamation-triangle'%} {% trans "Warning" %}: {% trans "Hypervisor doesn't have any Flavors" %} +
+
+ {% else %} +
+ + + + + + + + + + + + + {% for flavor in flavors %} + + + + + + + + + + {% endfor %} + +
#{% trans "Name" %}{% trans "VCPU" %}{% trans "RAM" %}{% trans "HDD" %}{% trans "Action" %}
{{ forloop.counter }}{{ flavor.label }}{{ flavor.vcpu }}{{ flavor.memory }} {% trans "MB" %}{{ flavor.disk }} {% trans "GB" %} + + + {% bs_icon 'plus'%} + + +
+ {% csrf_token %} + +
+
+
+ {% endif %} +
+
+ +
+
+
{% csrf_token %} +
+ +
+ +
+
+ {% if firmwares %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+
+ {% if dom_caps.cpu_modes %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+ +
+
+ +
+
    + +
+
+
+
+ + +
+ +
+
+ +
+
+
+ +
+
    + +
+ + +
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {% if dom_caps.graphics_support == 'yes' %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {% if virtio_support %} +
+ +
+ +
+
+ {% endif %} +
+
+
+ {% if storages %} + + {% else %} + + {% endif %} +
+
+
+
+
+
+ +
+
+
{% csrf_token %} +
+ +
+ +
+
+ {% if firmwares %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {% if dom_caps.graphics_support == 'yes' %} +
+ +
+ +
+
+ {% endif %} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {% if virtio_support %} +
+ +
+ +
+
+ {% endif %} +
+
+
+ {% if storages %} + + {% else %} + + {% endif %} +
+
+
+
+
+
+
+
+ +{% endblock %} +{% block script %} + + +{% if request.user.is_superuser %} + +{% endif %} +{% endblock %} diff --git a/instances/templates/instance.html b/instances/templates/instance.html index 85d9614..7d1ae8e 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -1,866 +1,168 @@ {% extends "base.html" %} +{% load static %} {% load i18n %} -{% block title %}{% trans "Instance" %} - {{ vname }}{% endblock %} +{% load bootstrap_icons %} + +{% block title %}{% trans "Instance" %} - {{ instance.name }}{% endblock %} + +{% block page_heading %} +{{ instance.name }}{% if instance.title %} ({{ instance.title }}){% endif %} +{% endblock page_heading %} + {% block content %} - -
- - - - - -

{{ vname }}

- {% ifequal status 5 %} - {% trans "Off" %} - {% endifequal %} - {% ifequal status 1 %} - {% trans "Active" %} - {% endifequal %} - {% ifequal status 3 %} - {% trans "Suspend" %} - {% endifequal %} -
- - - - - {% for disk in disks %} - - {% endfor %} - -
- {% if cur_vcpu %} -

{{ cur_vcpu }} {% trans "Vcpu" %}

- {% else %} -

{{ vcpu }} {% trans "Vcpu" %}

- {% endif %} -
-

{{ cur_memory }} {% trans "MB" %} {% trans "Ram" %}

-
-

{{ disk.size|filesizeformat }} {% trans "Disk" %}

-
-
-
+ {% include 'pleasewaitdialog.html' %} +
+
+ {% if instance.status == 5 %} + {% trans "Off" %} + {% endif %} + {% if instance.status == 1 %} + {% trans "Active" %} + {% endif %} + {% if instance.status == 3 %} + {% trans "Suspend" %} + {% endif %} + | + {% bs_icon 'broadcast' size='1em' %} + + | + {% if instance.snapshots %} + {% bs_icon 'camera'%} + | + {% endif %} + {% if instance.cur_vcpu %} + {{ instance.cur_vcpu }} {% trans "VCPU" %} + {% else %} + {{ instance.vcpu }} {% trans "VCPU" %} + {% endif %} + | + {{ instance.cur_memory }} {% trans "MB" %} {% trans "RAM" %} + | + {% for disk in instance.disks %} + {{ disk.size|filesizeformat }} {% trans "Disk" %} | + {% endfor %} + {% for net in instance.networks %} + {% for ipv4 in net.ipv4 %} + {{ ipv4 }} | + {% endfor %} + {% endfor %} + {% if instance.guest_agent_ready %} + + {% bs_icon 'info-circle' %} + + {% endif %} + + {% bs_icon 'repeat'%} + +
+ {% if user_quota_msg %} +
+ {{ user_quota_msg|capfirst }} {% trans "quota reached" %}. +
+ {% endif %} +
+
- {% include 'errors_block.html' %} - {% include 'messages_block.html' %} +
+ + + +
+ {% include 'instances/power_tab.html' %} + {% include 'instances/access_tab.html' %} + {% include 'instances/resize_tab.html' %} + {% include 'instances/snapshots_tab.html' %} + {% include 'instances/settings_tab.html' %} + {% include 'instances/stats_tab.html' %} + {% include 'instances/destroy_tab.html' %} +
+
+ {% if app_settings.VIEW_INSTANCE_DETAIL_BOTTOM_BAR == 'True' %} + {% include 'bottom_bar.html' %} + {% endif %} -
-
-
- - - -
-
-
- - - -
- {% ifequal status 1 %} -
-

{% trans "This action sends an ACPI shutdown signal to the instance." %}

-
{% csrf_token %} - -
-
-
-
-

{% trans "This action forcibly powers off and start the instance and may cause data corruption." %}

-
{% csrf_token %} - -
-
-
-
-

{% trans "This action forcibly powers off the instance and may cause data corruption." %}

-
{% csrf_token %} - -
-
-
- {% if request.user.is_superuser %} -
-

{% trans "This action suspends the instance." %}

-
{% csrf_token %} - -
-
-
- {% endif %} - {% endifequal %} - {% ifequal status 3 %} - {% if request.user.is_superuser %} -
-

{% trans "This action restore the instance after suspend." %}

-
{% csrf_token %} - -
-
-
- {% else %} -
-

{% trans "Administrator blocked your instance." %}

-
{% csrf_token %} - -
-
-
- {% endif %} - {% endifequal %} - {% ifequal status 5 %} -
-

{% trans "Click on Boot button to start this instance." %}

-
{% csrf_token %} - -
-
-
- {% endifequal %} -
-
-
-
-
- - - -
-
-

{% trans "This action opens a new window with a VNC connection to the console of the instance." %}

- {% ifequal status 1 %} - {% trans "Console" %} - {% else %} - - {% endifequal %} -
-
-
-

{% trans "You need shut down your instance and enter a new root password." %}

-
{% csrf_token %} -
-
- -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-
-

{% trans "You need shut down your instance and choose your public key." %}

-
{% csrf_token %} -
-
- -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-
-
-
-
-
- - - -
-
- {% if request.user.is_superuser or userinstace.is_change %} -
{% csrf_token %} -

{% trans "Logical host CPUs:" %} {{ vcpu_host }}

-
- -
- -
-
-
- -
- -
-
-

{% trans "Total host memory:" %} {{ memory_host|filesizeformat }}

-
- -
- - - {% trans "Custom value" %} -
-
-
- - -
- - - {% trans "Custom value" %} -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
- {% else %} - {% trans "You don't have permission for resizing instance" %} - - {% endif %} -
-
-
-
-
-
-
- - - -
-
- {% ifequal status 5 %} -

{% trans "This may take more than an hour, depending on how much content is on your droplet and how large the disk is." %}

-
{% csrf_token %} -
-
- -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
- {% else %} -

{% trans "To take a snapshot please Power Off the instance." %}

- {% endifequal %} -
-
- {% ifequal status 5 %} - {% if snapshots %} -

{% trans "Choose a snapshot for restore" %}

-
- - - - - - - - - - {% for snap in snapshots %} - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Date" %}{% trans "Action" %}
{{ snap.name }}{{ snap.date|date:"M d H:i:s" }} -
{% csrf_token %} - - {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
{% csrf_token %} - - -
-
-
- {% else %} -

{% trans "You do not have any snapshots" %}

- {% endif %} - {% else %} -

{% trans "To restore snapshots you need Power Off the instance." %}

- {% endifequal %} -
-
-
-
-
-
- - - -
-
-
{% csrf_token %} - {% for cd in media %} -
- - {% if not cd.image %} -
- -
-
- {% if media_iso %} - - {% else %} - - {% endif %} -
- {% else %} -
-

{{ cd.image }}

-
-
- - -
- {% endif %} -
- {% endfor %} -
-
-
- {% if request.user.is_superuser %} -
-

{% trans "Autostart your instance when host server is power on" %}

-
{% csrf_token %} - {% ifequal autostart 0 %} - - {% else %} - - {% endifequal %} -
-
-
-
-

{% trans "To set console's type, shutdown the instance." %}

-
{% csrf_token %} -
- -
- -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-

{% trans "To create console password, shutdown the instance." %}

-
{% csrf_token %} -
-
-
- -
-
- -
-
-
-
- -
- -
- {% if console_passwd %} - {% trans "Show" %} - {% endif %} -
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-

{% trans "To set console's keymap, shutdown the instance." %}

-
{% csrf_token %} -
-
-
- -
-
-
-
- -
- -
-
- {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-
-
-
-

{% trans "Create a clone" %}

-
{% csrf_token %} -
- -
- -
-
-

{% trans "Network devices" %}

- {% for network in networks %} -
- -
- -
-
- -
-
- {% endfor %} -

{% trans "Storage devices" %}

- {% for disk in clone_disks %} -
- -
- -
- {% ifequal disk.format 'qcow2' %} - -
- -
- {% endifequal %} -
- {% endfor %} - {% ifequal status 5 %} - - {% else %} - - {% endifequal %} -
-
-
-
-

{% trans "For migration both host servers must have equal settings and OS type" %}

-
{% csrf_token %} -
- -
-

{{ compute.name }}

-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- {% if computes_count != 1 %} - - {% else %} - - {% endif %} -
-

-
-
-

{% trans "If you need to edit xml please Power Off the instance" %}

-
{% csrf_token %} -
- -
- {% ifequal status 5 %} - - - {% else %} - - {% endifequal %} -
-
-
- {% endif %} -
-
-
-
-
- - - -
-
-
-
-

{% trans "CPU usage" %}

-
-
-
-
- -
-
-
-
- {% for net in networks %} -
-
-

{% trans "Bandwidth device:" %} eth{{ forloop.counter0 }}

-
-
-
-
- -
-
-
-
- {% endfor %} - {% for disk in disks %} -
-
-

{% trans "Disk I/O device:" %} {{ disk.dev }}

-
-
-
-
- -
-
-
-
- {% endfor %} -
-
-
-
-
-
-
- - - -
-
-

{% trans "Delete storage for instance?" %}

- {% if request.user.is_superuser or userinstace.is_delete %} - {% ifequal status 3 %} - - {% else %} -
{% csrf_token %} -
- -
- -
- {% endifequal %} - {% else %} - - {% endif %} -
-
-
-
-
-
-
-
-
{% endblock %} {% block script %} - + + - + + + + + - + + {% endblock %} diff --git a/instances/templates/instance_actions.html b/instances/templates/instance_actions.html new file mode 100644 index 0000000..ebd4ac2 --- /dev/null +++ b/instances/templates/instance_actions.html @@ -0,0 +1,53 @@ +{% load i18n %} +{% load bootstrap_icons %} +
{% csrf_token %} + {% if instance.proxy.instance.info.0 == 5 %} + {% if instance.is_template %} + + {% bs_icon 'clone' %} + + {% else %} + + {% bs_icon 'play-fill' %} + + {% endif %} + {% bs_icon 'pause-fill' %} + {% bs_icon 'power' %} + {% bs_icon 'repeat' %} + + {% endif %} + {% if instance.proxy.instance.info.0 == 3 %} + + {% bs_icon 'play-fill' %} + + {% bs_icon 'pause-fill' %} + + {% bs_icon 'power' %} + + {% bs_icon 'repeat' %} + + {% endif %} + {% if instance.proxy.instance.info.0 == 1 %} + {% bs_icon 'play-fill' %} + {% bs_icon 'pause-fill' %} + {% bs_icon 'power' %} + {% bs_icon 'repeat' %} + + {% endif %} +
+{% block script %} + +{% endblock script %} \ No newline at end of file diff --git a/instances/templates/instances.html b/instances/templates/instances.html deleted file mode 100644 index 144a5cd..0000000 --- a/instances/templates/instances.html +++ /dev/null @@ -1,250 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load staticfiles %} -{% block title %}{% trans "Instances" %}{% endblock %} -{% block style %} - -{% endblock %} -{% block content %} - -
-
- {% if request.user.is_superuser %} - {% include 'create_inst_block.html' %} - {% endif %} - {% if all_host_vms or all_user_vms %} - - {% endif %} -

{% trans "Instances" %}

-
-
- - - {% include 'errors_block.html' %} - -
-
-
- {% if request.user.is_superuser %} - {% if not all_host_vms %} -
-
- - {% trans "Warning:" %} {% trans "You don't have any Instace" %} -
-
- {% else %} - - - - - - - - - - - - - {% for host, inst in all_host_vms.items %} - {% for vm, info in inst.items %} - - - - - - - - - {% endfor %} - {% endfor %} - -
NameHostStatusVCPUMemoryActions
{{ vm }}{{ host.1 }}{% ifequal info.status 1 %} - {% trans "Active" %} - {% endifequal %} - {% ifequal info.status 5 %} - {% trans "Off" %} - {% endifequal %} - {% ifequal info.status 3 %} - {% trans "Suspend" %} - {% endifequal %} - {{ info.vcpu }}{{ info.memory }} {% trans "MB" %}
{% csrf_token %} - - - {% ifequal info.status 5 %} - - - - - - {% endifequal %} - {% ifequal info.status 3 %} - - - - - - {% endifequal %} - {% ifequal info.status 1 %} - - - - - - - - {% endifequal %} -
-
- {% endif %} - {% else %} - {% if not all_user_vms %} -
-
- - {% trans "Warning:" %} {% trans "You don't have any Instace" %} -
-
- {% else %} - - - - - - - - - - - - {% for inst, vm in all_user_vms.items %} - - - - - - - - {% endfor %} - -
NameStatusVCPUMemoryActions
{{ vm.name }}{% ifequal vm.status 1 %} - {% trans "Active" %} - {% endifequal %} - {% ifequal vm.status 5 %} - {% trans "Off" %} - {% endifequal %} - {% ifequal vm.status 3 %} - {% trans "Suspend" %} - {% endifequal %} - {{ vm.vcpu }}{{ vm.memory }} {% trans "MB" %}
{% csrf_token %} - - - {% ifequal vm.status 5 %} - - - - - {% endifequal %} - {% ifequal vm.status 3 %} - - - - - {% endifequal %} - {% ifequal vm.status 1 %} - - - - - - - {% endifequal %} -
-
- {% endif %} - {% endif %} -
-
-
-{% endblock %} -{% block script %} - - - -{% if request.user.is_superuser %} - -{% endif %} -{% endblock %} diff --git a/instances/templates/instances/access_tab.html b/instances/templates/instances/access_tab.html new file mode 100644 index 0000000..ce44d95 --- /dev/null +++ b/instances/templates/instances/access_tab.html @@ -0,0 +1,163 @@ +{% load i18n %} +
+ + + +
+
+

{% blocktrans with type=instance.console_type|upper %} This action opens a new window with a {{ type }} connection to the console of the instance.{% endblocktrans %} +

+ {% if instance.console_type == 'vnc' %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% endif %} + {% if instance.status == 1 %} + + + {% else %} + + {% endif %} +
+
+ {% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' %} +
+

{% trans "You need shut down your instance and enter a new root password." %}

+
+ {% csrf_token %} +
+
+ +
+
+ {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+ {% endif %} + {% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' %} +
+

{% trans "You need shut down your instance and choose your public key." %}

+
+ {% csrf_token %} +
+
+ +
+
+ {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+ {% endif %} + {% if instance.status == 1 %} +
+

{% trans "This action opens a remote viewer with a connection to the console of the instance." %}

+ +

{% trans "To download console.vv file for virt-viewer." %}

+ {% trans "Get console.vv" %} + +
+
+ {% endif %} +
+
+{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/instances/templates/instances/destroy_instance_form.html b/instances/templates/instances/destroy_instance_form.html new file mode 100644 index 0000000..430aab4 --- /dev/null +++ b/instances/templates/instances/destroy_instance_form.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %} +{% trans "Confirm Destroy" %} +{% endblock title %} + +{% block page_heading %} +{% trans "Destroy instance" %} {{ instance }} +{% endblock page_heading %} + +{% block content %} +{% if request.user.is_superuser or userinstance.is_delete %} + {% if instance.status == 3 %} +
+ {% trans "Instance is suspended, cannot destroy!" %} +
+ {% else %} +
+ {% trans "This action is irreversible!" %} +
+
{% csrf_token %} +
+ + +
+ {% if instance.nvram %} +
+ + +
+ {% endif %} + +
+ {% endif %} +{% else %} + +{% endif %} +{% endblock content %} \ No newline at end of file diff --git a/instances/templates/instances/destroy_tab.html b/instances/templates/instances/destroy_tab.html new file mode 100644 index 0000000..fc80215 --- /dev/null +++ b/instances/templates/instances/destroy_tab.html @@ -0,0 +1,27 @@ +{% load i18n %} +
+ + + +
+
+

{% trans 'This action starts remove instance process' %}

+ {% if request.user.is_superuser or userinstance.is_delete %} + {% if instance.status == 3 %} + {% trans "Destroy" %} + {% else %} + {% trans "Destroy" %} + {% endif %} + {% else %} + + {% endif %} +
+
+
+
\ No newline at end of file diff --git a/instances/templates/instances/edit_instance_volume.html b/instances/templates/instances/edit_instance_volume.html new file mode 100644 index 0000000..7e93cff --- /dev/null +++ b/instances/templates/instances/edit_instance_volume.html @@ -0,0 +1,119 @@ +{% load i18n %} +{% load bootstrap_icons %} +{% if request.user.is_superuser %} + + + + +{% endif %} diff --git a/instances/templates/instances/info_tab.html b/instances/templates/instances/info_tab.html new file mode 100644 index 0000000..24853ca --- /dev/null +++ b/instances/templates/instances/info_tab.html @@ -0,0 +1,20 @@ +{% load i18n %} +
+ +
+
{% trans "Hostname" %}
+
+
{% trans "OS Name" %}
+
+
{% trans "OS Pretty-Name" %}
+
+
{% trans "Version" %}
+
+
{% trans "Kernel Release" %}
+
+
{% trans "Kernel Version" %}
+
+
{% trans "Timezone / Offset" %}
+
+
+
diff --git a/instances/templates/instances/power_tab.html b/instances/templates/instances/power_tab.html new file mode 100644 index 0000000..3811490 --- /dev/null +++ b/instances/templates/instances/power_tab.html @@ -0,0 +1,129 @@ +{% load i18n %} +
+ + + +
+ {% if instance.status == 1 %} +
+

{% trans "This action sends an ACPI shutdown signal to the instance." %}

+
+ {% csrf_token %} + +
+
+
+
+

{% trans "This action forcibly powers off and start the instance and may cause data corruption." %}

+
{% csrf_token %} + +
+
+
+
+

{% trans "This action forcibly powers off the instance and may cause data corruption." %}

+
+ {% csrf_token %} + +
+
+
+ {% if request.user.is_superuser %} +
+

{% trans "This action suspends the instance." %}

+
{% csrf_token %} + +
+
+
+ {% endif %} + {% endif %} + {% if instance.status == 3 %} + {% if request.user.is_superuser %} +
+

{% trans "This action restore the instance after suspend." %}

+
{% csrf_token %} + +
+
+
+
+

{% trans "This action forcibly powers off the instance and may cause data corruption." %}

+
{% csrf_token %} + +
+
+
+ {% else %} +
+

{% trans "Administrator blocked your instance." %}

+
{% csrf_token %} + +
+
+
+ {% endif %} + {% endif %} + {% if instance.status == 5 %} +
+

{% trans "Click on Power On button to start this instance." %}

+
+ {% csrf_token %} + {% if instance.is_template %} +

{% trans "Template instance cannot be started." %}

+ + {% else %} + + {% endif %} +
+
+
+ {% endif %} +
+
diff --git a/instances/templates/instances/resize_tab.html b/instances/templates/instances/resize_tab.html new file mode 100644 index 0000000..2559b5d --- /dev/null +++ b/instances/templates/instances/resize_tab.html @@ -0,0 +1,163 @@ +{% load i18n %} +
+ + + +
+
+ {% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %} + {% if instance.status == 5 or not instance.vcpus %} +
{% csrf_token %} +

{% trans "Logical host CPUs" %} : {{ vcpu_host }}

+
+ +
+ +
+
+
+ +
+ +
+
+ + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+ {% else %} +

{% trans "Logical Instance Active/Maximum CPUs" %} : {{ instance.cur_vcpu }} / {{ instance.vcpu }}

+
+
+ {% for id, vcpu in instance.vcpus.items %} +
{% csrf_token %} +
+ + {% if vcpu.enabled == 'yes' and vcpu.hotpluggable == "yes" %} + + {% elif vcpu.enabled == 'yes' and vcpu.hotpluggable == "no" %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + {% else %} + {% trans "You don't have permission for resizing instance" %} + + {% endif %} +
+
+
+ {% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %} +
+ {% csrf_token %} +

{% trans "Total host memory" %}: {{ memory_host|filesizeformat }}

+
+ +
+ + + {% trans "Custom value" %} +
+
+
+ +
+ + + {% trans "Custom value" %} +
+
+ +
+ {% else %} + {% trans "You don't have permission for resizing instance" %} + + {% endif %} +
+
+
+ {% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %} +
+ {% csrf_token %} +

{% trans "Disk allocation (GB)" %}:

+ {% for disk in instance.disks %} +
+ + {% if disk.storage is None %} +
+
+ {% trans "Error getting disk info" %} +
+
+ {% else %} +
+ +
+ {% endif %} +
+ {% endfor %} + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+ {% else %} + {% trans "You don't have permission for resizing instance" %} + + {% endif %} +
+
+
+
diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html new file mode 100644 index 0000000..5d91ba5 --- /dev/null +++ b/instances/templates/instances/settings_tab.html @@ -0,0 +1,933 @@ +{% load i18n %} +{% load django_bootstrap5 %} +{% load bootstrap_icons %} +
+ + + +
+ {% if request.user.is_superuser %} + {% include 'instances/info_tab.html' %} +
+

{% trans 'Autostart' %}

+
+
+

{% trans "Autostart your instance when host server is power on " %} + {% if instance.autostart == 0 %} + {% trans "Enable" %} + {% else %} + {% trans "Disable" %} + {% endif %} +

+
+
+

{% trans 'Boot Order' %}

+
+
+ {% if instance.status == 5 %} +

{% trans "Enable Boot Menu for your instance when it starts up " %} + {% if instance.bootmenu == 0 %} +

{% csrf_token %} + +
+ {% else %} +
{% csrf_token %} + +
+ {% endif %} + {% else %} + {% if instance.bootmenu == 0 %} +

**** {% trans "Please shutdown instance to modify boot menu" %} ****

+ {% endif %} + {% endif %} +
+
+ + {% if instance.bootmenu == 1 %} +
+
+ {% for idx, val in instance.boot_order.items %} + + {% endfor %} +
+
+
{% csrf_token %} + +
+
+ {% for disk in instance.disks %} + + {% endfor %} + {% for cd in instance.media %} + + {% endfor %} + {% for net in instance.networks %} + + {% endfor %} +
+ +
+
+
+ +
+
+
+ {% endif %} +
+
+
+
{% csrf_token %} +

+ {% trans "Instance Media" %} + +

+
+
+ {% for cd in instance.media %} +
+
+ {% if not cd.image %} +
+ {% csrf_token %} +
+ + {% trans "CD-ROM" %} {{ forloop.counter }} + + + + {% if instance.media_iso and allow_admin_or_not_template %} + + {% else %} + + {% endif %} + {% if instance.status == 5 and allow_admin_or_not_template %} + + {% bs_icon 'dash-circle' %} + + {% endif %} + +
+
+ {% else %} +
+ {% csrf_token %} +
+ + {% trans "CD-ROM" %} {{ forloop.counter }} + + + + {% if allow_admin_or_not_template %} + + {% else %} + + {% endif %} +
+
+ {% endif %} +
+
+ {% empty %} +
+
{% trans 'There is not any CD-ROM device.' %}
+
+ {% endfor %} +
+

+ {% trans "Instance Volume" %} + {% include 'add_instance_volume.html' %} +

+ + + + + + + + + + + + + {% for disk in instance.disks %} + + + {% if disk.storage is None %} + + {% else %} + + + + + {% endif %} + + + {% endfor %} + +
{% trans "Device" %}{% trans "Used" %}{% trans "Capacity" %}{% trans "Storage" %}{% trans "Source" %}{% trans "Action" %}
+ + {{ disk.dev }} + +
+ {% trans "Error getting disk info" %} +
+
{{ disk.used | filesizeformat}}{{ disk.size | filesizeformat }}{{ disk.storage }}{{ disk.path }} +
+ {% csrf_token %} + + + + + {% include 'instances/edit_instance_volume.html' with id=forloop.counter0 %} +
+
+ {% csrf_token %} + + + + + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+ {% csrf_token %} + + + + + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+
+
+

+ {% trans "Add a network device" %} + {% include 'add_instance_network_block.html' %} +

+ +
+
+
{% trans "Network Devices" %}
+ + + + + + + + + + {% for network in instance.networks %} + + + + + + + + + + + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Info' %}{% trans 'Actions' %}
eth{{ forloop.counter0 }}({{ network.target|default:"no target" }}) +
+ {% csrf_token %} + + + + {% trans 'active' %} + {{ network.type }} +
+
+
+ {% trans 'MAC' %} + +
+
+
+ {% trans 'Filter' %} + +
+
+
+ {% trans 'Source' %} + +
+
+
{% csrf_token %} + + + +
+
+
+ {% csrf_token %} + + {% include 'add_network_qos.html' with id=forloop.counter0 %} +
+
+
{% csrf_token %} + +
+
+
+ {% trans 'IPv4' %} + +
+
+
+ {% trans 'IPv6' %} + +
+
+
+ {% trans 'Model' %} + +
+
+ {% if network.type == 'direct' %} + {% trans 'In most configurations, macvtap does not work for host to guest network communication' %} + {% endif %} +
+
+
+ + {% if instance.qos %} +
+
+

{% trans "QoS Configuration" %}

+
+
+ + + + + + + + + + + + {% for q, attrs in instance.qos.items %} + {% for att in attrs %} + + + {% csrf_token %} + + + + + + + {% endfor %} + {% endfor %} + +
{% trans "MAC" %}/{% trans "Direction" %}{% trans "Average" %}{% trans "Peak" %}{% trans "Burst" %}{% trans "Actions" %}
+ + + + + + + +
+ {% csrf_token %} + + + +
+
+
+
+ {% endif %} +
+
+
+

{% trans "For migration both host servers must have equal settings and OS type" %}

+
+ {% csrf_token %} +
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ {% if computes_count != 1 %} + + {% else %} + + {% endif %} +
+
+
+
+ {% if instance.status != 5 %} +

{% trans "If you need to edit XML please Power Off the instance" %}

+ {% endif %} +
+ {% csrf_token %} +
+ +
+ {% if instance.status == 5 %} + + + {% else %} + + {% endif %} +
+
+
+
+
+

+ {% trans "Instance owners" %} + {% include 'add_instance_owner_block.html' %} +

+
+
+ + + {% for userinstance in userinstances %} + + + + + {% endfor %} + +
{{ userinstance.user }} + + {% bs_icon 'trash' %} + +
+
+
+
+ {% endif %} + {% if request.user.is_superuser or request.user.is_staff or userinstance.is_vnc %} +
+
+ {% csrf_token %} + {% if instance.status != 5 %} +
+ {% trans "To change console settings, shutdown the instance." %} +
+ {% endif %} + {% bootstrap_form console_form layout='horizontal' %} +
+ {% if instance.status != 5 %} + + {% else %} + + {% endif %} +
+
+
+
+ {% endif %} + {% if perms.instances.clone_instances %} +
+

{% trans "Create a clone" %}

+
{% csrf_token %} +
+ + {% if request.user.is_superuser %} +
+
+ + +
+
+ {% elif app_settings.CLONE_INSTANCE_AUTO_NAME == 'True'%} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ {% if request.user.is_superuser %} + + {% for network in instance.networks %} +

+

+ +
+
+ + + +
+
+
+

+ {% endfor %} + {% else %} + {% for network in instance.networks %} + + {% endfor %} + {% endif %} + {% if request.user.is_superuser %} + + {% for disk in instance.disks %} +
+ +
+
+ + {% if disk.format == 'qcow2' %} + {% trans 'Metadata' %} +
+ +
+ + {% endif %} +
+
+
+ {% endfor %} + {% else %} + {% for disk in instance.disks %} + + {% endfor %} + {% endif %} +
+ +
+ +
+
+
+ +
+ +
+
+ {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+
+

{% trans "To set instance template name description, shutdown the instance." %}

+
{% csrf_token %} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+ +

{% trans "To set instance video model, shutdown the instance." %}

+
{% csrf_token %} +
+ +
+
+ + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+
+ +

{% trans "To set instance vCPUs hotpluggable" %}

+
{% csrf_token %} +
+ +
+
+ + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
+
+ +

{% trans "To Enable/Disable Qemu Guest Agent. Status" %}: + {% if instance.status == 1 %} + {% if instance.guest_agent_ready %} + + {% else %} + + {% endif %}

+ {% else %} + + {% endif %} +
{% csrf_token %} +
+ +
+
+ + +
+
+
+
+
+
+ {% endif %} +
+
diff --git a/instances/templates/instances/snapshots_tab.html b/instances/templates/instances/snapshots_tab.html new file mode 100755 index 0000000..6cecd37 --- /dev/null +++ b/instances/templates/instances/snapshots_tab.html @@ -0,0 +1,149 @@ +{% load i18n %} +{% load bootstrap_icons %} +
+ + + +
+
+ {% if instance.status != 5 %} +

{% trans "With running machine, internal snapshots may take more than an hour, depending on how much memory has on your instance and how large the disk is." %}

+

{% trans "Live snapshot could cause server timeout and instance might be paused!!!" %}

+ {% else %} +

{% trans "Create an internal snapshot" %}

+ {% endif %} +
+ {% csrf_token %} +
+ + | + + {% if instance.external_snapshots|length > 0 %} + + {% else %} + + {% endif %} +
+
+
+
+
+ {% if instance.status != 5 %} +

{% trans "You can get external snapshots within this tab." %}

+ {% else %} +

{% trans "Create an external snapshot" %}

+ {% endif %} +

{% trans "External snapshots are experimental in this stage, use it if you know what you are doing. 'Revert Snapshot' may require manual operation with CLI." %}

+
+ {% csrf_token %} +
+ + | + + {% if instance.external_snapshots|length > 0 or instance.snapshots|length > 0 %} + + {% else %} + + {% endif %} +
+

{% trans "WebVirtCloud supports only one external snapshot at the moment." %}

+
+
+
+
+ {% if instance.snapshots or instance.external_snapshots %} +

{% trans "Choose a snapshot for restore/delete" %}

+
+ + + + + + + + + {% if instance.snapshots %} + {% for snap in instance.snapshots %} + + + + + + + + {% endfor %} + {% elif instance.external_snapshots %} + {% for ext_snap in instance.external_snapshots %} + + + + + + + + {% endfor %} + {% endif %} + +
{% trans "Date" %}{% trans "Name" %}{% trans "Type - Description" %}{% trans "Action" %}
{{ snap.date|date:"M d H:i:s" }}{{ snap.name }}({% trans "Internal" %}) - {{ snap.description }} +
+ {% csrf_token %} + + {% if instance.status == 5 %} + + {% else %} + + {% endif %} +
+
+
{% csrf_token %} + + +
+
{{ ext_snap.date|date:"M d H:i:s" }}{{ ext_snap.name }}({% trans "External" %}) - {{ ext_snap.description }} +
+ {% csrf_token %} + + + + +
+
+
{% csrf_token %} + + +
+
+
+ {% else %} +

{% trans "You do not have any snapshots" %}

+ {% endif %} +
+
+
diff --git a/instances/templates/instances/stats_tab.html b/instances/templates/instances/stats_tab.html new file mode 100644 index 0000000..768fc8b --- /dev/null +++ b/instances/templates/instances/stats_tab.html @@ -0,0 +1,119 @@ +{% load i18n %} +{% load bootstrap_icons %} +
+ + + +
+
+
+
+
{% bs_icon 'arrow-right' %} + {% trans "CPU Usage" %} +
+
+
+
+
+ +
+
+
+
+
+
+
{% bs_icon 'arrow-right' %} + {% trans "Memory Usage" %} +
+
+
+
+
+ +
+
+
+
+ {% for net in instance.networks %} +
+
+
{% bs_icon 'arrow-right' %} + {% trans "Bandwidth Device" %}: eth{{ forloop.counter0 }} +
+
+
+
+
+ +
+
+
+
+ {% endfor %} + {% for disk in instance.disks %} +
+
+
{% bs_icon 'arrow-right' %} + {% trans "Disk I/O device" %}: {{ disk.dev }} +
+
+
+
+
+ +
+
+
+
+ {% endfor %} +
+
+
+
+ + + + + + + + + + + + + +
{% trans "Date" %}{% trans "User" %}{% trans "Message" %}
{% trans 'None' %}...
+
+
+
+
+
+ diff --git a/instances/templatetags/tags_active.py b/instances/templatetags/tags_active.py index 8d8ce62..2745147 100644 --- a/instances/templatetags/tags_active.py +++ b/instances/templatetags/tags_active.py @@ -1,11 +1,12 @@ -from django import template import re +from django import template + register = template.Library() @register.simple_tag def class_active(request, pattern): if re.search(pattern, request.path): - return 'class="active"' + return "active" return '' diff --git a/instances/tests.py b/instances/tests.py index 7ce503c..ecdc96c 100644 --- a/instances/tests.py +++ b/instances/tests.py @@ -1,3 +1,1094 @@ -from django.test import TestCase +import re -# Create your tests here. +from accounts.models import UserAttributes, UserInstance, UserSSHKey +from appsettings.models import AppSettings +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.http.response import Http404 +from django.shortcuts import reverse +from django.test import TestCase +from instances.views import instance +from libvirt import VIR_DOMAIN_UNDEFINE_NVRAM +from vrtManager.create import wvmCreate +from vrtManager.util import randomUUID + +from .models import Flavor, Instance +from .utils import refr + + +class InstancesTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Add users for testing purposes + User = get_user_model() + cls.admin_user = User.objects.get(pk=1) + cls.test_user = User.objects.create(username="test-user") + UserAttributes.objects.create( + user=cls.test_user, + max_instances=1, + max_cpus=1, + max_memory=128, + max_disk_size=1, + ) + permission = Permission.objects.get(codename="clone_instances") + cls.test_user.user_permissions.add(permission) + + # Add localhost compute + cls.compute = Compute( + name="test-compute", + hostname="localhost", + login="", + password="", + details="local", + type=4, + ) + cls.compute.save() + + cls.connection = wvmCreate( + cls.compute.hostname, + cls.compute.login, + cls.compute.password, + cls.compute.type, + ) + + # Add disks for testing + cls.connection.create_volume( + "default", + "test-volume", + 1, + "qcow2", + False, + 0, + 0, + ) + # XML for testing vm + with open("conf/test-vm.xml", "r") as f: + cls.xml = f.read() + + # Create testing vm from XML + cls.connection._defineXML(cls.xml) + refr(cls.compute) + cls.instance: Instance = Instance.objects.get(pk=1) + + @classmethod + def tearDownClass(cls): + # Destroy testing vm + cls.instance.proxy.delete_all_disks() + cls.instance.proxy.delete(VIR_DOMAIN_UNDEFINE_NVRAM) + super().tearDownClass() + + def setUp(self): + self.client.login(username="admin", password="admin") + self.rsa_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test" + + def test_index(self): + response = self.client.get(reverse("instances:index")) + self.assertEqual(response.status_code, 200) + + self.client.force_login(self.test_user) + response = self.client.get(reverse("instances:index")) + self.assertEqual(response.status_code, 200) + + def test_create_select_type(self): + response = self.client.get( + reverse("instances:create_instance_select_type", args=[1]) + ) + self.assertEqual(response.status_code, 200) + + def test_instance_page(self): + response = self.client.get( + reverse("instances:instance", args=[self.instance.id]) + ) + self.assertEqual(response.status_code, 200) + + self.client.force_login(self.test_user) + response = self.client.get( + reverse("instances:instance", args=[self.instance.id]) + ) + self.assertRaises(Http404) + + # def test_create_volume(self): + # response = self.client.post( + # reverse('create_volume', args=[self.compute.id, 'default']), + # { + # 'name': 'test', + # 'format': 'qcow2', + # 'size': '1', + # 'meta_prealloc': False, + # }, + # ) + # self.assertRedirects(response, reverse('storage', args=[self.compute.id, 'default'])) + + def test_create_destroy_instance(self): + # Create + response = self.client.get( + reverse( + "instances:create_instance", args=[self.compute.id, "x86_64", "q35"] + ) + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse( + "instances:create_instance", args=[self.compute.id, "x86_64", "q35"] + ), + { + "name": "test", + "firmware": "BIOS", + "vcpu": 1, + "vcpu_mode": "host-model", + "memory": 128, + "device0": "disk", + "bus0": "virtio", + "images": "test-volume.qcow2", + "storage-control": "default", + "image-control": "test.qcow2", + "networks": "default", + "network-control": "default", + "cache_mode": "directsync", + "nwfilter": "", + "graphics": "spice", + "video": "vga", + "listener_addr": "0.0.0.0", + "console_pass": "", + "qemu_ga": False, + "virtio": True, + "create": True, + }, + ) + self.assertEqual(response.status_code, 302) + + instance_qs: Instance = Instance.objects.filter(name="test") + self.assertEqual(len(instance_qs), 1) + + instance = instance_qs[0] + + # Destroy + response = self.client.get(reverse("instances:destroy", args=[instance.id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("instances:destroy", args=[instance.id]), + {}, # do not destroy disk image + HTTP_REFERER=reverse("index"), + ) + self.assertRedirects(response, reverse("instances:index")) + + def test_create_from_xml(self): + uuid = randomUUID() + xml = self.xml.replace("test-vm", "test-vm-xml") + xml = re.sub("\s?.*?", f"{uuid}", xml) + response = self.client.post( + reverse("instances:create_instance_select_type", args=[self.compute.id]), + { + "create_xml": True, + "dom_xml": xml, + }, + ) + self.assertEqual(response.status_code, 302) + + xml_instance_qs: Instance = Instance.objects.filter(name="test-vm-xml") + self.assertEqual(len(xml_instance_qs), 1) + + xml_instance = xml_instance_qs[0] + + # destroy started instance to maximize coverage + xml_instance.proxy.start() + + response = self.client.post( + reverse("instances:destroy", args=[xml_instance.id]), + {}, # do not delete disk image + HTTP_REFERER=reverse("index"), + ) + self.assertRedirects(response, reverse("instances:index")) + + def test_resize_cpu(self): + self.assertEqual(self.instance.vcpu, 1) + self.assertEqual(self.instance.cur_vcpu, 1) + + response = self.client.post( + reverse("instances:resizevm_cpu", args=[self.instance.id]), + { + "vcpu": 4, + "cur_vcpu": 2, + }, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + # reset cached properties + del self.instance.vcpu + del self.instance.cur_vcpu + + self.assertEqual(self.instance.vcpu, 4) + self.assertEqual(self.instance.cur_vcpu, 2) + + def test_resize_cpu_with_quota(self): + # test for non admin user with quotas + vcpu = self.instance.vcpu + cur_vcpu = self.instance.cur_vcpu + + UserInstance.objects.create( + user=self.test_user, instance=self.instance, is_change=True + ) + + self.client.force_login(self.test_user) + + response = self.client.post( + reverse("instances:resizevm_cpu", args=[self.instance.id]), + { + "vcpu": 4, + "cur_vcpu": 2, + }, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + del self.instance.vcpu + del self.instance.cur_vcpu + + # no changes as user reached quota + self.assertEqual(self.instance.vcpu, vcpu) + self.assertEqual(self.instance.cur_vcpu, cur_vcpu) + + def test_resize_memory(self): + self.assertEqual(self.instance.memory, 128) + self.assertEqual(self.instance.cur_memory, 128) + + response = self.client.post( + reverse("instances:resize_memory", args=[self.instance.id]), + {"memory": 512, "cur_memory": 256}, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + del self.instance.memory + del self.instance.cur_memory + self.assertEqual(self.instance.memory, 512) + self.assertEqual(self.instance.cur_memory, 256) + + response = self.client.post( + reverse("instances:resize_memory", args=[self.instance.id]), + {"memory_custom": 500, "cur_memory_custom": 200}, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + del self.instance.memory + del self.instance.cur_memory + + self.assertEqual(self.instance.memory, 500) + self.assertEqual(self.instance.cur_memory, 200) + + def test_resize_memory_with_quota(self): + # test for non admin user with quotas + memory = self.instance.memory + cur_memory = self.instance.cur_memory + + UserInstance.objects.create( + user=self.test_user, instance=self.instance, is_change=True + ) + + self.client.force_login(self.test_user) + + response = self.client.post( + reverse("instances:resize_memory", args=[self.instance.id]), + {"memory": 512, "cur_memory": 256}, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + del self.instance.memory + del self.instance.cur_memory + + # no changes as user reached quota + self.assertEqual(self.instance.memory, memory) + self.assertEqual(self.instance.cur_memory, cur_memory) + + def test_resize_disk(self): + self.assertEqual(self.instance.disks[0]["size"], 1024**3) + + response = self.client.post( + reverse("instances:resize_disk", args=[self.instance.id]), + { + "disk_size_vda": 2, + }, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + del self.instance.disks + self.assertEqual(self.instance.disks[0]["size"], 2 * 1024**3) + + def test_resize_disk_with_quota(self): + # test for non admin user with quotas + disk_size = self.instance.disks[0]["size"] + UserInstance.objects.create( + user=self.test_user, instance=self.instance, is_change=True + ) + + self.client.force_login(self.test_user) + + response = self.client.post( + reverse("instances:resize_disk", args=[self.instance.id]), + { + "disk_size_vda": 3, + }, + ) + self.assertRedirects( + response, reverse("instances:instance", args=[self.instance.id]) + "#resize" + ) + + # no changes as user reached quota + del self.instance.disks + self.assertEqual(self.instance.disks[0]["size"], disk_size) + + def test_add_delete_new_volume(self): + self.assertEqual(len(self.instance.disks), 1) + + response = self.client.post( + reverse("instances:add_new_vol", args=[self.instance.id]), + { + "storage": "default", + "name": "test-volume-2", + "size": 1, + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.disks + self.assertEqual(len(self.instance.disks), 2) + + response = self.client.post( + reverse("instances:delete_vol", args=[self.instance.id]), + { + "storage": "default", + "dev": "vdb", + "name": "test-volume-2.qcow2", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.disks + self.assertEqual(len(self.instance.disks), 1) + + def test_detach_attach_volume(self): + # detach volume + response = self.client.post( + reverse("instances:detach_vol", args=[self.instance.id]), + { + "dev": "vda", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.disks + self.assertEqual(len(self.instance.disks), 0) + + # reattach volume + response = self.client.post( + reverse("instances:add_existing_vol", args=[self.instance.id]), + { + "selected_storage": "default", + "vols": "test-volume.qcow2", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.disks + self.assertEqual(len(self.instance.disks), 1) + + def test_edit_volume(self): + response = self.client.post( + reverse("instances:edit_volume", args=[self.instance.id]), + { + "vol_path": "/var/lib/libvirt/images/test-volume.qcow2", + # 'vol_shareable': False, + # 'vol_readonly': False, + "vol_bus": "virtio", + "vol_bus_old": "virtio", + "vol_format": "qcow2", + "dev": "vda", + "edit_volume": True, + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + def test_attach_detach_cdrom(self): + self.assertEqual(len(self.instance.media), 1) + + response = self.client.post( + reverse("instances:add_cdrom", args=[self.instance.id]), + { + "bus": "sata", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.media + self.assertEqual(len(self.instance.media), 2) + + # create dummy iso + # with tempfile.NamedTemporaryFile() as f: + # f.write(b'\x00' * 1024**2) + + # response = self.client.post( + # reverse('storage', args=[instance.compute.id, 'default']), + # { + # 'iso_upload': True, + # 'file': f + # }, + # ) + + # detach CD-ROM drive + response = self.client.post( + reverse("instances:detach_cdrom", args=[self.instance.id, "sda"]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.media + self.assertEqual(len(self.instance.media), 1) + + def test_snapshots(self): + self.assertEqual(len(self.instance.snapshots), 0) + + response = self.client.post( + reverse("instances:snapshot", args=[self.instance.id]), + { + "name": "test-snapshot", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.snapshots + self.assertEqual(len(self.instance.snapshots), 1) + + response = self.client.post( + reverse("instances:revert_snapshot", args=[self.instance.id]), + { + "name": "test-snapshot", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + response = self.client.post( + reverse("instances:delete_snapshot", args=[self.instance.id]), + { + "name": "test-snapshot", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.snapshots + self.assertEqual(len(self.instance.snapshots), 0) + + def test_autostart(self): + self.assertEqual(self.instance.autostart, 0) + + response = self.client.post( + reverse("instances:set_autostart", args=[self.instance.id]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.autostart + self.assertEqual(self.instance.autostart, 1) + + response = self.client.post( + reverse("instances:unset_autostart", args=[self.instance.id]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.autostart + self.assertEqual(self.instance.autostart, 0) + + def test_bootmenu(self): + self.assertEqual(self.instance.bootmenu, True) + + response = self.client.post( + reverse("instances:unset_bootmenu", args=[self.instance.id]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.bootmenu + self.assertEqual(self.instance.bootmenu, False) + + response = self.client.post( + reverse("instances:set_bootmenu", args=[self.instance.id]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.bootmenu + self.assertEqual(self.instance.bootmenu, True) + + def test_guest_agent(self): + self.assertEqual(self.instance.guest_agent, False) + + response = self.client.post( + reverse("instances:set_guest_agent", args=[self.instance.id]), + {"guest_agent": True}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.guest_agent + self.assertEqual(self.instance.guest_agent, True) + + response = self.client.post( + reverse("instances:set_guest_agent", args=[self.instance.id]), + {"guest_agent": False}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.guest_agent + self.assertEqual(self.instance.guest_agent, False) + + def test_video_model(self): + self.assertEqual(self.instance.video_model, "vga") + + response = self.client.post( + reverse("instances:set_video_model", args=[self.instance.id]), + {"video_model": "virtio"}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.video_model + self.assertEqual(self.instance.video_model, "virtio") + + def test_owner(self): + self.assertEqual(UserInstance.objects.count(), 0) + response = self.client.post( + reverse("instances:add_owner", args=[self.instance.id]), + {"user_id": self.admin_user.id}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(UserInstance.objects.count(), 1) + + user_instance: UserInstance = UserInstance.objects.get(id=1) + self.assertEqual(user_instance.instance_id, self.instance.id) + self.assertEqual(user_instance.user_id, self.admin_user.id) + + # test when no multiple owners allowed + setting = AppSettings.objects.get(key="ALLOW_INSTANCE_MULTIPLE_OWNER") + setting.value = "False" + setting.save() + + response = self.client.post( + reverse("instances:add_owner", args=[self.instance.id]), + {"user_id": self.test_user.id}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(UserInstance.objects.count(), 1) + + response = self.client.post( + reverse("instances:del_owner", args=[self.instance.id]), + {"userinstance": user_instance.id}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(UserInstance.objects.count(), 0) + + def test_clone(self): + instance_count = Instance.objects.count() + response = self.client.post( + reverse("instances:clone", args=[self.instance.id]), + { + "name": "test-vm-clone", + "clone-net-mac-0": "de:ad:be:ef:de:ad", + "disk-vda": "test-clone.img", + "clone-title": "", + "clone-description": "", + "clone": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Instance.objects.count(), instance_count + 1) + + clone_qs = Instance.objects.filter(name="test-vm-clone") + self.assertEqual(len(clone_qs), 1) + clone = clone_qs[0] + + self.assertEqual(clone.proxy.get_net_devices()[0]["mac"], "de:ad:be:ef:de:ad") + + response = self.client.post( + reverse("instances:snapshot", args=[clone.id]), + { + "name": "test", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + response = self.client.post( + reverse("instances:destroy", args=[clone.id]), + {"delete_disk": True, "delete_nvram": True}, + HTTP_REFERER=reverse("index"), + ) + self.assertRedirects(response, reverse("instances:index")) + self.assertEqual(Instance.objects.count(), instance_count) + + def test_clone_with_quota(self): + # test for non admin user with quotas + instance_count = Instance.objects.count() + + UserInstance.objects.create(user=self.test_user, instance=self.instance) + + self.client.force_login(self.test_user) + + response = self.client.post( + reverse("instances:clone", args=[self.instance.id]), + { + "name": "test-vm-clone", + "clone-net-mac-0": "de:ad:be:ef:de:ad", + "disk-vda": "test-clone.img", + "clone-title": "", + "clone-description": "", + "clone": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + # no new instances created as user reached quota + self.assertEqual(Instance.objects.count(), instance_count) + + def test_clone_errors(self): + instance_count = Instance.objects.count() + + # duplicate name + response = self.client.post( + reverse("instances:clone", args=[self.instance.id]), + { + "name": "test-vm", + "clone-net-mac-0": "de:ad:be:ef:de:ad", + "disk-vda": "test.img", + "clone-title": "", + "clone-description": "", + "clone": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Instance.objects.count(), instance_count) + + # wrong name + response = self.client.post( + reverse("instances:clone", args=[self.instance.id]), + { + "name": "!@#$", + "clone-net-mac-0": "de:ad:be:ef:de:ad", + "disk-vda": "!@#$.img", + "clone-title": "", + "clone-description": "", + "clone": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Instance.objects.count(), instance_count) + + # wrong mac + response = self.client.post( + reverse("instances:clone", args=[self.instance.id]), + { + "name": "test-vm-clone", + "clone-net-mac-0": "gh:ad:be:ef:de:ad", + "disk-vda": "test-clone.img", + "clone-title": "", + "clone-description": "", + "clone": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Instance.objects.count(), instance_count) + + def test_console(self): + response = self.client.post( + reverse("instances:update_console", args=[self.instance.id]), + {"type": "spice", "listen_on": "0.0.0.0", "password": "", "keymap": "auto"}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + def test_status(self): + response = self.client.get(reverse("instances:status", args=[self.instance.id])) + self.assertEqual(response.status_code, 200) + + def test_stats(self): + response = self.client.get(reverse("instances:stats", args=[self.instance.id])) + self.assertEqual(response.status_code, 200) + + def test_guess_mac_address(self): + response = self.client.get( + reverse("instances:guess_mac_address", args=[self.instance.name]) + ) + self.assertEqual(response.status_code, 200) + + def test_random_mac_address(self): + response = self.client.get(reverse("instances:random_mac_address")) + self.assertEqual(response.status_code, 200) + + def test_guess_clone_name(self): + response = self.client.get(reverse("instances:guess_clone_name")) + self.assertEqual(response.status_code, 200) + + def test_sshkeys(self): + UserSSHKey.objects.create( + keyname="keyname", keypublic=self.rsa_key, user=self.test_user + ) + UserInstance.objects.create(user=self.test_user, instance=self.instance) + + response = self.client.get( + reverse("instances:sshkeys", args=[self.instance.id]) + ) + self.assertEqual(response.status_code, 200) + + response = self.client.get( + reverse("instances:sshkeys", args=[self.instance.id]) + "?plain=true" + ) + self.assertEqual(response.status_code, 200) + + def test_check_instance(self): + response = self.client.get( + reverse("instances:check_instance", args=["test-vm"]) + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(response.content, {"vname": "test-vm", "exists": True}) + + def test_start_template(self): + # starting templates must fail + self.assertEqual(self.instance.status, 5) + + self.instance.is_template = True + self.instance.save() + + response = self.client.get( + reverse("instances:poweron", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + self.assertEqual(self.instance.status, 5) + + self.instance.is_template = False + self.instance.save() + + def test_power(self): + # poweron + self.assertEqual(self.instance.status, 5) + + response = self.client.get( + reverse("instances:poweron", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + + self.assertEqual(self.instance.status, 1) + + # suspend + response = self.client.get( + reverse("instances:suspend", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + self.assertEqual(self.instance.status, 3) + + # resume + response = self.client.get( + reverse("instances:resume", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + self.assertEqual(self.instance.status, 1) + + # poweroff + response = self.client.get( + reverse("instances:poweroff", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + # as no OS is installed ACPI won't work + del self.instance.status + self.assertEqual(self.instance.status, 1) + + # powercycle + response = self.client.get( + reverse("instances:powercycle", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + self.assertEqual(self.instance.status, 1) + + # force_off + response = self.client.get( + reverse("instances:force_off", args=[self.instance.id]), + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.status + self.assertEqual(self.instance.status, 5) + + def test_vv_file(self): + response = self.client.get( + reverse("instances:getvvfile", args=[self.instance.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_vcpu_hotplug(self): + response = self.client.post( + reverse("instances:set_vcpu_hotplug", args=[self.instance.id]), + {"vcpu_hotplug": "True"}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + def test_change_network(self): + self.assertEqual(self.instance.networks[0]["mac"], "52:54:00:a2:3c:e7") + response = self.client.post( + reverse("instances:change_network", args=[self.instance.id]), + { + "net-mac-0": "52:54:00:a2:3c:e8", + "net-source-0": "net:default", + "net-nwfilter-0": "", + "net-model-0": "virtio", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.networks + self.assertEqual(self.instance.networks[0]["mac"], "52:54:00:a2:3c:e8") + + def test_add_delete_network(self): + self.assertEqual(len(self.instance.networks), 1) + net_mac = self.instance.networks[0]["mac"] + response = self.client.post( + reverse("instances:add_network", args=[self.instance.id]), + { + "add-net-mac": "52:54:00:a2:3c:e9", + "add-net-network": "net:default", + "add_net-nwfilter": "", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.networks + self.assertEqual(len(self.instance.networks), 2) + self.assertEqual(self.instance.networks[1]["mac"], "52:54:00:a2:3c:e9") + + response = self.client.post( + reverse("instances:delete_network", args=[self.instance.id]), + { + "delete_network": "52:54:00:a2:3c:e9", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.networks + self.assertEqual(len(self.instance.networks), 1) + self.assertEqual(self.instance.networks[0]["mac"], net_mac) + + def test_set_link_state(self): + self.assertEqual(self.instance.networks[0]["state"], "up") + response = self.client.post( + reverse("instances:set_link_state", args=[self.instance.id]), + { + "mac": self.instance.networks[0]["mac"], + "set_link_state": "up", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.networks + self.assertEqual(self.instance.networks[0]["state"], "down") + + def test_set_unset_qos(self): + self.assertEqual(len(self.instance.qos.keys()), 0) + net_mac = self.instance.networks[0]["mac"] + response = self.client.post( + reverse("instances:set_qos", args=[self.instance.id]), + { + "net-mac-0": net_mac, + "qos_direction": "inbound", + "qos_average": 1, + "qos_peak": 1, + "qos_burst": 1, + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.qos + self.assertEqual(len(self.instance.qos.keys()), 1) + + response = self.client.post( + reverse("instances:unset_qos", args=[self.instance.id]), + { + "net-mac": net_mac, + "qos_direction": "inbound", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + del self.instance.qos + self.assertEqual(len(self.instance.qos.keys()), 0) + + # test on running instance + # self.instance.proxy.start() + # response = self.client.post( + # reverse('instances:set_qos', args=[self.instance.id]), + # { + # 'net-mac-0': net_mac, + # 'qos_direction': 'inbound', + # 'qos_average': 1, + # 'qos_peak': 1, + # 'qos_burst': 1, + # }, + # HTTP_REFERER=reverse('index'), + # ) + # self.assertEqual(response.status_code, 302) + # self.instance.proxy.force_shutdown() + + def test_change_options(self): + self.assertEqual(self.instance.title, "") + self.assertEqual(self.instance.description, "") + + response = self.client.post( + reverse("instances:change_options", args=[self.instance.id]), + { + "title": "test-vm-title", + "description": "test-vm description", + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + del self.instance.title + del self.instance.description + + self.assertEqual(self.instance.title, "test-vm-title") + self.assertEqual(self.instance.description, "test-vm description") + + def test_flavors(self): + response = self.client.get(reverse("instances:flavor_create")) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("instances:flavor_create"), + { + "label": "test_flavor", + "memory": 256, + "vcpu": 1, + "disk": 10, + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + id = Flavor.objects.last().id + + response = self.client.get(reverse("instances:flavor_update", args=[id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("instances:flavor_update", args=[id]), + { + "label": "test_flavor_", + "memory": 256, + "vcpu": 1, + "disk": 10, + }, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + response = self.client.get(reverse("instances:flavor_delete", args=[id])) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse("instances:flavor_delete", args=[id]), + {}, + HTTP_REFERER=reverse("index"), + ) + self.assertEqual(response.status_code, 302) + + # def donot_test_instance(self): + # compute: Compute = Compute.objects.get(pk=1) + # user: User = User.objects.get(pk=1) + + # # delete started instance with disks + # self.instance.proxy.start() + # del self.instance.status + # self.assertEqual(self.instance.status, 1) + + # # create volume + # response = self.client.post( + # reverse('create_volume', args=[compute.id, 'default']), + # { + # 'name': 'test3', + # 'format': 'qcow2', + # 'size': '1', + # 'meta_prealloc': False, + # }, + # ) + # self.assertRedirects(response, reverse('storage', args=[compute.id, 'default'])) + + # # delete volume + # response = self.client.post( + # reverse('instances:delete_vol', args=[instance.id]), + # { + # 'storage': 'default', + # 'dev': 'vdb', + # 'name': 'test3.qcow2', + # }, + # HTTP_REFERER=reverse('index'), + # ) + # self.assertEqual(response.status_code, 302) + + # , list(response.context['messages'])[0] diff --git a/instances/urls.py b/instances/urls.py old mode 100644 new mode 100755 index ce53b89..acb5444 --- a/instances/urls.py +++ b/instances/urls.py @@ -1,11 +1,87 @@ -from django.conf.urls import url +from django.urls import path + from . import views +app_name = "instances" + urlpatterns = [ - url(r'^(?P[0-9]+)/(?P[\w\-\.]+)/$', - views.instance, name='instance'), - url(r'^statistics/(?P[0-9]+)/(?P[\w\-\.]+)/$', - views.inst_graph, name='inst_graph'), - url(r'^status/(?P[0-9]+)/(?P[\w\-\.]+)/$', - views.inst_status, name='inst_status'), + path("", views.index, name="index"), + path("flavor/create/", views.flavor_create, name="flavor_create"), + path("flavor//update/", views.flavor_update, name="flavor_update"), + path("flavor//delete/", views.flavor_delete, name="flavor_delete"), + path("/", views.instance, name="instance"), + path("/poweron/", views.poweron, name="poweron"), + path("/powercycle/", views.powercycle, name="powercycle"), + path("/poweroff/", views.poweroff, name="poweroff"), + path("/suspend/", views.suspend, name="suspend"), + path("/resume/", views.resume, name="resume"), + path("/force_off/", views.force_off, name="force_off"), + path("/destroy/", views.destroy, name="destroy"), + path("/migrate/", views.migrate, name="migrate"), + path("/status/", views.status, name="status"), + path("/stats/", views.stats, name="stats"), + path("/osinfo/", views.osinfo, name="osinfo"), + path("/rootpasswd/", views.set_root_pass, name="rootpasswd"), + path("/add_public_key/", views.add_public_key, name="add_public_key"), + path("/resizevm_cpu/", views.resizevm_cpu, name="resizevm_cpu"), + path("/resize_memory/", views.resize_memory, name="resize_memory"), + path("/resize_disk/", views.resize_disk, name="resize_disk"), + path("/add_new_vol/", views.add_new_vol, name="add_new_vol"), + path("/delete_vol/", views.delete_vol, name="delete_vol"), + path("/add_owner/", views.add_owner, name="add_owner"), + path("/add_existing_vol/", views.add_existing_vol, name="add_existing_vol"), + path("/edit_volume/", views.edit_volume, name="edit_volume"), + path("/detach_vol/", views.detach_vol, name="detach_vol"), + path("/add_cdrom/", views.add_cdrom, name="add_cdrom"), + path("/detach_cdrom//", views.detach_cdrom, name="detach_cdrom"), + path("/unmount_iso/", views.unmount_iso, name="unmount_iso"), + path("/mount_iso/", views.mount_iso, name="mount_iso"), + path("/snapshot/", views.snapshot, name="snapshot"), + path("/delete_snapshot/", views.delete_snapshot, name="delete_snapshot"), + path("/revert_snapshot/", views.revert_snapshot, name="revert_snapshot"), + path("/create_external_snapshot/", views.create_external_snapshot, name="create_external_snapshot"), + path("/revert_external_snapshot/", views.revert_external_snapshot, name="revert_external_snapshot"), + path("/delete_external_snapshot/", views.delete_external_snapshot, name="delete_external_snapshot"), + path("/set_vcpu/", views.set_vcpu, name="set_vcpu"), + path("/set_vcpu_hotplug/", views.set_vcpu_hotplug, name="set_vcpu_hotplug"), + path("/set_autostart/", views.set_autostart, name="set_autostart"), + path("/unset_autostart/", views.unset_autostart, name="unset_autostart"), + path("/set_bootmenu/", views.set_bootmenu, name="set_bootmenu"), + path("/unset_bootmenu/", views.unset_bootmenu, name="unset_bootmenu"), + path("/set_bootorder/", views.set_bootorder, name="set_bootorder"), + path("/change_xml/", views.change_xml, name="change_xml"), + path("/set_guest_agent/", views.set_guest_agent, name="set_guest_agent"), + path("/set_video_model/", views.set_video_model, name="set_video_model"), + path("/change_network/", views.change_network, name="change_network"), + path("/add_network/", views.add_network, name="add_network"), + path("/delete_network/", views.delete_network, name="delete_network"), + path("/set_link_state/", views.set_link_state, name="set_link_state"), + path("/set_qos/", views.set_qos, name="set_qos"), + path("/unset_qos/", views.unset_qos, name="unset_qos"), + path( + "/del_owner/", views.del_owner, name="del_owner" + ), # no links to this one??? + path("/clone/", views.clone, name="clone"), + path("/update_console/", views.update_console, name="update_console"), + path("/change_options/", views.change_options, name="change_options"), + path( + "/getvvfile/", views.getvvfile, name="getvvfile" + ), # no links to this one??? + path( + "create//", + views.create_instance_select_type, + name="create_instance_select_type", + ), + path( + "create////", + views.create_instance, + name="create_instance", + ), + path( + "guess_mac_address//", views.guess_mac_address, name="guess_mac_address" + ), + path("guess_clone_name/", views.guess_clone_name, name="guess_clone_name"), + path("random_mac_address/", views.random_mac_address, name="random_mac_address"), + path("check_instance//", views.check_instance, name="check_instance"), + path("/sshkeys/", views.sshkeys, name="sshkeys"), ] diff --git a/instances/utils.py b/instances/utils.py new file mode 100644 index 0000000..19b2111 --- /dev/null +++ b/instances/utils.py @@ -0,0 +1,218 @@ +import os +import random +import string + +from accounts.models import UserInstance, UserAttributes +from appsettings.settings import app_settings +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from vrtManager.connection import connection_manager +from vrtManager.instance import wvmInstance, wvmInstances + +from .models import Instance + + +def get_clone_free_names(size=10): + prefix = app_settings.CLONE_INSTANCE_DEFAULT_PREFIX + free_names = [] + existing_names = [i.name for i in Instance.objects.filter(name__startswith=prefix)] + index = 1 + while len(free_names) < size: + new_name = prefix + str(index) + if new_name not in existing_names: + free_names.append(new_name) + index += 1 + return free_names + + +def check_user_quota(user, instance, cpu, memory, disk_size): + ua, attributes_created = UserAttributes.objects.get_or_create(user=user) + msg = "" + + if user.is_superuser: + return msg + + quota_debug = app_settings.QUOTA_DEBUG + + user_instances = UserInstance.objects.filter(user=user, instance__is_template=False) + instance += user_instances.count() + for usr_inst in user_instances: + if connection_manager.host_is_up( + usr_inst.instance.compute.type, + usr_inst.instance.compute.hostname, + ): + conn = wvmInstance( + usr_inst.instance.compute.hostname, + usr_inst.instance.compute.login, + usr_inst.instance.compute.password, + usr_inst.instance.compute.type, + usr_inst.instance.name, + ) + cpu += int(conn.get_vcpu()) + memory += int(conn.get_memory()) + for disk in conn.get_disk_devices(): + if disk["size"]: + disk_size += int(disk["size"]) >> 30 + + if ua.max_instances > 0 and instance > ua.max_instances: + msg = "instance" + if quota_debug: + msg += f" ({instance} > {ua.max_instances})" + if ua.max_cpus > 0 and cpu > ua.max_cpus: + msg = "cpu" + if quota_debug: + msg += f" ({cpu} > {ua.max_cpus})" + if ua.max_memory > 0 and memory > ua.max_memory: + msg = "memory" + if quota_debug: + msg += f" ({memory} > {ua.max_memory})" + if ua.max_disk_size > 0 and disk_size > ua.max_disk_size: + msg = "disk" + if quota_debug: + msg += f" ({disk_size} > {ua.max_disk_size})" + return msg + + +def get_new_disk_dev(media, disks, bus): + existing_disk_devs = [] + existing_media_devs = [] + if bus == "virtio": + dev_base = "vd" + elif bus == "ide": + dev_base = "hd" + elif bus == "fdc": + dev_base = "fd" + else: + dev_base = "sd" + + if disks: + existing_disk_devs = [disk["dev"] for disk in disks] + + # cd-rom bus could be virtio/sata, because of that we should check it also + if media: + existing_media_devs = [m["dev"] for m in media] + + for al in string.ascii_lowercase: + dev = dev_base + al + if dev not in existing_disk_devs and dev not in existing_media_devs: + return dev + raise Exception(_("None available device name")) + + +def get_network_tuple(network_source_str): + network_source_pack = network_source_str.split(":", 1) + if len(network_source_pack) > 1: + return network_source_pack[1], network_source_pack[0] + else: + return network_source_pack[0], "net" + + +def migrate_instance( + new_compute, + instance, + user, + live=False, + unsafe=False, + xml_del=False, + offline=False, + autoconverge=False, + compress=False, + postcopy=False, +): + status = connection_manager.host_is_up(new_compute.type, new_compute.hostname) + if not status: + return + if new_compute == instance.compute: + return + try: + conn_migrate = wvmInstances( + new_compute.hostname, + new_compute.login, + new_compute.password, + new_compute.type, + ) + + autostart = instance.autostart + conn_migrate.moveto( + instance.proxy, + instance.name, + live, + unsafe, + xml_del, + offline, + autoconverge, + compress, + postcopy, + ) + finally: + conn_migrate.close() + + try: + conn_new = wvmInstance( + new_compute.hostname, + new_compute.login, + new_compute.password, + new_compute.type, + instance.name, + ) + + if autostart: + conn_new.set_autostart(1) + finally: + conn_new.close() + + instance.compute = new_compute + instance.save() + + +def refr(compute): + if compute.status is True: + domains = compute.proxy.wvm.listAllDomains() + domain_names = [d.name() for d in domains] + domain_uuids = [d.UUIDString() for d in domains] + # Delete instances that're not on host + Instance.objects.filter(compute=compute).exclude(name__in=domain_names).delete() + Instance.objects.filter(compute=compute).exclude(uuid__in=domain_uuids).delete() + # Create instances that're not in DB + names = Instance.objects.filter(compute=compute).values_list("name", flat=True) + for domain in domains: + if domain.name() not in names: + Instance(compute=compute, name=domain.name(), uuid=domain.UUIDString()).save() + + +def get_dhcp_mac_address(vname): + dhcp_file = str(settings.BASE_DIR) + "/dhcpd.conf" + mac = "" + if os.path.isfile(dhcp_file): + with open(dhcp_file, "r") as f: + name_found = False + for line in f: + if "host %s." % vname in line: + name_found = True + if name_found and "hardware ethernet" in line: + mac = line.split(" ")[-1].strip().strip(";") + break + return mac + + +def get_random_mac_address(): + mac = settings.MAC_OUI + ":%02x:%02x:%02x" % ( + random.randint(0x00, 0xFF), + random.randint(0x00, 0xFF), + random.randint(0x00, 0xFF), + ) + return mac + + +def get_clone_disk_name(disk, prefix, clone_name=""): + if not disk["image"]: + return None + if disk["image"].startswith(prefix) and clone_name: + suffix = disk["image"][len(prefix) :] + image = f"{clone_name}{suffix}" + elif "." in disk["image"] and len(disk["image"].rsplit(".", 1)[1]) <= 7: + name, suffix = disk["image"].rsplit(".", 1) + image = f"{name}-clone.{suffix}" + else: + image = f"{disk['image']}-clone" + return image diff --git a/instances/views.py b/instances/views.py old mode 100644 new mode 100755 index 29a1a1b..54b73af --- a/instances/views.py +++ b/instances/views.py @@ -1,631 +1,1955 @@ -import time +import crypt_r import json +import os +import re import socket -import crypt -from string import letters, digits -from random import choice +import subprocess +import time from bisect import insort -from django.http import HttpResponse, HttpResponseRedirect -from django.core.urlresolvers import reverse -from django.shortcuts import render, get_object_or_404 -from django.utils.translation import ugettext_lazy as _ -from computes.models import Compute -from instances.models import Instance + from accounts.models import UserInstance, UserSSHKey -from vrtManager.hostdetails import wvmHostDetails -from vrtManager.instance import wvmInstance, wvmInstances -from vrtManager.connection import connection_manager -from libvirt import libvirtError, VIR_DOMAIN_XML_SECURE -from webvirtcloud.settings import QEMU_KEYMAPS, QEMU_CONSOLE_TYPES +from admin.decorators import superuser_only +from appsettings.models import AppSettings +from appsettings.settings import app_settings +from computes.models import Compute +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.models import User +from django.http import Http404, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext_noop as _ +from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM, + VIR_DOMAIN_UNDEFINE_NVRAM, + VIR_DOMAIN_START_PAUSED, + libvirtError) from logs.views import addlogmsg +from vrtManager import util +from vrtManager.create import wvmCreate +from vrtManager.instance import wvmInstances +from vrtManager.interface import wvmInterface +from vrtManager.storage import wvmStorage +from vrtManager.util import randomPasswd + +from instances.models import Instance + +from . import utils +from .forms import ConsoleForm, FlavorForm, NewVMForm +from .models import Flavor def index(request): - """ - :param request: - :return: - """ + instances = None - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('login')) + computes = ( + Compute.objects.all() + .order_by("name") + .prefetch_related("instance_set") + .prefetch_related("instance_set__userinstance_set") + ) + for compute in computes: + utils.refr(compute) + + if request.user.is_superuser or request.user.has_perm("instances.view_instances"): + instances = Instance.objects.all().prefetch_related("userinstance_set") else: - return HttpResponseRedirect(reverse('instances')) + instances = Instance.objects.filter( + userinstance__user=request.user + ).prefetch_related("userinstance_set") + + return render( + request, "allinstances.html", {"computes": computes, "instances": instances} + ) -def instances(request): - """ - :param request: - :return: - """ - - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) - - error_messages = [] - all_host_vms = {} - all_user_vms = {} - computes = Compute.objects.all() - - if not request.user.is_superuser: - user_instances = UserInstance.objects.filter(user_id=request.user.id) - for usr_inst in user_instances: - if connection_manager.host_is_up(usr_inst.instance.compute.type, - usr_inst.instance.compute.hostname): - conn = wvmHostDetails(usr_inst.instance.compute, - usr_inst.instance.compute.login, - usr_inst.instance.compute.password, - usr_inst.instance.compute.type) - all_user_vms[usr_inst] = conn.get_user_instances(usr_inst.instance.name) - all_user_vms[usr_inst].update({'compute_id': usr_inst.instance.compute.id}) - else: - for comp in computes: - if connection_manager.host_is_up(comp.type, comp.hostname): - try: - conn = wvmHostDetails(comp, comp.login, comp.password, comp.type) - if conn.get_host_instances(): - all_host_vms[comp.id, comp.name] = conn.get_host_instances() - for vm, info in conn.get_host_instances().items(): - try: - check_uuid = Instance.objects.get(compute_id=comp.id, name=vm) - if check_uuid.uuid != info['uuid']: - check_uuid.save() - except Instance.DoesNotExist: - check_uuid = Instance(compute_id=comp.id, name=vm, uuid=info['uuid']) - check_uuid.save() - conn.close() - except libvirtError as lib_err: - error_messages.append(lib_err) - - if request.method == 'POST': - name = request.POST.get('name', '') - compute_id = request.POST.get('compute_id', '') - instance = Instance.objects.get(compute_id=compute_id, name=name) - try: - conn = wvmInstances(instance.compute.hostname, - instance.compute.login, - instance.compute.password, - instance.compute.type) - if 'poweron' in request.POST: - msg = _("Power On") - addlogmsg(request.user.username, instance.name, msg) - conn.start(name) - return HttpResponseRedirect(request.get_full_path()) - - if 'poweroff' in request.POST: - msg = _("Power Off") - addlogmsg(request.user.username, instance.name, msg) - conn.shutdown(name) - return HttpResponseRedirect(request.get_full_path()) - - if 'powercycle' in request.POST: - msg = _("Power Cycle") - conn.force_shutdown(name) - conn.start(name) - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path()) - - if 'getvvfile' in request.POST: - msg = _("Send console.vv file") - addlogmsg(request.user.username, instance.name, msg) - response = HttpResponse(content='', content_type='application/x-virt-viewer', status=200, reason=None, charset='utf-8') - response.writelines('[virt-viewer]\n') - response.writelines('type=' + conn.graphics_type(name) + '\n') - response.writelines('host=' + conn.graphics_listen(name) + '\n') - response.writelines('port=' + conn.graphics_port(name) + '\n') - response.writelines('title=' + conn.domain_name(name) + '\n') - response.writelines('password=' + conn.graphics_passwd(name) + '\n') - response.writelines('enable-usbredir=1\n') - response.writelines('disable-effects=all\n') - response.writelines('secure-attention=ctrl+alt+ins\n') - response.writelines('release-cursor=ctrl+alt\n') - response.writelines('fullscreen=1\n') - response.writelines('delete-this-file=1\n') - response['Content-Disposition'] = 'attachment; filename="console.vv"' - return response - - if request.user.is_superuser: - - if 'suspend' in request.POST: - msg = _("Suspend") - addlogmsg(request.user.username, instance.name, msg) - conn.suspend(name) - return HttpResponseRedirect(request.get_full_path()) - - if 'resume' in request.POST: - msg = _("Resume") - addlogmsg(request.user.username, instance.name, msg) - conn.resume(name) - return HttpResponseRedirect(request.get_full_path()) - - except libvirtError as lib_err: - error_messages.append(lib_err) - addlogmsg(request.user.username, instance.name, lib_err.message) - - return render(request, 'instances.html', locals()) - - -def instance(request, compute_id, vname): - """ - :param request: - :return: - """ - - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('index')) - - error_messages = [] - messages = [] - compute = get_object_or_404(Compute, pk=compute_id) - computes = Compute.objects.all() - computes_count = len(computes) +def instance(request, pk): + instance: Instance = get_instance(request.user, pk) + compute: Compute = instance.compute + computes = Compute.objects.all().order_by("name") + computes_count = computes.count() + users = User.objects.all().order_by("username") publickeys = UserSSHKey.objects.filter(user_id=request.user.id) - keymaps = QEMU_KEYMAPS - console_types = QEMU_CONSOLE_TYPES + keymaps = settings.QEMU_KEYMAPS + console_types = AppSettings.objects.get( + key="QEMU_CONSOLE_DEFAULT_TYPE" + ).choices_as_list() + console_form = ConsoleForm( + initial={ + "type": instance.console_type, + "listen_on": instance.console_listener_address, + "password": instance.console_passwd, + "keymap": instance.console_keymap, + } + ) + console_listener_addresses = settings.QEMU_CONSOLE_LISTENER_ADDRESSES + bottom_bar = app_settings.VIEW_INSTANCE_DETAIL_BOTTOM_BAR + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) try: - userinstace = UserInstance.objects.get(instance__compute_id=compute_id, - instance__name=vname, - user__id=request.user.id) + userinstance = UserInstance.objects.get( + instance__compute_id=compute.id, + instance__name=instance.name, + user__id=request.user.id, + ) except UserInstance.DoesNotExist: - userinstace = None + userinstance = None - if not request.user.is_superuser: - if not userinstace: - return HttpResponseRedirect(reverse('index')) + memory_range = [256, 512, 768, 1024, 2048, 3072, 4096, 6144, 8192, 16384] + if instance.memory not in memory_range: + insort(memory_range, instance.memory) + if instance.cur_memory not in memory_range: + insort(memory_range, instance.cur_memory) + clone_free_names = utils.get_clone_free_names() + user_quota_msg = utils.check_user_quota(request.user, 0, 0, 0, 0) - def show_clone_disk(disks): - clone_disk = [] - for disk in disks: - if disk['image'] is None: - continue - if disk['image'].count(".") and len(disk['image'].rsplit(".", 1)[1]) <= 7: - name, suffix = disk['image'].rsplit(".", 1) - image = name + "-clone" + "." + suffix - else: - image = disk['image'] + "-clone" - clone_disk.append( - {'dev': disk['dev'], 'storage': disk['storage'], - 'image': image, 'format': disk['format']}) - return clone_disk + default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS + default_io = app_settings.INSTANCE_VOLUME_DEFAULT_IO + default_discard = app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD + default_zeroes = app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES + default_cache = app_settings.INSTANCE_VOLUME_DEFAULT_CACHE + default_format = app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT + # default_disk_owner_uid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID) + # default_disk_owner_gid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID) - try: - conn = wvmInstance(compute.hostname, - compute.login, - compute.password, - compute.type, - vname) + # clone_instance_auto_name = app_settings.CLONE_INSTANCE_AUTO_NAME - status = conn.get_status() - autostart = conn.get_autostart() - vcpu = conn.get_vcpu() - cur_vcpu = conn.get_cur_vcpu() - uuid = conn.get_uuid() - memory = conn.get_memory() - cur_memory = conn.get_cur_memory() - description = conn.get_description() - disks = conn.get_disk_device() - media = conn.get_media_device() - networks = conn.get_net_device() - media_iso = sorted(conn.get_iso_media()) - vcpu_range = conn.get_max_cpus() - memory_range = [256, 512, 768, 1024, 2048, 4096, 6144, 8192, 16384] - if memory not in memory_range: - insort(memory_range, memory) - if cur_memory not in memory_range: - insort(memory_range, cur_memory) - memory_host = conn.get_max_memory() - vcpu_host = len(vcpu_range) - telnet_port = conn.get_telnet_port() - console_type = conn.get_console_type() - console_port = conn.get_console_port() - console_keymap = conn.get_console_keymap() - snapshots = sorted(conn.get_snapshot(), reverse=True) - inst_xml = conn._XMLDesc(VIR_DOMAIN_XML_SECURE) - has_managed_save_image = conn.get_managed_save_image() - clone_disks = show_clone_disk(disks) - console_passwd = conn.get_console_passwd() + # try: + # instance = Instance.objects.get(compute=compute, name=vname) + # if instance.uuid != uuid: + # instance.uuid = uuid + # instance.save() + # msg = _(f"Fixing UUID {uuid}") + # addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + # except Instance.DoesNotExist: + # instance = Instance(compute=compute, name=vname, uuid=uuid) + # instance.save() + # msg = _("Instance does not exist: Creating new instance") + # addlogmsg(request.user.username, instance.compute.name, instance.name, msg) - try: - instance = Instance.objects.get(compute_id=compute_id, name=vname) - if instance.uuid != uuid: - instance.uuid = uuid - instance.save() - except Instance.DoesNotExist: - instance = Instance(compute_id=compute_id, name=vname, uuid=uuid) - instance.save() + # userinstances = UserInstance.objects.filter(instance=instance).order_by('user__username') + userinstances = instance.userinstance_set.order_by("user__username") + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) - if request.method == 'POST': - if 'poweron' in request.POST: - conn.start() - msg = _("Power On") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#poweron') + # Host resources + vcpu_host = len(instance.vcpu_range) + memory_host = instance.proxy.get_max_memory() + bus_host = instance.proxy.get_disk_bus_types(instance.arch, instance.machine) + networks_host = sorted(instance.proxy.get_networks()) + interfaces_host = sorted(instance.proxy.get_ifaces()) + nwfilters_host = instance.proxy.get_nwfilters() + storages_host = sorted(instance.proxy.get_storages(True)) + net_models_host = instance.proxy.get_network_models() - if 'powercycle' in request.POST: - conn.force_shutdown() - conn.start() - msg = _("Power Cycle") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#powercycle') + if app_settings.VM_DRBD_STATUS == "True": + instance.drbd = drbd_status(request, pk) + instance.save() - if 'poweroff' in request.POST: - conn.shutdown() - msg = _("Power Off") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#poweroff') + return render(request, "instance.html", locals(),) - if 'powerforce' in request.POST: - conn.force_shutdown() - msg = _("Force Off") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#powerforce') - if 'delete' in request.POST: - if conn.get_status() == 1: - conn.force_shutdown() - if request.POST.get('delete_disk', ''): - conn.delete_disk() - conn.delete() +def status(request, pk): + instance = get_instance(request.user, pk) + return JsonResponse({"status": instance.proxy.get_status()}) - instance = Instance.objects.get(compute_id=compute_id, name=vname) - instance_name = instance.name - instance.delete() - if not request.user.is_superuser: - del_userinstance = UserInstance.objects.get(id=userinstace.id) - del_userinstance.delete() +def drbd_status(request, pk): + instance = get_instance(request.user, pk) + result = "None DRBD" + + if instance.compute.type == 2: + conn = instance.compute.login + "@" + instance.compute.hostname + remoteDrbdStatus = subprocess.run( + ["ssh", conn, "sudo", "/usr/sbin/drbdadm", "status", "&&", "exit"], + stdout=subprocess.PIPE, + text=True, + ) + + if remoteDrbdStatus.stdout: + try: + instanceFindDrbd = re.compile( + instance.name + "[_]*[A-Z]* role:(.+?)\n disk:(.+?)\n", + re.IGNORECASE, + ) + instanceDrbd = instanceFindDrbd.findall(remoteDrbdStatus.stdout) + + primaryCount = 0 + secondaryCount = 0 + statusDisk = "OK" + + for disk in instanceDrbd: + if disk[0] == "Primary": + primaryCount = primaryCount + 1 + elif disk[0] == "Secondary": + secondaryCount = secondaryCount + 1 + if disk[1] != "UpToDate": + statusDisk = "NOK" + + if primaryCount > 0 and secondaryCount > 0: + statusRole = "NOK" else: - try: - del_userinstance = UserInstance.objects.filter(instance__compute_id=compute_id, instance__name=vname) - del_userinstance.delete() - except UserInstance.DoesNotExist: - pass - - msg = _("Destroy") - addlogmsg(request.user.username, instance_name, msg) - - return HttpResponseRedirect(reverse('instances')) - - if 'rootpasswd' in request.POST: - passwd = request.POST.get('passwd', '') - passwd_hash = crypt.crypt(passwd, '$6$kgPoiREy') - data = {'action': 'password', 'passwd': passwd_hash, 'vname': vname} - - if conn.get_status() == 5: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect((compute.hostname, 16510)) - s.send(json.dumps(data)) - result = json.loads(s.recv(1024)) - s.close() - msg = _("Reset root password") - addlogmsg(request.user.username, instance.name, msg) - - if result['return'] == 'success': - messages.append(msg) + if primaryCount > secondaryCount: + statusRole = "Primary" else: - error_messages.append(msg) - else: - msg = _("Please shutdow down your instance and then try again") - error_messages.append(msg) + statusRole = "Secondary" - if 'addpublickey' in request.POST: - sshkeyid = request.POST.get('sshkeyid', '') - publickey = UserSSHKey.objects.get(id=sshkeyid) - data = {'action': 'publickey', 'key': publickey.keypublic, 'vname': vname} + result = statusRole + "/" + statusDisk - if conn.get_status() == 5: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect((compute.hostname, 16510)) - s.send(json.dumps(data)) - result = json.loads(s.recv(1024)) - s.close() - msg = _("Installed new ssh public key %s" % publickey.keyname) - addlogmsg(request.user.username, instance.name, msg) + except: + print("Error to get drbd role and status") - if result['return'] == 'success': - messages.append(msg) - else: - error_messages.append(msg) - else: - msg = _("Please shutdow down your instance and then try again") - error_messages.append(msg) - - if 'resize' in request.POST: - vcpu = request.POST.get('vcpu', '') - cur_vcpu = request.POST.get('cur_vcpu', '') - memory = request.POST.get('memory', '') - memory_custom = request.POST.get('memory_custom', '') - if memory_custom: - memory = memory_custom - cur_memory = request.POST.get('cur_memory', '') - cur_memory_custom = request.POST.get('cur_memory_custom', '') - if cur_memory_custom: - cur_memory = cur_memory_custom - conn.resize(cur_memory, memory, cur_vcpu, vcpu) - msg = _("Resize") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#resize') - - if 'umount_iso' in request.POST: - image = request.POST.get('path', '') - dev = request.POST.get('umount_iso', '') - conn.umount_iso(dev, image) - msg = _("Mount media") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#media') - - if 'mount_iso' in request.POST: - image = request.POST.get('media', '') - dev = request.POST.get('mount_iso', '') - conn.mount_iso(dev, image) - msg = _("Umount media") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#media') - - if 'snapshot' in request.POST: - name = request.POST.get('name', '') - conn.create_snapshot(name) - msg = _("New snapshot") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#restoresnapshot') - - if 'delete_snapshot' in request.POST: - snap_name = request.POST.get('name', '') - conn.snapshot_delete(snap_name) - msg = _("Delete snapshot") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#restoresnapshot') - - if 'revert_snapshot' in request.POST: - snap_name = request.POST.get('name', '') - conn.snapshot_revert(snap_name) - msg = _("Successful revert snapshot: ") - msg += snap_name - messages.append(msg) - msg = _("Revert snapshot") - addlogmsg(request.user.username, instance.name, msg) - - if request.user.is_superuser: - if 'suspend' in request.POST: - conn.suspend() - msg = _("Suspend") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#resume') - - if 'resume' in request.POST: - conn.resume() - msg = _("Resume") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#suspend') - - if 'set_autostart' in request.POST: - conn.set_autostart(1) - msg = _("Set autostart") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#autostart') - - if 'unset_autostart' in request.POST: - conn.set_autostart(0) - msg = _("Unset autostart") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#autostart') - - if 'change_xml' in request.POST: - exit_xml = request.POST.get('inst_xml', '') - if exit_xml: - conn._defineXML(exit_xml) - msg = _("Edit XML") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#xmledit') - - if 'set_console_passwd' in request.POST: - if request.POST.get('auto_pass', ''): - passwd = ''.join([choice(letters + digits) for i in xrange(12)]) - else: - passwd = request.POST.get('console_passwd', '') - clear = request.POST.get('clear_pass', False) - if clear: - passwd = '' - if not passwd and not clear: - msg = _("Enter the console password or select Generate") - error_messages.append(msg) - if not error_messages: - if not conn.set_console_passwd(passwd): - msg = _("Error setting console password. You should check that your instance have an graphic device.") - error_messages.append(msg) - else: - msg = _("Set VNC password") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#vncsettings') - - if 'set_console_keymap' in request.POST: - keymap = request.POST.get('console_keymap', '') - clear = request.POST.get('clear_keymap', False) - if clear: - conn.set_console_keymap('') - else: - conn.set_console_keymap(keymap) - msg = _("Set VNC keymap") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#vncsettings') - - if 'set_console_type' in request.POST: - console_type = request.POST.get('console_type', '') - conn.set_console_type(console_type) - msg = _("Set VNC type") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(request.get_full_path() + '#vncsettings') - - if 'migrate' in request.POST: - compute_id = request.POST.get('compute_id', '') - live = request.POST.get('live_migrate', False) - unsafe = request.POST.get('unsafe_migrate', False) - xml_del = request.POST.get('xml_delete', False) - new_compute = Compute.objects.get(id=compute_id) - conn_migrate = wvmInstances(new_compute.hostname, - new_compute.login, - new_compute.password, - new_compute.type) - conn_migrate.moveto(conn, vname, live, unsafe, xml_del) - conn_migrate.define_move(vname) - conn_migrate.close() - msg = _("Migrate") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(reverse('instance', args=[compute_id, vname])) - - if 'clone' in request.POST: - clone_data = {} - clone_data['name'] = request.POST.get('name', '') - - for post in request.POST: - if 'disk' or 'meta' in post: - clone_data[post] = request.POST.get(post, '') - - conn.clone_instance(clone_data) - msg = _("Clone") - addlogmsg(request.user.username, instance.name, msg) - return HttpResponseRedirect(reverse('instance', args=[compute_id, clone_data['name']])) - - conn.close() - - except libvirtError as lib_err: - error_messages.append(lib_err.message) - addlogmsg(request.user.username, vname, lib_err.message) - - return render(request, 'instance.html', locals()) + return result -def inst_status(request, compute_id, vname): - """ - :param request: - :return: - """ - - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('login')) - - compute = get_object_or_404(Compute, pk=compute_id) - response = HttpResponse() - response['Content-Type'] = "text/javascript" - - try: - conn = wvmInstance(compute.hostname, - compute.login, - compute.password, - compute.type, - vname) - data = json.dumps({'status': conn.get_status()}) - conn.close() - except libvirtError: - data = json.dumps({'error': 'Error 500'}) - response.write(data) - return response - - -def inst_graph(request, compute_id, vname): - """ - :param request: - :return: - """ - - if not request.user.is_authenticated(): - return HttpResponseRedirect(reverse('login')) - - datasets = {} +def stats(request, pk): + instance = get_instance(request.user, pk) json_blk = [] - datasets_blk = {} json_net = [] - datasets_net = {} - cookies = {} - points = 5 - curent_time = time.strftime("%H:%M:%S") - compute = get_object_or_404(Compute, pk=compute_id) - response = HttpResponse() - response['Content-Type'] = "text/javascript" - def check_points(dataset): - if len(dataset) > points: - dataset.pop(0) - return dataset + # TODO: stats are inaccurate + cpu_usage = instance.proxy.cpu_usage() + mem_usage = instance.proxy.mem_usage() + blk_usage = instance.proxy.disk_usage() + net_usage = instance.proxy.net_usage() + + current_time = time.strftime("%H:%M:%S") + for blk in blk_usage: + json_blk.append( + { + "dev": blk["dev"], + "data": [int(blk["rd"]) / 1048576, int(blk["wr"]) / 1048576], + } + ) + + for net in net_usage: + json_net.append( + { + "dev": net["dev"], + "data": [int(net["rx"]) / 1048576, int(net["tx"]) / 1048576], + } + ) + + return JsonResponse( + { + "cpudata": int(cpu_usage["cpu"]), + "memdata": mem_usage, + "blkdata": json_blk, + "netdata": json_net, + "timeline": current_time, + } + ) + + +def osinfo(request, pk): + instance = get_instance(request.user, pk) + results = instance.proxy.osinfo() + + return JsonResponse(results) + + +def guess_mac_address(request, vname): + data = {"vname": vname} + mac = utils.get_dhcp_mac_address(vname) + if not mac: + mac = utils.get_random_mac_address() + data["mac"] = mac + return HttpResponse(json.dumps(data)) + + +def random_mac_address(request): + data = dict() + data["mac"] = utils.get_random_mac_address() + return HttpResponse(json.dumps(data)) + + +def guess_clone_name(request): + dhcp_file = "/srv/webvirtcloud/dhcpd.conf" + prefix = app_settings.CLONE_INSTANCE_DEFAULT_PREFIX + if os.path.isfile(dhcp_file): + instance_names = [ + i.name for i in Instance.objects.filter(name__startswith=prefix) + ] + with open(dhcp_file, "r") as f: + for line in f: + line = line.strip() + if f"host {prefix}" in line: + fqdn = line.split(" ")[1] + hostname = fqdn.split(".")[0] + if hostname.startswith(prefix) and hostname not in instance_names: + return HttpResponse(json.dumps({"name": hostname})) + return HttpResponse(json.dumps({})) + + +def check_instance(request, vname): + instance = Instance.objects.filter(name=vname) + data = {"vname": vname, "exists": False} + if instance: + data["exists"] = True + return JsonResponse(data) + + +def sshkeys(request, pk): + """ + :param request: + :param vname: + :return: + """ + instance = get_instance(request.user, pk) + instance_keys = [] + userinstances = UserInstance.objects.filter(instance=instance) + + for ui in userinstances: + keys = UserSSHKey.objects.filter(user=ui.user) + for k in keys: + instance_keys.append(k.keypublic) + if request.GET.get("plain", ""): + response = "\n".join(instance_keys) + response += "\n" + else: + response = json.dumps(instance_keys) + return HttpResponse(response) + + +def get_instance(user, pk): + """ + Check that instance is available for user, if not raise 404 + """ + instance = get_object_or_404(Instance, pk=pk) + user_instances = user.userinstance_set.all().values_list("instance", flat=True) + + if ( + user.is_superuser + or user.has_perm("instances.view_instances") + or instance.id in user_instances + ): + return instance + else: + raise Http404() + + +def poweron(request, pk): + instance = get_instance(request.user, pk) + if instance.is_template: + messages.warning(request, _("Templates cannot be started.")) + else: + instance.proxy.start() + addlogmsg( + request.user.username, instance.compute.name, instance.name, _("Power On") + ) + + return redirect(request.META.get("HTTP_REFERER")) + + +def powercycle(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.force_shutdown() + instance.proxy.start() + addlogmsg( + request.user.username, instance.compute.name, instance.name, _("Power Cycle") + ) + return redirect(request.META.get("HTTP_REFERER")) + + +def poweroff(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.shutdown() + addlogmsg( + request.user.username, instance.compute.name, instance.name, _("Power Off") + ) + + return redirect(request.META.get("HTTP_REFERER")) + + +@superuser_only +def suspend(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.suspend() + addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend")) + return redirect(request.META.get("HTTP_REFERER")) + + +@superuser_only +def resume(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.resume() + addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume")) + return redirect(request.META.get("HTTP_REFERER")) + + +def force_off(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.force_shutdown() + addlogmsg( + request.user.username, instance.compute.name, instance.name, _("Force Off") + ) + return redirect(request.META.get("HTTP_REFERER")) + + +def destroy(request, pk): + instance = get_instance(request.user, pk) + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_delete=request.user.is_superuser) + + if request.method in ["POST", "DELETE"] and userinstance.is_delete: + if instance.proxy.get_status() == 1: + instance.proxy.force_shutdown() + + if request.POST.get("delete_disk", ""): + snapshots = sorted( + instance.proxy.get_snapshot(), reverse=True, key=lambda k: k["date"] + ) + for snapshot in snapshots: + instance.proxy.snapshot_delete(snapshot["name"]) + instance.proxy.delete_all_disks() + + if request.POST.get("delete_nvram", ""): + instance.proxy.delete(VIR_DOMAIN_UNDEFINE_NVRAM) + else: + instance.proxy.delete(VIR_DOMAIN_UNDEFINE_KEEP_NVRAM) + + instance.delete() + addlogmsg( + request.user.username, instance.compute.name, instance.name, _("Destroy") + ) + return redirect(reverse("instances:index")) + + return render( + request, + "instances/destroy_instance_form.html", + { + "instance": instance, + "userinstance": userinstance, + }, + ) + + +@superuser_only +def migrate(request, pk): + instance = get_instance(request.user, pk) + + compute_id = request.POST.get("compute_id", "") + live = request.POST.get("live_migrate", False) + unsafe = request.POST.get("unsafe_migrate", False) + xml_del = request.POST.get("xml_delete", False) + offline = request.POST.get("offline_migrate", False) + autoconverge = request.POST.get("autoconverge", False) + compress = request.POST.get("compress", False) + postcopy = request.POST.get("postcopy", False) + + current_host = instance.compute.hostname + target_host = Compute.objects.get(id=compute_id) try: - conn = wvmInstance(compute.hostname, - compute.login, - compute.password, - compute.type, - vname) - cpu_usage = conn.cpu_usage() - blk_usage = conn.disk_usage() - net_usage = conn.net_usage() - conn.close() + utils.migrate_instance( + target_host, + instance, + request.user, + live, + unsafe, + xml_del, + offline, + autoconverge, + compress, + postcopy, + ) + except libvirtError as err: + messages.error(request, err) + + migration_method = "live" if live is True else "offline" + msg = _("Instance is migrated(%(method)s) to %(hostname)s") % { + "hostname": target_host.hostname, + "method": migration_method, + } + addlogmsg(request.user.username, current_host, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER")) + + +def set_root_pass(request, pk): + instance = get_instance(request.user, pk) + + if request.method == "POST": + passwd = request.POST.get("passwd", None) + if passwd: + passwd_hash = crypt_r.crypt(passwd, "$6$kgPoiREy") + data = {"action": "password", "passwd": passwd_hash, "vname": instance.name} + + if instance.proxy.get_status() == 5: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect((instance.compute.hostname, 16510)) + s.send(bytes(json.dumps(data).encode())) + d = s.recv(1024).strip() + result = json.loads(d) + s.close() + if result["return"] == "success": + msg = _("Reset root password") + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + messages.success(request, msg) + else: + messages.error(request, result["message"]) + else: + msg = _("Please shutdown down your instance and then try again") + messages.error(request, msg) + return redirect(reverse("instances:instance", args=[instance.id]) + "#access") + + +def add_public_key(request, pk): + instance = get_instance(request.user, pk) + if request.method == "POST": + sshkeyid = request.POST.get("sshkeyid", "") + publickey = UserSSHKey.objects.get(id=sshkeyid) + data = { + "action": "publickey", + "key": publickey.keypublic, + "vname": instance.name, + } + + if instance.proxy.get_status() == 5: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect((instance.compute.hostname, 16510)) + s.send(json.dumps(data).encode()) + result = json.loads(s.recv(1024)) + s.close() + if result["return"] == "error": + msg = result["message"] + else: + msg = _("Installed new SSH public key %(keyname)s") % { + "keyname": publickey.keyname + } + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + if result["return"] == "success": + messages.success(request, msg) + else: + messages.error(request, msg) + else: + msg = _("Please shutdown down your instance and then try again") + messages.error(request, msg) + return redirect(reverse("instances:instance", args=[instance.id]) + "#access") + + +def resizevm_cpu(request, pk): + instance = get_instance(request.user, pk) + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_change=False) + vcpu = instance.proxy.get_vcpu() + if request.method == "POST": + if request.user.is_superuser or request.user.is_staff or userinstance.is_change: + new_vcpu = request.POST.get("vcpu", "") + new_cur_vcpu = request.POST.get("cur_vcpu", "") + + quota_msg = utils.check_user_quota( + request.user, 0, int(new_vcpu) - vcpu, 0, 0 + ) + if not request.user.is_superuser and quota_msg: + msg = _( + "User %(quota_msg)s quota reached, cannot resize CPU of '%(instance_name)s'!" + ) % { + "quota_msg": quota_msg, + "instance_name": instance.name, + } + messages.error(request, msg) + else: + cur_vcpu = new_cur_vcpu + vcpu = new_vcpu + instance.proxy.resize_cpu(cur_vcpu, vcpu) + msg = _("CPU is resized: %(old)s to %(new)s") % { + "old": cur_vcpu, + "new": vcpu, + } + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + messages.success(request, msg) + return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") + + +def resize_memory(request, pk): + instance = get_instance(request.user, pk) + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_change=False) + + memory = instance.proxy.get_memory() + cur_memory = instance.proxy.get_cur_memory() + + if request.method == "POST": + if request.user.is_superuser or request.user.is_staff or userinstance.is_change: + new_memory = request.POST.get("memory", "") + new_memory_custom = request.POST.get("memory_custom", "") + if new_memory_custom: + new_memory = new_memory_custom + new_cur_memory = request.POST.get("cur_memory", "") + new_cur_memory_custom = request.POST.get("cur_memory_custom", "") + if new_cur_memory_custom: + new_cur_memory = new_cur_memory_custom + quota_msg = utils.check_user_quota( + request.user, 0, 0, int(new_memory) - memory, 0 + ) + if not request.user.is_superuser and quota_msg: + msg = _( + "User %(quota_msg)s quota reached, cannot resize memory of '%(instance_name)s'!" + ) % { + "quota_msg": quota_msg, + "instance_name": instance.name, + } + messages.error(request, msg) + else: + instance.proxy.resize_mem(new_cur_memory, new_memory) + msg = _( + "Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s" + ) % { + "old_cur": cur_memory, + "old_max": memory, + "new_cur": new_cur_memory, + "new_max": new_memory, + } + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + messages.success(request, msg) + + return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") + + +def resize_disk(request, pk): + instance = get_instance(request.user, pk) + + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_change=False) + + disks = instance.proxy.get_disk_devices() + + if request.method == "POST": + if request.user.is_superuser or request.user.is_staff or userinstance.is_change: + disks_new = list() + for disk in disks: + input_disk_size = ( + int(request.POST.get("disk_size_" + disk["dev"], "0")) * 1073741824 + ) + if input_disk_size > disk["size"] + (64 << 20): + disk["size_new"] = input_disk_size + disks_new.append(disk) + disk_sum = sum([disk["size"] >> 30 for disk in disks_new]) + disk_new_sum = sum([disk["size_new"] >> 30 for disk in disks_new]) + quota_msg = utils.check_user_quota( + request.user, 0, 0, 0, disk_new_sum - disk_sum + ) + if not request.user.is_superuser and quota_msg: + msg = _( + "User %(quota_msg)s quota reached, cannot resize disks of '%(instance_name)s'!" + ) % { + "quota_msg": quota_msg, + "instance_name": instance.name, + } + messages.error(request, msg) + else: + instance.proxy.resize_disk(disks_new) + msg = _("Disk is resized: %(dev)s") % {"dev": disk["dev"]} + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + messages.success(request, msg) + + return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") + + +def add_new_vol(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template: + media = instance.proxy.get_media_devices() + disks = instance.proxy.get_disk_devices() + conn_create = wvmCreate( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + ) + storage = request.POST.get("storage", "") + name = request.POST.get("name", "") + format = request.POST.get("format", app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT) + size = request.POST.get("size", 0) + meta_prealloc = True if request.POST.get("meta_prealloc", False) else False + bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS) + cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE) + target_dev = utils.get_new_disk_dev(media, disks, bus) + + source = conn_create.create_volume( + storage, + name, + size, + format, + meta_prealloc, + int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID), + int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID), + ) + + conn_pool = wvmStorage( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + storage, + ) + + pool_type = conn_pool.get_type() + disk_type = conn_pool.get_volume_type(os.path.basename(source)) + + if pool_type == "rbd": + source_info = conn_pool.get_rbd_source() + else: # add more disk types to handle different pool and disk types + source_info = None + + instance.proxy.attach_disk( + target_dev, + source, + source_info=source_info, + pool_type=pool_type, + disk_type=disk_type, + target_bus=bus, + format_type=format, + cache_mode=cache, + ) + msg = _("Attach new disk: %(name)s (%(format)s)") % { + "name": name, + "format": format, + } + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def add_existing_vol(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template: + storage = request.POST.get("selected_storage", "") + name = request.POST.get("vols", "") + bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS) + cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE) + + media = instance.proxy.get_media_devices() + disks = instance.proxy.get_disk_devices() + + conn_create = wvmStorage( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + storage, + ) + + format_type = conn_create.get_volume_format_type(name) + disk_type = conn_create.get_volume_type(name) + pool_type = conn_create.get_type() + if pool_type == "rbd": + source_info = conn_create.get_rbd_source() + path = conn_create.get_source_name() + else: + source_info = None + path = conn_create.get_target_path() + + target_dev = utils.get_new_disk_dev(media, disks, bus) + source = f"{path}/{name}" + + instance.proxy.attach_disk( + target_dev, + source, + source_info=source_info, + pool_type=pool_type, + disk_type=disk_type, + target_bus=bus, + format_type=format_type, + cache_mode=cache, + ) + msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def edit_volume(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if "edit_volume" in request.POST and allow_admin_or_not_template: + target_dev = request.POST.get("dev", "") + + new_path = request.POST.get("vol_path", "") + shareable = bool(request.POST.get("vol_shareable", False)) + readonly = bool(request.POST.get("vol_readonly", False)) + disk_type = request.POST.get("vol_type", "") + bus = request.POST.get("vol_bus_old", "") + new_bus = request.POST.get("vol_bus", bus) + serial = request.POST.get("vol_serial", "") + format = request.POST.get("vol_format", "") + cache = request.POST.get( + "vol_cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE + ) + io = request.POST.get("vol_io_mode", app_settings.INSTANCE_VOLUME_DEFAULT_IO) + discard = request.POST.get( + "vol_discard_mode", app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD + ) + zeroes = request.POST.get( + "vol_detect_zeroes", app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES + ) + new_target_dev = utils.get_new_disk_dev(instance.media, instance.disks, new_bus) + + if new_bus != bus: + instance.proxy.detach_disk(target_dev) + instance.proxy.attach_disk( + new_target_dev, + new_path, + target_bus=new_bus, + driver_type=format, + cache_mode=cache, + readonly=readonly, + shareable=shareable, + serial=serial, + io_mode=io, + discard_mode=discard, + detect_zeroes_mode=zeroes, + ) + else: + instance.proxy.edit_disk( + target_dev, + new_path, + readonly, + shareable, + new_bus, + serial, + format, + cache, + io, + discard, + zeroes, + ) + + if not instance.proxy.get_status() == 5: + messages.success( + request, + _( + "Volume changes are applied. " + + "But it will be activated after shutdown" + ), + ) + else: + messages.success(request, _("Volume is changed successfully.")) + msg = _("Edit disk: %(target_dev)s") % {"target_dev": target_dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def delete_vol(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template: + storage = request.POST.get("storage", "") + conn_delete = wvmStorage( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + storage, + ) + dev = request.POST.get("dev", "") + path = request.POST.get("path", "") + name = request.POST.get("name", "") + + msg = _("Delete disk: %(dev)s") % {"dev": dev} + instance.proxy.detach_disk(dev) + conn_delete.del_volume(name) + + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def detach_vol(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template: + dev = request.POST.get("dev", "") + path = request.POST.get("path", "") + instance.proxy.detach_disk(dev) + msg = _("Detach disk: %(dev)s") % {"dev": dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def add_cdrom(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template: + bus = request.POST.get("bus", "ide" if instance.machine == "pc" else "sata") + target = utils.get_new_disk_dev(instance.media, instance.disks, bus) + instance.proxy.attach_disk( + target, + "", + disk_device="cdrom", + cache_mode="none", + target_bus=bus, + readonly=True, + ) + msg = _("Add CD-ROM: %(target)s") % {"target": target} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def detach_cdrom(request, pk, dev): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template: + # dev = request.POST.get('detach_cdrom', '') + instance.proxy.detach_disk(dev) + msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def unmount_iso(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template: + image = request.POST.get("path", "") + dev = request.POST.get("umount_iso", "") + instance.proxy.umount_iso(dev, image) + msg = _("Mount media: %(dev)s") % {"dev": dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def mount_iso(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template: + image = request.POST.get("media", "") + dev = request.POST.get("mount_iso", "") + instance.proxy.mount_iso(dev, image) + msg = _("Unmount media: %(dev)s") % {"dev": dev} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + + return redirect(request.META.get("HTTP_REFERER") + "#disks") + + +def snapshot(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + name = request.POST.get("name", "") + desc = request.POST.get("description", "") + instance.proxy.create_snapshot(name, desc) + msg = _("Create snapshot: %(snap)s") % {"snap": name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +def delete_snapshot(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + snap_name = request.POST.get("name", "") + instance.proxy.snapshot_delete(snap_name) + msg = _("Delete snapshot: %(snap)s") % {"snap": snap_name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +def revert_snapshot(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + snap_name = request.POST.get("name", "") + instance.proxy.snapshot_revert(snap_name) + msg = _("Successful revert snapshot: ") + msg += snap_name + messages.success(request, msg) + msg = _("Revert snapshot: %(snap)s") % {"snap": snap_name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +def create_external_snapshot(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + name = request.POST.get("name", "") + desc = request.POST.get("description", "") + instance.proxy.create_external_snapshot("s1." + name, instance, desc=desc) + msg = _("Create external snapshot: %(snap)s") % {"snap": name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +def get_external_snapshots(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + external_snapshots = instance.proxy.get_external_snapshots() + return external_snapshots + + +def revert_external_snapshot(request, pk): + instance = get_instance(request.user, pk) + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + instance_state = True if instance.proxy.get_status() != 5 else False + name = request.POST.get("name", "") + date = request.POST.get("date", "") + desc = request.POST.get("desc", "") + instance.proxy.force_shutdown() if instance_state else None + instance.proxy.revert_external_snapshot(name, date, desc) + instance.proxy.start() if instance_state else None + msg = _("Revert external snapshot: %(snap)s") % {"snap": name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +def delete_external_snapshot(request, pk): + instance = get_instance(request.user, pk) + instance_state = True if instance.proxy.get_status() == 5 else False + allow_admin_or_not_template = ( + request.user.is_superuser or request.user.is_staff or not instance.is_template + ) + + if allow_admin_or_not_template and request.user.has_perm( + "instances.snapshot_instances" + ): + name = request.POST.get("name", "") + + instance.proxy.start(VIR_DOMAIN_START_PAUSED) if instance_state else None try: - cookies['cpu'] = request.COOKIES['cpu'] - cookies['blk'] = request.COOKIES['blk'] - cookies['net'] = request.COOKIES['net'] - cookies['timer'] = request.COOKIES['timer'] - except KeyError: - cookies['cpu'] = None - cookies['blk'] = None - cookies['net'] = None + instance.proxy.delete_external_snapshot(name) + msg = _("Delete external snapshot: %(snap)s") % {"snap": name} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + finally: + instance.proxy.force_shutdown() if instance_state else None - if not cookies['cpu']: - datasets['cpu'] = [0] * points - datasets['timer'] = [0] * points + return redirect(request.META.get("HTTP_REFERER") + "#managesnapshot") + + +@superuser_only +def set_vcpu(request, pk): + instance = get_instance(request.user, pk) + id = request.POST.get("id", "") + enabled = request.POST.get("set_vcpu", "") + if enabled == "True": + instance.proxy.set_vcpu(id, 1) + else: + instance.proxy.set_vcpu(id, 0) + msg = _("VCPU %(id)s is enabled=%(enabled)s") % {"id": id, "enabled": enabled} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#resize") + + +@superuser_only +def set_vcpu_hotplug(request, pk): + instance = get_instance(request.user, pk) + status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False + msg = _("VCPU Hot-plug is enabled=%(status)s") % {"status": status} + instance.proxy.set_vcpu_hotplug(status) + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#resize") + + +@superuser_only +def set_autostart(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.set_autostart(1) + msg = _("Set autostart") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#boot_opt") + + +@superuser_only +def unset_autostart(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.set_autostart(0) + msg = _("Unset autostart") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#boot_opt") + + +@superuser_only +def set_bootmenu(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.set_bootmenu(1) + msg = _("Enable boot menu") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#boot_opt") + + +@superuser_only +def unset_bootmenu(request, pk): + instance = get_instance(request.user, pk) + instance.proxy.set_bootmenu(0) + msg = _("Disable boot menu") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#boot_opt") + + +@superuser_only +def set_bootorder(request, pk): + instance = get_instance(request.user, pk) + bootorder = request.POST.get("bootorder", "") + if bootorder: + order_list = {} + for idx, val in enumerate(bootorder.split(",")): + dev_type, dev = val.split(":", 1) + order_list[idx] = {"type": dev_type, "dev": dev} + instance.proxy.set_bootorder(order_list) + msg = _("Set boot order") + + if not instance.proxy.get_status() == 5: + messages.success( + request, + _( + "Boot menu changes applied. " + + "But it will be activated after shutdown" + ), + ) else: - datasets['cpu'] = eval(cookies['cpu']) - datasets['timer'] = eval(cookies['timer']) + messages.success(request, _("Boot order changed successfully.")) + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#boot_opt") - datasets['timer'].append(curent_time) - datasets['cpu'].append(int(cpu_usage['cpu'])) - datasets['timer'] = check_points(datasets['timer']) - datasets['cpu'] = check_points(datasets['cpu']) +@superuser_only +def change_xml(request, pk): + instance = get_instance(request.user, pk) + new_xml = request.POST.get("inst_xml", "") + if new_xml: + instance.proxy._defineXML(new_xml) + msg = _("Change instance XML") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#xmledit") - for blk in blk_usage: - if not cookies['blk']: - datasets_wr = [0] * points - datasets_rd = [0] * points - else: - datasets['blk'] = eval(cookies['blk']) - datasets_rd = datasets['blk'][blk['dev']][0] - datasets_wr = datasets['blk'][blk['dev']][1] - datasets_rd.append(int(blk['rd']) / 1048576) - datasets_wr.append(int(blk['wr']) / 1048576) +@superuser_only +def set_guest_agent(request, pk): + instance = get_instance(request.user, pk) + status = request.POST.get("guest_agent") + if status == "True": + instance.proxy.add_guest_agent() + if status == "False": + instance.proxy.remove_guest_agent() - datasets_rd = check_points(datasets_rd) - datasets_wr = check_points(datasets_wr) + msg = _("Set Guest Agent: %(status)s") % {"status": status} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#options") - json_blk.append({'dev': blk['dev'], 'data': [datasets_rd, datasets_wr]}) - datasets_blk[blk['dev']] = [datasets_rd, datasets_wr] - for net in net_usage: - if not cookies['net']: - datasets_rx = [0] * points - datasets_tx = [0] * points - else: - datasets['net'] = eval(cookies['net']) - datasets_rx = datasets['net'][net['dev']][0] - datasets_tx = datasets['net'][net['dev']][1] +@superuser_only +def set_video_model(request, pk): + instance = get_instance(request.user, pk) + video_model = request.POST.get("video_model", "vga") + instance.proxy.set_video_model(video_model) + msg = _("Set Video Model: %(model)s") % {"model": video_model} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#options") - datasets_rx.append(int(net['rx']) / 1048576) - datasets_tx.append(int(net['tx']) / 1048576) - datasets_rx = check_points(datasets_rx) - datasets_tx = check_points(datasets_tx) +@superuser_only +def change_network(request, pk): + instance = get_instance(request.user, pk) - json_net.append({'dev': net['dev'], 'data': [datasets_rx, datasets_tx]}) - datasets_net[net['dev']] = [datasets_rx, datasets_tx] + msg = _("Change network") + network_data = {} - data = json.dumps({'cpudata': datasets['cpu'], 'blkdata': json_blk, - 'netdata': json_net, 'timeline': datasets['timer']}) + for post in request.POST: + if post.startswith("net-source-"): + (source, source_type) = utils.get_network_tuple(request.POST.get(post)) + network_data[post] = source + network_data[post + "-type"] = source_type - response.cookies['cpu'] = datasets['cpu'] - response.cookies['timer'] = datasets['timer'] - response.cookies['blk'] = datasets_blk - response.cookies['net'] = datasets_net - except libvirtError: - data = json.dumps({'error': 'Error 500'}) + if source_type == "iface": + iface = wvmInterface( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + source, + ) + network_data[post + "-type"] = iface.get_type() + elif post.startswith("net-"): + network_data[post] = request.POST.get(post, "") - response.write(data) + instance.proxy.change_network(network_data) + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + msg = _("Network Device Config is changed. Please shutdown instance to activate.") + if instance.proxy.get_status() != 5: + messages.success(request, msg) + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def add_network(request, pk): + instance = get_instance(request.user, pk) + + mac = request.POST.get("add-net-mac") + nwfilter = request.POST.get("add-net-nwfilter") + (source, source_type) = utils.get_network_tuple(request.POST.get("add-net-network")) + model = request.POST.get("add-net-model") + + if source_type == "iface": + iface = wvmInterface( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + source, + ) + source_type = iface.get_type() + + instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter) + msg = _("Add network: %(mac)s") % {"mac": mac} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def delete_network(request, pk): + instance = get_instance(request.user, pk) + mac_address = request.POST.get("delete_network", "") + + instance.proxy.delete_network(mac_address) + msg = _("Delete Network: %(mac)s") % {"mac": mac_address} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def set_link_state(request, pk): + instance = get_instance(request.user, pk) + + mac_address = request.POST.get("mac", "") + state = request.POST.get("set_link_state") + state = "down" if state == "up" else "up" + instance.proxy.set_link_state(mac_address, state) + msg = _("Set Link State: %(state)s") % {"state": state} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def set_qos(request, pk): + instance = get_instance(request.user, pk) + + qos_dir = request.POST.get("qos_direction", "") + average = request.POST.get("qos_average") or 0 + peak = request.POST.get("qos_peak") or 0 + burst = request.POST.get("qos_burst") or 0 + keys = request.POST.keys() + mac_key = [key for key in keys if "mac" in key] + if mac_key: + mac = request.POST.get(mac_key[0]) + + instance.proxy.set_qos(mac, qos_dir, average, peak, burst) + if instance.proxy.get_status() == 5: + messages.success( + request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()} + ) + else: + messages.success( + request, + _( + "%(qos_dir)s QoS is set. Network XML is changed. \ + Stop and start network to activate new config." + ) + % {"qos_dir": qos_dir.capitalize()}, + ) + + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def unset_qos(request, pk): + instance = get_instance(request.user, pk) + qos_dir = request.POST.get("qos_direction", "") + mac = request.POST.get("net-mac") + instance.proxy.unset_qos(mac, qos_dir) + + if instance.proxy.get_status() == 5: + messages.success( + request, _("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()} + ) + else: + messages.success( + request, + _( + "%(qos_dir)s QoS is deleted. Network XML is changed. \ + Stop and start network to activate new config." + ) + % {"qos_dir": qos_dir.capitalize()}, + ) + return redirect(request.META.get("HTTP_REFERER") + "#network") + + +@superuser_only +def add_owner(request, pk): + instance = get_instance(request.user, pk) + user_id = request.POST.get("user_id") + + check_inst = 0 + + if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False": + check_inst = UserInstance.objects.filter(instance=instance).count() + + if check_inst > 0: + messages.error( + request, _("Only one owner is allowed and the one already added") + ) + else: + add_user_inst = UserInstance(instance=instance, user_id=user_id) + add_user_inst.save() + user = User.objects.get(id=user_id) + msg = _("Add owner: %(user)s") % {"user": user} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#users") + + +@superuser_only +def del_owner(request, pk): + instance = get_instance(request.user, pk) + userinstance_id = int(request.POST.get("userinstance", "")) + userinstance = UserInstance.objects.get(pk=userinstance_id) + userinstance.delete() + msg = _("Delete owner: %(userinstance_id)s ") % {"userinstance_id": userinstance_id} + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#users") + + +@permission_required("instances.clone_instances", raise_exception=True) +def clone(request, pk): + instance = get_instance(request.user, pk) + + clone_data = dict() + clone_data["name"] = request.POST.get("name", "") + + disk_sum = sum([disk["size"] >> 30 for disk in instance.disks]) + quota_msg = utils.check_user_quota( + request.user, 1, instance.vcpu, instance.memory, disk_sum + ) + check_instance = Instance.objects.filter(name=clone_data["name"]) + + clone_data["disk_owner_uid"] = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID) + clone_data["disk_owner_gid"] = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID) + + for post in request.POST: + clone_data[post] = request.POST.get(post, "").strip() + + if app_settings.CLONE_INSTANCE_AUTO_NAME == "True" and not clone_data["name"]: + auto_vname = utils.get_clone_free_names()[0] + clone_data["name"] = auto_vname + clone_data["clone-net-mac-0"] = utils.get_dhcp_mac_address(auto_vname) + for disk in instance.disks: + disk_dev = f"disk-{disk['dev']}" + disk_name = utils.get_clone_disk_name(disk, instance.name, auto_vname) + clone_data[disk_dev] = disk_name + + if not request.user.is_superuser and quota_msg: + msg = _( + "User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!" + ) % { + "quota_msg": quota_msg, + "clone_name": clone_data["name"], + } + messages.error(request, msg) + elif check_instance: + msg = _("Instance '%(clone_name)s' already exists!") % { + "clone_name": clone_data["name"] + } + messages.error(request, msg) + elif not re.match(r"^[a-zA-Z0-9-]+$", clone_data["name"]): + msg = _("Instance name '%(clone_name)s' contains invalid characters!") % { + "clone_name": clone_data["name"] + } + messages.error(request, msg) + elif not re.match( + r"^([0-9A-F]{2})(:?[0-9A-F]{2}){5}$", + clone_data["clone-net-mac-0"], + re.IGNORECASE, + ): + msg = _("Instance MAC '%(clone_mac)s' invalid format!") % { + "clone_mac": clone_data["clone-net-mac-0"] + } + messages.error(request, msg) + else: + new_instance = Instance(compute=instance.compute, name=clone_data["name"]) + try: + new_uuid = instance.proxy.clone_instance(clone_data) + new_instance.uuid = new_uuid + new_instance.save() + user_instance = UserInstance( + instance_id=new_instance.id, user_id=request.user.id, is_delete=True + ) + user_instance.save() + msg = _("Create a clone of '%(instance_name)s'") % { + "instance_name": instance.name + } + messages.success(request, msg) + addlogmsg( + request.user.username, instance.compute.name, new_instance.name, msg + ) + + if app_settings.CLONE_INSTANCE_AUTO_MIGRATE == "True": + new_compute = Compute.objects.order_by("?").first() + utils.migrate_instance( + new_compute, new_instance, request.user, xml_del=True, offline=True + ) + + return redirect(reverse("instances:instance", args=[new_instance.id])) + except Exception as e: + messages.error(request, e) + + return redirect(request.META.get("HTTP_REFERER") + "#clone") + + +def update_console(request, pk): + instance = get_instance(request.user, pk) + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_vnc=False) + + if request.user.is_superuser or request.user.is_staff or userinstance.is_vnc: + form = ConsoleForm(request.POST or None) + if form.is_valid(): + if ( + "generate_password" in form.changed_data + or "clear_password" in form.changed_data + or "password" in form.changed_data + ): + if form.cleaned_data["generate_password"]: + password = randomPasswd() + elif form.cleaned_data["clear_password"]: + password = "" + else: + password = form.cleaned_data["password"] + + if not instance.proxy.set_console_passwd(password): + msg = _( + "Error setting console password. " + + "You should check that your instance have an graphic device." + ) + messages.error(request, msg) + else: + msg = _("Set VNC password") + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + + if "keymap" in form.changed_data or "clear_keymap" in form.changed_data: + if form.cleaned_data["clear_keymap"]: + instance.proxy.set_console_keymap("") + else: + instance.proxy.set_console_keymap(form.cleaned_data["keymap"]) + + msg = _("Set VNC keymap") + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + + if "type" in form.changed_data: + instance.proxy.set_console_type(form.cleaned_data["type"]) + msg = _("Set VNC type") + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + + if "listen_on" in form.changed_data: + instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"]) + msg = _("Set VNC listen address") + addlogmsg( + request.user.username, instance.compute.name, instance.name, msg + ) + + return redirect(request.META.get("HTTP_REFERER") + "#vncsettings") + + +def change_options(request, pk): + instance = get_instance(request.user, pk) + try: + userinstance = instance.userinstance_set.get(user=request.user) + except Exception: + userinstance = UserInstance(is_change=False) + + if request.user.is_superuser or request.user.is_staff or userinstance.is_change: + instance.is_template = request.POST.get("is_template", False) + instance.save() + + options = {} + for post in request.POST: + if post in ["title", "description"]: + options[post] = request.POST.get(post, "") + instance.proxy.set_options(options) + + msg = _("Edit options") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + return redirect(request.META.get("HTTP_REFERER") + "#options") + + +def getvvfile(request, pk): + instance = get_instance(request.user, pk) + conn = wvmInstances( + instance.compute.hostname, + instance.compute.login, + instance.compute.password, + instance.compute.type, + ) + + msg = _("Send console.vv file") + addlogmsg(request.user.username, instance.compute.name, instance.name, msg) + response = HttpResponse( + content="", + content_type="application/x-virt-viewer", + status=200, + reason=None, + charset="utf-8", + ) + response.writelines("[virt-viewer]\n") + response.writelines("type=" + conn.graphics_type(instance.name) + "\n") + if conn.graphics_listen(instance.name) == "0.0.0.0": + response.writelines("host=" + conn.host + "\n") + else: + response.writelines("host=" + conn.graphics_listen(instance.name) + "\n") + response.writelines("port=" + conn.graphics_port(instance.name) + "\n") + response.writelines("title=" + conn.domain_name(instance.name) + "\n") + response.writelines("password=" + conn.graphics_passwd(instance.name) + "\n") + response.writelines("enable-usbredir=1\n") + response.writelines("disable-effects=all\n") + response.writelines("secure-attention=ctrl+alt+ins\n") + response.writelines("release-cursor=ctrl+alt\n") + response.writelines("fullscreen=1\n") + response.writelines("delete-this-file=1\n") + response["Content-Disposition"] = 'attachment; filename="console.vv"' return response + + +@superuser_only +def create_instance_select_type(request, compute_id): + """ + :param request: + :param compute_id: + :return: + """ + + conn = None + storages = list() + networks = list() + hypervisors = list() + meta_prealloc = False + compute = get_object_or_404(Compute, pk=compute_id) + + conn = wvmCreate(compute.hostname, compute.login, compute.password, compute.type) + instances = conn.get_instances() + all_hypervisors = conn.get_hypervisors_machines() + + # Supported hypervisors by webvirtcloud: i686, x86_64(for now) + supported_arch = [ + "x86_64", + "i686", + "aarch64", + "armv7l", + "ppc64", + "ppc64le", + "s390x", + ] + hypervisors = [hpv for hpv in all_hypervisors.keys() if hpv in supported_arch] + default_machine = app_settings.INSTANCE_MACHINE_DEFAULT_TYPE + default_arch = app_settings.INSTANCE_ARCH_DEFAULT_TYPE + + if request.method == "POST": + if "create_xml" in request.POST: + xml = request.POST.get("dom_xml", "") + try: + name = util.get_xml_path(xml, "/domain/name") + except util.etree.Error: + name = None + if name in instances: + error_msg = _("A virtual machine with this name already exists") + messages.error(request, error_msg) + else: + conn._defineXML(xml) + utils.refr(compute) + instance = compute.instance_set.get(name=name) + return redirect(reverse("instances:instance", args=[instance.id])) + + return render(request, "create_instance_w1.html", locals()) + + +@superuser_only +def create_instance(request, compute_id, arch, machine): + """ + :param request: + :param compute_id: + :param arch: + :param machine: + :return: + """ + + conn = None + storages = list() + networks = list() + hypervisors = list() + firmwares = list() + meta_prealloc = False + compute = get_object_or_404(Compute, pk=compute_id) + flavors = Flavor.objects.filter().order_by("id") + appsettings = AppSettings.objects.all() + + try: + conn = wvmCreate( + compute.hostname, + compute.login, + compute.password, + compute.type + ) + + default_firmware = app_settings.INSTANCE_FIRMWARE_DEFAULT_TYPE + default_cpu_mode = app_settings.INSTANCE_CPU_DEFAULT_MODE + instances = conn.get_instances() + videos = conn.get_video_models(arch, machine) + default_video = app_settings.INSTANCE_VIDEO_DEFAULT_TYPE + cache_modes = sorted(conn.get_cache_modes().items()) + default_cache = app_settings.INSTANCE_VOLUME_DEFAULT_CACHE + default_io = app_settings.INSTANCE_VOLUME_DEFAULT_IO + default_zeroes = app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES + default_discard = app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD + default_disk_format = app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT + default_disk_owner_uid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID) + default_disk_owner_gid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID) + default_scsi_disk_model = app_settings.INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER + listener_addr = settings.QEMU_CONSOLE_LISTENER_ADDRESSES + mac_auto = util.randomMAC() + disk_devices = conn.get_disk_device_types(arch, machine) + disk_buses = conn.get_disk_bus_types(arch, machine) + default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS + networks = sorted(conn.get_networks()) + nwfilters = conn.get_nwfilters() + net_models_host = conn.get_network_models() + default_nic_type = app_settings.INSTANCE_NIC_DEFAULT_TYPE + storages = sorted(conn.get_storages(only_actives=True)) + default_graphics = app_settings.QEMU_CONSOLE_DEFAULT_TYPE + default_cdrom = app_settings.INSTANCE_CDROM_ADD + input_device_buses = ["default", "virtio", "usb"] + default_input_device_bus = app_settings.INSTANCE_INPUT_DEFAULT_DEVICE + + dom_caps = conn.get_dom_capabilities(arch, machine) + caps = conn.get_capabilities(arch) + + virtio_support = conn.is_supports_virtio(arch, machine) + hv_supports_uefi = conn.supports_uefi_xml(dom_caps["loader_enums"]) + # Add BIOS + label = conn.label_for_firmware_path(arch, None) + if label: + firmwares.append(label) + # Add UEFI + loader_path = conn.find_uefi_path_for_arch(arch, dom_caps["loaders"]) + label = conn.label_for_firmware_path(arch, loader_path) + if label: + firmwares.append(label) + firmwares = list(set(firmwares)) + + flavor_form = FlavorForm() + + if conn: + if not storages: + raise libvirtError(_("You haven't defined any storage pools")) + if not networks: + raise libvirtError(_("You haven't defined any network pools")) + + if request.method == "POST": + if "create" in request.POST: + firmware = dict() + volume_list = list() + is_disk_created = False + clone_path = "" + form = NewVMForm(request.POST) + if form.is_valid(): + data = form.cleaned_data + if data["meta_prealloc"]: + meta_prealloc = True + if instances: + if data["name"] in instances: + raise libvirtError( + _("A virtual machine with this name already exists") + ) + if Instance.objects.filter(name__exact=data["name"]): + raise libvirtError( + _( + "There is an instance with same name. Remove it and try again!" + ) + ) + + if data["hdd_size"]: + if not data["mac"]: + raise libvirtError( + _("No Virtual Machine MAC has been entered") + ) + else: + path = conn.create_volume( + data["storage"], + data["name"], + data["hdd_size"], + default_disk_format, + meta_prealloc, + default_disk_owner_uid, + default_disk_owner_gid, + ) + volume = dict() + volume["device"] = "disk" + volume["path"] = path + volume["type"] = conn.get_volume_format_type(path) + volume["cache_mode"] = data["cache_mode"] + volume["bus"] = default_bus + if volume["bus"] == "scsi": + volume["scsi_model"] = default_scsi_disk_model + volume["discard_mode"] = default_discard + volume["detect_zeroes_mode"] = default_zeroes + volume["io_mode"] = default_io + + volume_list.append(volume) + is_disk_created = True + + elif data["template"]: + templ_path = conn.get_volume_path(data["template"]) + dest_vol = conn.get_volume_path( + data["name"] + ".img", data["storage"] + ) + if dest_vol: + raise libvirtError( + _( + "Image has already exist. Please check volumes or change instance name" + ) + ) + else: + clone_path = conn.clone_from_template( + data["name"], + templ_path, + data["storage"], + meta_prealloc, + default_disk_owner_uid, + default_disk_owner_gid, + ) + volume = dict() + volume["path"] = clone_path + volume["type"] = conn.get_volume_format_type(clone_path) + volume["device"] = "disk" + volume["cache_mode"] = data["cache_mode"] + volume["bus"] = default_bus + if volume["bus"] == "scsi": + volume["scsi_model"] = default_scsi_disk_model + volume["discard_mode"] = default_discard + volume["detect_zeroes_mode"] = default_zeroes + volume["io_mode"] = default_io + + volume_list.append(volume) + is_disk_created = True + else: + if not data["images"]: + raise libvirtError( + _("First you need to create or select an image") + ) + else: + for idx, vol in enumerate(data["images"].split(",")): + path = conn.get_volume_path(vol) + volume = dict() + volume["path"] = path + volume["type"] = conn.get_volume_format_type(path) + volume["device"] = request.POST.get( + "device" + str(idx), "" + ) + volume["bus"] = request.POST.get( + "bus" + str(idx), "" + ) + if volume["bus"] == "scsi": + volume["scsi_model"] = default_scsi_disk_model + volume["cache_mode"] = data["cache_mode"] + volume["discard_mode"] = default_discard + volume["detect_zeroes_mode"] = default_zeroes + volume["io_mode"] = default_io + + volume_list.append(volume) + if data["cache_mode"] not in conn.get_cache_modes(): + error_msg = _("Invalid cache mode") + raise libvirtError + + if "UEFI" in data["firmware"]: + firmware["loader"] = data["firmware"].split(":")[1].strip() + firmware["secure"] = "no" + firmware["readonly"] = "yes" + firmware["type"] = "pflash" + if "secboot" in firmware["loader"] and machine != "q35": + messages.warning( + request, + "Changing machine type from '%s' to 'q35' " + "which is required for UEFI secure boot." % machine, + ) + machine = "q35" + firmware["secure"] = "yes" + + if data["net_model"] == "default": + data["net_model"] = "virtio" + + uuid = util.randomUUID() + try: + conn.create_instance( + name=data["name"], + memory=data["memory"], + vcpu=data["vcpu"], + vcpu_mode=data["vcpu_mode"], + uuid=uuid, + arch=arch, + machine=machine, + firmware=firmware, + volumes=volume_list, + networks=data["networks"], + virtio=data["virtio"], + listener_addr=data["listener_addr"], + nwfilter=data["nwfilter"], + net_model=data["net_model"], + graphics=data["graphics"], + video=data["video"], + console_pass=data["console_pass"], + mac=data["mac"], + qemu_ga=data["qemu_ga"], + add_cdrom=data["add_cdrom"], + add_input=data["add_input"], + ) + create_instance = Instance( + compute_id=compute_id, name=data["name"], uuid=uuid + ) + create_instance.save() + msg = _("Instance is created") + messages.success(request, msg) + addlogmsg( + request.user.username, + create_instance.compute.name, + create_instance.name, + msg, + ) + return redirect( + reverse("instances:instance", args=[create_instance.id]) + ) + except libvirtError as lib_err: + if data["hdd_size"] or len(volume_list) > 0: + if is_disk_created: + for vol in volume_list: + conn.delete_volume(vol["path"]) + messages.error(request, lib_err) + conn.close() + except libvirtError as lib_err: + messages.error(request, lib_err) + + return render(request, "create_instance_w2.html", locals()) + + +@superuser_only +def flavor_create(request): + form = FlavorForm(request.POST or None) + if form.is_valid(): + form.save() + messages.success(request, _("Flavor Created")) + return redirect(request.META.get("HTTP_REFERER")) + + return render( + request, + "common/form.html", + {"form": form, "title": _("Create Flavor")}, + ) + + +@superuser_only +def flavor_update(request, pk): + flavor = get_object_or_404(Flavor, pk=pk) + form = FlavorForm(request.POST or None, instance=flavor) + if form.is_valid(): + form.save() + messages.success(request, _("Flavor Updated")) + return redirect(request.META.get("HTTP_REFERER")) + + return render( + request, + "common/form.html", + {"form": form, "title": _("Update Flavor")}, + ) + + +@superuser_only +def flavor_delete(request, pk): + flavor = get_object_or_404(Flavor, pk=pk) + if request.method == "POST": + flavor.delete() + messages.success(request, _("Flavor Deleted")) + return redirect(request.META.get("HTTP_REFERER")) + + return render( + request, + "common/confirm_delete.html", + {"object": flavor}, + ) diff --git a/interfaces/admin.py b/interfaces/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/interfaces/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/interfaces/api/serializers.py b/interfaces/api/serializers.py new file mode 100644 index 0000000..e9a7bea --- /dev/null +++ b/interfaces/api/serializers.py @@ -0,0 +1,11 @@ + +from rest_framework import serializers +from interfaces.models import Interfaces + + +class InterfacesSerializer(serializers.ModelSerializer): + + class Meta: + model = Interfaces + fields = ['name', 'type', 'state', 'mac'] + diff --git a/interfaces/api/viewsets.py b/interfaces/api/viewsets.py new file mode 100644 index 0000000..1b7a6e7 --- /dev/null +++ b/interfaces/api/viewsets.py @@ -0,0 +1,32 @@ +from django.shortcuts import get_object_or_404 +from computes.models import Compute +from rest_framework import status, viewsets + + +from vrtManager.interface import wvmInterfaces, wvmInterface + +from .serializers import InterfacesSerializer +from rest_framework.response import Response + + + +class InterfaceViewSet(viewsets.ViewSet): + """ + A viewset for listing retrieving interfaces. + """ + + def list(self, request, compute_pk=None): + queryset = [] + compute = get_object_or_404(Compute, pk=compute_pk) + + conn = wvmInterfaces(compute.hostname, compute.login, compute.password, compute.type) + ifaces = conn.get_ifaces() + + for iface in ifaces: + interf = wvmInterface(compute.hostname, compute.login, compute.password, compute.type, iface) + queryset.append(interf.get_details()) + + serializer = InterfacesSerializer(queryset, many=True, context={'request': request}) + + return Response(serializer.data) + diff --git a/interfaces/forms.py b/interfaces/forms.py index e7c22bf..7ef2b2d 100644 --- a/interfaces/forms.py +++ b/interfaces/forms.py @@ -1,73 +1,108 @@ import re + from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class AddInterface(forms.Form): name = forms.CharField(max_length=10, required=True) - itype = forms.ChoiceField(required=True, choices=(('bridge', 'bridge'), ('ethernet', 'ethernet'))) - start_mode = forms.ChoiceField(required=True, - choices=(('none', 'none'), ('onboot', 'onboot'), ('hotplug', 'hotplug'))) + itype = forms.ChoiceField( + required=True, choices=(("bridge", "bridge"), ("ethernet", "ethernet")) + ) + start_mode = forms.ChoiceField( + required=True, + choices=(("none", "none"), ("onboot", "onboot"), ("hotplug", "hotplug")), + ) netdev = forms.CharField(max_length=15, required=True) - ipv4_type = forms.ChoiceField(required=True, choices=(('dhcp', 'dhcp'), ('static', 'static'), ('none', 'none'))) + ipv4_type = forms.ChoiceField( + required=True, + choices=(("dhcp", "dhcp"), ("static", "static"), ("none", "none")), + ) ipv4_addr = forms.CharField(max_length=18, required=False) ipv4_gw = forms.CharField(max_length=15, required=False) - ipv6_type = forms.ChoiceField(required=True, choices=(('dhcp', 'dhcp'), ('static', 'static'), ('none', 'none'))) + ipv6_type = forms.ChoiceField( + required=True, + choices=(("dhcp", "dhcp"), ("static", "static"), ("none", "none")), + ) ipv6_addr = forms.CharField(max_length=100, required=False) ipv6_gw = forms.CharField(max_length=100, required=False) - stp = forms.ChoiceField(required=False, choices=(('on', 'on'), ('off', 'off'))) + stp = forms.ChoiceField(required=False, choices=(("on", "on"), ("off", "off"))) delay = forms.IntegerField(required=False) def clean_ipv4_addr(self): - ipv4_addr = self.cleaned_data['ipv4_addr'] - have_symbol = re.match('^[0-9./]+$', ipv4_addr) + ipv4_addr = self.cleaned_data["ipv4_addr"] + have_symbol = re.match("^[0-9./]+$", ipv4_addr) if not have_symbol: - raise forms.ValidationError(_('The ipv4 must not contain any special characters')) + raise forms.ValidationError( + _("The IPv4 address must not contain any special characters") + ) elif len(ipv4_addr) > 20: - raise forms.ValidationError(_('The ipv4 must not exceed 20 characters')) + raise forms.ValidationError( + _("The IPv4 address must not exceed 20 characters") + ) return ipv4_addr def clean_ipv4_gw(self): - ipv4_gw = self.cleaned_data['ipv4_gw'] - have_symbol = re.match('^[0-9.]+$', ipv4_gw) + ipv4_gw = self.cleaned_data["ipv4_gw"] + have_symbol = re.match("^[0-9.]+$", ipv4_gw) if not have_symbol: - raise forms.ValidationError(_('The ipv4 gateway must not contain any special characters')) + raise forms.ValidationError( + _("The IPv4 gateway must not contain any special characters") + ) elif len(ipv4_gw) > 20: - raise forms.ValidationError(_('The ipv4 gateway must not exceed 20 characters')) + raise forms.ValidationError( + _("The IPv4 gateway must not exceed 20 characters") + ) return ipv4_gw def clean_ipv6_addr(self): - ipv6_addr = self.cleaned_data['ipv6_addr'] - have_symbol = re.match('^[0-9a-f./:]+$', ipv6_addr) + ipv6_addr = self.cleaned_data["ipv6_addr"] + have_symbol = re.match("^[0-9a-f./:]+|^$", ipv6_addr) if not have_symbol: - raise forms.ValidationError(_('The ipv6 must not contain any special characters')) + raise forms.ValidationError( + _("The IPv6 address must not contain any special characters") + ) elif len(ipv6_addr) > 100: - raise forms.ValidationError(_('The ipv6 must not exceed 100 characters')) + raise forms.ValidationError( + _("The IPv6 address must not exceed 100 characters") + ) return ipv6_addr def clean_ipv6_gw(self): - ipv6_gw = self.cleaned_data['ipv6_gw'] - have_symbol = re.match('^[0-9.]+$', ipv6_gw) + ipv6_gw = self.cleaned_data["ipv6_gw"] + have_symbol = re.match("^[0-9a-f./:]+|^$", ipv6_gw) if not have_symbol: - raise forms.ValidationError(_('The ipv6 gateway must not contain any special characters')) + raise forms.ValidationError( + _("The IPv6 gateway must not contain any special characters") + ) elif len(ipv6_gw) > 100: - raise forms.ValidationError(_('The ipv6 gateway must not exceed 100 characters')) + raise forms.ValidationError( + _("The IPv6 gateway must not exceed 100 characters") + ) return ipv6_gw def clean_name(self): - name = self.cleaned_data['name'] - have_symbol = re.match('^[a-z0-9.]+$', name) + name = self.cleaned_data["name"] + have_symbol = re.match("^[a-z0-9.]+$", name) if not have_symbol: - raise forms.ValidationError(_('The interface must not contain any special characters')) + raise forms.ValidationError( + _("The interface must not contain any special characters") + ) elif len(name) > 10: - raise forms.ValidationError(_('The interface must not exceed 10 characters')) + raise forms.ValidationError( + _("The interface must not exceed 10 characters") + ) return name def clean_netdev(self): - netdev = self.cleaned_data['netdev'] - have_symbol = re.match('^[a-z0-9.:]+$', netdev) + netdev = self.cleaned_data["netdev"] + have_symbol = re.match("^[a-z0-9.:]+$", netdev) if not have_symbol: - raise forms.ValidationError(_('The interface must not contain any special characters')) + raise forms.ValidationError( + _("The interface must not contain any special characters") + ) elif len(netdev) > 10: - raise forms.ValidationError(_('The interface must not exceed 10 characters')) + raise forms.ValidationError( + _("The interface must not exceed 10 characters") + ) return netdev diff --git a/interfaces/migrations/0001_initial.py b/interfaces/migrations/0001_initial.py new file mode 100644 index 0000000..833947d --- /dev/null +++ b/interfaces/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.15 on 2022-08-23 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Interfaces', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(error_messages={'required': 'No interface name has been entered'}, max_length=20, verbose_name='name')), + ('type', models.CharField(max_length=12, verbose_name='status')), + ('state', models.CharField(max_length=100, verbose_name='device')), + ('mac', models.CharField(max_length=24, verbose_name='forward')), + ], + options={ + 'managed': False, + }, + ), + ] diff --git a/interfaces/models.py b/interfaces/models.py index 71a8362..ff7d7eb 100644 --- a/interfaces/models.py +++ b/interfaces/models.py @@ -1,3 +1,17 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ + # Create your models here. +class Interfaces(models.Model): + name = models.CharField( + _("name"), + max_length=20, + error_messages={"required": _("No interface name has been entered")}, + ) + type = models.CharField(_("status"), max_length=12) + state = models.CharField(_("device"), max_length=100) + mac = models.CharField(_("forward"), max_length=24) + + class Meta: + managed = False diff --git a/interfaces/templates/create_iface_block.html b/interfaces/templates/create_iface_block.html index 2700481..dbd1555 100644 --- a/interfaces/templates/create_iface_block.html +++ b/interfaces/templates/create_iface_block.html @@ -1,7 +1,8 @@ {% load i18n %} +{% load bootstrap_icons %} {% if request.user.is_superuser %} - - + + @@ -9,119 +10,125 @@