2022-06-24 15:05:32 +00:00
|
|
|
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
|
|
|
#
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU Affero General Public License as
|
|
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
|
|
# License, or (at your option) any later version.
|
|
|
|
#
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU Affero General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2022-07-08 12:26:59 +00:00
|
|
|
import logging
|
2022-06-24 15:05:32 +00:00
|
|
|
import os
|
2022-07-08 12:26:59 +00:00
|
|
|
import sh
|
2022-06-24 15:05:32 +00:00
|
|
|
import shutil
|
2022-06-28 18:24:14 +00:00
|
|
|
import yaml
|
2022-06-29 19:40:55 +00:00
|
|
|
import requests
|
2022-06-24 15:05:32 +00:00
|
|
|
from pathlib import Path
|
2022-07-08 12:26:59 +00:00
|
|
|
from threading import Thread
|
2022-06-29 19:40:55 +00:00
|
|
|
from time import sleep
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-06-25 12:32:03 +00:00
|
|
|
from django.utils.crypto import get_random_string
|
2022-06-24 15:05:32 +00:00
|
|
|
from django.template.loader import render_to_string
|
2022-09-13 14:27:37 +00:00
|
|
|
from django.contrib.auth import get_user_model
|
2022-06-29 19:40:55 +00:00
|
|
|
from django.core.mail import send_mail
|
2022-06-24 15:05:32 +00:00
|
|
|
from django.conf import settings
|
2022-07-08 15:13:22 +00:00
|
|
|
from payments import get_payment_model
|
2022-06-24 15:05:32 +00:00
|
|
|
|
|
|
|
from dash.models import Instance
|
|
|
|
|
2022-06-29 19:40:55 +00:00
|
|
|
from infrastructure.models import InstanceCreated, JobType, Job
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
logging.basicConfig()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2022-06-29 19:40:55 +00:00
|
|
|
|
|
|
|
class Worker(Thread):
|
|
|
|
def __init__(self, job: Job):
|
|
|
|
self.job = job
|
|
|
|
super().__init__()
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
gitea_uri = Infra.get_gitea_uri(instance=self.job.instance)
|
2022-06-30 07:35:57 +00:00
|
|
|
woodpecker = Infra.get_woodpecker_uri(instance=self.job.instance)
|
2022-06-29 19:40:55 +00:00
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
print(f"[ping] Trying to reach {gitea_uri}")
|
|
|
|
resp = requests.get(gitea_uri)
|
|
|
|
if resp.status_code == 200:
|
|
|
|
break
|
|
|
|
except Exception:
|
|
|
|
return False
|
|
|
|
sleep(10)
|
|
|
|
|
|
|
|
job = self.job
|
|
|
|
self.job = None
|
|
|
|
email = job.instance.owned_by.email
|
|
|
|
|
2022-07-04 09:27:34 +00:00
|
|
|
ctx = {
|
|
|
|
"gitea_uri": gitea_uri,
|
|
|
|
"woodpecker_uri": woodpecker,
|
|
|
|
"username": job.instance.owned_by.username,
|
|
|
|
}
|
|
|
|
body = render_to_string(
|
|
|
|
"infrastructure/emails/instance-created.txt",
|
|
|
|
context=ctx,
|
|
|
|
)
|
|
|
|
|
2022-07-04 10:37:30 +00:00
|
|
|
sender = settings.DEFAULT_FROM_EMAIL
|
2022-06-29 19:40:55 +00:00
|
|
|
|
2022-07-04 09:27:34 +00:00
|
|
|
send_mail(
|
2022-09-12 11:06:29 +00:00
|
|
|
subject="[Gna!] Your Gna! instance is now online!",
|
2022-07-04 09:27:34 +00:00
|
|
|
message=body,
|
2022-09-12 11:06:29 +00:00
|
|
|
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
2022-06-29 19:40:55 +00:00
|
|
|
recipient_list=[email],
|
|
|
|
)
|
|
|
|
job.delete()
|
2022-06-27 15:29:24 +00:00
|
|
|
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
def create_vm_if_not_exists(instance: Instance) -> (str, str):
|
2022-06-28 15:24:21 +00:00
|
|
|
"""
|
|
|
|
Create VM utility. Gitea password is returned
|
|
|
|
"""
|
2022-09-13 14:27:37 +00:00
|
|
|
|
|
|
|
def notify_staff(instance: Instance):
|
|
|
|
infra = Infra()
|
|
|
|
User = get_user_model()
|
|
|
|
gitea_uri = Infra.get_gitea_uri(instance=instance)
|
|
|
|
woodpecker = Infra.get_woodpecker_uri(instance=instance)
|
|
|
|
|
|
|
|
for staff in User.objects.filter(is_staff=True):
|
|
|
|
ctx = {
|
|
|
|
"gitea_uri": gitea_uri,
|
|
|
|
"woodpecker_uri": woodpecker,
|
|
|
|
"username": staff.username,
|
|
|
|
}
|
|
|
|
body = render_to_string(
|
|
|
|
"infrastructure/emails/staff-new-instance-alert.txt",
|
|
|
|
context=ctx,
|
|
|
|
)
|
|
|
|
|
|
|
|
sender = settings.DEFAULT_FROM_EMAIL
|
|
|
|
|
|
|
|
send_mail(
|
|
|
|
subject="[Gna!] New instance alert",
|
|
|
|
message=body,
|
|
|
|
from_email=f"No reply Gna!<{sender}>", # TODO read from settings.py
|
|
|
|
recipient_list=[staff.email],
|
|
|
|
)
|
|
|
|
|
2022-06-27 15:29:24 +00:00
|
|
|
infra = Infra()
|
|
|
|
if not InstanceCreated.objects.filter(instance=instance).exists():
|
2022-06-28 18:57:47 +00:00
|
|
|
(gitea_password, commit) = infra.add_vm(instance=instance)
|
2022-06-29 19:40:55 +00:00
|
|
|
InstanceCreated.objects.create(instance=instance, created=True)
|
2022-09-13 14:27:37 +00:00
|
|
|
notify_staff(instance=instance)
|
2022-06-29 19:40:55 +00:00
|
|
|
job = Job.objects.create(instance=instance, job_type=str(JobType.PING))
|
|
|
|
Worker(job=job).start()
|
2022-06-28 19:19:58 +00:00
|
|
|
return (gitea_password, commit)
|
2022-06-28 18:24:14 +00:00
|
|
|
else:
|
2022-06-28 18:57:47 +00:00
|
|
|
if str.strip(infra.get_flavor(instance=instance)) != str.strip(
|
|
|
|
infra.translate_size(instance=instance)
|
|
|
|
):
|
2022-06-29 19:40:55 +00:00
|
|
|
# Worker.init_global()
|
2022-09-13 14:27:37 +00:00
|
|
|
notify_staff(instance=instance)
|
2022-06-28 18:57:47 +00:00
|
|
|
return infra.add_vm(instance=instance)
|
|
|
|
return None
|
2022-06-27 15:29:24 +00:00
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-07-01 14:24:20 +00:00
|
|
|
def delete_vm(instance: Instance):
|
2022-06-27 19:54:43 +00:00
|
|
|
infra = Infra()
|
2022-07-08 15:13:22 +00:00
|
|
|
Payment = get_payment_model()
|
|
|
|
for payment in Payment.objects.filter(
|
|
|
|
paid_by=instance.owned_by, instance_name=instance.name
|
|
|
|
):
|
|
|
|
payment.vm_deleted = True
|
|
|
|
payment.save()
|
|
|
|
|
2022-06-27 19:54:43 +00:00
|
|
|
infra.remove_vm(instance=instance)
|
|
|
|
if InstanceCreated.objects.filter(instance=instance).exists():
|
|
|
|
InstanceCreated.objects.get(instance=instance).delete()
|
|
|
|
instance.delete()
|
|
|
|
|
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
class Infra:
|
|
|
|
"""
|
|
|
|
Utility function to manage infrastructure repository
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
|
|
|
self.repo_path = Path(conf["PATH"])
|
2022-07-02 12:32:25 +00:00
|
|
|
self._clone()
|
|
|
|
|
|
|
|
def _clone(self):
|
2022-07-08 12:26:59 +00:00
|
|
|
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
|
|
|
ssh_cmd = f"/usr/bin/ssh -oStrictHostKeyChecking=no -i {conf['SSH_KEY']}"
|
|
|
|
self.git = sh.git.bake(_env={"GIT_SSH_COMMAND": ssh_cmd})
|
2022-07-02 12:32:25 +00:00
|
|
|
conf = settings.HOSTEA["INFRA"]["HOSTEA_REPO"]
|
|
|
|
if os.path.exists(self.repo_path):
|
|
|
|
shutil.rmtree(self.repo_path)
|
2022-07-08 12:26:59 +00:00
|
|
|
self.git.clone(conf["REMOTE"], self.repo_path)
|
|
|
|
self.git = self.git.bake("-C", self.repo_path)
|
2022-06-24 15:05:32 +00:00
|
|
|
|
|
|
|
def _host_vars_dir(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: get host_vars directory for a subdomain
|
|
|
|
"""
|
|
|
|
return self.repo_path.joinpath(f"inventory/host_vars/{subdomain}-host/")
|
|
|
|
|
|
|
|
def _provision_path(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: get provision file pay for a subdomain
|
|
|
|
"""
|
|
|
|
return self._host_vars_dir(subdomain=subdomain).joinpath("provision.yml")
|
|
|
|
|
2022-06-29 19:38:22 +00:00
|
|
|
@classmethod
|
|
|
|
def get_gitea_uri(cls, instance: Instance) -> str:
|
2022-06-29 05:35:33 +00:00
|
|
|
"""
|
|
|
|
Get an instance's Gitea URI
|
|
|
|
"""
|
|
|
|
base = settings.HOSTEA["INFRA"]["HOSTEA_DOMAIN"]
|
2022-06-29 19:38:22 +00:00
|
|
|
return f"https://{instance.name}.{base}"
|
2022-06-29 05:35:33 +00:00
|
|
|
|
2022-06-29 19:38:22 +00:00
|
|
|
@classmethod
|
|
|
|
def _gen_woodpecker_hostname(cls, instance: Instance) -> str:
|
2022-06-29 05:35:33 +00:00
|
|
|
"""
|
|
|
|
Get Woodpecker hostname of an instance
|
|
|
|
"""
|
2022-06-30 07:35:57 +00:00
|
|
|
return f"{instance.name}-ci"
|
2022-06-29 05:35:33 +00:00
|
|
|
|
2022-06-29 19:38:22 +00:00
|
|
|
@classmethod
|
2022-06-30 07:35:57 +00:00
|
|
|
def get_woodpecker_uri(cls, instance: Instance) -> str:
|
2022-06-29 05:35:33 +00:00
|
|
|
"""
|
|
|
|
Get an instance's Gitea URI
|
|
|
|
"""
|
2022-06-29 19:38:22 +00:00
|
|
|
base = settings.HOSTEA["INFRA"]["HOSTEA_DOMAIN"]
|
|
|
|
return f"https://{cls._gen_woodpecker_hostname(instance=instance)}.{base}"
|
2022-06-29 05:35:33 +00:00
|
|
|
|
2022-06-28 18:24:14 +00:00
|
|
|
def get_flavor(self, instance: Instance):
|
2022-06-29 05:35:33 +00:00
|
|
|
"""
|
|
|
|
Get VM flavour/size/configuration from the fleet repository
|
|
|
|
"""
|
2022-06-28 18:24:14 +00:00
|
|
|
subdomain = instance.name
|
|
|
|
provision = self._provision_path(subdomain)
|
|
|
|
with open(provision, "r", encoding="utf-8") as f:
|
|
|
|
config = yaml.safe_load(f)
|
|
|
|
if "openstack_flavor" in config:
|
|
|
|
return config["openstack_flavor"].split("{{ ")[1].split(" }}")[0]
|
2022-06-29 05:35:33 +00:00
|
|
|
return None
|
2022-06-28 18:24:14 +00:00
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
def _gitea_path(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: get gitea file for a subdomain
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self._host_vars_dir(subdomain=subdomain).joinpath("gitea.yml")
|
|
|
|
|
|
|
|
def _backup_path(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: get backup file for a subdomain
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self.repo_path.joinpath(f"inventory/{subdomain}-backup.yml")
|
|
|
|
|
2022-06-28 08:42:19 +00:00
|
|
|
def _service_path(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: get service file for a subdomain
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self.repo_path.joinpath(f"inventory/{subdomain}-service.yml")
|
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
def _hostscript_path(self, subdomain: str) -> Path:
|
|
|
|
"""
|
|
|
|
utility method: hostscript file for a subdomain
|
|
|
|
"""
|
2022-06-28 15:24:21 +00:00
|
|
|
return self.repo_path.joinpath(f"hosts-scripts/{subdomain}-host.sh")
|
2022-06-24 15:05:32 +00:00
|
|
|
|
|
|
|
def write_hostscript(self, subdomain: str, content: str):
|
|
|
|
"""
|
|
|
|
Write contents to hostscript.
|
|
|
|
Hostscript will contain the history of all actions that have been
|
|
|
|
ordered on a particular VM. So content needs to be appended to it,
|
|
|
|
rather than overwritten.
|
|
|
|
"""
|
|
|
|
hostscript = self._hostscript_path(subdomain)
|
|
|
|
with open(hostscript, "a", encoding="utf-8") as f:
|
|
|
|
f.write(content)
|
|
|
|
f.write("\n")
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
def _push(self, message):
|
|
|
|
self.git.add(".")
|
|
|
|
self.git.config("user.email", settings.HOSTEA["INSTANCE_MAINTAINER_CONTACT"])
|
|
|
|
self.git.config("user.name", "Hostea dashboard")
|
|
|
|
try:
|
|
|
|
self.git.commit("-m", f"dashboard: {message}")
|
|
|
|
except sh.ErrorReturnCode_1:
|
|
|
|
logger.debug("no change")
|
|
|
|
else:
|
|
|
|
self.git.push("origin", "master")
|
|
|
|
return self._sha()
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
def _sha(self):
|
|
|
|
sha = self.git("rev-parse", "origin/master")
|
|
|
|
return str(sha).strip()
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-06-29 05:35:33 +00:00
|
|
|
@staticmethod
|
|
|
|
def translate_size(instance: Instance) -> str:
|
|
|
|
"""
|
|
|
|
Translate openstack(I think OVH-specific) sizes to enough.community
|
|
|
|
normalized sizes
|
|
|
|
"""
|
2022-06-28 18:24:14 +00:00
|
|
|
if instance.configuration_id.name == "s1-2":
|
|
|
|
return "openstack_flavor_small"
|
2022-06-29 05:35:33 +00:00
|
|
|
if instance.configuration_id.name == "s1-4":
|
2022-06-28 18:24:14 +00:00
|
|
|
return "openstack_flavor_medium"
|
2022-06-29 05:35:33 +00:00
|
|
|
if instance.configuration_id.name == "s1-8":
|
2022-06-28 18:24:14 +00:00
|
|
|
return "openstack_flavor_large"
|
2022-06-29 05:35:33 +00:00
|
|
|
return instance.configuration_id.name
|
2022-06-28 18:24:14 +00:00
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
def add_vm(self, instance: Instance) -> (str, str):
|
2022-06-24 15:05:32 +00:00
|
|
|
"""
|
|
|
|
Add new VM to infrastructure repository
|
2022-06-25 12:32:03 +00:00
|
|
|
|
|
|
|
The gitea user password is returned
|
2022-06-24 15:05:32 +00:00
|
|
|
"""
|
2022-06-25 12:32:03 +00:00
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
subdomain = instance.name
|
|
|
|
host_vars_dir = self._host_vars_dir(subdomain)
|
|
|
|
|
|
|
|
if not host_vars_dir.exists():
|
|
|
|
os.makedirs(host_vars_dir)
|
|
|
|
|
2022-06-28 09:17:21 +00:00
|
|
|
hostscript_path = self.repo_path.joinpath("hosts-scripts/")
|
2022-06-24 15:05:32 +00:00
|
|
|
if not hostscript_path.exists():
|
|
|
|
os.makedirs(hostscript_path)
|
|
|
|
|
2022-06-25 12:32:03 +00:00
|
|
|
woodpecker_agent_secret = get_random_string(64)
|
|
|
|
gitea_password = get_random_string(20)
|
|
|
|
|
|
|
|
ctx = {
|
|
|
|
"woodpecker_agent_secret": woodpecker_agent_secret,
|
2022-06-29 05:35:33 +00:00
|
|
|
"woodpecker_hostname": self._gen_woodpecker_hostname(instance=instance),
|
2022-06-25 12:32:03 +00:00
|
|
|
"woodpecker_admins": f"{instance.owned_by.username}",
|
|
|
|
"gitea_email": instance.owned_by.email,
|
|
|
|
"gitea_password": gitea_password,
|
|
|
|
"subdomain": subdomain,
|
|
|
|
}
|
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
gitea = self._gitea_path(subdomain)
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(gitea, "w+", encoding="utf-8") as f:
|
2022-06-25 12:32:03 +00:00
|
|
|
f.write(
|
|
|
|
render_to_string(
|
|
|
|
"infrastructure/yml/gitea.yml",
|
|
|
|
context=ctx,
|
|
|
|
)
|
|
|
|
)
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-06-28 18:24:14 +00:00
|
|
|
size = self.translate_size(instance=instance)
|
2022-06-24 15:05:32 +00:00
|
|
|
provision = self._provision_path(subdomain)
|
2022-06-25 12:32:03 +00:00
|
|
|
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(provision, "w+", encoding="utf-8") as f:
|
2022-06-24 15:05:32 +00:00
|
|
|
f.write(
|
|
|
|
render_to_string(
|
2022-06-25 12:32:03 +00:00
|
|
|
"infrastructure/yml/provision.yml", context={"vm_size": size}
|
2022-06-24 15:05:32 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
backup = self._backup_path(subdomain)
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(backup, "w+", encoding="utf-8") as f:
|
2022-06-24 15:05:32 +00:00
|
|
|
f.write(
|
|
|
|
render_to_string(
|
|
|
|
"infrastructure/yml/backups.yml", context={"subdomain": subdomain}
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2022-06-28 08:42:19 +00:00
|
|
|
service = self._service_path(subdomain)
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(service, "w+", encoding="utf-8") as f:
|
2022-06-28 08:42:19 +00:00
|
|
|
f.write(
|
|
|
|
render_to_string(
|
|
|
|
"infrastructure/yml/service.yml", context={"subdomain": subdomain}
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
2022-06-28 09:17:21 +00:00
|
|
|
hostscript = self._hostscript_path(subdomain)
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(hostscript, "w+", encoding="utf-8") as f:
|
2022-06-28 09:17:21 +00:00
|
|
|
f.write("\n")
|
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
self.write_hostscript(
|
|
|
|
subdomain=subdomain,
|
|
|
|
content=render_to_string(
|
2022-06-25 12:32:03 +00:00
|
|
|
"infrastructure/sh/hostscripts/create.sh",
|
2022-06-25 12:54:52 +00:00
|
|
|
context={"subdomain": subdomain},
|
2022-06-24 15:05:32 +00:00
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
commit = self._push(f"add vm {subdomain}")
|
2022-06-28 18:57:47 +00:00
|
|
|
return (gitea_password, commit)
|
2022-06-24 15:05:32 +00:00
|
|
|
|
|
|
|
def remove_vm(self, instance: Instance):
|
|
|
|
"""
|
|
|
|
Remove a VM from infrastructure repository
|
|
|
|
"""
|
|
|
|
subdomain = instance.name
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
host_vars_dir = self._host_vars_dir(subdomain)
|
|
|
|
if os.path.exists(host_vars_dir):
|
2022-06-28 15:24:21 +00:00
|
|
|
shutil.rmtree(host_vars_dir)
|
2022-06-24 15:05:32 +00:00
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
backup = self._backup_path(subdomain)
|
|
|
|
if os.path.exists(backup):
|
2022-06-28 15:24:21 +00:00
|
|
|
os.remove(backup)
|
|
|
|
|
2022-07-08 12:26:59 +00:00
|
|
|
service = self._service_path(subdomain)
|
|
|
|
if os.path.exists(service):
|
2022-06-28 15:24:21 +00:00
|
|
|
os.remove(service)
|
2022-06-28 08:42:19 +00:00
|
|
|
|
2022-06-28 09:17:21 +00:00
|
|
|
hostscript = self._hostscript_path(subdomain)
|
2022-06-28 18:24:14 +00:00
|
|
|
with open(hostscript, "w+", encoding="utf-8") as f:
|
2022-06-28 09:17:21 +00:00
|
|
|
f.write("\n")
|
|
|
|
|
2022-06-24 15:05:32 +00:00
|
|
|
self.write_hostscript(
|
|
|
|
subdomain=subdomain,
|
|
|
|
content=render_to_string(
|
2022-06-25 12:54:52 +00:00
|
|
|
"infrastructure/sh/hostscripts/rm.sh",
|
|
|
|
context={"subdomain": subdomain},
|
2022-06-24 15:05:32 +00:00
|
|
|
),
|
|
|
|
)
|
2022-07-08 12:26:59 +00:00
|
|
|
return self._push(f"rm vm {subdomain}")
|