Skip to content

Commit d0253af

Browse files
Merge pull request #385 from SelfhostedPro/develop
# Yacht Alpha v0.0.7 Released! ## Notable Changes: * [Shipwright](https://shipwright.yacht.sh) a new template builder is released (pre-alpha) * Yacht is now a PWA and if published with SSL you'll be able to install it on most devices for easy access. * API Key framework so now applications can interact with Yacht directly (found in user settings when auth is enabled). * Changed logs and stats to Server Sent Events so websocket support is no longer needed *\*this may change in the future as I believe it will be needed for container CLI access* * Redesigned the look of all the main pages * Support for command, memory limits, and cpu in templates and deployments. ## Bugfixes: * Better error handling for Projects. * Issue where ports were defined more than once * Data in dashboards being off * Various UI glitches * Other various fixes (view merge request for a full list)
2 parents 7e390a8 + aad1783 commit d0253af

File tree

123 files changed

+4103
-1142
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

123 files changed

+4103
-1142
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,13 @@ jobs:
5151
platforms: linux/amd64,linux/arm64,linux/arm
5252
push: true
5353
build-args: |
54-
VUE_APP_VERSION=v0.0.6-alpha-${{ steps.current-time.outputs.formattedTime }}
54+
VUE_APP_VERSION=v0.0.7-alpha-${{ steps.current-time.outputs.formattedTime }}
5555
tags: |
5656
selfhostedpro/yacht
5757
selfhostedpro/yacht:latest-${{ steps.current-time.outputs.formattedTime }}
58+
selfhostedpro/yacht:v0.0.7-alpha-${{ steps.current-time.outputs.formattedTime }}
5859
ghcr.io/selfhostedpro/yacht
5960
ghcr.io/selfhostedpro/yacht:latest-${{ steps.current-time.outputs.formattedTime }}
60-
selfhostedpro/yacht:v0.0.6-alpha-${{ steps.current-time.outputs.formattedTime }}
61-
ghcr.io/selfhostedpro/yacht
62-
ghcr.io/selfhostedpro/yacht:latest-${{ steps.current-time.outputs.formattedTime }}
63-
ghcr.io/selfhostedpro/yacht:v0.0.6-alpha-${{ steps.current-time.outputs.formattedTime }}
61+
ghcr.io/selfhostedpro/yacht:v0.0.7-alpha-${{ steps.current-time.outputs.formattedTime }}
6462
cache-from: type=local,src=/tmp/.buildx-cache
6563
cache-to: type=local,dest=/tmp/.buildx-cache

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.github
22
.vscode
3+
versions
34
sql_app.db
45
venv
56
*.db

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,16 @@ RUN \
2727
apk add --no-cache --virtual=build-dependencies \
2828
g++ \
2929
make \
30+
postgresql-dev \
3031
python3-dev \
3132
libffi-dev \
3233
ruby-dev &&\
3334
echo "**** install packages ****" && \
3435
apk add --no-cache \
3536
python3 \
3637
py3-pip \
37-
postgresql-dev \
38+
mysql-dev \
39+
postgresql-dev \
3840
mysql-dev \
3941
nginx &&\
4042
gem install sass &&\
@@ -58,4 +60,4 @@ COPY nginx.conf /etc/nginx/
5860

5961
# Expose
6062
VOLUME /config
61-
EXPOSE 8000
63+
EXPOSE 8000

backend/alembic/env.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from os.path import abspath, dirname
2+
import sys
3+
import os
14
from logging.config import fileConfig
25

36
from sqlalchemy import engine_from_config, MetaData
@@ -16,18 +19,11 @@
1619
# for 'autogenerate' support
1720
# from myapp import mymodel
1821
# target_metadata = mymodel.Base.metadata
19-
import os
20-
import sys
21-
from os.path import abspath, dirname
2222

2323
sys.path.insert(0, dirname(dirname(abspath(__file__))))
24-
25-
2624
from api.db import models
27-
from api.settings import Settings
2825

2926
print("--- MODELS ---")
30-
print(models)
3127
# Combine metadata from auth and containers/templates
3228
combined_meta_data = MetaData()
3329
for declarative_base in [models.Base]:
@@ -66,6 +62,7 @@ def run_migrations_offline():
6662
)
6763

6864
with context.begin_transaction():
65+
context.execute("DROP TABLE IF EXISTS alembic_version;")
6966
context.run_migrations()
7067

7168

@@ -86,6 +83,7 @@ def run_migrations_online():
8683
context.configure(connection=connection, target_metadata=target_metadata)
8784

8885
with context.begin_transaction():
86+
context.execute("DROP TABLE IF EXISTS alembic_version;")
8987
context.run_migrations()
9088

9189

backend/api/actions/apps.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi import HTTPException
2+
from fastapi.responses import StreamingResponse
23

