add: universal client core

This commit is contained in:
“Naeel”
2026-06-30 15:43:00 +04:00
parent 4fec63fd72
commit 04a5610aa9
7 changed files with 2081 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
package core
import (
"testing"
)
func TestNormalizeValue_EmptyString(t *testing.T) {
param := universalCfsParam{DataType: "string"}
result := normalizeUniversalValueV6("", param)
if result != "" {
t.Errorf("expected empty string, got: %q", result)
}
}
func TestNormalizeValue_NullString(t *testing.T) {
param := universalCfsParam{DataType: "string"}
result := normalizeUniversalValueV6("null", param)
if result != "" {
t.Errorf("expected empty string for 'null' input, got: %q", result)
}
}
func TestNormalizeValue_MapType(t *testing.T) {
param := universalCfsParam{DataType: "map"}
result := normalizeUniversalValueV6("", param)
if result != "{}" {
t.Errorf("expected '{}' for map dataType, got: %q", result)
}
}
func TestNormalizeValue_ArrayType(t *testing.T) {
param := universalCfsParam{DataType: "array"}
result := normalizeUniversalValueV6("", param)
if result != "[]" {
t.Errorf("expected '[]' for array dataType, got: %q", result)
}
}
func TestNormalizeValue_TrimSpace(t *testing.T) {
param := universalCfsParam{DataType: "string"}
result := normalizeUniversalValueV6(" hello ", param)
if result != "hello" {
t.Errorf("expected 'hello' after trim, got: %q", result)
}
}
@@ -0,0 +1,213 @@
package core
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
)
type InstanceStateDetails struct {
Params map[string]string
Out map[string]string
RawParams map[string]interface{}
RawOut map[string]interface{}
Vault InstanceVaultMeta
}
type InstanceVaultMeta struct {
Url string
UserPath string
Fields []string
}
// GetInstanceStateDetails returns instance.state params/out and vault metadata.
func (c *UniversalClient) GetInstanceStateDetails(ctx context.Context, instanceUid string) (*InstanceStateDetails, error) {
if strings.TrimSpace(instanceUid) == "" {
return nil, fmt.Errorf("missing instance uid")
}
respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil)
if err != nil {
return nil, err
}
var res map[string]interface{}
if err := json.Unmarshal(respBody, &res); err != nil {
return nil, err
}
inst, ok := res["instance"].(map[string]interface{})
if !ok {
return &InstanceStateDetails{Params: map[string]string{}, Out: map[string]string{}}, nil
}
state, _ := inst["state"].(map[string]interface{})
if state == nil {
return &InstanceStateDetails{Params: map[string]string{}, Out: map[string]string{}}, nil
}
paramsRaw := extractStateRawMap(state["params"])
outRaw := extractStateRawMap(state["out"])
params := extractStateStringMap(paramsRaw)
out := extractStateStringMap(outRaw)
vault := extractVaultMeta(state["vault"])
return &InstanceStateDetails{
Params: params,
Out: out,
RawParams: paramsRaw,
RawOut: outRaw,
Vault: vault,
}, nil
}
// GetInstanceVaultSecrets fetches all named secrets from the vault endpoint.
func (c *UniversalClient) GetInstanceVaultSecrets(ctx context.Context, instanceUid string, fields []string) (map[string]string, error) {
secrets := make(map[string]string)
if strings.TrimSpace(instanceUid) == "" || len(fields) == 0 {
return secrets, nil
}
var errs []string
for _, field := range fields {
name := strings.TrimSpace(field)
if name == "" {
continue
}
value, err := c.GetInstanceVaultSecret(ctx, instanceUid, name)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
continue
}
secrets[name] = value
}
if len(errs) > 0 {
return secrets, fmt.Errorf(strings.Join(errs, "; "))
}
return secrets, nil
}
// GetInstanceVaultSecret fetches a single secret value by name.
func (c *UniversalClient) GetInstanceVaultSecret(ctx context.Context, instanceUid string, secretName string) (string, error) {
if strings.TrimSpace(instanceUid) == "" {
return "", fmt.Errorf("missing instance uid")
}
if strings.TrimSpace(secretName) == "" {
return "", fmt.Errorf("missing secret name")
}
path := fmt.Sprintf("/instances/%s/vault/%s", instanceUid, url.PathEscape(secretName))
respBody, _, err := c.doRequest(ctx, "GET", path, nil)
if err != nil {
return "", err
}
return extractVaultSecretValue(respBody)
}
func extractStateRawMap(value interface{}) map[string]interface{} {
raw, _ := value.(map[string]interface{})
if raw == nil {
return map[string]interface{}{}
}
return raw
}
func extractStateStringMap(raw map[string]interface{}) map[string]string {
if raw == nil {
return map[string]string{}
}
out := make(map[string]string, len(raw))
for key, val := range raw {
out[key] = normalizeInstanceParamValue(val)
}
return out
}
func extractVaultMeta(value interface{}) InstanceVaultMeta {
raw, _ := value.(map[string]interface{})
if raw == nil {
return InstanceVaultMeta{}
}
return InstanceVaultMeta{
Url: normalizeInstanceParamValue(raw["url"]),
UserPath: normalizeInstanceParamValue(raw["userPath"]),
Fields: extractStringSlice(raw["fields"]),
}
}
func extractStringSlice(value interface{}) []string {
list, _ := value.([]interface{})
if list == nil {
return []string{}
}
out := make([]string, 0, len(list))
for _, item := range list {
val := strings.TrimSpace(normalizeInstanceParamValue(item))
if val == "" {
continue
}
out = append(out, val)
}
return out
}
func extractVaultSecretValue(body []byte) (string, error) {
var decoded interface{}
if err := json.Unmarshal(body, &decoded); err != nil {
return strings.TrimSpace(string(body)), nil
}
switch v := decoded.(type) {
case string:
return strings.TrimSpace(v), nil
case map[string]interface{}:
if value, ok := extractSecretFromMap(v); ok {
return value, nil
}
b, _ := json.Marshal(v)
return strings.TrimSpace(string(b)), nil
default:
b, _ := json.Marshal(v)
return strings.TrimSpace(string(b)), nil
}
}
func extractSecretFromMap(raw map[string]interface{}) (string, bool) {
for _, key := range []string{"value", "secret", "password", "token"} {
if value, ok := raw[key]; ok {
out := strings.TrimSpace(normalizeInstanceParamValue(value))
if out != "" {
return out, true
}
}
}
if data, ok := raw["data"].(map[string]interface{}); ok {
if value, ok := extractSecretFromMap(data); ok {
return value, true
}
}
if record, ok := raw["record"].(map[string]interface{}); ok {
if value, ok := extractSecretFromMap(record); ok {
return value, true
}
}
if len(raw) == 1 {
for _, value := range raw {
out := strings.TrimSpace(normalizeInstanceParamValue(value))
if out != "" {
return out, true
}
}
}
return "", false
}
@@ -0,0 +1,76 @@
package core
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
// GetInstanceStateParams returns instance.state.params as a map of string values.
func (c *UniversalClient) GetInstanceStateParams(ctx context.Context, instanceUid string) (map[string]string, error) {
if strings.TrimSpace(instanceUid) == "" {
return nil, fmt.Errorf("missing instance uid")
}
respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil)
if err != nil {
return nil, err
}
var res map[string]interface{}
if err := json.Unmarshal(respBody, &res); err != nil {
return nil, err
}
inst, ok := res["instance"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("instance field missing in response")
}
state, _ := inst["state"].(map[string]interface{})
if state == nil {
return map[string]string{}, nil
}
paramsRaw, _ := state["params"].(map[string]interface{})
if paramsRaw == nil {
return map[string]string{}, nil
}
params := make(map[string]string, len(paramsRaw))
for key, val := range paramsRaw {
params[key] = normalizeInstanceParamValue(val)
}
return params, nil
}
func normalizeInstanceParamValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case bool:
if v {
return "true"
}
return "false"
case float64:
if math.Mod(v, 1) == 0 {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case json.Number:
return v.String()
default:
b, err := json.Marshal(v)
if err == nil {
return strings.TrimSpace(string(b))
}
return fmt.Sprintf("%v", v)
}
}
@@ -0,0 +1,46 @@
package core
import (
"context"
"fmt"
"strings"
"time"
)
type operationTimeoutOverrideKey struct{}
// WithOperationTimeout attaches optional per-request operation timeout override to context.
// Empty value means no override and falls back to configured defaults.
func WithOperationTimeout(ctx context.Context, raw string) (context.Context, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ctx, nil
}
d, err := time.ParseDuration(trimmed)
if err != nil {
return nil, fmt.Errorf("некорректный operation_timeout %q: %w", raw, err)
}
if d <= 0 {
return nil, fmt.Errorf("некорректный operation_timeout %q: значение должно быть больше 0", raw)
}
return context.WithValue(ctx, operationTimeoutOverrideKey{}, d), nil
}
func operationTimeoutOverrideFromContext(ctx context.Context) (time.Duration, bool) {
if ctx == nil {
return 0, false
}
v := ctx.Value(operationTimeoutOverrideKey{})
d, ok := v.(time.Duration)
if !ok || d <= 0 {
return 0, false
}
return d, true
}
func (c *UniversalClient) operationTimeoutForContext(ctx context.Context, serviceID int, action string) time.Duration {
if d, ok := operationTimeoutOverrideFromContext(ctx); ok {
return d
}
return c.operationTimeoutFor(serviceID, action)
}
@@ -0,0 +1,168 @@
package core
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
const (
defaultOperationTimeoutValue = 30 * time.Minute
defaultIdleTimeoutValue = 30 * time.Minute
)
type OperationTimeouts struct {
DefaultTimeout time.Duration
IdleTimeout time.Duration
ByOperationName map[string]time.Duration
ByServiceOperation map[int]map[string]time.Duration
}
type operationTimeoutsConfig struct {
DefaultTimeout string `json:"default_timeout"`
IdleTimeout string `json:"idle_timeout"`
Operations map[string]string `json:"operations"`
Overrides map[string]string `json:"overrides"`
}
func DefaultOperationTimeouts() *OperationTimeouts {
return &OperationTimeouts{
DefaultTimeout: defaultOperationTimeoutValue,
IdleTimeout: defaultIdleTimeoutValue,
ByOperationName: map[string]time.Duration{
"create": 30 * time.Minute,
"modify": 30 * time.Minute,
"resume": 30 * time.Minute,
"suspend": 30 * time.Minute,
"delete_user": 10 * time.Minute,
"create_user": 10 * time.Minute,
"delete_database": 10 * time.Minute,
"create_database": 10 * time.Minute,
},
ByServiceOperation: map[int]map[string]time.Duration{},
}
}
func LoadOperationTimeoutsFromBytes(raw []byte) (*OperationTimeouts, error) {
var cfg operationTimeoutsConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse operation timeouts config: %w", err)
}
result := DefaultOperationTimeouts()
if strings.TrimSpace(cfg.DefaultTimeout) != "" {
d, err := time.ParseDuration(strings.TrimSpace(cfg.DefaultTimeout))
if err != nil {
return nil, fmt.Errorf("invalid default_timeout value %q: %w", cfg.DefaultTimeout, err)
}
if d <= 0 {
return nil, fmt.Errorf("invalid default_timeout value %q: must be > 0", cfg.DefaultTimeout)
}
result.DefaultTimeout = d
}
if strings.TrimSpace(cfg.IdleTimeout) != "" {
d, err := time.ParseDuration(strings.TrimSpace(cfg.IdleTimeout))
if err != nil {
return nil, fmt.Errorf("invalid idle_timeout value %q: %w", cfg.IdleTimeout, err)
}
if d <= 0 {
return nil, fmt.Errorf("invalid idle_timeout value %q: must be > 0", cfg.IdleTimeout)
}
result.IdleTimeout = d
}
for operation, timeoutRaw := range cfg.Operations {
operationKey := strings.ToLower(strings.TrimSpace(operation))
if operationKey == "" {
continue
}
d, err := time.ParseDuration(strings.TrimSpace(timeoutRaw))
if err != nil {
return nil, fmt.Errorf("invalid timeout for operation %q: %w", operation, err)
}
if d <= 0 {
return nil, fmt.Errorf("invalid timeout for operation %q: must be > 0", operation)
}
result.ByOperationName[operationKey] = d
}
for overrideKey, timeoutRaw := range cfg.Overrides {
serviceID, operationKey, err := parseServiceOperationOverrideKey(overrideKey)
if err != nil {
return nil, err
}
d, err := time.ParseDuration(strings.TrimSpace(timeoutRaw))
if err != nil {
return nil, fmt.Errorf("invalid timeout for override %q: %w", overrideKey, err)
}
if d <= 0 {
return nil, fmt.Errorf("invalid timeout for override %q: must be > 0", overrideKey)
}
if _, ok := result.ByServiceOperation[serviceID]; !ok {
result.ByServiceOperation[serviceID] = map[string]time.Duration{}
}
result.ByServiceOperation[serviceID][operationKey] = d
}
return result, nil
}
func parseServiceOperationOverrideKey(raw string) (int, string, error) {
key := strings.ToLower(strings.TrimSpace(raw))
parts := strings.Split(key, ".")
if len(parts) != 3 || parts[0] != "services" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
return 0, "", fmt.Errorf("invalid override key %q: expected services.<serviceId>.<operation>", raw)
}
serviceID, err := strconv.Atoi(parts[1])
if err != nil || serviceID <= 0 {
return 0, "", fmt.Errorf("invalid override key %q: serviceId must be positive integer", raw)
}
return serviceID, parts[2], nil
}
func (c *UniversalClient) operationTimeoutFor(serviceID int, action string) time.Duration {
if c == nil || c.OperationTimeouts == nil {
return defaultOperationTimeoutValue
}
key := strings.ToLower(strings.TrimSpace(action))
if serviceID > 0 && key != "" {
if serviceOverrides, ok := c.OperationTimeouts.ByServiceOperation[serviceID]; ok {
if d, ok := serviceOverrides[key]; ok && d > 0 {
return d
}
}
}
if key != "" {
if d, ok := c.OperationTimeouts.ByOperationName[key]; ok && d > 0 {
return d
}
}
if c.OperationTimeouts.DefaultTimeout > 0 {
return c.OperationTimeouts.DefaultTimeout
}
return defaultOperationTimeoutValue
}
func (c *UniversalClient) idleTimeoutFor(serviceID int) time.Duration {
if c == nil || c.OperationTimeouts == nil {
return defaultIdleTimeoutValue
}
if serviceID > 0 {
if serviceOverrides, ok := c.OperationTimeouts.ByServiceOperation[serviceID]; ok {
if d, ok := serviceOverrides["idle"]; ok && d > 0 {
return d
}
}
}
if c.OperationTimeouts.IdleTimeout > 0 {
return c.OperationTimeouts.IdleTimeout
}
if c.OperationTimeouts.DefaultTimeout > 0 {
return c.OperationTimeouts.DefaultTimeout
}
return defaultIdleTimeoutValue
}
@@ -0,0 +1,199 @@
package core
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type RefServiceInstanceOption struct {
InstanceUID string
DisplayName string
}
// ResolveRefSvcParamValue normalizes a refSvcId parameter to a UUID if possible.
// If value already looks like a UUID, it is returned as-is.
func (c *UniversalClient) ResolveRefSvcParamValue(ctx context.Context, refSvcId int, value string) (string, error) {
if c == nil {
return "", nil
}
if refSvcId <= 0 {
return value, nil
}
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
// FIX(uuid-case): UUID-like values are normalised to lowercase so that
// plan (user input may be uppercase) and API response (always lowercase)
// produce identical strings and do not cause Terraform"s plan/state
// inconsistency error.
if isUUIDLike(trimmed) {
return strings.ToLower(trimmed), nil
}
uid, err := c.findInstanceUidByDisplayNameRefSvc(ctx, refSvcId, trimmed)
if err != nil {
return "", err
}
return uid, nil
}
// ResolveRefSvcParamDisplayName maps UUID values back to display_name when possible.
func (c *UniversalClient) ResolveRefSvcParamDisplayName(ctx context.Context, refSvcId int, value string) (string, error) {
if c == nil {
return "", nil
}
if refSvcId <= 0 {
return value, nil
}
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
if !isUUIDLike(trimmed) {
return trimmed, nil
}
// Prefer direct lookup by instance UID to avoid paging the full instances list.
displayName, err := c.getInstanceDisplayNameByUidRefSvc(ctx, refSvcId, trimmed)
if err != nil {
return "", err
}
if strings.TrimSpace(displayName) == "" {
// Fallback to list scan if the direct lookup did not match by serviceId.
displayName, err = c.findInstanceDisplayNameByUidRefSvc(ctx, refSvcId, trimmed)
if err != nil {
return "", err
}
if strings.TrimSpace(displayName) == "" {
return trimmed, nil
}
}
return displayName, nil
}
// ListRefServiceInstances returns active instance options for a referenced service.
func (c *UniversalClient) ListRefServiceInstances(ctx context.Context, serviceId int) ([]RefServiceInstanceOption, error) {
if c == nil || serviceId <= 0 {
return nil, nil
}
var out []RefServiceInstanceOption
page := 1
for {
// isDeleted=false — API-уровень фильтрации, не тратим трафик на удалённые инстансы
path := fmt.Sprintf("/instances?page=%d&size=100&isDeleted=false", page)
respBody, _, err := c.doRequest(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var res struct {
Results []struct {
InstanceUid string `json:"instanceUid"`
DisplayName string `json:"displayName"`
ServiceId int `json:"serviceId"`
IsDeleted bool `json:"isDeleted"`
ExplainedStatus string `json:"explainedStatus"`
} `json:"results"`
}
if err := json.Unmarshal(respBody, &res); err != nil {
return nil, err
}
if len(res.Results) == 0 {
break
}
for _, item := range res.Results {
if item.ServiceId != serviceId {
continue
}
// показываем только running инстансы — deleted/not_created не пригодны к использованию
if item.IsDeleted || !strings.EqualFold(strings.TrimSpace(item.ExplainedStatus), "running") {
continue
}
uid := strings.TrimSpace(item.InstanceUid)
name := strings.TrimSpace(item.DisplayName)
if uid == "" {
continue
}
out = append(out, RefServiceInstanceOption{InstanceUID: uid, DisplayName: name})
}
page++
if page > 100 {
break
}
}
return out, nil
}
// ListAvailableResourceRealms returns realm names available for the target service.
func (c *UniversalClient) ListAvailableResourceRealms(ctx context.Context, serviceId int) ([]string, error) {
if c == nil || serviceId <= 0 {
return nil, nil
}
respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/resourceRealms/available?svcId=%d", serviceId), nil)
if err != nil {
return nil, err
}
var payload struct {
Results json.RawMessage `json:"results"`
}
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, err
}
parseArray := func(raw json.RawMessage) []string {
var values []string
_ = json.Unmarshal(raw, &values)
return values
}
parseString := func(raw json.RawMessage) []string {
var single string
if err := json.Unmarshal(raw, &single); err != nil {
return nil
}
single = strings.TrimSpace(single)
if single == "" {
return nil
}
parts := strings.Split(single, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
if len(result) == 0 {
result = append(result, single)
}
return result
}
values := parseArray(payload.Results)
if len(values) == 0 {
values = parseString(payload.Results)
}
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out, nil
}