diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..c9da01e --- /dev/null +++ b/.env.dev @@ -0,0 +1,9 @@ +DEBUG='1' +SQL_ENGINE='django.db.backends.mysql' +SQL_HOST='192.168.0.211' +SQL_USER='demo' +SQL_PASSWORD='ddochi90!@' +SQL_DATABASE='msa-demo' +SQL_PORT='3306' +SECRET_KEY='django-insecure-*kh6e0376o-0m5n*xz^2a2t^fa^77c1=))f$3egn7!w7axaj-l' +AUTH_VERIFY_URL=http://192.168.0.202:8000 \ No newline at end of file diff --git a/ansible/migrations/0001_initial.py b/ansible/migrations/0001_initial.py new file mode 100644 index 0000000..0cf1e80 --- /dev/null +++ b/ansible/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.14 on 2025-05-20 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AnsibleTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('playbook_content', models.TextField()), + ('inventory_content', models.TextField()), + ('status', models.CharField(default='pending', max_length=50)), + ('output', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/ansible/models.py b/ansible/models.py index 71a8362..34c8ca0 100644 --- a/ansible/models.py +++ b/ansible/models.py @@ -1,3 +1,12 @@ from django.db import models -# Create your models here. +class AnsibleTask(models.Model): + name = models.CharField(max_length=200) + playbook_content = models.TextField() # ✅ YAML 문자열 + inventory_content = models.TextField() # ✅ 인벤토리 형식 문자열 + status = models.CharField(max_length=50, default='pending') # 'pending', 'running', 'success', 'failed', 'error' + output = models.TextField(blank=True) # ✅ 실행 결과 로그 + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} ({self.status})" diff --git a/ansible/serializers.py b/ansible/serializers.py new file mode 100644 index 0000000..534f59b --- /dev/null +++ b/ansible/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from .models import AnsibleTask + + +# ✅ 기본 Serializer: 목록 / 생성용 +class AnsibleTaskSerializer(serializers.ModelSerializer): + class Meta: + model = AnsibleTask + fields = [ + "id", + "name", + "playbook_content", + "inventory_content", + "status", + "output", + "created_at", + ] + read_only_fields = ("id", "status", "output", "created_at") + + +# ✅ 상세용 Serializer: 실행 결과만 확인 +class AnsibleTaskDetailSerializer(serializers.ModelSerializer): + class Meta: + model = AnsibleTask + fields = [ + "id", + "name", + "status", + "output", + "created_at", + ] + read_only_fields = fields diff --git a/ansible/services.py b/ansible/services.py new file mode 100644 index 0000000..20d2e0e --- /dev/null +++ b/ansible/services.py @@ -0,0 +1,49 @@ +# services.py +import os +import tempfile +import subprocess +from .models import AnsibleTask +from django.conf import settings + + +def run_ansible_job(task: AnsibleTask, ssh_key: str): + task.status = "running" + task.save() + + try: + with tempfile.NamedTemporaryFile(delete=False, mode="w") as playbook_file, \ + tempfile.NamedTemporaryFile(delete=False, mode="w") as inventory_file, \ + tempfile.NamedTemporaryFile(delete=False, mode="w") as private_key_file: + + playbook_file.write(task.playbook_content.strip()) + playbook_file.close() + + inventory_file.write(task.inventory_content.strip()) + inventory_file.close() + + private_key_file.write(ssh_key.strip() + "\n") + private_key_file.close() + os.chmod(private_key_file.name, 0o600) + + command = [ + "ansible-playbook", + playbook_file.name, + "-i", inventory_file.name, + "--private-key", private_key_file.name, + "-u", "ubuntu", + ] + + result = subprocess.run(command, capture_output=True, text=True) + task.status = "success" if result.returncode == 0 else "failed" + task.output = result.stdout + "\n" + result.stderr + + except Exception as e: + task.status = "error" + task.output = f"\u274c 실행 중 예외 발생: {str(e)}" + + finally: + for f in [playbook_file.name, inventory_file.name, private_key_file.name]: + if os.path.exists(f): + os.remove(f) + + task.save() \ No newline at end of file diff --git a/ansible/urls.py b/ansible/urls.py new file mode 100644 index 0000000..a2dab7a --- /dev/null +++ b/ansible/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import AnsibleTaskViewSet + +router = DefaultRouter() +router.register(r'tasks', AnsibleTaskViewSet, basename='ansibletask') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/ansible/views.py b/ansible/views.py index 91ea44a..5168574 100644 --- a/ansible/views.py +++ b/ansible/views.py @@ -1,3 +1,101 @@ -from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.views import TokenObtainPairView -# Create your views here. +from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer +from cryptography.fernet import Fernet +from django.conf import settings +import base64 +import hashlib + +# Fernet 키를 settings.SECRET_KEY에서 파생 +hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest() +fernet_key = base64.urlsafe_b64encode(hashed[:32]) +fernet = Fernet(fernet_key) + + +class RegisterView(APIView): + def post(self, request): + serializer = RegisterSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + return Response({"message": "User registered successfully."}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class MeView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + serializer = RegisterSerializer(user) + return Response(serializer.data) + + def put(self, request): + user = request.user + serializer = RegisterSerializer(user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class CustomTokenObtainPairView(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer + + +class SSHKeyUploadView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + private_key = request.data.get("private_key") + key_name = request.data.get("key_name") + + if not private_key or not key_name: + return Response( + {"error": "private_key와 key_name 모두 필요합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = request.user + try: + encrypted_key = fernet.encrypt(private_key.encode()).decode() # ✅ decode 추가 + user.encrypted_private_key = encrypted_key + user.encrypted_private_key_name = key_name + user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name"]) + return Response({"message": "SSH key 저장 완료."}) + except Exception as e: + return Response({"error": str(e)}, status=500) + + def delete(self, request): + user = request.user + user.encrypted_private_key = None + user.encrypted_private_key_name = None + user.last_used_at = None + user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name", "last_used_at"]) + return Response({"message": "SSH key deleted."}, status=200) + + +class SSHKeyInfoView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + return Response({ + "has_key": bool(user.encrypted_private_key), + "encrypted_private_key_name": user.encrypted_private_key_name, + "last_used_at": user.last_used_at + }) + + +# ✅ 실제 암호화된 키를 반환하는 API +class SSHKeyRetrieveView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + if not user.encrypted_private_key: + return Response({"error": "SSH 키가 등록되어 있지 않습니다."}, status=404) + return Response({"ssh_key": user.encrypted_private_key}) diff --git a/ansible_prj/settings.py b/ansible_prj/settings.py index f8f7960..0d69a0a 100644 --- a/ansible_prj/settings.py +++ b/ansible_prj/settings.py @@ -14,6 +14,9 @@ import os from dotenv import load_dotenv from pathlib import Path import sys +from cryptography.fernet import Fernet +import hashlib +import base64 LOGGING = { 'version': 1, @@ -49,6 +52,11 @@ else: # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-ec9me^z%x7-2vwee5#qq(kvn@^cs!!22_*f-im(320_k5-=0j5') +# Fernet은 32바이트 base64 인코딩된 키를 요구하므로, Django SECRET_KEY를 기반으로 키 생성 +hashed = hashlib.sha256(SECRET_KEY.encode()).digest() +FERNET_KEY = base64.urlsafe_b64encode(hashed[:32]) # 32 bytes → base64로 인코딩 +FERNET = Fernet(FERNET_KEY) + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get('DEBUG', 1)) diff --git a/ansible_prj/urls.py b/ansible_prj/urls.py index 65340e8..3a6d446 100644 --- a/ansible_prj/urls.py +++ b/ansible_prj/urls.py @@ -15,8 +15,24 @@ Including another URLconf 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 drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework import permissions + +schema_view = get_schema_view( + openapi.Info( + title="ToDo API", + default_version='v1', + description="MSA Django Todo API", + ), + public=True, + permission_classes=[permissions.AllowAny], +) urlpatterns = [ path('admin/', admin.site.urls), -] + path('api/ansible/', include('ansible.urls')), # ✅ 추가됨 + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..626854c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +ansible==10.7.0 +ansible-core==2.17.7 +asgiref==3.8.1 +cffi==1.17.1 +cryptography==45.0.2 +Django==4.2.14 +django-cors-headers==4.7.0 +djangorestframework==3.15.2 +djangorestframework_simplejwt==5.5.0 +drf-yasg==1.21.10 +inflection==0.5.1 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==25.0 +pycparser==2.22 +PyJWT==2.9.0 +python-dotenv==1.0.1 +pytz==2025.2 +PyYAML==6.0.2 +resolvelib==1.0.1 +sqlparse==0.5.3 +typing_extensions==4.13.2 +uritemplate==4.1.1