34
from api.db.schemas.apps import DeployLogs, DeployForm, AppLogs, Processes
45
from api.utils.apps import (
@@ -12,13 +13,24 @@
1213
conv_restart2data,
1314
conv_sysctls2data,
1415
conv_volumes2data,
16+
conv_cpus2data,
1517
_check_updates,
18+
calculate_cpu_percent,
19+
calculate_cpu_percent2,
20+
format_bytes,
1621
)
1722
from api.utils.templates import conv2dict
1823

24+
import yaml
25+
import json
26+
import io
27+
import zipfile
1928
import time
2029
import subprocess
2130
import docker
31+
import aiodocker
32+
import asyncio
33+
2234

2335
"""
2436
Returns all running apps in a list
@@ -160,6 +172,7 @@ def deploy_app(template: DeployForm):
160172
template.name,
161173
conv_image2data(template.image),
162174
conv_restart2data(template.restart_policy),
175+
template.command,
163176
conv_ports2data(template.ports, template.network, template.network_mode),
164177
conv_portlabels2data(template.ports),
165178
template.network_mode,
@@ -170,6 +183,8 @@ def deploy_app(template: DeployForm):
170183
conv_labels2data(template.labels),
171184
conv_sysctls2data(template.sysctls),
172185
conv_caps2data(template.cap_add),
186+
conv_cpus2data(template.cpus),
187+
template.mem_limit,
173188
edit=template.edit or False,
174189
_id=template.id or None,
175190
)
@@ -214,6 +229,7 @@ def launch_app(
214229
name,
215230
image,
216231
restart_policy,
232+
command,
217233
ports,
218234
portlabels,
219235
network_mode,
@@ -224,6 +240,8 @@ def launch_app(
224240
labels,
225241
sysctls,
226242
caps,
243+
cpus,
244+
mem_limit,
227245
edit,
228246
_id,
229247
):
@@ -246,6 +264,7 @@ def launch_app(
246264
name=name,
247265
image=image,
248266
restart_policy=restart_policy,
267+
command=command,
249268
ports=ports,
250269
network=network,
251270
network_mode=network_mode,
@@ -255,13 +274,17 @@ def launch_app(
255274
labels=combined_labels,
256275
devices=devices,
257276
cap_add=caps,
277+
nano_cpus=cpus,
278+
mem_limit=mem_limit,
258279
detach=True,
259280
)
260-
except Exception as e:
281+
except docker.errors.APIError as e:
261282
if e.status_code == 500:
262283
failed_app = dclient.containers.get(name)
263284
failed_app.remove()
264-
raise e
285+
raise HTTPException(
286+
status_code=e.status_code, detail=e.explanation.decode("utf-8")
287+
)
265288

266289
print(
267290
f"""Container started successfully.
@@ -424,3 +447,101 @@ def check_self_update():
424447
raise HTTPException(status_code=400, detail=exc.args)
425448

426449
return _check_updates(yacht.image.tags[0])
450+
451+
452+
def generate_support_bundle(app_name):
453+
dclient = docker.from_env()
454+
if dclient.containers.get(app_name):
455+
app = dclient.containers.get(app_name)
456+
stream = io.BytesIO()
457+
with zipfile.ZipFile(stream, "w") as zf:
458+
# print(compose)
459+
# print(compose.get("services"))
460+
attrs = app.attrs
461+
service_log = app.logs()
462+
zf.writestr(f"{app_name}.log", service_log)
463+
zf.writestr(f"{app_name}-config.yml", yaml.dump(attrs))
464+
# It is possible that ".write(...)" has better memory management here.
465+
stream.seek(0)
466+
return StreamingResponse(
467+
stream,
468+
media_type="application/x-zip-compressed",
469+
headers={
470+
"Content-Disposition": f"attachment;filename={app_name}_bundle.zip"
471+
},
472+
)
473+
else:
474+
raise HTTPException(404, f"App {app_name} not found.")
475+
476+
477+
async def log_generator(request, app_name):
478+
while True:
479+
async with aiodocker.Docker() as docker:
480+
container: DockerContainer = await docker.containers.get(app_name)
481+
if container._container["State"]["Status"] == "running":
482+
logs_generator = container.log(
483+
stdout=True, stderr=True, follow=True, tail=200
484+
)
485+
async for line in logs_generator:
486+
yield {"event": "update", "retry": 3000, "data": line}
487+
488+
if await request.is_disconnected():
489+
break
490+
491+
492+
async def stat_generator(request, app_name):
493+
prev_stats = None
494+
while True:
495+
async with aiodocker.Docker() as adocker:
496+
container: DockerContainer = await adocker.containers.get(app_name)
497+
if container._container["State"]["Status"] == "running":
498+
stats_generator = container.stats(stream=True)
499+
500+
async for line in stats_generator:
501+
current_stats = await process_app_stats(line, app_name)
502+
if prev_stats != current_stats:
503+
yield {
504+
"event": "update",
505+
"retry": 30000,
506+
"data": json.dumps(current_stats),
507+
}
508+
prev_stats = current_stats
509+
510+
if await request.is_disconnected():
511+
break
512+
513+
# Stats are generated every second by docker
514+
# so there's no point in checking more often than that
515+
await asyncio.sleep(1)
516+
517+
518+
async def process_app_stats(line, app_name):
519+
cpu_total = 0.0
520+
cpu_system = 0.0
521+
cpu_percent = 0.0
522+
if line["memory_stats"]:
523+
mem_current = line["memory_stats"]["usage"]
524+
mem_total = line["memory_stats"]["limit"]
525+
mem_percent = (mem_current / mem_total) * 100.0
526+
else:
527+
mem_current = None
528+
mem_total = None
529+
mem_percent = None
530+
531+
try:
532+
cpu_percent, cpu_system, cpu_total = await calculate_cpu_percent2(
533+
line, cpu_total, cpu_system
534+
)
535+
except KeyError:
536+
print("error while getting new CPU stats: %r, falling back")
537+
cpu_percent = await calculate_cpu_percent(line)
538+
539+
full_stats = {
540+
"time": line["read"],
541+
"name": app_name,
542+
"mem_total": mem_total,
543+
"cpu_percent": round(cpu_percent, 1),
544+
"mem_current": mem_current,
545+
"mem_percent": round(mem_percent, 1),
546+
}
547+
return full_stats

