Compare commits
21 Commits
publicapi-
...
1f384f34b5
Author | SHA1 | Date | |
---|---|---|---|
1f384f34b5 | |||
c25d85bbdd | |||
0f4ac8e444 | |||
3cf501a393 | |||
180efa01af | |||
7ed847251f | |||
e3a4776a5d | |||
357d99cb91 | |||
f5c079ffc2 | |||
0e5dfe6bde | |||
2adbb8d94c | |||
8446d11720 | |||
a580b04659 | |||
2e6894ad14 | |||
2e6c6517f3 | |||
be6c1b38b7 | |||
aa8e129913 | |||
71e09807a7 | |||
4816170c03 | |||
6af241e8be | |||
c8ec53f207 |
14
.drone.yml
Normal file
14
.drone.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
name: publish docker image
|
||||||
|
steps:
|
||||||
|
- name: build and publish
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: docker_reg_username_3wc
|
||||||
|
password:
|
||||||
|
from_secret: docker_reg_passwd_3wc
|
||||||
|
repo: 3wordchant/capsul-flask
|
||||||
|
tags: latest
|
||||||
|
|
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
FROM python:3.8-alpine as build
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
gcc \
|
||||||
|
gettext \
|
||||||
|
git \
|
||||||
|
jpeg-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libjpeg \
|
||||||
|
musl-dev \
|
||||||
|
postgresql-dev \
|
||||||
|
python3-dev \
|
||||||
|
zlib-dev
|
||||||
|
|
||||||
|
RUN mkdir -p /app/{code,venv}
|
||||||
|
WORKDIR /app/code
|
||||||
|
COPY Pipfile Pipfile.lock /app/code/
|
||||||
|
|
||||||
|
RUN python3 -m venv /app/venv
|
||||||
|
RUN pip install pipenv setuptools
|
||||||
|
ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv"
|
||||||
|
RUN pip install wheel cppy
|
||||||
|
# Install dependencies into the virtual environment with Pipenv
|
||||||
|
RUN pipenv install --deploy --verbose
|
||||||
|
|
||||||
|
FROM python:3.8-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
cloud-utils \
|
||||||
|
libjpeg \
|
||||||
|
libpq \
|
||||||
|
libstdc++ \
|
||||||
|
libvirt-client \
|
||||||
|
openssh-client \
|
||||||
|
virt-install
|
||||||
|
|
||||||
|
COPY . /app/code/
|
||||||
|
WORKDIR /app/code
|
||||||
|
|
||||||
|
COPY --from=build /app/venv /app/venv
|
||||||
|
ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv"
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--worker-connections", "1000", "app:app"]
|
||||||
|
|
||||||
|
VOLUME /app/code
|
||||||
|
|
||||||
|
EXPOSE 5000
|
1
Pipfile
1
Pipfile
@ -9,7 +9,6 @@ blinker = "==1.4"
|
|||||||
click = "==7.1.2"
|
click = "==7.1.2"
|
||||||
Flask = "==1.1.2"
|
Flask = "==1.1.2"
|
||||||
Flask-Mail = "==0.9.1"
|
Flask-Mail = "==0.9.1"
|
||||||
Flask-Testing = "==0.8.1"
|
|
||||||
gunicorn = "==20.0.4"
|
gunicorn = "==20.0.4"
|
||||||
isort = "==4.3.21"
|
isort = "==4.3.21"
|
||||||
itsdangerous = "==1.1.0"
|
itsdangerous = "==1.1.0"
|
||||||
|
4
app.py
4
app.py
@ -1,4 +1,2 @@
|
|||||||
|
|
||||||
from capsulflask import create_app
|
from capsulflask import app
|
||||||
|
|
||||||
create_app()
|
|
||||||
|
@ -8,7 +8,7 @@ import requests
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from dotenv import find_dotenv, dotenv_values
|
from dotenv import load_dotenv, find_dotenv
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_mail import Mail, Message
|
from flask_mail import Mail, Message
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
@ -22,112 +22,121 @@ from capsulflask import hub_model, spoke_model, cli
|
|||||||
from capsulflask.btcpay import client as btcpay
|
from capsulflask.btcpay import client as btcpay
|
||||||
from capsulflask.http_client import MyHTTPClient
|
from capsulflask.http_client import MyHTTPClient
|
||||||
|
|
||||||
|
|
||||||
class StdoutMockFlaskMail:
|
class StdoutMockFlaskMail:
|
||||||
def send(self, message: Message):
|
def send(self, message: Message):
|
||||||
current_app.logger.info(f"Email would have been sent if configured:\n\nto: {','.join(message.recipients)}\nsubject: {message.subject}\nbody:\n\n{message.body}\n\n")
|
current_app.logger.info(f"Email would have been sent if configured:\n\nto: {','.join(message.recipients)}\nsubject: {message.subject}\nbody:\n\n{message.body}\n\n")
|
||||||
|
|
||||||
def create_app():
|
|
||||||
|
|
||||||
config = {
|
load_dotenv(find_dotenv())
|
||||||
**dotenv_values(find_dotenv()),
|
|
||||||
**os.environ, # override loaded values with environment variables
|
|
||||||
}
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
for var_name in [
|
||||||
|
"SPOKE_HOST_TOKEN", "HUB_TOKEN", "STRIPE_SECRET_KEY",
|
||||||
|
"BTCPAY_PRIVATE_KEY", "MAIL_PASSWORD"
|
||||||
|
]:
|
||||||
|
var = os.environ.get(f"{var_name}_FILE")
|
||||||
|
if not var:
|
||||||
|
continue
|
||||||
|
|
||||||
app.config.from_mapping(
|
if not os.path.isfile(var):
|
||||||
TESTING=config.get("TESTING", False),
|
continue
|
||||||
BASE_URL=config.get("BASE_URL", "http://localhost:5000"),
|
|
||||||
SECRET_KEY=config.get("SECRET_KEY", "dev"),
|
|
||||||
HUB_MODE_ENABLED=config.get("HUB_MODE_ENABLED", "True").lower() in ['true', '1', 't', 'y', 'yes'],
|
|
||||||
SPOKE_MODE_ENABLED=config.get("SPOKE_MODE_ENABLED", "True").lower() in ['true', '1', 't', 'y', 'yes'],
|
|
||||||
INTERNAL_HTTP_TIMEOUT_SECONDS=config.get("INTERNAL_HTTP_TIMEOUT_SECONDS", "300"),
|
|
||||||
HUB_MODEL=config.get("HUB_MODEL", "capsul-flask"),
|
|
||||||
SPOKE_MODEL=config.get("SPOKE_MODEL", "mock"),
|
|
||||||
LOG_LEVEL=config.get("LOG_LEVEL", "INFO"),
|
|
||||||
SPOKE_HOST_ID=config.get("SPOKE_HOST_ID", "baikal"),
|
|
||||||
SPOKE_HOST_TOKEN=config.get("SPOKE_HOST_TOKEN", "changeme"),
|
|
||||||
HUB_TOKEN=config.get("HUB_TOKEN", "changeme"),
|
|
||||||
|
|
||||||
# https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
|
with open(var) as secret_file:
|
||||||
# https://stackoverflow.com/questions/56332906/where-to-put-ssl-certificates-when-trying-to-connect-to-a-remote-database-using
|
os.environ[var_name] = secret_file.read().rstrip('\n')
|
||||||
# TLS example: sslmode=verify-full sslrootcert=letsencrypt-root-ca.crt host=db.example.com port=5432 user=postgres password=dev dbname=postgres
|
del os.environ[f"{var_name}_FILE"]
|
||||||
POSTGRES_CONNECTION_PARAMETERS=config.get(
|
|
||||||
"POSTGRES_CONNECTION_PARAMETERS",
|
|
||||||
"host=localhost port=5432 user=postgres password=dev dbname=postgres"
|
|
||||||
),
|
|
||||||
|
|
||||||
DATABASE_SCHEMA=config.get("DATABASE_SCHEMA", "public"),
|
app = Flask(__name__)
|
||||||
|
|
||||||
MAIL_SERVER=config.get("MAIL_SERVER", ""),
|
app.config.from_mapping(
|
||||||
MAIL_PORT=config.get("MAIL_PORT", "465"),
|
BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"),
|
||||||
MAIL_USE_TLS=config.get("MAIL_USE_TLS", "False").lower() in ['true', '1', 't', 'y', 'yes'],
|
SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"),
|
||||||
MAIL_USE_SSL=config.get("MAIL_USE_SSL", "True").lower() in ['true', '1', 't', 'y', 'yes'],
|
HUB_MODE_ENABLED=os.environ.get("HUB_MODE_ENABLED", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
|
||||||
MAIL_USERNAME=config.get("MAIL_USERNAME", ""),
|
SPOKE_MODE_ENABLED=os.environ.get("SPOKE_MODE_ENABLED", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
|
||||||
MAIL_PASSWORD=config.get("MAIL_PASSWORD", ""),
|
INTERNAL_HTTP_TIMEOUT_SECONDS=os.environ.get("INTERNAL_HTTP_TIMEOUT_SECONDS", default="300"),
|
||||||
MAIL_DEFAULT_SENDER=config.get("MAIL_DEFAULT_SENDER", "no-reply@capsul.org"),
|
HUB_MODEL=os.environ.get("HUB_MODEL", default="capsul-flask"),
|
||||||
ADMIN_EMAIL_ADDRESSES=config.get("ADMIN_EMAIL_ADDRESSES", "ops@cyberia.club"),
|
SPOKE_MODEL=os.environ.get("SPOKE_MODEL", default="mock"),
|
||||||
ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES=config.get("ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES", "forest.n.johnson@gmail.com,capsul@cyberia.club"),
|
LOG_LEVEL=os.environ.get("LOG_LEVEL", default="INFO"),
|
||||||
|
SPOKE_HOST_ID=os.environ.get("SPOKE_HOST_ID", default="baikal"),
|
||||||
|
SPOKE_HOST_TOKEN=os.environ.get("SPOKE_HOST_TOKEN", default="changeme"),
|
||||||
|
HUB_TOKEN=os.environ.get("HUB_TOKEN", default="changeme"),
|
||||||
|
|
||||||
PROMETHEUS_URL=config.get("PROMETHEUS_URL", "https://prometheus.cyberia.club"),
|
# https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
|
||||||
|
# https://stackoverflow.com/questions/56332906/where-to-put-ssl-certificates-when-trying-to-connect-to-a-remote-database-using
|
||||||
|
# TLS example: sslmode=verify-full sslrootcert=letsencrypt-root-ca.crt host=db.example.com port=5432 user=postgres password=dev dbname=postgres
|
||||||
|
POSTGRES_CONNECTION_PARAMETERS=os.environ.get(
|
||||||
|
"POSTGRES_CONNECTION_PARAMETERS",
|
||||||
|
default="host=localhost port=5432 user=postgres password=dev dbname=postgres"
|
||||||
|
),
|
||||||
|
|
||||||
STRIPE_API_VERSION=config.get("STRIPE_API_VERSION", "2020-03-02"),
|
DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"),
|
||||||
STRIPE_SECRET_KEY=config.get("STRIPE_SECRET_KEY", ""),
|
|
||||||
STRIPE_PUBLISHABLE_KEY=config.get("STRIPE_PUBLISHABLE_KEY", ""),
|
|
||||||
#STRIPE_WEBHOOK_SECRET=config.get("STRIPE_WEBHOOK_SECRET", "")
|
|
||||||
|
|
||||||
BTCPAY_PRIVATE_KEY=config.get("BTCPAY_PRIVATE_KEY", "").replace("\\n", "\n"),
|
MAIL_SERVER=os.environ.get("MAIL_SERVER", default=""),
|
||||||
BTCPAY_URL=config.get("BTCPAY_URL", "https://btcpay.cyberia.club")
|
MAIL_PORT=os.environ.get("MAIL_PORT", default="465"),
|
||||||
)
|
MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="False").lower() in ['true', '1', 't', 'y', 'yes'],
|
||||||
|
MAIL_USE_SSL=os.environ.get("MAIL_USE_SSL", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
|
||||||
|
MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default=""),
|
||||||
|
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""),
|
||||||
|
MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="no-reply@capsul.org"),
|
||||||
|
ADMIN_EMAIL_ADDRESSES=os.environ.get("ADMIN_EMAIL_ADDRESSES", default="ops@cyberia.club"),
|
||||||
|
ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES=os.environ.get("ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES", default="forest.n.johnson@gmail.com,capsul@cyberia.club"),
|
||||||
|
|
||||||
app.config['HUB_URL'] = config.get("HUB_URL", app.config['BASE_URL'])
|
PROMETHEUS_URL=os.environ.get("PROMETHEUS_URL", default="https://prometheus.cyberia.club"),
|
||||||
|
|
||||||
class SetLogLevelToDebugForHeartbeatRelatedMessagesFilter(logging.Filter):
|
STRIPE_API_VERSION=os.environ.get("STRIPE_API_VERSION", default="2020-03-02"),
|
||||||
def isHeartbeatRelatedString(self, thing):
|
STRIPE_SECRET_KEY=os.environ.get("STRIPE_SECRET_KEY", default=""),
|
||||||
# thing_string = "<error>"
|
STRIPE_PUBLISHABLE_KEY=os.environ.get("STRIPE_PUBLISHABLE_KEY", default=""),
|
||||||
is_in_string = False
|
#STRIPE_WEBHOOK_SECRET=os.environ.get("STRIPE_WEBHOOK_SECRET", default="")
|
||||||
try:
|
|
||||||
thing_string = "%s" % thing
|
|
||||||
is_in_string = 'heartbeat-task' in thing_string or 'hub/heartbeat' in thing_string or 'spoke/heartbeat' in thing_string
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# self.warning("isHeartbeatRelatedString(%s): %s", thing_string, is_in_string )
|
|
||||||
return is_in_string
|
|
||||||
|
|
||||||
def filter(self, record):
|
BTCPAY_PRIVATE_KEY=os.environ.get("BTCPAY_PRIVATE_KEY", default="").replace("\\n", "\n"),
|
||||||
if app.config['LOG_LEVEL'] == "DEBUG":
|
BTCPAY_URL=os.environ.get("BTCPAY_URL", default="https://btcpay.cyberia.club")
|
||||||
return True
|
)
|
||||||
|
|
||||||
if self.isHeartbeatRelatedString(record.msg):
|
app.config['HUB_URL'] = os.environ.get("HUB_URL", default=app.config['BASE_URL'])
|
||||||
return False
|
|
||||||
for arg in record.args:
|
|
||||||
if self.isHeartbeatRelatedString(arg):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
class SetLogLevelToDebugForHeartbeatRelatedMessagesFilter(logging.Filter):
|
||||||
|
def isHeartbeatRelatedString(self, thing):
|
||||||
|
# thing_string = "<error>"
|
||||||
|
is_in_string = False
|
||||||
|
try:
|
||||||
|
thing_string = "%s" % thing
|
||||||
|
is_in_string = 'heartbeat-task' in thing_string or 'hub/heartbeat' in thing_string or 'spoke/heartbeat' in thing_string
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# self.warning("isHeartbeatRelatedString(%s): %s", thing_string, is_in_string )
|
||||||
|
return is_in_string
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
if app.config['LOG_LEVEL'] == "DEBUG":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logging_dict_config({
|
if self.isHeartbeatRelatedString(record.msg):
|
||||||
'version': 1,
|
return False
|
||||||
'formatters': {'default': {
|
for arg in record.args:
|
||||||
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
if self.isHeartbeatRelatedString(arg):
|
||||||
}},
|
return False
|
||||||
'filters': {
|
|
||||||
'setLogLevelToDebugForHeartbeatRelatedMessages': {
|
return True
|
||||||
'()': SetLogLevelToDebugForHeartbeatRelatedMessagesFilter,
|
|
||||||
}
|
logging_dict_config({
|
||||||
},
|
'version': 1,
|
||||||
'handlers': {'wsgi': {
|
'formatters': {'default': {
|
||||||
'class': 'logging.StreamHandler',
|
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
||||||
'stream': 'ext://flask.logging.wsgi_errors_stream',
|
}},
|
||||||
'formatter': 'default',
|
'filters': {
|
||||||
'filters': ['setLogLevelToDebugForHeartbeatRelatedMessages']
|
'setLogLevelToDebugForHeartbeatRelatedMessages': {
|
||||||
}},
|
'()': SetLogLevelToDebugForHeartbeatRelatedMessagesFilter,
|
||||||
'root': {
|
|
||||||
'level': app.config['LOG_LEVEL'],
|
|
||||||
'handlers': ['wsgi']
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
'handlers': {'wsgi': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'stream': 'ext://flask.logging.wsgi_errors_stream',
|
||||||
|
'formatter': 'default',
|
||||||
|
'filters': ['setLogLevelToDebugForHeartbeatRelatedMessages']
|
||||||
|
}},
|
||||||
|
'root': {
|
||||||
|
'level': app.config['LOG_LEVEL'],
|
||||||
|
'handlers': ['wsgi']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# app.logger.critical("critical")
|
# app.logger.critical("critical")
|
||||||
# app.logger.error("error")
|
# app.logger.error("error")
|
||||||
@ -135,122 +144,121 @@ def create_app():
|
|||||||
# app.logger.info("info")
|
# app.logger.info("info")
|
||||||
# app.logger.debug("debug")
|
# app.logger.debug("debug")
|
||||||
|
|
||||||
stripe.api_key = app.config['STRIPE_SECRET_KEY']
|
stripe.api_key = app.config['STRIPE_SECRET_KEY']
|
||||||
stripe.api_version = app.config['STRIPE_API_VERSION']
|
stripe.api_version = app.config['STRIPE_API_VERSION']
|
||||||
|
|
||||||
if app.config['MAIL_SERVER'] != "":
|
if app.config['MAIL_SERVER'] != "":
|
||||||
app.config['FLASK_MAIL_INSTANCE'] = Mail(app)
|
app.config['FLASK_MAIL_INSTANCE'] = Mail(app)
|
||||||
else:
|
else:
|
||||||
app.logger.warning("No MAIL_SERVER configured. capsul will simply print emails to stdout.")
|
app.logger.warning("No MAIL_SERVER configured. capsul will simply print emails to stdout.")
|
||||||
app.config['FLASK_MAIL_INSTANCE'] = StdoutMockFlaskMail()
|
app.config['FLASK_MAIL_INSTANCE'] = StdoutMockFlaskMail()
|
||||||
|
|
||||||
app.config['HTTP_CLIENT'] = MyHTTPClient(timeout_seconds=int(app.config['INTERNAL_HTTP_TIMEOUT_SECONDS']))
|
app.config['HTTP_CLIENT'] = MyHTTPClient(timeout_seconds=int(app.config['INTERNAL_HTTP_TIMEOUT_SECONDS']))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY'])
|
app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY'])
|
||||||
except:
|
except:
|
||||||
app.logger.warning("unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message(sys.exc_info()))
|
app.logger.warning("unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message(sys.exc_info()))
|
||||||
|
|
||||||
# only start the scheduler and attempt to migrate the database if we are running the app.
|
# only start the scheduler and attempt to migrate the database if we are running the app.
|
||||||
# otherwise we are running a CLI command.
|
# otherwise we are running a CLI command.
|
||||||
command_line = ' '.join(sys.argv)
|
command_line = ' '.join(sys.argv)
|
||||||
is_running_server = (
|
is_running_server = ('flask run' in command_line) or ('gunicorn' in command_line)
|
||||||
('flask run' in command_line) or
|
|
||||||
('gunicorn' in command_line) or
|
|
||||||
('test' in command_line)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.logger.info(f"is_running_server: {is_running_server}")
|
app.logger.info(f"is_running_server: {is_running_server}")
|
||||||
|
|
||||||
if app.config['HUB_MODE_ENABLED']:
|
if app.config['HUB_MODE_ENABLED']:
|
||||||
if app.config['HUB_MODEL'] == "capsul-flask":
|
|
||||||
app.config['HUB_MODEL'] = hub_model.CapsulFlaskHub()
|
|
||||||
|
|
||||||
# debug mode (flask reloader) runs two copies of the app. When running in debug mode,
|
if app.config['HUB_MODEL'] == "capsul-flask":
|
||||||
# we only want to start the scheduler one time.
|
app.config['HUB_MODEL'] = hub_model.CapsulFlaskHub()
|
||||||
if is_running_server and (not app.debug or config.get('WERKZEUG_RUN_MAIN') == 'true'):
|
|
||||||
scheduler = BackgroundScheduler()
|
|
||||||
heartbeat_task_url = f"{app.config['HUB_URL']}/hub/heartbeat-task"
|
|
||||||
heartbeat_task_headers = {'Authorization': f"Bearer {app.config['HUB_TOKEN']}"}
|
|
||||||
heartbeat_task = lambda: requests.post(heartbeat_task_url, headers=heartbeat_task_headers)
|
|
||||||
scheduler.add_job(name="heartbeat-task", func=heartbeat_task, trigger="interval", seconds=5)
|
|
||||||
scheduler.start()
|
|
||||||
|
|
||||||
atexit.register(lambda: scheduler.shutdown())
|
# debug mode (flask reloader) runs two copies of the app. When running in debug mode,
|
||||||
|
# we only want to start the scheduler one time.
|
||||||
|
if is_running_server and (not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true'):
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
heartbeat_task_url = f"{app.config['HUB_URL']}/hub/heartbeat-task"
|
||||||
|
heartbeat_task_headers = {'Authorization': f"Bearer {app.config['HUB_TOKEN']}"}
|
||||||
|
heartbeat_task = lambda: requests.post(heartbeat_task_url, headers=heartbeat_task_headers)
|
||||||
|
scheduler.add_job(name="heartbeat-task", func=heartbeat_task, trigger="interval", seconds=5)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
else:
|
atexit.register(lambda: scheduler.shutdown())
|
||||||
app.config['HUB_MODEL'] = hub_model.MockHub()
|
|
||||||
|
|
||||||
from capsulflask import db
|
else:
|
||||||
db.init_app(app, is_running_server)
|
app.config['HUB_MODEL'] = hub_model.MockHub()
|
||||||
|
|
||||||
from capsulflask import (
|
from capsulflask import db
|
||||||
auth, landing, console, payment, metrics, cli, hub_api, publicapi, admin
|
db.init_app(app, is_running_server)
|
||||||
)
|
|
||||||
|
from capsulflask import auth, landing, console, payment, metrics, cli, hub_api, admin
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
|
||||||
app.register_blueprint(landing.bp)
|
app.register_blueprint(landing.bp)
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
app.register_blueprint(console.bp)
|
app.register_blueprint(console.bp)
|
||||||
app.register_blueprint(payment.bp)
|
app.register_blueprint(payment.bp)
|
||||||
app.register_blueprint(metrics.bp)
|
app.register_blueprint(metrics.bp)
|
||||||
app.register_blueprint(cli.bp)
|
app.register_blueprint(cli.bp)
|
||||||
app.register_blueprint(hub_api.bp)
|
app.register_blueprint(hub_api.bp)
|
||||||
app.register_blueprint(admin.bp)
|
app.register_blueprint(admin.bp)
|
||||||
app.register_blueprint(publicapi.bp)
|
|
||||||
|
|
||||||
app.add_url_rule("/", endpoint="index")
|
app.add_url_rule("/", endpoint="index")
|
||||||
|
|
||||||
if app.config['SPOKE_MODE_ENABLED']:
|
|
||||||
if app.config['SPOKE_MODEL'] == "shell-scripts":
|
|
||||||
app.config['SPOKE_MODEL'] = spoke_model.ShellScriptSpoke()
|
|
||||||
else:
|
|
||||||
app.config['SPOKE_MODEL'] = spoke_model.MockSpoke()
|
|
||||||
|
|
||||||
from capsulflask import spoke_api
|
|
||||||
|
|
||||||
app.register_blueprint(spoke_api.bp)
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def security_headers(response):
|
|
||||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
|
||||||
if 'Content-Security-Policy' not in response.headers:
|
|
||||||
response.headers['Content-Security-Policy'] = "default-src 'self'"
|
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
if app.config['SPOKE_MODE_ENABLED']:
|
||||||
def override_url_for():
|
|
||||||
"""
|
if app.config['SPOKE_MODEL'] == "shell-scripts":
|
||||||
override the url_for function built into flask
|
app.config['SPOKE_MODEL'] = spoke_model.ShellScriptSpoke()
|
||||||
with our own custom implementation that busts the cache correctly when files change
|
else:
|
||||||
"""
|
app.config['SPOKE_MODEL'] = spoke_model.MockSpoke()
|
||||||
return dict(url_for=url_for_with_cache_bust)
|
|
||||||
|
from capsulflask import spoke_api
|
||||||
|
|
||||||
|
app.register_blueprint(spoke_api.bp)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def security_headers(response):
|
||||||
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
if 'Content-Security-Policy' not in response.headers:
|
||||||
|
response.headers['Content-Security-Policy'] = "default-src 'self'"
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def url_for_with_cache_bust(endpoint, **values):
|
@app.context_processor
|
||||||
"""
|
def override_url_for():
|
||||||
Add a query parameter based on the hash of the file, this acts as a cache bust
|
"""
|
||||||
"""
|
override the url_for function built into flask
|
||||||
|
with our own custom implementation that busts the cache correctly when files change
|
||||||
|
"""
|
||||||
|
return dict(url_for=url_for_with_cache_bust)
|
||||||
|
|
||||||
if endpoint == 'static':
|
|
||||||
filename = values.get('filename', None)
|
|
||||||
if filename:
|
|
||||||
if 'STATIC_FILE_HASH_CACHE' not in current_app.config:
|
|
||||||
current_app.config['STATIC_FILE_HASH_CACHE'] = dict()
|
|
||||||
|
|
||||||
if filename not in current_app.config['STATIC_FILE_HASH_CACHE']:
|
@app.context_processor
|
||||||
filepath = os.path.join(current_app.root_path, endpoint, filename)
|
def load_config_vars():
|
||||||
#print(filepath)
|
return dict(config=app.config)
|
||||||
if os.path.isfile(filepath) and os.access(filepath, os.R_OK):
|
|
||||||
|
|
||||||
with open(filepath, 'rb') as file:
|
def url_for_with_cache_bust(endpoint, **values):
|
||||||
hasher = hashlib.md5()
|
"""
|
||||||
hasher.update(file.read())
|
Add a query parameter based on the hash of the file, this acts as a cache bust
|
||||||
current_app.config['STATIC_FILE_HASH_CACHE'][filename] = hasher.hexdigest()[-6:]
|
"""
|
||||||
|
|
||||||
values['q'] = current_app.config['STATIC_FILE_HASH_CACHE'][filename]
|
if endpoint == 'static':
|
||||||
|
filename = values.get('filename', None)
|
||||||
|
if filename:
|
||||||
|
if 'STATIC_FILE_HASH_CACHE' not in current_app.config:
|
||||||
|
current_app.config['STATIC_FILE_HASH_CACHE'] = dict()
|
||||||
|
|
||||||
return url_for(endpoint, **values)
|
if filename not in current_app.config['STATIC_FILE_HASH_CACHE']:
|
||||||
|
filepath = os.path.join(current_app.root_path, endpoint, filename)
|
||||||
|
#print(filepath)
|
||||||
|
if os.path.isfile(filepath) and os.access(filepath, os.R_OK):
|
||||||
|
|
||||||
return app
|
with open(filepath, 'rb') as file:
|
||||||
|
hasher = hashlib.md5()
|
||||||
|
hasher.update(file.read())
|
||||||
|
current_app.config['STATIC_FILE_HASH_CACHE'][filename] = hasher.hexdigest()[-6:]
|
||||||
|
|
||||||
|
values['q'] = current_app.config['STATIC_FILE_HASH_CACHE'][filename]
|
||||||
|
|
||||||
|
return url_for(endpoint, **values)
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from base64 import b64decode
|
|
||||||
import functools
|
import functools
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@ -25,15 +24,6 @@ def account_required(view):
|
|||||||
|
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped_view(**kwargs):
|
def wrapped_view(**kwargs):
|
||||||
api_token = request.headers.get('authorization', None)
|
|
||||||
if api_token is not None:
|
|
||||||
email = get_model().authenticate_token(b64decode(api_token).decode('utf-8'))
|
|
||||||
|
|
||||||
if email is not None:
|
|
||||||
session.clear()
|
|
||||||
session["account"] = email
|
|
||||||
session["csrf-token"] = generate()
|
|
||||||
|
|
||||||
if session.get("account") is None or session.get("csrf-token") is None :
|
if session.get("account") is None or session.get("csrf-token") is None :
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
@ -41,6 +31,7 @@ def account_required(view):
|
|||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def admin_account_required(view):
|
def admin_account_required(view):
|
||||||
"""View decorator that redirects non-admin users to the login page."""
|
"""View decorator that redirects non-admin users to the login page."""
|
||||||
|
|
||||||
@ -65,7 +56,7 @@ def login():
|
|||||||
if not email:
|
if not email:
|
||||||
errors.append("email is required")
|
errors.append("email is required")
|
||||||
elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0:
|
elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0:
|
||||||
errors.append("enter a valid email address")
|
errors.append("enter a valid email address")
|
||||||
|
|
||||||
if len(errors) == 0:
|
if len(errors) == 0:
|
||||||
result = get_model().login(email)
|
result = get_model().login(email)
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
from base64 import b64encode
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask import flash
|
from flask import flash
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -100,6 +98,7 @@ def index():
|
|||||||
@bp.route("/<string:id>", methods=("GET", "POST"))
|
@bp.route("/<string:id>", methods=("GET", "POST"))
|
||||||
@account_required
|
@account_required
|
||||||
def detail(id):
|
def detail(id):
|
||||||
|
|
||||||
duration=request.args.get('duration')
|
duration=request.args.get('duration')
|
||||||
if not duration:
|
if not duration:
|
||||||
duration = "5m"
|
duration = "5m"
|
||||||
@ -189,67 +188,6 @@ def detail(id):
|
|||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create(vm_sizes, operating_systems, public_keys_for_account, server_data):
|
|
||||||
errors = list()
|
|
||||||
|
|
||||||
size = server_data.get("size")
|
|
||||||
os = server_data.get("os")
|
|
||||||
posted_keys_count = int(server_data.get("ssh_authorized_key_count"))
|
|
||||||
|
|
||||||
if not size:
|
|
||||||
errors.append("Size is required")
|
|
||||||
elif size not in vm_sizes:
|
|
||||||
errors.append(f"Invalid size {size}")
|
|
||||||
|
|
||||||
if not os:
|
|
||||||
errors.append("OS is required")
|
|
||||||
elif os not in operating_systems:
|
|
||||||
errors.append(f"Invalid os {os}")
|
|
||||||
|
|
||||||
posted_keys = list()
|
|
||||||
|
|
||||||
if posted_keys_count > 1000:
|
|
||||||
errors.append("something went wrong with ssh keys")
|
|
||||||
else:
|
|
||||||
for i in range(0, posted_keys_count):
|
|
||||||
if f"ssh_key_{i}" in server_data:
|
|
||||||
posted_name = server_data.get(f"ssh_key_{i}")
|
|
||||||
key = None
|
|
||||||
for x in public_keys_for_account:
|
|
||||||
if x['name'] == posted_name:
|
|
||||||
key = x
|
|
||||||
if key:
|
|
||||||
posted_keys.append(key)
|
|
||||||
else:
|
|
||||||
errors.append(f"SSH Key \"{posted_name}\" doesn't exist")
|
|
||||||
|
|
||||||
if len(posted_keys) == 0:
|
|
||||||
errors.append("At least one SSH Public Key is required")
|
|
||||||
|
|
||||||
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(
|
|
||||||
vm_sizes[size]['memory_mb']*1024*1024
|
|
||||||
)
|
|
||||||
|
|
||||||
if not capacity_avaliable:
|
|
||||||
errors.append("""
|
|
||||||
host(s) at capacity. no capsuls can be created at this time. sorry.
|
|
||||||
""")
|
|
||||||
|
|
||||||
if len(errors) == 0:
|
|
||||||
id = make_capsul_id()
|
|
||||||
current_app.config["HUB_MODEL"].create(
|
|
||||||
email = session["account"],
|
|
||||||
id=id,
|
|
||||||
os=os,
|
|
||||||
size=size,
|
|
||||||
template_image_file_name=operating_systems[os]['template_image_file_name'],
|
|
||||||
vcpus=vm_sizes[size]['vcpus'],
|
|
||||||
memory_mb=vm_sizes[size]['memory_mb'],
|
|
||||||
ssh_authorized_keys=list(map(lambda x: x["content"], posted_keys))
|
|
||||||
)
|
|
||||||
return id, errors
|
|
||||||
|
|
||||||
return None, errors
|
|
||||||
|
|
||||||
@bp.route("/create", methods=("GET", "POST"))
|
@bp.route("/create", methods=("GET", "POST"))
|
||||||
@account_required
|
@account_required
|
||||||
@ -264,12 +202,62 @@ def create():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
||||||
return abort(418, f"u want tea")
|
return abort(418, f"u want tea")
|
||||||
id, errors = _create(
|
|
||||||
vm_sizes,
|
size = request.form["size"]
|
||||||
operating_systems,
|
os = request.form["os"]
|
||||||
public_keys_for_account,
|
if not size:
|
||||||
request.form)
|
errors.append("Size is required")
|
||||||
|
elif size not in vm_sizes:
|
||||||
|
errors.append(f"Invalid size {size}")
|
||||||
|
|
||||||
|
if not os:
|
||||||
|
errors.append("OS is required")
|
||||||
|
elif os not in operating_systems:
|
||||||
|
errors.append(f"Invalid os {os}")
|
||||||
|
|
||||||
|
posted_keys_count = int(request.form["ssh_authorized_key_count"])
|
||||||
|
posted_keys = list()
|
||||||
|
|
||||||
|
if posted_keys_count > 1000:
|
||||||
|
errors.append("something went wrong with ssh keys")
|
||||||
|
else:
|
||||||
|
for i in range(0, posted_keys_count):
|
||||||
|
if f"ssh_key_{i}" in request.form:
|
||||||
|
posted_name = request.form[f"ssh_key_{i}"]
|
||||||
|
key = None
|
||||||
|
for x in public_keys_for_account:
|
||||||
|
if x['name'] == posted_name:
|
||||||
|
key = x
|
||||||
|
if key:
|
||||||
|
posted_keys.append(key)
|
||||||
|
else:
|
||||||
|
errors.append(f"SSH Key \"{posted_name}\" doesn't exist")
|
||||||
|
|
||||||
|
if len(posted_keys) == 0:
|
||||||
|
errors.append("At least one SSH Public Key is required")
|
||||||
|
|
||||||
|
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(vm_sizes[size]['memory_mb']*1024*1024)
|
||||||
|
|
||||||
|
if not capacity_avaliable:
|
||||||
|
errors.append("""
|
||||||
|
host(s) at capacity. no capsuls can be created at this time. sorry.
|
||||||
|
""")
|
||||||
|
|
||||||
if len(errors) == 0:
|
if len(errors) == 0:
|
||||||
|
id = make_capsul_id()
|
||||||
|
# we can't create the vm record in the DB yet because its IP address needs to be allocated first.
|
||||||
|
# so it will be created when the allocation happens inside the hub_api.
|
||||||
|
current_app.config["HUB_MODEL"].create(
|
||||||
|
email = session["account"],
|
||||||
|
id=id,
|
||||||
|
os=os,
|
||||||
|
size=size,
|
||||||
|
template_image_file_name=operating_systems[os]['template_image_file_name'],
|
||||||
|
vcpus=vm_sizes[size]['vcpus'],
|
||||||
|
memory_mb=vm_sizes[size]['memory_mb'],
|
||||||
|
ssh_authorized_keys=list(map(lambda x: dict(name=x['name'], content=x['content']), posted_keys))
|
||||||
|
)
|
||||||
|
|
||||||
return redirect(f"{url_for('console.index')}?created={id}")
|
return redirect(f"{url_for('console.index')}?created={id}")
|
||||||
|
|
||||||
affordable_vm_sizes = dict()
|
affordable_vm_sizes = dict()
|
||||||
@ -299,25 +287,23 @@ def create():
|
|||||||
vm_sizes=affordable_vm_sizes
|
vm_sizes=affordable_vm_sizes
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.route("/keys", methods=("GET", "POST"))
|
@bp.route("/ssh", methods=("GET", "POST"))
|
||||||
@account_required
|
@account_required
|
||||||
def ssh_api_keys():
|
def ssh_public_keys():
|
||||||
errors = list()
|
errors = list()
|
||||||
|
|
||||||
token = None
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
|
||||||
return abort(418, f"u want tea")
|
return abort(418, f"u want tea")
|
||||||
|
|
||||||
action = request.form["action"]
|
method = request.form["method"]
|
||||||
|
content = None
|
||||||
if action == 'upload_ssh_key':
|
if method == "POST":
|
||||||
content = None
|
|
||||||
content = request.form["content"].replace("\r", " ").replace("\n", " ").strip()
|
content = request.form["content"].replace("\r", " ").replace("\n", " ").strip()
|
||||||
|
|
||||||
name = request.form["name"]
|
name = request.form["name"]
|
||||||
if not name or len(name.strip()) < 1:
|
if not name or len(name.strip()) < 1:
|
||||||
|
if method == "POST":
|
||||||
parts = re.split(" +", content)
|
parts = re.split(" +", content)
|
||||||
if len(parts) > 2 and len(parts[2].strip()) > 0:
|
if len(parts) > 2 and len(parts[2].strip()) > 0:
|
||||||
name = parts[2].strip()
|
name = parts[2].strip()
|
||||||
@ -325,9 +311,10 @@ def ssh_api_keys():
|
|||||||
name = parts[0].strip()
|
name = parts[0].strip()
|
||||||
else:
|
else:
|
||||||
errors.append("Name is required")
|
errors.append("Name is required")
|
||||||
if not re.match(r"^[0-9A-Za-z_@:. -]+$", name):
|
if not re.match(r"^[0-9A-Za-z_@:. -]+$", name):
|
||||||
errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"")
|
errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"")
|
||||||
|
|
||||||
|
if method == "POST":
|
||||||
if not content or len(content.strip()) < 1:
|
if not content or len(content.strip()) < 1:
|
||||||
errors.append("Content is required")
|
errors.append("Content is required")
|
||||||
else:
|
else:
|
||||||
@ -340,36 +327,24 @@ def ssh_api_keys():
|
|||||||
if len(errors) == 0:
|
if len(errors) == 0:
|
||||||
get_model().create_ssh_public_key(session["account"], name, content)
|
get_model().create_ssh_public_key(session["account"], name, content)
|
||||||
|
|
||||||
elif action == "delete_ssh_key":
|
elif method == "DELETE":
|
||||||
get_model().delete_ssh_public_key(session["account"], name)
|
|
||||||
|
|
||||||
elif action == "generate_api_token":
|
if len(errors) == 0:
|
||||||
name = request.form["name"]
|
get_model().delete_ssh_public_key(session["account"], name)
|
||||||
if name == '':
|
|
||||||
name = datetime.utcnow().strftime('%y-%m-%d %H:%M:%S')
|
|
||||||
token = b64encode(
|
|
||||||
get_model().generate_api_token(session["account"], name).encode('utf-8')
|
|
||||||
).decode('utf-8')
|
|
||||||
|
|
||||||
elif action == "delete_api_token":
|
|
||||||
get_model().delete_api_token(session["account"], request.form["id"])
|
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
flash(error)
|
flash(error)
|
||||||
|
|
||||||
ssh_keys_list=list(map(
|
keys_list=list(map(
|
||||||
lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"),
|
lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"),
|
||||||
get_model().list_ssh_public_keys_for_account(session["account"])
|
get_model().list_ssh_public_keys_for_account(session["account"])
|
||||||
))
|
))
|
||||||
|
|
||||||
api_tokens_list = get_model().list_api_tokens(session["account"])
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"keys.html",
|
"ssh-public-keys.html",
|
||||||
csrf_token = session["csrf-token"],
|
csrf_token = session["csrf-token"],
|
||||||
api_tokens=api_tokens_list,
|
ssh_public_keys=keys_list,
|
||||||
ssh_public_keys=ssh_keys_list,
|
has_ssh_public_keys=len(keys_list) > 0
|
||||||
generated_api_token=token,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_vms():
|
def get_vms():
|
||||||
@ -393,6 +368,7 @@ def get_vm_months_float(vm, as_of):
|
|||||||
return days / average_number_of_days_in_a_month
|
return days / average_number_of_days_in_a_month
|
||||||
|
|
||||||
def get_account_balance(vms, payments, as_of):
|
def get_account_balance(vms, payments, as_of):
|
||||||
|
|
||||||
vm_cost_dollars = 0.0
|
vm_cost_dollars = 0.0
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
vm_months = get_vm_months_float(vm, as_of)
|
vm_months = get_vm_months_float(vm, as_of)
|
||||||
@ -405,6 +381,7 @@ def get_account_balance(vms, payments, as_of):
|
|||||||
@bp.route("/account-balance")
|
@bp.route("/account-balance")
|
||||||
@account_required
|
@account_required
|
||||||
def account_balance():
|
def account_balance():
|
||||||
|
|
||||||
payment_sessions = get_model().list_payment_sessions_for_account(session['account'])
|
payment_sessions = get_model().list_payment_sessions_for_account(session['account'])
|
||||||
for payment_session in payment_sessions:
|
for payment_session in payment_sessions:
|
||||||
if payment_session['type'] == 'btcpay':
|
if payment_session['type'] == 'btcpay':
|
||||||
|
@ -33,7 +33,7 @@ def init_app(app, is_running_server):
|
|||||||
result = re.search(r"^\d+_(up|down)", filename)
|
result = re.search(r"^\d+_(up|down)", filename)
|
||||||
if not result:
|
if not result:
|
||||||
app.logger.error(f"schemaVersion {filename} must match ^\\d+_(up|down). exiting.")
|
app.logger.error(f"schemaVersion {filename} must match ^\\d+_(up|down). exiting.")
|
||||||
continue
|
exit(1)
|
||||||
key = result.group()
|
key = result.group()
|
||||||
with open(join(schemaMigrationsPath, filename), 'rb') as file:
|
with open(join(schemaMigrationsPath, filename), 'rb') as file:
|
||||||
schemaMigrations[key] = file.read().decode("utf8")
|
schemaMigrations[key] = file.read().decode("utf8")
|
||||||
@ -43,7 +43,7 @@ def init_app(app, is_running_server):
|
|||||||
hasSchemaVersionTable = False
|
hasSchemaVersionTable = False
|
||||||
actionWasTaken = False
|
actionWasTaken = False
|
||||||
schemaVersion = 0
|
schemaVersion = 0
|
||||||
desiredSchemaVersion = 19
|
desiredSchemaVersion = 18
|
||||||
|
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
|
|
||||||
@ -128,3 +128,4 @@ def close_db(e=None):
|
|||||||
if db_model is not None:
|
if db_model is not None:
|
||||||
db_model.cursor.close()
|
db_model.cursor.close()
|
||||||
current_app.config['PSYCOPG2_CONNECTION_POOL'].putconn(db_model.connection)
|
current_app.config['PSYCOPG2_CONNECTION_POOL'].putconn(db_model.connection)
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# I was never able to get this type hinting to work correctly
|
# I was never able to get this type hinting to work correctly
|
||||||
# from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor
|
# from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor
|
||||||
import hashlib
|
|
||||||
from nanoid import generate
|
from nanoid import generate
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from typing import List
|
from typing import List
|
||||||
@ -17,6 +17,7 @@ class DBModel:
|
|||||||
self.cursor = cursor
|
self.cursor = cursor
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------ LOGIN ---------
|
# ------ LOGIN ---------
|
||||||
|
|
||||||
|
|
||||||
@ -43,16 +44,6 @@ class DBModel:
|
|||||||
|
|
||||||
return (token, ignoreCaseMatches)
|
return (token, ignoreCaseMatches)
|
||||||
|
|
||||||
def authenticate_token(self, token):
|
|
||||||
m = hashlib.md5()
|
|
||||||
m.update(token.encode('utf-8'))
|
|
||||||
hash_token = m.hexdigest()
|
|
||||||
self.cursor.execute("SELECT email FROM api_tokens WHERE token = %s", (hash_token, ))
|
|
||||||
result = self.cursor.fetchall()
|
|
||||||
if len(result) == 1:
|
|
||||||
return result[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def consume_token(self, token):
|
def consume_token(self, token):
|
||||||
self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, ))
|
self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s and created > (NOW() - INTERVAL '20 min')", (token, ))
|
||||||
row = self.cursor.fetchone()
|
row = self.cursor.fetchone()
|
||||||
@ -141,32 +132,6 @@ class DBModel:
|
|||||||
self.cursor.execute( "DELETE FROM ssh_public_keys where email = %s AND name = %s", (email, name) )
|
self.cursor.execute( "DELETE FROM ssh_public_keys where email = %s AND name = %s", (email, name) )
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
|
|
||||||
def list_api_tokens(self, email):
|
|
||||||
self.cursor.execute(
|
|
||||||
"SELECT id, token, name, created FROM api_tokens WHERE email = %s",
|
|
||||||
(email, )
|
|
||||||
)
|
|
||||||
return list(map(
|
|
||||||
lambda x: dict(id=x[0], token=x[1], name=x[2], created=x[3]),
|
|
||||||
self.cursor.fetchall()
|
|
||||||
))
|
|
||||||
|
|
||||||
def generate_api_token(self, email, name):
|
|
||||||
token = generate()
|
|
||||||
m = hashlib.md5()
|
|
||||||
m.update(token.encode('utf-8'))
|
|
||||||
hash_token = m.hexdigest()
|
|
||||||
self.cursor.execute(
|
|
||||||
"INSERT INTO api_tokens (email, name, token) VALUES (%s, %s, %s)",
|
|
||||||
(email, name, hash_token)
|
|
||||||
)
|
|
||||||
self.connection.commit()
|
|
||||||
return token
|
|
||||||
|
|
||||||
def delete_api_token(self, email, id_):
|
|
||||||
self.cursor.execute( "DELETE FROM api_tokens where email = %s AND id = %s", (email, id_))
|
|
||||||
self.connection.commit()
|
|
||||||
|
|
||||||
def list_vms_for_account(self, email):
|
def list_vms_for_account(self, email):
|
||||||
self.cursor.execute("""
|
self.cursor.execute("""
|
||||||
SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month
|
SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month
|
||||||
@ -514,3 +479,8 @@ class DBModel:
|
|||||||
#cursor.close()
|
#cursor.close()
|
||||||
|
|
||||||
return to_return
|
return to_return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -215,11 +215,12 @@ class CapsulFlaskHub(VirtualizationInterface):
|
|||||||
# no need to do anything here since if it cant be parsed then generic_operation will handle it.
|
# no need to do anything here since if it cant be parsed then generic_operation will handle it.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if error_message != "":
|
||||||
|
raise ValueError(f"create capsul operation {operation_id} on {assigned_hosts} failed with {error_message}")
|
||||||
|
|
||||||
if number_of_assigned != 1:
|
if number_of_assigned != 1:
|
||||||
assigned_hosts_string = ", ".join(assigned_hosts)
|
assigned_hosts_string = ", ".join(assigned_hosts)
|
||||||
raise ValueError(f"expected create capsul operation {operation_id} to be assigned to one host, it was assigned to {number_of_assigned} ({assigned_hosts_string})")
|
raise ValueError(f"expected create capsul operation {operation_id} to be assigned to one host, it was assigned to {number_of_assigned} ({assigned_hosts_string})")
|
||||||
if error_message != "":
|
|
||||||
raise ValueError(f"create capsul operation {operation_id} on {assigned_hosts_string} failed with {error_message}")
|
|
||||||
|
|
||||||
|
|
||||||
def destroy(self, email: str, id: str):
|
def destroy(self, email: str, id: str):
|
||||||
|
@ -48,6 +48,10 @@ def validate_dollars():
|
|||||||
def btcpay_payment():
|
def btcpay_payment():
|
||||||
errors = list()
|
errors = list()
|
||||||
|
|
||||||
|
if current_app.config['BTCPAY_PRIVATE_KEY'] == "":
|
||||||
|
flash("BTCPay is not enabled on this server")
|
||||||
|
return redirect(url_for("console.account_balance"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
result = validate_dollars()
|
result = validate_dollars()
|
||||||
errors = result[0]
|
errors = result[0]
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import datetime
|
|
||||||
|
|
||||||
from flask import Blueprint
|
|
||||||
from flask import current_app
|
|
||||||
from flask import jsonify
|
|
||||||
from flask import request
|
|
||||||
from flask import session
|
|
||||||
from nanoid import generate
|
|
||||||
|
|
||||||
from capsulflask.auth import account_required
|
|
||||||
from capsulflask.db import get_model
|
|
||||||
|
|
||||||
bp = Blueprint("publicapi", __name__, url_prefix="/api")
|
|
||||||
|
|
||||||
@bp.route("/capsul/create", methods=["POST"])
|
|
||||||
@account_required
|
|
||||||
def capsul_create():
|
|
||||||
email = session["account"]
|
|
||||||
|
|
||||||
from .console import _create,get_account_balance, get_payments, get_vms
|
|
||||||
|
|
||||||
vm_sizes = get_model().vm_sizes_dict()
|
|
||||||
operating_systems = get_model().operating_systems_dict()
|
|
||||||
public_keys_for_account = get_model().list_ssh_public_keys_for_account(session["account"])
|
|
||||||
account_balance = get_account_balance(get_vms(), get_payments(), datetime.datetime.utcnow())
|
|
||||||
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024)
|
|
||||||
|
|
||||||
request.json['ssh_authorized_key_count'] = 1
|
|
||||||
|
|
||||||
id, errors = _create(
|
|
||||||
vm_sizes,
|
|
||||||
operating_systems,
|
|
||||||
public_keys_for_account,
|
|
||||||
request.json)
|
|
||||||
|
|
||||||
if id is not None:
|
|
||||||
return jsonify(
|
|
||||||
id=id,
|
|
||||||
)
|
|
||||||
return jsonify(errors=errors)
|
|
@ -1,2 +0,0 @@
|
|||||||
DROP TABLE api_keys;
|
|
||||||
UPDATE schemaversion SET version = 18;
|
|
@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE api_tokens (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
token TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
UPDATE schemaversion SET version = 19;
|
|
@ -3,7 +3,7 @@
|
|||||||
# check available RAM and IPv4s
|
# check available RAM and IPv4s
|
||||||
|
|
||||||
ram_bytes_to_allocate="$1"
|
ram_bytes_to_allocate="$1"
|
||||||
ram_bytes_available=$(grep -E "^(size|memory_available_bytes)" /proc/spl/kstat/zfs/arcstats | awk '{sum+=$3} END {printf "%.0f", sum}')
|
ram_bytes_available="$(($(grep Available /proc/meminfo | grep -o '[0-9]*') * 1024))"
|
||||||
ram_bytes_remainder="$((ram_bytes_available - ram_bytes_to_allocate))"
|
ram_bytes_remainder="$((ram_bytes_available - ram_bytes_to_allocate))"
|
||||||
|
|
||||||
if echo "$ram_bytes_to_allocate" | grep -vqE "^[0-9]+$"; then
|
if echo "$ram_bytes_to_allocate" | grep -vqE "^[0-9]+$"; then
|
||||||
@ -11,8 +11,8 @@ if echo "$ram_bytes_to_allocate" | grep -vqE "^[0-9]+$"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 20GB
|
# 0.25GB
|
||||||
if [ "$ram_bytes_remainder" -le $((20 * 1024 * 1024 * 1024)) ]; then
|
if [ "$ram_bytes_remainder" -le $((1 * 1024 * 1024 * 1024 / 4)) ]; then
|
||||||
echo "VM is requesting more RAM than $(hostname -f) has available."
|
echo "VM is requesting more RAM than $(hostname -f) has available."
|
||||||
echo "Bytes requested: $ram_bytes_to_allocate"
|
echo "Bytes requested: $ram_bytes_to_allocate"
|
||||||
echo "Bytes available: $ram_bytes_available"
|
echo "Bytes available: $ram_bytes_available"
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
vmname="$1"
|
vmname="$1"
|
||||||
template_file="/tank/img/$2"
|
template_file="/tank/img/$2"
|
||||||
|
qemu_tank_dir="/tank"
|
||||||
vcpus="$3"
|
vcpus="$3"
|
||||||
memory="$4"
|
memory="$4"
|
||||||
pubkeys="$5"
|
pubkeys="$5"
|
||||||
@ -50,40 +51,40 @@ if echo "$public_ipv4" | grep -vqE "^[0-9.]+$"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
disk="/tank/vm/$vmname.qcow2"
|
disk="$vmname.qcow2"
|
||||||
cdrom="/tank/vm/$vmname.iso"
|
cdrom="$vmname.iso"
|
||||||
xml="/tank/vm/$vmname.xml"
|
xml="$vmname.xml"
|
||||||
|
|
||||||
if [ -f /tank/vm/$vmname.qcow2 ]; then
|
if [ -f /tank/vm/$vmname.qcow2 ]; then
|
||||||
echo "Randomly generated name matched an existing VM! Odds are like one in a billion. Buy a lotto ticket."
|
echo "Randomly generated name matched an existing VM! Odds are like one in a billion. Buy a lotto ticket."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cp "$template_file" "$disk"
|
cp "$template_file" "/tank/vm/$disk"
|
||||||
cp /tank/config/cyberia-cloudinit.yml /tmp/cloudinit.yml
|
cp /tank/config/cyberia-cloudinit.yml /tmp/cloudinit.yml
|
||||||
echo "$pubkeys" | while IFS= read -r line; do
|
echo "$pubkeys" | while IFS= read -r line; do
|
||||||
echo " - $line" >> /tmp/cloudinit.yml
|
echo " - $line" >> /tmp/cloudinit.yml
|
||||||
done
|
done
|
||||||
|
|
||||||
cloud-localds "$cdrom" /tmp/cloudinit.yml
|
cloud-localds "/tank/vm/$cdrom" /tmp/cloudinit.yml
|
||||||
|
|
||||||
qemu-img resize "$disk" "$root_volume_size"
|
qemu-img resize "/tank/vm/$disk" "$root_volume_size"
|
||||||
virt-install \
|
virt-install \
|
||||||
--memory "$memory" \
|
--memory "$memory" \
|
||||||
--vcpus "$vcpus" \
|
--vcpus "$vcpus" \
|
||||||
--name "$vmname" \
|
--name "$vmname" \
|
||||||
--disk "$disk",bus=virtio \
|
--disk "$qemu_tank_dir/vm/$disk",bus=virtio \
|
||||||
--disk "$cdrom",device=cdrom \
|
--disk "$qemu_tank_dir/vm/$cdrom",device=cdrom \
|
||||||
--os-type Linux \
|
--os-type Linux \
|
||||||
--os-variant generic \
|
--os-variant generic \
|
||||||
--virt-type kvm \
|
--virt-type kvm \
|
||||||
--graphics vnc,listen=127.0.0.1 \
|
--graphics vnc,listen=127.0.0.1 \
|
||||||
--network network=$network_name,filterref=clean-traffic,model=virtio \
|
--network network=$network_name,model=virtio \
|
||||||
--import \
|
--import \
|
||||||
--print-xml > "$xml"
|
--print-xml > "/tank/vm/$xml"
|
||||||
|
|
||||||
chmod 0600 "$xml" "$disk" "$cdrom"
|
chmod 0600 "/tank/vm/$xml" "/tank/vm/$disk" "/tank/vm/$cdrom"
|
||||||
virsh define "$xml"
|
virsh define "/tank/vm/$xml"
|
||||||
virsh start "$vmname"
|
virsh start "$vmname"
|
||||||
|
|
||||||
echo "success"
|
echo "success"
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.2 KiB |
@ -46,7 +46,9 @@
|
|||||||
<a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
|
<a href="/payment/stripe">Add funds with Credit/Debit (stripe)</a>
|
||||||
<ul><li>notice: stripe will load nonfree javascript </li></ul>
|
<ul><li>notice: stripe will load nonfree javascript </li></ul>
|
||||||
</li>
|
</li>
|
||||||
|
{% if config['BTCPAY_PRIVATE_KEY'] != "" %}
|
||||||
<li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
|
<li><a href="/payment/btcpay">Add funds with Bitcoin/Litecoin/Monero (btcpay)</a></li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<li>Cash: email <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li>
|
<li>Cash: email <a href="mailto:treasurer@cyberia.club">treasurer@cyberia.club</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<div class="row justify-space-between half-margin">
|
<div class="row justify-space-between half-margin">
|
||||||
<div>
|
<div>
|
||||||
<a href="/"><b>Capsul</b></a>💊
|
🦉 <a href="/"><b>YOLOCOLO</b></a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
@ -27,11 +27,10 @@
|
|||||||
<div class="row justify-center half-margin wrap nav-links">
|
<div class="row justify-center half-margin wrap nav-links">
|
||||||
<a href="/pricing">Pricing</a>
|
<a href="/pricing">Pricing</a>
|
||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
<a href="/changelog">Changelog</a>
|
|
||||||
|
|
||||||
{% if session["account"] %}
|
{% if session["account"] %}
|
||||||
<a href="/console">Capsuls</a>
|
<a href="/console">Capsuls</a>
|
||||||
<a href="/console/keys">SSH & API Keys</a>
|
<a href="/console/ssh">SSH Public Keys</a>
|
||||||
<a href="/console/account-balance">Account Balance</a>
|
<a href="/console/account-balance">Account Balance</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -47,11 +46,12 @@
|
|||||||
</main>
|
</main>
|
||||||
{% block subcontent %}{% endblock %}
|
{% block subcontent %}{% endblock %}
|
||||||
<footer>
|
<footer>
|
||||||
(c) Attribution-ShareAlike 4.0 International <br/>
|
This server runs <a
|
||||||
A service by Cyberia Computer Club 2020-<span class="bigtext">∞</span> <br/>
|
href="https://giit.cyberia.club/~forest/capsul-flask">capsul-flask</a> by
|
||||||
<br/>
|
Cyberia Computer Club, available under the <a
|
||||||
<br/>
|
href="https://creativecommons.org/licenses/by-sa/4.0/">Attribution-ShareAlike
|
||||||
<a href="https://giit.cyberia.club/~forest/capsul-flask/tree/master/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
|
4.0 International</a> licence.<br/><br/>
|
||||||
|
<a href="https://git.autonomic.zone/3wordchant/capsul-flask/src/branch/yolocolo/capsulflask{% block pagesource %}{% endblock %}">View page source</a>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row justify-start">
|
<div class="row justify-start">
|
||||||
<label class="align" for="ssh_authorized_keys">SSH Authorized Keys</label>
|
<label class="align" for="ssh_authorized_keys">SSH Authorized Keys</label>
|
||||||
<a id="ssh_authorized_keys" href="/console/keys">{{ vm['ssh_authorized_keys'] }}</a>
|
<a id="ssh_authorized_keys" href="/console/ssh">{{ vm['ssh_authorized_keys'] }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
<p>(At least one month of funding is required)</p>
|
<p>(At least one month of funding is required)</p>
|
||||||
{% elif no_ssh_public_keys %}
|
{% elif no_ssh_public_keys %}
|
||||||
<p>You don't have any ssh public keys yet.</p>
|
<p>You don't have any ssh public keys yet.</p>
|
||||||
<p>You must <a href="/console/keys">upload one</a> before you can create a Capsul.</p>
|
<p>You must <a href="/console/ssh">upload one</a> before you can create a Capsul.</p>
|
||||||
{% elif not capacity_avaliable %}
|
{% elif not capacity_avaliable %}
|
||||||
<p>Host(s) at capacity. No capsuls can be created at this time. sorry. </p>
|
<p>Host(s) at capacity. No capsuls can be created at this time. sorry. </p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -10,81 +10,32 @@
|
|||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Which instance type should I buy?
|
What is this?
|
||||||
<p>There are no hard rules for this sort of thing, but here are some guidelines:</p>
|
<p>
|
||||||
<p>f1-xs: blog, vpn, bot, cgit</p>
|
This is a <strong>technical demo</strong> of <a
|
||||||
<p>f1-s: a bot, owncloud, gitea, popular blog</p>
|
href="https://giit.cyberia.club/~forest/capsul-flask">Capsul</a>, for the
|
||||||
<p>f1-m: docker host, build system</p>
|
as-yet-untitled <a href="https://coops.tech">Cotech</a> server hosting
|
||||||
<p>f1-l: large webservice, rotund java app</p>
|
initiative, which you can <a
|
||||||
<p>f1-x: gitlab (wow such memory very devops</p>
|
href="https://community.coops.tech/t/call-for-input-v2-co-op-vps-survey/2802/9">read
|
||||||
<p>f1-xx: something gargantuan</p>
|
about on the Cotech forum</a>.
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
How do I log in?
|
What do you mean, "technical demo"?
|
||||||
<p>ssh to the ip provided to you using the cyberian user.</p>
|
<p>No backups</p>
|
||||||
<pre class='code'>$ ssh cyberian@1.2.3.4</pre>
|
<p>No service level agreement</p>
|
||||||
|
<p>"Best effort" support</p>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
How do I change to the root user?
|
Where can I get this, but, more reliable?
|
||||||
<p>The cyberian user has passwordless sudo access by default. This should work:</p>
|
<p>Cyberia, the authors of this platform, run the canonical instance, <a
|
||||||
<pre class='code'>
|
href="https://capsul.org">Capsul.org</a>, on hardware they own. Please
|
||||||
# Linux
|
send them your money! (cash, crypto, or card accepted).</p>
|
||||||
$ sudo su -
|
|
||||||
|
|
||||||
# OpenBSD
|
|
||||||
$ doas su -</pre>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Do you offer reverse DNS?
|
How do I use this system?
|
||||||
<p>We do, but right now it's a manual process. Shoot us an email and we'll get it done.</p>
|
<p>Please see <a href="https://capsul.org/faq">the official Capsul FAQ
|
||||||
</li>
|
page</a>.</p>
|
||||||
<li>
|
|
||||||
What if I don't pay / don't maintain my payments?
|
|
||||||
<p>Your VM will eventually be deleted.
|
|
||||||
Capsul will send you a few inoffensive reminders as that termination date approaches.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Besides my virtual machines and payments, what information do you keep about me?
|
|
||||||
<p>We associate an email address with every VM so that we can track payment and respond to support requests.</p>
|
|
||||||
<p>If you pay with a credit card, Stripe stores some additional details about you that we literally cannot delete.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
What can I do with my VM?
|
|
||||||
<p>Make it into a mailserver, a tor relay, a VPN host, whatever you'd like - we do have one small request, though.</p>
|
|
||||||
<p>Crypto mining on capsul is currently considered obnoxious behavior, because the hashrates on our CPUs is so low and because mining crypto consumes entire processor cores that could have otherwise been shared between many dozens of other users.</p>
|
|
||||||
<p>In the future, if we have plentiful CPU resources, we may come out with a tier more suitable for mining - maybe a high cpu tier or similar, where each VM gets a full dedicated core and sharing them is not anticipated.</p>
|
|
||||||
<p>We will never snoop on your traffic or inspect what's going on inside of our customer virtual machines - we don't want to. We hope that you'll extend us a similar courtesy and try not to use too much of our shared CPU resources. Capsul is currently a shared (resource-wise) world, and we all must live in it together!</p>
|
|
||||||
<p>Also, mandatory: our systems exist within the USA, and as such those systems are bound by US law.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Can you recover my passwords/insert new keys?
|
|
||||||
<p>Can we? Technically yes. Will we? No, never. It would violate the trust that our users have in us.
|
|
||||||
We have no interest in touching client VMs after they're running.
|
|
||||||
We promise to keep your machines running smoothly.
|
|
||||||
If you lose access to your VM, that's on you.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Do you offer refunds?
|
|
||||||
<p>Not now, but email us and we can probably figure something out.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Where do the VMs run? Is it on a machine that you guys own/control?
|
|
||||||
<p>Capsul runs on a server named Baikal which Cyberia built from scratch & mailed to a datacenter
|
|
||||||
in Georgia called CyberWurx. CyberWurx staff installed it for us in a rack space that
|
|
||||||
Cyberia pays for. </p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Do you offer support?
|
|
||||||
<p>Yep, see <a href="/support">our support page</a>.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Do you have an SLA?
|
|
||||||
<p>No, but we normally respond pretty quickly.</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Will you implement feature X?
|
|
||||||
<p>Maybe! Email <a href="mailto:ops@cyberia.club">ops@cyberia.club</a> and ask us about it.</p>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,31 +1,26 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>CAPSUL</h1>
|
<h1>
|
||||||
<pre>
|
<pre>
|
||||||
.-.
|
_ _
|
||||||
/:::\
|
_ _ ___ | | ___ ___ ___ | | ___
|
||||||
/::::/
|
| | | |/ _ \| |/ _ \ / __/ _ \| |/ _ \
|
||||||
/ `-:/
|
| |_| | (_) | | (_) | (_| (_) | | (_) |
|
||||||
/ /
|
\__, |\___/|_|\___/ \___\___/|_|\___/
|
||||||
\ /
|
|___/
|
||||||
`"`
|
|
||||||
</pre>
|
</pre>
|
||||||
<span>Simple, fast, private compute by <a href="https://cyberia.club">cyberia.club</a></span>
|
<span>Co-operative hosting using <a href="https://cyberia.club">Cyberia</a>'s Capsul</span>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subcontent %}
|
{% block subcontent %}
|
||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Low friction: simply log in with your email address and fund your account with Credit/Debit or Cryptocurrency</li>
|
<li>Sign up for an account!</li>
|
||||||
<li>All root disks are backed up at no charge</li>
|
<li>Add some funds!</li>
|
||||||
<li>All storage is fast, local, and solid-state</li>
|
<li>Create a VPS!</li>
|
||||||
<li>All network connections are low latency</li>
|
<li>Give your feedback!</li>
|
||||||
<li>Supported by amazing volunteers from Cyberia</li>
|
|
||||||
<li>Upfront prices, no confusing billing</li>
|
|
||||||
<li>Operated by a Minnesota non-profit organization that will never exploit you</li>
|
|
||||||
<li>We donate a portion of our proceeds to likeminded hacker groups around the globe</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -7,26 +7,17 @@
|
|||||||
<h1>CAPSUL TYPES & PRICING</h1>
|
<h1>CAPSUL TYPES & PRICING</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="row half-margin">
|
<div class="row half-margin">
|
||||||
|
<p>
|
||||||
|
Rates for this service isn't set yet. You can see Cyberia's Capsul pricing
|
||||||
|
on <a href="https://capsul.org/pricing">their website</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<pre>
|
<pre>
|
||||||
type monthly* cpus mem ssd net*
|
SUPPORTED OPERATING SYSTEMS:
|
||||||
----- ------- ---- --- --- ---
|
|
||||||
f1-xs $5.00 1 512M 25G .5TB
|
|
||||||
f1-s $7.50 1 1024M 25G 1TB
|
|
||||||
f1-m $12.50 1 2048M 25G 2TB
|
|
||||||
f1-l $20.00 2 3072M 25G 3TB
|
|
||||||
f1-x $27.50 3 4096M 25G 4TB
|
|
||||||
f1-xx $50.00 4 8192M 25G 5TB
|
|
||||||
|
|
||||||
* net is calculated as a per-month average
|
{% for os_id, os in operating_systems.items() %} - {{ os.description }}
|
||||||
* vms are billed for a minimum of 24 hours upon creation
|
{% endfor %}
|
||||||
* all VMs come standard with one public IPv4 address
|
</pre>
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED OPERATING SYSTEMS:
|
|
||||||
|
|
||||||
|
|
||||||
{% for os_id, os in operating_systems.items() %} - {{ os.description }}
|
|
||||||
{% endfor %}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}SSH & API Keys{% endblock %}
|
{% block title %}SSH Public Keys{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row third-margin">
|
<div class="row third-margin">
|
||||||
<h1>SSH PUBLIC KEYS</h1>
|
<h1>SSH PUBLIC KEYS</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="row third-margin"><div>
|
<div class="row third-margin"><div>
|
||||||
{% if ssh_public_keys|length > 0 %} <hr/> {% endif %}
|
{% if has_ssh_public_keys %} <hr/> {% endif %}
|
||||||
|
|
||||||
{% for ssh_public_key in ssh_public_keys %}
|
{% for ssh_public_key in ssh_public_keys %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="method" value="DELETE"></input>
|
<input type="hidden" name="method" value="DELETE"></input>
|
||||||
<input type="hidden" name="action" value="delete_ssh_key"></input>
|
|
||||||
<input type="hidden" name="name" value="{{ ssh_public_key['name'] }}"></input>
|
<input type="hidden" name="name" value="{{ ssh_public_key['name'] }}"></input>
|
||||||
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -23,14 +22,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if ssh_public_keys|length > 0 %} <hr/> {% endif %}
|
{% if has_ssh_public_keys %} <hr/> {% endif %}
|
||||||
|
|
||||||
<div class="third-margin">
|
<div class="third-margin">
|
||||||
<h1>UPLOAD A NEW SSH PUBLIC KEY</h1>
|
<h1>UPLOAD A NEW SSH PUBLIC KEY</h1>
|
||||||
</div>
|
</div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="method" value="POST"></input>
|
<input type="hidden" name="method" value="POST"></input>
|
||||||
<input type="hidden" name="action" value="upload_ssh_key"></input>
|
|
||||||
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
||||||
<div class="row justify-start">
|
<div class="row justify-start">
|
||||||
<label class="align" for="content">File Contents</label>
|
<label class="align" for="content">File Contents</label>
|
||||||
@ -56,51 +54,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
<hr/>
|
|
||||||
<div class="row third-margin">
|
|
||||||
<h1>API KEYS</h1>
|
|
||||||
</div>
|
|
||||||
<div class="row third-margin"><div>
|
|
||||||
{% if generated_api_token %}
|
|
||||||
<hr/>
|
|
||||||
Generated key:
|
|
||||||
<span class="code">{{ generated_api_token }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if api_tokens|length >0 %} <hr/>{% endif %}
|
|
||||||
{% for api_token in api_tokens %}
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="method" value="DELETE"></input>
|
|
||||||
<input type="hidden" name="action" value="delete_api_token"></input>
|
|
||||||
<input type="hidden" name="id" value="{{ api_token['id'] }}"></input>
|
|
||||||
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
|
||||||
<div class="row">
|
|
||||||
<span class="code">{{ api_token['name'] }}</span>
|
|
||||||
created {{ api_token['created'].strftime("%b %d %Y") }}
|
|
||||||
<input type="submit" value="Delete">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
|
||||||
{% if api_tokens|length >0 %} <hr/>{% endif %}
|
|
||||||
|
|
||||||
<div class="third-margin">
|
|
||||||
<h1>GENERATE A NEW API KEY</h1>
|
|
||||||
</div>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="method" value="POST"></input>
|
|
||||||
<input type="hidden" name="action" value="generate_api_token"></input>
|
|
||||||
<input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>
|
|
||||||
<div class="smalltext">
|
|
||||||
<p>Generate a new API key, to integrate with other systems.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row justify-start">
|
|
||||||
<label class="align" for="name">Key Name</label>
|
|
||||||
<input type="text" id="name" name="name"></input> (defaults to creation time)
|
|
||||||
</div>
|
|
||||||
<div class="row justify-end">
|
|
||||||
<input type="submit" value="Generate">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div></div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block pagesource %}/templates/ssh-public-keys.html{% endblock %}
|
{% block pagesource %}/templates/ssh-public-keys.html{% endblock %}
|
@ -7,20 +7,14 @@
|
|||||||
<h1>SUPPORT</h1>
|
<h1>SUPPORT</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="row half-margin">
|
<div class="row half-margin">
|
||||||
<a href="mailto:support@cyberia.club?subject=Please%20help!">support@cyberia.club</a>
|
<a href="mailto:yolocolo@doesthisthing.work?subject=Please%20help!">yolocolo@doesthisthing.work</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subcontent %}
|
{% block subcontent %}
|
||||||
<p>
|
<p>
|
||||||
Note: We maintain a searchable archive of all support emails at
|
You can also find us on Matrix: <a
|
||||||
<a href="https://lists.cyberia.club/~cyberia/support">https://lists.cyberia.club/~cyberia/support</a>
|
href="https://matrix.to/#/#untitled-hosting.public:autonomic.zone">#untitled-hosting.public:autonomic.zone</a>.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you do not want your mail to appear in a public archive, email <a href="mailto:capsul@cyberia.club?subject=Please%20help!">capsul@cyberia.club</a> instead.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Please describe your problem or feature request, and we will do our best to get back to you promptly. Thank you very much.
|
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
from flask import url_for, session
|
|
||||||
|
|
||||||
from capsulflask.db import get_model
|
|
||||||
from capsulflask.tests_base import BaseTestCase
|
|
||||||
|
|
||||||
|
|
||||||
class LoginTests(BaseTestCase):
|
|
||||||
render_templates = False
|
|
||||||
|
|
||||||
def test_login_request(self):
|
|
||||||
with self.client as client:
|
|
||||||
response = client.get(url_for("auth.login"))
|
|
||||||
self.assert_200(response)
|
|
||||||
|
|
||||||
# FIXME test generated login link
|
|
||||||
|
|
||||||
def test_login_magiclink(self):
|
|
||||||
token, ignoreCaseMatches = get_model().login('test@example.com')
|
|
||||||
|
|
||||||
with self.client as client:
|
|
||||||
response = client.get(url_for("auth.magiclink", token=token))
|
|
||||||
self.assertRedirects(response, url_for("console.index"))
|
|
||||||
self.assertEqual(session['account'], 'test@example.com')
|
|
@ -1,170 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
|
|
||||||
from capsulflask.hub_model import MockHub
|
|
||||||
from capsulflask.db import get_model
|
|
||||||
from capsulflask.tests_base import BaseTestCase
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleTests(BaseTestCase):
|
|
||||||
capsul_data = {
|
|
||||||
"size": "f1-xs",
|
|
||||||
"os": "debian10",
|
|
||||||
"ssh_authorized_key_count": 1,
|
|
||||||
"ssh_key_0": "key"
|
|
||||||
}
|
|
||||||
|
|
||||||
ssh_key_data = {
|
|
||||||
"name": "key2",
|
|
||||||
"action": "upload_ssh_key",
|
|
||||||
"content": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDntq1t8Ddsa2q4p+PM7W4CLYYmxakokRRVLlf7AQlsTJFPsgBe9u0zuoOaKDMkBr0dlnuLm4Eub1Mj+BrdqAokto0YDiAnxUKRuYQKuHySKK8bLkisi2k47jGBDikx/jihgiuFTawo1mYsJJepC7PPwZGsoCImJEgq1L+ug0p3Zrj3QkUx4h25MpCSs2yvfgWjDyN8hEC76O42P+4ETezYrzrd1Kj26hdzHRnrxygvIUOtfau+5ydlaz8xQBEPrEY6/+pKDuwtXg1pBL7GmoUxBXVfHQSgq5s9jIJH+G0CR0ZoHMB25Ln4X/bsCQbLOu21+IGYKSDVM5TIMLtkKUkERQMVWvnpOp1LZKir4dC0m7SW74wpA8+2b1IsURIr9ARYGJpCEv1Q1Wz/X3yTf6Mfey7992MjUc9HcgjgU01/+kYomoXHprzolk+22Gjfgo3a4dRIoTY82GO8kkUKiaWHvDkkVURCY5dpteLA05sk3Z9aRMYsNXPLeOOPfzTlDA0="
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_index(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
with self.client as client:
|
|
||||||
response = client.get(url_for("console.index"))
|
|
||||||
self.assert_200(response)
|
|
||||||
|
|
||||||
def test_create_loads(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
with self.client as client:
|
|
||||||
response = client.get(url_for("console.create"))
|
|
||||||
self.assert_200(response)
|
|
||||||
|
|
||||||
def test_create_fails_capacity(self):
|
|
||||||
with self.client as client:
|
|
||||||
client.get(url_for("console.create"))
|
|
||||||
csrf_token = self.get_context_variable('csrf_token')
|
|
||||||
|
|
||||||
data = self.capsul_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
|
|
||||||
# Override MockHub.capacity_avaliable to always return False
|
|
||||||
with patch.object(MockHub, 'capacity_avaliable', return_value=False) as mock_method:
|
|
||||||
self.app.config["HUB_MODEL"] = MockHub()
|
|
||||||
|
|
||||||
client.post(url_for("console.create"), data=data)
|
|
||||||
capacity_message = \
|
|
||||||
'\n host(s) at capacity. no capsuls can be created at this time. sorry. \n '
|
|
||||||
self.assert_message_flashed(capacity_message, category='message')
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
len(get_model().list_vms_for_account('test@example.com')),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
mock_method.assert_called_with(512 * 1024 * 1024)
|
|
||||||
|
|
||||||
def test_create_fails_invalid(self):
|
|
||||||
with self.client as client:
|
|
||||||
client.get(url_for("console.create"))
|
|
||||||
csrf_token = self.get_context_variable('csrf_token')
|
|
||||||
|
|
||||||
data = self.capsul_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
data['os'] = ''
|
|
||||||
client.post(url_for("console.create"), data=data)
|
|
||||||
|
|
||||||
self.assert_message_flashed(
|
|
||||||
'OS is required',
|
|
||||||
category='message'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
len(get_model().list_vms_for_account('test@example.com')),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_succeeds(self):
|
|
||||||
with self.client as client:
|
|
||||||
client.get(url_for("console.create"))
|
|
||||||
csrf_token = self.get_context_variable('csrf_token')
|
|
||||||
|
|
||||||
data = self.capsul_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
response = client.post(url_for("console.create"), data=data)
|
|
||||||
|
|
||||||
# FIXME: mock create doesn't create, see #83
|
|
||||||
# vms = get_model().list_vms_for_account('test@example.com')
|
|
||||||
# self.assertEqual(
|
|
||||||
# len(vms),
|
|
||||||
# 1
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# vm_id = vms[0].id
|
|
||||||
#
|
|
||||||
# self.assertRedirects(
|
|
||||||
# response,
|
|
||||||
# url_for("console.index") + f'?{vm_id}'
|
|
||||||
# )
|
|
||||||
|
|
||||||
def test_keys_loads(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
with self.client as client:
|
|
||||||
response = client.get(url_for("console.ssh_api_keys"))
|
|
||||||
self.assert_200(response)
|
|
||||||
keys = self.get_context_variable('ssh_public_keys')
|
|
||||||
self.assertEqual(keys[0]['name'], 'key')
|
|
||||||
|
|
||||||
def test_keys_add_ssh_fails_invalid(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
with self.client as client:
|
|
||||||
client.get(url_for("console.ssh_api_keys"))
|
|
||||||
csrf_token = self.get_context_variable('csrf_token')
|
|
||||||
|
|
||||||
data = self.ssh_key_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
|
|
||||||
data_invalid_content = data
|
|
||||||
data_invalid_content['content'] = 'foo'
|
|
||||||
client.post(
|
|
||||||
url_for("console.ssh_api_keys"),
|
|
||||||
data=data_invalid_content
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assert_message_flashed(
|
|
||||||
'Content must match "^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$"',
|
|
||||||
category='message'
|
|
||||||
)
|
|
||||||
|
|
||||||
data_missing_content = data
|
|
||||||
data_missing_content['content'] = ''
|
|
||||||
client.post(url_for("console.ssh_api_keys"), data=data_missing_content)
|
|
||||||
|
|
||||||
self.assert_message_flashed(
|
|
||||||
'Content is required', category='message'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_keys_add_ssh_fails_duplicate(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
with self.client as client:
|
|
||||||
client.get(url_for("console.ssh_api_keys"))
|
|
||||||
csrf_token = self.get_context_variable('csrf_token')
|
|
||||||
|
|
||||||
data = self.ssh_key_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
data['name'] = 'key'
|
|
||||||
client.post(url_for("console.ssh_api_keys"), data=data)
|
|
||||||
|
|
||||||
self.assert_message_flashed(
|
|
||||||
'A key with that name already exists',
|
|
||||||
category='message'
|
|
||||||
)
|
|
||||||
|
|
||||||
data = self.ssh_key_data
|
|
||||||
data['csrf-token'] = csrf_token
|
|
||||||
data['name'] = 'key'
|
|
||||||
client.post(url_for("console.ssh_api_keys"), data=data)
|
|
||||||
|
|
||||||
self.assert_message_flashed(
|
|
||||||
'A key with that name already exists',
|
|
||||||
category='message'
|
|
||||||
)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._login('test@example.com')
|
|
||||||
get_model().create_ssh_public_key('test@example.com', 'key', 'foo')
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
get_model().delete_ssh_public_key('test@example.com', 'key')
|
|
@ -1,14 +0,0 @@
|
|||||||
from capsulflask.tests_base import BaseTestCase
|
|
||||||
|
|
||||||
|
|
||||||
class LandingTests(BaseTestCase):
|
|
||||||
#: Do not render templates, we're only testing logic here.
|
|
||||||
render_templates = False
|
|
||||||
|
|
||||||
def test_landing(self):
|
|
||||||
pages = ['/', 'pricing', 'faq', 'about-ssh', 'changelog', 'support']
|
|
||||||
|
|
||||||
with self.client as client:
|
|
||||||
for page in pages:
|
|
||||||
response = client.get(page)
|
|
||||||
self.assert_200(response)
|
|
@ -1,45 +0,0 @@
|
|||||||
from base64 import b64encode
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
|
|
||||||
from capsulflask.db import get_model
|
|
||||||
from capsulflask.tests_base import BaseLiveServerTestCase
|
|
||||||
|
|
||||||
|
|
||||||
class PublicAPITests(BaseLiveServerTestCase):
|
|
||||||
def test_server_is_up_and_running(self):
|
|
||||||
response = requests.get(self.get_server_url())
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_capsul_create_succeeds(self):
|
|
||||||
response = requests.post(
|
|
||||||
self.get_server_url() +
|
|
||||||
url_for('publicapi.capsul_create'),
|
|
||||||
headers={'Authorization': self.token},
|
|
||||||
json={
|
|
||||||
'size': 'f1-xs',
|
|
||||||
'os': 'openbsd68',
|
|
||||||
'ssh_key_0': 'key'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# FIXME: mock create doesn't create, see #83
|
|
||||||
# vms = get_model().list_vms_for_account('test@example.com')
|
|
||||||
#
|
|
||||||
# self.assertEqual(
|
|
||||||
# len(vms),
|
|
||||||
# 1
|
|
||||||
# )
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
get_model().create_ssh_public_key('test@example.com', 'key', 'foo')
|
|
||||||
self.token = b64encode(
|
|
||||||
get_model().generate_api_token('test@example.com', 'apikey').encode('utf-8')
|
|
||||||
).decode('utf-8')
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
get_model().delete_ssh_public_key('test@example.com', 'key')
|
|
||||||
get_model().delete_api_token('test@example.com', 1)
|
|
@ -1,35 +0,0 @@
|
|||||||
import os
|
|
||||||
from nanoid import generate
|
|
||||||
|
|
||||||
from flask_testing import TestCase, LiveServerTestCase
|
|
||||||
|
|
||||||
from capsulflask import create_app
|
|
||||||
from capsulflask.db import get_model
|
|
||||||
|
|
||||||
|
|
||||||
class BaseSharedTestCase(object):
|
|
||||||
def create_app(self):
|
|
||||||
# Use default connection paramaters
|
|
||||||
os.environ['POSTGRES_CONNECTION_PARAMETERS'] = "host=localhost port=5432 user=postgres password=dev dbname=capsulflask_test"
|
|
||||||
os.environ['TESTING'] = '1'
|
|
||||||
os.environ['SPOKE_MODEL'] = 'mock'
|
|
||||||
os.environ['HUB_MODEL'] = 'mock'
|
|
||||||
return create_app()
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BaseTestCase(BaseSharedTestCase, TestCase):
|
|
||||||
def _login(self, user_email):
|
|
||||||
get_model().login(user_email)
|
|
||||||
with self.client.session_transaction() as session:
|
|
||||||
session['account'] = user_email
|
|
||||||
session['csrf-token'] = generate()
|
|
||||||
|
|
||||||
|
|
||||||
class BaseLiveServerTestCase(BaseSharedTestCase, LiveServerTestCase):
|
|
||||||
pass
|
|
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: 3wordchant/capsul-flask:latest
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- "./:/app/code"
|
||||||
|
- "../tank:/tank"
|
||||||
|
- "/var/run/libvirt/libvirt-sock:/var/run/libvirt/libvirt-sock"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- "POSTGRES_CONNECTION_PARAMETERS=host=db port=5432 user=capsul password=capsul dbname=capsul"
|
||||||
|
- SPOKE_MODEL=shell-scripts
|
||||||
|
#- FLASK_DEBUG=1
|
||||||
|
- BASE_URL=http://localhost:5000
|
||||||
|
- ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES=3wc.capsul@doesthisthing.work
|
||||||
|
- VIRSH_DEFAULT_CONNECT_URI=qemu:///system
|
||||||
|
# The image uses gunicorn by default, let's override it with Flask's
|
||||||
|
# built-in development server
|
||||||
|
command: ["flask", "run", "-h", "0.0.0.0", "-p", "5000"]
|
||||||
|
devices:
|
||||||
|
- "/dev/kvm:/dev/kvm"
|
||||||
|
db:
|
||||||
|
image: "postgres:9.6.5-alpine"
|
||||||
|
volumes:
|
||||||
|
- "postgres:/var/lib/postgresql/data"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: capsul
|
||||||
|
POSTGRES_PASSWORD: capsul
|
||||||
|
POSTGRES_DB: capsul
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres:
|
Reference in New Issue
Block a user