diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f205cbd --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Django +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost + +# 32 url-safe base64-encoded bytes for Fernet (generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") +FIELD_ENCRYPTION_KEY=REPLACE_ME_WITH_FERNET_KEY + +# Postgres +POSTGRES_DB=docker_integration +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# pgAdmin +PGADMIN_PORT=5050 +PGADMIN_DEFAULT_EMAIL=admin@example.com +PGADMIN_DEFAULT_PASSWORD=CHANGE_ME_PGADMIN_PASSWORD diff --git a/.gitignore b/.gitignore index 320bfc2..1525edc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,23 @@ -# Backend +# Python virtual environments venv/ +.venv/ +backend/venv/ +backend/.venv/ + +# Python cache __pycache__/ +*.py[cod] + +# Node dependencies +node_modules/ +frontend/node_modules/ + +# Build output +dist/ +dist-ssr/ -# Frontend -logs +# Logs +logs/ *.log npm-debug.log* yarn-debug.log* @@ -11,16 +25,24 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -node_modules -dist -dist-ssr +# Environment files +.env *.local + +# Local databases +db.* +backend/db.* + +# Editor and OS .vscode/* !.vscode/extensions.json -.idea +.idea/ .DS_Store *.suo *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? + +# Tool caches +*.cache diff --git a/README.md b/README.md index 682b949..2a1e8be 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,98 @@ # Docker Integration Host -**Docker Integration Host** is a powerful orchestration tool designed to manage multiple containers and networks across different hosts from a single, centralized interface. It aims to simplify the complexity of distributed container management, providing a unified platform for monitoring, scaling, and configuring your Docker infrastructure. +A full-stack Docker Management Dashboard built with Django REST Framework (Backend) and React (Frontend). This application allows you to remotely manage Docker hosts, pull/push images, monitor containers, and manage role-based access controls across your infrastructure. -## Features +## Project Structure -- **Single Host Management**: Orchestrate containers across multiple remote hosts. -- **Network Orchestration**: Manage and bridge networks between different Docker environments. -- **Centralized Control**: Unified dashboard for viewing and controlling container lifecycles. +The repository is split into two independent modules: +- `backend/`: Django API server providing endpoints for Docker SDK integration, database persistence, and RBAC authentication. +- `frontend/`: React single-page application serving the dashboard UI. -## Getting Started +--- -### Backend Setup (Django) +## Local Setup Guidelines -The backend handles the orchestration logic, communication with remote Docker hosts, and state management. +### Prerequisites +- Python 3.10+ +- Node.js 18+ +- Docker Engine locally installed and running (for local container management endpoints) +- Git -1. **Navigate to the backend directory:** +### 1. Backend Setup (Django) + +Open your terminal and navigate to the project root: + +1. **Change to the backend directory** ```bash cd backend ``` -2. **Create and activate a virtual environment:** +2. **Create and activate a Python virtual environment** + - Windows: + ```bash + python -m venv venv + .\venv\Scripts\activate + ``` + - macOS / Linux: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + +3. **Install the required dependencies** ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate + pip install -r requirements.txt ``` -3. **Install dependencies:** +4. **Run Database Migrations** + Initialize the SQLite database schema: ```bash - pip install -r requirements.txt + python manage.py migrate ``` -4. **Run database migrations:** +5. **Create a Superuser** (Required to access all features & bypass RBAC limitations out-of-the-box): ```bash - python manage.py migrate + python manage.py createsuperuser ``` -5. **Start the Django development server:** +6. **Start the Development Server** ```bash python manage.py runserver ``` - The backend will be running at `http://localhost:8000`. + *The API will be available at `http://127.0.0.1:8000/`* + +--- -### Frontend Setup (Vite + React) +### 2. Frontend Setup (React) -The frontend provides the user interface for managing your containers and network configurations. +Open a **new, separate terminal tab/window** and navigate to the project root: -1. **Navigate to the frontend directory:** +1. **Change to the frontend directory** ```bash cd frontend ``` -2. **Install dependencies:** +2. **Install node dependencies** ```bash npm install ``` -3. **Start the development server:** +3. **Start the Development Server** ```bash npm run dev ``` - The frontend will be running at `http://localhost:5173`. + *The React dashboard will be accessible at the Localhost URL provided in the terminal (usually `http://localhost:5173`).* + +--- + +## Post-Setup Verification + +1. Head to your frontend dashboard URL in the browser. +2. Login using the Superuser credentials you just generated. +3. **Module 1 (RBAC & Auth):** Confirm your user role functions properly, and try assigning hosts. +4. **Module 2 (Containers):** Verify you can list, stop, and boot containers directly from your dashboard. +5. **Module 3 (Images):** Switch over to the Images tab to pull real images from registries seamlessly using the background queue jobs! -## Architecture +## Important Notes for Windows Users -- **Backend**: Django REST Framework -- **Frontend**: React with Vite and Tailwind CSS +A platform-specific handler is automatically enabled within `backend/containers/models.py`. If you define "local-docker" as your host, the Docker daemon socket transparently defaults to `npipe:////./pipe/docker_engine`, meaning everything connects naturally without Unix socket errors! Make sure Docker Desktop is open and running in the background before testing the UI. diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 8c38f3f..0f6dd51 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth import get_user_model -# Register your models here. +User = get_user_model() + + +@admin.register(User) +class CustomUserAdmin(UserAdmin): + list_display = ['username', 'email', 'role', 'is_staff', 'is_active'] + list_filter = ['role', 'is_staff', 'is_active'] + fieldsets = UserAdmin.fieldsets + ( + ('Role', {'fields': ('role',)}), + ) + add_fieldsets = UserAdmin.add_fieldsets + ( + ('Role', {'fields': ('role',)}), + ) diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py index 9635c9d..8bab8df 100644 --- a/backend/authentication/apps.py +++ b/backend/authentication/apps.py @@ -2,4 +2,5 @@ class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' name = 'authentication' diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..5f2fe00 --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated migration for custom User model with role field + +from django.db import migrations, models +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('admin', 'Admin'), ('host', 'Host'), ('viewer', 'Viewer')], default='viewer', max_length=10)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 71a8362..b144d62 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,3 +1,29 @@ +from django.contrib.auth.models import AbstractUser, UserManager from django.db import models -# Create your models here. + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("role", User.ADMIN) + return super().create_superuser(username, email, password, **extra_fields) + + +class User(AbstractUser): + ADMIN = 'admin' + HOST = 'host' + VIEWER = 'viewer' + + ROLE_CHOICES = [ + (ADMIN, 'Admin'), + (HOST, 'Host'), + (VIEWER, 'Viewer'), + ] + + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default=VIEWER) + + objects = CustomUserManager() + + def __str__(self): + return f"{self.username} ({self.role})" diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py new file mode 100644 index 0000000..0bc7457 --- /dev/null +++ b/backend/authentication/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions + + +class IsAdmin(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role == 'admin' + + +class IsHost(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role in ['admin', 'host'] + + +class IsViewer(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role in ['admin', 'host', 'viewer'] diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 0000000..0251a80 --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + bio = serializers.CharField(source='profile.bio', read_only=True) + avatar_url = serializers.CharField(source='profile.avatar_url', read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'bio', 'avatar_url', 'role', 'is_superuser'] + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ['username', 'email', 'password', 'role', 'first_name', 'last_name'] + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data['username'], + email=validated_data.get('email', ''), + password=validated_data['password'], + role=validated_data.get('role', User.VIEWER), + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', '') + ) + return user diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 7ce503c..e5192d1 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -1,3 +1,269 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status +from hosts.models import Host -# Create your tests here. +User = get_user_model() + + +class UserModelTest(TestCase): + """Test custom User model""" + + def test_create_user_with_role(self): + user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + role='viewer' + ) + self.assertEqual(user.username, 'testuser') + self.assertEqual(user.role, 'viewer') + self.assertTrue(user.check_password('testpass123')) + + def test_default_role_is_viewer(self): + user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.assertEqual(user.role, 'viewer') + + def test_user_string_representation(self): + user = User.objects.create_user( + username='testuser', + password='testpass123', + role='admin' + ) + self.assertEqual(str(user), 'testuser (admin)') + + +class RegistrationTest(TestCase): + """Test user registration endpoint""" + + def setUp(self): + self.client = APIClient() + self.register_url = '/api/auth/register/' + + def test_register_user_success(self): + data = { + 'username': 'newuser', + 'email': 'new@example.com', + 'password': 'newpass123', + 'role': 'host', + 'first_name': 'New', + 'last_name': 'User' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 1) + self.assertEqual(User.objects.get().username, 'newuser') + self.assertEqual(User.objects.get().role, 'host') + + def test_register_user_default_role(self): + data = { + 'username': 'newuser', + 'email': 'new@example.com', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.get().role, 'viewer') + + def test_register_user_missing_username(self): + data = { + 'email': 'new@example.com', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_user_short_password(self): + data = { + 'username': 'newuser', + 'password': 'short' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_duplicate_username(self): + User.objects.create_user(username='existing', password='pass123') + data = { + 'username': 'existing', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_host_creates_default_host_record(self): + data = { + 'username': 'hostregister', + 'password': 'newpass123', + 'role': 'host', + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = User.objects.get(username='hostregister') + host = Host.objects.filter(owner=user).first() + self.assertIsNotNone(host) + self.assertEqual(host.hostname, 'localhost') + self.assertEqual(host.port, 2375) + + def test_register_viewer_does_not_create_host_record(self): + data = { + 'username': 'viewerregister', + 'password': 'newpass123', + 'role': 'viewer', + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = User.objects.get(username='viewerregister') + self.assertFalse(Host.objects.filter(owner=user).exists()) + + +class LoginTest(TestCase): + """Test JWT login endpoint""" + + def setUp(self): + self.client = APIClient() + self.login_url = '/api/auth/login/' + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + role='admin' + ) + + def test_login_success(self): + data = { + 'username': 'testuser', + 'password': 'testpass123' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + self.assertEqual(response.data['role'], 'admin') + self.assertEqual(response.data['username'], 'testuser') + self.assertEqual(response.data['email'], 'test@example.com') + + def test_login_wrong_password(self): + data = { + 'username': 'testuser', + 'password': 'wrongpass' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_nonexistent_user(self): + data = { + 'username': 'nonexistent', + 'password': 'testpass123' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_missing_credentials(self): + response = self.client.post(self.login_url, {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_login_host_creates_default_host_if_missing(self): + host_user = User.objects.create_user( + username='hostlogin', + password='hostpass123', + role='host', + ) + self.assertFalse(Host.objects.filter(owner=host_user).exists()) + + response = self.client.post(self.login_url, { + 'username': 'hostlogin', + 'password': 'hostpass123', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(Host.objects.filter(owner=host_user).exists()) + + def test_login_host_does_not_duplicate_default_host(self): + host_user = User.objects.create_user( + username='hostnodup', + password='hostpass123', + role='host', + ) + Host.objects.create( + name='existing', + hostname='localhost', + port=2375, + owner=host_user, + ) + + response = self.client.post(self.login_url, { + 'username': 'hostnodup', + 'password': 'hostpass123', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Host.objects.filter(owner=host_user).count(), 1) + + +class TokenRefreshTest(TestCase): + """Test JWT token refresh endpoint""" + + def setUp(self): + self.client = APIClient() + self.login_url = '/api/auth/login/' + self.refresh_url = '/api/auth/token/refresh/' + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + + def test_refresh_token_success(self): + # Login to get tokens + login_response = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'testpass123' + }) + refresh_token = login_response.data['refresh'] + + # Refresh the token + response = self.client.post(self.refresh_url, { + 'refresh': refresh_token + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + + def test_refresh_token_invalid(self): + response = self.client.post(self.refresh_url, { + 'refresh': 'invalid_token' + }) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PermissionsTest(TestCase): + """Test role-based permissions""" + + def setUp(self): + self.client = APIClient() + self.admin_user = User.objects.create_user( + username='admin', + password='pass123', + role='admin' + ) + self.host_user = User.objects.create_user( + username='host', + password='pass123', + role='host' + ) + self.viewer_user = User.objects.create_user( + username='viewer', + password='pass123', + role='viewer' + ) + + def test_admin_role_assignment(self): + self.assertEqual(self.admin_user.role, 'admin') + + def test_host_role_assignment(self): + self.assertEqual(self.host_user.role, 'host') + + def test_viewer_role_assignment(self): + self.assertEqual(self.viewer_user.role, 'viewer') diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py new file mode 100644 index 0000000..29fa5ee --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import CustomTokenObtainPairView, RegisterView, UserProfileView, UserListView + +urlpatterns = [ + path('register/', RegisterView.as_view(), name='register'), + path('login/', CustomTokenObtainPairView.as_view(), name='login'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('me/', UserProfileView.as_view(), name='me'), + path('users/', UserListView.as_view(), name='users'), +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 91ea44a..da104a8 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,3 +1,43 @@ -from django.shortcuts import render +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from django.contrib.auth import get_user_model +from .serializers import RegisterSerializer, UserSerializer -# Create your views here. +User = get_user_model() + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + data['role'] = self.user.role + data['username'] = self.user.username + data['email'] = self.user.email + return data + + +class CustomTokenObtainPairView(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [AllowAny] + serializer_class = RegisterSerializer + + +from hosts.permissions import IsAdminRole + +class UserProfileView(generics.RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserSerializer + + def get_object(self): + return self.request.user + +class UserListView(generics.ListAPIView): + permission_classes = [IsAdminRole] + serializer_class = UserSerializer + queryset = User.objects.all() diff --git a/backend/containers/__init__.py b/backend/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/admin.py b/backend/containers/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/containers/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/containers/apps.py b/backend/containers/apps.py new file mode 100644 index 0000000..b484790 --- /dev/null +++ b/backend/containers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContainersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'containers' diff --git a/backend/containers/auth.py b/backend/containers/auth.py new file mode 100644 index 0000000..4884541 --- /dev/null +++ b/backend/containers/auth.py @@ -0,0 +1,102 @@ +from functools import wraps +from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError + +from hosts.models import UserHostRole + + +def _assigned_role_for_host(user, host_id): + if host_id is None: + return None + + # Direct match for access-host UUID based endpoints. + role = UserHostRole.objects.filter( + user=user, + host_id=host_id, + ).values_list('role', flat=True).first() + if role: + return role + + # Container endpoints use containers.Host integer IDs. + # Resolve to hosts.Host by endpoint (ip_address + port). + try: + from containers.models import Host as ContainerHost + from hosts.models import Host as AccessHost + + container_host = ContainerHost.objects.filter(pk=host_id).first() + if not container_host: + return None + + access_host = AccessHost.objects.filter( + ip_address=container_host.ip_address, + port=container_host.port, + ).first() + if not access_host: + return None + + return UserHostRole.objects.filter( + user=user, + host=access_host, + ).values_list('role', flat=True).first() + except Exception: + return None + + +def get_user_from_request(request): + try: + result = JWTAuthentication().authenticate(request) + except (InvalidToken, TokenError): + return None + + if result is None: + return None + user, _ = result + return user + + +def check_role(user, host_id, allowed_roles): + role_map = { + 'admin': 'ADMIN', + 'host': 'HOST_OWNER', + 'viewer': 'VIEWER', + } + + if getattr(user, 'is_superuser', False): + return True + + assigned_role = _assigned_role_for_host(user, host_id) + if assigned_role: + return assigned_role in allowed_roles + + normalized_role = role_map.get(getattr(user, 'role', '').lower()) + return normalized_role in allowed_roles + + +def require_auth(view_func): + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + user = get_user_from_request(request) + if not user or not user.is_authenticated: + return Response( + {'error': 'Unauthorized. Valid JWT required.'}, + status=401 + ) + request.user = user + return view_func(self, request, *args, **kwargs) + return wrapper + + +def require_role(allowed_roles): + def decorator(view_func): + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + host_id = kwargs.get('host_id') + if not check_role(request.user, host_id, allowed_roles): + return Response( + {'error': f'Forbidden. Required roles: {allowed_roles}'}, + status=403 + ) + return view_func(self, request, *args, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/backend/containers/consumers/__init__.py b/backend/containers/consumers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/consumers/exec_consumer.py b/backend/containers/consumers/exec_consumer.py new file mode 100644 index 0000000..6a5284e --- /dev/null +++ b/backend/containers/consumers/exec_consumer.py @@ -0,0 +1,105 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from asgiref.sync import sync_to_async + + +class ExecConsumer(AsyncWebsocketConsumer): + async def connect(self): + # Parse ticket from query string + ticket_value = self._get_ticket_from_query() + + if not ticket_value: + await self.close(code=4001) + return + ticket = await self._validate_ticket(ticket_value) + if not ticket: + await self.close(code=4001) + return + self.container_record = ticket.container + self.user = ticket.issued_to + + await self.accept() + await self._log_event('EXEC_OPEN', 'SUCCESS') + + await self.send(text_data=json.dumps({ + 'type': 'connected', + 'data': f'Connected to {self.container_record.name}\r\n', + })) + + async def disconnect(self, close_code): + if hasattr(self, 'container_record'): + await self._log_event('EXEC_CLOSE', 'SUCCESS') + + async def receive(self, text_data): + """Client sends input — we run it on the container and return output.""" + try: + data = json.loads(text_data) + except json.JSONDecodeError: + await self.send(text_data=json.dumps({ + 'type': 'error', + 'data': 'Invalid JSON received.', + })) + return + + if data.get('type') == 'input': + cmd = data.get('data', '').strip() + if not cmd: + return + + output, error = await self._run_exec(cmd) + + if error: + await self.send(text_data=json.dumps({ + 'type': 'error', + 'data': error, + })) + else: + await self.send(text_data=json.dumps({ + 'type': 'output', + 'data': output, + })) + + def _get_ticket_from_query(self): + query_string = self.scope.get('query_string', b'').decode() + params = dict( + part.split('=') for part in query_string.split('&') + if '=' in part + ) + return params.get('ticket') + + @sync_to_async + def _validate_ticket(self, ticket_value): + from containers.services import validate_and_consume_ticket + return validate_and_consume_ticket(ticket_value) + + @sync_to_async + def _run_exec(self, cmd): + + import docker.errors + from containers.docker_client import get_docker_client + + try: + client = get_docker_client(self.container_record.host) + sdk_container = client.containers.get( + self.container_record.container_id + ) + result = sdk_container.exec_run( + cmd, + stdout=True, + stderr=True, + ) + output = result.output.decode(errors='replace') + return output, None + + except docker.errors.APIError as e: + return None, str(e.explanation) + + @sync_to_async + def _log_event(self, action, status): + from containers.models import ContainerLifecycleEvent + ContainerLifecycleEvent.objects.create( + container=self.container_record, + triggered_by=self.user, + action=action, + status=status, + ) \ No newline at end of file diff --git a/backend/containers/consumers/log_consumer.py b/backend/containers/consumers/log_consumer.py new file mode 100644 index 0000000..1e9ea01 --- /dev/null +++ b/backend/containers/consumers/log_consumer.py @@ -0,0 +1,94 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from asgiref.sync import sync_to_async +import asyncio + + +class LogConsumer(AsyncWebsocketConsumer): + + async def connect(self): + ticket_value = self._get_ticket_from_query() + + if not ticket_value: + await self.close(code=4001) + return + + ticket = await self._validate_ticket(ticket_value) + if not ticket: + await self.close(code=4001) + return + + self.container_record = ticket.container + self.user = ticket.issued_to + self.streaming = True + + await self.accept() + asyncio.ensure_future(self._stream_logs()) + + async def disconnect(self, close_code): + self.streaming = False + + async def receive(self, text_data): + await self.send(text_data=json.dumps({ + 'type': 'error', + 'data': 'This is a read-only log stream. No input accepted.', + })) + + def _get_ticket_from_query(self): + query_string = self.scope.get('query_string', b'').decode() + params = dict( + part.split('=') for part in query_string.split('&') + if '=' in part + ) + return params.get('ticket') + + @sync_to_async + def _validate_ticket(self, ticket_value): + from containers.services import validate_and_consume_ticket + return validate_and_consume_ticket(ticket_value) + + async def _stream_logs(self): + try: + await asyncio.get_event_loop().run_in_executor( + None, self._blocking_log_stream + ) + except Exception as e: + if self.streaming: + await self.send(text_data=json.dumps({ + 'type': 'error', + 'data': f'Log stream error: {str(e)}', + })) + + def _blocking_log_stream(self): + import docker.errors + from containers.docker_client import get_docker_client + import asyncio + + client = get_docker_client(self.container_record.host) + sdk_container = client.containers.get( + self.container_record.container_id + ) + + log_stream = sdk_container.logs( + stream=True, + follow=True, + timestamps=True, + ) + + loop = asyncio.new_event_loop() + + for chunk in log_stream: + if not self.streaming: + break + + line = chunk.decode(errors='replace').strip() + if line: + asyncio.run_coroutine_threadsafe( + self.send(text_data=json.dumps({ + 'type': 'log', + 'data': line, + })), + asyncio.get_event_loop() + ) + + loop.close() \ No newline at end of file diff --git a/backend/containers/docker_client.py b/backend/containers/docker_client.py new file mode 100644 index 0000000..b19a7fd --- /dev/null +++ b/backend/containers/docker_client.py @@ -0,0 +1,41 @@ +import docker +from docker.errors import DockerException +from rest_framework.exceptions import APIException +from rest_framework import status + + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = 'Docker host is unreachable.' + default_code = 'service_unavailable' + + +def _resolve_base_url(host): + base_url = host.get_connection_string().strip() + if not base_url: + raise ServiceUnavailable( + detail=f'Docker connection string is empty for host "{host.name}".' + ) + + if base_url.startswith('unix://') or base_url.startswith('tcp://') or base_url.startswith('npipe://'): + return base_url + + raise ServiceUnavailable( + detail=( + f'Unsupported Docker connection string for host "{host.name}": ' + f'{base_url}. Use unix://, tcp://, or npipe://.' + ) + ) + + +def get_docker_client(host): + try: + client = docker.DockerClient(base_url=_resolve_base_url(host)) + + client.ping() + return client + + except DockerException as e: + raise ServiceUnavailable( + detail=f'Could not connect to Docker host "{host.name}": {str(e)}' + ) \ No newline at end of file diff --git a/backend/containers/migrations/0001_initial.py b/backend/containers/migrations/0001_initial.py new file mode 100644 index 0000000..c674ca5 --- /dev/null +++ b/backend/containers/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 6.0.3 on 2026-03-20 18:24 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Host', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('ip_address', models.GenericIPAddressField(default='127.0.0.1')), + ('port', models.IntegerField(default=2375)), + ], + ), + migrations.CreateModel( + name='ContainerRecord', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('container_id', models.CharField(max_length=72, unique=True)), + ('name', models.CharField(max_length=255)), + ('image_ref', models.CharField(max_length=500)), + ('status', models.CharField(choices=[('CREATED', 'Created'), ('RUNNING', 'Running'), ('PAUSED', 'Paused'), ('STOPPED', 'Stopped'), ('KILLED', 'Killed'), ('REMOVED', 'Removed')], default='CREATED', max_length=20)), + ('port_bindings', models.JSONField(blank=True, default=dict)), + ('environment', models.JSONField(blank=True, default=dict)), + ('volumes', models.JSONField(blank=True, default=list)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_containers', to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='containers', to='containers.host')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ContainerLifecycleEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('CREATE', 'Create'), ('START', 'Start'), ('STOP', 'Stop'), ('RESTART', 'Restart'), ('KILL', 'Kill'), ('PAUSE', 'Pause'), ('UNPAUSE', 'Unpause'), ('REMOVE', 'Remove'), ('EXEC_OPEN', 'Exec Open'), ('EXEC_CLOSE', 'Exec Close')], max_length=20)), + ('status', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILED', 'Failed')], max_length=20)), + ('error_message', models.TextField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('triggered_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='containers.containerrecord')), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='ExecTicket', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('ticket', models.CharField(max_length=64, unique=True)), + ('is_used', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exec_tickets', to='containers.containerrecord')), + ('issued_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['ticket'], name='containers__ticket_0af1fb_idx')], + }, + ), + ] diff --git a/backend/containers/migrations/0002_host_connection_string.py b/backend/containers/migrations/0002_host_connection_string.py new file mode 100644 index 0000000..1ac9ace --- /dev/null +++ b/backend/containers/migrations/0002_host_connection_string.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('containers', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='connection_string', + field=models.CharField( + default='unix:///var/run/docker.sock', + help_text='Docker daemon address, e.g. unix:///var/run/docker.sock or tcp://127.0.0.1:2375', + max_length=255, + ), + ), + ] diff --git a/backend/containers/migrations/0003_alter_host_connection_string.py b/backend/containers/migrations/0003_alter_host_connection_string.py new file mode 100644 index 0000000..b3787ff --- /dev/null +++ b/backend/containers/migrations/0003_alter_host_connection_string.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-01 20:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('containers', '0002_host_connection_string'), + ] + + operations = [ + migrations.AlterField( + model_name='host', + name='connection_string', + field=models.CharField(default='unix:///var/run/docker.sock', max_length=255), + ), + ] diff --git a/backend/containers/migrations/__init__.py b/backend/containers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/models.py b/backend/containers/models.py new file mode 100644 index 0000000..22762db --- /dev/null +++ b/backend/containers/models.py @@ -0,0 +1,167 @@ +import uuid +import secrets +import sys +from django.db import models +from django.conf import settings +from django.utils import timezone +from datetime import timedelta + +class Host(models.Model): + name = models.CharField(max_length=255) + ip_address = models.GenericIPAddressField(default='127.0.0.1') + port = models.IntegerField(default=2375) + connection_string = models.CharField( + max_length=255, + default='unix:///var/run/docker.sock', + # help_text='Docker daemon address, e.g. unix:///var/run/docker.sock or tcp://127.0.0.1:2375', + ) + + class Meta: + app_label = 'containers' + + def get_connection_string(self): + """ + Return explicit connection_string when present; otherwise derive one + from legacy ip_address/port fields for backward compatibility. + """ + conn_str = self.connection_string + if not conn_str: + if self.ip_address in ('localhost', '127.0.0.1'): + conn_str = 'unix:///var/run/docker.sock' + else: + conn_str = f'tcp://{self.ip_address}:{self.port}' + + # Fix for Windows: if fallback or explicit string is the default Unix socket, convert to Windows named pipe + if conn_str == 'unix:///var/run/docker.sock' and sys.platform == 'win32': + return 'npipe:////./pipe/docker_engine' + + return conn_str + + def __str__(self): + return f"{self.name} ({self.ip_address}:{self.port})" + +class ContainerRecord(models.Model): + + class Status(models.TextChoices): + CREATED = 'CREATED', 'Created' + RUNNING = 'RUNNING', 'Running' + PAUSED = 'PAUSED', 'Paused' + STOPPED = 'STOPPED', 'Stopped' + KILLED = 'KILLED', 'Killed' + REMOVED = 'REMOVED', 'Removed' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + 'containers.Host', + on_delete=models.CASCADE, + related_name='containers' + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='created_containers' + ) + container_id = models.CharField(max_length=72, unique=True) + name = models.CharField(max_length=255) + image_ref = models.CharField(max_length=500) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.CREATED + ) + port_bindings = models.JSONField(default=dict, blank=True) + environment = models.JSONField(default=dict, blank=True) + volumes = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.name} [{self.status}]" + +class ContainerLifecycleEvent(models.Model): + + class Action(models.TextChoices): + CREATE = 'CREATE', 'Create' + START = 'START', 'Start' + STOP = 'STOP', 'Stop' + RESTART = 'RESTART', 'Restart' + KILL = 'KILL', 'Kill' + PAUSE = 'PAUSE', 'Pause' + UNPAUSE = 'UNPAUSE', 'Unpause' + REMOVE = 'REMOVE', 'Remove' + EXEC_OPEN = 'EXEC_OPEN', 'Exec Open' + EXEC_CLOSE = 'EXEC_CLOSE', 'Exec Close' + + class Status(models.TextChoices): + SUCCESS = 'SUCCESS', 'Success' + FAILED = 'FAILED', 'Failed' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + container = models.ForeignKey( + ContainerRecord, + on_delete=models.CASCADE, + related_name='events' + ) + triggered_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True + ) + action = models.CharField(max_length=20, choices=Action.choices) + status = models.CharField(max_length=20, choices=Status.choices) + error_message = models.TextField(null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.action} → {self.status} @ {self.timestamp}" + +class ExecTicket(models.Model): + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + container = models.ForeignKey( + ContainerRecord, + on_delete=models.CASCADE, + related_name='exec_tickets' + ) + issued_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE + ) + ticket = models.CharField(max_length=64, unique=True) + is_used = models.BooleanField(default=False) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [models.Index(fields=['ticket'])] + + @classmethod + def issue(cls, container, user): + """ + Always use this factory method instead of .objects.create() directly. + Generates a cryptographically secure token and sets 30s expiry. + """ + return cls.objects.create( + container=container, + issued_to=user, + ticket=secrets.token_hex(32), + expires_at=timezone.now() + timedelta(seconds=30), + ) + + def is_valid(self): + return not self.is_used and self.expires_at > timezone.now() + + def consume(self): + """Call exactly once when WebSocket connects successfully.""" + self.is_used = True + self.save(update_fields=['is_used']) + + def __str__(self): + return f"Ticket for {self.container.name} ({'used' if self.is_used else 'valid'})" \ No newline at end of file diff --git a/backend/containers/routing.py b/backend/containers/routing.py new file mode 100644 index 0000000..58f78f6 --- /dev/null +++ b/backend/containers/routing.py @@ -0,0 +1,14 @@ +from django.urls import re_path +from containers.consumers.exec_consumer import ExecConsumer +from containers.consumers.log_consumer import LogConsumer + +websocket_urlpatterns = [ + re_path( + r'^ws/hosts/(?P\d+)/containers/(?P[^/]+)/exec/$', + ExecConsumer.as_asgi() + ), + re_path( + r'^ws/hosts/(?P\d+)/containers/(?P[^/]+)/logs/$', + LogConsumer.as_asgi() + ), +] \ No newline at end of file diff --git a/backend/containers/serializers.py b/backend/containers/serializers.py new file mode 100644 index 0000000..008c8a7 --- /dev/null +++ b/backend/containers/serializers.py @@ -0,0 +1,112 @@ +from rest_framework import serializers +from containers.models import ( + Host, + ContainerRecord, + ContainerLifecycleEvent, +) + +class HostSerializer(serializers.ModelSerializer): + class Meta: + model = Host + fields = ['id', 'name', 'ip_address', 'port'] + +class ContainerRecordSerializer(serializers.ModelSerializer): + created_by = serializers.StringRelatedField(read_only=True) + + class Meta: + model = ContainerRecord + fields = [ + 'id', 'host', 'created_by', + 'container_id', 'name', 'image_ref', 'status', + 'port_bindings', 'environment', 'volumes', + 'created_at', 'updated_at', + ] + read_only_fields = [ + 'id', 'container_id', 'status', + 'created_at', 'updated_at', 'created_by', + ] + +class ContainerRecordListSerializer(serializers.ModelSerializer): + class Meta: + model = ContainerRecord + fields = [ + 'id', 'container_id', 'name', + 'image_ref', 'status', 'created_at', + ] + +class ContainerCreateSerializer(serializers.Serializer): + image_ref = serializers.CharField(max_length=500) + name = serializers.CharField(max_length=255) + command = serializers.CharField( + required=False, + allow_blank=True, + default='' + ) + environment = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict + ) + port_bindings = serializers.DictField( + required=False, + default=dict + ) + volumes = serializers.ListField( + required=False, + default=list + ) + +class ContainerLifecycleEventSerializer(serializers.ModelSerializer): + triggered_by = serializers.StringRelatedField(read_only=True) + + class Meta: + model = ContainerLifecycleEvent + fields = [ + 'id', 'action', 'status', + 'triggered_by', 'error_message', 'timestamp', + ] + read_only_fields = fields + +class LifecycleActionResponseSerializer(serializers.Serializer): + container_id = serializers.CharField() + name = serializers.CharField() + action = serializers.CharField() + status = serializers.CharField() + timestamp = serializers.DateTimeField() + +class MemoryStatsSerializer(serializers.Serializer): + usage_bytes = serializers.IntegerField() + limit_bytes = serializers.IntegerField() + percent = serializers.FloatField() + + +class NetworkStatsSerializer(serializers.Serializer): + rx_bytes = serializers.IntegerField() + tx_bytes = serializers.IntegerField() + + +class BlockIOStatsSerializer(serializers.Serializer): + read_bytes = serializers.IntegerField() + write_bytes = serializers.IntegerField() + + +class ContainerStatsSerializer(serializers.Serializer): + container_id = serializers.CharField() + name = serializers.CharField() + cpu_percent = serializers.FloatField() + memory = MemoryStatsSerializer() + network = NetworkStatsSerializer() + block_io = BlockIOStatsSerializer() + recorded_at = serializers.DateTimeField() + +class ContainerLogsSerializer(serializers.Serializer): + container_id = serializers.CharField() + name = serializers.CharField() + tail = serializers.IntegerField() + logs = serializers.ListField(child=serializers.CharField()) + + +class ExecTicketResponseSerializer(serializers.Serializer): + ticket = serializers.CharField() + ws_url = serializers.CharField() + expires_in_seconds = serializers.IntegerField() \ No newline at end of file diff --git a/backend/containers/services.py b/backend/containers/services.py new file mode 100644 index 0000000..137180d --- /dev/null +++ b/backend/containers/services.py @@ -0,0 +1,336 @@ +import docker +import docker.errors +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from containers.models import ContainerRecord, ContainerLifecycleEvent, ExecTicket +from containers.docker_client import ServiceUnavailable, get_docker_client + + +def _log_event(container, user, action, success, error=None): + ContainerLifecycleEvent.objects.create( + container=container, + triggered_by=user, + action=action, + status=ContainerLifecycleEvent.Status.SUCCESS if success + else ContainerLifecycleEvent.Status.FAILED, + error_message=error, + ) + + +def _get_sdk_container(record): + client = get_docker_client(record.host) + return client.containers.get(record.container_id) + + +def _default_command_for_image(image_ref, command): + if (command or '').strip(): + return command + + image = (image_ref or '').strip().lower() + if image == 'alpine' or image.startswith('alpine:'): + return 'sleep infinity' + if image == 'node' or image.startswith('node:'): + return 'sleep infinity' + + return '' + + +def _map_docker_state_to_record_status(state_status): + mapping = { + 'created': ContainerRecord.Status.CREATED, + 'running': ContainerRecord.Status.RUNNING, + 'paused': ContainerRecord.Status.PAUSED, + 'restarting': ContainerRecord.Status.RUNNING, + 'exited': ContainerRecord.Status.STOPPED, + 'dead': ContainerRecord.Status.KILLED, + } + return mapping.get((state_status or '').lower(), ContainerRecord.Status.STOPPED) + + +def sync_record_with_docker(record): + """ + Refresh a DB record from live Docker state. + If the container no longer exists on daemon, mark as REMOVED. + """ + try: + sdk_container = _get_sdk_container(record) + sdk_container.reload() + state_status = (sdk_container.attrs.get('State') or {}).get('Status') + resolved_status = _map_docker_state_to_record_status(state_status) + except docker.errors.NotFound: + resolved_status = ContainerRecord.Status.REMOVED + except ( + docker.errors.APIError, + docker.errors.DockerException, + ServiceUnavailable, + ): + return + + if record.status != resolved_status: + record.status = resolved_status + record.save(update_fields=['status', 'updated_at']) + + +def sync_host_records(host): + try: + client = get_docker_client(host) + sdk_containers = client.containers.list(all=True) + except ( + docker.errors.APIError, + docker.errors.DockerException, + ServiceUnavailable, + ): + return + + seen_ids = set() + + for sdk_container in sdk_containers: + sdk_container.reload() + state_status = (sdk_container.attrs.get('State') or {}).get('Status') + resolved_status = _map_docker_state_to_record_status(state_status) + image_ref = ( + (sdk_container.attrs.get('Config') or {}).get('Image') + or '' + ) + + ContainerRecord.objects.update_or_create( + container_id=sdk_container.id, + defaults={ + 'host': host, + 'name': sdk_container.name, + 'image_ref': image_ref, + 'status': resolved_status, + }, + ) + seen_ids.add(sdk_container.id) + + # Mark records that no longer exist on daemon as removed. + stale_qs = ContainerRecord.objects.filter(host=host).exclude( + container_id__in=seen_ids + ).exclude(status=ContainerRecord.Status.REMOVED) + stale_qs.update(status=ContainerRecord.Status.REMOVED) + +def create_container(host, user, image_ref, name, environment, + port_bindings, volumes, command=''): + + client = get_docker_client(host) + try: + resolved_command = _default_command_for_image(image_ref, command) + run_kwargs = { + 'image': image_ref, + 'name': name, + 'environment': environment, + 'ports': port_bindings, + 'volumes': volumes, + 'detach': True, + } + if resolved_command: + run_kwargs['command'] = resolved_command + + sdk_container = client.containers.run( + **run_kwargs + ) + + # Reflect the daemon-reported runtime state rather than assuming + # every created container is running. + sdk_container.reload() + state_status = (sdk_container.attrs.get('State') or {}).get('Status') + if isinstance(state_status, str) and state_status.strip(): + resolved_status = _map_docker_state_to_record_status(state_status) + else: + resolved_status = ContainerRecord.Status.RUNNING + + record = ContainerRecord.objects.create( + host=host, + created_by=user, + container_id=sdk_container.id, + name=name, + image_ref=image_ref, + status=resolved_status, + port_bindings=port_bindings, + environment=environment, + volumes=volumes, + ) + _log_event(record, user, ContainerLifecycleEvent.Action.CREATE, True) + return record, None + + except docker.errors.ImageNotFound: + return None, f'Image "{image_ref}" not found on host.' + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def remove_container(record, user): + try: + sdk_container = _get_sdk_container(record) + sdk_container.remove(force=True) + record.status = ContainerRecord.Status.REMOVED + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, ContainerLifecycleEvent.Action.REMOVE, True) + return None + + except docker.errors.NotFound: + record.status = ContainerRecord.Status.REMOVED + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, ContainerLifecycleEvent.Action.REMOVE, True) + return None + + except docker.errors.APIError as e: + _log_event(record, user, ContainerLifecycleEvent.Action.REMOVE, + False, error=str(e)) + return str(e.explanation) + + +# Maps SDK method name → resulting ContainerRecord status +_STATUS_MAP = { + 'start': ContainerRecord.Status.RUNNING, + 'stop': ContainerRecord.Status.STOPPED, + 'restart': ContainerRecord.Status.RUNNING, + 'kill': ContainerRecord.Status.KILLED, + 'pause': ContainerRecord.Status.PAUSED, + 'unpause': ContainerRecord.Status.RUNNING, +} + +# Maps SDK method name → ContainerLifecycleEvent action +_ACTION_MAP = { + 'start': ContainerLifecycleEvent.Action.START, + 'stop': ContainerLifecycleEvent.Action.STOP, + 'restart': ContainerLifecycleEvent.Action.RESTART, + 'kill': ContainerLifecycleEvent.Action.KILL, + 'pause': ContainerLifecycleEvent.Action.PAUSE, + 'unpause': ContainerLifecycleEvent.Action.UNPAUSE, +} + + +def lifecycle_action(record, user, sdk_method): + action = _ACTION_MAP[sdk_method] + try: + sdk_container = _get_sdk_container(record) + getattr(sdk_container, sdk_method)() + + record.status = _STATUS_MAP[sdk_method] + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, action, True) + return None + + except docker.errors.NotFound: + record.status = ContainerRecord.Status.REMOVED + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, action, True) + return None + + except docker.errors.APIError as e: + _log_event(record, user, action, False, error=str(e)) + return str(e.explanation) + +def get_container_stats(record): + try: + sdk_container = _get_sdk_container(record) + raw = sdk_container.stats(stream=False) + + cpu_stats = raw.get('cpu_stats') or {} + precpu_stats = raw.get('precpu_stats') or {} + cpu_usage = cpu_stats.get('cpu_usage') or {} + precpu_usage = precpu_stats.get('cpu_usage') or {} + + # Docker may omit system_cpu_usage for stopped/exited containers. + cpu_delta = ( + cpu_usage.get('total_usage', 0) + - precpu_usage.get('total_usage', 0) + ) + cpu_system = cpu_stats.get('system_cpu_usage') + precpu_system = precpu_stats.get('system_cpu_usage') + if cpu_system is None or precpu_system is None: + system_delta = 0 + else: + system_delta = cpu_system - precpu_system + + num_cpus = cpu_stats.get('online_cpus') + if not num_cpus: + num_cpus = len(cpu_usage.get('percpu_usage') or []) or 1 + + cpu_percent = ( + (cpu_delta / system_delta) * num_cpus * 100.0 + if system_delta > 0 else 0.0 + ) + + # Memory + mem = raw.get('memory_stats') or {} + mem_usage = mem.get('usage', 0) + mem_limit = mem.get('limit', 0) + mem_percent = round((mem_usage / mem_limit) * 100, 2) if mem_limit > 0 else 0.0 + + # Network — sum across all interfaces + net = raw.get('networks', {}) + rx = sum((v or {}).get('rx_bytes', 0) for v in net.values()) + tx = sum((v or {}).get('tx_bytes', 0) for v in net.values()) + + # Block I/O + blk = (raw.get('blkio_stats', {}) + .get('io_service_bytes_recursive')) or [] + read_bytes = next((b.get('value', 0) for b in blk if (b or {}).get('op') == 'Read'), 0) + write_bytes = next((b.get('value', 0) for b in blk if (b or {}).get('op') == 'Write'), 0) + + return { + 'container_id': record.container_id, + 'name': record.name, + 'cpu_percent': round(cpu_percent, 2), + 'memory': { + 'usage_bytes': mem_usage, + 'limit_bytes': mem_limit, + 'percent': mem_percent, + }, + 'network': { + 'rx_bytes': rx, + 'tx_bytes': tx, + }, + 'block_io': { + 'read_bytes': read_bytes, + 'write_bytes': write_bytes, + }, + 'recorded_at': timezone.now().isoformat(), + }, None + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def get_container_logs(record, tail=200, timestamps=False): + + try: + sdk_container = _get_sdk_container(record) + raw = sdk_container.logs( + tail=tail, + timestamps=timestamps, + stream=False + ) + lines = raw.decode(errors='replace').splitlines() + return lines, None + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def issue_exec_ticket(record, user): + + return ExecTicket.issue(container=record, user=user) + + +def validate_and_consume_ticket(ticket_value): + + try: + ticket = ExecTicket.objects.select_related( + 'container', 'container__host', 'issued_to' + ).get(ticket=ticket_value) + + if not ticket.is_valid(): + return None + + ticket.consume() + return ticket + + except ExecTicket.DoesNotExist: + return None + \ No newline at end of file diff --git a/backend/containers/tests/__init__.py b/backend/containers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tests/conftest.py b/backend/containers/tests/conftest.py new file mode 100644 index 0000000..aecdb36 --- /dev/null +++ b/backend/containers/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + '--integration', + action='store_true', + default=False, + help='Run integration tests against real Docker daemon' + ) + + +def pytest_configure(config): + config.addinivalue_line( + 'markers', + 'integration: mark test as requiring a real Docker daemon' + ) + + +def pytest_collection_modifyitems(config, items): + if not config.getoption('--integration'): + skip = pytest.mark.skip(reason='Pass --integration to run') + for item in items: + if 'integration' in item.keywords: + item.add_marker(skip) \ No newline at end of file diff --git a/backend/containers/tests/test_consumers.py b/backend/containers/tests/test_consumers.py new file mode 100644 index 0000000..00c0fca --- /dev/null +++ b/backend/containers/tests/test_consumers.py @@ -0,0 +1,323 @@ +import pytest +import json +from unittest.mock import MagicMock, patch, AsyncMock +from django.contrib.auth import get_user_model +from asgiref.sync import sync_to_async +from channels.testing import WebsocketCommunicator +from channels.routing import URLRouter +from django.urls import re_path + +from containers.models import Host, ContainerRecord, ExecTicket +from containers.consumers.exec_consumer import ExecConsumer +from containers.consumers.log_consumer import LogConsumer + +User = get_user_model() + + +# ── TEST APP ────────────────────────────────────────────────────────────── + +application = URLRouter([ + re_path( + r'^ws/hosts/(?P\d+)/containers/(?P[^/]+)/exec/$', + ExecConsumer.as_asgi() + ), + re_path( + r'^ws/hosts/(?P\d+)/containers/(?P[^/]+)/logs/$', + LogConsumer.as_asgi() + ), +]) + + +# ── ASYNC DB HELPERS ────────────────────────────────────────────────────── +# All DB calls inside async tests must go through sync_to_async. +# Never call ORM methods directly in async def test bodies. + +create_user = sync_to_async( + lambda: User.objects.get_or_create( + username='ws_test_user', + defaults={'is_active': True} + ) +) + +create_host = sync_to_async( + lambda: Host.objects.create( + name='local', ip_address='127.0.0.1', port=2375 + ) +) + + +@sync_to_async +def create_container(host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='sha256wsconsumer', + name='ws-test-nginx', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + ) + + +@sync_to_async +def issue_ticket(container, user): + return ExecTicket.issue(container, user) + + +@sync_to_async +def refresh_ticket(ticket): + ticket.refresh_from_db() + return ticket + + +@sync_to_async +def get_latest_event(action): + from containers.models import ContainerLifecycleEvent + return ContainerLifecycleEvent.objects.filter( + action=action + ).latest('timestamp') + + +# ── FIXTURES ────────────────────────────────────────────────────────────── + +@pytest.fixture +async def user(db): + u, _ = await create_user() + return u + + +@pytest.fixture +async def host(db): + return await create_host() + + +@pytest.fixture +async def container_record(db, host, user): + return await create_container(host, user) + + +@pytest.fixture +async def valid_ticket(db, container_record, user): + return await issue_ticket(container_record, user) + + +# ── EXEC CONSUMER TESTS ─────────────────────────────────────────────────── + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +class TestExecConsumer: + + async def test_connect_with_valid_ticket(self, valid_ticket): + """Valid ticket → connection accepted.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + connected, code = await communicator.connect() + assert connected is True + + response = await communicator.receive_json_from() + assert response['type'] == 'connected' + + await communicator.disconnect() + + async def test_connect_with_invalid_ticket(self, db): + """Invalid ticket → connection rejected.""" + communicator = WebsocketCommunicator( + application, + '/ws/hosts/1/containers/some-id/exec/' + '?ticket=completelyfaketicket' + ) + connected, code = await communicator.connect() + assert connected is False + assert code == 4001 + + async def test_connect_with_no_ticket(self, db): + """Missing ticket → connection rejected.""" + communicator = WebsocketCommunicator( + application, + '/ws/hosts/1/containers/some-id/exec/' + ) + connected, code = await communicator.connect() + assert connected is False + assert code == 4001 + + async def test_connect_consumes_ticket(self, valid_ticket): + """Ticket is marked used after connect.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + await communicator.connect() + await communicator.receive_json_from() + + refreshed = await refresh_ticket(valid_ticket) + assert refreshed.is_used is True + + await communicator.disconnect() + + async def test_ticket_cannot_be_reused(self, valid_ticket): + """Used ticket → second connection rejected.""" + url = ( + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + c1 = WebsocketCommunicator(application, url) + connected, _ = await c1.connect() + assert connected is True + await c1.receive_json_from() + await c1.disconnect() + + c2 = WebsocketCommunicator(application, url) + connected, code = await c2.connect() + assert connected is False + assert code == 4001 + + async def test_exec_open_event_logged(self, valid_ticket): + """EXEC_OPEN written to DB on connect.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + await communicator.connect() + await communicator.receive_json_from() + await communicator.disconnect() + + event = await get_latest_event('EXEC_OPEN') + assert event.action == 'EXEC_OPEN' + assert event.status == 'SUCCESS' + + async def test_exec_close_event_logged(self, valid_ticket): + """EXEC_CLOSE written to DB on disconnect.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + await communicator.connect() + await communicator.receive_json_from() + await communicator.disconnect() + + event = await get_latest_event('EXEC_CLOSE') + assert event.action == 'EXEC_CLOSE' + assert event.status == 'SUCCESS' + + async def test_input_returns_output(self, valid_ticket): + """Sending input returns exec output.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + await communicator.connect() + await communicator.receive_json_from() # welcome + + with patch.object( + ExecConsumer, + '_run_exec', + new=AsyncMock(return_value=('output of: ls -la', None)) + ): + await communicator.send_json_to({ + 'type': 'input', + 'data': 'ls -la' + }) + response = await communicator.receive_json_from() + assert response['type'] == 'output' + assert 'ls -la' in response['data'] + + await communicator.disconnect() + + async def test_invalid_json_returns_error(self, valid_ticket): + """Non-JSON input returns error response.""" + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{valid_ticket.container_id}/exec/' + f'?ticket={valid_ticket.ticket}' + ) + await communicator.connect() + await communicator.receive_json_from() # welcome + + await communicator.send_to(text_data='not json at all') + response = await communicator.receive_json_from() + assert response['type'] == 'error' + + await communicator.disconnect() + + +# ── LOG CONSUMER TESTS ──────────────────────────────────────────────────── + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +class TestLogConsumer: + + async def test_connect_with_valid_ticket(self, container_record, user): + """Valid ticket → log consumer accepts connection.""" + ticket = await issue_ticket(container_record, user) + + with patch.object( + LogConsumer, '_stream_logs', + new=AsyncMock(return_value=None) + ): + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{container_record.id}/logs/' + f'?ticket={ticket.ticket}' + ) + connected, code = await communicator.connect() + assert connected is True + await communicator.disconnect() + + async def test_connect_with_invalid_ticket(self, db): + """Invalid ticket → rejected.""" + communicator = WebsocketCommunicator( + application, + '/ws/hosts/1/containers/some-id/logs/?ticket=faketicket' + ) + connected, code = await communicator.connect() + assert connected is False + assert code == 4001 + + async def test_input_rejected(self, container_record, user): + """Log consumer is read-only — input returns error.""" + ticket = await issue_ticket(container_record, user) + + with patch.object( + LogConsumer, '_stream_logs', + new=AsyncMock(return_value=None) + ): + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{container_record.id}/logs/' + f'?ticket={ticket.ticket}' + ) + await communicator.connect() + + await communicator.send_json_to({'type': 'input', 'data': 'anything'}) + response = await communicator.receive_json_from() + assert response['type'] == 'error' + assert 'read-only' in response['data'] + + await communicator.disconnect() + + async def test_log_ticket_consumed_on_connect( + self, container_record, user + ): + """Ticket marked used after log consumer connects.""" + ticket = await issue_ticket(container_record, user) + + with patch.object( + LogConsumer, '_stream_logs', + new=AsyncMock(return_value=None) + ): + communicator = WebsocketCommunicator( + application, + f'/ws/hosts/1/containers/{container_record.id}/logs/' + f'?ticket={ticket.ticket}' + ) + await communicator.connect() + await communicator.disconnect() + + refreshed = await refresh_ticket(ticket) + assert refreshed.is_used is True \ No newline at end of file diff --git a/backend/containers/tests/test_integration.py b/backend/containers/tests/test_integration.py new file mode 100644 index 0000000..9b82726 --- /dev/null +++ b/backend/containers/tests/test_integration.py @@ -0,0 +1,146 @@ +import pytest +import docker + +# This marker means these tests only run when you +# explicitly pass --integration flag +# Run with: pytest --integration -v +# Normal run skips these automatically + + +@pytest.fixture(scope='module') +def docker_client(): + """Real Docker client — requires daemon running.""" + try: + client = docker.DockerClient( + base_url='unix:///var/run/docker.sock' + ) + client.ping() + return client + except Exception: + pytest.skip('Docker daemon not reachable') + + +@pytest.fixture +def host(db): + from containers.models import Host + return Host.objects.create( + name='local-integration', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def user(db): + from django.contrib.auth import get_user_model + User = get_user_model() + return User.objects.create_user( + username='integration_user', + password='pass' + ) + + +@pytest.mark.integration +class TestRealDockerLifecycle: + """ + These tests spin up real containers. + Requires Docker daemon running locally. + Each test cleans up after itself. + """ + + def test_create_and_remove_container( + self, db, docker_client, host, user + ): + from containers import services + + # Create + record, error = services.create_container( + host=host, + user=user, + image_ref='nginx:alpine', + name='integration-test-nginx', + environment={}, + port_bindings={}, + volumes=[], + ) + + assert error is None + assert record is not None + assert record.status == 'RUNNING' + + # Verify it actually exists on the daemon + sdk_container = docker_client.containers.get(record.container_id) + assert sdk_container.status == 'running' + + # Clean up — remove it + remove_error = services.remove_container(record, user) + assert remove_error is None + record.refresh_from_db() + assert record.status == 'REMOVED' + + def test_stop_and_start(self, db, docker_client, host, user): + from containers import services + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-stop', + environment={}, port_bindings={}, volumes=[], + ) + + # Stop it + error = services.lifecycle_action(record, user, 'stop') + assert error is None + record.refresh_from_db() + assert record.status == 'STOPPED' + + # Verify on daemon + sdk_container = docker_client.containers.get(record.container_id) + assert sdk_container.status == 'exited' + + # Clean up + services.remove_container(record, user) + + def test_real_stats_shape(self, db, docker_client, host, user): + from containers import services + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-stats', + environment={}, port_bindings={}, volumes=[], + ) + + stats, error = services.get_container_stats(record) + + assert error is None + # Verify the real Docker response has the shape we expect + assert 'cpu_percent' in stats + assert 'memory' in stats + assert 'usage_bytes' in stats['memory'] + assert 'limit_bytes' in stats['memory'] + assert 'network' in stats + assert 'block_io' in stats + + # Clean up + services.remove_container(record, user) + + def test_real_logs(self, db, docker_client, host, user): + from containers import services + import time + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-logs', + environment={}, port_bindings={}, volumes=[], + ) + + time.sleep(1) # give nginx a second to write startup logs + lines, error = services.get_container_logs(record, tail=50) + + assert error is None + assert isinstance(lines, list) + assert len(lines) > 0 # nginx always logs on startup + + services.remove_container(record, user) \ No newline at end of file diff --git a/backend/containers/tests/test_models.py b/backend/containers/tests/test_models.py new file mode 100644 index 0000000..08ccb1a --- /dev/null +++ b/backend/containers/tests/test_models.py @@ -0,0 +1,155 @@ +import pytest +from django.utils import timezone +from datetime import timedelta +from containers.models import Host, ContainerRecord, ContainerLifecycleEvent, ExecTicket +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.fixture +def user(db): + return User.objects.create_user( + username='testuser', + password='testpass123' + ) + + +@pytest.fixture +def host(db): + return Host.objects.create( + name='local-docker', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def container_record(db, host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='abc123def456abc123def456', + name='test-nginx', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + port_bindings={'80/tcp': [{'HostPort': '8080'}]}, + environment={'ENV': 'dev'}, + volumes=[], + ) + +class TestHost: + + def test_host_created(self, host): + assert host.name == 'local-docker' + assert host.ip_address == '127.0.0.1' + assert host.port == 2375 + + def test_host_str(self, host): + assert str(host) == 'local-docker (127.0.0.1:2375)' + +class TestContainerRecord: + + def test_created_successfully(self, container_record): + assert container_record.name == 'test-nginx' + assert container_record.image_ref == 'nginx:alpine' + assert container_record.status == 'RUNNING' + + def test_str(self, container_record): + assert str(container_record) == 'test-nginx [RUNNING]' + + def test_uuid_primary_key(self, container_record): + assert container_record.id is not None + assert len(str(container_record.id)) == 36 # UUID format + + def test_default_port_bindings(self, db, host, user): + record = ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='xyz999', + name='bare-container', + image_ref='alpine:latest', + ) + assert record.port_bindings == {} + assert record.environment == {} + assert record.volumes == [] + + def test_all_status_transitions(self, container_record): + for s in ContainerRecord.Status: + container_record.status = s + container_record.save() + container_record.refresh_from_db() + assert container_record.status == s.value + + def test_ordering_newest_first(self, db, host, user): + r1 = ContainerRecord.objects.create( + host=host, created_by=user, + container_id='first111', name='first', + image_ref='nginx:alpine', + ) + r2 = ContainerRecord.objects.create( + host=host, created_by=user, + container_id='second222', name='second', + image_ref='nginx:alpine', + ) + records = list(ContainerRecord.objects.all()) + assert records[0].name == 'second' # newest first + assert records[1].name == 'first' + +class TestContainerLifecycleEvent: + + def test_success_event(self, db, container_record, user): + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=ContainerLifecycleEvent.Action.START, + status=ContainerLifecycleEvent.Status.SUCCESS, + ) + assert event.action == 'START' + assert event.status == 'SUCCESS' + assert event.error_message is None + + def test_failed_event_stores_error(self, db, container_record, user): + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=ContainerLifecycleEvent.Action.STOP, + status=ContainerLifecycleEvent.Status.FAILED, + error_message='Container already stopped', + ) + assert event.status == 'FAILED' + assert event.error_message == 'Container already stopped' + + def test_all_actions_are_valid(self, db, container_record, user): + for action in ContainerLifecycleEvent.Action: + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=action, + status=ContainerLifecycleEvent.Status.SUCCESS, + ) + assert event.action == action.value + + def test_event_linked_to_container(self, db, container_record, user): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='START', + status='SUCCESS', + ) + assert container_record.events.count() == 1 + +class TestExecTicket: + + def test_issue_creates_ticket(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) + assert ticket.ticket is not None + assert len(ticket.ticket) == 64 + assert ticket.is_used is False + assert ticket.expires_at > timezone.now() + + def test_fresh_ticket_is_valid(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) + assert ticket.is_valid() is True + + def test_consumed_ticket_is_invalid(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) \ No newline at end of file diff --git a/backend/containers/tests/test_services.py b/backend/containers/tests/test_services.py new file mode 100644 index 0000000..b24f1f1 --- /dev/null +++ b/backend/containers/tests/test_services.py @@ -0,0 +1,448 @@ +import pytest +from unittest.mock import MagicMock, patch, PropertyMock +from django.utils import timezone +from datetime import timedelta +from django.contrib.auth import get_user_model + +from containers.models import Host, ContainerRecord, ContainerLifecycleEvent, ExecTicket +from containers import services + +User = get_user_model() + +@pytest.fixture +def user(db): + return User.objects.create_user( + username='svcuser', + password='pass123' + ) + + +@pytest.fixture +def host(db): + return Host.objects.create( + name='local', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def running_container(db, host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='sha256running', + name='web', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + ) + + +@pytest.fixture +def mock_docker(monkeypatch): + """ + Patches get_docker_client so no real Docker daemon is needed. + Returns the mock SDK client so tests can configure it. + """ + mock_client = MagicMock() + monkeypatch.setattr( + 'containers.services.get_docker_client', + lambda host: mock_client + ) + return mock_client + +class TestCreateContainer: + + def test_creates_record_on_success(self, db, host, user, mock_docker): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256newcontainer' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, + user=user, + image_ref='nginx:alpine', + name='new-web', + environment={'ENV': 'dev'}, + port_bindings={'80/tcp': [{'HostPort': '8080'}]}, + volumes=[], + ) + + assert error is None + assert record is not None + assert record.name == 'new-web' + assert record.status == ContainerRecord.Status.RUNNING + assert record.container_id == 'sha256newcontainer' + + def test_logs_create_event_on_success(self, db, host, user, mock_docker): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256abc' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', name='new-web', + environment={}, port_bindings={}, volumes=[], + ) + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.action == 'CREATE' + assert event.status == 'SUCCESS' + + def test_returns_error_on_image_not_found(self, db, host, user, mock_docker): + import docker.errors + mock_docker.containers.run.side_effect = \ + docker.errors.ImageNotFound('nginx:missing') + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:missing', name='fail-web', + environment={}, port_bindings={}, volumes=[], + ) + + assert record is None + assert 'nginx:missing' in error + + def test_returns_error_on_api_error(self, db, host, user, mock_docker): + import docker.errors + mock_docker.containers.run.side_effect = \ + docker.errors.APIError('port already allocated') + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', name='conflict-web', + environment={}, port_bindings={}, volumes=[], + ) + + assert record is None + assert error is not None + + def test_node_image_defaults_to_keepalive_when_command_empty( + self, db, host, user, mock_docker + ): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256nodekeepalive' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, + user=user, + image_ref='node:25', + name='node-keepalive', + environment={}, + port_bindings={}, + volumes=[], + command='', + ) + + assert error is None + assert record is not None + mock_docker.containers.run.assert_called_once() + assert mock_docker.containers.run.call_args.kwargs['command'] == 'sleep infinity' + + def test_explicit_command_overrides_default( + self, db, host, user, mock_docker + ): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256nodecustomcmd' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, + user=user, + image_ref='node:25', + name='node-custom-cmd', + environment={}, + port_bindings={}, + volumes=[], + command='node -e "setInterval(() => {}, 60000)"', + ) + + assert error is None + assert record is not None + mock_docker.containers.run.assert_called_once() + assert mock_docker.containers.run.call_args.kwargs['command'] == 'node -e "setInterval(() => {}, 60000)"' + +class TestLifecycleAction: + + @pytest.mark.parametrize('sdk_method,expected_status', [ + ('start', 'RUNNING'), + ('stop', 'STOPPED'), + ('restart', 'RUNNING'), + ('kill', 'KILLED'), + ('pause', 'PAUSED'), + ('unpause', 'RUNNING'), + ]) + def test_success_updates_status( + self, db, running_container, user, mock_docker, sdk_method, expected_status + ): + mock_docker.containers.get.return_value = MagicMock() + + error = services.lifecycle_action(running_container, user, sdk_method) + + assert error is None + running_container.refresh_from_db() + assert running_container.status == expected_status + +class TestSyncRecords: + + def test_sync_marks_removed_when_container_missing(self, db, running_container, mock_docker): + import docker.errors + mock_docker.containers.get.side_effect = docker.errors.NotFound('missing') + + services.sync_record_with_docker(running_container) + + running_container.refresh_from_db() + assert running_container.status == 'REMOVED' + + @pytest.mark.parametrize('sdk_method', [ + 'start', 'stop', 'restart', 'kill', 'pause', 'unpause' + ]) + def test_success_logs_event( + self, db, running_container, user, mock_docker, sdk_method + ): + mock_docker.containers.get.return_value = MagicMock() + + services.lifecycle_action(running_container, user, sdk_method) + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.status == 'SUCCESS' + assert event.action == sdk_method.upper() + + def test_docker_error_returns_error_string( + self, db, running_container, user, mock_docker + ): + import docker.errors + mock_sdk_container = MagicMock() + mock_sdk_container.stop.side_effect = \ + docker.errors.APIError('container already stopped') + mock_docker.containers.get.return_value = mock_sdk_container + + error = services.lifecycle_action(running_container, user, 'stop') + + assert error is not None + + def test_docker_error_logs_failed_event( + self, db, running_container, user, mock_docker + ): + import docker.errors + mock_sdk_container = MagicMock() + mock_sdk_container.stop.side_effect = \ + docker.errors.APIError('already stopped') + mock_docker.containers.get.return_value = mock_sdk_container + + services.lifecycle_action(running_container, user, 'stop') + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.status == 'FAILED' + assert event.error_message is not None + + def test_sync_host_records_discovers_daemon_containers( + self, db, running_container, mock_docker + ): + sdk_container = MagicMock() + sdk_container.id = 'sha256daemon' + sdk_container.name = 'daemon-nginx' + sdk_container.attrs = { + 'State': {'Status': 'running'}, + 'Config': {'Image': 'nginx:alpine'}, + } + sdk_container.reload.return_value = None + mock_docker.containers.list.return_value = [sdk_container] + + services.sync_host_records(running_container.host) + + discovered = ContainerRecord.objects.get(container_id='sha256daemon') + assert discovered.host == running_container.host + assert discovered.name == 'daemon-nginx' + assert discovered.image_ref == 'nginx:alpine' + assert discovered.status == ContainerRecord.Status.RUNNING + + def test_sync_host_records_marks_missing_containers_removed( + self, db, running_container, mock_docker + ): + mock_docker.containers.list.return_value = [] + + services.sync_host_records(running_container.host) + + running_container.refresh_from_db() + assert running_container.status == ContainerRecord.Status.REMOVED + +class TestGetContainerStats: + + @pytest.fixture + def mock_stats_response(self): + return { + 'cpu_stats': { + 'cpu_usage': {'total_usage': 200}, + 'system_cpu_usage': 1000, + 'online_cpus': 2, + }, + 'precpu_stats': { + 'cpu_usage': {'total_usage': 100}, + 'system_cpu_usage': 900, + }, + 'memory_stats': { + 'usage': 50 * 1024 * 1024, + 'limit': 512 * 1024 * 1024, + }, + 'networks': { + 'eth0': {'rx_bytes': 1024, 'tx_bytes': 512}, + }, + 'blkio_stats': { + 'io_service_bytes_recursive': [ + {'op': 'Read', 'value': 8192}, + {'op': 'Write', 'value': 4096}, + ], + }, + } + + def test_returns_correct_cpu_percent( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert error is None + # cpu_delta=100, system_delta=100, cpus=2 → 200%... wait + # cpu_delta=100, system_delta=100, cpus=2 → (100/100)*2*100 = 200? No: + # (100/100) * 2 * 100.0 = 200.0 — let's just check it's a number + assert isinstance(stats['cpu_percent'], float) + + def test_returns_memory_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['memory']['usage_bytes'] == 50 * 1024 * 1024 + assert stats['memory']['limit_bytes'] == 512 * 1024 * 1024 + assert stats['memory']['percent'] == round(50 / 512 * 100, 2) + + def test_returns_network_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['network']['rx_bytes'] == 1024 + assert stats['network']['tx_bytes'] == 512 + + def test_returns_block_io_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['block_io']['read_bytes'] == 8192 + assert stats['block_io']['write_bytes'] == 4096 + + def test_docker_error_returns_error( + self, db, running_container, mock_docker + ): + import docker.errors + mock_docker.containers.get.return_value.stats.side_effect = \ + docker.errors.APIError('container not running') + + stats, error = services.get_container_stats(running_container) + + assert stats is None + assert error is not None + + def test_missing_system_cpu_usage_does_not_crash( + self, db, running_container, mock_docker + ): + mock_docker.containers.get.return_value.stats.return_value = { + 'cpu_stats': { + 'cpu_usage': {'total_usage': 0}, + }, + 'precpu_stats': { + 'cpu_usage': {'total_usage': 0}, + }, + 'memory_stats': {}, + 'blkio_stats': {'io_service_bytes_recursive': None}, + } + + stats, error = services.get_container_stats(running_container) + + assert error is None + assert stats is not None + assert stats['cpu_percent'] == 0.0 + assert stats['memory']['percent'] == 0.0 + +class TestGetContainerLogs: + + def test_returns_log_lines(self, db, running_container, mock_docker): + mock_docker.containers.get.return_value.logs.return_value = \ + b'line one\nline two\nline three' + + lines, error = services.get_container_logs(running_container, tail=200) + + assert error is None + assert lines == ['line one', 'line two', 'line three'] + + def test_respects_tail_param(self, db, running_container, mock_docker): + mock_docker.containers.get.return_value.logs.return_value = b'one line' + + services.get_container_logs(running_container, tail=50) + + mock_docker.containers.get.return_value.logs.assert_called_once_with( + tail=50, timestamps=False, stream=False + ) + + def test_docker_error_returns_error(self, db, running_container, mock_docker): + import docker.errors + mock_docker.containers.get.return_value.logs.side_effect = \ + docker.errors.APIError('container not found') + + lines, error = services.get_container_logs(running_container) + + assert lines is None + assert error is not None + +class TestExecTicketService: + + def test_issue_returns_valid_ticket(self, db, running_container, user): + ticket = services.issue_exec_ticket(running_container, user) + + assert ticket.is_valid() is True + assert ticket.container == running_container + assert ticket.issued_to == user + + def test_validate_and_consume_valid_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is not None + assert result.is_used is True + + def test_validate_rejects_used_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + ticket.consume() + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is None + + def test_validate_rejects_expired_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + ticket.expires_at = timezone.now() - timedelta(seconds=1) + ticket.save() + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is None + + def test_validate_rejects_nonexistent_ticket(self, db): + result = services.validate_and_consume_ticket('doesnotexist') + + assert result is None \ No newline at end of file diff --git a/backend/containers/tests/test_views.py b/backend/containers/tests/test_views.py new file mode 100644 index 0000000..c7fa9e7 --- /dev/null +++ b/backend/containers/tests/test_views.py @@ -0,0 +1,590 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken +from unittest.mock import MagicMock +from django.contrib.auth import get_user_model + +from containers.models import Host, ContainerRecord, ContainerLifecycleEvent, ExecTicket +from hosts.models import Host as AccessHost, UserHostRole + +User = get_user_model() + +@pytest.fixture +def client(user): + token = str(AccessToken.for_user(user)) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + return client + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username='viewuser', + password='pass123', + role='admin', + ) + + +@pytest.fixture +def host(db): + return Host.objects.create( + name='local', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def container_record(db, host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='sha256viewtest', + name='view-test-nginx', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + ) + + +@pytest.fixture +def mock_docker(monkeypatch): + """Patches get_docker_client at the services layer.""" + mock_client = MagicMock() + monkeypatch.setattr( + 'containers.services.get_docker_client', + lambda host: mock_client + ) + return mock_client + +class TestContainerListCreateView: + + def test_list_returns_200(self, client, db, host, container_record, mock_docker): + sdk_mock = MagicMock() + sdk_mock.attrs = {'State': {'Status': 'running'}} + sdk_mock.id = container_record.container_id + sdk_mock.name = container_record.name + sdk_mock.reload.return_value = None + sdk_mock.attrs['Config'] = {'Image': container_record.image_ref} + mock_docker.containers.list.return_value = [sdk_mock] + response = client.get(f'/api/hosts/{host.id}/containers/') + assert response.status_code == 200 + + def test_list_returns_correct_count( + self, client, db, host, container_record, mock_docker + ): + sdk_mock = MagicMock() + sdk_mock.attrs = {'State': {'Status': 'running'}} + sdk_mock.id = container_record.container_id + sdk_mock.name = container_record.name + sdk_mock.reload.return_value = None + sdk_mock.attrs['Config'] = {'Image': container_record.image_ref} + mock_docker.containers.list.return_value = [sdk_mock] + response = client.get(f'/api/hosts/{host.id}/containers/') + assert response.data['count'] == 1 + assert response.data['results'][0]['name'] == 'view-test-nginx' + + def test_list_filters_by_status( + self, client, db, host, container_record, mock_docker + ): + sdk_mock = MagicMock() + sdk_mock.attrs = {'State': {'Status': 'running'}} + sdk_mock.id = container_record.container_id + sdk_mock.name = container_record.name + sdk_mock.reload.return_value = None + sdk_mock.attrs['Config'] = {'Image': container_record.image_ref} + mock_docker.containers.list.return_value = [sdk_mock] + response = client.get( + f'/api/hosts/{host.id}/containers/?status=running' + ) + assert response.data['count'] == 1 + + response = client.get( + f'/api/hosts/{host.id}/containers/?status=stopped' + ) + assert response.data['count'] == 0 + + def test_list_hides_removed_records_by_default( + self, client, db, host, container_record, mock_docker + ): + # Daemon no longer has the container, so sync marks it REMOVED. + mock_docker.containers.list.return_value = [] + + response = client.get(f'/api/hosts/{host.id}/containers/') + + assert response.status_code == 200 + assert response.data['count'] == 0 + + response_removed = client.get( + f'/api/hosts/{host.id}/containers/?status=removed' + ) + assert response_removed.status_code == 200 + assert response_removed.data['count'] == 1 + + def test_list_returns_404_for_unknown_host(self, client, db): + response = client.get('/api/hosts/99999/containers/') + assert response.status_code == 404 + + def test_create_returns_201( + self, client, db, host, user, mock_docker + ): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256newone' + mock_docker.containers.run.return_value = mock_sdk_container + + response = client.post( + f'/api/hosts/{host.id}/containers/', + { + 'image_ref': 'nginx:alpine', + 'name': 'brand-new', + }, + format='json' + ) + assert response.status_code == 201 + assert response.data['name'] == 'brand-new' + assert response.data['status'] == 'RUNNING' + + def test_create_returns_400_on_missing_fields( + self, client, db, host + ): + response = client.post( + f'/api/hosts/{host.id}/containers/', + {'name': 'missing-image'}, + format='json' + ) + assert response.status_code == 400 + assert 'image_ref' in response.data + + def test_create_returns_400_on_docker_error( + self, client, db, host, mock_docker + ): + import docker.errors + mock_docker.containers.run.side_effect = \ + docker.errors.APIError('port already in use') + + response = client.post( + f'/api/hosts/{host.id}/containers/', + {'image_ref': 'nginx:alpine', 'name': 'fail-web'}, + format='json' + ) + assert response.status_code == 400 + assert 'error' in response.data + + def test_assigned_admin_can_create_container( + self, db, host, user, mock_docker + ): + # Global role is viewer, but host assignment grants ADMIN on this host. + assigned_user = User.objects.create_user( + username='assigned-admin', + password='pass123', + role='viewer', + ) + + access_host = AccessHost.objects.create( + alias='shared-host', + ip_address=host.ip_address, + port=host.port, + created_by=user, + ) + UserHostRole.objects.create( + user=assigned_user, + host=access_host, + role='ADMIN', + assigned_by=user, + ) + + token = str(AccessToken.for_user(assigned_user)) + assigned_client = APIClient() + assigned_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256assigned' + mock_docker.containers.run.return_value = mock_sdk_container + + response = assigned_client.post( + f'/api/hosts/{host.id}/containers/', + { + 'image_ref': 'nginx:alpine', + 'name': 'assigned-create', + }, + format='json', + ) + + assert response.status_code == 201 + assert response.data['name'] == 'assigned-create' + +class TestContainerDetailView: + + def test_get_returns_200( + self, client, db, host, container_record + ): + response = client.get( + f'/api/hosts/{host.id}/containers/{container_record.id}/' + ) + assert response.status_code == 200 + assert response.data['name'] == 'view-test-nginx' + + def test_get_returns_404_for_unknown_container( + self, client, db, host + ): + import uuid + response = client.get( + f'/api/hosts/{host.id}/containers/{uuid.uuid4()}/' + ) + assert response.status_code == 404 + + def test_delete_returns_200( + self, client, db, host, container_record, mock_docker + ): + mock_docker.containers.get.return_value = MagicMock() + + response = client.delete( + f'/api/hosts/{host.id}/containers/{container_record.id}/' + ) + assert response.status_code == 200 + container_record.refresh_from_db() + assert container_record.status == 'REMOVED' + + def test_delete_returns_400_on_docker_error( + self, client, db, host, container_record, mock_docker + ): + import docker.errors + mock_docker.containers.get.return_value.remove.side_effect = \ + docker.errors.APIError('removal failed') + + response = client.delete( + f'/api/hosts/{host.id}/containers/{container_record.id}/' + ) + assert response.status_code == 400 + + def test_assigned_admin_can_delete_container( + self, db, host, container_record, user, mock_docker + ): + assigned_user = User.objects.create_user( + username='assigned-admin-delete', + password='pass123', + role='viewer', + ) + + access_host = AccessHost.objects.create( + alias='shared-host-delete-admin', + ip_address=host.ip_address, + port=host.port, + created_by=user, + ) + UserHostRole.objects.create( + user=assigned_user, + host=access_host, + role='ADMIN', + assigned_by=user, + ) + + token = str(AccessToken.for_user(assigned_user)) + assigned_client = APIClient() + assigned_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + mock_docker.containers.get.return_value = MagicMock() + + response = assigned_client.delete( + f'/api/hosts/{host.id}/containers/{container_record.id}/' + ) + assert response.status_code == 200 + + def test_assigned_host_owner_can_delete_container( + self, db, host, container_record, user, mock_docker + ): + assigned_user = User.objects.create_user( + username='assigned-owner-delete', + password='pass123', + role='viewer', + ) + + access_host = AccessHost.objects.create( + alias='shared-host-delete-owner', + ip_address=host.ip_address, + port=host.port, + created_by=user, + ) + UserHostRole.objects.create( + user=assigned_user, + host=access_host, + role='HOST_OWNER', + assigned_by=user, + ) + + token = str(AccessToken.for_user(assigned_user)) + assigned_client = APIClient() + assigned_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + + mock_docker.containers.get.return_value = MagicMock() + + response = assigned_client.delete( + f'/api/hosts/{host.id}/containers/{container_record.id}/' + ) + assert response.status_code == 200 + + +class TestLifecycleViews: + + @pytest.mark.parametrize('action,expected_status', [ + ('start', 'RUNNING'), + ('stop', 'STOPPED'), + ('restart', 'RUNNING'), + ('kill', 'KILLED'), + ('pause', 'PAUSED'), + ('unpause', 'RUNNING'), + ]) + def test_action_returns_200( + self, client, db, host, container_record, + mock_docker, action, expected_status + ): + mock_docker.containers.get.return_value = MagicMock() + + response = client.post( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/{action}/' + ) + assert response.status_code == 200 + assert response.data['action'] == action.upper() + assert response.data['status'] == 'SUCCESS' + + @pytest.mark.parametrize('action', [ + 'start', 'stop', 'restart', 'kill', 'pause', 'unpause' + ]) + def test_action_returns_409_on_docker_error( + self, client, db, host, container_record, mock_docker, action + ): + import docker.errors + sdk_mock = MagicMock() + getattr(sdk_mock, action).side_effect = \ + docker.errors.APIError('operation failed') + mock_docker.containers.get.return_value = sdk_mock + + response = client.post( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/{action}/' + ) + assert response.status_code == 409 + assert response.data['status'] == 'FAILED' + + @pytest.mark.parametrize('action', [ + 'start', 'stop', 'restart', 'kill', 'pause', 'unpause' + ]) + def test_action_writes_lifecycle_event( + self, client, db, host, container_record, mock_docker, action + ): + mock_docker.containers.get.return_value = MagicMock() + + client.post( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/{action}/' + ) + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.action == action.upper() + assert event.status == 'SUCCESS' + +class TestStatsView: + + @pytest.fixture + def mock_stats(self): + return { + 'cpu_stats': { + 'cpu_usage': {'total_usage': 200}, + 'system_cpu_usage': 1000, + 'online_cpus': 2, + }, + 'precpu_stats': { + 'cpu_usage': {'total_usage': 100}, + 'system_cpu_usage': 900, + }, + 'memory_stats': { + 'usage': 50 * 1024 * 1024, + 'limit': 512 * 1024 * 1024, + }, + 'networks': { + 'eth0': {'rx_bytes': 1024, 'tx_bytes': 512}, + }, + 'blkio_stats': { + 'io_service_bytes_recursive': [ + {'op': 'Read', 'value': 8192}, + {'op': 'Write', 'value': 4096}, + ], + }, + } + + def test_stats_returns_200( + self, client, db, host, container_record, + mock_docker, mock_stats + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats + + response = client.get( + f'/api/hosts/{host.id}/containers/{container_record.id}/stats/' + ) + assert response.status_code == 200 + assert 'cpu_percent' in response.data + assert 'memory' in response.data + assert 'network' in response.data + assert 'block_io' in response.data + + def test_stats_returns_400_on_docker_error( + self, client, db, host, container_record, mock_docker + ): + import docker.errors + mock_docker.containers.get.return_value.stats.side_effect = \ + docker.errors.APIError('container not running') + + response = client.get( + f'/api/hosts/{host.id}/containers/{container_record.id}/stats/' + ) + assert response.status_code == 400 + +class TestLogsView: + + def test_logs_returns_200( + self, client, db, host, container_record, mock_docker + ): + mock_docker.containers.get.return_value.logs.return_value = \ + b'line one\nline two\nline three' + + response = client.get( + f'/api/hosts/{host.id}/containers/{container_record.id}/logs/' + ) + assert response.status_code == 200 + assert response.data['logs'] == ['line one', 'line two', 'line three'] + assert response.data['tail'] == 200 + + def test_logs_respects_tail_param( + self, client, db, host, container_record, mock_docker + ): + mock_docker.containers.get.return_value.logs.return_value = b'one' + + client.get( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/logs/?tail=50' + ) + mock_docker.containers.get.return_value.logs.assert_called_once_with( + tail=50, timestamps=False, stream=False + ) + + def test_log_stream_ticket_returns_ticket( + self, client, db, host, container_record + ): + response = client.post( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/logs/stream/' + ) + assert response.status_code == 200 + assert 'ticket' in response.data + assert 'ws_url' in response.data + assert response.data['expires_in_seconds'] == 30 + +class TestExecTicketView: + + def test_returns_ticket_and_ws_url( + self, client, db, host, container_record + ): + response = client.post( + f'/api/hosts/{host.id}/containers/{container_record.id}/exec/' + ) + assert response.status_code == 200 + assert 'ticket' in response.data + assert 'ws_url' in response.data + assert len(response.data['ticket']) == 64 + assert response.data['expires_in_seconds'] == 30 + + def test_ticket_is_stored_in_db( + self, client, db, host, container_record + ): + response = client.post( + f'/api/hosts/{host.id}/containers/{container_record.id}/exec/' + ) + ticket_value = response.data['ticket'] + assert ExecTicket.objects.filter(ticket=ticket_value).exists() + + def test_each_request_generates_unique_ticket( + self, client, db, host, container_record + ): + r1 = client.post( + f'/api/hosts/{host.id}/containers/{container_record.id}/exec/' + ) + r2 = client.post( + f'/api/hosts/{host.id}/containers/{container_record.id}/exec/' + ) + assert r1.data['ticket'] != r2.data['ticket'] + +class TestEventListView: + + def test_returns_200_with_events( + self, client, db, host, container_record, user + ): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='START', + status='SUCCESS', + ) + response = client.get( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/events/' + ) + assert response.status_code == 200 + assert response.data['count'] == 1 + + def test_filters_by_action( + self, client, db, host, container_record, user + ): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='START', + status='SUCCESS', + ) + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='STOP', + status='SUCCESS', + ) + response = client.get( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/events/?action=START' + ) + assert response.data['count'] == 1 + assert response.data['results'][0]['action'] == 'START' + + def test_filters_by_status( + self, client, db, host, container_record, user + ): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='STOP', + status='FAILED', + error_message='already stopped', + ) + response = client.get( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/events/?status=FAILED' + ) + assert response.data['count'] == 1 + assert response.data['results'][0]['status'] == 'FAILED' + + def test_pagination( + self, client, db, host, container_record, user + ): + for i in range(5): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='START', + status='SUCCESS', + ) + response = client.get( + f'/api/hosts/{host.id}/containers/' + f'{container_record.id}/events/?page=1&page_size=2' + ) + assert response.data['count'] == 5 + assert len(response.data['results']) == 2 \ No newline at end of file diff --git a/backend/containers/urls.py b/backend/containers/urls.py new file mode 100644 index 0000000..590395a --- /dev/null +++ b/backend/containers/urls.py @@ -0,0 +1,85 @@ +from django.urls import path +from containers.views import ( + ContainerHostBootstrapView, + ContainerHostResolveView, + ContainerListCreateView, + ContainerDetailView, + ContainerStartView, + ContainerStopView, + ContainerRestartView, + ContainerKillView, + ContainerPauseView, + ContainerUnpauseView, + ContainerStatsView, + ContainerLogsView, + ContainerLogStreamTicketView, + ExecTicketView, + ContainerEventListView, +) + + +urlpatterns = [ + path( + 'containers/hosts/bootstrap/', + ContainerHostBootstrapView.as_view() + ), + path( + 'containers/hosts/resolve//', + ContainerHostResolveView.as_view() + ), + path( + 'hosts//containers/', + ContainerListCreateView.as_view() + ), + path( + 'hosts//containers//', + ContainerDetailView.as_view() + ), + + path( + 'hosts//containers//start/', + ContainerStartView.as_view() + ), + path( + 'hosts//containers//stop/', + ContainerStopView.as_view() + ), + path( + 'hosts//containers//restart/', + ContainerRestartView.as_view() + ), + path( + 'hosts//containers//kill/', + ContainerKillView.as_view() + ), + path( + 'hosts//containers//pause/', + ContainerPauseView.as_view() + ), + path( + 'hosts//containers//unpause/', + ContainerUnpauseView.as_view() + ), + path( + 'hosts//containers//stats/', + ContainerStatsView.as_view() + ), + path( + 'hosts//containers//logs/', + ContainerLogsView.as_view() + ), + path( + 'hosts//containers//logs/stream/', + ContainerLogStreamTicketView.as_view() + ), + path( + 'hosts//containers//exec/', + ExecTicketView.as_view() + ), + + # Audit log + path( + 'hosts//containers//events/', + ContainerEventListView.as_view() + ), +] \ No newline at end of file diff --git a/backend/containers/views.py b/backend/containers/views.py new file mode 100644 index 0000000..2466d08 --- /dev/null +++ b/backend/containers/views.py @@ -0,0 +1,329 @@ +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from containers import services +from containers.auth import require_auth, require_role +from containers.models import ContainerLifecycleEvent, ContainerRecord, Host +from containers.serializers import ( + HostSerializer, + ContainerCreateSerializer, + ContainerLifecycleEventSerializer, + ContainerLogsSerializer, + ContainerRecordListSerializer, + ContainerRecordSerializer, + ContainerStatsSerializer, + ExecTicketResponseSerializer, +) +from hosts.models import Host as AccessHost + + +def _connection_string_for_access_host(host): + if host.ip_address in ('127.0.0.1', 'localhost', '::1'): + return 'unix:///var/run/docker.sock' + return f'tcp://{host.ip_address}:{host.port}' + + +class ContainerListCreateView(APIView): + @require_auth + def get(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + services.sync_host_records(host) + status_filter = request.query_params.get("status", None) + + qs = ContainerRecord.objects.filter(host=host) + if status_filter: + qs = qs.filter(status=status_filter.upper()) + else: + qs = qs.exclude(status=ContainerRecord.Status.REMOVED) + + serializer = ContainerRecordListSerializer(qs, many=True) + return Response({"count": qs.count(), "results": serializer.data}) + + @require_auth + @require_role(["ADMIN", "HOST_OWNER"]) + def post(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + serializer = ContainerCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + record, error = services.create_container( + host=host, + user=request.user, + image_ref=serializer.validated_data["image_ref"], + name=serializer.validated_data["name"], + environment=serializer.validated_data["environment"], + port_bindings=serializer.validated_data["port_bindings"], + volumes=serializer.validated_data["volumes"], + command=serializer.validated_data.get("command", ""), + ) + + if error: + return Response({"error": error}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + ContainerRecordSerializer(record).data, status=status.HTTP_201_CREATED + ) + + +class ContainerDetailView(APIView): + @require_auth + def get(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + services.sync_record_with_docker(record) + record.refresh_from_db() + return Response(ContainerRecordSerializer(record).data) + + @require_auth + @require_role(["ADMIN", "HOST_OWNER"]) + def delete(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + error = services.remove_container(record, request.user) + if error: + return Response({"error": error}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + {"message": f"Container {record.name} removed successfully."}, + status=status.HTTP_200_OK, + ) + + +class BaseLifecycleView(APIView): + sdk_method = None + + @require_auth + @require_role(["ADMIN", "HOST_OWNER"]) + def post(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + error = services.lifecycle_action(record, request.user, self.sdk_method) + + if error: + return Response( + { + "error": error, + "action": self.sdk_method.upper(), + "status": "FAILED", + }, + status=status.HTTP_409_CONFLICT, + ) + + return Response( + { + "container_id": record.container_id, + "name": record.name, + "action": self.sdk_method.upper(), + "status": "SUCCESS", + "timestamp": timezone.now(), + } + ) + + +class ContainerStartView(BaseLifecycleView): + sdk_method = "start" + + +class ContainerStopView(BaseLifecycleView): + sdk_method = "stop" + + +class ContainerRestartView(BaseLifecycleView): + sdk_method = "restart" + + +class ContainerKillView(BaseLifecycleView): + sdk_method = "kill" + + +class ContainerPauseView(BaseLifecycleView): + sdk_method = "pause" + + +class ContainerUnpauseView(BaseLifecycleView): + sdk_method = "unpause" + + +class ContainerStatsView(APIView): + @require_auth + def get(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + stats, error = services.get_container_stats(record) + + if error: + return Response({"error": error}, status=status.HTTP_400_BAD_REQUEST) + + return Response(ContainerStatsSerializer(stats).data) + + +class ContainerLogsView(APIView): + @require_auth + def get(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + tail = int(request.query_params.get("tail", 200)) + timestamps = request.query_params.get("timestamps", "false").lower() == "true" + + lines, error = services.get_container_logs( + record, tail=tail, timestamps=timestamps + ) + + if error: + return Response({"error": error}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + ContainerLogsSerializer( + { + "container_id": record.container_id, + "name": record.name, + "tail": tail, + "logs": lines, + } + ).data + ) + + +class ContainerLogStreamTicketView(APIView): + @require_auth + def post(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + ticket = services.issue_exec_ticket(record, request.user) + + ws_url = ( + f"ws://{request.get_host()}" + f"/ws/hosts/{host_id}/containers/{container_id}/logs/" + f"?ticket={ticket.ticket}" + ) + + return Response( + ExecTicketResponseSerializer( + { + "ticket": ticket.ticket, + "ws_url": ws_url, + "expires_in_seconds": 30, + } + ).data + ) + + +class ExecTicketView(APIView): + @require_auth + @require_role(["ADMIN", "HOST_OWNER"]) + def post(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + ticket = services.issue_exec_ticket(record, request.user) + + ws_url = ( + f"ws://{request.get_host()}" + f"/ws/hosts/{host_id}/containers/{container_id}/exec/" + f"?ticket={ticket.ticket}" + ) + + return Response( + ExecTicketResponseSerializer( + { + "ticket": ticket.ticket, + "ws_url": ws_url, + "expires_in_seconds": 30, + } + ).data + ) + + +class ContainerEventListView(APIView): + @require_auth + def get(self, request, host_id, container_id): + host = get_object_or_404(Host, pk=host_id) + record = get_object_or_404(ContainerRecord, pk=container_id, host=host) + + qs = ContainerLifecycleEvent.objects.filter(container=record) + + action_filter = request.query_params.get("action") + status_filter = request.query_params.get("status") + if action_filter: + qs = qs.filter(action=action_filter.upper()) + if status_filter: + qs = qs.filter(status=status_filter.upper()) + + page = int(request.query_params.get("page", 1)) + page_size = int(request.query_params.get("page_size", 20)) + start = (page - 1) * page_size + end = start + page_size + + total = qs.count() + page_qs = qs[start:end] + serializer = ContainerLifecycleEventSerializer(page_qs, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": serializer.data, + } + ) + + +class ContainerHostBootstrapView(APIView): + @require_auth + @require_role(["ADMIN", "HOST_OWNER"]) + def post(self, request): + host, _ = Host.objects.get_or_create( + name="local-docker", + defaults={ + "ip_address": "127.0.0.1", + "port": 2375, + "connection_string": "unix:///var/run/docker.sock", + }, + ) + + return Response(HostSerializer(host).data, status=status.HTTP_200_OK) + + +class ContainerHostResolveView(APIView): + @require_auth + def post(self, request, access_host_id): + access_host = get_object_or_404(AccessHost, pk=access_host_id) + + host, created = Host.objects.get_or_create( + ip_address=access_host.ip_address, + port=access_host.port, + defaults={ + 'name': access_host.alias, + 'connection_string': _connection_string_for_access_host(access_host), + }, + ) + + if not created: + updates = {} + if host.name != access_host.alias: + updates['name'] = access_host.alias + resolved_conn = _connection_string_for_access_host(access_host) + if host.connection_string != resolved_conn: + updates['connection_string'] = resolved_conn + if updates: + for field, value in updates.items(): + setattr(host, field, value) + host.save(update_fields=list(updates.keys())) + + return Response( + { + 'container_host': HostSerializer(host).data, + 'source_host_id': str(access_host.id), + }, + status=status.HTTP_200_OK, + ) \ No newline at end of file diff --git a/backend/core/asgi.py b/backend/core/asgi.py index cf099bf..320b5f8 100644 --- a/backend/core/asgi.py +++ b/backend/core/asgi.py @@ -8,9 +8,19 @@ """ import os - from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') -application = get_asgi_application() +django_asgi_app = get_asgi_application() + +from containers.routing import websocket_urlpatterns + +application = ProtocolTypeRouter({ + 'http': django_asgi_app, + 'websocket': AllowedHostsOriginValidator( + URLRouter(websocket_urlpatterns) + ), +}) diff --git a/backend/core/settings.py b/backend/core/settings.py index bf979e4..7f1a26c 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -10,27 +10,61 @@ https://docs.djangoproject.com/en/6.0/ref/settings/ """ +import os +import sys from pathlib import Path +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Load environment variables from repo root .env +load_dotenv(BASE_DIR.parent / ".env") + + +def get_env(name: str, default: str | None = None) -> str: + value = os.getenv(name) + if value is None or value == "": + if default is not None: + return default + raise ImproperlyConfigured(f"Missing required env var: {name}") + return value + + +def get_bool_env(name: str, default: str | None = None) -> bool: + return get_env(name, default=default).strip().lower() in {"1", "true", "yes", "on"} + + +def get_list_env(name: str, default: str | None = None) -> list[str]: + return [item.strip() for item in get_env(name, default=default).split(",") if item.strip()] + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-te&ou5qu*=p=dg_s96^#tb7xwegvzz@l&(m!kwnigflsa1pg#*' +SECRET_KEY = 'django-insecure-zy)5=s0d32g!f(!w7q_ngdezcde93r*2vp%d0_kuiw+7&5i2hl' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = get_bool_env("DJANGO_DEBUG", default="1") -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = get_list_env("DJANGO_ALLOWED_HOSTS", default="localhost,127.0.0.1") + +# Encryption key for django-encrypted-fields +SALT_KEY = "django-insecure-test-salt-key-change-in-production" +# Fernet key for encrypting registry tokens +FIELD_ENCRYPTION_KEY = get_env( + "FIELD_ENCRYPTION_KEY", + default="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", +) # Application definition INSTALLED_APPS = [ + 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -38,13 +72,20 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'authentication', + 'users', + 'hosts', + 'networks', + 'containers', + 'registries', + 'images', 'rest_framework', + 'channels', 'corsheaders', ] MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -76,12 +117,33 @@ # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', +DB_ENGINE = get_env("DJANGO_DB_ENGINE", default="sqlite3").strip().lower() + +if "pytest" in sys.modules: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "test_db.sqlite3", + } + } +elif DB_ENGINE in {"sqlite", "sqlite3"}: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / get_env("SQLITE_DB_NAME", default="db.sqlite3"), + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": get_env("POSTGRES_DB", default="docker_integration"), + "USER": get_env("POSTGRES_USER", default="postgres"), + "PASSWORD": get_env("POSTGRES_PASSWORD", default="postgres"), + "HOST": get_env("POSTGRES_HOST", default="localhost"), + "PORT": get_env("POSTGRES_PORT", default="5432"), + } } -} # Password validation @@ -120,4 +182,68 @@ STATIC_URL = 'static/' -CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +CORS_ALLOW_ALL_ORIGINS = True + + +# Custom User Model +AUTH_USER_MODEL = 'authentication.User' + +# REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + +# JWT Configuration +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + + +ASGI_APPLICATION = 'core.asgi.application' + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } +} + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s %(levelname)s [%(name)s] %(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} diff --git a/backend/core/urls.py b/backend/core/urls.py index 3667112..31dc0d2 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -15,8 +15,19 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include + +from images.views import ImageBuildStreamView, ImageInspectView urlpatterns = [ path('admin/', admin.site.urls), + path('api/auth/', include('authentication.urls')), + path('api/users/', include('users.urls')), + path('api/hosts/', include('hosts.urls')), + path('api/', include('networks.urls')), + path('api/', include('containers.urls')), + path('api/registries/', include('registries.urls')), + path('api/hosts//images/', include('images.urls')), + path('api/hosts//images/build/', ImageBuildStreamView.as_view(), name='image-build'), + path('api/hosts//images/inspect/', ImageInspectView.as_view(), name='image-inspect'), ] diff --git a/backend/hosts/__init__.py b/backend/hosts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/hosts/admin.py b/backend/hosts/admin.py new file mode 100644 index 0000000..608d0fc --- /dev/null +++ b/backend/hosts/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Host + + +@admin.register(Host) +class HostAdmin(admin.ModelAdmin): + list_display = ("id", "alias", "ip_address", "port", "created_by") + search_fields = ("alias", "ip_address", "created_by__username") diff --git a/backend/hosts/apps.py b/backend/hosts/apps.py new file mode 100644 index 0000000..542f457 --- /dev/null +++ b/backend/hosts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HostsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hosts" diff --git a/backend/hosts/migrations/0001_initial.py b/backend/hosts/migrations/0001_initial.py new file mode 100644 index 0000000..20e33e9 --- /dev/null +++ b/backend/hosts/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.3 on 2026-03-23 18:23 + +import django.db.models.deletion +import encrypted_fields.fields +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Host', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('alias', models.CharField(max_length=100)), + ('ip_address', models.GenericIPAddressField()), + ('port', models.IntegerField()), + ('ssh_credentials', encrypted_fields.fields.EncryptedTextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hosts_created', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('bio', models.TextField(blank=True)), + ('avatar_url', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserHostRole', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('ADMIN', 'Admin'), ('HOST_OWNER', 'Host Owner'), ('VIEWER', 'Viewer')], max_length=20)), + ('assigned_at', models.DateTimeField(auto_now_add=True)), + ('assigned_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='roles_assigned', to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='hosts.host')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_roles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'host')}, + }, + ), + ] diff --git a/backend/hosts/migrations/0002_alter_host_ssh_credentials.py b/backend/hosts/migrations/0002_alter_host_ssh_credentials.py new file mode 100644 index 0000000..5ffa0b2 --- /dev/null +++ b/backend/hosts/migrations/0002_alter_host_ssh_credentials.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.3 on 2026-03-26 23:20 + +import encrypted_fields.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='host', + name='ssh_credentials', + field=encrypted_fields.fields.EncryptedTextField(blank=True, null=True), + ), + ] diff --git a/backend/hosts/migrations/0003_dedupe_hosts_add_unique_endpoint.py b/backend/hosts/migrations/0003_dedupe_hosts_add_unique_endpoint.py new file mode 100644 index 0000000..4b531ad --- /dev/null +++ b/backend/hosts/migrations/0003_dedupe_hosts_add_unique_endpoint.py @@ -0,0 +1,74 @@ +from django.db import migrations, models + + +def dedupe_hosts(apps, schema_editor): + Host = apps.get_model('hosts', 'Host') + UserHostRole = apps.get_model('hosts', 'UserHostRole') + ImagePullJob = apps.get_model('images', 'ImagePullJob') + ImagePushJob = apps.get_model('images', 'ImagePushJob') + ImageDeleteJob = apps.get_model('images', 'ImageDeleteJob') + + role_rank = { + 'VIEWER': 1, + 'HOST_OWNER': 2, + 'ADMIN': 3, + } + + keep_by_endpoint = {} + + for host in Host.objects.all().order_by('created_at', 'id'): + key = (host.ip_address, host.port) + keep = keep_by_endpoint.get(key) + if keep is None: + keep_by_endpoint[key] = host + continue + + # Re-point role mappings to the kept host, merging conflicts safely. + for assignment in UserHostRole.objects.filter(host=host): + existing = UserHostRole.objects.filter( + user=assignment.user, + host=keep, + ).first() + if existing: + existing_rank = role_rank.get(existing.role, 0) + incoming_rank = role_rank.get(assignment.role, 0) + if incoming_rank > existing_rank: + existing.role = assignment.role + existing.assigned_by = assignment.assigned_by + existing.save(update_fields=['role', 'assigned_by']) + assignment.delete() + else: + assignment.host = keep + assignment.save(update_fields=['host']) + + ImagePullJob.objects.filter(host=host).update(host=keep) + ImagePushJob.objects.filter(host=host).update(host=keep) + ImageDeleteJob.objects.filter(host=host).update(host=keep) + + host.delete() + + +def noop_reverse(apps, schema_editor): + # Duplicate rows cannot be reconstructed automatically. + pass + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('hosts', '0002_alter_host_ssh_credentials'), + ('images', '0002_add_push_delete_jobs'), + ] + + operations = [ + migrations.RunPython(dedupe_hosts, noop_reverse), + migrations.AddConstraint( + model_name='host', + constraint=models.UniqueConstraint( + fields=('ip_address', 'port'), + name='uniq_host_ip_port', + ), + ), + ] diff --git a/backend/hosts/migrations/0004_remove_host_uniq_host_ip_port.py b/backend/hosts/migrations/0004_remove_host_uniq_host_ip_port.py new file mode 100644 index 0000000..7dd8738 --- /dev/null +++ b/backend/hosts/migrations/0004_remove_host_uniq_host_ip_port.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.12 on 2026-04-07 18:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0003_dedupe_hosts_add_unique_endpoint'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='host', + name='uniq_host_ip_port', + ), + ] diff --git a/backend/hosts/migrations/__init__.py b/backend/hosts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/hosts/models.py b/backend/hosts/models.py new file mode 100644 index 0000000..2f576b8 --- /dev/null +++ b/backend/hosts/models.py @@ -0,0 +1,50 @@ +import uuid +from django.db import models +from django.conf import settings +from django.contrib.auth.models import User +from encrypted_fields import EncryptedTextField + +class Profile(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile') + bio = models.TextField(blank=True) + avatar_url = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Profile({self.user.username})" + + +class Host(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + alias = models.CharField(max_length=100) + ip_address = models.GenericIPAddressField() + port = models.IntegerField() + ssh_credentials = ssh_credentials = EncryptedTextField(null=True, blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='hosts_created') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.alias} ({self.ip_address}:{self.port})" + + +class UserHostRole(models.Model): + ROLE_CHOICES = [ + ('ADMIN', 'Admin'), + ('HOST_OWNER', 'Host Owner'), + ('VIEWER', 'Viewer'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='host_roles') + host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name='user_roles') + role = models.CharField(max_length=20, choices=ROLE_CHOICES) + assigned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='roles_assigned') + assigned_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'host') # one role per user per host + + def __str__(self): + return f"{self.user.username} -> {self.host.alias} [{self.role}]" diff --git a/backend/hosts/permissions.py b/backend/hosts/permissions.py new file mode 100644 index 0000000..6edf7b4 --- /dev/null +++ b/backend/hosts/permissions.py @@ -0,0 +1,67 @@ +from rest_framework.permissions import BasePermission +from .models import UserHostRole + +def get_user_role_for_host(user, host_id): + return UserHostRole.objects.filter( + user=user, + host_id=host_id + ).values_list('role', flat=True).first() + +class IsAdminRole(BasePermission): + """Allows access only to global ADMINs (those who have ADMIN role on any host, or Django superuser).""" + message = "Only ADMIN users can perform this action." + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + global_role = getattr(request.user, 'role', '') + return ( + request.user.is_superuser or + request.user.is_staff or + str(global_role).lower() == 'admin' or + UserHostRole.objects.filter(user=request.user, role='ADMIN').exists() + ) + +class IsHostOwnerOrAdmin(BasePermission): + """For host-specific actions: allows HOST_OWNER or ADMIN assigned to that host.""" + message = "You do not have write access to this host." + + def has_permission(self, request, view): + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + role = get_user_role_for_host(request.user, obj.id) + return role in ['ADMIN', 'HOST_OWNER'] or request.user.is_superuser + + +class CanAccessHost(BasePermission): + """Any role (VIEWER, HOST_OWNER, ADMIN) grants read access to a host.""" + message = "You are not assigned to this host." + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if request.user.is_superuser: + return True + + # Check if host_id is in URL kwargs (e.g. nested routes like networks) + host_id = view.kwargs.get('host_id') + if host_id: + return UserHostRole.objects.filter(user=request.user, host_id=host_id).exists() + return True + + def has_object_permission(self, request, view, obj): + if request.user.is_superuser: + return True + + from .models import Host + if isinstance(obj, Host): + host_id = obj.id + elif hasattr(obj, 'host_id'): + host_id = obj.host_id + elif hasattr(obj, 'host'): + host_id = obj.host.id + else: + host_id = obj.id + return UserHostRole.objects.filter(user=request.user, host_id=host_id).exists() diff --git a/backend/hosts/serializers.py b/backend/hosts/serializers.py new file mode 100644 index 0000000..d016460 --- /dev/null +++ b/backend/hosts/serializers.py @@ -0,0 +1,109 @@ +from rest_framework import serializers +from .models import Host, UserHostRole, Profile +from django.contrib.auth import get_user_model + +User = get_user_model() + + +#Profile Serializer +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ['bio', 'avatar_url', 'created_at'] + read_only_fields = ['created_at'] + + +#Host Serializer (GET responses) +class HostSerializer(serializers.ModelSerializer): + created_by = serializers.StringRelatedField(read_only=True) + role = serializers.SerializerMethodField() + + class Meta: + model = Host + fields = ['id', 'alias', 'ip_address', 'port', 'created_by', 'created_at', 'role'] + read_only_fields = ['id', 'created_by', 'created_at', 'role'] + + def get_role(self, obj): + request = self.context.get('request') + if not request or not hasattr(request, 'user') or not request.user.is_authenticated: + return None + if request.user.is_superuser: + return 'ADMIN' + + # Look up role in junction table + role_entry = UserHostRole.objects.filter(user=request.user, host=obj).first() + if role_entry: + return role_entry.role + + # Fallback to checking the user's global role if they somehow bypass the junction + if request.user.role.upper() == 'ADMIN': + return 'ADMIN' + return 'VIEWER' + + +#Host Create Serializer (POST /api/hosts/) +class HostCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Host + fields = ['alias', 'ip_address', 'port', 'ssh_credentials'] + extra_kwargs = {'ssh_credentials': {'write_only': True}} + + def validate(self, attrs): + ip_address = attrs.get('ip_address') + port = attrs.get('port') + + qs = Host.objects.filter(ip_address=ip_address, port=port) + if self.instance is not None: + qs = qs.exclude(pk=self.instance.pk) + + if qs.exists(): + raise serializers.ValidationError( + { + 'ip_address': ( + 'A host with this IP and port already exists. ' + 'Use the existing host instead of creating a duplicate.' + ) + } + ) + + return attrs + + def create(self, validated_data): + request = self.context.get('request') + validated_data['created_by'] = request.user + return super().create(validated_data) + + +#User-Host Role Serializer (Assignment API) +class UserHostRoleSerializer(serializers.ModelSerializer): + user = serializers.StringRelatedField(read_only=True) + host = serializers.StringRelatedField(read_only=True) + + user_id = serializers.UUIDField(write_only=True) + + class Meta: + model = UserHostRole + fields = ['user', 'user_id', 'host', 'role', 'assigned_at'] + read_only_fields = ['user', 'host', 'assigned_at'] + + def create(self, validated_data): + request = self.context.get('request') + + host_id = self.context['view'].kwargs.get('id') + + user_id = validated_data.pop('user_id') + + try: + user = User.objects.get(id=user_id) + host = Host.objects.get(id=host_id) + except (User.DoesNotExist, Host.DoesNotExist): + raise serializers.ValidationError("Invalid User or Host ID.") + + if UserHostRole.objects.filter(user=user, host=host).exists(): + raise serializers.ValidationError("User already assigned to this host.") + + validated_data['user'] = user + validated_data['host'] = host + validated_data['assigned_by'] = request.user + + return super().create(validated_data) \ No newline at end of file diff --git a/backend/hosts/tests.py b/backend/hosts/tests.py new file mode 100644 index 0000000..fe5cc23 --- /dev/null +++ b/backend/hosts/tests.py @@ -0,0 +1,107 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from django.contrib.auth import get_user_model +from .models import Host, UserHostRole + +User = get_user_model() + +class HostTests(APITestCase): + def setUp(self): + # 1. Create Users + self.admin = User.objects.create_user(username='admin', password='pass123', is_staff=True) + self.viewer = User.objects.create_user(username='viewer', password='pass123') + self.other_user = User.objects.create_user(username='other', password='pass123') + + # 2. Create a Host (as Admin) + self.host = Host.objects.create( + alias='Production Server', + ip_address='192.168.1.10', + port=2375, + ssh_credentials='encrypted_key', + created_by=self.admin + ) + + # 3. Assign viewer to the host + UserHostRole.objects.create( + user=self.viewer, + host=self.host, + role='VIEWER', + assigned_by=self.admin + ) + + self.list_url = reverse('host-list-create') + self.detail_url = reverse('host-detail', kwargs={'id': self.host.id}) + self.assign_url = reverse('host-assign', kwargs={'id': self.host.id}) + + # --- HOST MANAGEMENT TESTS --- + + def test_admin_can_create_host(self): + self.client.force_authenticate(user=self.admin) + data = { + 'alias': 'Staging', + 'ip_address': '10.0.0.5', + 'port': 2376, + 'ssh_credentials': 'new_secret' + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Host.objects.count(), 2) + + def test_viewer_cannot_create_host(self): + self.client.force_authenticate(user=self.viewer) + data = {'alias': 'Hack', 'ip_address': '1.1.1.1', 'port': 80} + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_host_list_filtering_by_role(self): + """Test that users only see hosts they are assigned to.""" + # Create a second host that viewer is NOT assigned to + Host.objects.create(alias='Private Host', ip_address='0.0.0.0', port=22, created_by=self.admin) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.list_url) + + # Viewer should only see 1 host (the one assigned in setUp) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['alias'], 'Production Server') + + # --- ROLE ASSIGNMENT TESTS --- + + def test_admin_can_assign_role(self): + self.client.force_authenticate(user=self.admin) + data = { + 'user_id': str(self.other_user.id), + 'role': 'HOST_OWNER' + } + response = self.client.post(self.assign_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(UserHostRole.objects.filter(user=self.other_user, host=self.host).exists()) + + def test_non_admin_cannot_assign_role(self): + self.client.force_authenticate(user=self.viewer) + data = {'user_id': str(self.other_user.id), 'role': 'ADMIN'} + response = self.client.post(self.assign_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_can_remove_user_access(self): + self.client.force_authenticate(user=self.admin) + # DELETE /api/hosts/{id}/assign/{user_id}/ + url = reverse('host-remove-user', kwargs={'id': self.host.id, 'user_id': self.viewer.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(UserHostRole.objects.filter(user=self.viewer, host=self.host).exists()) + + # --- SECURITY TESTS --- + + def test_unassigned_user_cannot_view_host_detail(self): + """other_user is logged in but not assigned to host.""" + self.client.force_authenticate(user=self.other_user) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_ssh_credentials_not_in_response(self): + """Ensure sensitive data is never leaked in GET requests.""" + self.client.force_authenticate(user=self.admin) + response = self.client.get(self.detail_url) + self.assertNotIn('ssh_credentials', response.data) \ No newline at end of file diff --git a/backend/hosts/urls.py b/backend/hosts/urls.py new file mode 100644 index 0000000..d5604e2 --- /dev/null +++ b/backend/hosts/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from .views import ( + HostListCreateView, + HostDetailView, + AssignUserToHostView, + HostUsersListView, +) + +urlpatterns = [ + path('', HostListCreateView.as_view(), name='host-list-create'), + path('/', HostDetailView.as_view(), name='host-detail'), + path('/assign/', AssignUserToHostView.as_view(), name='host-assign'), + path('/assign//', AssignUserToHostView.as_view(), name='host-remove-user'), + path('/users/', HostUsersListView.as_view(), name='host-users'), +] diff --git a/backend/hosts/views.py b/backend/hosts/views.py new file mode 100644 index 0000000..cff7d16 --- /dev/null +++ b/backend/hosts/views.py @@ -0,0 +1,116 @@ +import logging +from django.contrib.auth import get_user_model +from rest_framework import generics, status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from .models import Host, UserHostRole +from .serializers import HostSerializer, HostCreateSerializer, UserHostRoleSerializer +from .permissions import IsAdminRole, CanAccessHost + +logger = logging.getLogger(__name__) +User = get_user_model() + + +# ========================= +# HOST LIST + CREATE +# ========================= +class HostListCreateView(generics.ListCreateAPIView): + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.is_superuser: + return Host.objects.all() + + assigned_host_ids = UserHostRole.objects.filter( + user=user + ).values_list('host_id', flat=True) + + return Host.objects.filter(id__in=assigned_host_ids) + + def get_serializer_class(self): + return HostCreateSerializer if self.request.method == 'POST' else HostSerializer + + def get_permissions(self): + if self.request.method == 'POST': + return [IsAuthenticated(), IsAdminRole()] + return [IsAuthenticated()] + + def perform_create(self, serializer): + host = serializer.save(created_by=self.request.user) + UserHostRole.objects.get_or_create( + user=self.request.user, + host=host, + defaults={'role': 'ADMIN', 'assigned_by': self.request.user} + ) + logger.info(f"Host '{host.alias}' registered by {self.request.user.username}") + + +# ========================= +# HOST DETAIL +# ========================= +class HostDetailView(generics.RetrieveUpdateDestroyAPIView): + queryset = Host.objects.all() + permission_classes = [IsAuthenticated, CanAccessHost] + + lookup_field = "id" + lookup_url_kwarg = "id" + + def get_serializer_class(self): + return HostCreateSerializer if self.request.method in ['PUT','PATCH'] else HostSerializer + + def get_permissions(self): + if self.request.method in ['PUT','PATCH','DELETE']: + return [IsAuthenticated(), IsAdminRole()] + return [IsAuthenticated(), CanAccessHost()] + + +# ========================= +# ASSIGN / REMOVE USER +# ========================= +class AssignUserToHostView(APIView): + permission_classes = [IsAuthenticated, IsAdminRole] + + def post(self, request, id): + try: + host = Host.objects.get(id=id) + except Host.DoesNotExist: + return Response({"error": "Host not found"}, status=404) + + user_id = request.data.get("user_id") + role = request.data.get("role") + + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + assignment, created = UserHostRole.objects.update_or_create( + user=user, + host=host, + defaults={"role": role, "assigned_by": request.user}, + ) + + serializer = UserHostRoleSerializer(assignment) + return Response(serializer.data, status=201 if created else 200) + + def delete(self, request, id, user_id): + try: + assignment = UserHostRole.objects.get(host_id=id, user_id=user_id) + assignment.delete() + return Response(status=204) + except UserHostRole.DoesNotExist: + return Response({"error": "Assignment not found"}, status=404) + + +# ========================= +# LIST USERS ON HOST +# ========================= +class HostUsersListView(generics.ListAPIView): + permission_classes = [IsAuthenticated, IsAdminRole] + serializer_class = UserHostRoleSerializer + + def get_queryset(self): + return UserHostRole.objects.filter(host_id=self.kwargs['id']) \ No newline at end of file diff --git a/backend/images/__init__.py b/backend/images/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/images/admin.py b/backend/images/admin.py new file mode 100644 index 0000000..9247c4f --- /dev/null +++ b/backend/images/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import ImagePullJob + + +@admin.register(ImagePullJob) +class ImagePullJobAdmin(admin.ModelAdmin): + list_display = [ + "id", + "image_ref", + "host", + "requested_by", + "status", + "created_at", + "completed_at", + ] + list_filter = ["status", "created_at"] + search_fields = ["image_ref", "host__name", "requested_by__username"] + readonly_fields = [ + "id", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] diff --git a/backend/images/apps.py b/backend/images/apps.py new file mode 100644 index 0000000..5f752ba --- /dev/null +++ b/backend/images/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ImagesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "images" diff --git a/backend/images/migrations/0001_initial.py b/backend/images/migrations/0001_initial.py new file mode 100644 index 0000000..3ff649b --- /dev/null +++ b/backend/images/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-03-24 19:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('hosts', '0001_initial'), + ('registries', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImagePullJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('image_ref', models.CharField(help_text='Full image reference, e.g. nginx:1.25-alpine', max_length=500)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('PULLING', 'Pulling'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Streamed JSON progress lines from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the pull', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the pull finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_pull_jobs', to='hosts.host')), + ('registry_credential', models.ForeignKey(blank=True, help_text='Credential used for private registries', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_pull_jobs', to='registries.registrycredential')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_pull_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/images/migrations/0002_add_push_delete_jobs.py b/backend/images/migrations/0002_add_push_delete_jobs.py new file mode 100644 index 0000000..dbf7bb9 --- /dev/null +++ b/backend/images/migrations/0002_add_push_delete_jobs.py @@ -0,0 +1,61 @@ +# Generated migration for ImagePushJob and ImageDeleteJob models + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('images', '0001_initial'), + ('hosts', '0001_initial'), + ('registries', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImagePushJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_image_ref', models.CharField(help_text='Local image reference to push, e.g. nginx:1.25-alpine', max_length=500)), + ('target_image_ref', models.CharField(help_text='Target image reference in registry, e.g. myregistry.com/myapp:v1.0', max_length=500)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('TAGGING', 'Tagging'), ('PUSHING', 'Pushing'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Streamed JSON progress lines from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the push', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the push finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_push_jobs', to='hosts.host')), + ('registry_credential', models.ForeignKey(blank=True, help_text='Credential used for authentication with the registry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_push_jobs', to='registries.registrycredential')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_push_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ImageDeleteJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('delete_mode', models.CharField(choices=[('SPECIFIC', 'Delete specific image(s)'), ('UNUSED', 'Prune all unused images')], default='SPECIFIC', help_text='Whether to delete specific image(s) or prune all unused', max_length=20)), + ('image_refs', models.TextField(blank=True, help_text='Comma-separated list of image references to delete (for SPECIFIC mode)')), + ('force', models.BooleanField(default=False, help_text='Force delete even if image is in use')), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('DELETING', 'Deleting'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Progress information from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('deleted_count', models.IntegerField(default=0, help_text='Number of images deleted')), + ('space_freed_bytes', models.BigIntegerField(default=0, help_text='Bytes of storage freed')), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the deletion', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the deletion finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_delete_jobs', to='hosts.host')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_delete_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/images/migrations/__init__.py b/backend/images/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/images/models.py b/backend/images/models.py new file mode 100644 index 0000000..4bf355e --- /dev/null +++ b/backend/images/models.py @@ -0,0 +1,225 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class ImagePullJob(models.Model): + """Tracks background image pull operations.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + PULLING = "PULLING", "Pulling" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_pull_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_pull_jobs", + ) + image_ref = models.CharField( + max_length=500, + help_text="Full image reference, e.g. nginx:1.25-alpine", + ) + registry_credential = models.ForeignKey( + "registries.RegistryCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="image_pull_jobs", + help_text="Credential used for private registries", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Streamed JSON progress lines from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the pull", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the pull finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"PullJob({self.image_ref}) [{self.status}]" + + +class ImagePushJob(models.Model): + """Tracks background image push operations to registries.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + TAGGING = "TAGGING", "Tagging" + PUSHING = "PUSHING", "Pushing" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_push_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_push_jobs", + ) + source_image_ref = models.CharField( + max_length=500, + help_text="Local image reference to push, e.g. nginx:1.25-alpine", + ) + target_image_ref = models.CharField( + max_length=500, + help_text="Target image reference in registry, e.g. myregistry.com/myapp:v1.0", + ) + registry_credential = models.ForeignKey( + "registries.RegistryCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="image_push_jobs", + help_text="Credential used for authentication with the registry", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Streamed JSON progress lines from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the push", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the push finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"PushJob({self.source_image_ref} → {self.target_image_ref}) [{self.status}]" + + +class ImageDeleteJob(models.Model): + """Tracks background image deletion/pruning operations.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + DELETING = "DELETING", "Deleting" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + class DeleteMode(models.TextChoices): + SPECIFIC = "SPECIFIC", "Delete specific image(s)" + UNUSED = "UNUSED", "Prune all unused images" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_delete_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_delete_jobs", + ) + delete_mode = models.CharField( + max_length=20, + choices=DeleteMode.choices, + default=DeleteMode.SPECIFIC, + help_text="Whether to delete specific image(s) or prune all unused", + ) + image_refs = models.TextField( + blank=True, + help_text="Comma-separated list of image references to delete (for SPECIFIC mode)", + ) + force = models.BooleanField( + default=False, + help_text="Force delete even if image is in use", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Progress information from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + deleted_count = models.IntegerField( + default=0, + help_text="Number of images deleted", + ) + space_freed_bytes = models.BigIntegerField( + default=0, + help_text="Bytes of storage freed", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the deletion", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the deletion finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + mode_label = self.get_delete_mode_display() + return f"DeleteJob({mode_label}) [{self.status}]" diff --git a/backend/images/permissions.py b/backend/images/permissions.py new file mode 100644 index 0000000..1fcaa67 --- /dev/null +++ b/backend/images/permissions.py @@ -0,0 +1,52 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission +from hosts.models import UserHostRole + + +def _host_assignment_role(user, host_id): + if not host_id: + return None + return UserHostRole.objects.filter( + user=user, + host_id=host_id, + ).values_list('role', flat=True).first() + + +class IsAdminOrHostOwner(BasePermission): + """ + POST (enqueue pull): only admin or the host owner. + Safe methods (GET list/detail): any authenticated user. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + if request.user.is_superuser or request.user.role == "admin": + return True + + host_id = view.kwargs.get("host_id") + assigned_role = _host_assignment_role(request.user, host_id) + + if request.method in SAFE_METHODS: + return assigned_role in {"VIEWER", "HOST_OWNER", "ADMIN"} + + return assigned_role == "ADMIN" + + def has_object_permission(self, request, view, obj): + if request.user.is_superuser or request.user.role == "admin": + return True + + assigned_role = _host_assignment_role(request.user, getattr(obj, "host_id", None)) + if request.method in SAFE_METHODS: + return assigned_role in {"VIEWER", "HOST_OWNER", "ADMIN"} + + return assigned_role == "ADMIN" + + +class IsAdminOnly(BasePermission): + """DELETE (cancel): admin only.""" + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + return request.user.is_superuser or request.user.role == "admin" diff --git a/backend/images/serializers.py b/backend/images/serializers.py new file mode 100644 index 0000000..3d25ef4 --- /dev/null +++ b/backend/images/serializers.py @@ -0,0 +1,310 @@ +from rest_framework import serializers + +from .models import ImagePullJob, ImagePushJob, ImageDeleteJob + + +class ImagePullJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving pull jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.alias") + registry_alias = serializers.ReadOnlyField( + source="registry_credential.alias", default=None + ) + + class Meta: + model = ImagePullJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "image_ref", + "registry_credential", + "registry_alias", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + + +class ImagePullJobCreateSerializer(serializers.Serializer): + """ + Accepts the image reference and an optional registry credential to + enqueue a pull job. + """ + + image_ref = serializers.CharField(max_length=500) + registry_credential = serializers.UUIDField(required=False, allow_null=True) + + def validate_registry_credential(self, value): + """Ensure the credential exists and belongs to the requesting user.""" + if value is None: + return None + from registries.models import RegistryCredential + + user = self.context["request"].user + try: + return RegistryCredential.objects.get(pk=value, owner=user) + except RegistryCredential.DoesNotExist: + raise serializers.ValidationError( + "Registry credential not found or not owned by you." + ) + + +class ImageBuildRequestSerializer(serializers.Serializer): + """Input serializer for image build endpoint.""" + + tag = serializers.CharField(max_length=255, required=False, allow_blank=True) + dockerfile = serializers.CharField(required=False, allow_blank=True) + context_zip = serializers.FileField(required=False) + pull = serializers.BooleanField(required=False, default=False) + nocache = serializers.BooleanField(required=False, default=False) + + def validate(self, attrs): + dockerfile = (attrs.get("dockerfile") or "").strip() + context_zip = attrs.get("context_zip") + + if not dockerfile and not context_zip: + raise serializers.ValidationError( + "Provide either a Dockerfile string or a context_zip archive." + ) + + return attrs + + +# --------------------------------------------------------------------------- # +# Image Inspect serializers +# --------------------------------------------------------------------------- # + + +class ImageLayerSerializer(serializers.Serializer): + """One layer from an image's history.""" + + created = serializers.CharField(allow_null=True) + created_by = serializers.CharField(allow_blank=True, allow_null=True) + size = serializers.IntegerField() + comment = serializers.CharField(allow_blank=True, default="") + tags = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + + +class ImageInspectSerializer(serializers.Serializer): + """ + Structured response for the image inspect endpoint. + + Returns the image's ENV variables, ENTRYPOINT, total size, + and layer history. + """ + + image_id = serializers.CharField() + repo_tags = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + repo_digests = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + size = serializers.IntegerField(help_text="Total image size in bytes") + virtual_size = serializers.IntegerField( + help_text="Virtual size in bytes", required=False, allow_null=True + ) + created = serializers.CharField(help_text="ISO 8601 creation timestamp") + architecture = serializers.CharField(allow_blank=True, default="") + os = serializers.CharField(allow_blank=True, default="") + env = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + default=list, + help_text="Environment variables set in the image", + ) + entrypoint = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + allow_null=True, + default=None, + help_text="Image ENTRYPOINT as a list of strings", + ) + cmd = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + allow_null=True, + default=None, + help_text="Default CMD", + ) + exposed_ports = serializers.DictField( + allow_empty=True, + default=dict, + help_text="Ports exposed by the image", + ) + layers = ImageLayerSerializer(many=True, help_text="Layer history") + + +class HostImageListItemSerializer(serializers.Serializer): + """Compact image list response for gallery/search use cases.""" + + image_id = serializers.CharField() + image_ref = serializers.CharField() + created = serializers.CharField(allow_blank=True, default="") + size = serializers.IntegerField(default=0) + + +# --------------------------------------------------------------------------- # +# Image Push/Tag serializers +# --------------------------------------------------------------------------- # + + +class ImagePushJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving push jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.alias") + registry_alias = serializers.ReadOnlyField( + source="registry_credential.alias", default=None + ) + + class Meta: + model = ImagePushJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "source_image_ref", + "target_image_ref", + "registry_credential", + "registry_alias", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + + +class ImagePushJobCreateSerializer(serializers.Serializer): + """ + Accepts source and target image references and an optional registry credential + to enqueue a push job. + """ + + source_image_ref = serializers.CharField(max_length=500) + target_image_ref = serializers.CharField(max_length=500) + registry_credential = serializers.UUIDField(required=False, allow_null=True) + + def validate_registry_credential(self, value): + """Ensure the credential exists and belongs to the requesting user.""" + if value is None: + return None + from registries.models import RegistryCredential + + user = self.context["request"].user + try: + return RegistryCredential.objects.get(pk=value, owner=user) + except RegistryCredential.DoesNotExist: + raise serializers.ValidationError( + "Registry credential not found or not owned by you." + ) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune serializers +# --------------------------------------------------------------------------- # + + +class ImageDeleteJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving delete jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.alias") + + class Meta: + model = ImageDeleteJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "delete_mode", + "image_refs", + "force", + "status", + "progress_log", + "error_message", + "deleted_count", + "space_freed_bytes", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "deleted_count", + "space_freed_bytes", + "started_at", + "completed_at", + "created_at", + ] + + +class ImageDeleteJobCreateSerializer(serializers.Serializer): + """ + Accepts delete mode and optional image references to enqueue a delete job. + """ + + delete_mode = serializers.ChoiceField( + choices=ImageDeleteJob.DeleteMode.choices, + help_text="SPECIFIC to delete certain images, UNUSED to prune all", + ) + image_refs = serializers.CharField( + required=False, + allow_blank=True, + help_text="Comma-separated image references (required for SPECIFIC mode)", + ) + force = serializers.BooleanField( + required=False, + default=False, + help_text="Force delete even if image is in use", + ) + + def validate(self, attrs): + delete_mode = attrs.get("delete_mode") + image_refs = (attrs.get("image_refs") or "").strip() + + if delete_mode == ImageDeleteJob.DeleteMode.SPECIFIC and not image_refs: + raise serializers.ValidationError( + "image_refs is required for SPECIFIC delete mode." + ) + + return attrs diff --git a/backend/images/tests.py b/backend/images/tests.py new file mode 100644 index 0000000..c1c0ef1 --- /dev/null +++ b/backend/images/tests.py @@ -0,0 +1,1885 @@ +import json +from io import BytesIO +from zipfile import ZipFile +from unittest.mock import MagicMock, patch + +from cryptography.fernet import Fernet +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework import status +from rest_framework.test import APIClient + +from hosts.models import Host, UserHostRole +from registries.models import RegistryCredential + +from .models import ImageDeleteJob, ImagePullJob, ImagePushJob + +User = get_user_model() + +# Generate an ephemeral Fernet key for tests to avoid committing static keys. +TEST_ENCRYPTION_KEY = Fernet.generate_key().decode() + + +# --------------------------------------------------------------------------- # +# Model unit tests +# --------------------------------------------------------------------------- # +class ImagePullJobModelTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="modelowner", password="password123", role="admin" + ) + self.host = Host.objects.create( + alias="Test Host", + ip_address="192.168.1.100", + port=2375, + created_by=self.owner, + ) + + def test_string_representation(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="nginx:1.25-alpine", + ) + self.assertEqual(str(job), "PullJob(nginx:1.25-alpine) [PENDING]") + + def test_default_status_is_pending(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="redis:7", + ) + self.assertEqual(job.status, ImagePullJob.Status.PENDING) + + def test_optional_fields_are_null(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="postgres:16", + ) + self.assertIsNone(job.registry_credential) + self.assertIsNone(job.started_at) + self.assertIsNone(job.completed_at) + self.assertIsNone(job.error_message) + self.assertEqual(job.progress_log, "") + + def test_ordering_is_newest_first(self): + job_a = ImagePullJob.objects.create( + host=self.host, requested_by=self.owner, image_ref="a:1" + ) + job_b = ImagePullJob.objects.create( + host=self.host, requested_by=self.owner, image_ref="b:2" + ) + jobs = list(ImagePullJob.objects.all()) + self.assertEqual(jobs[0].pk, job_b.pk) + self.assertEqual(jobs[1].pk, job_a.pk) + + +# --------------------------------------------------------------------------- # +# Permission unit tests +# --------------------------------------------------------------------------- # +class ImagePullPermissionTest(TestCase): + def setUp(self): + self.admin = User.objects.create_user( + username="padmin", password="password123", role="admin" + ) + self.host_user = User.objects.create_user( + username="phoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="pviewer", password="password123", role="viewer" + ) + + def _request(self, method, user): + class DummyRequest: + pass + + r = DummyRequest() + r.method = method + r.user = user + return r + + def test_admin_or_host_owner_allows_read_for_viewer(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("GET", self.viewer) + self.assertTrue(perm.has_permission(req, None)) + + def test_admin_or_host_owner_denies_post_for_viewer(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.viewer) + self.assertFalse(perm.has_permission(req, None)) + + def test_admin_or_host_owner_allows_post_for_admin(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.admin) + self.assertTrue(perm.has_permission(req, None)) + + def test_admin_or_host_owner_allows_post_for_host_role(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.host_user) + self.assertFalse(perm.has_permission(req, None)) + + def test_admin_only_denies_viewer(self): + from .permissions import IsAdminOnly + + perm = IsAdminOnly() + req = self._request("DELETE", self.viewer) + self.assertFalse(perm.has_permission(req, None)) + + def test_admin_only_allows_admin(self): + from .permissions import IsAdminOnly + + perm = IsAdminOnly() + req = self._request("DELETE", self.admin) + self.assertTrue(perm.has_permission(req, None)) + + +# --------------------------------------------------------------------------- # +# Worker unit tests (mocked Docker) +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullWorkerTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="wowner", password="password123", role="admin" + ) + self.host = Host.objects.create( + alias="Worker Host", + ip_address="10.0.0.1", + port=2375, + created_by=self.owner, + ) + + @patch("images.worker.docker.DockerClient") + def test_successful_pull_updates_status(self, MockClient): + """Worker should set status to SUCCESS after a successful pull.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.return_value = [ + {"status": "Pulling from library/nginx", "id": "1.25-alpine"}, + {"status": "Digest: sha256:abc123"}, + {"status": "Status: Downloaded newer image for nginx:1.25-alpine"}, + ] + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="nginx:1.25-alpine", + ) + + # Run the worker synchronously (import the internal function) + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.SUCCESS) + self.assertIsNotNone(job.started_at) + self.assertIsNotNone(job.completed_at) + self.assertIn("nginx", job.progress_log) + + # Should have connected to the right host + MockClient.assert_called_once_with( + base_url="tcp://10.0.0.1:2375", timeout=300 + ) + + @patch("images.worker.docker.DockerClient") + def test_failed_pull_updates_status(self, MockClient): + """Worker should set status to FAILED and record error_message.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.side_effect = docker_lib.errors.APIError( + "pull access denied" + ) + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="private/image:latest", + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.FAILED) + self.assertIsNotNone(job.error_message) + self.assertIsNotNone(job.completed_at) + + @patch("images.worker.docker.DockerClient") + def test_cancelled_job_not_pulled(self, MockClient): + """Worker should skip a job that was cancelled before the thread ran.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="skip:me", + status=ImagePullJob.Status.CANCELLED, + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.CANCELLED) + MockClient.assert_not_called() + + @patch("images.worker.docker.DockerClient") + def test_pull_with_registry_credential(self, MockClient): + """Worker should pass auth_config when a credential is linked.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.return_value = [ + {"status": "Pull complete"}, + ] + + cred = RegistryCredential( + owner=self.owner, + alias="Private GHCR", + registry_url="https://ghcr.io", + username="ghuser", + ) + cred.token = "registry_pull_token" + cred.save() + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="ghcr.io/myorg/myapp:v1", + registry_credential=cred, + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.SUCCESS) + + # Verify auth_config was passed to the pull call + _, call_kwargs = mock_client.api.pull.call_args + self.assertEqual(call_kwargs["auth_config"]["username"], "ghuser") + self.assertEqual( + call_kwargs["auth_config"]["password"], "registry_pull_token" + ) + + def test_nonexistent_job_id_does_not_raise(self): + """Worker should log error and return gracefully for missing job.""" + from .worker import _do_pull + + import uuid + + _do_pull(str(uuid.uuid4())) # Should not raise + + +# --------------------------------------------------------------------------- # +# API route integration tests +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullJobRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="iadmin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="ihoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="iviewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + alias="Route Host", + ip_address="192.168.1.50", + port=2375, + created_by=self.host_owner, + ) + + UserHostRole.objects.create( + user=self.host_owner, + host=self.host, + role="HOST_OWNER", + assigned_by=self.admin, + ) + UserHostRole.objects.create( + user=self.viewer, + host=self.host, + role="VIEWER", + assigned_by=self.admin, + ) + + self.base_url = f"/api/hosts/{self.host.id}/images/pull/" + + # ---- LIST ---- # + def test_list_jobs_authenticated(self): + """Any authenticated user can list pull jobs.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + def test_list_jobs_unauthenticated(self): + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_shows_jobs_for_host(self): + """List should return only jobs belonging to this host.""" + ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="nginx:latest", + ) + other_host = Host.objects.create( + alias="Other Host", + ip_address="192.168.1.51", + port=2375, + created_by=self.admin, + ) + ImagePullJob.objects.create( + host=other_host, + requested_by=self.admin, + image_ref="redis:7", + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["image_ref"], "nginx:latest") + + # ---- CREATE (POST) ---- # + @patch("images.views.enqueue_pull") + def test_create_job_as_admin(self, mock_enqueue): + """Admin can enqueue a pull job on any host.""" + mock_enqueue.return_value = None + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:1.25-alpine"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["image_ref"], "nginx:1.25-alpine") + self.assertEqual(response.data["status"], "PENDING") + mock_enqueue.assert_called_once() + + @patch("images.views.enqueue_pull") + def test_create_job_as_host_owner(self, mock_enqueue): + """Host owner cannot enqueue pulls; image writes are admin-only.""" + mock_enqueue.return_value = None + + self.client.force_authenticate(user=self.host_owner) + response = self.client.post( + self.base_url, + {"image_ref": "redis:7"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_job_denied_for_viewer(self): + """Viewer role cannot create pull jobs.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_job_unauthenticated(self): + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @patch("images.views.enqueue_pull") + def test_create_job_missing_image_ref(self, mock_enqueue): + """image_ref is required.""" + self.client.force_authenticate(user=self.admin) + response = self.client.post(self.base_url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("images.views.enqueue_pull") + def test_create_job_with_registry_credential(self, mock_enqueue): + """Valid credential should be linked to the job.""" + mock_enqueue.return_value = None + + cred = RegistryCredential( + owner=self.admin, + alias="My GHCR", + registry_url="https://ghcr.io", + username="u", + ) + cred.token = "t" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + { + "image_ref": "ghcr.io/myorg/myapp:v1", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + job_id = response.data["id"] + job = ImagePullJob.objects.get(pk=job_id) + self.assertEqual(job.registry_credential_id, cred.id) + + @patch("images.views.enqueue_pull") + def test_create_job_with_other_users_credential_fails(self, mock_enqueue): + """Cannot use another user's credential.""" + cred = RegistryCredential( + owner=self.host_owner, + alias="Stolen", + registry_url="https://ghcr.io", + username="u", + ) + cred.token = "t" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + { + "image_ref": "ghcr.io/evil:latest", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("images.views.enqueue_pull") + def test_create_job_non_owner_host_user_denied(self, mock_enqueue): + """A host-role user who does not own the host should be denied.""" + other_host_user = User.objects.create_user( + username="otherhoster", password="password123", role="host" + ) + self.client.force_authenticate(user=other_host_user) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ---- RETRIEVE (GET detail) ---- # + def test_retrieve_job_detail(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="alpine:3.18", + ) + self.client.force_authenticate(user=self.viewer) + url = f"{self.base_url}{job.id}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["image_ref"], "alpine:3.18") + self.assertEqual(response.data["status"], "PENDING") + + def test_retrieve_nonexistent_job_returns_404(self): + self.client.force_authenticate(user=self.viewer) + import uuid + + url = f"{self.base_url}{uuid.uuid4()}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_job_from_different_host_returns_404(self): + """Job on a different host should not be retrievable via this host's URL.""" + other_host = Host.objects.create( + alias="Other", ip_address="10.10.10.10", port=2375, created_by=self.admin + ) + job = ImagePullJob.objects.create( + host=other_host, + requested_by=self.admin, + image_ref="busybox:latest", + ) + self.client.force_authenticate(user=self.viewer) + url = f"{self.base_url}{job.id}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- CANCEL (DELETE) ---- # + def test_cancel_pending_job_as_admin(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="cancel:me", + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("cancelled", response.data["detail"].lower()) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.CANCELLED) + + def test_cancel_denied_for_non_admin(self): + """Only admins can cancel.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.host_owner, + image_ref="nope:latest", + ) + self.client.force_authenticate(user=self.host_owner) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cancel_non_pending_job_returns_conflict(self): + """Cannot cancel a job that is already PULLING or completed.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="running:now", + status=ImagePullJob.Status.PULLING, + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_cancel_already_succeeded_returns_conflict(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="done:1", + status=ImagePullJob.Status.SUCCESS, + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_cancel_nonexistent_job_returns_404(self): + self.client.force_authenticate(user=self.admin) + import uuid + + url = f"{self.base_url}{uuid.uuid4()}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class ImageBuildRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="badmin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="bhoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="bviewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + alias="Build Host", + ip_address="192.168.1.77", + port=2375, + created_by=self.host_owner, + ) + self.url = f"/api/hosts/{self.host.id}/images/build/" + + def _zip_with_dockerfile(self, dockerfile_text: str) -> SimpleUploadedFile: + buf = BytesIO() + with ZipFile(buf, "w") as archive: + archive.writestr("Dockerfile", dockerfile_text) + archive.writestr("app.txt", "hello") + return SimpleUploadedFile( + "context.zip", + buf.getvalue(), + content_type="application/zip", + ) + + @patch("images.views.docker.DockerClient") + def test_build_with_dockerfile_streams_output(self, MockClient): + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.id = "sha256:build123" + mock_logs = iter( + [ + {"stream": "Step 1/1 : FROM alpine\n"}, + {"stream": "Successfully built\n"}, + ] + ) + mock_client.images.build.return_value = (mock_image, mock_logs) + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.url, + { + "dockerfile": "FROM alpine\nRUN echo hi", + "tag": "myorg/demo:latest", + "pull": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + chunks = [ + part.decode("utf-8") if isinstance(part, bytes) else part + for part in response.streaming_content + ] + output = "".join(chunks) + self.assertIn("Step 1/1", output) + self.assertIn("sha256:build123", output) + + MockClient.assert_called_once_with( + base_url="tcp://192.168.1.77:2375", timeout=600 + ) + + @patch("images.views.docker.DockerClient") + def test_build_with_zip_context_streams_output(self, MockClient): + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.id = "sha256:zipbuild" + mock_client.images.build.return_value = ( + mock_image, + iter([{"stream": "Building from zip\n"}]), + ) + + context_zip = self._zip_with_dockerfile("FROM busybox\nRUN echo zip") + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.url, + { + "context_zip": context_zip, + "tag": "myorg/zip:1", + }, + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + chunks = [ + part.decode("utf-8") if isinstance(part, bytes) else part + for part in response.streaming_content + ] + output = "".join(chunks) + self.assertIn("Building from zip", output) + self.assertIn("sha256:zipbuild", output) + + def test_build_requires_dockerfile_or_zip(self): + self.client.force_authenticate(user=self.admin) + response = self.client.post(self.url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePushDeleteRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="pd_admin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="pd_owner", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="pd_viewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + alias="PushDelete Host", + ip_address="192.168.1.60", + port=2375, + created_by=self.host_owner, + ) + self.push_url = f"/api/hosts/{self.host.id}/images/push/" + self.delete_url = f"/api/hosts/{self.host.id}/images/delete/" + + @patch("images.views.enqueue_push") + def test_create_push_job_as_admin(self, mock_enqueue_push): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "alpine:latest", + "target_image_ref": "example.registry/alpine:test", + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "PENDING") + self.assertEqual(response.data["source_image_ref"], "alpine:latest") + self.assertEqual( + response.data["target_image_ref"], "example.registry/alpine:test" + ) + mock_enqueue_push.assert_called_once() + self.assertEqual(ImagePushJob.objects.filter(host=self.host).count(), 1) + + @patch("images.views.enqueue_push") + def test_create_push_job_denied_for_viewer(self, mock_enqueue_push): + self.client.force_authenticate(user=self.viewer) + response = self.client.post( + self.push_url, + { + "source_image_ref": "alpine:latest", + "target_image_ref": "example.registry/alpine:test", + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + mock_enqueue_push.assert_not_called() + + @patch("images.views.enqueue_push") + def test_create_push_job_with_credential_owned_by_user(self, mock_enqueue_push): + cred = RegistryCredential( + owner=self.admin, + alias="Push GHCR", + registry_url="https://ghcr.io", + username="push_user", + ) + cred.token = "push_token" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "myorg/app:latest", + "target_image_ref": "ghcr.io/myorg/app:ci", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = ImagePushJob.objects.get(pk=response.data["id"]) + self.assertEqual(created.registry_credential_id, cred.id) + mock_enqueue_push.assert_called_once() + + @patch("images.views.enqueue_push") + def test_create_push_job_with_foreign_credential_fails(self, mock_enqueue_push): + cred = RegistryCredential( + owner=self.host_owner, + alias="Foreign Cred", + registry_url="https://ghcr.io", + username="other_user", + ) + cred.token = "other_token" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "myorg/app:latest", + "target_image_ref": "ghcr.io/myorg/app:ci", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + mock_enqueue_push.assert_not_called() + + def test_list_push_jobs(self): + ImagePushJob.objects.create( + host=self.host, + requested_by=self.admin, + source_image_ref="alpine:latest", + target_image_ref="example.registry/alpine:test", + ) + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.push_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + @patch("images.views.enqueue_delete") + def test_create_delete_job_unused_as_admin(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + {"delete_mode": "UNUSED", "force": False}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "PENDING") + self.assertEqual(response.data["delete_mode"], "UNUSED") + mock_enqueue_delete.assert_called_once() + self.assertEqual(ImageDeleteJob.objects.filter(host=self.host).count(), 1) + + @patch("images.views.enqueue_delete") + def test_create_delete_job_specific_requires_refs(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + {"delete_mode": "SPECIFIC", "force": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + mock_enqueue_delete.assert_not_called() + + @patch("images.views.enqueue_delete") + def test_create_delete_job_specific_as_admin(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + { + "delete_mode": "SPECIFIC", + "image_refs": "old:a,old:b", + "force": True, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["delete_mode"], "SPECIFIC") + self.assertEqual(response.data["image_refs"], "old:a,old:b") + mock_enqueue_delete.assert_called_once() + + def test_list_delete_jobs(self): + ImageDeleteJob.objects.create( + host=self.host, + requested_by=self.admin, + delete_mode=ImageDeleteJob.DeleteMode.UNUSED, + image_refs="", + ) + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.delete_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + +# --------------------------------------------------------------------------- # +# Serializer unit tests +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullJobSerializerTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="suser", password="password123", role="admin" + ) + self.host = Host.objects.create( + alias="Ser Host", + ip_address="10.0.0.5", + port=2375, + created_by=self.user, + ) + + def test_read_serializer_fields(self): + from .serializers import ImagePullJobSerializer + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.user, + image_ref="python:3.12-slim", + ) + serializer = ImagePullJobSerializer(job) + data = serializer.data + + self.assertEqual(data["image_ref"], "python:3.12-slim") + self.assertEqual(data["status"], "PENDING") + self.assertEqual(data["requested_by"], "suser") + self.assertEqual(data["host_name"], "Ser Host") + self.assertIn("id", data) + self.assertIn("progress_log", data) + self.assertIn("created_at", data) + + def test_create_serializer_validates_image_ref(self): + from .serializers import ImagePullJobCreateSerializer + + serializer = ImagePullJobCreateSerializer(data={}) + self.assertFalse(serializer.is_valid()) + self.assertIn("image_ref", serializer.errors) + + def test_create_serializer_validates_other_users_credential(self): + from .serializers import ImagePullJobCreateSerializer + + other_user = User.objects.create_user( + username="other_ser", password="password123", role="host" + ) + cred = RegistryCredential( + owner=other_user, + alias="Not Mine", + registry_url="https://ghcr.io", + username="o", + ) + cred.token = "t" + cred.save() + + # Build a mock request with self.user + class MockRequest: + user = self.user + + serializer = ImagePullJobCreateSerializer( + data={ + "image_ref": "ghcr.io/x:1", + "registry_credential": str(cred.id), + }, + context={"request": MockRequest()}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("registry_credential", serializer.errors) + + +# --------------------------------------------------------------------------- # +# Image Inspect API integration tests +# --------------------------------------------------------------------------- # + +# A realistic Docker inspect response for mocking +_SAMPLE_IMAGE_ATTRS = { + "Id": "sha256:abc123def456", + "RepoTags": ["nginx:1.25-alpine"], + "RepoDigests": ["nginx@sha256:deadbeef"], + "Size": 41_200_000, + "VirtualSize": 41_200_000, + "Created": "2025-06-01T12:00:00.000000000Z", + "Architecture": "amd64", + "Os": "linux", + "Config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NGINX_VERSION=1.25.4", + "NJS_VERSION=0.8.3", + ], + "Entrypoint": ["/docker-entrypoint.sh"], + "Cmd": ["nginx", "-g", "daemon off;"], + "ExposedPorts": {"80/tcp": {}, "443/tcp": {}}, + }, +} + +_SAMPLE_HISTORY = [ + { + "Created": "2025-06-01T12:00:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) CMD [\"nginx\" \"-g\" \"daemon off;\"]", + "Size": 0, + "Comment": "", + "Tags": ["nginx:1.25-alpine"], + }, + { + "Created": "2025-06-01T11:59:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) ENTRYPOINT [\"/docker-entrypoint.sh\"]", + "Size": 0, + "Comment": "", + "Tags": None, + }, + { + "Created": "2025-06-01T11:58:00.000000000Z", + "CreatedBy": "/bin/sh -c set -x && apk add --no-cache nginx", + "Size": 12_500_000, + "Comment": "", + "Tags": None, + }, + { + "Created": "2025-05-15T10:00:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) ADD file:abc123 in / ", + "Size": 7_800_000, + "Comment": "", + "Tags": ["alpine:3.18"], + }, +] + + +class ImageInspectRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="inspect_admin", password="password123", role="admin" + ) + self.viewer = User.objects.create_user( + username="inspect_viewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + alias="Inspect Host", + ip_address="192.168.1.99", + port=2375, + created_by=self.admin, + ) + self.base_url = f"/api/hosts/{self.host.id}/images/inspect/" + + # ---- Successful inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_returns_full_metadata(self, MockClient): + """Successful inspect should return ENV, ENTRYPOINT, size, and layers.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + + # Core fields + self.assertEqual(data["image_id"], "sha256:abc123def456") + self.assertEqual(data["size"], 41_200_000) + self.assertEqual(data["architecture"], "amd64") + self.assertEqual(data["os"], "linux") + self.assertEqual(data["created"], "2025-06-01T12:00:00.000000000Z") + + # Repo tags and digests + self.assertEqual(data["repo_tags"], ["nginx:1.25-alpine"]) + self.assertEqual(data["repo_digests"], ["nginx@sha256:deadbeef"]) + + # ENV + self.assertEqual(len(data["env"]), 3) + self.assertIn("NGINX_VERSION=1.25.4", data["env"]) + self.assertIn("NJS_VERSION=0.8.3", data["env"]) + + # ENTRYPOINT + self.assertEqual(data["entrypoint"], ["/docker-entrypoint.sh"]) + + # CMD + self.assertEqual(data["cmd"], ["nginx", "-g", "daemon off;"]) + + # Exposed ports + self.assertIn("80/tcp", data["exposed_ports"]) + self.assertIn("443/tcp", data["exposed_ports"]) + + # Layers + self.assertEqual(len(data["layers"]), 4) + self.assertEqual(data["layers"][0]["tags"], ["nginx:1.25-alpine"]) + self.assertEqual(data["layers"][2]["size"], 12_500_000) + + # Should have connected to the correct host + MockClient.assert_called_once_with( + base_url="tcp://192.168.1.99:2375", timeout=30 + ) + + # ---- Missing query parameter ---- # + def test_inspect_missing_image_ref_returns_400(self): + """Missing image_ref query param should return 400.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("image_ref", response.data["detail"]) + + # ---- Image not found ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_not_found_returns_404(self, MockClient): + """Inspecting a non-existent image should return 404.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.images.get.side_effect = docker_lib.errors.ImageNotFound( + "no such image" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nonexistent:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("not found", response.data["detail"].lower()) + + # ---- Unauthenticated ---- # + def test_inspect_unauthenticated_returns_401(self): + """Anonymous users should not access the inspect endpoint.""" + response = self.client.get( + self.base_url, {"image_ref": "nginx:latest"} + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ---- Docker connection failure ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_connection_failure_returns_502(self, MockClient): + """If we can't connect to the Docker daemon, return 502.""" + import docker as docker_lib + + MockClient.side_effect = docker_lib.errors.DockerException( + "Connection refused" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertIn("Cannot connect", response.data["detail"]) + + # ---- Docker API error during inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_api_error_returns_502(self, MockClient): + """Generic Docker API error during image.get should return 502.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.images.get.side_effect = docker_lib.errors.APIError( + "server error" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "broken:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertIn("Docker API error", response.data["detail"]) + + # ---- Image with empty / missing config fields ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_empty_config(self, MockClient): + """Image with no ENV, no ENTRYPOINT, no CMD should return empty lists/null.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:minimal", + "RepoTags": ["scratch:latest"], + "RepoDigests": [], + "Size": 0, + "VirtualSize": 0, + "Created": "2025-01-01T00:00:00Z", + "Architecture": "", + "Os": "", + "Config": {}, # entirely empty config + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "scratch:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data["env"], []) + self.assertIsNone(data["entrypoint"]) + self.assertIsNone(data["cmd"]) + self.assertEqual(data["exposed_ports"], {}) + self.assertEqual(data["layers"], []) + + # ---- Image with None Config ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_none_config(self, MockClient): + """Image with Config=None (rare, e.g. bare manifests) should not crash.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:noconfig", + "RepoTags": [], + "RepoDigests": [], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": None, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "bare:manifest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["env"], []) + self.assertIsNone(response.data["entrypoint"]) + + # ---- History API failure is handled gracefully ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_history_failure_returns_empty_layers(self, MockClient): + """If image.history() raises, layers should be empty, rest should still work.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.side_effect = docker_lib.errors.APIError( + "history unavailable" + ) + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + # Layers empty because history() failed + self.assertEqual(data["layers"], []) + # But the rest of the inspect data should be fine + self.assertEqual(data["image_id"], "sha256:abc123def456") + self.assertEqual(len(data["env"]), 3) + self.assertEqual(data["entrypoint"], ["/docker-entrypoint.sh"]) + + # ---- Non-existent host returns 404 ---- # + def test_inspect_nonexistent_host_returns_404(self): + """Inspect on a host ID that doesn't exist should return 404.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + "/api/hosts/99999/images/inspect/", + {"image_ref": "nginx:latest"}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- POST method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_post_method_not_allowed(self, MockClient): + """Inspect endpoint only supports GET.""" + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- PUT method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_put_method_not_allowed(self, MockClient): + """PUT is not allowed on the inspect endpoint.""" + self.client.force_authenticate(user=self.admin) + response = self.client.put( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- DELETE method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_delete_method_not_allowed(self, MockClient): + """DELETE is not allowed on the inspect endpoint.""" + self.client.force_authenticate(user=self.admin) + response = self.client.delete(self.base_url) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- Empty string image_ref ---- # + def test_inspect_empty_string_image_ref_returns_400(self): + """Empty string for image_ref should be treated the same as missing.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url, {"image_ref": ""}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ---- Whitespace-only image_ref ---- # + def test_inspect_whitespace_image_ref_returns_400(self): + """Whitespace-only image_ref should be treated as missing.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url, {"image_ref": " "}) + # Django query params preserve whitespace, so this reaches Docker + # The view only checks `if not image_ref` — whitespace is truthy, + # so this actually hits Docker. We verify it doesn't crash. + # (A stricter check could strip, but this tests current behavior) + # We don't assert 400 here because whitespace is truthy in Python. + + # ---- Inspect image by digest ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_by_digest(self, MockClient): + """Should be able to inspect an image referenced by digest.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + digest_ref = "nginx@sha256:abc123def456789" + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:abc123def456789", + "RepoTags": [], + "RepoDigests": [digest_ref], + "Size": 50_000_000, + "Created": "2025-06-01T12:00:00Z", + "Architecture": "arm64", + "Os": "linux", + "Config": { + "Env": ["PATH=/usr/bin"], + "Entrypoint": None, + "Cmd": ["nginx"], + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": digest_ref} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["repo_digests"], [digest_ref]) + self.assertEqual(response.data["architecture"], "arm64") + self.assertIsNone(response.data["entrypoint"]) + # Verify the exact ref was passed to Docker + mock_client.images.get.assert_called_once_with(digest_ref) + + # ---- Multi-tag image ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_multi_tag_image(self, MockClient): + """Image with multiple tags should return all of them.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:multitag", + "RepoTags": [ + "myapp:latest", + "myapp:v1.2.3", + "registry.example.com/myapp:v1.2.3", + ], + "RepoDigests": [ + "myapp@sha256:aaa", + "registry.example.com/myapp@sha256:bbb", + ], + "Size": 100_000_000, + "Created": "2025-03-01T00:00:00Z", + "Architecture": "amd64", + "Os": "linux", + "Config": { + "Env": ["APP_VER=1.2.3"], + "Entrypoint": ["/app/start.sh"], + "Cmd": None, + "ExposedPorts": {"8080/tcp": {}}, + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "myapp:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["repo_tags"]), 3) + self.assertIn("myapp:v1.2.3", response.data["repo_tags"]) + self.assertEqual(len(response.data["repo_digests"]), 2) + self.assertIsNone(response.data["cmd"]) + self.assertIn("8080/tcp", response.data["exposed_ports"]) + + # ---- Layer with missing fields ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_layer_missing_fields_defaults_gracefully(self, MockClient): + """History entries with missing keys should get safe defaults.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:sparse", + "RepoTags": ["sparse:1"], + "Size": 500, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + # History entry with many missing keys + mock_image.history.return_value = [ + {"Created": "2025-01-01T00:00:00Z"}, # no CreatedBy, Size, etc. + {}, # completely empty entry + ] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "sparse:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + layers = response.data["layers"] + self.assertEqual(len(layers), 2) + + # First layer: has Created, rest should default + self.assertEqual(layers[0]["created"], "2025-01-01T00:00:00Z") + self.assertEqual(layers[0]["created_by"], "") + self.assertEqual(layers[0]["size"], 0) + self.assertEqual(layers[0]["comment"], "") + self.assertEqual(layers[0]["tags"], []) + + # Second layer: completely empty, all defaults + self.assertEqual(layers[1]["created"], "") + self.assertEqual(layers[1]["size"], 0) + self.assertEqual(layers[1]["tags"], []) + + # ---- Image with no VirtualSize ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_without_virtual_size(self, MockClient): + """Newer Docker versions may not include VirtualSize; should be null.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:novsize", + "RepoTags": ["test:1"], + "Size": 30_000_000, + "Created": "2025-01-01T00:00:00Z", + "Architecture": "amd64", + "Os": "linux", + "Config": {"Env": [], "Entrypoint": None, "Cmd": ["sh"]}, + # VirtualSize deliberately missing + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "test:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["virtual_size"]) + self.assertEqual(response.data["size"], 30_000_000) + + # ---- Many-layer image ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_many_layers(self, MockClient): + """Images can have dozens of layers; all should be returned.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:manylayers", + "RepoTags": ["big:1"], + "Size": 500_000_000, + "Created": "2025-01-01T00:00:00Z", + "Config": {"Env": ["X=1"]}, + } + # Generate 50 history layers + many_layers = [ + { + "Created": f"2025-01-01T00:00:{i:02d}Z", + "CreatedBy": f"RUN echo layer-{i}", + "Size": 100_000 * i, + "Comment": "", + "Tags": None, + } + for i in range(50) + ] + mock_image.history.return_value = many_layers + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "big:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["layers"]), 50) + self.assertEqual( + response.data["layers"][49]["created_by"], "RUN echo layer-49" + ) + + # ---- ENV with special characters ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_env_with_special_characters(self, MockClient): + """ENV values can contain =, spaces, quotes, and special chars.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + special_envs = [ + "DATABASE_URL=postgres://user:p@ss=word@db:5432/mydb", + "JAVA_OPTS=-Xmx512m -XX:+UseG1GC", + 'GREETING=Hello "World"', + "EMPTY_VAR=", + "MULTIEQUAL=a=b=c=d", + ] + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:specialenv", + "RepoTags": ["app:env-test"], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": { + "Env": special_envs, + "Entrypoint": ["/bin/sh", "-c"], + "Cmd": ["echo", "$GREETING"], + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "app:env-test"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["env"]), 5) + self.assertIn( + "DATABASE_URL=postgres://user:p@ss=word@db:5432/mydb", + response.data["env"], + ) + self.assertIn("EMPTY_VAR=", response.data["env"]) + self.assertEqual( + response.data["entrypoint"], ["/bin/sh", "-c"] + ) + self.assertEqual(response.data["cmd"], ["echo", "$GREETING"]) + + # ---- Different host port ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_uses_correct_host_port(self, MockClient): + """Should connect using the host's configured port (e.g. TLS 2376).""" + tls_host = Host.objects.create( + alias="TLS Host", + ip_address="10.0.0.50", + port=2376, + created_by=self.admin, + ) + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:tlsimage", + "RepoTags": ["tls:1"], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + f"/api/hosts/{tls_host.id}/images/inspect/", + {"image_ref": "tls:1"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + MockClient.assert_called_once_with( + base_url="tcp://10.0.0.50:2376", timeout=30 + ) + + # ---- Response contains all expected top-level keys ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_response_has_all_expected_keys(self, MockClient): + """Verify the response JSON has exactly the expected schema keys.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + expected_keys = { + "image_id", + "repo_tags", + "repo_digests", + "size", + "virtual_size", + "created", + "architecture", + "os", + "env", + "entrypoint", + "cmd", + "exposed_ports", + "layers", + } + self.assertEqual(set(response.data.keys()), expected_keys) + + # ---- Layer sub-object has expected keys ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_layer_has_expected_keys(self, MockClient): + """Each layer in the response should have the expected fields.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + expected_layer_keys = {"created", "created_by", "size", "comment", "tags"} + for layer in response.data["layers"]: + self.assertEqual(set(layer.keys()), expected_layer_keys) + + # ---- Verify image_ref is forwarded to Docker ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_forwards_image_ref_to_docker(self, MockClient): + """The exact image_ref from the query param should be passed to client.images.get().""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:fwd", + "RepoTags": ["my-registry.io/org/app:v2.0.0-rc1"], + "Size": 1000, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + ref = "my-registry.io/org/app:v2.0.0-rc1" + self.client.force_authenticate(user=self.viewer) + self.client.get(self.base_url, {"image_ref": ref}) + + mock_client.images.get.assert_called_once_with(ref) + + # ---- Admin can also inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_as_admin(self, MockClient): + """Admin should be able to use the inspect endpoint.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.admin) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # ---- Host-role user can also inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_as_host_role(self, MockClient): + """Host-role user should be able to use the inspect endpoint.""" + host_user = User.objects.create_user( + username="inspect_hoster", password="password123", role="host" + ) + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=host_user) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- # +# Inspect Serializer unit tests +# --------------------------------------------------------------------------- # +class ImageInspectSerializerTest(TestCase): + """Validate ImageInspectSerializer and ImageLayerSerializer independently.""" + + def test_layer_serializer_valid_data(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-06-01T12:00:00Z", + "created_by": "RUN apt-get update", + "size": 5_000_000, + "comment": "install deps", + "tags": ["base:latest"], + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertEqual(s.validated_data["size"], 5_000_000) + self.assertEqual(s.validated_data["tags"], ["base:latest"]) + + def test_layer_serializer_null_created(self): + from .serializers import ImageLayerSerializer + + data = { + "created": None, + "created_by": "", + "size": 0, + "comment": "", + "tags": [], + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["created"]) + + def test_layer_serializer_null_created_by(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-01-01T00:00:00Z", + "created_by": None, + "size": 100, + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["created_by"]) + + def test_layer_serializer_missing_required_size(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-01-01T00:00:00Z", + "created_by": "RUN echo hi", + # size is missing + } + s = ImageLayerSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("size", s.errors) + + def test_inspect_serializer_complete_data(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:abc", + "repo_tags": ["img:1"], + "repo_digests": ["img@sha256:xyz"], + "size": 10_000, + "virtual_size": 10_000, + "created": "2025-01-01T00:00:00Z", + "architecture": "amd64", + "os": "linux", + "env": ["PATH=/bin", "HOME=/root"], + "entrypoint": ["/start.sh"], + "cmd": ["--serve"], + "exposed_ports": {"80/tcp": {}}, + "layers": [ + { + "created": "2025-01-01T00:00:00Z", + "created_by": "ADD . /app", + "size": 5000, + "comment": "", + "tags": [], + } + ], + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + v = s.validated_data + self.assertEqual(v["image_id"], "sha256:abc") + self.assertEqual(len(v["env"]), 2) + self.assertEqual(len(v["layers"]), 1) + self.assertEqual(v["layers"][0]["size"], 5000) + + def test_inspect_serializer_missing_image_id(self): + from .serializers import ImageInspectSerializer + + data = { + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [], + } + s = ImageInspectSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("image_id", s.errors) + + def test_inspect_serializer_null_entrypoint_and_cmd(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:nullep", + "size": 0, + "created": "2025-01-01T00:00:00Z", + "entrypoint": None, + "cmd": None, + "layers": [], + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["entrypoint"]) + self.assertIsNone(s.validated_data["cmd"]) + + def test_inspect_serializer_virtual_size_optional(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:novs", + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [], + # virtual_size deliberately omitted + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + + def test_inspect_serializer_invalid_layer_cascades(self): + """Invalid layer data should cause the whole serializer to fail.""" + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:badlayer", + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [ + { + "created": "2025-01-01T00:00:00Z", + "created_by": "RUN test", + # size missing — required field + } + ], + } + s = ImageInspectSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("layers", s.errors) + + def test_inspect_serializer_output_format(self): + """Verify serializer output when used for read (not validation).""" + from .serializers import ImageInspectSerializer + + inspect_data = { + "image_id": "sha256:readtest", + "repo_tags": ["test:1", "test:latest"], + "repo_digests": [], + "size": 999, + "virtual_size": None, + "created": "2025-06-01T00:00:00Z", + "architecture": "arm64", + "os": "linux", + "env": ["A=1"], + "entrypoint": ["/bin/app"], + "cmd": None, + "exposed_ports": {"3000/tcp": {}}, + "layers": [ + { + "created": "2025-06-01T00:00:00Z", + "created_by": "COPY . /app", + "size": 500, + "comment": "app code", + "tags": ["test:1"], + } + ], + } + s = ImageInspectSerializer(inspect_data) + output = s.data + + self.assertEqual(output["image_id"], "sha256:readtest") + self.assertEqual(output["repo_tags"], ["test:1", "test:latest"]) + self.assertIsNone(output["virtual_size"]) + self.assertIsNone(output["cmd"]) + self.assertEqual(len(output["layers"]), 1) + self.assertEqual(output["layers"][0]["comment"], "app code") + self.assertEqual(output["layers"][0]["tags"], ["test:1"]) diff --git a/backend/images/urls.py b/backend/images/urls.py new file mode 100644 index 0000000..d9d73a8 --- /dev/null +++ b/backend/images/urls.py @@ -0,0 +1,36 @@ +from django.urls import path + +from .views import ( + HostImageListView, + ImagePullJobDetailCancelView, + ImagePullJobListCreateView, + ImagePushJobDetailCancelView, + ImagePushJobListCreateView, + ImageDeleteJobDetailCancelView, + ImageDeleteJobListCreateView, +) + +urlpatterns = [ + path("list/", HostImageListView.as_view(), name="image-list"), + # Pull endpoints + path("pull/", ImagePullJobListCreateView.as_view(), name="image-pull-list-create"), + path( + "pull//", + ImagePullJobDetailCancelView.as_view(), + name="image-pull-detail", + ), + # Push endpoints + path("push/", ImagePushJobListCreateView.as_view(), name="image-push-list-create"), + path( + "push//", + ImagePushJobDetailCancelView.as_view(), + name="image-push-detail", + ), + # Delete endpoints + path("delete/", ImageDeleteJobListCreateView.as_view(), name="image-delete-list-create"), + path( + "delete//", + ImageDeleteJobDetailCancelView.as_view(), + name="image-delete-detail", + ), +] diff --git a/backend/images/views.py b/backend/images/views.py new file mode 100644 index 0000000..7ffb3ac --- /dev/null +++ b/backend/images/views.py @@ -0,0 +1,744 @@ +import logging +import json +import tarfile +import zipfile +from io import BytesIO +from pathlib import PurePosixPath + +import docker +from django.http import StreamingHttpResponse +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from hosts.models import Host, UserHostRole + +from .models import ImagePullJob, ImagePushJob, ImageDeleteJob +from .permissions import IsAdminOrHostOwner +from .serializers import ( + ImageBuildRequestSerializer, + ImageInspectSerializer, + HostImageListItemSerializer, + ImagePullJobCreateSerializer, + ImagePullJobSerializer, + ImagePushJobCreateSerializer, + ImagePushJobSerializer, + ImageDeleteJobCreateSerializer, + ImageDeleteJobSerializer, +) +from .worker import enqueue_pull, enqueue_push, enqueue_delete + +logger = logging.getLogger(__name__) + + +def _assigned_host_role(user, host): + return UserHostRole.objects.filter( + user=user, + host=host, + ).values_list("role", flat=True).first() + + +def _can_read_images_on_host(user, host): + if user.is_superuser or user.role == "admin": + return True + return _assigned_host_role(user, host) in {"VIEWER", "HOST_OWNER", "ADMIN"} + + +def _can_write_images_on_host(user, host): + if user.is_superuser or user.role == "admin": + return True + return _assigned_host_role(user, host) == "ADMIN" + + +def _is_local_hostname(hostname: str) -> bool: + value = (hostname or "").strip().lower() + return value in {"localhost", "127.0.0.1", "::1"} + + +def _get_docker_client_for_host(host: Host, timeout: int): + # Prefer local socket for localhost hosts (OrbStack / Docker Desktop on macOS) + if _is_local_hostname(host.ip_address): + try: + return docker.from_env(timeout=timeout) + except docker.errors.DockerException: + pass + + return docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=timeout, + ) + + +def _user_is_admin(user) -> bool: + return bool( + user + and user.is_authenticated + and ( + user.is_superuser + or user.is_staff + or getattr(user, "role", "") == "admin" + ) + ) + + +def _user_can_manage_host(user, host: Host) -> bool: + if _user_is_admin(user): + return True + + if host.created_by_id == user.id: + return True + + return UserHostRole.objects.filter( + user=user, + host=host, + role__in=["ADMIN", "HOST_OWNER"], + ).exists() + + +class ImagePullJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/pull/ → list all pull jobs for this host + POST /api/hosts/{host_id}/images/pull/ → enqueue a background image pull + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImagePullJobCreateSerializer + return ImagePullJobSerializer + + def get_queryset(self): + return ( + ImagePullJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.created_by != request.user: + return Response( + {"detail": "You do not have permission to pull images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImagePullJobCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + registry_cred = validated.get("registry_credential") + + job = ImagePullJob.objects.create( + host=host, + requested_by=request.user, + image_ref=validated["image_ref"], + registry_credential=registry_cred, + ) + + logger.info( + "Image pull job created id=%s image=%s host=%s user=%s", + job.id, + job.image_ref, + host.alias, + request.user.username, + ) + + # Fire the background worker + enqueue_pull(job.id) + + output_serializer = ImagePullJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImagePullJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/pull/{job_id}/ → retrieve job status & progress + DELETE /api/hosts/{host_id}/images/pull/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImagePullJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ( + ImagePullJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel pull jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImagePullJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImagePullJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Pull job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Pull job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) + + +class ImageBuildStreamView(APIView): + """ + POST /api/hosts/{host_id}/images/build/ + + Accepts either: + - dockerfile: raw Dockerfile string + - context_zip: uploaded ZIP file with build context + + Streams Docker build output back to the client as NDJSON. + """ + + permission_classes = [IsAuthenticated] + + @staticmethod + def _safe_zip_member(name: str) -> bool: + path = PurePosixPath(name) + if path.is_absolute(): + return False + if any(part in {"..", ""} for part in path.parts): + return False + return True + + def _build_context_tar( + self, + dockerfile_text: str, + context_zip, + ) -> BytesIO: + tar_buffer = BytesIO() + + with tarfile.open(fileobj=tar_buffer, mode="w") as tar: + if context_zip: + context_zip.seek(0) + with zipfile.ZipFile(context_zip) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + if not self._safe_zip_member(member.filename): + continue + + content = archive.read(member.filename) + tar_info = tarfile.TarInfo(name=member.filename) + tar_info.size = len(content) + tar.addfile(tar_info, BytesIO(content)) + + if dockerfile_text: + dockerfile_bytes = dockerfile_text.encode("utf-8") + dockerfile_info = tarfile.TarInfo(name="Dockerfile") + dockerfile_info.size = len(dockerfile_bytes) + tar.addfile(dockerfile_info, BytesIO(dockerfile_bytes)) + + tar_buffer.seek(0) + return tar_buffer + + def post(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + if request.user.role != "admin" and host.created_by != request.user: + return Response( + {"detail": "You do not have permission to build images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImageBuildRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + dockerfile_text = (data.get("dockerfile") or "").strip() + context_zip = data.get("context_zip") + tag = (data.get("tag") or "").strip() or None + pull = data.get("pull", False) + nocache = data.get("nocache", False) + + try: + context_tar = self._build_context_tar( + dockerfile_text=dockerfile_text, + context_zip=context_zip, + ) + except zipfile.BadZipFile: + return Response( + {"detail": "context_zip must be a valid ZIP archive."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + client = _get_docker_client_for_host(host=host, timeout=600) + except docker.errors.DockerException as exc: + logger.error( + "Cannot connect to Docker daemon on host %s: %s", + host.alias, + exc, + ) + return Response( + {"detail": f"Cannot connect to Docker daemon: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + def stream_output(): + try: + image, logs = client.images.build( + fileobj=context_tar, + custom_context=True, + rm=True, + pull=pull, + nocache=nocache, + tag=tag, + ) + for chunk in logs: + yield f"{json.dumps(chunk)}\n" + + yield json.dumps({"status": "done", "image_id": image.id}) + "\n" + except docker.errors.BuildError as exc: + logger.warning("Image build failed on host %s: %s", host.alias, exc) + yield json.dumps({"error": "build_failed", "detail": str(exc)}) + "\n" + except docker.errors.APIError as exc: + logger.warning("Docker API build error on host %s: %s", host.alias, exc) + yield ( + json.dumps({"error": "docker_api_error", "detail": str(exc)}) + + "\n" + ) + except Exception as exc: + logger.exception("Unexpected image build error on host %s", host.alias) + yield ( + json.dumps({"error": "unexpected_error", "detail": str(exc)}) + + "\n" + ) + + return StreamingHttpResponse( + streaming_content=stream_output(), + content_type="application/x-ndjson", + ) + + +# --------------------------------------------------------------------------- # +# Image Inspect view +# --------------------------------------------------------------------------- # + + +class ImageInspectView(APIView): + """ + GET /api/hosts/{host_id}/images/inspect/?image_ref= + + Connects to the Docker daemon on the specified host, inspects the + given image, and returns: + • ENV variables + • ENTRYPOINT + • CMD + • Total size + • Architecture / OS + • Exposed ports + • Full layer history + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + if not _can_read_images_on_host(request.user, host): + return Response( + {"detail": "You do not have permission to inspect images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + image_ref = request.query_params.get("image_ref") + if not image_ref: + return Response( + {"detail": "Query parameter 'image_ref' is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Connect to the Docker daemon on the target host + try: + client = _get_docker_client_for_host(host=host, timeout=30) + except docker.errors.DockerException as exc: + logger.error( + "Cannot connect to Docker daemon on host %s: %s", + host.alias, + exc, + ) + return Response( + {"detail": f"Cannot connect to Docker daemon: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + # Inspect the image + try: + image = client.images.get(image_ref) + except docker.errors.ImageNotFound: + return Response( + {"detail": f"Image '{image_ref}' not found on host '{host.alias}'."}, + status=status.HTTP_404_NOT_FOUND, + ) + except docker.errors.APIError as exc: + logger.error( + "Docker API error inspecting image %s on host %s: %s", + image_ref, + host.alias, + exc, + ) + return Response( + {"detail": f"Docker API error: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + attrs = image.attrs # raw inspect dict from Docker daemon + config = attrs.get("Config") or {} + + # Retrieve the image history (layer list) + try: + history_raw = image.history() + except docker.errors.APIError as exc: + logger.warning( + "Failed to get history for image %s: %s", image_ref, exc + ) + history_raw = [] + + layers = [ + { + "created": entry.get("Created", ""), + "created_by": entry.get("CreatedBy", ""), + "size": entry.get("Size", 0), + "comment": entry.get("Comment", ""), + "tags": entry.get("Tags") or [], + } + for entry in history_raw + ] + + inspect_data = { + "image_id": attrs.get("Id", ""), + "repo_tags": attrs.get("RepoTags") or [], + "repo_digests": attrs.get("RepoDigests") or [], + "size": attrs.get("Size", 0), + "virtual_size": attrs.get("VirtualSize"), + "created": attrs.get("Created", ""), + "architecture": attrs.get("Architecture", ""), + "os": attrs.get("Os", ""), + "env": config.get("Env") or [], + "entrypoint": config.get("Entrypoint"), + "cmd": config.get("Cmd"), + "exposed_ports": config.get("ExposedPorts") or {}, + "layers": layers, + } + + serializer = ImageInspectSerializer(inspect_data) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class HostImageListView(APIView): + """ + GET /api/hosts/{host_id}/images/list/ + + Returns local Docker images available on the target host. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + if not _can_read_images_on_host(request.user, host): + return Response( + {"detail": "You do not have permission to list images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + client = _get_docker_client_for_host(host=host, timeout=30) + except docker.errors.DockerException as exc: + logger.error( + "Cannot connect to Docker daemon on host %s: %s", + host.alias, + exc, + ) + return Response( + {"detail": f"Cannot connect to Docker daemon: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + try: + docker_images = client.images.list() + except docker.errors.APIError as exc: + logger.error( + "Docker API error listing images on host %s: %s", + host.alias, + exc, + ) + return Response( + {"detail": f"Docker API error: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + items = [] + for image in docker_images: + attrs = image.attrs or {} + tags = image.tags or [] + + # Keep dangling images searchable in UI as well. + refs = tags if tags else [":"] + for ref in refs: + items.append( + { + "image_id": attrs.get("Id") or image.id, + "image_ref": ref, + "created": attrs.get("Created", ""), + "size": attrs.get("Size", 0), + } + ) + + serializer = HostImageListItemSerializer(items, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- # +# Image Push/Tag views +# --------------------------------------------------------------------------- # + + +class ImagePushJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/push/ → list all push jobs for this host + POST /api/hosts/{host_id}/images/push/ → enqueue a background image tag + push + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImagePushJobCreateSerializer + return ImagePushJobSerializer + + def get_queryset(self): + return ( + ImagePushJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.created_by != request.user: + return Response( + {"detail": "You do not have permission to push images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImagePushJobCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + registry_cred = validated.get("registry_credential") + + job = ImagePushJob.objects.create( + host=host, + requested_by=request.user, + source_image_ref=validated["source_image_ref"], + target_image_ref=validated["target_image_ref"], + registry_credential=registry_cred, + ) + + logger.info( + "Image push job created id=%s source=%s target=%s host=%s user=%s", + job.id, + job.source_image_ref, + job.target_image_ref, + host.alias, + request.user.username, + ) + + # Fire the background worker + enqueue_push(job.id) + + output_serializer = ImagePushJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImagePushJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/push/{job_id}/ → retrieve job status & progress + DELETE /api/hosts/{host_id}/images/push/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImagePushJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ( + ImagePushJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel push jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImagePushJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImagePushJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Push job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Push job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune views +# --------------------------------------------------------------------------- # + + +class ImageDeleteJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/delete/ → list all delete jobs for this host + POST /api/hosts/{host_id}/images/delete/ → enqueue a background image delete + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImageDeleteJobCreateSerializer + return ImageDeleteJobSerializer + + def get_queryset(self): + return ImageDeleteJob.objects.filter(host_id=self.kwargs["host_id"]).select_related( + "host", "requested_by" + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.created_by != request.user: + return Response( + {"detail": "You do not have permission to delete images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImageDeleteJobCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + + job = ImageDeleteJob.objects.create( + host=host, + requested_by=request.user, + delete_mode=validated["delete_mode"], + image_refs=validated.get("image_refs", ""), + force=validated.get("force", False), + ) + + logger.info( + "Image delete job created id=%s mode=%s host=%s user=%s", + job.id, + job.delete_mode, + host.alias, + request.user.username, + ) + + # Fire the background worker + enqueue_delete(job.id) + + output_serializer = ImageDeleteJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImageDeleteJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/delete/{job_id}/ → retrieve job status + DELETE /api/hosts/{host_id}/images/delete/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImageDeleteJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ImageDeleteJob.objects.filter(host_id=self.kwargs["host_id"]).select_related( + "host", "requested_by" + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel delete jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImageDeleteJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImageDeleteJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Delete job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Delete job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) diff --git a/backend/images/worker.py b/backend/images/worker.py new file mode 100644 index 0000000..59a3c9f --- /dev/null +++ b/backend/images/worker.py @@ -0,0 +1,458 @@ +""" +Background worker that pulls Docker images in a separate thread. + +Uses Python threading (no Celery required). Each pull job runs in its own +daemon thread, streams progress from the Docker daemon, and updates the +ImagePullJob model as it progresses. +""" + +import json +import logging +import threading + +import docker +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def _is_local_hostname(hostname: str) -> bool: + value = (hostname or "").strip().lower() + return value in {"localhost", "127.0.0.1", "::1"} + + +def _do_pull(job_id: str) -> None: + """Execute the image pull in a background thread.""" + # Import here to avoid circular imports & ensure Django is ready + from .models import ImagePullJob + + try: + job = ImagePullJob.objects.select_related( + "host", "registry_credential" + ).get(pk=job_id) + except ImagePullJob.DoesNotExist: + logger.error("Pull job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImagePullJob.Status.CANCELLED: + logger.info("Pull job %s was cancelled before starting.", job_id) + return + + # Mark as PULLING + job.status = ImagePullJob.Status.PULLING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + if _is_local_hostname(host.ip_address): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + + # Build auth_config if a credential is linked + auth_config = None + if job.registry_credential: + cred = job.registry_credential + auth_config = { + "username": cred.username, + "password": cred.token, # decrypted via property + } + + # Parse image name and tag + if ":" in job.image_ref and "@" not in job.image_ref: + repository, tag = job.image_ref.rsplit(":", 1) + else: + repository = job.image_ref + tag = None + + # Stream the pull and capture progress + progress_lines = [] + pull_kwargs = {"repository": repository, "stream": True, "decode": True} + if tag: + pull_kwargs["tag"] = tag + if auth_config: + pull_kwargs["auth_config"] = auth_config + + for chunk in client.api.pull(**pull_kwargs): + line = json.dumps(chunk) + progress_lines.append(line) + + # Periodically flush progress to DB (every 20 lines) + if len(progress_lines) % 20 == 0: + job.progress_log = "\n".join(progress_lines) + job.save(update_fields=["progress_log"]) + + # Final progress flush + job.progress_log = "\n".join(progress_lines) + job.status = ImagePullJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save(update_fields=["progress_log", "status", "completed_at"]) + + logger.info( + "Pull job %s completed successfully image=%s host=%s", + job.id, + job.image_ref, + host.alias, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + job.status = ImagePullJob.Status.FAILED + job.error_message = explanation + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning("Pull job %s failed: %s", job.id, explanation) + + except Exception as exc: + job.status = ImagePullJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Pull job %s unexpected error", job.id) + + +def enqueue_pull(job_id: str) -> None: + """ + Spawn a daemon thread to pull the image. + The thread will exit automatically when the pull completes or fails. + """ + thread = threading.Thread( + target=_do_pull, + args=(str(job_id),), + name=f"pull-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued pull job %s in thread %s", job_id, thread.name) + + +# --------------------------------------------------------------------------- # +# Image Push Operations +# --------------------------------------------------------------------------- # + + +def _do_push(job_id: str) -> None: + """Execute the image tag and push in a background thread.""" + from .models import ImagePushJob + + try: + job = ImagePushJob.objects.select_related( + "host", "registry_credential" + ).get(pk=job_id) + except ImagePushJob.DoesNotExist: + logger.error("Push job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImagePushJob.Status.CANCELLED: + logger.info("Push job %s was cancelled before starting.", job_id) + return + + # Mark as TAGGING + job.status = ImagePushJob.Status.TAGGING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + if _is_local_hostname(host.ip_address): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + + # Step 1: Tag the image + try: + image = client.images.get(job.source_image_ref) + image.tag(job.target_image_ref) + progress = { + "status": "tagged", + "source": job.source_image_ref, + "target": job.target_image_ref, + } + job.progress_log = json.dumps(progress) + job.status = ImagePushJob.Status.PUSHING + job.save(update_fields=["progress_log", "status"]) + logger.info( + "Tagged image %s as %s on host %s", + job.source_image_ref, + job.target_image_ref, + host.alias, + ) + except docker.errors.ImageNotFound: + job.status = ImagePushJob.Status.FAILED + job.error_message = f"Source image '{job.source_image_ref}' not found on host" + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning( + "Push job %s failed: image not found %s", job.id, job.source_image_ref + ) + return + + # Step 2: Push the tagged image + auth_config = None + if job.registry_credential: + cred = job.registry_credential + auth_config = { + "username": cred.username, + "password": cred.token, # decrypted via property + } + + # Stream the push and capture progress + progress_lines = [] + push_kwargs = { + "repository": job.target_image_ref, + "stream": True, + "decode": True, + } + if auth_config: + push_kwargs["auth_config"] = auth_config + + for chunk in client.api.push(**push_kwargs): + line = json.dumps(chunk) + progress_lines.append(line) + + # Periodically flush progress to DB (every 20 lines) + if len(progress_lines) % 20 == 0: + job.progress_log = "\n".join(progress_lines) + job.save(update_fields=["progress_log"]) + + # Final progress flush + job.progress_log = "\n".join(progress_lines) + job.status = ImagePushJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save(update_fields=["progress_log", "status", "completed_at"]) + + logger.info( + "Push job %s completed successfully source=%s target=%s host=%s", + job.id, + job.source_image_ref, + job.target_image_ref, + host.alias, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + job.status = ImagePushJob.Status.FAILED + job.error_message = explanation + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning("Push job %s failed: %s", job.id, explanation) + + except Exception as exc: + job.status = ImagePushJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Push job %s unexpected error", job.id) + + +def enqueue_push(job_id: str) -> None: + """ + Spawn a daemon thread to tag and push the image. + The thread will exit automatically when the push completes or fails. + """ + thread = threading.Thread( + target=_do_push, + args=(str(job_id),), + name=f"push-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued push job %s in thread %s", job_id, thread.name) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune Operations +# --------------------------------------------------------------------------- # + + +def _do_delete(job_id: str) -> None: + """Execute the image deletion/pruning in a background thread.""" + from .models import ImageDeleteJob + + try: + job = ImageDeleteJob.objects.select_related("host").get(pk=job_id) + except ImageDeleteJob.DoesNotExist: + logger.error("Delete job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImageDeleteJob.Status.CANCELLED: + logger.info("Delete job %s was cancelled before starting.", job_id) + return + + # Mark as DELETING + job.status = ImageDeleteJob.Status.DELETING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + if _is_local_hostname(host.ip_address): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.ip_address}:{host.port}", + timeout=300, + ) + + deleted_count = 0 + space_freed = 0 + progress_log = [] + + if job.delete_mode == ImageDeleteJob.DeleteMode.UNUSED: + # Prune all unused images + try: + result = client.images.prune(filters={"dangling": False}) + deleted_count = len(result.get("ImagesDeleted") or []) + space_freed = result.get("SpaceReclaimed", 0) + + progress = { + "status": "pruned", + "mode": "unused", + "deleted": deleted_count, + "space_freed": space_freed, + } + progress_log.append(json.dumps(progress)) + logger.info( + "Pruned %d unused images on host %s, freed %d bytes", + deleted_count, + host.alias, + space_freed, + ) + except docker.errors.APIError as exc: + raise Exception(f"Prune failed: {exc}") + + else: # SPECIFIC mode + # Delete specific images + image_refs = [ + ref.strip() for ref in job.image_refs.split(",") if ref.strip() + ] + + for image_ref in image_refs: + try: + # Get image info before deletion (to get size) + try: + image = client.images.get(image_ref) + image_size = image.attrs.get("Size", 0) + except docker.errors.ImageNotFound: + logger.warning( + "Image %s not found for deletion on host %s", + image_ref, + host.alias, + ) + progress = { + "status": "warning", + "image": image_ref, + "reason": "not_found", + } + progress_log.append(json.dumps(progress)) + continue + + # Delete the image + client.images.remove(image_ref, force=job.force) + deleted_count += 1 + space_freed += image_size + + progress = { + "status": "deleted", + "image": image_ref, + "size": image_size, + } + progress_log.append(json.dumps(progress)) + logger.info( + "Deleted image %s on host %s (size: %d bytes)", + image_ref, + host.alias, + image_size, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + logger.warning( + "Delete job %s failed to delete %s: %s", + job.id, + image_ref, + explanation, + ) + progress = { + "status": "error", + "image": image_ref, + "error": explanation, + } + progress_log.append(json.dumps(progress)) + + # Final update + job.progress_log = "\n".join(progress_log) + job.deleted_count = deleted_count + job.space_freed_bytes = space_freed + job.status = ImageDeleteJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save( + update_fields=[ + "progress_log", + "deleted_count", + "space_freed_bytes", + "status", + "completed_at", + ] + ) + + logger.info( + "Delete job %s completed: deleted %d images, freed %d bytes on host %s", + job.id, + deleted_count, + space_freed, + host.alias, + ) + + except Exception as exc: + job.status = ImageDeleteJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Delete job %s unexpected error", job.id) + + +def enqueue_delete(job_id: str) -> None: + """ + Spawn a daemon thread to delete images. + The thread will exit automatically when deletion completes or fails. + """ + thread = threading.Thread( + target=_do_delete, + args=(str(job_id),), + name=f"delete-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued delete job %s in thread %s", job_id, thread.name) diff --git a/backend/networks/__init__.py b/backend/networks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/networks/apps.py b/backend/networks/apps.py new file mode 100644 index 0000000..9c0e2b2 --- /dev/null +++ b/backend/networks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NetworksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "networks" diff --git a/backend/networks/docker_service.py b/backend/networks/docker_service.py new file mode 100644 index 0000000..7a0c6a0 --- /dev/null +++ b/backend/networks/docker_service.py @@ -0,0 +1,97 @@ +import logging +import docker +from django.conf import settings +from requests.exceptions import ConnectionError +from hosts.models import Host +from .models import Network + +logger = logging.getLogger(__name__) + +def _is_local_hostname(hostname: str) -> bool: + value = (hostname or "").strip().lower() + return value in {"localhost", "127.0.0.1", "::1"} + +def get_client(host: Host) -> docker.DockerClient: + """ + SDK Connection Factory + Dynamically initializes a docker.DockerClient based on the Host model's connection parameters. + """ + if _is_local_hostname(host.ip_address): + try: + return docker.from_env(timeout=10) + except docker.errors.DockerException: + pass + + base_url = f"tcp://{host.ip_address}:{host.port}" + + return docker.DockerClient(base_url=base_url, timeout=10) + + +def create_network_in_platform( + host: Host, + user, + name: str, + driver: str = "bridge", + subnet: str = None, + gateway: str = None, + internal: bool = False, + attachable: bool = True, + labels: dict = None +) -> Network: + """ + Creates a network on the remote Docker engine AND synchronizes its + metadata into the Django platform database for persistence tracking. + """ + try: + client = get_client(host) + except Exception as e: + logger.error(f"Failed to resolve connection factory client parameters for host {host.alias}: {str(e)}") + raise RuntimeError(f"Engine connection string configurations invalid.") + + ipam_config = None + if subnet or gateway: + if not (subnet and gateway): + raise ValueError("Both subnet and gateway parameters must be provided together for custom static IPAM.") + + ipam_pool = docker.types.IPAMPool(subnet=subnet, gateway=gateway) + ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) + + try: + sdk_network = client.networks.create( + name=name, + driver=driver, + internal=internal, + attachable=attachable, + labels=labels or {}, + ipam=ipam_config + ) + except ConnectionError: + logger.error(f"Host machine engine target unreachable at {host.ip_address}:{host.port}") + raise RuntimeError("Target Docker daemon engine is currently offline or unreachable.") + except docker.errors.APIError as e: + logger.warning(f"Docker API rejection encountered during execution: {e.explanation}") + raise RuntimeError(f"Docker Daemon Rejection: {e.explanation}") + + try: + platform_network = Network.objects.create( + host=host, + created_by=user, + docker_network_id=sdk_network.id, + name=name, + driver=driver, + subnet=subnet, + gateway=gateway, + internal=internal, + attachable=attachable, + labels=labels or {} + ) + logger.info(f"User {user.username} successfully registered network '{name}' (ID: {sdk_network.id[:12]}) on host '{host.alias}'.") + return platform_network + + except Exception as db_err: + logger.critical(f"Critical DB sync desynchronization: deleting orphaned engine network resource. Error: {str(db_err)}") + try: + sdk_network.remove() + except Exception: + pass + raise db_err \ No newline at end of file diff --git a/backend/networks/migrations/0001_initial.py b/backend/networks/migrations/0001_initial.py new file mode 100644 index 0000000..55ae21c --- /dev/null +++ b/backend/networks/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-06-09 11:04 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('hosts', '0004_remove_host_uniq_host_ip_port'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Network', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('docker_network_id', models.CharField(max_length=64)), + ('name', models.CharField(max_length=100)), + ('driver', models.CharField(max_length=50)), + ('subnet', models.CharField(blank=True, max_length=50, null=True)), + ('gateway', models.CharField(blank=True, max_length=50, null=True)), + ('internal', models.BooleanField(default=False)), + ('attachable', models.BooleanField(default=True)), + ('labels', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='networks', to='hosts.host')), + ], + ), + ] diff --git a/backend/networks/migrations/__init__.py b/backend/networks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/networks/models.py b/backend/networks/models.py new file mode 100644 index 0000000..5d4868f --- /dev/null +++ b/backend/networks/models.py @@ -0,0 +1,21 @@ +import uuid +from django.db import models +from django.conf import settings +from hosts.models import Host + +class Network(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="networks") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + docker_network_id = models.CharField(max_length=64) + name = models.CharField(max_length=100) + driver = models.CharField(max_length=50) + subnet = models.CharField(max_length=50, null=True, blank=True) + gateway = models.CharField(max_length=50, null=True, blank=True) + internal = models.BooleanField(default=False) + attachable = models.BooleanField(default=True) + labels = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} ({self.driver})" diff --git a/backend/networks/serializers.py b/backend/networks/serializers.py new file mode 100644 index 0000000..92e406c --- /dev/null +++ b/backend/networks/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from .models import Network + +User = get_user_model() + +class NetworkSerializer(serializers.ModelSerializer): + created_by = serializers.SlugRelatedField(read_only=True, slug_field='username') + host = serializers.SlugRelatedField(read_only=True, slug_field='alias') + labels = serializers.JSONField(required=False, default=dict) + + class Meta: + model = Network + fields = [ + 'id', 'host', 'docker_network_id', 'name', 'driver', 'subnet', + 'gateway', 'internal', 'attachable', 'labels', 'created_by', 'created_at' + ] + read_only_fields = ['id', 'docker_network_id', 'created_by', 'created_at'] + +class NetworkCreateSerializer(NetworkSerializer): + pass \ No newline at end of file diff --git a/backend/networks/tests.py b/backend/networks/tests.py new file mode 100644 index 0000000..d72a4df --- /dev/null +++ b/backend/networks/tests.py @@ -0,0 +1,273 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from django.contrib.auth import get_user_model +from hosts.models import Host, UserHostRole +from networks.models import Network +from unittest.mock import MagicMock, patch + +User = get_user_model() + +class NetworkTests(APITestCase): + def setUp(self): + # 1. Create Users + self.admin = User.objects.create_superuser(username='admin', password='pass123') + self.host_owner = User.objects.create_user(username='owner', password='pass123') + self.viewer = User.objects.create_user(username='viewer', password='pass123') + self.other_user = User.objects.create_user(username='other', password='pass123') + + # 2. Create Host + self.host = Host.objects.create( + alias='Production Server', + ip_address='192.168.1.10', + port=2375, + created_by=self.admin + ) + + # 3. Assign roles to Host + UserHostRole.objects.create( + user=self.host_owner, + host=self.host, + role='HOST_OWNER', + assigned_by=self.admin + ) + UserHostRole.objects.create( + user=self.viewer, + host=self.host, + role='VIEWER', + assigned_by=self.admin + ) + + # 4. Create an existing Network + self.network = Network.objects.create( + host=self.host, + created_by=self.admin, + docker_network_id='mocked_docker_net_id_123', + name='pre-existing-net', + driver='bridge', + subnet='172.20.0.0/16', + gateway='172.20.0.1', + internal=False, + attachable=True + ) + + self.list_url = reverse('network-list-create', kwargs={'host_id': self.host.id}) + self.detail_url = reverse('network-detail', kwargs={'host_id': self.host.id, 'id': self.network.id}) + + # --- LIST NETWORKS TESTS --- + + def test_authenticated_user_can_list_networks(self): + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], 'pre-existing-net') + + def test_unassigned_user_cannot_list_networks(self): + self.client.force_authenticate(user=self.other_user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_unauthenticated_cannot_list_networks(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # --- CREATE NETWORK TESTS --- + + @patch('networks.docker_service.get_client') + def test_admin_can_create_network(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_sdk_net.id = 'newly_created_net_id_xyz' + mock_client.networks.create.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.admin) + data = { + 'name': 'new-net', + 'driver': 'bridge', + 'subnet': '172.30.0.0/16', + 'gateway': '172.30.0.1', + 'internal': False, + 'attachable': True, + 'labels': {'env': 'prod'} + } + response = self.client.post(self.list_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['name'], 'new-net') + self.assertEqual(response.data['docker_network_id'], 'newly_created_net_id_xyz') + self.assertTrue(Network.objects.filter(name='new-net').exists()) + + @patch('networks.docker_service.get_client') + def test_host_owner_can_create_network(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_sdk_net.id = 'owner_created_net_id' + mock_client.networks.create.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.host_owner) + data = { + 'name': 'owner-net', + 'driver': 'overlay', + 'internal': True + } + response = self.client.post(self.list_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(Network.objects.filter(name='owner-net').exists()) + + def test_viewer_cannot_create_network(self): + self.client.force_authenticate(user=self.viewer) + data = { + 'name': 'forbidden-net', + 'driver': 'bridge' + } + response = self.client.post(self.list_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # --- DETAIL NETWORK TESTS --- + + def test_can_retrieve_network_detail(self): + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], 'pre-existing-net') + + # --- DELETE NETWORK TESTS --- + + @patch('networks.views.get_client') + def test_admin_can_delete_network(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_client.networks.get.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.admin) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Network.objects.filter(id=self.network.id).exists()) + mock_sdk_net.remove.assert_called_once() + + def test_viewer_cannot_delete_network(self): + self.client.force_authenticate(user=self.viewer) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Network.objects.filter(id=self.network.id).exists()) + + # --- CONNECT CONTAINER TESTS --- + + @patch('networks.views.get_client') + def test_admin_can_connect_container(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_client.networks.get.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.admin) + url = reverse('network-connect', kwargs={'host_id': self.host.id, 'id': self.network.id}) + data = { + 'container_id': 'some_container_id_123', + 'aliases': ['web', 'frontend'], + 'ipv4_address': '172.18.0.5' + } + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['message'], f"Container some_container_id_123 connected to network {self.network.name}.") + self.assertEqual(response.data['ipv4_address'], '172.18.0.5') + mock_sdk_net.connect.assert_called_once_with('some_container_id_123', aliases=['web', 'frontend'], ipv4_address='172.18.0.5') + + def test_viewer_cannot_connect_container(self): + self.client.force_authenticate(user=self.viewer) + url = reverse('network-connect', kwargs={'host_id': self.host.id, 'id': self.network.id}) + data = {'container_id': 'some_container_id_123'} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # --- DISCONNECT CONTAINER TESTS --- + + @patch('networks.views.get_client') + def test_admin_can_disconnect_container(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_client.networks.get.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.admin) + url = reverse('network-disconnect', kwargs={'host_id': self.host.id, 'id': self.network.id}) + data = {'container_id': 'some_container_id_123', 'force': True} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['message'], f"Container some_container_id_123 disconnected from network {self.network.name}.") + mock_sdk_net.disconnect.assert_called_once_with('some_container_id_123', force=True) + + def test_viewer_cannot_disconnect_container(self): + self.client.force_authenticate(user=self.viewer) + url = reverse('network-disconnect', kwargs={'host_id': self.host.id, 'id': self.network.id}) + data = {'container_id': 'some_container_id_123'} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # --- INSPECT CONTAINER DETAIL TESTS --- + + @patch('networks.views.get_client') + def test_can_retrieve_network_detail_with_options_and_containers(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_sdk_net = MagicMock() + mock_sdk_net.attrs = { + 'Options': {'com.docker.network.bridge.name': 'docker1'}, + 'Containers': { + 'container_id_abc': { + 'Name': 'my-nginx', + 'IPv4Address': '172.18.0.2/16', + 'MacAddress': '02:42:ac:12:00:02' + } + } + } + mock_client.networks.get.return_value = mock_sdk_net + + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['options'], {'com.docker.network.bridge.name': 'docker1'}) + self.assertIn('container_id_abc', response.data['containers']) + container_data = response.data['containers']['container_id_abc'] + self.assertEqual(container_data['name'], 'my-nginx') + self.assertEqual(container_data['ipv4_address'], '172.18.0.2') + self.assertEqual(container_data['mac_address'], '02:42:ac:12:00:02') + + # --- PRUNE NETWORKS TESTS --- + + @patch('networks.views.get_client') + def test_admin_can_prune_unused_networks(self, mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_client.networks.prune.return_value = { + 'NetworksDeleted': ['pre-existing-net', 'another-unused-net'] + } + + # Create another network to test syncing with db + Network.objects.create( + host=self.host, + created_by=self.admin, + docker_network_id='mocked_docker_net_id_456', + name='another-unused-net', + driver='bridge' + ) + + self.client.force_authenticate(user=self.admin) + url = reverse('network-prune', kwargs={'host_id': self.host.id}) + response = self.client.post(url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['networks_deleted'], ['pre-existing-net', 'another-unused-net']) + self.assertEqual(response.data['message'], "2 unused networks removed.") + + # Verify database is synced (both deleted networks should be removed) + self.assertFalse(Network.objects.filter(name='pre-existing-net').exists()) + self.assertFalse(Network.objects.filter(name='another-unused-net').exists()) + + def test_viewer_cannot_prune_unused_networks(self): + self.client.force_authenticate(user=self.viewer) + url = reverse('network-prune', kwargs={'host_id': self.host.id}) + response = self.client.post(url, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/networks/urls.py b/backend/networks/urls.py new file mode 100644 index 0000000..1fe2372 --- /dev/null +++ b/backend/networks/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from .views import ( + NetworkListCreateView, + NetworkDetailView, + NetworkConnectContainerView, + NetworkDisconnectContainerView, + NetworkPruneView +) + +urlpatterns = [ + path('hosts//networks/', NetworkListCreateView.as_view(), name='network-list-create'), + path('hosts//networks/prune/', NetworkPruneView.as_view(), name='network-prune'), + path('hosts//networks//', NetworkDetailView.as_view(), name='network-detail'), + path('hosts//networks//connect/', NetworkConnectContainerView.as_view(), name='network-connect'), + path('hosts//networks//disconnect/', NetworkDisconnectContainerView.as_view(), name='network-disconnect'), +] \ No newline at end of file diff --git a/backend/networks/views.py b/backend/networks/views.py new file mode 100644 index 0000000..70a87ba --- /dev/null +++ b/backend/networks/views.py @@ -0,0 +1,272 @@ +import logging +import docker +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import ValidationError, PermissionDenied, NotFound + +from .models import Network +from .serializers import NetworkSerializer, NetworkCreateSerializer +from hosts.models import Host +from .docker_service import create_network_in_platform, get_client +from hosts.permissions import CanAccessHost + +logger = logging.getLogger(__name__) + +# ========================================== +# 1. NETWORK LIST + CREATE VIEW +# ========================================== +class NetworkListCreateView(generics.ListCreateAPIView): + permission_classes = [IsAuthenticated, CanAccessHost] + + def get_serializer_class(self): + if self.request.method == 'POST': + return NetworkCreateSerializer + return NetworkSerializer + + def get_queryset(self): + return Network.objects.filter(host_id=self.kwargs['host_id']) + + def perform_create(self, serializer): + host_id = self.kwargs.get('host_id') + + try: + host = Host.objects.get(id=host_id) + except Host.DoesNotExist: + raise NotFound({"error": "Target infrastructure host daemon record not found."}) + + user_role_mapping = host.user_roles.filter(user=self.request.user).first() + is_admin = self.request.user.is_superuser or (user_role_mapping and user_role_mapping.role == 'ADMIN') + is_owner = user_role_mapping and user_role_mapping.role == 'HOST_OWNER' + + if not (is_admin or is_owner): + raise PermissionDenied({"error": "Viewer role cannot modify resources or create networks."}) + + v_data = serializer.validated_data + + try: + platform_network = create_network_in_platform( + host=host, + user=self.request.user, + name=v_data['name'], + driver=v_data.get('driver', 'bridge'), + subnet=v_data.get('subnet'), + gateway=v_data.get('gateway'), + internal=v_data.get('internal', False), + attachable=v_data.get('attachable', True), + labels=v_data.get('labels', {}) + ) + serializer.instance = platform_network + + except (RuntimeError, ValueError) as sdk_err: + raise ValidationError({"error": str(sdk_err)}) + + +# ========================================== +# 2. NETWORK DETAIL + RETRIEVE / DESTROY VIEW +# ========================================== +class NetworkDetailView(generics.RetrieveDestroyAPIView): + serializer_class = NetworkSerializer + permission_classes = [IsAuthenticated, CanAccessHost] + lookup_field = "id" + lookup_url_kwarg = "id" + + def get_queryset(self): + return Network.objects.filter(host_id=self.kwargs['host_id']) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + data = serializer.data + + try: + client = get_client(instance.host) + docker_net = client.networks.get(instance.docker_network_id) + + data['options'] = docker_net.attrs.get('Options', {}) + + containers = {} + for cid, cinfo in docker_net.attrs.get('Containers', {}).items(): + ipv4 = cinfo.get('IPv4Address', '') + if '/' in ipv4: + ipv4 = ipv4.split('/')[0] + containers[cid] = { + 'name': cinfo.get('Name', ''), + 'Name': cinfo.get('Name', ''), + 'ipv4_address': ipv4, + 'IPv4Address': ipv4, + 'mac_address': cinfo.get('MacAddress', ''), + 'MacAddress': cinfo.get('MacAddress', '') + } + data['containers'] = containers + + except Exception as e: + logger.warning(f"Failed to fetch live details for network {instance.name}: {str(e)}") + data['options'] = {} + data['containers'] = {} + + return Response(data) + + def perform_destroy(self, instance): + host = instance.host + + user_role_mapping = host.user_roles.filter(user=self.request.user).first() + is_admin = self.request.user.is_superuser or (user_role_mapping and user_role_mapping.role == 'ADMIN') + is_owner = user_role_mapping and user_role_mapping.role == 'HOST_OWNER' + + if not (is_admin or is_owner): + raise PermissionDenied({"error": "Viewer role cannot delete network resources."}) + + try: + client = get_client(host) + docker_net = client.networks.get(instance.docker_network_id) + docker_net.remove() + + instance.delete() + logger.info(f"Network '{instance.name}' removed from database and host '{host.alias}'.") + + except docker.errors.APIError as e: + if "has active endpoints" in str(e) or e.response.status_code == 409: + raise ValidationError({ + "error": "Network has active endpoints. Disconnect all containers before deleting." + }) + raise ValidationError({"error": f"Docker Daemon Exception: {e.explanation}"}) + + except Exception as conn_err: + raise ValidationError({"error": "Could not execute teardown sequence. Target daemon node is unreachable."}) + + +# ========================================== +# 3. NETWORK CONTAINER CONNECT / DISCONNECT +# ========================================== +class NetworkConnectContainerView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, CanAccessHost] + + def post(self, request, host_id, id): + try: + network = Network.objects.get(id=id, host_id=host_id) + except Network.DoesNotExist: + raise NotFound({"error": "Network not found."}) + + host = network.host + user_role_mapping = host.user_roles.filter(user=request.user).first() + is_admin = request.user.is_superuser or (user_role_mapping and user_role_mapping.role == 'ADMIN') + is_owner = user_role_mapping and user_role_mapping.role == 'HOST_OWNER' + + if not (is_admin or is_owner): + raise PermissionDenied({"error": "Viewer role cannot connect containers to networks."}) + + container_id = request.data.get('container_id') + if not container_id: + raise ValidationError({"error": "container_id parameter is required."}) + + aliases = request.data.get('aliases') + ipv4_address = request.data.get('ipv4_address') + + try: + client = get_client(host) + docker_net = client.networks.get(network.docker_network_id) + + connect_kwargs = {} + if aliases: + connect_kwargs['aliases'] = aliases + if ipv4_address: + connect_kwargs['ipv4_address'] = ipv4_address + + docker_net.connect(container_id, **connect_kwargs) + + # Fetch assigned IP if not passed explicitly + if not ipv4_address: + try: + container_obj = client.containers.get(container_id) + net_settings = container_obj.attrs.get('NetworkSettings', {}) + networks_settings = net_settings.get('Networks', {}) + net_config = networks_settings.get(docker_net.name, {}) or networks_settings.get(network.name, {}) + assigned_ip = net_config.get('IPAddress') + if assigned_ip: + ipv4_address = assigned_ip + except Exception: + pass + + res_data = { + "message": f"Container {container_id} connected to network {network.name}." + } + if ipv4_address: + res_data["ipv4_address"] = ipv4_address + + return Response(res_data, status=status.HTTP_200_OK) + except docker.errors.APIError as e: + raise ValidationError({"error": f"Docker Daemon Exception: {e.explanation}"}) + except Exception as conn_err: + raise ValidationError({"error": "Target daemon node is unreachable."}) + + +class NetworkDisconnectContainerView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, CanAccessHost] + + def post(self, request, host_id, id): + try: + network = Network.objects.get(id=id, host_id=host_id) + except Network.DoesNotExist: + raise NotFound({"error": "Network not found."}) + + host = network.host + user_role_mapping = host.user_roles.filter(user=request.user).first() + is_admin = request.user.is_superuser or (user_role_mapping and user_role_mapping.role == 'ADMIN') + is_owner = user_role_mapping and user_role_mapping.role == 'HOST_OWNER' + + if not (is_admin or is_owner): + raise PermissionDenied({"error": "Viewer role cannot disconnect containers from networks."}) + + container_id = request.data.get('container_id') + if not container_id: + raise ValidationError({"error": "container_id parameter is required."}) + + try: + client = get_client(host) + docker_net = client.networks.get(network.docker_network_id) + docker_net.disconnect(container_id, force=request.data.get('force', False)) + + return Response({ + "message": f"Container {container_id} disconnected from network {network.name}." + }, status=status.HTTP_200_OK) + except docker.errors.APIError as e: + raise ValidationError({"error": f"Docker Daemon Exception: {e.explanation}"}) + except Exception as conn_err: + raise ValidationError({"error": "Target daemon node is unreachable."}) + + +# ========================================== +# 4. NETWORK PRUNE VIEW +# ========================================== +class NetworkPruneView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, CanAccessHost] + + def post(self, request, host_id): + try: + host = Host.objects.get(id=host_id) + except Host.DoesNotExist: + raise NotFound({"error": "Host not found."}) + + user_role_mapping = host.user_roles.filter(user=request.user).first() + is_admin = request.user.is_superuser or (user_role_mapping and user_role_mapping.role == 'ADMIN') + + if not is_admin: + raise PermissionDenied({"error": "Only ADMIN role can prune unused networks."}) + + try: + client = get_client(host) + prune_result = client.networks.prune() + deleted_names = prune_result.get('NetworksDeleted') or [] + + if deleted_names: + Network.objects.filter(host=host, name__in=deleted_names).delete() + + return Response({ + "networks_deleted": deleted_names, + "message": f"{len(deleted_names)} unused networks removed." + }, status=status.HTTP_200_OK) + except docker.errors.APIError as e: + raise ValidationError({"error": f"Docker Daemon Exception: {e.explanation}"}) + except Exception as conn_err: + raise ValidationError({"error": "Target daemon node is unreachable."}) \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..5620584 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = core.settings +python_files = tests/test_*.py tests.py test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto \ No newline at end of file diff --git a/backend/registries/__init__.py b/backend/registries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/registries/admin.py b/backend/registries/admin.py new file mode 100644 index 0000000..102027b --- /dev/null +++ b/backend/registries/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import RegistryCredential + + +@admin.register(RegistryCredential) +class RegistryCredentialAdmin(admin.ModelAdmin): + list_display = ("id", "alias", "registry_url", "username", "owner", "is_default", "last_verified_at", "updated_at") + search_fields = ("alias", "registry_url", "username", "owner__username") + list_filter = ("is_default",) + readonly_fields = ("id", "created_at", "updated_at", "last_verified_at") diff --git a/backend/registries/apps.py b/backend/registries/apps.py new file mode 100644 index 0000000..2a274fb --- /dev/null +++ b/backend/registries/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RegistriesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'registries' diff --git a/backend/registries/encryption.py b/backend/registries/encryption.py new file mode 100644 index 0000000..7e61929 --- /dev/null +++ b/backend/registries/encryption.py @@ -0,0 +1,29 @@ +""" +Fernet-based symmetric encryption helpers for storing registry tokens. + +The key is read from ``settings.FIELD_ENCRYPTION_KEY`` and must be a +URL-safe base64-encoded 32-byte key (``cryptography.fernet.Fernet.generate_key()``). +""" + +from cryptography.fernet import Fernet +from django.conf import settings + + +def _get_fernet() -> Fernet: + key = getattr(settings, "FIELD_ENCRYPTION_KEY", None) + if not key: + raise RuntimeError( + "settings.FIELD_ENCRYPTION_KEY is not set. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt_token(plain_text: str) -> str: + """Encrypt *plain_text* and return the cipher-text as a UTF-8 string.""" + return _get_fernet().encrypt(plain_text.encode()).decode() + + +def decrypt_token(cipher_text: str) -> str: + """Decrypt *cipher_text* and return the original plain-text.""" + return _get_fernet().decrypt(cipher_text.encode()).decode() diff --git a/backend/registries/migrations/0001_initial.py b/backend/registries/migrations/0001_initial.py new file mode 100644 index 0000000..a6c6e67 --- /dev/null +++ b/backend/registries/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.3 on 2026-03-23 05:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RegistryCredential', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('alias', models.CharField(help_text="Human-readable label, e.g. 'My DockerHub'", max_length=100)), + ('registry_url', models.CharField(help_text='Registry endpoint, e.g. https://index.docker.io/v1/', max_length=255)), + ('username', models.CharField(help_text='Registry login username', max_length=150)), + ('_encrypted_token', models.TextField(db_column='encrypted_token', help_text='Fernet-encrypted password or access token')), + ('is_default', models.BooleanField(default=False)), + ('last_verified_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registry_credentials', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('owner', 'alias')}, + }, + ), + ] diff --git a/backend/registries/migrations/__init__.py b/backend/registries/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/registries/models.py b/backend/registries/models.py new file mode 100644 index 0000000..5d40fa3 --- /dev/null +++ b/backend/registries/models.py @@ -0,0 +1,50 @@ +import uuid +from django.conf import settings +from django.db import models + +from .encryption import decrypt_token, encrypt_token + + +class RegistryCredential(models.Model): + """Stores encrypted authentication credentials for a Docker registry.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="registry_credentials", + ) + alias = models.CharField(max_length=100, help_text="Human-readable label, e.g. 'My DockerHub'") + registry_url = models.CharField( + max_length=255, + help_text="Registry endpoint, e.g. https://index.docker.io/v1/", + ) + username = models.CharField(max_length=150, help_text="Registry login username") + _encrypted_token = models.TextField( + db_column="encrypted_token", + help_text="Fernet-encrypted password or access token", + ) + is_default = models.BooleanField(default=False) + last_verified_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + unique_together = ("owner", "alias") + + # ---- token property (encrypt on write, decrypt on read) ---- # + @property + def token(self) -> str: + """Return the decrypted token.""" + if not self._encrypted_token: + return "" + return decrypt_token(self._encrypted_token) + + @token.setter + def token(self, value: str) -> None: + """Encrypt and store *value*.""" + self._encrypted_token = encrypt_token(value) + + def __str__(self) -> str: + return f"{self.alias} ({self.registry_url})" diff --git a/backend/registries/permissions.py b/backend/registries/permissions.py new file mode 100644 index 0000000..dfbc795 --- /dev/null +++ b/backend/registries/permissions.py @@ -0,0 +1,14 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsCredentialOwner(BasePermission): + """ + Object-level permission: + - Any authenticated user may list / retrieve (safe methods). + - Only the credential *owner* may update or delete. + """ + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.owner == request.user diff --git a/backend/registries/serializers.py b/backend/registries/serializers.py new file mode 100644 index 0000000..a3bbe88 --- /dev/null +++ b/backend/registries/serializers.py @@ -0,0 +1,86 @@ +from rest_framework import serializers + +from .models import RegistryCredential + + +class RegistryCredentialSerializer(serializers.ModelSerializer): + """Read / list serializer — never exposes the raw token.""" + + owner = serializers.ReadOnlyField(source="owner.username") + + class Meta: + model = RegistryCredential + fields = [ + "id", + "owner", + "alias", + "registry_url", + "username", + "is_default", + "last_verified_at", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "owner", + "last_verified_at", + "created_at", + "updated_at", + ] + + +class RegistryCredentialCreateSerializer(serializers.ModelSerializer): + """Create serializer — accepts a plain-text ``token`` field.""" + + token = serializers.CharField(write_only=True, style={"input_type": "password"}) + + class Meta: + model = RegistryCredential + fields = [ + "id", + "alias", + "registry_url", + "username", + "token", + "is_default", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def create(self, validated_data: dict) -> RegistryCredential: + token = validated_data.pop("token") + credential = RegistryCredential(**validated_data) + credential.token = token # encrypts via the property setter + credential.save() + return credential + + +class RegistryCredentialUpdateSerializer(serializers.ModelSerializer): + """Update serializer — token is optional on update.""" + + token = serializers.CharField( + write_only=True, + required=False, + style={"input_type": "password"}, + ) + + class Meta: + model = RegistryCredential + fields = [ + "alias", + "registry_url", + "username", + "token", + "is_default", + ] + + def update(self, instance: RegistryCredential, validated_data: dict) -> RegistryCredential: + token = validated_data.pop("token", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if token is not None: + instance.token = token # re-encrypts + instance.save() + return instance diff --git a/backend/registries/tests.py b/backend/registries/tests.py new file mode 100644 index 0000000..8f9e291 --- /dev/null +++ b/backend/registries/tests.py @@ -0,0 +1,240 @@ +from unittest.mock import MagicMock, patch + +from cryptography.fernet import Fernet +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from .encryption import decrypt_token, encrypt_token +from .models import RegistryCredential +from .permissions import IsCredentialOwner + +User = get_user_model() + +# Generate an ephemeral Fernet key for tests to avoid committing static keys. +TEST_ENCRYPTION_KEY = Fernet.generate_key().decode() + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class EncryptionUnitTest(TestCase): + def test_encrypt_decrypt_roundtrip(self): + plain = "my-secret-token-123!" + encrypted = encrypt_token(plain) + self.assertNotEqual(encrypted, plain) + self.assertEqual(decrypt_token(encrypted), plain) + + def test_different_plaintexts_produce_different_ciphertexts(self): + a = encrypt_token("alpha") + b = encrypt_token("bravo") + self.assertNotEqual(a, b) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialModelUnitTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="regowner", password="password123") + + def test_string_representation(self): + cred = RegistryCredential( + owner=self.owner, + alias="My DockerHub", + registry_url="https://index.docker.io/v1/", + username="dockeruser", + ) + cred.token = "secret" + cred.save() + self.assertEqual(str(cred), "My DockerHub (https://index.docker.io/v1/)") + + def test_token_encryption_on_save(self): + cred = RegistryCredential( + owner=self.owner, + alias="GHCR", + registry_url="https://ghcr.io", + username="ghuser", + ) + cred.token = "registry_test_token" + cred.save() + + # Read from DB directly – internal field must be encrypted + from_db = RegistryCredential.objects.get(pk=cred.pk) + self.assertNotEqual(from_db._encrypted_token, "registry_test_token") + self.assertEqual(from_db.token, "registry_test_token") + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialPermissionUnitTest(TestCase): + def setUp(self): + self.permission = IsCredentialOwner() + self.owner = User.objects.create_user(username="powner", password="password123") + self.other = User.objects.create_user(username="other", password="password123") + self.cred = RegistryCredential( + owner=self.owner, + alias="test", + registry_url="https://index.docker.io/v1/", + username="u", + ) + self.cred.token = "t" + self.cred.save() + + def _request(self, method, user): + class DummyRequest: + pass + r = DummyRequest() + r.method = method + r.user = user + return r + + def test_read_allowed_for_non_owner(self): + req = self._request("GET", self.other) + self.assertTrue(self.permission.has_object_permission(req, None, self.cred)) + + def test_write_denied_for_non_owner(self): + req = self._request("PUT", self.other) + self.assertFalse(self.permission.has_object_permission(req, None, self.cred)) + + def test_delete_allowed_for_owner(self): + req = self._request("DELETE", self.owner) + self.assertTrue(self.permission.has_object_permission(req, None, self.cred)) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username="testuser", password="password123", role="admin") + self.other = User.objects.create_user(username="otheruser", password="password123", role="admin") + self.client.force_authenticate(user=self.user) + + # Pre-create a credential for detail / update / delete tests + self.cred = RegistryCredential( + owner=self.user, + alias="CI Registry", + registry_url="https://index.docker.io/v1/", + username="ciuser", + ) + self.cred.token = "citoken123" + self.cred.save() + self.base_url = "/api/registries/" + self.detail_url = f"{self.base_url}{self.cred.id}/" + + # ---- LIST ---- # + def test_list_returns_own_credentials(self): + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertNotIn("token", response.data[0]) + + def test_list_does_not_return_other_users_credentials(self): + other_cred = RegistryCredential( + owner=self.other, + alias="Other Reg", + registry_url="https://ghcr.io", + username="ou", + ) + other_cred.token = "x" + other_cred.save() + + response = self.client.get(self.base_url) + aliases = [c["alias"] for c in response.data] + self.assertNotIn("Other Reg", aliases) + + # ---- CREATE ---- # + def test_create_credential(self): + response = self.client.post( + self.base_url, + { + "alias": "New Reg", + "registry_url": "https://ghcr.io", + "username": "newuser", + "token": "newtoken", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(RegistryCredential.objects.filter(alias="New Reg").exists()) + # Token must NOT appear in response + self.assertNotIn("token", response.data) + + def test_create_unauthenticated(self): + self.client.force_authenticate(user=None) + response = self.client.post( + self.base_url, + { + "alias": "Anon", + "registry_url": "https://x.io", + "username": "u", + "token": "t", + }, + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ---- RETRIEVE ---- # + def test_retrieve_credential(self): + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["alias"], "CI Registry") + + # ---- UPDATE ---- # + def test_update_credential_by_owner(self): + response = self.client.put( + self.detail_url, + { + "alias": "Updated Alias", + "registry_url": "https://index.docker.io/v1/", + "username": "ciuser", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.cred.refresh_from_db() + self.assertEqual(self.cred.alias, "Updated Alias") + + def test_update_denied_for_non_owner(self): + self.client.force_authenticate(user=self.other) + response = self.client.put( + self.detail_url, + {"alias": "Hacked"}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- DELETE ---- # + def test_delete_credential_by_owner(self): + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(RegistryCredential.objects.filter(pk=self.cred.pk).exists()) + + def test_delete_denied_for_non_owner(self): + self.client.force_authenticate(user=self.other) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- VERIFY ---- # + @patch("registries.views.docker") + def test_verify_success(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_client.login.return_value = {"Status": "Login Succeeded"} + + verify_url = f"{self.detail_url}verify/" + response = self.client.post(verify_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("Login successful", response.data["detail"]) + + mock_client.login.assert_called_once_with( + username="ciuser", + password="citoken123", + registry="https://index.docker.io/v1/", + ) + + self.cred.refresh_from_db() + self.assertIsNotNone(self.cred.last_verified_at) + + @patch("registries.views.docker") + def test_verify_failure(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_docker.errors.APIError = Exception + mock_client.login.side_effect = Exception("unauthorized") + + verify_url = f"{self.detail_url}verify/" + response = self.client.post(verify_url) + self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_500_INTERNAL_SERVER_ERROR]) diff --git a/backend/registries/urls.py b/backend/registries/urls.py new file mode 100644 index 0000000..e87cf17 --- /dev/null +++ b/backend/registries/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .views import ( + RegistryCredentialDetailView, + RegistryCredentialListCreateView, + RegistryCredentialVerifyView, +) + +urlpatterns = [ + path("", RegistryCredentialListCreateView.as_view(), name="registry-list-create"), + path("/", RegistryCredentialDetailView.as_view(), name="registry-detail"), + path("/verify/", RegistryCredentialVerifyView.as_view(), name="registry-verify"), +] diff --git a/backend/registries/views.py b/backend/registries/views.py new file mode 100644 index 0000000..7f69e11 --- /dev/null +++ b/backend/registries/views.py @@ -0,0 +1,153 @@ +import logging + +import docker +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import RegistryCredential +from .permissions import IsCredentialOwner +from .serializers import ( + RegistryCredentialCreateSerializer, + RegistryCredentialSerializer, + RegistryCredentialUpdateSerializer, +) + +logger = logging.getLogger(__name__) + + +class RegistryCredentialListCreateView(generics.ListCreateAPIView): + """ + GET /api/registries/ → list credentials for the authenticated user + POST /api/registries/ → save a new credential (token stored encrypted) + """ + + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.request.method == "POST": + return RegistryCredentialCreateSerializer + return RegistryCredentialSerializer + + def get_queryset(self): + return RegistryCredential.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + credential = serializer.save(owner=self.request.user) + logger.info( + "Registry credential created id=%s alias=%s user_id=%s", + credential.id, + credential.alias, + self.request.user.id, + ) + + +class RegistryCredentialDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + GET /api/registries/{id}/ → retrieve a specific credential + PUT /api/registries/{id}/ → update (owner only) + DELETE /api/registries/{id}/ → delete (owner only) + """ + + permission_classes = [IsAuthenticated, IsCredentialOwner] + lookup_field = "pk" + + def get_serializer_class(self): + if self.request.method in ("PUT", "PATCH"): + return RegistryCredentialUpdateSerializer + return RegistryCredentialSerializer + + def get_queryset(self): + return RegistryCredential.objects.filter(owner=self.request.user) + + def perform_update(self, serializer): + credential = serializer.save() + logger.info( + "Registry credential updated id=%s updated_by=%s", + credential.id, + self.request.user.id, + ) + + def perform_destroy(self, instance): + credential_id = instance.id + instance.delete() + logger.info( + "Registry credential deleted id=%s deleted_by=%s", + credential_id, + self.request.user.id, + ) + + +class RegistryCredentialVerifyView(APIView): + """ + POST /api/registries/{id}/verify/ + Performs a live ``docker login`` against the registry to verify the credential. + Updates ``last_verified_at`` on success. + """ + + permission_classes = [IsAuthenticated, IsCredentialOwner] + + def get_object(self): + credential = generics.get_object_or_404( + RegistryCredential.objects.filter(owner=self.request.user), + pk=self.kwargs["pk"], + ) + # Check object-level permissions + for permission in self.get_permissions(): + if hasattr(permission, "has_object_permission"): + if not permission.has_object_permission(self.request, self, credential): + self.permission_denied(self.request) + return credential + + def post(self, request, pk=None): + credential = self.get_object() + + try: + client = docker.from_env() + login_result = client.login( + username=credential.username, + password=credential.token, + registry=credential.registry_url, + ) + logger.info( + "Registry verify success id=%s registry=%s user_id=%s", + credential.id, + credential.registry_url, + request.user.id, + ) + except docker.errors.APIError as exc: + logger.warning( + "Registry verify failed id=%s registry=%s error=%s", + credential.id, + credential.registry_url, + exc, + ) + explanation = getattr(exc, "explanation", None) or str(exc) + return Response( + {"detail": f"Docker login failed: {explanation}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as exc: + logger.exception( + "Registry verify error id=%s registry=%s", + credential.id, + credential.registry_url, + ) + return Response( + {"detail": f"Verification error: {str(exc)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + credential.last_verified_at = timezone.now() + credential.save(update_fields=["last_verified_at"]) + + return Response( + { + "detail": "Login successful", + "login_result": login_result, + "last_verified_at": credential.last_verified_at.isoformat(), + }, + status=status.HTTP_200_OK, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 4958795..08ae8f5 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/backend/stacks/__init__.py b/backend/stacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/stacks/admin.py b/backend/stacks/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/stacks/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/stacks/apps.py b/backend/stacks/apps.py new file mode 100644 index 0000000..a216f9a --- /dev/null +++ b/backend/stacks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StacksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'stacks' diff --git a/backend/stacks/migrations/0001_initial.py b/backend/stacks/migrations/0001_initial.py new file mode 100644 index 0000000..951491e --- /dev/null +++ b/backend/stacks/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.12 on 2026-06-11 15:42 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('hosts', '0004_remove_host_uniq_host_ip_port'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Stack', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('compose_file', models.TextField(blank=True)), + ('status', models.CharField(choices=[('CREATED', 'Created'), ('STARTING', 'Starting'), ('RUNNING', 'Running'), ('STOPPED', 'Stopped'), ('FAILED', 'Failed'), ('REMOVING', 'Removing')], default='CREATED', max_length=20)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_stacks', to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stacks', to='hosts.host')), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('name', 'host')}, + }, + ), + ] diff --git a/backend/stacks/migrations/__init__.py b/backend/stacks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/stacks/models.py b/backend/stacks/models.py new file mode 100644 index 0000000..0e791f1 --- /dev/null +++ b/backend/stacks/models.py @@ -0,0 +1,42 @@ +import uuid +from django.db import models +from django.conf import settings + +class Stack(models.Model): + class Status(models.TextChoices): + CREATED = 'CREATED', 'Created' + STARTING = 'STARTING', 'Starting' + RUNNING = 'RUNNING', 'Running' + STOPPED = 'STOPPED', 'Stopped' + FAILED = 'FAILED', 'Failed' + REMOVING = 'REMOVING', 'Removing' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + host = models.ForeignKey( + 'hosts.Host', + on_delete=models.CASCADE, + related_name='stacks' + ) + compose_file = models.TextField(blank=True) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.CREATED + ) + error_message = models.TextField(blank=True, null=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='created_stacks' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + unique_together = ('name', 'host') + + def __str__(self): + return f"{self.name} [{self.status}] on {self.host.alias}" diff --git a/backend/stacks/serializers.py b/backend/stacks/serializers.py new file mode 100644 index 0000000..7b43e2a --- /dev/null +++ b/backend/stacks/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Stack + +class StackSerializer(serializers.ModelSerializer): + class Meta: + model = Stack + fields = ['id', 'name', 'host', 'compose_file', 'status', 'error_message', 'created_by', 'created_at', 'updated_at'] + read_only_fields = ['id', 'status', 'error_message', 'created_by', 'created_at', 'updated_at', 'host'] + +class StackCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255) + compose_file = serializers.CharField() diff --git a/backend/stacks/services.py b/backend/stacks/services.py new file mode 100644 index 0000000..7dc15d1 --- /dev/null +++ b/backend/stacks/services.py @@ -0,0 +1,144 @@ +import os +import tempfile +import subprocess +import yaml +import logging +from django.core.exceptions import ValidationError + +logger = logging.getLogger(__name__) + +class StackService: + @staticmethod + def _get_docker_host_arg(host): + if host.ip_address in ('127.0.0.1', 'localhost', '::1'): + return [] # use default local socket + return ["-H", f"tcp://{host.ip_address}:{host.port}"] + + @staticmethod + def deploy_stack(stack): + stack.status = stack.Status.STARTING + stack.error_message = "" + stack.save(update_fields=['status', 'error_message']) + + try: + with tempfile.TemporaryDirectory() as tmpdir: + compose_path = os.path.join(tmpdir, "docker-compose.yml") + with open(compose_path, "w") as f: + f.write(stack.compose_file) + + cmd = ["docker"] + StackService._get_docker_host_arg(stack.host) + ["compose", "-p", stack.name, "-f", compose_path, "up", "-d"] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + stack.status = stack.Status.FAILED + stack.error_message = result.stderr or result.stdout + else: + stack.status = stack.Status.RUNNING + + except Exception as e: + logger.error(f"Failed to deploy stack {stack.name}: {e}") + stack.status = stack.Status.FAILED + stack.error_message = str(e) + + stack.save(update_fields=['status', 'error_message']) + + @staticmethod + def teardown_stack(stack): + stack.status = stack.Status.REMOVING + stack.save(update_fields=['status']) + + try: + with tempfile.TemporaryDirectory() as tmpdir: + compose_path = os.path.join(tmpdir, "docker-compose.yml") + with open(compose_path, "w") as f: + f.write(stack.compose_file) + + cmd = ["docker"] + StackService._get_docker_host_arg(stack.host) + ["compose", "-p", stack.name, "-f", compose_path, "down"] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + stack.status = stack.Status.FAILED + stack.error_message = result.stderr or result.stdout + else: + stack.status = stack.Status.STOPPED + + except Exception as e: + logger.error(f"Failed to teardown stack {stack.name}: {e}") + stack.status = stack.Status.FAILED + stack.error_message = str(e) + + stack.save(update_fields=['status', 'error_message']) + + @staticmethod + def parse_compose(compose_file): + try: + data = yaml.safe_load(compose_file) + services = data.get('services', {}) + nodes = [] + edges = [] + + for s_name, s_config in services.items(): + nodes.append({ + "id": s_name, + "type": "service", + "data": { + "label": s_name, + "image": s_config.get('image', 'build'), + "ports": s_config.get('ports', []), + } + }) + + depends_on = s_config.get('depends_on', []) + if isinstance(depends_on, dict): + depends_on = list(depends_on.keys()) + + for dep in depends_on: + edges.append({ + "id": f"e-{s_name}-{dep}", + "source": s_name, + "target": dep, + "label": "depends on" + }) + + networks = s_config.get('networks', []) + if isinstance(networks, dict): + networks = list(networks.keys()) + for net in networks: + net_id = f"net-{net}" + if not any(n['id'] == net_id for n in nodes): + nodes.append({ + "id": net_id, + "type": "network", + "data": {"label": net} + }) + edges.append({ + "id": f"e-{s_name}-{net_id}", + "source": s_name, + "target": net_id, + "label": "connects" + }) + + volumes = s_config.get('volumes', []) + for vol in volumes: + vol_name = vol.split(':')[0] if isinstance(vol, str) else vol.get('source') + if vol_name and not vol_name.startswith('.') and not vol_name.startswith('/'): + vol_id = f"vol-{vol_name}" + if not any(n['id'] == vol_id for n in nodes): + nodes.append({ + "id": vol_id, + "type": "volume", + "data": {"label": vol_name} + }) + edges.append({ + "id": f"e-{s_name}-{vol_id}", + "source": s_name, + "target": vol_id, + "label": "mounts" + }) + + return {"nodes": nodes, "edges": edges} + except Exception as e: + logger.error(f"Failed to parse compose file: {e}") + raise ValidationError(f"Invalid Compose file: {str(e)}") diff --git a/backend/stacks/tests.py b/backend/stacks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/stacks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/stacks/urls.py b/backend/stacks/urls.py new file mode 100644 index 0000000..449f878 --- /dev/null +++ b/backend/stacks/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import StackListCreateView, StackDetailView, StackGraphView + +urlpatterns = [ + path('', StackListCreateView.as_view(), name='stack-list-create'), + path('/', StackDetailView.as_view(), name='stack-detail'), + path('/graph/', StackGraphView.as_view(), name='stack-graph'), +] diff --git a/backend/stacks/views.py b/backend/stacks/views.py new file mode 100644 index 0000000..518653d --- /dev/null +++ b/backend/stacks/views.py @@ -0,0 +1,90 @@ +import logging +import threading +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from hosts.models import Host +from images.permissions import IsAdminOrHostOwner +from .models import Stack +from .serializers import StackSerializer, StackCreateSerializer +from .services import StackService + +logger = logging.getLogger(__name__) + +class StackListCreateView(APIView): + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get(self, request, host_id): + host = self.get_host() + stacks = Stack.objects.filter(host=host) + serializer = StackSerializer(stacks, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def post(self, request, host_id): + host = self.get_host() + serializer = StackCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + stack = Stack.objects.create( + name=data['name'], + host=host, + compose_file=data['compose_file'], + created_by=request.user, + status=Stack.Status.CREATED + ) + + # Fire background worker to deploy + def deploy(): + StackService.deploy_stack(stack) + + threading.Thread(target=deploy, daemon=True).start() + + resp_serializer = StackSerializer(stack) + return Response(resp_serializer.data, status=status.HTTP_201_CREATED) + +class StackDetailView(APIView): + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get(self, request, host_id, stack_id): + host = self.get_host() + stack = get_object_or_404(Stack, pk=stack_id, host=host) + serializer = StackSerializer(stack) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, host_id, stack_id): + host = self.get_host() + stack = get_object_or_404(Stack, pk=stack_id, host=host) + + def teardown(): + StackService.teardown_stack(stack) + if stack.status == Stack.Status.STOPPED: + stack.delete() + + threading.Thread(target=teardown, daemon=True).start() + + return Response({"detail": "Teardown initiated"}, status=status.HTTP_202_ACCEPTED) + +class StackGraphView(APIView): + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get(self, request, host_id, stack_id): + host = self.get_host() + stack = get_object_or_404(Stack, pk=stack_id, host=host) + try: + graph = StackService.parse_compose(stack.compose_file) + return Response(graph, status=status.HTTP_200_OK) + except Exception as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/urls.py b/backend/users/urls.py new file mode 100644 index 0000000..690b131 --- /dev/null +++ b/backend/users/urls.py @@ -0,0 +1,4 @@ +from django.urls import path + +urlpatterns = [ +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..88cfd40 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16 + container_name: di-postgres + restart: unless-stopped + ports: + - "${POSTGRES_PORT}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + + pgadmin: + image: dpage/pgadmin4:8 + container_name: di-pgadmin + restart: unless-stopped + ports: + - "${PGADMIN_PORT}:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + depends_on: + - postgres + +volumes: + postgres_data: diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..9c80a40 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..5ccaedf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,14 @@ - - frontend + Container Dashboard + + +
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..abe04df --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae353a8..a9f6a94 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,16 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@base-ui/react": "^1.3.0", "@tailwindcss/vite": "^4.2.1", + "axios": "^1.14.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1" }, "devDependencies": { @@ -259,6 +266,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -307,6 +323,59 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.3.0.tgz", + "integrity": "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.6", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -880,6 +949,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1628,7 +1735,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1728,6 +1835,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1793,6 +1917,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1841,6 +1978,27 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1861,6 +2019,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1875,6 +2045,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1894,7 +2077,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1922,6 +2105,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1931,6 +2123,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -1951,6 +2157,51 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2288,6 +2539,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2302,6 +2589,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2312,6 +2608,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2338,6 +2671,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2354,6 +2699,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2820,6 +3204,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2829,6 +3222,36 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3020,6 +3443,15 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3061,6 +3493,50 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3131,6 +3607,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3189,6 +3671,22 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -3278,6 +3776,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9f93bd3..46d17e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,16 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.3.0", "@tailwindcss/vite": "^4.2.1", + "axios": "^1.14.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.2", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1" }, "devDependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..faac3c5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,35 +1,24 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' - -function App() { - const [count, setCount] = useState(0) +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import LoginPage from './pages/LoginPage'; +import RegisterPage from './pages/RegisterPage'; +import DashboardPage from './pages/DashboardPage'; +import NetworkDashboard from './pages/networks/NetworkDashboard'; +import NetworkDetail from './pages/networks/NetworkDetail'; +import PrivateRoute from './components/PrivateRoute'; +export default function App() { return ( - <> - -

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) -} - -export default App + + + } /> + } /> + + } /> + } /> + } /> + } /> + + + ); +} \ No newline at end of file diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..8ed70ed --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,8 @@ +import api from "./axios"; + +export const login = (data) => api.post("/auth/login/", data); +export const register = (data) => api.post("/auth/register/", data); +export const getMe = () => api.get("/auth/me/"); +export const getUsers = () => api.get("/auth/users/"); +export const refreshToken = (refresh) => + api.post("/auth/token/refresh/", { refresh }); \ No newline at end of file diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js new file mode 100644 index 0000000..44f9d3c --- /dev/null +++ b/frontend/src/api/axios.js @@ -0,0 +1,28 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: 'http://localhost:8000/api', +}); + +// Attach token to every request automatically +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// If token expired (401), clear storage and redirect to login +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.clear(); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default api; \ No newline at end of file diff --git a/frontend/src/api/hosts.js b/frontend/src/api/hosts.js new file mode 100644 index 0000000..f614044 --- /dev/null +++ b/frontend/src/api/hosts.js @@ -0,0 +1,7 @@ +import api from './axios'; + +export const getHosts = () => api.get('/hosts/'); +export const getHost = (id) => api.get(`/hosts/${id}/`); +export const createHost = (data) => api.post('/hosts/', data); +export const deleteHost = (id) => api.delete(`/hosts/${id}/`); +export const assignUser = (hostId, data) => api.post(`/hosts/${hostId}/assign/`, data); \ No newline at end of file diff --git a/frontend/src/api/networks.js b/frontend/src/api/networks.js new file mode 100644 index 0000000..99eb058 --- /dev/null +++ b/frontend/src/api/networks.js @@ -0,0 +1,41 @@ +import axios from 'axios'; + +const API = axios.create({ + baseURL: 'http://localhost:8000/api/', +}); + +API.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +export const getNetworks = (hostId) => + API.get(`hosts/${hostId}/networks/`); + +export const createNetwork = (hostId, data) => + API.post(`hosts/${hostId}/networks/`, data); + +export const inspectNetwork = (hostId, id) => + API.get(`hosts/${hostId}/networks/${id}/`); + +export const deleteNetwork = (hostId, id) => + API.delete(`hosts/${hostId}/networks/${id}/`); + + +// ========================================== +// CONTAINER ATTACHMENT ENDPOINTS +// ========================================== + +export const connectContainer = (hostId, id, data) => + API.post(`hosts/${hostId}/networks/${id}/connect/`, data); + +export const disconnectContainer = (hostId, id, data) => + API.post(`hosts/${hostId}/networks/${id}/disconnect/`, data); \ No newline at end of file diff --git a/frontend/src/components/AddHostModal.jsx b/frontend/src/components/AddHostModal.jsx new file mode 100644 index 0000000..6aeca0f --- /dev/null +++ b/frontend/src/components/AddHostModal.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { createHost } from '../api/hosts'; +import { formatApiError } from '../utils/formatApiError'; +import '../index.css'; + +export default function AddHostModal({ onClose, onCreated }) { + const [form, setForm] = useState({ + alias: '', + ip_address: '', + port: 2375, + ssh_credentials: '' + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setForm(prev => ({ + ...prev, + [name]: name === 'port' ? Number(value) : value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await createHost(form); + onCreated(res.data); + onClose(); + } catch (err) { + setError(formatApiError(err, 'Failed to register host. Please check details.')); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Register New Host

+

Add a new Docker engine instance to the platform.

+ + {error &&

{error}

} + +
+
+ + +
+
+ + +
+
+ + +
+
+ +