feat: init

This commit is contained in:
2026-06-06 06:15:45 +03:30
commit a986de98b0
33 changed files with 2141 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/KubelanCloud/kks-csi-plugin/pkg/csi/provisioner"
)
type ClientConfig struct {
BaseURL string
Token string
Timeout time.Duration
}
type Client struct {
baseURL string
token string
client *http.Client
}
func NewClient(cfg ClientConfig) *Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"),
token: strings.TrimSpace(cfg.Token),
client: &http.Client{Timeout: timeout},
}
}
func (c *Client) Close() error {
return nil
}
func (c *Client) ClusterInfo(ctx context.Context) (provisioner.ClusterInfo, error) {
var out provisioner.ClusterInfo
if err := c.doJSON(ctx, http.MethodGet, "/v1/cluster", nil, &out); err != nil {
return provisioner.ClusterInfo{}, err
}
return out, nil
}
func (c *Client) CreateVolume(ctx context.Context, req provisioner.CreateVolumeRequest) (provisioner.Volume, error) {
var out provisioner.Volume
if err := c.doJSON(ctx, http.MethodPost, "/v1/volumes", req, &out); err != nil {
return provisioner.Volume{}, err
}
return out, nil
}
func (c *Client) DeleteVolume(ctx context.Context, volumeID string) error {
return c.doJSON(ctx, http.MethodDelete, "/v1/volumes/"+escapePath(volumeID), nil, nil)
}
func (c *Client) VolumeExists(ctx context.Context, volumeID string) (bool, error) {
var out provisioner.VolumeExistsResponse
err := c.doJSON(ctx, http.MethodGet, "/v1/volumes/"+escapePath(volumeID), nil, &out)
if err != nil {
if isNotFound(err) {
return false, nil
}
return false, err
}
return out.Exists, nil
}
func (c *Client) PublishVolume(ctx context.Context, volumeID, nodeID string) (provisioner.PublishVolumeResponse, error) {
var out provisioner.PublishVolumeResponse
req := provisioner.PublishVolumeRequest{NodeID: nodeID}
path := "/v1/volumes/" + escapePath(volumeID) + "/publish"
if err := c.doJSON(ctx, http.MethodPost, path, req, &out); err != nil {
return provisioner.PublishVolumeResponse{}, err
}
return out, nil
}
func (c *Client) UnpublishVolume(ctx context.Context, volumeID, nodeID string) error {
req := provisioner.UnpublishVolumeRequest{
NodeID: nodeID,
VolumeID: volumeID,
}
return c.doJSON(ctx, http.MethodPost, "/v1/volumes/unpublish", req, nil)
}
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, respBody any) error {
var body io.Reader
if reqBody != nil {
raw, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("request %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return &HTTPError{
StatusCode: resp.StatusCode,
Method: method,
Path: path,
Body: strings.TrimSpace(string(raw)),
}
}
if respBody == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, respBody); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
type HTTPError struct {
StatusCode int
Method string
Path string
Body string
}
func (e *HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("%s %s: status %d", e.Method, e.Path, e.StatusCode)
}
return fmt.Sprintf("%s %s: status %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
func isNotFound(err error) bool {
httpErr, ok := err.(*HTTPError)
return ok && httpErr.StatusCode == http.StatusNotFound
}
func escapePath(value string) string {
return strings.ReplaceAll(value, "/", "%2F")
}
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/KubelanCloud/kks-csi-plugin/pkg/csi/provisioner"
)
func TestClientClusterInfo(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/cluster" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(provisioner.ClusterInfo{
StorageID: "abc123",
})
}))
t.Cleanup(srv.Close)
client := NewClient(ClientConfig{BaseURL: srv.URL})
info, err := client.ClusterInfo(context.Background())
if err != nil {
t.Fatalf("ClusterInfo failed: %v", err)
}
if info.StorageID != "abc123" {
t.Fatalf("unexpected cluster info: %#v", info)
}
}
func TestClientCreateVolume(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/volumes" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(provisioner.Volume{
VolumeID: "abc123/k8s-volumes/pvc-1",
SizeBytes: 1024,
})
}))
t.Cleanup(srv.Close)
client := NewClient(ClientConfig{BaseURL: srv.URL})
vol, err := client.CreateVolume(context.Background(), provisioner.CreateVolumeRequest{
Name: "pvc-1",
SizeBytes: 1024,
})
if err != nil {
t.Fatalf("CreateVolume failed: %v", err)
}
if vol.VolumeID != "abc123/k8s-volumes/pvc-1" {
t.Fatalf("unexpected volume: %#v", vol)
}
}