backend/api/actions/compose.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,9 @@ def get_compose_projects():
194194
if loaded_compose.get("networks"):
195195
for network in loaded_compose.get("networks"):
196196
networks.append(network)
197-
for service in loaded_compose.get("services"):
198-
services[service] = loaded_compose["services"][service]
197+
if loaded_compose.get("services"):
198+
for service in loaded_compose.get("services"):
199+
services[service] = loaded_compose["services"][service]
199200
_project = {
200201
"name": project,
201202
"path": file,
@@ -234,8 +235,9 @@ def get_compose(name):
234235
if loaded_compose.get("networks"):
235236
for network in loaded_compose.get("networks"):
236237
networks.append(network)
237-
for service in loaded_compose.get("services"):
238-
services[service] = loaded_compose["services"][service]
238+
if loaded_compose.get("services"):
239+
for service in loaded_compose.get("services"):
240+
services[service] = loaded_compose["services"][service]
239241
_content = open(file)
240242
content = _content.read()
241243
compose_object = {
@@ -269,6 +271,11 @@ def write_compose(compose):
269271
try:
270272
f.write(compose.content)
271273
f.close()
274+
except TypeError as exc:
275+
if exc.args[0] == "write() argument must be str, not None":
276+
raise HTTPException(
277+
status_code=422, detail="Compose file cannot be empty."
278+
)
272279
except Exception as exc:
273280
raise HTTPException(exc.status_code, exc.detail)
274281

backend/api/actions/resources.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from fastapi import HTTPException
33

44
### IMAGES ###
5+
6+
57
def get_images():
68
dclient = docker.from_env()
79
containers = dclient.containers.list(all=True)
@@ -174,11 +176,15 @@ def get_networks():
174176
raise HTTPException(
175177
status_code=exc.response.status_code, detail=exc.explanation
176178
)
177-
if attrs.get("inUse") == None:
178-
attrs.update({"inUse": False})
179-
if attrs.get("Labels", {}).get("com.docker.compose.project"):
180-
attrs.update({"Project": attrs["Labels"]["com.docker.compose.project"]})
181-
network_list.append(attrs)
179+
if attrs:
180+
if attrs.get("inUse") is None:
181+
attrs.update({"inUse": False})
182+
if attrs.get("Labels", {}):
183+
if attrs.get("Labels", {}).get("com.docker.compose.project"):
184+
attrs.update(
185+
{"Project": attrs["Labels"]["com.docker.compose.project"]}
186+
)
187+
network_list.append(attrs)
182188
return network_list
183189

184190

backend/api/db/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .models import *

backend/api/db/crud/settings.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from sqlalchemy.orm import Session
22

33
from api.db.models import containers as models
4+
from api.db.models.settings import SecretKey
45
from datetime import datetime
6+
from api.settings import Settings
57
import json
68

9+
settings = Settings()
10+
711

812
def export_settings(db: Session):
913
file_export = {}
@@ -12,6 +16,27 @@ def export_settings(db: Session):
1216
return file_export
1317

1418

19+
def get_secret_key(db: Session):
20+
check = db.query(models.SecretKey).first()
21+
if check:
22+
return True
23+
else:
24+
return False
25+
26+
27+
def generate_secret_key(db: Session):
28+
check = db.query(SecretKey).first()
29+
if check is None:
30+
key = SecretKey(key=settings.SECRET_KEY)
31+
db.add(key)
32+
db.commit()
33+
print("Secret key generated")
34+
return key.key
35+
else:
36+
print("Secret key exists")
37+
return check.key
38+
39+
1540
def import_settings(db: Session, upload):
1641
import_file = upload.file.read()
1742
decoded_import = import_file.decode("utf-8")

0 commit comments

Comments
 (0)