From 1198a354ccb2b854da54420a0c59e5d082fe870b Mon Sep 17 00:00:00 2001 From: Kwon Date: Mon, 25 May 2026 15:09:48 +0900 Subject: [PATCH 1/3] Add Test dockerscript for rustfs --- Dockerfile | 2 +- client/rustfs.go | 113 ++++++++++++++++++++++++++++++++++ go.mod | 22 ++++++- go.sum | 36 +++++++++++ tests/docker-compose.test.yml | 26 ++++++++ 5 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 client/rustfs.go diff --git a/Dockerfile b/Dockerfile index c40b9f6..6fb7677 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 빌드 및 실행 단계 -FROM golang:1.23 +FROM golang:1.24 # 필수 패키지 설치 (libvirt 개발 패키지 포함) RUN apt-get update && apt-get install -y \ diff --git a/client/rustfs.go b/client/rustfs.go new file mode 100644 index 0000000..50de646 --- /dev/null +++ b/client/rustfs.go @@ -0,0 +1,113 @@ +package client + +import ( + "context" + "os" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type RustFSClient struct { + client *s3.Client +} + +// Currently RustFSClient has solid s3.Client as its field, +// when logic get complex and needs for mocking arise, replace it with interface + +func NewRustFSClient() (*RustFSClient, error) { + endpoint := os.Getenv("RUSTFS_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:9001" + } + accessKey := os.Getenv("RUSTFS_ACCESS_KEY") + if accessKey == "" { + accessKey = "minioadmin" + } + secretKey := os.Getenv("RUSTFS_SECRET_KEY") + if secretKey == "" { + secretKey = "minioadmin" + } + //TODO: replace unstable accesskey override when testing with proper method + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + ) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + + return &RustFSClient{client: client}, nil +} + +func (c *RustFSClient) ListBuckets(ctx context.Context) ([]string, error) { + out, err := c.client.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { + return nil, err + } + names := make([]string, len(out.Buckets)) + for i, b := range out.Buckets { + names[i] = aws.ToString(b.Name) + } + return names, nil +} + +func (c *RustFSClient) CreateBucket(ctx context.Context, bucket string) error { + _, err := c.client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + return err +} + +// / Refer: https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/s3#NewPresignClient +// Direct listing, generating bucket and presigned URL is the role for RustFSClient(control) +// This Presigned url should be passed to Core client, and core should use this url to upload/download file. +// This Presigend related functions are yet not fully tested, so there might be some issues. Please refer to AWS SDK for Go v2 documentation +// +// for more details and examples on how to use the PresignClient. +func (c *RustFSClient) PresignPutObject(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { + presignClient := s3.NewPresignClient(c.client) + req, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return "", err + } + return req.URL, nil +} + +func (c *RustFSClient) PresignGetObject(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { + presignClient := s3.NewPresignClient(c.client) + req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return "", err + } + return req.URL, nil +} + +// newCLI := &RustFSClient{client: client} +// ctx, cancel := context.WithTimeout(context.Background(), time.Duration(20)*time.Second) +// defer cancel() +// err = newCLI.CreateBucket(ctx, "adsfasdfds") +// err = newCLI.CreateBucket(ctx, "asdfaifdsfn") +// err = newCLI.CreateBucket(ctx, "asdf") +// err = newCLI.CreateBucket(ctx, "zxvc") +// err = newCLI.CreateBucket(ctx, "asdfs") +// bucks, err := newCLI.ListBuckets(ctx) +// fmt.Println(bucks) +// if err != nil { +// fmt.Println(err) +// } diff --git a/go.mod b/go.mod index 43c1b96..014ef18 100755 --- a/go.mod +++ b/go.mod @@ -1,10 +1,14 @@ module github.com/easy-cloud-Knet/KWS_Control -go 1.23.0 +go 1.24 -toolchain go1.23.4 +toolchain go1.24.3 require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.18 + github.com/aws/aws-sdk-go-v2/credentials v1.19.17 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/go-sql-driver/mysql v1.9.2 github.com/redis/go-redis/v9 v9.11.0 github.com/sirupsen/logrus v1.9.3 @@ -15,6 +19,20 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index d3fa9de..ec267f9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,41 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q= +github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index 04348a2..38a6ace 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -35,6 +35,27 @@ services: timeout: 3s retries: 10 + rustfs-test: + image: rustfs/rustfs:latest + container_name: kws-test-rustfs + ports: + - "19000:9000" + - "19001:9001" + environment: + RUSTFS_ACCESS_KEY: minioadmin + RUSTFS_SECRET_KEY: minioadmin + RUSTFS_VOLUMES: /data/rustfs0 + RUSTFS_OBS_LOG_DIRECTORY: /logs + RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*" + RUSTFS_OBS_LOGGER_LEVEL: warn + RUSTFS_OBS_ENVIRONMENT: production + healthcheck: + test: ["CMD", "nc", "-z", "127.0.0.1", "9000"] + interval: 3s + timeout: 5s + retries: 15 + start_period: 15s + control-test: build: context: .. @@ -58,6 +79,9 @@ services: CORES: mock-core:8080 GUACAMOLE_BASE_URL: http://mock-core:8080/guacamole CMS_HOST: mock-core:8080 + RUSTFS_ENDPOINT: http://rustfs-test:9000 + RUSTFS_ACCESS_KEY: minioadmin + RUSTFS_SECRET_KEY: minioadmin depends_on: mysql-test: condition: service_healthy @@ -65,3 +89,5 @@ services: condition: service_healthy mock-core: condition: service_healthy + rustfs-test: + condition: service_healthy From 37a4494a651491829f8659a4949eee3a77d3f0d3 Mon Sep 17 00:00:00 2001 From: Kwon Date: Sun, 31 May 2026 18:29:46 +0900 Subject: [PATCH 2/3] Add service, functions for rustFS client --- .gitignore | 3 +- api/handlers.go | 4 ++ api/snapshot.go | 85 ++++++++++++++++++++++++++++++++++++++++ client/model/snapshot.go | 14 +++++++ client/model/vm.go | 21 +++++----- client/rustfs.go | 81 +++++++++++++++++++++++++++++--------- service/external_snap.go | 60 ++++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 api/snapshot.go create mode 100644 client/model/snapshot.go create mode 100644 service/external_snap.go diff --git a/.gitignore b/.gitignore index d541517..f07cfc0 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +CLAUDE.md + # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,goland+all # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,goland+all @@ -18,7 +20,6 @@ out/ # mpeltonen/sbt-idea plugin .idea_modules/ - # JIRA plugin atlassian-ide-plugin.xml diff --git a/api/handlers.go b/api/handlers.go index ff1108d..085b827 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -29,6 +29,10 @@ func Server(portNum int, contextStruct *vms.ControlContext, rdb *redis.Client) e http.HandleFunc("GET /vm/info", withSecurityHeaders(h.vmInfo)) http.HandleFunc("POST /vm/start", withSecurityHeaders(h.startVm)) + http.HandleFunc("POST /vm/snapshot", withSecurityHeaders(h.takeSnapshot)) + http.HandleFunc("GET /vm/snapshot", withSecurityHeaders(h.listSnapshots)) + http.HandleFunc("DELETE /vm/snapshot", withSecurityHeaders(h.deleteSnapshot)) + fmt.Printf("Running server on port %d\n", portNum) err := http.ListenAndServe(":"+strconv.Itoa(portNum), nil) if err != nil { diff --git a/api/snapshot.go b/api/snapshot.go new file mode 100644 index 0000000..78a92d0 --- /dev/null +++ b/api/snapshot.go @@ -0,0 +1,85 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/easy-cloud-Knet/KWS_Control/service" + "github.com/easy-cloud-Knet/KWS_Control/structure" + "github.com/easy-cloud-Knet/KWS_Control/util" +) + +type ApiTakeSnapshotRequest struct { + UUID structure.UUID `json:"uuid"` + SnapName string `json:"snapName"` +} + +type ApiDeleteSnapshotRequest struct { + UUID structure.UUID `json:"uuid"` + SnapKey string `json:"snapKey"` +} + +func (c *handlerContext) takeSnapshot(w http.ResponseWriter, r *http.Request) { + log := util.GetLogger() + defer r.Body.Close() + + var req ApiTakeSnapshotRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.UUID == "" || req.SnapName == "" { + util.RespondError(w, http.StatusBadRequest, "uuid and snapName are required") + return + } + + if err := service.TakeSnapshot(req.UUID, req.SnapName, c.context); err != nil { + log.Error("takeSnapshot: %v", err, true) + util.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + util.RespondJSON(w, http.StatusOK, map[string]string{"message": "snapshot taken"}) +} + +func (c *handlerContext) listSnapshots(w http.ResponseWriter, r *http.Request) { + log := util.GetLogger() + + uuid := structure.UUID(r.URL.Query().Get("uuid")) + if uuid == "" { + util.RespondError(w, http.StatusBadRequest, "uuid query parameter is required") + return + } + + keys, err := service.ListSnapshots(uuid) + if err != nil { + log.Error("listSnapshots: %v", err, true) + util.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + util.RespondJSON(w, http.StatusOK, map[string][]string{"snapshots": keys}) +} + +func (c *handlerContext) deleteSnapshot(w http.ResponseWriter, r *http.Request) { + log := util.GetLogger() + defer r.Body.Close() + + var req ApiDeleteSnapshotRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.UUID == "" || req.SnapKey == "" { + util.RespondError(w, http.StatusBadRequest, "uuid and snapKey are required") + return + } + + if err := service.DeleteSnapshot(req.UUID, req.SnapKey); err != nil { + log.Error("deleteSnapshot: %v", err, true) + util.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/client/model/snapshot.go b/client/model/snapshot.go new file mode 100644 index 0000000..123e024 --- /dev/null +++ b/client/model/snapshot.go @@ -0,0 +1,14 @@ +package model + +import "github.com/easy-cloud-Knet/KWS_Control/structure" + +// TakeSnapshotRequest asks Core to take an external snapshot and upload to RustFS via presigned PUT URL. +type TakeSnapshotRequest struct { + UUID structure.UUID `json:"uuid"` + SnapKey string `json:"snapKey"` + PresignedURL string `json:"presignedUrl,omitempty"` +} + +type TakeSnapshotResponse struct { + Message string `json:"message,omitempty"` +} diff --git a/client/model/vm.go b/client/model/vm.go index a495ced..6cf1811 100644 --- a/client/model/vm.go +++ b/client/model/vm.go @@ -19,16 +19,17 @@ type UserInfoVM struct { } type CreateVMRequest struct { - DomType string `json:"domType"` - DomName string `json:"domName"` - UUID structure.UUID `json:"uuid"` - OS string `json:"os"` - HardwareInfo HardwareInfo `json:"HWInfo"` - NetConf NetDefine `json:"network"` - Users []UserInfoVM `json:"users"` - SdnUUID string `json:"sdnUUID"` - MacAddr string `json:"macAddr"` - Subnettype string `json:"Subnettype"` + DomType string `json:"domType"` + DomName string `json:"domName"` + UUID structure.UUID `json:"uuid"` + OS string `json:"os"` + HardwareInfo HardwareInfo `json:"HWInfo"` + NetConf NetDefine `json:"network"` + Users []UserInfoVM `json:"users"` + SdnUUID string `json:"sdnUUID"` + MacAddr string `json:"macAddr"` + Subnettype string `json:"Subnettype"` + PresignedImageURL string `json:"presignedImageUrl,omitempty"` } type DomainDeleteType uint diff --git a/client/rustfs.go b/client/rustfs.go index 50de646..c06b2a5 100644 --- a/client/rustfs.go +++ b/client/rustfs.go @@ -2,26 +2,43 @@ package client import ( "context" + "errors" + "fmt" "os" + "sync" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type RustFSClient struct { client *s3.Client } +var ( + rustFSInstance *RustFSClient + rustFSOnce sync.Once + rustFSErr error +) + +func GetRustFSClient() (*RustFSClient, error) { + rustFSOnce.Do(func() { + rustFSInstance, rustFSErr = NewRustFSClient() + }) + return rustFSInstance, rustFSErr +} + // Currently RustFSClient has solid s3.Client as its field, // when logic get complex and needs for mocking arise, replace it with interface func NewRustFSClient() (*RustFSClient, error) { endpoint := os.Getenv("RUSTFS_ENDPOINT") if endpoint == "" { - endpoint = "http://localhost:9001" + endpoint = "http://localhost:9000" } accessKey := os.Getenv("RUSTFS_ACCESS_KEY") if accessKey == "" { @@ -68,12 +85,9 @@ func (c *RustFSClient) CreateBucket(ctx context.Context, bucket string) error { return err } -// / Refer: https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/s3#NewPresignClient +// Refer: https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/s3#NewPresignClient // Direct listing, generating bucket and presigned URL is the role for RustFSClient(control) // This Presigned url should be passed to Core client, and core should use this url to upload/download file. -// This Presigend related functions are yet not fully tested, so there might be some issues. Please refer to AWS SDK for Go v2 documentation -// -// for more details and examples on how to use the PresignClient. func (c *RustFSClient) PresignPutObject(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { presignClient := s3.NewPresignClient(c.client) req, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{ @@ -98,16 +112,47 @@ func (c *RustFSClient) PresignGetObject(ctx context.Context, bucket, key string, return req.URL, nil } -// newCLI := &RustFSClient{client: client} -// ctx, cancel := context.WithTimeout(context.Background(), time.Duration(20)*time.Second) -// defer cancel() -// err = newCLI.CreateBucket(ctx, "adsfasdfds") -// err = newCLI.CreateBucket(ctx, "asdfaifdsfn") -// err = newCLI.CreateBucket(ctx, "asdf") -// err = newCLI.CreateBucket(ctx, "zxvc") -// err = newCLI.CreateBucket(ctx, "asdfs") -// bucks, err := newCLI.ListBuckets(ctx) -// fmt.Println(bucks) -// if err != nil { -// fmt.Println(err) -// } +// HeadObject returns (true, nil) if the object exists, (false, nil) if only the object is missing, +// and (false, err) for all other errors including NoSuchBucket. +func (c *RustFSClient) HeadObject(ctx context.Context, bucket, key string) (bool, error) { + _, err := c.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + var notFound *types.NotFound + if errors.As(err, ¬Found) { + return false, nil + } + return false, fmt.Errorf("HeadObject %s/%s: %w", bucket, key, err) + } + return true, nil +} + +// ListObjects returns all object keys in the bucket matching the prefix. +// Returns an empty slice when no objects match. Returns an error if the bucket does not exist. +func (c *RustFSClient) ListObjects(ctx context.Context, bucket, prefix string) ([]string, error) { + out, err := c.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(prefix), + }) + if err != nil { + return nil, fmt.Errorf("ListObjects %s: %w", bucket, err) + } + keys := make([]string, len(out.Contents)) + for i, obj := range out.Contents { + keys[i] = aws.ToString(obj.Key) + } + return keys, nil +} + +func (c *RustFSClient) DeleteObject(ctx context.Context, bucket, key string) error { + _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("DeleteObject %s/%s: %w", bucket, key, err) + } + return nil +} diff --git a/service/external_snap.go b/service/external_snap.go new file mode 100644 index 0000000..e4da6a5 --- /dev/null +++ b/service/external_snap.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/easy-cloud-Knet/KWS_Control/client" + vms "github.com/easy-cloud-Knet/KWS_Control/structure" +) + +const snapshotPresignTTL = 15 * time.Minute + +func ListSnapshots(uuid vms.UUID) ([]string, error) { + rustfs, err := client.GetRustFSClient() + if err != nil { + return nil, fmt.Errorf("ListSnapshots: RustFS unavailable: %w", err) + } + keys, err := rustfs.ListObjects(context.Background(), string(uuid), "") + if err != nil { + return nil, fmt.Errorf("ListSnapshots %s: %w", uuid, err) + } + return keys, nil +} + +func DeleteSnapshot(uuid vms.UUID, snapKey string) error { + rustfs, err := client.GetRustFSClient() + if err != nil { + return fmt.Errorf("DeleteSnapshot: RustFS unavailable: %w", err) + } + if err := rustfs.DeleteObject(context.Background(), string(uuid), snapKey); err != nil { + return fmt.Errorf("DeleteSnapshot %s/%s: %w", uuid, snapKey, err) + } + return nil +} + +// TakeSnapshot generates a presigned PUT URL and sends it to Core to upload the snapshot. +// TODO: wire up Core client call when Core implements the snapshot upload API. +func TakeSnapshot(uuid vms.UUID, snapName string, ctx *vms.ControlContext) error { + core := ctx.FindCoreByVmUUID(uuid) + if core == nil { + return fmt.Errorf("TakeSnapshot: VM %s not found", uuid) + } + + rustfs, err := client.GetRustFSClient() + if err != nil { + return fmt.Errorf("TakeSnapshot: RustFS unavailable: %w", err) + } + + presignedURL, err := rustfs.PresignPutObject(context.Background(), string(uuid), snapName, snapshotPresignTTL) + if err != nil { + return fmt.Errorf("TakeSnapshot %s: failed to generate presigned URL: %w", uuid, err) + } + + // TODO: coreClient.TakeSnapshot(context.Background(), model.TakeSnapshotRequest{ + // UUID: uuid, SnapKey: snapName, PresignedURL: presignedURL, + // }) + _ = presignedURL + return nil +} From 43238cf17d2da0bdb80b34021a7b3d2d50b9b1dc Mon Sep 17 00:00:00 2001 From: Kwon Date: Sun, 31 May 2026 19:24:05 +0900 Subject: [PATCH 3/3] Add test for snapshot --- tests/blackbox_io_test.sh | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/blackbox_io_test.sh b/tests/blackbox_io_test.sh index 981dd5a..3e74681 100755 --- a/tests/blackbox_io_test.sh +++ b/tests/blackbox_io_test.sh @@ -398,6 +398,84 @@ else fail "Flow: Redis should have status 'started begin'" '"status":"started begin"' "$redis_val" fi +# ============================================================ +# Section 7: Snapshot API +# ============================================================ +section "Section 7: Snapshot API" + +SNAP_UUID="snap-vm-$(date +%s)" + +mc_exec() { + docker run --rm --network "${PROJECT_NAME}_default" --entrypoint sh minio/mc \ + -c "mc alias set r http://kws-test-rustfs:9000 minioadmin minioadmin --quiet 2>/dev/null && $*" 2>/dev/null +} + +# --- 7a. Validation --- +echo -e " ${CYAN}-- Validation --${NC}" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/vm/snapshot" 2>/dev/null) || HTTP_CODE="000" +assert_status "GET /vm/snapshot without uuid param should be 400" "400" "$HTTP_CODE" + +http POST "/vm/snapshot" '{invalid}' +assert_status "POST /vm/snapshot with invalid JSON should be 400" "400" "$HTTP_CODE" + +http POST "/vm/snapshot" '{"uuid":"x"}' +assert_status "POST /vm/snapshot missing snapName should be 400" "400" "$HTTP_CODE" + +http DELETE "/vm/snapshot" '{"uuid":"x"}' +assert_status "DELETE /vm/snapshot missing snapKey should be 400" "400" "$HTTP_CODE" + +# --- 7b. Non-existent UUID --- +echo -e "\n ${CYAN}-- Non-existent UUID --${NC}" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/vm/snapshot?uuid=ghost-vm-0000" 2>/dev/null) || HTTP_CODE="000" +assert_status "GET /vm/snapshot with non-existent bucket should be 500" "500" "$HTTP_CODE" + +http POST "/vm/snapshot" '{"uuid":"ghost-vm-0000","snapName":"snap1"}' +assert_status "POST /vm/snapshot with unknown VM should be 500" "500" "$HTTP_CODE" + +http DELETE "/vm/snapshot" '{"uuid":"ghost-vm-0000","snapKey":"snap1"}' +assert_status "DELETE /vm/snapshot on non-existent bucket should be 500" "500" "$HTTP_CODE" + +# --- 7c. Happy Path --- +echo -e "\n ${CYAN}-- Happy Path --${NC}" + +http POST "/vm" "{ + \"domType\": \"kvm\", + \"domName\": \"snap-test-vm\", + \"uuid\": \"${SNAP_UUID}\", + \"os\": \"ubuntu\", + \"HWInfo\": {\"cpu\": 1, \"memory\": 1024, \"disk\": 10}, + \"network\": {\"ips\": [], \"NetType\": 0}, + \"users\": [{\"name\": \"ubuntu\", \"groups\": \"sudo\", \"passWord\": \"testpass\", \"ssh\": []}], + \"Subnettype\": \"\" +}" +assert_status "Create VM for snapshot test should be 201" "201" "$HTTP_CODE" + +mc_exec "mc mb r/${SNAP_UUID} && echo seed > /tmp/f && mc cp /tmp/f r/${SNAP_UUID}/snap-seed" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "${BASE_URL}/vm/snapshot?uuid=${SNAP_UUID}" 2>/dev/null) || HTTP_CODE="000" +assert_status "GET /vm/snapshot should be 200" "200" "$HTTP_CODE" + +HTTP_BODY=$(curl -s "${BASE_URL}/vm/snapshot?uuid=${SNAP_UUID}" 2>/dev/null) +assert_body_contains "GET /vm/snapshot returns seeded key" "snap-seed" "$HTTP_BODY" + +http DELETE "/vm/snapshot" "{\"uuid\":\"${SNAP_UUID}\",\"snapKey\":\"snap-seed\"}" +assert_status "DELETE /vm/snapshot should be 200" "200" "$HTTP_CODE" + +HTTP_BODY=$(curl -s "${BASE_URL}/vm/snapshot?uuid=${SNAP_UUID}" 2>/dev/null) +if echo "$HTTP_BODY" | grep -q "snap-seed"; then + fail "Snapshot list after delete should not contain snap-seed" "not snap-seed" "$HTTP_BODY" +else + pass "Snapshot list after delete is empty" +fi + +# TakeSnapshot: presigned URL 생성까지만 동작 (Core 호출 TODO) +http POST "/vm/snapshot" "{\"uuid\":\"${SNAP_UUID}\",\"snapName\":\"snap-new\"}" +assert_status "POST /vm/snapshot (presign only, Core TODO) should be 200" "200" "$HTTP_CODE" + +http DELETE "/vm" "{\"uuid\":\"${SNAP_UUID}\"}" + # ============================================================ # Results # ============================================================