Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Commit without deploy #379

Merged
merged 23 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0a548ea
feat: add git modal to see changes
MSchmoecker Feb 24, 2025
3b570d4
feat: improve file list
MSchmoecker Feb 25, 2025
10dbf93
feat: improved file name display
MSchmoecker Feb 25, 2025
568e512
feat: move commit away from deploy
MSchmoecker Feb 25, 2025
a948d35
feat: add disabled deploy button tooltip
MSchmoecker Feb 25, 2025
c016609
fix: initial commit
MSchmoecker Feb 25, 2025
1bdc67d
fix: Create History Entry test
MSchmoecker Feb 25, 2025
26370dd
fix: edit config tags with state reload in background
MSchmoecker Feb 25, 2025
5e61f8c
fix: file watcher in test
MSchmoecker Feb 25, 2025
7bd917c
feat: commit and deploy
MSchmoecker Feb 25, 2025
45ce5e0
chore: reorder commit and deploy modal elements
MSchmoecker Feb 26, 2025
c7aac2e
chore: adjust layout
MSchmoecker Feb 26, 2025
486a4fb
feat: open commit modal before download image and start VM
MSchmoecker Feb 26, 2025
6d7179d
fix: tests
MSchmoecker Feb 26, 2025
a0ea065
test: add deploy modal screenshot for VM test
MSchmoecker Feb 26, 2025
9cad81b
test: add real deploy modal screenshot
MSchmoecker Feb 26, 2025
3627171
fix: tests
MSchmoecker Feb 26, 2025
5c7ec3d
fix: make addModule function asynchronous to ensure state is saved be…
elikoga Feb 26, 2025
017b0d1
fix: add cleanup for temporary files and remove qcow images after pro…
elikoga Feb 27, 2025
10cd20e
fix: fill custom promt timing
MSchmoecker Feb 27, 2025
13c98aa
fix: change async functions to synchronous for state updates and comm…
MSchmoecker Feb 27, 2025
62e3a8a
fix: download image dirty state timing
MSchmoecker Feb 27, 2025
7981750
Update e2e snapshots
thymis-github-app[bot] Feb 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/npm_test_integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ if grep -q -E -e "npx playwright install" -e "error: attribute '\"[[:digit:]]+\.
echo "Playwright tests failed due to missing browsers"
echo "MISSING_BROWSERS=true" >> $GITHUB_ENV
fi
if grep -q -E -e "[[:digit:]]+ failed" -e "was not able to start" -e "Error: Timed out waiting [[:digit:]]+ms from config.webServer" output.log; then
if grep -q -E -e "[[:digit:]]+ failed" -e "was not able to start" -e "Application startup failed" -e "Error: Timed out waiting [[:digit:]]+ms from config.webServer" output.log; then
echo "Playwright tests failed"
exit 1
fi
Expand Down
44 changes: 43 additions & 1 deletion controller/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions controller/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ python-multipart = "^0.0.20"
paramiko = "^3.5.0"
http-network-relay = {git = "https://[email protected]/Thymis-io/http-network-relay.git"}
thymis-agent = {path = "../agent"}
watchdog = "^6.0.0"

[tool.poetry.group.test.dependencies]
pytest = "^8.3.2"
Expand Down
3 changes: 2 additions & 1 deletion controller/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_valid_user_session,
)
from thymis_controller.models.state import Config
from thymis_controller.notifications import NotificationManager

# Create an in-memory SQLite database for testing
SQLITE_DATABASE_URL = "sqlite:///:memory:"
Expand Down Expand Up @@ -44,7 +45,7 @@ def project(db_session):

# create temp folder
tmpdir = tempfile.TemporaryDirectory(delete=False)
yield Project(tmpdir.name, db_session)
yield Project(tmpdir.name, NotificationManager(), db_session)
tmpdir.cleanup()


Expand Down
4 changes: 3 additions & 1 deletion controller/thymis_controller/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ async def lifespan(app: FastAPI):
notification_manager,
)
with sqlalchemy.orm.Session(db_engine) as db_session:
project = Project(global_settings.PROJECT_PATH.resolve(), db_session)
project = Project(
global_settings.PROJECT_PATH.resolve(), notification_manager, db_session
)
async with task_controller.start(db_engine):
logger.debug("starting frontend")
await frontend.frontend.run()
Expand Down
24 changes: 21 additions & 3 deletions controller/thymis_controller/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from thymis_controller.config import global_settings
from thymis_controller.models.state import State
from thymis_controller.nix import NIX_CMD, get_input_out_path, render_flake_nix
from thymis_controller.notifications import NotificationManager
from thymis_controller.repo import Repo
from thymis_controller.task import controller as task

