diff --git a/DomCon/domainList_status/cpu_status.go b/DomCon/domainList_status/cpu_status.go index a9627b9..2bb14a2 100644 --- a/DomCon/domainList_status/cpu_status.go +++ b/DomCon/domainList_status/cpu_status.go @@ -8,14 +8,11 @@ import ( // 인터페이스 구현체 func (vs *VCPUStatus) EmitStatus(dls *DomainListStatus) { - vs.Total = int(dls.VCPUTotal) - vs.Allocated = int(dls.VcpuAllocated) - vs.Sleeping = int(dls.VcpuSleeping) - - vs.Idle = vs.Total - vs.Allocated - if vs.Idle < 0 { - vs.Idle = 0 - } + vs.Total = int(atomic.LoadInt64(&dls.VCPUTotal)) + vs.Allocated = int(atomic.LoadInt64(&dls.VcpuAllocated)) + vs.Sleeping = int(atomic.LoadInt64(&dls.VcpuSleeping)) + + vs.Idle = max(vs.Total-vs.Allocated, 0) } func (dls *DomainListStatus) Update() { @@ -23,8 +20,7 @@ func (dls *DomainListStatus) Update() { } func (dls *DomainListStatus) UpdateCPUTotal() { - totalCPU := runtime.NumCPU() - dls.VCPUTotal = int64(totalCPU) + atomic.StoreInt64(&dls.VCPUTotal, int64(runtime.NumCPU())) } func (dls *DomainListStatus) AddAllocatedCPU(vcpu int) { diff --git a/api/Create/model.go b/api/Create/model.go index c35619c..8632722 100644 --- a/api/Create/model.go +++ b/api/Create/model.go @@ -6,26 +6,28 @@ import ( ) type CreateVMRequest struct { - DomName string `json:"domName"` - UUID string `json:"uuid"` - OS string `json:"os"` - HardwareInfo vmtypes.HardwareInfo `json:"HWInfo"` - NetConf network.NetDefine `json:"network"` - Users []vmtypes.User_info_VM `json:"users"` - SDNUUID string `json:"sdnUUID"` - MacAddr string `json:"macAddr"` + DomName string `json:"domName"` + UUID string `json:"uuid"` + OS string `json:"os"` + PresignedImageUrl string `json:"presignedImageUrl,omitempty"` + HardwareInfo vmtypes.HardwareInfo `json:"HWInfo"` + NetConf network.NetDefine `json:"network"` + Users []vmtypes.User_info_VM `json:"users"` + SDNUUID string `json:"sdnUUID"` + MacAddr string `json:"macAddr"` } func (r *CreateVMRequest) toVMInitInfo() *vmtypes.VM_Init_Info { return &vmtypes.VM_Init_Info{ - DomName: r.DomName, - UUID: r.UUID, - OS: r.OS, - HardwareInfo: r.HardwareInfo, - NetConf: r.NetConf, - Users: r.Users, - SDNUUID: r.SDNUUID, - MacAddr: r.MacAddr, + DomName: r.DomName, + UUID: r.UUID, + OS: r.OS, + PresignedImageUrl: r.PresignedImageUrl, + HardwareInfo: r.HardwareInfo, + NetConf: r.NetConf, + Users: r.Users, + SDNUUID: r.SDNUUID, + MacAddr: r.MacAddr, } } diff --git a/api/Snapshot/Snapshot.go b/api/Snapshot/Snapshot.go index 4f26ad0..ed63da3 100644 --- a/api/Snapshot/Snapshot.go +++ b/api/Snapshot/Snapshot.go @@ -1,9 +1,12 @@ package snapshot import ( + "context" "fmt" "net/http" + "time" + "github.com/easy-cloud-Knet/KWS_Core/internal/config" virerr "github.com/easy-cloud-Knet/KWS_Core/internal/error" httputil "github.com/easy-cloud-Knet/KWS_Core/pkg/httputil" externalsnapshot "github.com/easy-cloud-Knet/KWS_Core/services/snapshot/external_snap" @@ -292,3 +295,49 @@ func (h *Handler) DeleteSnapshot(w http.ResponseWriter, r *http.Request) { h.Logger.Info("snapshot delete success", zap.String("uuid", param.UUID), zap.String("snapshot_name", param.Name)) resp.ResponseWriteOK(w, nil) } + +func (h *Handler) TakeExternalSnapshot(w http.ResponseWriter, r *http.Request) { + param := &TakeExternalSnapshotRequest{} + resp := httputil.ResponseGen[TakeExternalSnapshotResponse]("Take External Snapshot") + + if err := httputil.HttpDecoder(r, param); err != nil { + resp.ResponseWriteErr(w, err, http.StatusBadRequest) + h.Logger.Error("take external snapshot decode failed", zap.Error(err)) + return + } + + if param.UUID == "" || param.SnapKey == "" || param.PresignedURL == "" { + resp.ResponseWriteErr(w, virerr.ErrorGen(virerr.InvalidParameter, fmt.Errorf("uuid, snapKey and presignedUrl are required")), http.StatusBadRequest) + return + } + + h.Logger.Info("take external snapshot start", zap.String("uuid", param.UUID), zap.String("snap_key", param.SnapKey)) + + dom, err := h.DomainControl.GetDomain(param.UUID) + if err != nil { + resp.ResponseWriteErr(w, err, http.StatusInternalServerError) + h.Logger.Error("take external snapshot failed - domain not found", zap.String("uuid", param.UUID), zap.Error(err)) + return + } + + snapName, err := externalsnapshot.CreateExternalSnapshot(dom, param.SnapKey, &externalsnapshot.ExternalSnapshotOptions{}) + if err != nil { + resp.ResponseWriteErr(w, err, http.StatusInternalServerError) + h.Logger.Error("take external snapshot failed - create snapshot", zap.String("uuid", param.UUID), zap.Error(err)) + return + } + + filePath := externalsnapshot.SnapshotFilePath(config.StorageBase, param.UUID, snapName, "vda") + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + + if err := externalsnapshot.UploadToPresignedURL(ctx, filePath, param.PresignedURL); err != nil { + resp.ResponseWriteErr(w, virerr.ErrorGen(virerr.SnapshotError, err), http.StatusInternalServerError) + h.Logger.Error("take external snapshot failed - upload", zap.String("uuid", param.UUID), zap.String("file", filePath), zap.Error(err)) + return + } + + h.Logger.Info("take external snapshot success", zap.String("uuid", param.UUID), zap.String("snap_key", snapName)) + resp.ResponseWriteOK(w, &TakeExternalSnapshotResponse{UUID: param.UUID, SnapKey: snapName}) +} diff --git a/api/Snapshot/model.go b/api/Snapshot/model.go index 38a8756..82f62be 100644 --- a/api/Snapshot/model.go +++ b/api/Snapshot/model.go @@ -35,3 +35,14 @@ type ExternalSnapshotMergeResponse struct { UUID string `json:"UUID"` MergedDisks []string `json:"MergedDisks"` } + +type TakeExternalSnapshotRequest struct { + UUID string `json:"uuid"` + SnapKey string `json:"snapKey"` + PresignedURL string `json:"presignedUrl"` +} + +type TakeExternalSnapshotResponse struct { + UUID string `json:"uuid"` + SnapKey string `json:"snapKey"` +} diff --git a/api/Snapshot/snapshot_test.go b/api/Snapshot/snapshot_test.go index d0a4622..b130e6a 100644 --- a/api/Snapshot/snapshot_test.go +++ b/api/Snapshot/snapshot_test.go @@ -266,6 +266,55 @@ func TestMergeExternalSnapshot_GetDomainError(t *testing.T) { } } +// TakeExternalSnapshot + +func TestTakeExternalSnapshot_BadRequest(t *testing.T) { + h := newTestHandler(&mockDomainController{}) + r := httptest.NewRequest(http.MethodPost, "/TakeExternalSnapshot", bytes.NewBufferString("invalid json")) + w := httptest.NewRecorder() + + h.TakeExternalSnapshot(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestTakeExternalSnapshot_MissingFields(t *testing.T) { + h := newTestHandler(&mockDomainController{}) + + cases := []TakeExternalSnapshotRequest{ + {SnapKey: "snap1", PresignedURL: "http://example.com"}, + {UUID: "test-uuid", PresignedURL: "http://example.com"}, + {UUID: "test-uuid", SnapKey: "snap1"}, + } + + for _, req := range cases { + r := testutil.MakeRequest(t, req) + w := httptest.NewRecorder() + h.TakeExternalSnapshot(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("expected %d, got %d for req %+v", http.StatusBadRequest, w.Code, req) + } + } +} + +func TestTakeExternalSnapshot_GetDomainError(t *testing.T) { + h := newTestHandler(domainErrMock()) + r := testutil.MakeRequest(t, TakeExternalSnapshotRequest{ + UUID: "test-uuid", + SnapKey: "snap1", + PresignedURL: "http://example.com/presigned", + }) + w := httptest.NewRecorder() + + h.TakeExternalSnapshot(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected %d, got %d", http.StatusInternalServerError, w.Code) + } +} + // DeleteSnapshot func TestDeleteSnapshot_BadRequest(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 34b0c10..216bb47 100755 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -44,6 +44,7 @@ func InitServer(portNum int, h Handlers, logger *zap.Logger) { mux.HandleFunc("POST /RevertExternalSnapshot", h.Snapshot.RevertExternalSnapshot) mux.HandleFunc("POST /MergeExternalSnapshot", h.Snapshot.MergeExternalSnapshot) mux.HandleFunc("POST /DeleteSnapshot", h.Snapshot.DeleteSnapshot) + mux.HandleFunc("POST /TakeExternalSnapshot", h.Snapshot.TakeExternalSnapshot) mux.HandleFunc("GET /metrics", h.Metric.DefaultMetric().ServeHTTP) diff --git a/pkg/types/vm.go b/pkg/types/vm.go index 26ab361..e3d2674 100644 --- a/pkg/types/vm.go +++ b/pkg/types/vm.go @@ -3,14 +3,15 @@ package vmtypes import network "github.com/easy-cloud-Knet/KWS_Core/internal/net" type VM_Init_Info struct { - DomName string `json:"domName"` - UUID string `json:"uuid"` - OS string `json:"os"` - HardwareInfo HardwareInfo `json:"HWInfo"` - NetConf network.NetDefine `json:"network"` - Users []User_info_VM `json:"users"` - SDNUUID string `json:"sdnUUID"` - MacAddr string `json:"macAddr"` + DomName string `json:"domName"` + UUID string `json:"uuid"` + OS string `json:"os"` + PresignedImageUrl string `json:"presignedImageUrl,omitempty"` + HardwareInfo HardwareInfo `json:"HWInfo"` + NetConf network.NetDefine `json:"network"` + Users []User_info_VM `json:"users"` + SDNUUID string `json:"sdnUUID"` + MacAddr string `json:"macAddr"` } type HardwareInfo struct { diff --git a/services/creation/generate_files.go b/services/creation/generate_files.go index 62c66e7..e2cf436 100755 --- a/services/creation/generate_files.go +++ b/services/creation/generate_files.go @@ -1,9 +1,14 @@ package creation import ( + "context" "fmt" + "io" + "net/http" + "os" "os/exec" "path/filepath" + "time" "github.com/easy-cloud-Knet/KWS_Core/internal/config" virerr "github.com/easy-cloud-Knet/KWS_Core/internal/error" @@ -16,7 +21,7 @@ func (DB localConfigurer) CreateDiskImage(dirPath string, diskSize int) error { "-b", baseImage, "-f", "qcow2", "-F", "qcow2", - targetImage, fmt.Sprintf("%dG", diskSize), // 10G + targetImage, fmt.Sprintf("%dG", diskSize), ) if err := qemuImgCmd.Run(); err != nil { errorDescription := fmt.Errorf("generating Disk image error, check duplicated uuid or lack of HD capacity, or validity for base img %s, %v", dirPath, err) @@ -26,6 +31,61 @@ func (DB localConfigurer) CreateDiskImage(dirPath string, diskSize int) error { return nil } +// ensureBaseImage checks if the base image exists locally. +// If not, it downloads it from presignedURL to the target path atomically. +func ensureBaseImage(path, presignedURL string) error { + if _, err := os.Stat(path); err == nil { + return nil + } + + if presignedURL == "" { + return fmt.Errorf("base image not found at %s and no presignedImageUrl provided", path) + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create baseimg directory: %w", err) + } + + tmpPath := path + ".tmp" + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignedURL, nil) + if err != nil { + return fmt.Errorf("failed to build download request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to download base image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download base image returned status %d", resp.StatusCode) + } + + tmp, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to write base image: %w", err) + } + tmp.Close() + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to place base image: %w", err) + } + + return nil +} + func (DB localConfigurer) CreateISOFile(dirPath string) error { isoOutput := filepath.Join(dirPath, "cidata.iso") diff --git a/services/creation/generate_files_test.go b/services/creation/generate_files_test.go new file mode 100644 index 0000000..70b2fdf --- /dev/null +++ b/services/creation/generate_files_test.go @@ -0,0 +1,76 @@ +package creation + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestEnsureBaseImage_AlreadyExists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "base.img") + if err := os.WriteFile(path, []byte("existing"), 0644); err != nil { + t.Fatal(err) + } + + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + + if err := ensureBaseImage(path, srv.URL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if called { + t.Error("HTTP call made when file already exists") + } +} + +func TestEnsureBaseImage_MissingNoURL(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.img") + if err := ensureBaseImage(path, ""); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestEnsureBaseImage_Downloads(t *testing.T) { + content := []byte("fake-image-data") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(content) + })) + defer srv.Close() + + path := filepath.Join(t.TempDir(), "ubuntu-22.04") + + if err := ensureBaseImage(path, srv.URL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(content) { + t.Errorf("content mismatch: got %q, want %q", got, content) + } +} + +func TestEnsureBaseImage_NoTmpFileLeftOnServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + dir := t.TempDir() + path := filepath.Join(dir, "ubuntu-22.04") + + if err := ensureBaseImage(path, srv.URL); err == nil { + t.Fatal("expected error, got nil") + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Error("tmp file not cleaned up after server error") + } +} diff --git a/services/creation/local_domain.go b/services/creation/local_domain.go index cd2a954..c677a0a 100755 --- a/services/creation/local_domain.go +++ b/services/creation/local_domain.go @@ -89,6 +89,12 @@ func (DB localConfigurer) Generate(logger *zap.Logger) error { return virerr.ErrorGen(virerr.InvalidUUID, err) } + baseImage := fmt.Sprintf("%s/baseimg/%s", config.StorageBase, DB.VMDescription.OS) + if err := ensureBaseImage(baseImage, DB.VMDescription.PresignedImageUrl); err != nil { + logger.Error("base image check failed", zap.String("os", DB.VMDescription.OS), zap.Error(err)) + return virerr.ErrorGen(virerr.DomainGenerationError, err) + } + dirPath, err := safepath.GetSafeFilePath(config.StorageBase, DB.VMDescription.UUID) if err != nil { logger.Error("failed to generate safe file path", zap.String("uuid", DB.VMDescription.UUID), zap.Error(err)) diff --git a/services/snapshot/external_snap/upload_ext.go b/services/snapshot/external_snap/upload_ext.go new file mode 100644 index 0000000..3a63402 --- /dev/null +++ b/services/snapshot/external_snap/upload_ext.go @@ -0,0 +1,48 @@ +package external + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" +) + +// SnapshotFilePath returns the path of the primary disk snapshot file. +// Mirrors the path convention in createExternalSnapshot: +// {storageBase}/{domainUUID}/snapshots/{snapName}/{disk}.qcow2 +func SnapshotFilePath(storageBase, domainUUID, snapName, disk string) string { + return filepath.Join(storageBase, domainUUID, "snapshots", snapName, disk+".qcow2") +} + +// UploadToPresignedURL uploads the file at filePath via an S3-compatible presigned PUT URL. +func UploadToPresignedURL(ctx context.Context, filePath, presignedURL string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open snapshot file %s: %w", filePath, err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("failed to stat snapshot file: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, presignedURL, f) + if err != nil { + return fmt.Errorf("failed to build upload request: %w", err) + } + req.ContentLength = info.Size() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to upload snapshot: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("upload returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/services/snapshot/external_snap/upload_ext_test.go b/services/snapshot/external_snap/upload_ext_test.go new file mode 100644 index 0000000..bdc696b --- /dev/null +++ b/services/snapshot/external_snap/upload_ext_test.go @@ -0,0 +1,75 @@ +package external + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestUploadToPresignedURL_Success(t *testing.T) { + content := []byte("snapshot-data") + + var gotMethod string + var gotContentLength int64 + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentLength = r.ContentLength + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + path := filepath.Join(t.TempDir(), "snap.qcow2") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + if err := UploadToPresignedURL(context.Background(), path, srv.URL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPut { + t.Errorf("expected PUT, got %s", gotMethod) + } + if gotContentLength != int64(len(content)) { + t.Errorf("ContentLength: got %d, want %d", gotContentLength, len(content)) + } + if string(gotBody) != string(content) { + t.Errorf("body mismatch: got %q, want %q", gotBody, content) + } +} + +func TestUploadToPresignedURL_FileNotFound(t *testing.T) { + err := UploadToPresignedURL(context.Background(), "/nonexistent/snap.qcow2", "http://127.0.0.1") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestUploadToPresignedURL_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + path := filepath.Join(t.TempDir(), "snap.qcow2") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + if err := UploadToPresignedURL(context.Background(), path, srv.URL); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSnapshotFilePath(t *testing.T) { + got := SnapshotFilePath("/var/lib/kws", "vm-uuid", "snap1", "vda") + want := "/var/lib/kws/vm-uuid/snapshots/snap1/vda.qcow2" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +}