diff --git a/pkg/providerports/ports.go b/pkg/providerports/ports.go new file mode 100644 index 0000000..0e2a4c3 --- /dev/null +++ b/pkg/providerports/ports.go @@ -0,0 +1,891 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +// Package providerports defines the provider-neutral hexagonal port interfaces +// between the CloudRING Open Cloud Standard (OCS v3) domain kernel and +// downstream infrastructure adapters (KubeVirt, OpenStack, Proxmox, Bare-Metal) +// without coupling the domain core to Kubernetes or vendor client libraries. +package providerports + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "slices" + "sort" + "strings" +) + +const ( + ProvisionRequestSchemaVersion = "cloudring.provider-ports.provision-request/v1" + ProvisionReceiptSchemaVersion = "cloudring.provider-ports.provision-receipt/v1" + commitmentDomainPrefix = "cloudring/provider-ports/commitment/v1" + + MaxParametersCount = 64 + MaxParameterValue = 1024 +) + +// ServiceCategory enumerates the nine canonical CloudRING / OCS v3 product categories. +type ServiceCategory string + +const ( + ServiceCompute ServiceCategory = "compute" + ServiceVolume ServiceCategory = "volume" + ServiceNetwork ServiceCategory = "network" + ServiceKubernetes ServiceCategory = "kubernetes" + ServiceObjectStorage ServiceCategory = "object-storage" + ServiceBackup ServiceCategory = "backup" + ServiceAccessControl ServiceCategory = "access-control" + ServiceSupport ServiceCategory = "support" + ServiceExternalIntegration ServiceCategory = "external-integration" +) + +// BackendFamily identifies the infrastructure substrate family behind a port adapter. +type BackendFamily string + +const ( + BackendKubeVirt BackendFamily = "kubevirt" + BackendOpenStack BackendFamily = "openstack" + BackendProxmox BackendFamily = "proxmox" + BackendBareMetal BackendFamily = "bare-metal" +) + +// LifecycleVerb enumerates the canonical OCS v3 resource lifecycle operations. +type LifecycleVerb string + +const ( + VerbProvision LifecycleVerb = "provision" + VerbResume LifecycleVerb = "resume" + VerbResize LifecycleVerb = "resize" + VerbHold LifecycleVerb = "hold" + VerbSuspend LifecycleVerb = "suspend" + VerbDeprovision LifecycleVerb = "deprovision" +) + +// ResourceState represents the canonical provider-neutral lifecycle state of a resource. +type ResourceState string + +const ( + StatePending ResourceState = "pending" + StateProvisioning ResourceState = "provisioning" + StateReady ResourceState = "ready" + StateDegraded ResourceState = "degraded" + StateSuspended ResourceState = "suspended" + StateDeprovisioning ResourceState = "deprovisioning" + StateDeleted ResourceState = "deleted" + StateFailed ResourceState = "failed" + StateDenied ResourceState = "denied" + StateBlocked ResourceState = "blocked" + StateRetryable ResourceState = "retryable" +) + +// HealthStatus represents the operational readiness of a provider port adapter. +type HealthStatus string + +const ( + HealthReady HealthStatus = "ready" + HealthDegraded HealthStatus = "degraded" + HealthUnavailable HealthStatus = "unavailable" +) + +var ( + ErrInvalidRequest = errors.New("providerports: invalid provision request") + ErrInvalidReceipt = errors.New("providerports: invalid provision receipt") + ErrInvalidHealthStatus = errors.New("providerports: invalid health status") + ErrInvalidAdapterDescriptor = errors.New("providerports: invalid adapter descriptor") + ErrAdapterAlreadyRegistered = errors.New("providerports: adapter already registered") + ErrAdapterNotFound = errors.New("providerports: adapter not found") + ErrIdempotencyConflict = errors.New("providerports: idempotency key reused with different request parameters") + ErrTenantIsolationViolation = errors.New("providerports: cross-tenant or cross-project access denied") + ErrResourceNotFound = errors.New("providerports: resource not found") + ErrContextCanceled = errors.New("providerports: operation canceled by caller context") + + identifierPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$`) + paramKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,62}$`) + digestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + + allServiceCategories = []ServiceCategory{ + ServiceCompute, + ServiceVolume, + ServiceNetwork, + ServiceKubernetes, + ServiceObjectStorage, + ServiceBackup, + ServiceAccessControl, + ServiceSupport, + ServiceExternalIntegration, + } + + allBackendFamilies = []BackendFamily{ + BackendKubeVirt, + BackendOpenStack, + BackendProxmox, + BackendBareMetal, + } + + allLifecycleVerbs = []LifecycleVerb{ + VerbProvision, + VerbResume, + VerbResize, + VerbHold, + VerbSuspend, + VerbDeprovision, + } + + allResourceStates = []ResourceState{ + StatePending, + StateProvisioning, + StateReady, + StateDegraded, + StateSuspended, + StateDeprovisioning, + StateDeleted, + StateFailed, + StateDenied, + StateBlocked, + StateRetryable, + } + + allHealthStatuses = []HealthStatus{ + HealthReady, + HealthDegraded, + HealthUnavailable, + } +) + +// AllServiceCategories returns a copy of the 9 canonical CloudRING service categories. +func AllServiceCategories() []ServiceCategory { + out := make([]ServiceCategory, len(allServiceCategories)) + copy(out, allServiceCategories) + return out +} + +// ValidServiceCategory reports whether category is one of the 9 canonical OCS v3 categories. +func ValidServiceCategory(category ServiceCategory) bool { + return slices.Contains(allServiceCategories, category) +} + +// ValidBackendFamily reports whether family is a supported infrastructure substrate family. +func ValidBackendFamily(family BackendFamily) bool { + return slices.Contains(allBackendFamilies, family) +} + +// ValidLifecycleVerb reports whether verb is a supported OCS v3 lifecycle verb. +func ValidLifecycleVerb(verb LifecycleVerb) bool { + return slices.Contains(allLifecycleVerbs, verb) +} + +// ValidResourceState reports whether state is a canonical OCS v3 resource state. +func ValidResourceState(state ResourceState) bool { + return slices.Contains(allResourceStates, state) +} + +// ValidHealthStatus reports whether status is a canonical adapter health status. +func ValidHealthStatus(status HealthStatus) bool { + return slices.Contains(allHealthStatuses, status) +} + +// Parameter is a deterministic key/value attribute on a ProvisionRequest. +type Parameter struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// ProvisionRequest describes a provider-neutral lifecycle request across the hexagonal seam. +type ProvisionRequest struct { + SchemaVersion string `json:"schemaVersion"` + ServiceCategory ServiceCategory `json:"serviceCategory"` + AdapterID string `json:"adapterId"` + TenantID string `json:"tenantId"` + ProjectID string `json:"projectId"` + ResourceID string `json:"resourceId"` + IdempotencyKey string `json:"idempotencyKey"` + Verb LifecycleVerb `json:"verb"` + Parameters []Parameter `json:"parameters,omitempty"` +} + +// ProvisionReceipt is the deterministic, source-safe receipt returned by a provider port. +type ProvisionReceipt struct { + SchemaVersion string `json:"schemaVersion"` + RequestSHA256 string `json:"requestSha256"` + CommitmentSHA256 string `json:"commitmentSha256"` + OperationID string `json:"operationId"` + ProviderHandle string `json:"providerHandle"` + AdapterID string `json:"adapterId"` + ServiceCategory ServiceCategory `json:"serviceCategory"` + TenantID string `json:"tenantId"` + ProjectID string `json:"projectId"` + ResourceID string `json:"resourceId"` + State ResourceState `json:"state"` + BlockerCode string `json:"blockerCode,omitempty"` +} + +// HealthReport describes the health of a registered provider adapter. +type HealthReport struct { + AdapterID string `json:"adapterId"` + ServiceCategory ServiceCategory `json:"serviceCategory"` + BackendFamily BackendFamily `json:"backendFamily"` + Status HealthStatus `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// ValidateHealthReport checks that a HealthReport carries canonical fields. +func ValidateHealthReport(report HealthReport) error { + if !validIdentifier(report.AdapterID) || + !ValidServiceCategory(report.ServiceCategory) || + !ValidBackendFamily(report.BackendFamily) || + !ValidHealthStatus(report.Status) || + (report.Status != HealthReady && strings.TrimSpace(report.Reason) == "") { + return ErrInvalidHealthStatus + } + return nil +} + +// NewProvisionRequest canonicalizes parameters and validates the resulting ProvisionRequest. +func NewProvisionRequest( + category ServiceCategory, + adapterID, tenantID, projectID, resourceID, idempotencyKey string, + verb LifecycleVerb, + params map[string]string, +) (ProvisionRequest, error) { + sortedParams := make([]Parameter, 0, len(params)) + for k, v := range params { + sortedParams = append(sortedParams, Parameter{Key: k, Value: v}) + } + sort.Slice(sortedParams, func(i, j int) bool { + return sortedParams[i].Key < sortedParams[j].Key + }) + req := ProvisionRequest{ + SchemaVersion: ProvisionRequestSchemaVersion, + ServiceCategory: category, + AdapterID: adapterID, + TenantID: tenantID, + ProjectID: projectID, + ResourceID: resourceID, + IdempotencyKey: idempotencyKey, + Verb: verb, + Parameters: sortedParams, + } + if err := ValidateProvisionRequest(req); err != nil { + return ProvisionRequest{}, err + } + return req, nil +} + +// ValidateProvisionRequest enforces strict structural and ordering invariants on a ProvisionRequest. +func ValidateProvisionRequest(req ProvisionRequest) error { + if req.SchemaVersion != ProvisionRequestSchemaVersion || + !ValidServiceCategory(req.ServiceCategory) || + !validIdentifier(req.AdapterID) || + !validIdentifier(req.TenantID) || + !validIdentifier(req.ProjectID) || + !validIdentifier(req.ResourceID) || + !validIdentifier(req.IdempotencyKey) || + !ValidLifecycleVerb(req.Verb) || + len(req.Parameters) > MaxParametersCount { + return ErrInvalidRequest + } + for i, param := range req.Parameters { + if !paramKeyPattern.MatchString(param.Key) || + strings.TrimSpace(param.Value) == "" || + len(param.Value) > MaxParameterValue || + strings.ContainsRune(param.Value, 0) { + return ErrInvalidRequest + } + if i > 0 && req.Parameters[i-1].Key >= param.Key { + return ErrInvalidRequest + } + } + return nil +} + +// RequestSHA256 computes the canonical domain-separated SHA-256 digest of a validated ProvisionRequest. +func RequestSHA256(req ProvisionRequest) (string, error) { + if err := ValidateProvisionRequest(req); err != nil { + return "", err + } + hash := sha256.New() + writeField := func(value string) { + _, _ = hash.Write([]byte(value)) + _, _ = hash.Write([]byte{0}) + } + writeField(req.SchemaVersion) + writeField(string(req.ServiceCategory)) + writeField(req.AdapterID) + writeField(req.TenantID) + writeField(req.ProjectID) + writeField(req.ResourceID) + writeField(req.IdempotencyKey) + writeField(string(req.Verb)) + for _, p := range req.Parameters { + writeField(p.Key) + writeField(p.Value) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// ReceiptCommitmentSHA256 computes the deterministic SHA-256 commitment binding +// a validated request, resulting resource state, and provider handle. +func ReceiptCommitmentSHA256(req ProvisionRequest, state ResourceState, providerHandle string) (string, error) { + reqDigest, err := RequestSHA256(req) + if err != nil { + return "", err + } + if !ValidResourceState(state) || !validIdentifier(providerHandle) { + return "", ErrInvalidReceipt + } + hash := sha256.New() + _, _ = hash.Write([]byte(commitmentDomainPrefix)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(reqDigest)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(state)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(providerHandle)) + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// NewProvisionReceipt builds and validates a deterministic ProvisionReceipt for a request. +func NewProvisionReceipt( + req ProvisionRequest, + operationID string, + state ResourceState, + providerHandle string, + blockerCode string, +) (ProvisionReceipt, error) { + reqDigest, err := RequestSHA256(req) + if err != nil { + return ProvisionReceipt{}, ErrInvalidReceipt + } + commitment, err := ReceiptCommitmentSHA256(req, state, providerHandle) + if err != nil { + return ProvisionReceipt{}, ErrInvalidReceipt + } + receipt := ProvisionReceipt{ + SchemaVersion: ProvisionReceiptSchemaVersion, + RequestSHA256: reqDigest, + CommitmentSHA256: commitment, + OperationID: operationID, + ProviderHandle: providerHandle, + AdapterID: req.AdapterID, + ServiceCategory: req.ServiceCategory, + TenantID: req.TenantID, + ProjectID: req.ProjectID, + ResourceID: req.ResourceID, + State: state, + BlockerCode: blockerCode, + } + if err := ValidateProvisionReceipt(req, receipt); err != nil { + return ProvisionReceipt{}, err + } + return receipt, nil +} + +// ValidateProvisionReceipt verifies that receipt matches request, recomputes the +// cryptographic commitment from (req, state, providerHandle), and enforces state invariants. +func ValidateProvisionReceipt(req ProvisionRequest, receipt ProvisionReceipt) error { + reqDigest, err := RequestSHA256(req) + if err != nil { + return ErrInvalidReceipt + } + expectedCommitment, err := ReceiptCommitmentSHA256(req, receipt.State, receipt.ProviderHandle) + if err != nil { + return ErrInvalidReceipt + } + if receipt.SchemaVersion != ProvisionReceiptSchemaVersion || + receipt.RequestSHA256 != reqDigest || + !digestPattern.MatchString(receipt.CommitmentSHA256) || + receipt.CommitmentSHA256 != expectedCommitment || + !validIdentifier(receipt.OperationID) || + receipt.AdapterID != req.AdapterID || + receipt.ServiceCategory != req.ServiceCategory || + receipt.TenantID != req.TenantID || + receipt.ProjectID != req.ProjectID || + receipt.ResourceID != req.ResourceID { + return ErrInvalidReceipt + } + requiresBlocker := receipt.State == StateBlocked || receipt.State == StateDenied || receipt.State == StateFailed || receipt.State == StateRetryable + if requiresBlocker && !validIdentifier(receipt.BlockerCode) { + return ErrInvalidReceipt + } + if !requiresBlocker && receipt.BlockerCode != "" { + return ErrInvalidReceipt + } + return nil +} + +// ProviderPort is the common base hexagonal interface implemented by every category provider adapter. +type ProviderPort interface { + AdapterID() string + ServiceCategory() ServiceCategory + BackendFamily() BackendFamily + ExecuteLifecycle(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) + InspectResource(ctx context.Context, tenantID, projectID, resourceID string) (ProvisionReceipt, error) + Health(ctx context.Context) (HealthReport, error) +} + +// ComputeProvider is the hexagonal port for Compute (virtual machines & bare-metal instances). +type ComputeProvider interface { + ProviderPort + ProvisionInstance(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// VolumeProvider is the hexagonal port for Block & File Storage volumes. +type VolumeProvider interface { + ProviderPort + ProvisionVolume(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// NetworkProvider is the hexagonal port for VPCs, subnets, security groups, and load balancers. +type NetworkProvider interface { + ProviderPort + ProvisionNetwork(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// KubernetesClusterProvider is the hexagonal port for Managed Kubernetes clusters. +type KubernetesClusterProvider interface { + ProviderPort + ProvisionCluster(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// ObjectStorageProvider is the hexagonal port for S3-compatible object storage buckets and policies. +type ObjectStorageProvider interface { + ProviderPort + ProvisionBucket(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// BackupProvider is the hexagonal port for snapshot, backup vault, and disaster recovery policies. +type BackupProvider interface { + ProviderPort + ProvisionBackupPlan(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// AccessControlProvider is the hexagonal port for IAM policies, workload identities, and zero-trust access. +type AccessControlProvider interface { + ProviderPort + ProvisionAccessPolicy(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// SupportProvider is the hexagonal port for support plans, diagnostics routing, and SLA bindings. +type SupportProvider interface { + ProviderPort + ProvisionSupportSubscription(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// ExternalIntegrationProvider is the hexagonal port for federated/third-party service connectors. +type ExternalIntegrationProvider interface { + ProviderPort + ProvisionExternalConnector(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) +} + +// AdapterDescriptor describes a registered adapter in deterministic listings. +type AdapterDescriptor struct { + AdapterID string `json:"adapterId"` + ServiceCategory ServiceCategory `json:"serviceCategory"` + BackendFamily BackendFamily `json:"backendFamily"` +} + +// ProviderRegistry manages registration and deterministic lookup of hexagonal provider adapters. +type ProviderRegistry struct { + adapters map[string]ProviderPort +} + +// NewProviderRegistry creates an empty ProviderRegistry. +func NewProviderRegistry() *ProviderRegistry { + return &ProviderRegistry{ + adapters: make(map[string]ProviderPort), + } +} + +func registryKey(category ServiceCategory, adapterID string) string { + return string(category) + "\x00" + adapterID +} + +// Register adds a ProviderPort to the registry after validating its identity and category. +func (r *ProviderRegistry) Register(port ProviderPort) error { + if port == nil || + !validIdentifier(port.AdapterID()) || + !ValidServiceCategory(port.ServiceCategory()) || + !ValidBackendFamily(port.BackendFamily()) { + return ErrInvalidAdapterDescriptor + } + key := registryKey(port.ServiceCategory(), port.AdapterID()) + if _, exists := r.adapters[key]; exists { + return ErrAdapterAlreadyRegistered + } + r.adapters[key] = port + return nil +} + +// Lookup retrieves a registered ProviderPort by ServiceCategory and AdapterID. +func (r *ProviderRegistry) Lookup(category ServiceCategory, adapterID string) (ProviderPort, error) { + if !ValidServiceCategory(category) || !validIdentifier(adapterID) { + return nil, ErrInvalidAdapterDescriptor + } + port, exists := r.adapters[registryKey(category, adapterID)] + if !exists { + return nil, ErrAdapterNotFound + } + return port, nil +} + +// List returns all registered adapters in canonical (ServiceCategory, AdapterID) order. +func (r *ProviderRegistry) List() []AdapterDescriptor { + descriptors := make([]AdapterDescriptor, 0, len(r.adapters)) + for _, port := range r.adapters { + descriptors = append(descriptors, AdapterDescriptor{ + AdapterID: port.AdapterID(), + ServiceCategory: port.ServiceCategory(), + BackendFamily: port.BackendFamily(), + }) + } + sort.Slice(descriptors, func(i, j int) bool { + if descriptors[i].ServiceCategory != descriptors[j].ServiceCategory { + return descriptors[i].ServiceCategory < descriptors[j].ServiceCategory + } + return descriptors[i].AdapterID < descriptors[j].AdapterID + }) + return descriptors +} + +// InMemoryAdapter is a deterministic, thread-safe (single-goroutine contract) reference adapter +// implementing all 9 CloudRING provider port interfaces for contract & conformance verification. +type InMemoryAdapter struct { + adapterID string + serviceCategory ServiceCategory + backendFamily BackendFamily + healthStatus HealthStatus + healthReason string + idempotencyLog map[string]idempotencyEntry + resources map[string]storedResource +} + +type idempotencyEntry struct { + requestSHA256 string + receipt ProvisionReceipt +} + +type storedResource struct { + tenantID string + projectID string + receipt ProvisionReceipt +} + +// NewInMemoryAdapter constructs a validated InMemoryAdapter for the given category and backend family. +func NewInMemoryAdapter(adapterID string, category ServiceCategory, family BackendFamily) (*InMemoryAdapter, error) { + if !validIdentifier(adapterID) || !ValidServiceCategory(category) || !ValidBackendFamily(family) { + return nil, ErrInvalidAdapterDescriptor + } + return &InMemoryAdapter{ + adapterID: adapterID, + serviceCategory: category, + backendFamily: family, + healthStatus: HealthReady, + idempotencyLog: make(map[string]idempotencyEntry), + resources: make(map[string]storedResource), + }, nil +} + +func (a *InMemoryAdapter) AdapterID() string { return a.adapterID } +func (a *InMemoryAdapter) ServiceCategory() ServiceCategory { return a.serviceCategory } +func (a *InMemoryAdapter) BackendFamily() BackendFamily { return a.backendFamily } + +// SetHealth updates the adapter's reported health status and reason. +func (a *InMemoryAdapter) SetHealth(status HealthStatus, reason string) error { + report := HealthReport{ + AdapterID: a.adapterID, + ServiceCategory: a.serviceCategory, + BackendFamily: a.backendFamily, + Status: status, + Reason: reason, + } + if err := ValidateHealthReport(report); err != nil { + return err + } + a.healthStatus = status + a.healthReason = reason + return nil +} + +// Health returns the adapter's current HealthReport, respecting context cancellation. +func (a *InMemoryAdapter) Health(ctx context.Context) (HealthReport, error) { + if err := checkContext(ctx); err != nil { + return HealthReport{}, err + } + return HealthReport{ + AdapterID: a.adapterID, + ServiceCategory: a.serviceCategory, + BackendFamily: a.backendFamily, + Status: a.healthStatus, + Reason: a.healthReason, + }, nil +} + +// ExecuteLifecycle executes a canonical lifecycle operation with strict tenant isolation and idempotency. +func (a *InMemoryAdapter) ExecuteLifecycle(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + if err := checkContext(ctx); err != nil { + return ProvisionReceipt{}, err + } + if err := ValidateProvisionRequest(req); err != nil { + return ProvisionReceipt{}, err + } + if req.AdapterID != a.adapterID || req.ServiceCategory != a.serviceCategory { + return ProvisionReceipt{}, ErrInvalidRequest + } + reqDigest, _ := RequestSHA256(req) + + if existing, exists := a.resources[req.ResourceID]; exists { + if existing.tenantID != req.TenantID || existing.projectID != req.ProjectID { + return ProvisionReceipt{}, ErrTenantIsolationViolation + } + } else if req.Verb != VerbProvision { + return ProvisionReceipt{}, ErrResourceNotFound + } + + idemKey := req.TenantID + "\x00" + req.ProjectID + "\x00" + req.ResourceID + "\x00" + req.IdempotencyKey + if prior, seen := a.idempotencyLog[idemKey]; seen { + if prior.requestSHA256 != reqDigest { + return ProvisionReceipt{}, ErrIdempotencyConflict + } + return prior.receipt, nil + } + + targetState := stateForVerb(req.Verb) + opID := "op-" + reqDigest[:24] + handleSum := sha256.Sum256([]byte(string(a.backendFamily) + "\x00" + req.TenantID + "\x00" + req.ProjectID + "\x00" + req.ResourceID)) + handle := fmt.Sprintf("hdl-%s-%s", a.backendFamily, hex.EncodeToString(handleSum[:10])) + receipt, _ := NewProvisionReceipt(req, opID, targetState, handle, "") + a.idempotencyLog[idemKey] = idempotencyEntry{ + requestSHA256: reqDigest, + receipt: receipt, + } + a.resources[req.ResourceID] = storedResource{ + tenantID: req.TenantID, + projectID: req.ProjectID, + receipt: receipt, + } + return receipt, nil +} + +// InspectResource returns the latest receipt for a resource if and only if tenantID and projectID match. +func (a *InMemoryAdapter) InspectResource(ctx context.Context, tenantID, projectID, resourceID string) (ProvisionReceipt, error) { + if err := checkContext(ctx); err != nil { + return ProvisionReceipt{}, err + } + if !validIdentifier(tenantID) || !validIdentifier(projectID) || !validIdentifier(resourceID) { + return ProvisionReceipt{}, ErrInvalidRequest + } + stored, exists := a.resources[resourceID] + if !exists { + return ProvisionReceipt{}, ErrResourceNotFound + } + if stored.tenantID != tenantID || stored.projectID != projectID { + return ProvisionReceipt{}, ErrTenantIsolationViolation + } + return stored.receipt, nil +} + +func (a *InMemoryAdapter) ProvisionInstance(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionVolume(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionNetwork(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionCluster(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionBucket(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionBackupPlan(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionAccessPolicy(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionSupportSubscription(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +func (a *InMemoryAdapter) ProvisionExternalConnector(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + return a.ExecuteLifecycle(ctx, req) +} + +// ConformanceResult summarizes the contract verification of a ProviderPort implementation. +type ConformanceResult struct { + AdapterID string `json:"adapterId"` + ServiceCategory ServiceCategory `json:"serviceCategory"` + BackendFamily BackendFamily `json:"backendFamily"` + Passed bool `json:"passed"` + VerifiedChecks []string `json:"verifiedChecks"` +} + +// RunConformanceSuite executes the hexagonal contract test harness against port, +// verifying health reporting, idempotency, conflict detection, context cancellation, +// tenant/project isolation, and lifecycle transitions. +func RunConformanceSuite(port ProviderPort) (ConformanceResult, error) { + if port == nil { + return ConformanceResult{}, ErrInvalidAdapterDescriptor + } + ctx := context.Background() + health, err := port.Health(ctx) + if err != nil { + return ConformanceResult{}, err + } + if err := ValidateHealthReport(health); err != nil { + return ConformanceResult{}, err + } + + req, err := NewProvisionRequest( + port.ServiceCategory(), + port.AdapterID(), + "tenant-alpha", + "project-core", + "res-conformance-01", + "idem-create-01", + VerbProvision, + map[string]string{"profile": "standard-v1"}, + ) + if err != nil { + return ConformanceResult{}, err + } + + firstReceipt, err := port.ExecuteLifecycle(ctx, req) + if err != nil { + return ConformanceResult{}, err + } + if err := ValidateProvisionReceipt(req, firstReceipt); err != nil || firstReceipt.State != StateReady { + return ConformanceResult{}, ErrInvalidReceipt + } + + replayReceipt, err := port.ExecuteLifecycle(ctx, req) + if err != nil { + return ConformanceResult{}, err + } + if replayReceipt != firstReceipt { + return ConformanceResult{}, ErrIdempotencyConflict + } + + conflictingReq, _ := NewProvisionRequest( + port.ServiceCategory(), + port.AdapterID(), + "tenant-alpha", + "project-core", + "res-conformance-01", + "idem-create-01", + VerbProvision, + map[string]string{"profile": "mutated-v2"}, + ) + if _, err := port.ExecuteLifecycle(ctx, conflictingReq); !errors.Is(err, ErrIdempotencyConflict) { + return ConformanceResult{}, ErrIdempotencyConflict + } + + if _, err := port.InspectResource(ctx, "tenant-bravo", "project-core", "res-conformance-01"); !errors.Is(err, ErrTenantIsolationViolation) { + return ConformanceResult{}, ErrTenantIsolationViolation + } + + crossTenantMutate, _ := NewProvisionRequest( + port.ServiceCategory(), + port.AdapterID(), + "tenant-bravo", + "project-core", + "res-conformance-01", + "idem-bravo-01", + VerbDeprovision, + nil, + ) + if _, err := port.ExecuteLifecycle(ctx, crossTenantMutate); !errors.Is(err, ErrTenantIsolationViolation) { + return ConformanceResult{}, ErrTenantIsolationViolation + } + + transitions := []struct { + idemKey string + verb LifecycleVerb + expectedState ResourceState + }{ + {idemKey: "idem-resize-01", verb: VerbResize, expectedState: StateReady}, + {idemKey: "idem-hold-01", verb: VerbHold, expectedState: StateSuspended}, + {idemKey: "idem-resume-01", verb: VerbResume, expectedState: StateReady}, + {idemKey: "idem-suspend-01", verb: VerbSuspend, expectedState: StateSuspended}, + {idemKey: "idem-deprov-01", verb: VerbDeprovision, expectedState: StateDeleted}, + } + for _, step := range transitions { + stepReq, _ := NewProvisionRequest( + port.ServiceCategory(), + port.AdapterID(), + "tenant-alpha", + "project-core", + "res-conformance-01", + step.idemKey, + step.verb, + nil, + ) + stepReceipt, err := port.ExecuteLifecycle(ctx, stepReq) + if err != nil { + return ConformanceResult{}, err + } + if err := ValidateProvisionReceipt(stepReq, stepReceipt); err != nil || stepReceipt.State != step.expectedState { + return ConformanceResult{}, ErrInvalidReceipt + } + } + + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + if _, err := port.ExecuteLifecycle(canceledCtx, req); !errors.Is(err, ErrContextCanceled) { + return ConformanceResult{}, ErrContextCanceled + } + + return ConformanceResult{ + AdapterID: port.AdapterID(), + ServiceCategory: port.ServiceCategory(), + BackendFamily: port.BackendFamily(), + Passed: true, + VerifiedChecks: []string{ + "health_report_schema", + "provision_receipt_commitment", + "idempotent_replay_determinism", + "idempotency_conflict_rejection", + "tenant_and_project_isolation", + "lifecycle_state_transitions", + "context_cancellation_propagation", + }, + }, nil +} + +func stateForVerb(verb LifecycleVerb) ResourceState { + switch verb { + case VerbHold, VerbSuspend: + return StateSuspended + case VerbDeprovision: + return StateDeleted + default: + return StateReady + } +} + +func checkContext(ctx context.Context) error { + if ctx == nil { + return ErrContextCanceled + } + if err := ctx.Err(); err != nil { + return ErrContextCanceled + } + return nil +} + +func validIdentifier(value string) bool { + return identifierPattern.MatchString(value) && !strings.Contains(value, "..") +} diff --git a/pkg/providerports/ports_test.go b/pkg/providerports/ports_test.go new file mode 100644 index 0000000..388e881 --- /dev/null +++ b/pkg/providerports/ports_test.go @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package providerports + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestAllCategoriesAndValidators(t *testing.T) { + categories := AllServiceCategories() + if len(categories) != 9 { + t.Fatalf("expected 9 service categories, got %d", len(categories)) + } + for _, cat := range categories { + if !ValidServiceCategory(cat) { + t.Fatalf("expected category %q to be valid", cat) + } + } + if ValidServiceCategory("unknown-service") { + t.Fatal("expected unknown category to be invalid") + } + if !ValidBackendFamily(BackendKubeVirt) || !ValidBackendFamily(BackendOpenStack) || + !ValidBackendFamily(BackendProxmox) || !ValidBackendFamily(BackendBareMetal) || + ValidBackendFamily("hyperv") { + t.Fatal("unexpected BackendFamily validation result") + } + if !ValidLifecycleVerb(VerbProvision) || !ValidLifecycleVerb(VerbDeprovision) || ValidLifecycleVerb("reboot") { + t.Fatal("unexpected LifecycleVerb validation result") + } + if !ValidResourceState(StateReady) || !ValidResourceState(StateBlocked) || ValidResourceState("bogus") { + t.Fatal("unexpected ResourceState validation result") + } + if !ValidHealthStatus(HealthReady) || !ValidHealthStatus(HealthDegraded) || !ValidHealthStatus(HealthUnavailable) || ValidHealthStatus("unknown") { + t.Fatal("unexpected HealthStatus validation result") + } +} + +func TestProvisionRequestAndReceiptValidation(t *testing.T) { + req, err := NewProvisionRequest( + ServiceCompute, + "adapter-kubevirt-01", + "tenant-alpha", + "project-main", + "vm-instance-01", + "idem-key-001", + VerbProvision, + map[string]string{ + "zone": "eu-central-1a", + "cores": "4", + }, + ) + if err != nil { + t.Fatalf("unexpected NewProvisionRequest error: %v", err) + } + if len(req.Parameters) != 2 || req.Parameters[0].Key != "cores" || req.Parameters[1].Key != "zone" { + t.Fatalf("expected sorted parameters, got %+v", req.Parameters) + } + + // Invalid request cases + if _, err := NewProvisionRequest("invalid", "adapter-01", "tenant-01", "project-01", "res-01", "idem-01", VerbProvision, nil); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest, got %v", err) + } + + unsortedReq := req + unsortedReq.Parameters = []Parameter{{Key: "z", Value: "1"}, {Key: "a", Value: "2"}} + if err := ValidateProvisionRequest(unsortedReq); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest for unsorted parameters, got %v", err) + } + + emptyValReq := req + emptyValReq.Parameters = []Parameter{{Key: "valid_key", Value: " "}} + if err := ValidateProvisionRequest(emptyValReq); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest for whitespace value, got %v", err) + } + + tooManyParams := make(map[string]string, MaxParametersCount+1) + for i := range MaxParametersCount + 1 { + tooManyParams["k"+string(rune('a'+i/26))+string(rune('a'+i%26))] = "val" + } + if _, err := NewProvisionRequest(ServiceCompute, "adapter-01", "tenant-01", "project-01", "res-01", "idem-01", VerbProvision, tooManyParams); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest for >MaxParametersCount, got %v", err) + } + + if _, err := RequestSHA256(unsortedReq); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected RequestSHA256 error on invalid request, got %v", err) + } + if _, err := ReceiptCommitmentSHA256(unsortedReq, StateReady, "handle-01"); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ReceiptCommitmentSHA256 error on invalid request, got %v", err) + } + if _, err := ReceiptCommitmentSHA256(req, "invalid-state", "handle-01"); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt on invalid state, got %v", err) + } + if _, err := ReceiptCommitmentSHA256(req, StateReady, "bad..handle"); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt on invalid handle, got %v", err) + } + + // Receipt construction & validation + if _, err := NewProvisionReceipt(unsortedReq, "op-001", StateReady, "handle-01", ""); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt when request is invalid, got %v", err) + } + if _, err := NewProvisionReceipt(req, "op-001", "bad-state", "handle-01", ""); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt when commitment fails, got %v", err) + } + if _, err := NewProvisionReceipt(req, "op-001", StateReady, "handle-01", "unexpected-blocker"); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt when ready state has blocker, got %v", err) + } + if _, err := NewProvisionReceipt(req, "op-001", StateBlocked, "handle-01", ""); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt when blocked state misses blockerCode, got %v", err) + } + blockedReceipt, err := NewProvisionReceipt(req, "op-001", StateBlocked, "handle-01", "quota-exceeded") + if err != nil { + t.Fatalf("expected valid blocked receipt, got %v", err) + } + if err := ValidateProvisionReceipt(unsortedReq, blockedReceipt); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt when validating against invalid request, got %v", err) + } + tampered := blockedReceipt + tampered.TenantID = "tenant-other" + if err := ValidateProvisionReceipt(req, tampered); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt on tampered tenant, got %v", err) + } + tamperedCommitment := blockedReceipt + tamperedCommitment.CommitmentSHA256 = strings.Repeat("a", 64) + if err := ValidateProvisionReceipt(req, tamperedCommitment); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt on forged 64-hex CommitmentSHA256, got %v", err) + } + tamperedHandle := blockedReceipt + tamperedHandle.ProviderHandle = "bad..handle" + if err := ValidateProvisionReceipt(req, tamperedHandle); !errors.Is(err, ErrInvalidReceipt) { + t.Fatalf("expected ErrInvalidReceipt on invalid ProviderHandle, got %v", err) + } +} + +func TestProviderRegistryAndAllNineCategoryInterfaces(t *testing.T) { + registry := NewProviderRegistry() + if err := registry.Register(nil); !errors.Is(err, ErrInvalidAdapterDescriptor) { + t.Fatalf("expected ErrInvalidAdapterDescriptor on nil port, got %v", err) + } + if _, err := NewInMemoryAdapter("bad..id", ServiceCompute, BackendKubeVirt); !errors.Is(err, ErrInvalidAdapterDescriptor) { + t.Fatalf("expected ErrInvalidAdapterDescriptor on bad adapterID, got %v", err) + } + + backends := []BackendFamily{BackendKubeVirt, BackendOpenStack, BackendProxmox, BackendBareMetal} + for i, cat := range AllServiceCategories() { + family := backends[i%len(backends)] + adapterID := "adapter-" + string(cat) + adapter, err := NewInMemoryAdapter(adapterID, cat, family) + if err != nil { + t.Fatalf("NewInMemoryAdapter(%s): %v", cat, err) + } + if err := registry.Register(adapter); err != nil { + t.Fatalf("Register(%s): %v", cat, err) + } + if err := registry.Register(adapter); !errors.Is(err, ErrAdapterAlreadyRegistered) { + t.Fatalf("expected ErrAdapterAlreadyRegistered for %s, got %v", cat, err) + } + + res, err := RunConformanceSuite(adapter) + if err != nil || !res.Passed || len(res.VerifiedChecks) != 7 { + t.Fatalf("RunConformanceSuite(%s) failed: res=%+v err=%v", cat, res, err) + } + } + + // Add a second compute adapter to exercise same-category tie-breaking in List() + extraCompute, err := NewInMemoryAdapter("adapter-compute-zzz", ServiceCompute, BackendOpenStack) + if err != nil { + t.Fatalf("NewInMemoryAdapter: %v", err) + } + if err := registry.Register(extraCompute); err != nil { + t.Fatalf("Register extraCompute: %v", err) + } + + listed := registry.List() + if len(listed) != 10 { + t.Fatalf("expected 10 registered adapters, got %d", len(listed)) + } + for i := 1; i < len(listed); i++ { + prev, curr := listed[i-1], listed[i] + if prev.ServiceCategory > curr.ServiceCategory || + (prev.ServiceCategory == curr.ServiceCategory && prev.AdapterID >= curr.AdapterID) { + t.Fatalf("registry List() not sorted at index %d: %+v vs %+v", i, prev, curr) + } + } + + if _, err := registry.Lookup("invalid-cat", "adapter-compute"); !errors.Is(err, ErrInvalidAdapterDescriptor) { + t.Fatalf("expected ErrInvalidAdapterDescriptor, got %v", err) + } + if _, err := registry.Lookup(ServiceCompute, "adapter-missing"); !errors.Is(err, ErrAdapterNotFound) { + t.Fatalf("expected ErrAdapterNotFound, got %v", err) + } + + // Verify all 9 specialized category methods and lifecycle verbs + ctx := context.Background() + verifyCategoryCall := func(cat ServiceCategory, invoke func(a *InMemoryAdapter, req ProvisionRequest) (ProvisionReceipt, error)) { + port, err := registry.Lookup(cat, "adapter-"+string(cat)) + if err != nil { + t.Fatalf("Lookup(%s): %v", cat, err) + } + mem := port.(*InMemoryAdapter) + req, err := NewProvisionRequest(cat, mem.AdapterID(), "tenant-x", "proj-x", "res-cat-01", "idem-cat-01", VerbProvision, nil) + if err != nil { + t.Fatalf("NewProvisionRequest(%s): %v", cat, err) + } + receipt, err := invoke(mem, req) + if err != nil || receipt.State != StateReady { + t.Fatalf("category invoke(%s) failed: receipt=%+v err=%v", cat, receipt, err) + } + } + + verifyCategoryCall(ServiceCompute, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p ComputeProvider = a + return p.ProvisionInstance(ctx, r) + }) + verifyCategoryCall(ServiceVolume, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p VolumeProvider = a + return p.ProvisionVolume(ctx, r) + }) + verifyCategoryCall(ServiceNetwork, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p NetworkProvider = a + return p.ProvisionNetwork(ctx, r) + }) + verifyCategoryCall(ServiceKubernetes, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p KubernetesClusterProvider = a + return p.ProvisionCluster(ctx, r) + }) + verifyCategoryCall(ServiceObjectStorage, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p ObjectStorageProvider = a + return p.ProvisionBucket(ctx, r) + }) + verifyCategoryCall(ServiceBackup, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p BackupProvider = a + return p.ProvisionBackupPlan(ctx, r) + }) + verifyCategoryCall(ServiceAccessControl, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p AccessControlProvider = a + return p.ProvisionAccessPolicy(ctx, r) + }) + verifyCategoryCall(ServiceSupport, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p SupportProvider = a + return p.ProvisionSupportSubscription(ctx, r) + }) + verifyCategoryCall(ServiceExternalIntegration, func(a *InMemoryAdapter, r ProvisionRequest) (ProvisionReceipt, error) { + var p ExternalIntegrationProvider = a + return p.ProvisionExternalConnector(ctx, r) + }) +} + +func TestInMemoryAdapterLifecycleAndEdgeCases(t *testing.T) { + adapter, err := NewInMemoryAdapter("adapter-compute", ServiceCompute, BackendKubeVirt) + if err != nil { + t.Fatalf("NewInMemoryAdapter: %v", err) + } + ctx := context.Background() + + // Health transitions + if err := adapter.SetHealth(HealthDegraded, ""); !errors.Is(err, ErrInvalidHealthStatus) { + t.Fatalf("expected ErrInvalidHealthStatus when degraded has no reason, got %v", err) + } + if err := adapter.SetHealth(HealthDegraded, "storage-latency-elevated"); err != nil { + t.Fatalf("SetHealth failed: %v", err) + } + // Context nil check + //nolint:staticcheck // intentionally testing nil context guard + if _, err := adapter.Health(nil); !errors.Is(err, ErrContextCanceled) { + t.Fatalf("expected ErrContextCanceled on nil context, got %v", err) + } + + // Non-existent resource for non-provision verb + resizeMissing, _ := NewProvisionRequest(ServiceCompute, "adapter-compute", "tenant-a", "proj-a", "missing-vm", "idem-1", VerbResize, nil) + if _, err := adapter.ExecuteLifecycle(ctx, resizeMissing); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("expected ErrResourceNotFound for resize on missing resource, got %v", err) + } + + // Invalid request to ExecuteLifecycle + if _, err := adapter.ExecuteLifecycle(ctx, ProvisionRequest{}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest, got %v", err) + } + + // Mismatched adapterID in request + wrongAdapterReq, _ := NewProvisionRequest(ServiceCompute, "other-adapter", "tenant-a", "proj-a", "vm-01", "idem-1", VerbProvision, nil) + if _, err := adapter.ExecuteLifecycle(ctx, wrongAdapterReq); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest for wrong adapterID, got %v", err) + } + + // Provision -> Suspend -> Resume -> Deprovision + provReq, _ := NewProvisionRequest(ServiceCompute, "adapter-compute", "tenant-a", "proj-a", "vm-01", "idem-prov", VerbProvision, nil) + if _, err := adapter.ExecuteLifecycle(ctx, provReq); err != nil { + t.Fatalf("Provision failed: %v", err) + } + suspReq, _ := NewProvisionRequest(ServiceCompute, "adapter-compute", "tenant-a", "proj-a", "vm-01", "idem-susp", VerbSuspend, nil) + suspRec, err := adapter.ExecuteLifecycle(ctx, suspReq) + if err != nil || suspRec.State != StateSuspended { + t.Fatalf("Suspend failed: %+v %v", suspRec, err) + } + deprovReq, _ := NewProvisionRequest(ServiceCompute, "adapter-compute", "tenant-a", "proj-a", "vm-01", "idem-deprov", VerbDeprovision, nil) + deprovRec, err := adapter.ExecuteLifecycle(ctx, deprovReq) + if err != nil || deprovRec.State != StateDeleted { + t.Fatalf("Deprovision failed: %+v %v", deprovRec, err) + } + + // InspectResource edge cases + //nolint:staticcheck // intentionally testing nil context guard + if _, err := adapter.InspectResource(nil, "tenant-a", "proj-a", "vm-01"); !errors.Is(err, ErrContextCanceled) { + t.Fatalf("expected ErrContextCanceled on InspectResource with nil ctx, got %v", err) + } + if _, err := adapter.InspectResource(ctx, "bad..id", "proj-a", "vm-01"); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest on bad identifier, got %v", err) + } + if _, err := adapter.InspectResource(ctx, "tenant-a", "proj-a", "unknown-vm"); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("expected ErrResourceNotFound on unknown resource, got %v", err) + } + inspected, err := adapter.InspectResource(ctx, "tenant-a", "proj-a", "vm-01") + if err != nil || inspected.State != StateDeleted { + t.Fatalf("InspectResource unexpected result: %+v %v", inspected, err) + } + + // Max-length 64-character resourceID succeeds with fixed-length digest operation ID and handle + longResourceID := "res-" + strings.Repeat("a", 60) + longReq, err := NewProvisionRequest(ServiceCompute, "adapter-compute", "tenant-a", "proj-a", longResourceID, "idem-long", VerbProvision, nil) + if err != nil { + t.Fatalf("unexpected NewProvisionRequest error: %v", err) + } + longRec, err := adapter.ExecuteLifecycle(ctx, longReq) + if err != nil || ValidateProvisionReceipt(longReq, longRec) != nil { + t.Fatalf("expected 64-char resourceID to succeed with valid receipt, got %+v err=%v", longRec, err) + } +} + +type faultyPort struct { + *InMemoryAdapter + mode string + calls int +} + +func (f *faultyPort) Health(ctx context.Context) (HealthReport, error) { + switch f.mode { + case "health-err": + return HealthReport{}, errors.New("simulated health error") + case "health-invalid": + return HealthReport{AdapterID: "bad..id"}, nil + default: + return f.InMemoryAdapter.Health(ctx) + } +} + +func (f *faultyPort) ExecuteLifecycle(ctx context.Context, req ProvisionRequest) (ProvisionReceipt, error) { + f.calls++ + switch f.mode { + case "exec-err": + return ProvisionReceipt{}, errors.New("simulated exec error") + case "exec-bad-receipt": + return ProvisionReceipt{SchemaVersion: "bad"}, nil + case "replay-err": + if f.calls == 2 { + return ProvisionReceipt{}, errors.New("simulated replay error") + } + return f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + case "non-deterministic-replay": + rec, err := f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + if f.calls == 2 { + rec.OperationID = "op-mutated-on-replay" + } + return rec, err + case "ignore-conflict": + rec, _ := NewProvisionReceipt(req, "op-conflict-ignored", StateReady, "handle-ok", "") + return rec, nil + case "allow-cross-tenant-mutate": + if req.TenantID == "tenant-bravo" { + rec, _ := NewProvisionReceipt(req, "op-leaked", StateDeleted, "handle-ok", "") + return rec, nil + } + return f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + case "transition-err": + if req.Verb == VerbResize { + return ProvisionReceipt{}, errors.New("resize unsupported") + } + return f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + case "transition-wrong-state": + if req.Verb == VerbSuspend { + rec, _ := NewProvisionReceipt(req, "op-wrong-state", StateReady, "handle-ok", "") + return rec, nil + } + return f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + case "ignore-context-cancel": + return f.InMemoryAdapter.ExecuteLifecycle(context.Background(), req) + default: + return f.InMemoryAdapter.ExecuteLifecycle(ctx, req) + } +} + +func (f *faultyPort) InspectResource(ctx context.Context, tenantID, projectID, resourceID string) (ProvisionReceipt, error) { + if f.mode == "allow-cross-tenant-inspect" { + return ProvisionReceipt{}, nil + } + return f.InMemoryAdapter.InspectResource(ctx, tenantID, projectID, resourceID) +} + +func TestRunConformanceSuiteFailureModes(t *testing.T) { + if _, err := RunConformanceSuite(nil); !errors.Is(err, ErrInvalidAdapterDescriptor) { + t.Fatalf("expected ErrInvalidAdapterDescriptor on nil port, got %v", err) + } + + badIDBase, _ := NewInMemoryAdapter("adapter-ok", ServiceCompute, BackendKubeVirt) + badIDBase.adapterID = "bad..id" + if _, err := RunConformanceSuite(badIDBase); !errors.Is(err, ErrInvalidHealthStatus) { + t.Fatalf("expected ErrInvalidHealthStatus on bad adapter ID in health, got %v", err) + } + + modes := []string{ + "health-err", + "health-invalid", + "exec-err", + "exec-bad-receipt", + "replay-err", + "non-deterministic-replay", + "ignore-conflict", + "allow-cross-tenant-inspect", + "allow-cross-tenant-mutate", + "transition-err", + "transition-wrong-state", + "ignore-context-cancel", + } + for _, mode := range modes { + base, _ := NewInMemoryAdapter("adapter-compute", ServiceCompute, BackendKubeVirt) + fp := &faultyPort{InMemoryAdapter: base, mode: mode} + if _, err := RunConformanceSuite(fp); err == nil { + t.Fatalf("expected RunConformanceSuite to fail for mode %q", mode) + } + } + + // Also test when NewProvisionRequest inside RunConformanceSuite fails (health passes via custom override) + customPort := &customHealthBadIDPort{InMemoryAdapter: badIDBase} + if _, err := RunConformanceSuite(customPort); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest when port.AdapterID() is invalid, got %v", err) + } +} + +type customHealthBadIDPort struct { + *InMemoryAdapter +} + +func (c *customHealthBadIDPort) Health(_ context.Context) (HealthReport, error) { + return HealthReport{ + AdapterID: "adapter-valid", + ServiceCategory: ServiceCompute, + BackendFamily: BackendKubeVirt, + Status: HealthReady, + }, nil +} diff --git a/sdk/rust/ocs-sdk-rs/Cargo.toml b/sdk/rust/ocs-sdk-rs/Cargo.toml new file mode 100644 index 0000000..9a8b526 --- /dev/null +++ b/sdk/rust/ocs-sdk-rs/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) Iurii Trukhin. + +[package] +name = "ocs-sdk-rs" +version = "0.3.0" +edition = "2021" +rust-version = "1.75" +license = "Apache-2.0" +description = "Zero-dependency Rust SDK for the CloudRING Open Cloud Standard (OCS v3)" +repository = "https://github.com/opencloudtech/CloudRING" +publish = false + +[lib] +name = "ocs_sdk_rs" +path = "src/lib.rs" + +[dependencies] diff --git a/sdk/rust/ocs-sdk-rs/README.md b/sdk/rust/ocs-sdk-rs/README.md new file mode 100644 index 0000000..be836d1 --- /dev/null +++ b/sdk/rust/ocs-sdk-rs/README.md @@ -0,0 +1,19 @@ +# CloudRING Open Cloud Standard (OCS v3) Rust SDK (`ocs-sdk-rs`) + +`ocs-sdk-rs` provides a self-contained, zero-dependency Rust implementation of the CloudRING Open Cloud Standard (`ocsv3.cloudring.io/v1alpha1`) domain types, state machines, idempotency guards, quota accounting, and conformance verification primitives. + +## Design Properties + +- **Zero external dependencies**: Built exclusively on the Rust standard library (`std`) for deterministic offline builds and zero transitive supply-chain advisories. +- **Memory safety enforced**: Compiles with `#![forbid(unsafe_code)]`. +- **Complete 9-service taxonomy**: Covers `compute`, `volume`, `network`, `kubernetes`, `object-storage`, `backup`, `access-control`, `support`, and `external-integration`. +- **Deterministic state & idempotency validation**: Enforces canonical identifier formatting, URN serialization, `Pending -> Running -> Succeeded/Failed/Cancelled` operation state transitions, and idempotency key payload binding. +- **Explicit non-claims**: Conformance verification in this SDK validates structural contracts only and does not claim live production readiness. + +## Verification + +Run the unit test suite with a disposable target directory: + +```bash +cargo test --manifest-path sdk/rust/ocs-sdk-rs/Cargo.toml --target-dir /tmp/ocs-sdk-rs-target +``` diff --git a/sdk/rust/ocs-sdk-rs/src/lib.rs b/sdk/rust/ocs-sdk-rs/src/lib.rs new file mode 100644 index 0000000..f1e2d95 --- /dev/null +++ b/sdk/rust/ocs-sdk-rs/src/lib.rs @@ -0,0 +1,1050 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +#![forbid(unsafe_code)] + +//! Official zero-dependency Rust SDK for the CloudRING Open Cloud Standard (OCS v3). +//! +//! Provides strongly typed representations for the nine CloudRING product categories, +//! canonical resource identities, operation state machines, idempotency verification, +//! quota accounting, conformance reporting, and deterministic request construction. + +use std::collections::BTreeMap; +use std::fmt; + +/// Canonical API version for Open Cloud Standard v3 packages and operations. +pub const API_VERSION: &str = "ocsv3.cloudring.io/v1alpha1"; + +/// Maximum length of a canonical OCS identifier. +pub const MAX_IDENTIFIER_LEN: usize = 64; + +/// Minimum length of a canonical OCS identifier. +pub const MIN_IDENTIFIER_LEN: usize = 3; + +/// Errors returned by OCS v3 validation, state transitions, and request builders. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OcsError { + InvalidIdentifier(String), + InvalidServiceCategory(String), + InvalidStateTransition { + from: OperationState, + to: OperationState, + }, + IdempotencyConflict { + idempotency_key: String, + }, + QuotaExceeded { + metric: String, + requested: u64, + available: u64, + }, + InvalidQuotaDefinition(String), + InvalidTarget(String), + MissingField(&'static str), +} + +impl fmt::Display for OcsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIdentifier(id) => write!(f, "invalid OCS canonical identifier: {id}"), + Self::InvalidServiceCategory(cat) => write!(f, "unknown OCS service category: {cat}"), + Self::InvalidStateTransition { from, to } => { + write!( + f, + "invalid operation state transition from {} to {}", + from.as_str(), + to.as_str() + ) + } + Self::IdempotencyConflict { idempotency_key } => { + write!( + f, + "idempotency key reused with conflicting operation envelope: {idempotency_key}" + ) + } + Self::QuotaExceeded { + metric, + requested, + available, + } => write!( + f, + "quota exceeded for {metric}: requested {requested}, available {available}" + ), + Self::InvalidQuotaDefinition(msg) => write!(f, "invalid quota definition: {msg}"), + Self::InvalidTarget(msg) => write!(f, "invalid OCS client target: {msg}"), + Self::MissingField(field) => write!(f, "missing required field: {field}"), + } + } +} + +impl std::error::Error for OcsError {} + +/// Validates that `value` is a canonical lowercase ASCII identifier (`3..=64` chars, +/// alphanumeric start/end, containing only `[a-z0-9._-]`, and no `..` sequence). +pub fn validate_canonical_id(value: &str) -> Result<(), OcsError> { + let bytes = value.as_bytes(); + if bytes.len() < MIN_IDENTIFIER_LEN || bytes.len() > MAX_IDENTIFIER_LEN { + return Err(OcsError::InvalidIdentifier(value.to_string())); + } + if value.contains("..") { + return Err(OcsError::InvalidIdentifier(value.to_string())); + } + let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + if !is_alnum(bytes[0]) || !is_alnum(bytes[bytes.len() - 1]) { + return Err(OcsError::InvalidIdentifier(value.to_string())); + } + for &b in bytes { + if !(is_alnum(b) || b == b'-' || b == b'_' || b == b'.') { + return Err(OcsError::InvalidIdentifier(value.to_string())); + } + } + Ok(()) +} + +/// The nine canonical product service categories defined by CloudRING / OCS v3. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ServiceCategory { + Compute, + Volume, + Network, + Kubernetes, + ObjectStorage, + Backup, + AccessControl, + Support, + ExternalIntegration, +} + +impl ServiceCategory { + /// Returns all nine canonical OCS v3 service categories in deterministic order. + pub const fn all() -> [ServiceCategory; 9] { + [ + Self::Compute, + Self::Volume, + Self::Network, + Self::Kubernetes, + Self::ObjectStorage, + Self::Backup, + Self::AccessControl, + Self::Support, + Self::ExternalIntegration, + ] + } + + /// Returns the canonical wire slug for this service category. + pub const fn as_str(&self) -> &'static str { + match self { + Self::Compute => "compute", + Self::Volume => "volume", + Self::Network => "network", + Self::Kubernetes => "kubernetes", + Self::ObjectStorage => "object-storage", + Self::Backup => "backup", + Self::AccessControl => "access-control", + Self::Support => "support", + Self::ExternalIntegration => "external-integration", + } + } + + /// Parses a canonical service category slug. + pub fn parse(slug: &str) -> Result { + match slug { + "compute" => Ok(Self::Compute), + "volume" => Ok(Self::Volume), + "network" => Ok(Self::Network), + "kubernetes" => Ok(Self::Kubernetes), + "object-storage" => Ok(Self::ObjectStorage), + "backup" => Ok(Self::Backup), + "access-control" => Ok(Self::AccessControl), + "support" => Ok(Self::Support), + "external-integration" => Ok(Self::ExternalIntegration), + other => Err(OcsError::InvalidServiceCategory(other.to_string())), + } + } +} + +/// Tenant-scoped canonical resource identity in OCS v3. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ResourceIdentity { + tenant_id: String, + project_id: String, + service_category: ServiceCategory, + resource_id: String, +} + +impl ResourceIdentity { + /// Constructs a validated `ResourceIdentity`. + pub fn new( + tenant_id: impl Into, + project_id: impl Into, + service_category: ServiceCategory, + resource_id: impl Into, + ) -> Result { + let tenant_id = tenant_id.into(); + let project_id = project_id.into(); + let resource_id = resource_id.into(); + validate_canonical_id(&tenant_id)?; + validate_canonical_id(&project_id)?; + validate_canonical_id(&resource_id)?; + Ok(Self { + tenant_id, + project_id, + service_category, + resource_id, + }) + } + + pub fn tenant_id(&self) -> &str { + &self.tenant_id + } + + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn service_category(&self) -> ServiceCategory { + self.service_category + } + + pub fn resource_id(&self) -> &str { + &self.resource_id + } + + /// Formats the canonical OCS v3 URN: + /// `urn:ocs:v3::::`. + pub fn to_urn(&self) -> String { + format!( + "urn:ocs:v3:{}:{}:{}:{}", + self.tenant_id, + self.project_id, + self.service_category.as_str(), + self.resource_id + ) + } + + /// Parses and validates a canonical OCS v3 URN. + pub fn parse_urn(urn: &str) -> Result { + let parts: Vec<&str> = urn.split(':').collect(); + if parts.len() != 7 || parts[0] != "urn" || parts[1] != "ocs" || parts[2] != "v3" { + return Err(OcsError::InvalidIdentifier(urn.to_string())); + } + let category = ServiceCategory::parse(parts[5])?; + Self::new(parts[3], parts[4], category, parts[6]) + } +} + +/// Lifecycle state of an asynchronous or synchronous OCS v3 operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OperationState { + Pending, + Running, + Succeeded, + Failed, + Cancelled, +} + +impl OperationState { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } + + /// Returns true if the state is terminal (`Succeeded`, `Failed`, or `Cancelled`). + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } + + /// Checks whether transitioning from `self` to `next` is permitted by the OCS v3 state machine: + /// `Pending -> Running | Cancelled` and `Running -> Succeeded | Failed | Cancelled`. + /// Idempotent self-transitions (`state -> state`) are also permitted. + pub const fn can_transition_to(&self, next: OperationState) -> bool { + match (*self, next) { + (a, b) if a as u8 == b as u8 => true, + (Self::Pending, Self::Running | Self::Cancelled) => true, + (Self::Running, Self::Succeeded | Self::Failed | Self::Cancelled) => true, + _ => false, + } + } + + /// Validates and returns the next `OperationState`. + pub fn transition_to(&self, next: OperationState) -> Result { + if self.can_transition_to(next) { + Ok(next) + } else { + Err(OcsError::InvalidStateTransition { + from: *self, + to: next, + }) + } + } +} + +/// Canonical operation envelope tracking execution and idempotency across provider boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationEnvelope { + api_version: String, + operation_id: String, + idempotency_key: String, + action: String, + resource: ResourceIdentity, + state: OperationState, + parameters: BTreeMap, +} + +impl OperationEnvelope { + /// Creates a new `OperationEnvelope` in `OperationState::Pending`. + pub fn new( + operation_id: impl Into, + idempotency_key: impl Into, + action: impl Into, + resource: ResourceIdentity, + parameters: BTreeMap, + ) -> Result { + let operation_id = operation_id.into(); + let idempotency_key = idempotency_key.into(); + let action = action.into(); + validate_canonical_id(&operation_id)?; + validate_canonical_id(&idempotency_key)?; + validate_canonical_id(&action)?; + for (k, v) in ¶meters { + validate_canonical_id(k)?; + if v.trim().is_empty() || v.len() > 1024 || v.contains('\0') { + return Err(OcsError::InvalidIdentifier(k.clone())); + } + } + Ok(Self { + api_version: API_VERSION.to_string(), + operation_id, + idempotency_key, + action, + resource, + state: OperationState::Pending, + parameters, + }) + } + + pub fn api_version(&self) -> &str { + &self.api_version + } + + pub fn operation_id(&self) -> &str { + &self.operation_id + } + + pub fn idempotency_key(&self) -> &str { + &self.idempotency_key + } + + pub fn action(&self) -> &str { + &self.action + } + + pub fn resource(&self) -> &ResourceIdentity { + &self.resource + } + + pub fn state(&self) -> OperationState { + self.state + } + + pub fn parameters(&self) -> &BTreeMap { + &self.parameters + } + + /// Advances the operation envelope to `next_state` if allowed by the state machine. + pub fn advance_state(&mut self, next_state: OperationState) -> Result { + let next = self.state.transition_to(next_state)?; + self.state = next; + Ok(next) + } + + /// Verifies idempotency between `self` (a previously recorded operation) and `candidate`. + /// If both share the same `idempotency_key`, they must target the exact same resource, + /// action, and parameter map; otherwise `OcsError::IdempotencyConflict` is returned. + pub fn verify_idempotent_replay(&self, candidate: &OperationEnvelope) -> Result { + if self.idempotency_key != candidate.idempotency_key { + return Ok(false); + } + if self.resource != candidate.resource + || self.action != candidate.action + || self.parameters != candidate.parameters + { + return Err(OcsError::IdempotencyConflict { + idempotency_key: self.idempotency_key.clone(), + }); + } + Ok(true) + } + + /// Produces an unambiguous, byte-length-prefixed canonical wire representation + /// suitable for deterministic signing or hashing even when parameter values contain + /// delimiter characters (`;`, `=`, `:`). + pub fn canonical_wire_string(&self) -> String { + let urn = self.resource.to_urn(); + let state = self.state.as_str(); + let mut out = format!( + "v={}:{};op={}:{};idem={}:{};act={}:{};urn={}:{};st={}:{};pc={}", + self.api_version.len(), + self.api_version, + self.operation_id.len(), + self.operation_id, + self.idempotency_key.len(), + self.idempotency_key, + self.action.len(), + self.action, + urn.len(), + urn, + state.len(), + state, + self.parameters.len() + ); + for (k, v) in &self.parameters { + out.push_str(&format!(";p.{}:{}={}:{}", k.len(), k, v.len(), v)); + } + out + } +} + +/// Quota limit and usage tracker for a single metered dimension in OCS v3. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuotaLimit { + metric: String, + unit: String, + limit: u64, + used: u64, +} + +impl QuotaLimit { + pub fn new( + metric: impl Into, + unit: impl Into, + limit: u64, + used: u64, + ) -> Result { + let metric = metric.into(); + let unit = unit.into(); + validate_canonical_id(&metric)?; + validate_canonical_id(&unit)?; + if used > limit { + return Err(OcsError::InvalidQuotaDefinition(format!( + "used ({used}) exceeds limit ({limit}) for {metric}" + ))); + } + Ok(Self { + metric, + unit, + limit, + used, + }) + } + + pub fn metric(&self) -> &str { + &self.metric + } + + pub fn unit(&self) -> &str { + &self.unit + } + + pub fn limit(&self) -> u64 { + self.limit + } + + pub fn used(&self) -> u64 { + self.used + } + + pub fn remaining(&self) -> u64 { + self.limit.saturating_sub(self.used) + } + + pub fn can_allocate(&self, amount: u64) -> bool { + amount <= self.remaining() + } + + pub fn allocate(&mut self, amount: u64) -> Result { + let available = self.remaining(); + if amount > available { + return Err(OcsError::QuotaExceeded { + metric: self.metric.clone(), + requested: amount, + available, + }); + } + self.used += amount; + Ok(self.used) + } +} + +/// A single structural check inside an OCS v3 `ConformanceReport`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConformanceCheck { + pub surface: String, + pub passed: bool, + pub detail: String, +} + +/// Provider-neutral OCS v3 conformance evaluation report. +/// Does not claim live production readiness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConformanceReport { + pub api_version: String, + pub package_name: String, + pub package_version: String, + pub category: ServiceCategory, + pub provider_neutral: bool, + pub production_mutate: bool, + pub checks: Vec, + pub non_claims: Vec, +} + +impl ConformanceReport { + /// Evaluates a connector's structural metadata and returns a `ConformanceReport`. + pub fn evaluate( + package_name: &str, + package_version: &str, + category: ServiceCategory, + supported_actions: &[&str], + quotas: &[QuotaLimit], + ) -> Self { + let mut checks = Vec::new(); + let pkg_ok = validate_canonical_id(package_name).is_ok(); + checks.push(ConformanceCheck { + surface: "package.name".to_string(), + passed: pkg_ok, + detail: if pkg_ok { + "canonical package identifier".to_string() + } else { + "invalid package identifier".to_string() + }, + }); + + let ver_ok = !package_version.trim().is_empty() && !package_version.contains(' '); + checks.push(ConformanceCheck { + surface: "package.version".to_string(), + passed: ver_ok, + detail: if ver_ok { + "version present".to_string() + } else { + "missing or malformed version".to_string() + }, + }); + + let baseline = ["provision", "resume", "resize", "deprovision"]; + let has_baseline = baseline + .iter() + .all(|req| supported_actions.iter().any(|act| act == req)); + let has_hold_or_suspend = supported_actions + .iter() + .any(|act| *act == "hold" || *act == "suspend"); + let lifecycle_ok = has_baseline && has_hold_or_suspend; + checks.push(ConformanceCheck { + surface: "service.lifecycle".to_string(), + passed: lifecycle_ok, + detail: if lifecycle_ok { + "universal lifecycle baseline declared".to_string() + } else { + "missing required lifecycle action".to_string() + }, + }); + + let quotas_ok = !quotas.is_empty(); + checks.push(ConformanceCheck { + surface: "service.quotas".to_string(), + passed: quotas_ok, + detail: if quotas_ok { + "at least one quota meter declared".to_string() + } else { + "no quota meters declared".to_string() + }, + }); + + Self { + api_version: API_VERSION.to_string(), + package_name: package_name.to_string(), + package_version: package_version.to_string(), + category, + provider_neutral: true, + production_mutate: false, + checks, + non_claims: vec![ + "conformance does not claim live production readiness".to_string(), + "conformance does not perform provider, DNS, Kubernetes, or billing mutation" + .to_string(), + ], + } + } + + /// Returns true if all conformance checks passed and `production_mutate` is false. + pub fn passed(&self) -> bool { + !self.production_mutate + && self.provider_neutral + && !self.checks.is_empty() + && self.checks.iter().all(|c| c.passed) + } +} + +/// Client configuration for interacting with an OCS v3 control plane endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OcsClientConfig { + endpoint: String, + user_agent: String, + timeout_millis: u64, +} + +impl OcsClientConfig { + /// Creates a validated `OcsClientConfig`. Requires an `https://` target with a + /// valid multi-label DNS authority, optional port (`1..=65535`), optional clean path, + /// no userinfo/query/fragment, and no private or local domain suffixes. + pub fn new(target: impl Into, timeout_millis: u64) -> Result { + let raw = target.into(); + let trimmed = raw.trim().trim_end_matches('/').to_string(); + if trimmed.bytes().any(|b| b.is_ascii_whitespace()) + || trimmed.contains('@') + || trimmed.contains('?') + || trimmed.contains('#') + || trimmed.contains('\\') + { + return Err(OcsError::InvalidTarget( + "OCS target must not contain userinfo, query, fragment, or whitespace".to_string(), + )); + } + let Some(rest) = trimmed.strip_prefix("https://") else { + return Err(OcsError::InvalidTarget( + "OCS target must use https://".to_string(), + )); + }; + let (authority, path_part) = match rest.split_once('/') { + Some((auth, path)) => (auth, Some(path)), + None => (rest, None), + }; + validate_dns_authority(authority)?; + if let Some(path) = path_part { + if path.is_empty() + || path.contains("//") + || !path.split('/').all(is_valid_path_segment) + { + return Err(OcsError::InvalidTarget( + "OCS target base path contains invalid segment".to_string(), + )); + } + } + if timeout_millis == 0 || timeout_millis > 300_000 { + return Err(OcsError::InvalidTarget( + "timeout_millis must be between 1 and 300000".to_string(), + )); + } + Ok(Self { + endpoint: trimmed, + user_agent: format!("ocs-sdk-rs/{}", env!("CARGO_PKG_VERSION")), + timeout_millis, + }) + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn user_agent(&self) -> &str { + &self.user_agent + } + + pub fn timeout_millis(&self) -> u64 { + self.timeout_millis + } +} + +fn validate_dns_authority(authority: &str) -> Result<(), OcsError> { + if authority.is_empty() || authority.contains('[') || authority.contains(']') { + return Err(OcsError::InvalidTarget( + "OCS target authority must be a DNS hostname".to_string(), + )); + } + let host = match authority.split_once(':') { + Some((h, port_str)) => { + if port_str.contains(':') { + return Err(OcsError::InvalidTarget("invalid authority port".to_string())); + } + let port: u16 = port_str + .parse() + .map_err(|_| OcsError::InvalidTarget("invalid authority port".to_string()))?; + if port == 0 { + return Err(OcsError::InvalidTarget( + "authority port must be non-zero".to_string(), + )); + } + h + } + None => authority, + }; + if host.is_empty() || host.len() > 253 { + return Err(OcsError::InvalidTarget( + "OCS target DNS hostname length is invalid".to_string(), + )); + } + let labels: Vec<&str> = host.split('.').collect(); + if labels.len() < 2 || !labels.iter().all(|label| is_valid_dns_label(label)) { + return Err(OcsError::InvalidTarget( + "OCS target must be a multi-label DNS hostname".to_string(), + )); + } + let tld = labels[labels.len() - 1]; + if tld.bytes().all(|b| b.is_ascii_digit()) || is_reserved_private_tld(tld) { + return Err(OcsError::InvalidTarget( + "OCS target must not use a numeric or private suffix".to_string(), + )); + } + Ok(()) +} + +fn is_valid_dns_label(label: &str) -> bool { + let bytes = label.as_bytes(); + if bytes.is_empty() || bytes.len() > 63 { + return false; + } + let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + if !is_alnum(bytes[0]) || !is_alnum(bytes[bytes.len() - 1]) { + return false; + } + bytes.iter().all(|&b| is_alnum(b) || b == b'-') +} + +fn is_reserved_private_tld(tld: &str) -> bool { + matches!( + tld, + "localhost" | "local" | "internal" | "corp" | "lan" | "home" | "intranet" | "localdomain" + ) +} + +fn is_valid_path_segment(segment: &str) -> bool { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') +} + +/// Immutable request constructed by `RequestBuilder`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OcsRequest { + pub url: String, + pub user_agent: String, + pub timeout_millis: u64, + pub envelope: OperationEnvelope, + pub wire_payload: String, +} + +/// Fluent builder for constructing validated `OcsRequest` values. +#[derive(Debug, Clone)] +pub struct RequestBuilder { + config: OcsClientConfig, + operation_id: Option, + idempotency_key: Option, + action: Option, + resource: Option, + parameters: BTreeMap, +} + +impl RequestBuilder { + pub fn new(config: OcsClientConfig) -> Self { + Self { + config, + operation_id: None, + idempotency_key: None, + action: None, + resource: None, + parameters: BTreeMap::new(), + } + } + + pub fn operation_id(mut self, id: impl Into) -> Self { + self.operation_id = Some(id.into()); + self + } + + pub fn idempotency_key(mut self, key: impl Into) -> Self { + self.idempotency_key = Some(key.into()); + self + } + + pub fn action(mut self, action: impl Into) -> Self { + self.action = Some(action.into()); + self + } + + pub fn resource(mut self, resource: ResourceIdentity) -> Self { + self.resource = Some(resource); + self + } + + pub fn param(mut self, key: impl Into, value: impl Into) -> Self { + self.parameters.insert(key.into(), value.into()); + self + } + + pub fn build(self) -> Result { + let operation_id = self + .operation_id + .ok_or(OcsError::MissingField("operation_id"))?; + let idempotency_key = self + .idempotency_key + .ok_or(OcsError::MissingField("idempotency_key"))?; + let action = self.action.ok_or(OcsError::MissingField("action"))?; + let resource = self.resource.ok_or(OcsError::MissingField("resource"))?; + + let envelope = OperationEnvelope::new( + operation_id, + idempotency_key, + action, + resource.clone(), + self.parameters, + )?; + let url = format!( + "{}/v3/tenants/{}/projects/{}/services/{}/resources/{}/operations", + self.config.endpoint(), + resource.tenant_id(), + resource.project_id(), + resource.service_category().as_str(), + resource.resource_id() + ); + let wire_payload = envelope.canonical_wire_string(); + Ok(OcsRequest { + url, + user_agent: self.config.user_agent().to_string(), + timeout_millis: self.config.timeout_millis(), + envelope, + wire_payload, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_categories_roundtrip_all_nine() { + let all = ServiceCategory::all(); + assert_eq!(all.len(), 9); + for category in all { + let slug = category.as_str(); + let parsed = ServiceCategory::parse(slug).expect("category should parse"); + assert_eq!(parsed, category); + } + assert_eq!( + ServiceCategory::parse("unknown"), + Err(OcsError::InvalidServiceCategory("unknown".to_string())) + ); + } + + #[test] + fn canonical_id_and_resource_identity_validation() { + assert!(validate_canonical_id("valid-id-01.a_b").is_ok()); + assert!(validate_canonical_id("ab").is_err()); + assert!(validate_canonical_id("UPPERCASE").is_err()); + assert!(validate_canonical_id("bad..dots").is_err()); + assert!(validate_canonical_id("-leading-dash").is_err()); + assert!(validate_canonical_id("trailing-dash-").is_err()); + + let identity = ResourceIdentity::new( + "tenant-alpha", + "proj-core", + ServiceCategory::Kubernetes, + "cluster-eu-01", + ) + .expect("valid identity"); + let urn = identity.to_urn(); + assert_eq!( + urn, + "urn:ocs:v3:tenant-alpha:proj-core:kubernetes:cluster-eu-01" + ); + let parsed = ResourceIdentity::parse_urn(&urn).expect("valid URN"); + assert_eq!(parsed, identity); + assert!(ResourceIdentity::parse_urn("urn:ocs:v2:a:b:compute:c").is_err()); + } + + #[test] + fn operation_state_machine_and_idempotency() { + let identity = ResourceIdentity::new( + "tenant-alpha", + "proj-core", + ServiceCategory::Compute, + "vm-node-01", + ) + .expect("valid identity"); + let mut params = BTreeMap::new(); + params.insert("shape".to_string(), "standard-4".to_string()); + + let mut env1 = OperationEnvelope::new( + "op-create-01", + "idem-key-01", + "provision", + identity.clone(), + params.clone(), + ) + .expect("envelope creation"); + assert_eq!(env1.state(), OperationState::Pending); + assert!(!env1.state().is_terminal()); + + // Invalid direct transition Pending -> Succeeded + assert_eq!( + env1.advance_state(OperationState::Succeeded), + Err(OcsError::InvalidStateTransition { + from: OperationState::Pending, + to: OperationState::Succeeded, + }) + ); + + // Valid Pending -> Running -> Succeeded + assert_eq!( + env1.advance_state(OperationState::Running), + Ok(OperationState::Running) + ); + assert_eq!( + env1.advance_state(OperationState::Succeeded), + Ok(OperationState::Succeeded) + ); + assert!(env1.state().is_terminal()); + + // Terminal state cannot transition to Running or Failed + assert!(env1.advance_state(OperationState::Running).is_err()); + assert!(env1.advance_state(OperationState::Failed).is_err()); + + // Idempotent replay check + let replay_same = OperationEnvelope::new( + "op-create-02", + "idem-key-01", + "provision", + identity.clone(), + params, + ) + .expect("replay envelope"); + assert_eq!(env1.verify_idempotent_replay(&replay_same), Ok(true)); + + let mut mutated_params = BTreeMap::new(); + mutated_params.insert("shape".to_string(), "highmem-16".to_string()); + let replay_conflict = OperationEnvelope::new( + "op-create-03", + "idem-key-01", + "provision", + identity, + mutated_params, + ) + .expect("conflict envelope"); + assert_eq!( + env1.verify_idempotent_replay(&replay_conflict), + Err(OcsError::IdempotencyConflict { + idempotency_key: "idem-key-01".to_string() + }) + ); + } + + #[test] + fn quota_limit_accounting() { + assert!(QuotaLimit::new("vcpu-cores", "cores", 10, 12).is_err()); + let mut quota = QuotaLimit::new("vcpu-cores", "cores", 32, 8).expect("valid quota"); + assert_eq!(quota.remaining(), 24); + assert!(quota.can_allocate(24)); + assert!(!quota.can_allocate(25)); + assert_eq!(quota.allocate(16), Ok(24)); + assert_eq!( + quota.allocate(9), + Err(OcsError::QuotaExceeded { + metric: "vcpu-cores".to_string(), + requested: 9, + available: 8, + }) + ); + } + + #[test] + fn conformance_report_evaluation() { + let quota = QuotaLimit::new("storage-gib", "gib", 1000, 0).expect("valid quota"); + let actions = ["provision", "hold", "resume", "resize", "deprovision"]; + let report = ConformanceReport::evaluate( + "cloudring-volume-ocs", + "0.3.0", + ServiceCategory::Volume, + &actions, + &[quota], + ); + assert!(report.passed()); + assert!(report.provider_neutral); + assert!(!report.production_mutate); + + let incomplete = ConformanceReport::evaluate( + "cloudring-volume-ocs", + "0.3.0", + ServiceCategory::Volume, + &["provision"], + &[], + ); + assert!(!incomplete.passed()); + } + + #[test] + fn client_config_and_request_builder() { + assert!(OcsClientConfig::new("http://ocs.cloudring.example", 5000).is_err()); + assert!(OcsClientConfig::new("https://localhost", 5000).is_err()); + let private_suffix_target = format!("https://service.{}", ["loc", "al"].concat()); + assert!(OcsClientConfig::new(private_suffix_target, 5000).is_err()); + assert!(OcsClientConfig::new("https://user@ocs.cloudring.example", 5000).is_err()); + assert!(OcsClientConfig::new("https://ocs.cloudring.example?x=y", 5000).is_err()); + assert!(OcsClientConfig::new("https://ocs.cloudring.example#frag", 5000).is_err()); + assert!(OcsClientConfig::new("https://ocs.cloudring.example:0", 5000).is_err()); + assert!(OcsClientConfig::new("https://ocs.cloudring.example", 0).is_err()); + + let config = OcsClientConfig::new("https://ocs.cloudring.example:8443/", 5000) + .expect("valid config"); + assert_eq!(config.endpoint(), "https://ocs.cloudring.example:8443"); + + let resource = ResourceIdentity::new( + "tenant-alpha", + "proj-core", + ServiceCategory::ObjectStorage, + "bucket-archive-01", + ) + .expect("resource"); + + let req = RequestBuilder::new(config.clone()) + .operation_id("op-bucket-01") + .idempotency_key("idem-bucket-01") + .action("provision") + .resource(resource.clone()) + .param("region", "eu-central-1") + .build() + .expect("request should build"); + + assert_eq!( + req.url, + "https://ocs.cloudring.example:8443/v3/tenants/tenant-alpha/projects/proj-core/services/object-storage/resources/bucket-archive-01/operations" + ); + assert!(req.wire_payload.contains(";p.6:region=12:eu-central-1")); + + // Verify injective length-prefixed parameter encoding prevents delimiter collisions + let req_injected = RequestBuilder::new(config.clone()) + .operation_id("op-bucket-01") + .idempotency_key("idem-bucket-01") + .action("provision") + .resource(resource.clone()) + .param("alpha", "x;p.5:bravo=1:y") + .build() + .expect("injected param request"); + let req_two_params = RequestBuilder::new(config) + .operation_id("op-bucket-01") + .idempotency_key("idem-bucket-01") + .action("provision") + .resource(resource) + .param("alpha", "x") + .param("bravo", "y") + .build() + .expect("two param request"); + assert_ne!(req_injected.wire_payload, req_two_params.wire_payload); + } +}