Expand Down Expand Up @@ -139,17 +140,24 @@ def get_module_class_instance_by_type(module_type: str):
class Project:
path: pathlib.Path
repo: Repo
notification_manager: NotificationManager
known_hosts_path: pathlib.Path
public_key: str
state_lock = threading.Lock()
repo_dir: pathlib.Path

def __init__(self, path, db_session: sqlalchemy.orm.Session):
def __init__(
self,
path,
notification_manager: NotificationManager,
db_session: sqlalchemy.orm.Session,
):
self.path = pathlib.Path(path)
self.notification_manager = notification_manager
# create the path if not exists
self.path.mkdir(exist_ok=True, parents=True)
self.repo_dir = self.path / "repository"
self.repo = Repo(self.repo_dir)
self.repo = Repo(self.repo_dir, self.notification_manager)

# get public key of controller instance
public_key_process = subprocess.run(
Expand Down Expand Up @@ -185,6 +193,11 @@ def __init__(self, path, db_session: sqlalchemy.orm.Session):
logger.error("Error while migrating state: %s", e)
traceback.print_exc()

if not self.repo.has_root_commit():
self.repo.add(".")
self.repo.commit("Initial commit")
self.repo.start_file_watcher()

logger.debug("Initializing known_hosts file")
self.known_hosts_path = None
self.update_known_hosts(db_session)
Expand Down Expand Up @@ -264,8 +277,13 @@ def clear_history(self, db_session: sqlalchemy.orm.Session):
# reinits the git repo
if (self.repo_dir / ".git").exists():
shutil.rmtree(self.repo_dir / ".git")
self.repo = Repo(self.repo_dir)
self.repo.stop_file_watcher()
self.repo = Repo(self.repo_dir, self.notification_manager)
self.write_state_and_reload(State())
if not self.repo.has_root_commit():
self.repo.add(".")
self.repo.commit("Initial commit")
self.repo.start_file_watcher()
self.update_known_hosts(db_session)

def update_known_hosts(self, db_session: sqlalchemy.orm.Session):
Expand Down
97 changes: 96 additions & 1 deletion controller/thymis_controller/repo.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import asyncio
import datetime
import logging
import os
import pathlib
import subprocess
import threading

from pydantic import BaseModel
from thymis_controller.notifications import NotificationManager
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer

logger = logging.getLogger(__name__)

Expand All @@ -16,11 +22,71 @@ class Commit(BaseModel):
author: str


class FileChange(BaseModel):
path: str
dir: str
file: str
diff: str


class RepoStatus(BaseModel):
changes: list[FileChange]


class StateEventHandler(FileSystemEventHandler):
def __init__(self, notification_manager: NotificationManager):
self.notification_manager = notification_manager
self.last_event = datetime.datetime.min
self.event_loop = asyncio.get_event_loop()
self.debounce_task = None
self.debounce_lock = threading.Lock()

def on_any_event(self, event: FileSystemEvent) -> None:
if event.event_type != "modified":
return
if self.should_debounce():
return

self.last_event = datetime.datetime.now()
self.broadcast_update()

def should_debounce(self):
delta = datetime.datetime.now() - self.last_event
if delta > datetime.timedelta(seconds=0.2):
return False

with self.debounce_lock:
if not self.debounce_task or self.debounce_task.done():
self.debounce_task = self.event_loop.create_task(self.debounce())
return True

async def debounce(self):
await asyncio.sleep(0.2)
self.broadcast_update()

def broadcast_update(self):
self.notification_manager.broadcast_invalidate_notification(
["/api/repo_status", "/api/state"]
)


class Repo:
def __init__(self, path: pathlib.Path):
def __init__(self, path: pathlib.Path, notification_manager: NotificationManager):
self.path = path
self.notification_manager = notification_manager
self.state_observer = None
self.init()

def start_file_watcher(self):
state_event_handler = StateEventHandler(self.notification_manager)
self.state_observer = Observer()
self.state_observer.schedule(state_event_handler, str(self.path / "state.json"))
self.state_observer.start()

def stop_file_watcher(self):
if self.state_observer:
self.state_observer.stop()

def run_command(self, *args: str) -> str:
return subprocess.run(
args, capture_output=True, text=True, cwd=self.path
Expand Down Expand Up @@ -81,3 +147,32 @@ def diff(self, refA: str, refB: str) -> str:
refB = refB or "HEAD"

return self.run_command("git", "diff", refA, refB, "state.json")

def status(self) -> RepoStatus:
try:
result = self.run_command("git", "status", "--porcelain")
except subprocess.CalledProcessError:
logger.exception("Error getting git status")
return RepoStatus(changes=[])

return RepoStatus(
changes=reversed(
[
FileChange(
path=path,
dir=os.path.dirname(path),
file=os.path.basename(path),
diff=self.run_command("git", "diff", "HEAD", path),
)
for line in result.splitlines()
for path in line.split(maxsplit=1)[1:]
]
)
)

def is_dirty(self) -> bool:
return bool(self.run_command("git", "status", "--porcelain"))

def has_root_commit(self) -> bool:
result = self.run_command("git", "rev-list", "--max-parents=0", "HEAD")
return bool(result)
34 changes: 26 additions & 8 deletions controller/thymis_controller/routers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import paramiko
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
Query,
Expand Down Expand Up @@ -62,8 +63,8 @@ def get_available_modules(request: Request) -> list[models.Module]:


@router.patch("/state")
async def update_state(request: Request, project: ProjectAD):
new_state = State.model_validate(await request.json())
def update_state(payload: Annotated[dict, Body()], project: ProjectAD):
new_state = State.model_validate(payload)
project.write_state_and_reload(new_state)
return new_state

Expand All @@ -89,16 +90,18 @@ async def build_repo(

@router.post("/action/deploy")
async def deploy(
message: str,
session: SessionAD,
project: ProjectAD,
task_controller: TaskControllerAD,
network_relay: NetworkRelayAD,
user_session_id: UserSessionIDAD,
configs: list[str] = Query(None, alias="config"),
):
project.repo.add(".")
project.repo.commit(message)
if project.repo.is_dirty():
raise HTTPException(
status_code=409,
detail="Repository is dirty. Please commit your changes first.",
)

devices: list[models.DeployDeviceInformation] = []

Expand Down Expand Up @@ -136,15 +139,18 @@ async def deploy(


@router.post("/action/build-download-image")
async def build_download_image(
def build_download_image(
identifier: str,
db_session: SessionAD,
task_controller: TaskControllerAD,
user_session_id: UserSessionIDAD,
project: ProjectAD,
):
project.repo.add(".")
project.repo.commit(f"Build image for {identifier}")
if project.repo.is_dirty():
raise HTTPException(
status_code=409,
detail="Repository is dirty. Please commit your changes first.",
)

config = next(
config
Expand Down Expand Up @@ -267,6 +273,18 @@ def get_diff(
return project.repo.diff(refA, refB)


@router.get("/repo_status", tags=["history"])
def get_repo_status(project: ProjectAD):
return project.repo.status()


@router.post("/action/commit")
def commit(project: ProjectAD, message: str):
project.repo.add(".")
project.repo.commit(message)
return {"message": "commit successful"}


@router.post("/action/update")
def update(
project: ProjectAD,
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/lib/EditTagModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@
label: projectTags.find((t) => t.identifier === tag)?.displayName ?? ''
}));
};

$: setSelectedTags(currentlyEditingConfig?.tags || [], projectTags);
</script>

<Modal
title={$t('configurations.edit-tags-title')}
open={!!currentlyEditingConfig}
on:close={() => (currentlyEditingConfig = undefined)}
on:open={() => setSelectedTags(currentlyEditingConfig?.tags || [], projectTags)}
outsideclose
bodyClass="p-4 md:p-5 space-y-4 flex-1"
>
Expand All @@ -46,9 +45,12 @@
<div class="flex justify-end gap-2">
<Button
on:click={async () => {
if (currentlyEditingConfig) {
currentlyEditingConfig.tags = selectedTags.map((tag) => tag.value);
}
$state.configs = $state.configs.map((config) => {
if (config.identifier === currentlyEditingConfig?.identifier) {
config.tags = selectedTags.map((tag) => tag.value);
}
return config;
});
await saveState();
currentlyEditingConfig = undefined;
}}
Expand Down
Loading
Loading