diff --git a/Makefile b/Makefile deleted file mode 100644 index ca8e7a2..0000000 --- a/Makefile +++ /dev/null @@ -1,51 +0,0 @@ -MISE := $(shell command -v mise 2>/dev/null) - -.PHONY: test test-integration lint fmt build ci docs-check proto - -test: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run test - -test-integration: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run test:integration - -lint: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run lint - -fmt: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run fmt - -build: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run build - -ci: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run ci - -docs-check: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run docs:check - -proto: -ifndef MISE - $(error mise is not installed. Install from https://mise.jdx.dev) -endif - mise run proto:gen diff --git a/openshell/v1/context_errors.go b/openshell/v1/context_errors.go new file mode 100644 index 0000000..3c934b3 --- /dev/null +++ b/openshell/v1/context_errors.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import "context" + +func contextError(err error) error { + if err == nil { + return nil + } + switch err { + case context.DeadlineExceeded: + return &StatusError{Code: ErrorDeadlineExceeded, Message: err.Error(), Cause: err} + case context.Canceled: + return &StatusError{Code: ErrorCancelled, Message: err.Error(), Cause: err} + default: + return &StatusError{Code: ErrorInternal, Message: err.Error(), Cause: err} + } +} diff --git a/openshell/v1/context_errors_test.go b/openshell/v1/context_errors_test.go new file mode 100644 index 0000000..442a3ee --- /dev/null +++ b/openshell/v1/context_errors_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContextError_Nil(t *testing.T) { + result := contextError(nil) + assert.Nil(t, result) +} + +func TestContextError_DeadlineExceeded(t *testing.T) { + result := contextError(context.DeadlineExceeded) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorDeadlineExceeded, se.Code) + assert.True(t, errors.Is(result, context.DeadlineExceeded)) +} + +func TestContextError_Canceled(t *testing.T) { + result := contextError(context.Canceled) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorCancelled, se.Code) + assert.True(t, errors.Is(result, context.Canceled)) +} + +func TestContextError_Default(t *testing.T) { + orig := errors.New("unexpected context error") + result := contextError(orig) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorInternal, se.Code) + assert.True(t, errors.Is(result, orig)) +} diff --git a/openshell/v1/errors_test.go b/openshell/v1/errors_test.go index 8565c03..e1ec0d6 100644 --- a/openshell/v1/errors_test.go +++ b/openshell/v1/errors_test.go @@ -22,15 +22,17 @@ func TestStatusError_Error(t *testing.T) { assert.Contains(t, s, "sandbox not found") } -func TestStatusError_ErrorWithDetails(t *testing.T) { +func TestStatusError_ErrorWithCause(t *testing.T) { + cause := errors.New("underlying error") err := &StatusError{ Code: ErrorInvalidArgument, Message: "bad name", - Details: map[string]string{"field": "name"}, + Cause: cause, } s := err.Error() assert.Contains(t, s, "InvalidArgument") assert.Contains(t, s, "bad name") + assert.Equal(t, cause, errors.Unwrap(err)) } func TestIsNotFound(t *testing.T) { diff --git a/openshell/v1/fake/sandbox.go b/openshell/v1/fake/sandbox.go index 3293503..2d29b16 100644 --- a/openshell/v1/fake/sandbox.go +++ b/openshell/v1/fake/sandbox.go @@ -296,7 +296,15 @@ func (c *fakeSandboxClient) WaitReady(ctx context.Context, workspace, name strin select { case <-ctx.Done(): - return nil, ctx.Err() + err := ctx.Err() + switch err { + case context.DeadlineExceeded: + return nil, &types.StatusError{Code: types.ErrorDeadlineExceeded, Message: err.Error(), Cause: err} + case context.Canceled: + return nil, &types.StatusError{Code: types.ErrorCancelled, Message: err.Error(), Cause: err} + default: + return nil, &types.StatusError{Code: types.ErrorInternal, Message: err.Error(), Cause: err} + } default: } diff --git a/openshell/v1/fake/sandbox_test.go b/openshell/v1/fake/sandbox_test.go index 6efb9c6..bec7db0 100644 --- a/openshell/v1/fake/sandbox_test.go +++ b/openshell/v1/fake/sandbox_test.go @@ -219,6 +219,20 @@ func TestSandbox_WaitReady_ContextCancellation(t *testing.T) { assert.ErrorIs(t, err, context.Canceled) } +func TestSandbox_WaitReady_ContextDeadlineExceeded(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.Error(t, err) + assert.True(t, types.IsDeadlineExceeded(err), "WaitReady must wrap context.DeadlineExceeded in StatusError") +} + func TestSandbox_WaitReady_AlreadyReady(t *testing.T) { sc := newTestSandboxClient() ctx := context.Background() diff --git a/openshell/v1/grpc_errors.go b/openshell/v1/grpc_errors.go deleted file mode 100644 index 660ff8e..0000000 --- a/openshell/v1/grpc_errors.go +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Package v1 provides the OpenShell SDK client. -// gRPC error conversion is handled by the internal/converter package. -package v1 diff --git a/openshell/v1/internal/converter/coverage_test.go b/openshell/v1/internal/converter/coverage_test.go new file mode 100644 index 0000000..3a78593 --- /dev/null +++ b/openshell/v1/internal/converter/coverage_test.go @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + dm "github.com/rhuss/openshell-sdk-go/proto/datamodelv1" + pb "github.com/rhuss/openshell-sdk-go/proto/openshellv1" + sandboxpb "github.com/rhuss/openshell-sdk-go/proto/sandboxv1" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// These tests use protobuf reflection to detect proto fields that the +// converter layer does not handle. When buf generates new fields from an +// updated .proto, the field name appears in the proto descriptor but not in +// the "handled" set below. +// +// Unhandled fields FAIL the test so that proto drift is caught immediately. +// If a field is intentionally deferred, add it to the "skipped" set with a +// justification comment. + +func TestConverterCoversAllProtoFields_SandboxSpec(t *testing.T) { + handled := fieldSet{ + "log_level": true, + "environment": true, + "template": true, + "policy": true, + "providers": true, + "resource_requirements": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxSpec{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxTemplate(t *testing.T) { + handled := fieldSet{ + "image": true, + "runtime_class_name": true, + "agent_socket": true, + "labels": true, + "annotations": true, + "environment": true, + "resources": true, + "user_namespaces": true, + "driver_config": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxTemplate{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { + handled := fieldSet{ + "sandbox_name": true, + "agent_pod": true, + "agent_fd": true, + "sandbox_fd": true, + "phase": true, + "conditions": true, + "current_policy_version": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { + handled := fieldSet{ + "type": true, + "status": true, + "reason": true, + "message": true, + "last_transition_time": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxCondition{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { + handled := fieldSet{ + "version": true, + "filesystem": true, + "network_policies": true, + "process": true, + "landlock": true, + } + + skipped := fieldSet{ + // Middleware support is not yet exposed in the SDK domain model. + // Tracked in GitHub issue #36 for Drop D. + "network_middlewares": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, skipped) +} + +func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) { + handled := fieldSet{ + "host": true, + "port": true, + "ports": true, + "protocol": true, + "tls": true, + "enforcement": true, + "access": true, + "rules": true, + "allowed_ips": true, + "deny_rules": true, + "allow_encoded_slash": true, + "persisted_queries": true, + "graphql_persisted_queries": true, + "graphql_max_body_bytes": true, + "path": true, + "websocket_credential_rewrite": true, + "request_body_credential_rewrite": true, + "advisor_proposed": true, + "credential_signing": true, + "signing_service": true, + "signing_region": true, + "json_rpc_max_body_bytes": true, + "mcp": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.NetworkEndpoint{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_L7Allow(t *testing.T) { + handled := fieldSet{ + "method": true, + "path": true, + "command": true, + "query": true, + "operation_type": true, + "operation_name": true, + "fields": true, + "params": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.L7Allow{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_L7DenyRule(t *testing.T) { + handled := fieldSet{ + "method": true, + "path": true, + "command": true, + "query": true, + "operation_type": true, + "operation_name": true, + "fields": true, + "params": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.L7DenyRule{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_Provider(t *testing.T) { + handled := fieldSet{ + "metadata": true, + "type": true, + "credentials": true, + "config": true, + "credential_expires_at_ms": true, + "profile_workspace": true, + "credential_handles": true, + } + + assertAllFieldsCovered(t, (&dm.Provider{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) { + handled := fieldSet{ + "driver": true, + "handle": true, + "metadata": true, + } + + assertAllFieldsCovered(t, (&dm.CredentialHandle{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_McpOptions(t *testing.T) { + handled := fieldSet{ + "strict_tool_names": true, + "allow_all_known_mcp_methods": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.McpOptions{}).ProtoReflect().Descriptor(), handled, nil) +} + +// fieldSet tracks proto field names that the converter handles. +type fieldSet map[string]bool + +// assertAllFieldsCovered fails the test for proto fields not present in +// either handled or skipped. Stale entries in the handled set (fields +// removed from the proto) also fail. +func assertAllFieldsCovered( + t *testing.T, + desc protoreflect.MessageDescriptor, + handled fieldSet, + skipped fieldSet, +) { + t.Helper() + + fields := desc.Fields() + for i := 0; i < fields.Len(); i++ { + name := string(fields.Get(i).Name()) + if handled[name] || skipped[name] { + continue + } + t.Errorf( + "proto %s field %q is not handled by the converter and not explicitly skipped. "+ + "Add converter support in the appropriate FromProto/ToProto function, "+ + "or add it to the skipped set with a justification.", + desc.FullName(), name, + ) + } + + for name := range handled { + found := false + for i := 0; i < fields.Len(); i++ { + if string(fields.Get(i).Name()) == name { + found = true + break + } + } + if !found { + t.Errorf( + "handled field %q is listed for proto %s but does not exist in the descriptor. "+ + "The proto field may have been removed or renamed.", + name, desc.FullName(), + ) + } + } +} diff --git a/openshell/v1/internal/converter/provider.go b/openshell/v1/internal/converter/provider.go index e334e1a..f4bc80a 100644 --- a/openshell/v1/internal/converter/provider.go +++ b/openshell/v1/internal/converter/provider.go @@ -19,7 +19,8 @@ func ProviderFromProto(p *dm.Provider) *types.Provider { result := &types.Provider{ Type: p.GetType(), Spec: types.ProviderSpec{ - Config: CopyStringMap(p.GetConfig()), + Config: CopyStringMap(p.GetConfig()), + ProfileWorkspace: p.GetProfileWorkspace(), }, } @@ -41,6 +42,17 @@ func ProviderFromProto(p *dm.Provider) *types.Provider { } } + if handles := p.GetCredentialHandles(); len(handles) > 0 { + result.Spec.CredentialHandles = make(map[string]types.CredentialHandle, len(handles)) + for k, h := range handles { + result.Spec.CredentialHandles[k] = types.CredentialHandle{ + Driver: h.GetDriver(), + Handle: h.GetHandle(), + Metadata: CopyStringMap(h.GetMetadata()), + } + } + } + return result } @@ -61,9 +73,10 @@ func ProviderToProto(p *types.Provider) *dm.Provider { Workspace: p.Workspace, DeletionTimestampMs: MillisFromTimePtr(p.DeletionTimestamp), }, - Type: p.Type, - Credentials: CopyStringMap(p.Spec.Credentials), - Config: CopyStringMap(p.Spec.Config), + Type: p.Type, + Credentials: CopyStringMap(p.Spec.Credentials), + Config: CopyStringMap(p.Spec.Config), + ProfileWorkspace: p.Spec.ProfileWorkspace, } if len(p.Spec.CredentialExpiresAt) > 0 { @@ -73,5 +86,16 @@ func ProviderToProto(p *types.Provider) *dm.Provider { } } + if len(p.Spec.CredentialHandles) > 0 { + result.CredentialHandles = make(map[string]*dm.CredentialHandle, len(p.Spec.CredentialHandles)) + for k, h := range p.Spec.CredentialHandles { + result.CredentialHandles[k] = &dm.CredentialHandle{ + Driver: h.Driver, + Handle: h.Handle, + Metadata: CopyStringMap(h.Metadata), + } + } + } + return result } diff --git a/openshell/v1/internal/converter/provider_test.go b/openshell/v1/internal/converter/provider_test.go index 619d4a7..c810a10 100644 --- a/openshell/v1/internal/converter/provider_test.go +++ b/openshell/v1/internal/converter/provider_test.go @@ -7,130 +7,205 @@ import ( "testing" "time" - v1 "github.com/rhuss/openshell-sdk-go/openshell/v1/types" + "github.com/rhuss/openshell-sdk-go/openshell/v1/types" dm "github.com/rhuss/openshell-sdk-go/proto/datamodelv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestProviderFromProto(t *testing.T) { +func TestProviderFromProto_Nil(t *testing.T) { + assert.Nil(t, ProviderFromProto(nil)) +} + +func TestProviderFromProto_Full(t *testing.T) { proto := &dm.Provider{ Metadata: &dm.ObjectMeta{ - Id: "prov-1", - Name: "my-claude", - CreatedAtMs: 1700000000000, - Labels: map[string]string{"env": "prod"}, - Annotations: map[string]string{"managed-by": "sdk"}, - ResourceVersion: 5, - Workspace: "default", - DeletionTimestampMs: 1700000060000, + Id: "prov-1", + Name: "claude-provider", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"env": "prod"}, + Annotations: map[string]string{"note": "test"}, + ResourceVersion: 42, + Workspace: "default", }, - Type: "claude", - Credentials: map[string]string{"API_KEY": "secret"}, - Config: map[string]string{"region": "us-east-1"}, + Type: "claude", + Credentials: map[string]string{"api_key": "secret"}, + Config: map[string]string{"base_url": "https://api.example.com"}, + ProfileWorkspace: "shared", CredentialExpiresAtMs: map[string]int64{ - "API_KEY": 1700100000000, + "api_key": 1700003600000, + }, + CredentialHandles: map[string]*dm.CredentialHandle{ + "api_key": { + Driver: "vault", + Handle: "secret/data/claude", + Metadata: map[string]string{"version": "3"}, + }, }, } - p := ProviderFromProto(proto) - - require.NotNil(t, p) - assert.Equal(t, "prov-1", p.ID) - assert.Equal(t, "my-claude", p.Name) - assert.Equal(t, "claude", p.Type) - assert.Equal(t, time.UnixMilli(1700000000000).UTC(), p.CreatedAt) - assert.Equal(t, map[string]string{"env": "prod"}, p.Labels) - assert.Equal(t, map[string]string{"managed-by": "sdk"}, p.Annotations) - assert.Equal(t, uint64(5), p.ResourceVersion) - assert.Equal(t, "default", p.Workspace) - require.NotNil(t, p.DeletionTimestamp) - assert.Equal(t, time.UnixMilli(1700000060000).UTC(), *p.DeletionTimestamp) - assert.Nil(t, p.Spec.Credentials, "credentials are write-only") - assert.Equal(t, map[string]string{"region": "us-east-1"}, p.Spec.Config) - assert.Equal(t, time.UnixMilli(1700100000000).UTC(), p.Spec.CredentialExpiresAt["API_KEY"]) + result := ProviderFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, "prov-1", result.ID) + assert.Equal(t, "claude-provider", result.Name) + assert.Equal(t, "claude", result.Type) + assert.Equal(t, uint64(42), result.ResourceVersion) + assert.Equal(t, "default", result.Workspace) + assert.Equal(t, map[string]string{"env": "prod"}, result.Labels) + assert.Equal(t, map[string]string{"note": "test"}, result.Annotations) + assert.Equal(t, map[string]string{"base_url": "https://api.example.com"}, result.Spec.Config) + assert.Equal(t, "shared", result.Spec.ProfileWorkspace) + + require.Len(t, result.Spec.CredentialExpiresAt, 1) + assert.False(t, result.Spec.CredentialExpiresAt["api_key"].IsZero()) + + require.Len(t, result.Spec.CredentialHandles, 1) + h := result.Spec.CredentialHandles["api_key"] + assert.Equal(t, "vault", h.Driver) + assert.Equal(t, "secret/data/claude", h.Handle) + assert.Equal(t, map[string]string{"version": "3"}, h.Metadata) } func TestProviderFromProto_NilMetadata(t *testing.T) { proto := &dm.Provider{ - Type: "gitlab", + Type: "openai", + Config: map[string]string{"key": "val"}, } - p := ProviderFromProto(proto) + result := ProviderFromProto(proto) - require.NotNil(t, p) - assert.Empty(t, p.ID) - assert.Empty(t, p.Name) - assert.Equal(t, "gitlab", p.Type) - assert.True(t, p.CreatedAt.IsZero()) + require.NotNil(t, result) + assert.Equal(t, "", result.ID) + assert.Equal(t, "", result.Name) + assert.Equal(t, "openai", result.Type) + assert.Equal(t, map[string]string{"key": "val"}, result.Spec.Config) } -func TestProviderFromProto_Nil(t *testing.T) { - p := ProviderFromProto(nil) - assert.Nil(t, p) +func TestProviderFromProto_EmptyHandles(t *testing.T) { + proto := &dm.Provider{ + Type: "test", + CredentialHandles: map[string]*dm.CredentialHandle{}, + } + + result := ProviderFromProto(proto) + + require.NotNil(t, result) + assert.Nil(t, result.Spec.CredentialHandles) } -func TestProviderToProto(t *testing.T) { - provDelTime := time.UnixMilli(1700000060000).UTC() - p := &v1.Provider{ - ID: "prov-1", - Name: "my-claude", - Type: "claude", - CreatedAt: time.UnixMilli(1700000000000).UTC(), - Labels: map[string]string{"env": "prod"}, - Annotations: map[string]string{"managed-by": "sdk"}, - ResourceVersion: 5, - Workspace: "default", - DeletionTimestamp: &provDelTime, - Spec: v1.ProviderSpec{ - Credentials: map[string]string{"API_KEY": "secret"}, - Config: map[string]string{"region": "us-east-1"}, - CredentialExpiresAt: map[string]time.Time{ - "API_KEY": time.UnixMilli(1700100000000).UTC(), +func TestProviderToProto_Nil(t *testing.T) { + assert.Nil(t, ProviderToProto(nil)) +} + +func TestProviderToProto_Full(t *testing.T) { + expires := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + provider := &types.Provider{ + ID: "prov-1", + Name: "test-provider", + Type: "claude", + Labels: map[string]string{"env": "dev"}, + Annotations: map[string]string{"note": "x"}, + ResourceVersion: 7, + Workspace: "ws-1", + Spec: types.ProviderSpec{ + Credentials: map[string]string{"token": "abc"}, + Config: map[string]string{"url": "https://example.com"}, + ProfileWorkspace: "global", + CredentialExpiresAt: map[string]time.Time{"token": expires}, + CredentialHandles: map[string]types.CredentialHandle{ + "token": { + Driver: "k8s-secrets", + Handle: "ns/secret-name", + Metadata: map[string]string{"k": "v"}, + }, }, }, } - proto := ProviderToProto(p) - - require.NotNil(t, proto) - require.NotNil(t, proto.Metadata) - assert.Equal(t, "prov-1", proto.Metadata.Id) - assert.Equal(t, "my-claude", proto.Metadata.Name) - assert.Equal(t, int64(1700000000000), proto.Metadata.CreatedAtMs) - assert.Equal(t, map[string]string{"env": "prod"}, proto.Metadata.Labels) - assert.Equal(t, map[string]string{"managed-by": "sdk"}, proto.Metadata.Annotations) - assert.Equal(t, uint64(5), proto.Metadata.ResourceVersion) - assert.Equal(t, "default", proto.Metadata.Workspace) - assert.Equal(t, int64(1700000060000), proto.Metadata.DeletionTimestampMs) - assert.Equal(t, "claude", proto.Type) - assert.Equal(t, map[string]string{"API_KEY": "secret"}, proto.Credentials) - assert.Equal(t, map[string]string{"region": "us-east-1"}, proto.Config) - assert.Equal(t, int64(1700100000000), proto.CredentialExpiresAtMs["API_KEY"]) + result := ProviderToProto(provider) + + require.NotNil(t, result) + assert.Equal(t, "prov-1", result.Metadata.Id) + assert.Equal(t, "test-provider", result.Metadata.Name) + assert.Equal(t, "claude", result.Type) + assert.Equal(t, "global", result.ProfileWorkspace) + assert.Equal(t, map[string]string{"token": "abc"}, result.Credentials) + assert.Equal(t, map[string]string{"url": "https://example.com"}, result.Config) + + require.Len(t, result.CredentialExpiresAtMs, 1) + assert.Greater(t, result.CredentialExpiresAtMs["token"], int64(0)) + + require.Len(t, result.CredentialHandles, 1) + h := result.CredentialHandles["token"] + assert.Equal(t, "k8s-secrets", h.Driver) + assert.Equal(t, "ns/secret-name", h.Handle) + assert.Equal(t, map[string]string{"k": "v"}, h.Metadata) } -func TestProviderToProto_Nil(t *testing.T) { - proto := ProviderToProto(nil) - assert.Nil(t, proto) +func TestProviderFromProto_DeepCopyCredentialHandles(t *testing.T) { + proto := &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "deep-copy-test"}, + Type: "test", + CredentialHandles: map[string]*dm.CredentialHandle{ + "key": { + Driver: "vault", + Handle: "secret/test", + Metadata: map[string]string{"version": "1"}, + }, + }, + } + + result := ProviderFromProto(proto) + require.Len(t, result.Spec.CredentialHandles, 1) + + proto.CredentialHandles["key"].Metadata["version"] = "mutated" + proto.CredentialHandles["key"].Driver = "mutated" + + assert.Equal(t, "1", result.Spec.CredentialHandles["key"].Metadata["version"]) + assert.Equal(t, "vault", result.Spec.CredentialHandles["key"].Driver) +} + +func TestProviderToProto_DeepCopyCredentialHandles(t *testing.T) { + provider := &types.Provider{ + Name: "deep-copy-test", + Type: "test", + Spec: types.ProviderSpec{ + CredentialHandles: map[string]types.CredentialHandle{ + "token": { + Driver: "k8s", + Handle: "ns/secret", + Metadata: map[string]string{"k": "v"}, + }, + }, + }, + } + + result := ProviderToProto(provider) + require.Len(t, result.CredentialHandles, 1) + + provider.Spec.CredentialHandles["token"] = types.CredentialHandle{ + Driver: "mutated", Handle: "mutated", Metadata: map[string]string{"k": "mutated"}, + } + + assert.Equal(t, "k8s", result.CredentialHandles["token"].Driver) + assert.Equal(t, "ns/secret", result.CredentialHandles["token"].Handle) + assert.Equal(t, "v", result.CredentialHandles["token"].Metadata["k"]) } func TestProviderRoundTrip(t *testing.T) { - provRTDelTime := time.UnixMilli(1700000090000).UTC() - original := &v1.Provider{ - ID: "prov-rt", - Name: "round-trip", - Type: "github", - CreatedAt: time.UnixMilli(1700000000000).UTC(), - Labels: map[string]string{"team": "infra"}, - Annotations: map[string]string{"rt": "yes"}, - ResourceVersion: 42, - Workspace: "test-ws", - DeletionTimestamp: &provRTDelTime, - Spec: v1.ProviderSpec{ - Credentials: map[string]string{"TOKEN": "abc123"}, - Config: map[string]string{"org": "myorg"}, - CredentialExpiresAt: map[string]time.Time{ - "TOKEN": time.UnixMilli(1700200000000).UTC(), + original := &types.Provider{ + ID: "rt-1", + Name: "roundtrip", + Type: "gitlab", + ResourceVersion: 3, + Workspace: "default", + Labels: map[string]string{"team": "infra"}, + Spec: types.ProviderSpec{ + Config: map[string]string{"url": "https://gitlab.com"}, + ProfileWorkspace: "shared", + CredentialHandles: map[string]types.CredentialHandle{ + "pat": {Driver: "vault", Handle: "secret/gitlab", Metadata: map[string]string{"ver": "1"}}, }, }, } @@ -141,14 +216,9 @@ func TestProviderRoundTrip(t *testing.T) { assert.Equal(t, original.ID, back.ID) assert.Equal(t, original.Name, back.Name) assert.Equal(t, original.Type, back.Type) - assert.Equal(t, original.CreatedAt, back.CreatedAt) - assert.Equal(t, original.Labels, back.Labels) - assert.Equal(t, original.Annotations, back.Annotations) - assert.Equal(t, original.ResourceVersion, back.ResourceVersion) assert.Equal(t, original.Workspace, back.Workspace) - require.NotNil(t, back.DeletionTimestamp) - assert.Equal(t, *original.DeletionTimestamp, *back.DeletionTimestamp) - assert.Nil(t, back.Spec.Credentials, "credentials are write-only and should not be returned") + assert.Equal(t, original.Labels, back.Labels) assert.Equal(t, original.Spec.Config, back.Spec.Config) - assert.Equal(t, original.Spec.CredentialExpiresAt, back.Spec.CredentialExpiresAt) + assert.Equal(t, original.Spec.ProfileWorkspace, back.Spec.ProfileWorkspace) + assert.Equal(t, original.Spec.CredentialHandles, back.Spec.CredentialHandles) } diff --git a/openshell/v1/internal/grpc/conn.go b/openshell/v1/internal/grpc/conn.go index 4e2a1e6..43599cf 100644 --- a/openshell/v1/internal/grpc/conn.go +++ b/openshell/v1/internal/grpc/conn.go @@ -26,13 +26,22 @@ type TLSParams struct { // NewConnection creates a gRPC client connection. // The address may include an http:// or https:// scheme (as written by the -// upstream gateway), which is stripped since gRPC expects host:port. +// upstream gateway). The scheme drives transport selection: http:// uses +// plaintext, https:// or no scheme uses TLS. func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCredentials) (*grpc.ClientConn, error) { - address = strings.TrimPrefix(address, "https://") - address = strings.TrimPrefix(address, "http://") + usePlaintext := false + if strings.HasPrefix(address, "http://") { + usePlaintext = true + address = strings.TrimPrefix(address, "http://") + } else { + address = strings.TrimPrefix(address, "https://") + } opts := []grpc.DialOption{} - if tlsCfg != nil && tlsCfg.Insecure { + if usePlaintext { + if tlsCfg != nil && (tlsCfg.CAFile != "" || tlsCfg.CertFile != "" || tlsCfg.KeyFile != "") { + return nil, fmt.Errorf("grpc connect: TLS parameters (CAFile/CertFile/KeyFile) are ignored with plaintext (http://) address") + } opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else if tlsCfg != nil { creds, err := buildTLSCredentials(tlsCfg) @@ -45,6 +54,9 @@ func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCre } if auth != nil { + if usePlaintext && auth.RequireTransportSecurity() { + return nil, fmt.Errorf("grpc connect: auth provider requires transport security but address uses plaintext (http://)") + } opts = append(opts, grpc.WithPerRPCCredentials(auth)) } @@ -56,7 +68,10 @@ func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCre } func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, error) { - tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: cfg.Insecure, //nolint:gosec // user-requested skip for dev gateways + } if cfg.CAFile != "" { caCert, err := os.ReadFile(cfg.CAFile) @@ -76,6 +91,8 @@ func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, erro return nil, fmt.Errorf("load client cert: %w", err) } tlsConfig.Certificates = []tls.Certificate{cert} + } else if cfg.CertFile != "" || cfg.KeyFile != "" { + return nil, fmt.Errorf("both CertFile and KeyFile must be provided for client certificate authentication") } return credentials.NewTLS(tlsConfig), nil diff --git a/openshell/v1/internal/grpc/conn_test.go b/openshell/v1/internal/grpc/conn_test.go new file mode 100644 index 0000000..6f03003 --- /dev/null +++ b/openshell/v1/internal/grpc/conn_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package grpc + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestNewConnectionHTTPSchemeUsesPlaintext(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = lis.Close() }() + + srv := grpc.NewServer() + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := NewConnection("http://"+lis.Addr().String(), nil, nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionHTTPSSchemeUsesTLS(t *testing.T) { + conn, err := NewConnection("https://127.0.0.1:1", nil, nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionNoSchemeUsesTLS(t *testing.T) { + conn, err := NewConnection("127.0.0.1:1", nil, nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionInsecureTLSConfig(t *testing.T) { + conn, err := NewConnection("127.0.0.1:1", &TLSParams{Insecure: true}, nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionHTTPWithSecureAuthRejects(t *testing.T) { + auth := &testTokenAuth{token: "dev-token", requireSecurity: true} + _, err := NewConnection("http://127.0.0.1:1", nil, auth) + require.Error(t, err) +} + +func TestNewConnectionHTTPWithInsecureAuth(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = lis.Close() }() + + srv := grpc.NewServer(grpc.Creds(insecure.NewCredentials())) + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + auth := &testTokenAuth{token: "dev-token", requireSecurity: false} + conn, err := NewConnection("http://"+lis.Addr().String(), nil, auth) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionHTTPWithTLSParamsRejects(t *testing.T) { + _, err := NewConnection("http://127.0.0.1:1", &TLSParams{CAFile: "/some/ca.pem"}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "TLS parameters") +} + +func TestNewConnectionHTTPWithEmptyTLSParamsAllowed(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = lis.Close() }() + + srv := grpc.NewServer() + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := NewConnection("http://"+lis.Addr().String(), &TLSParams{}, nil) + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +type testTokenAuth struct { + token string + requireSecurity bool +} + +func (a *testTokenAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer " + a.token}, nil +} + +func (a *testTokenAuth) RequireTransportSecurity() bool { + return a.requireSecurity +} diff --git a/openshell/v1/sandbox_client.go b/openshell/v1/sandbox_client.go index 60f74ed..4c5e8d9 100644 --- a/openshell/v1/sandbox_client.go +++ b/openshell/v1/sandbox_client.go @@ -146,11 +146,8 @@ func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, o return nil, err } - if sb.Status.Phase == SandboxReady { - return sb, nil - } - if sb.Status.Phase == SandboxError { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + if result, termErr := checkTerminalPhase(sb, name); result != nil || termErr != nil { + return result, termErr } ticker := time.NewTicker(interval) @@ -159,22 +156,32 @@ func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, o for { select { case <-ctx.Done(): - return nil, ctx.Err() + return nil, contextError(ctx.Err()) case <-ticker.C: sb, err = s.Get(ctx, workspace, name) if err != nil { return nil, err } - if sb.Status.Phase == SandboxReady { - return sb, nil - } - if sb.Status.Phase == SandboxError { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + if result, termErr := checkTerminalPhase(sb, name); result != nil || termErr != nil { + return result, termErr } } } } +func checkTerminalPhase(sb *Sandbox, name string) (*Sandbox, error) { + switch sb.Status.Phase { + case SandboxReady: + return sb, nil + case SandboxError: + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + case SandboxDeleting: + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)} + default: + return nil, nil + } +} + func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) { if name == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} @@ -212,12 +219,17 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts go func() { defer close(ch) + defer streamCancel() ev := first + isFirst := true for { if sbPayload, ok := ev.Payload.(*pb.SandboxStreamEvent_Sandbox); ok && sbPayload.Sandbox != nil { sandbox := converter.SandboxFromProto(sbPayload.Sandbox) eventType := EventModified - if sandbox.Status.Phase == SandboxDeleting { + if isFirst { + eventType = EventAdded + isFirst = false + } else if sandbox.Status.Phase == SandboxDeleting { eventType = EventDeleted } select { @@ -233,14 +245,15 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts var recvErr error ev, recvErr = stream.Recv() if recvErr != nil { - select { - case <-w.done: - default: - if recvErr != io.EOF { - select { - case ch <- Event[*Sandbox]{Type: EventError, Object: nil}: - default: - } + if recvErr != io.EOF { + select { + case <-w.done: + return + default: + } + select { + case ch <- Event[*Sandbox]{Type: EventError, Err: converter.FromGRPCError(recvErr)}: + case <-w.done: } } return diff --git a/openshell/v1/sandbox_client_test.go b/openshell/v1/sandbox_client_test.go index b5afd40..eb7c7fa 100644 --- a/openshell/v1/sandbox_client_test.go +++ b/openshell/v1/sandbox_client_test.go @@ -34,10 +34,11 @@ type mockSandboxServer struct { attachErr error detachErr error listProvErr error - watchEvents []*pb.SandboxStreamEvent - watchErr error - watchKeepOpen chan struct{} // if non-nil, WatchSandbox blocks after sending events until closed - watchRequest *pb.WatchSandboxRequest // recorded request + watchEvents []*pb.SandboxStreamEvent + watchErr error + watchPostEventsErr error + watchKeepOpen chan struct{} // if non-nil, WatchSandbox blocks after sending events until closed + watchRequest *pb.WatchSandboxRequest // recorded request // GetLogs fields getLogsResp *pb.GetSandboxLogsResponse @@ -53,6 +54,8 @@ func newMockSandboxServer() *mockSandboxServer { } func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandboxRequest) (*pb.SandboxResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() if s.createErr != nil { return nil, s.createErr } @@ -164,6 +167,9 @@ func (s *mockSandboxServer) WatchSandbox(req *pb.WatchSandboxRequest, stream grp return err } } + if s.watchPostEventsErr != nil { + return s.watchPostEventsErr + } // If watchKeepOpen is set, block until it is closed (simulates long-running stream) if keepOpen != nil { <-keepOpen @@ -512,6 +518,28 @@ func TestSandboxWaitReady_ContextTimeout(t *testing.T) { _, err := client.WaitReady(ctx, "default", "stuck-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) require.Error(t, err) + assert.True(t, IsDeadlineExceeded(err), "WaitReady must wrap context.DeadlineExceeded in StatusError") +} + +func TestSandboxWaitReady_ContextCancelled(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["cancel-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "cancel-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + _, err := client.WaitReady(ctx, "default", "cancel-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.Error(t, err) + assert.True(t, IsCancelled(err), "WaitReady must wrap context.Canceled in StatusError") } func TestSandboxWaitReady_SandboxFailed(t *testing.T) { @@ -528,6 +556,44 @@ func TestSandboxWaitReady_SandboxFailed(t *testing.T) { require.Error(t, err) } +func TestSandboxWaitReady_SandboxDeleting(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["deleting-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "deleting-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_DELETING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "default", "deleting-sb") + + require.Error(t, err) + assert.Contains(t, err.Error(), "being deleted") +} + +func TestSandboxWaitReady_BecomesDeleting(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["del-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "del-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + go func() { + time.Sleep(50 * time.Millisecond) + mock.setPhase("del-sb", pb.SandboxPhase_SANDBOX_PHASE_DELETING) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := client.WaitReady(ctx, "default", "del-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "being deleted") +} + func TestSandboxWaitReady_NotFound(t *testing.T) { mock := newMockSandboxServer() client, cleanup := setupSandboxTest(t, mock) @@ -565,7 +631,7 @@ func TestSandboxWatch_ReceivesEvents(t *testing.T) { defer w.Stop() ev1 := <-w.ResultChan() - assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, EventAdded, ev1.Type) require.NotNil(t, ev1.Object) assert.Equal(t, "sb-1", ev1.Object.Name) assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) @@ -597,7 +663,7 @@ func TestSandboxWatch_FiltersSandboxEventsOnly(t *testing.T) { defer w.Stop() ev := <-w.ResultChan() - assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, EventAdded, ev.Type) assert.Equal(t, "sb-1", ev.Object.Name) // Stream ends after server sends all events; channel should close @@ -654,6 +720,35 @@ func TestSandboxWatch_RPCError(t *testing.T) { assert.True(t, IsUnavailable(err)) } +func TestSandboxWatch_MidStreamErrorDeliveredAsStatusError(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + } + mock.watchPostEventsErr = status.Error(codes.Unavailable, "connection lost") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "default", "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev1 := <-w.ResultChan() + assert.Equal(t, EventAdded, ev1.Type) + + ev2 := <-w.ResultChan() + assert.Equal(t, EventError, ev2.Type) + require.Error(t, ev2.Err) + assert.True(t, IsUnavailable(ev2.Err), "mid-stream error should be converted to StatusError") +} + // --- T016: Watch name-to-ID resolution verification tests --- func TestSandboxWatch_ResolvesNameToID(t *testing.T) { @@ -736,7 +831,7 @@ func TestSandboxWatch_StopOnTerminal_Ready(t *testing.T) { // Should receive the Provisioning event ev1 := <-w.ResultChan() - assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, EventAdded, ev1.Type) assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) // Should receive the Ready event (terminal) @@ -780,7 +875,7 @@ func TestSandboxWatch_StopOnTerminal_Error(t *testing.T) { // Should receive the Provisioning event ev1 := <-w.ResultChan() - assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, EventAdded, ev1.Type) assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) // Should receive the Error event (terminal) @@ -803,6 +898,8 @@ func TestSandboxWatch_StopOnTerminal_False_DoesNotClose(t *testing.T) { Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) mock.watchEvents = []*pb.SandboxStreamEvent{ {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, @@ -812,25 +909,53 @@ func TestSandboxWatch_StopOnTerminal_False_DoesNotClose(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - // Default: StopOnTerminal=false — watcher should NOT auto-close on Ready w, err := client.Watch(context.Background(), "default", "sb-1") require.NoError(t, err) defer w.Stop() ev := <-w.ResultChan() - assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, EventAdded, ev.Type) assert.Equal(t, SandboxReady, ev.Object.Status.Phase) - // Channel closes because mock stream ends (not because of StopOnTerminal) - // This test verifies the existing behavior is preserved + // Channel must NOT close: stream is still open and StopOnTerminal=false select { - case _, ok := <-w.ResultChan(): - assert.False(t, ok, "channel should close after stream ends") - case <-time.After(time.Second): - t.Fatal("timed out waiting for channel close") + case <-w.ResultChan(): + t.Fatal("channel should stay open when StopOnTerminal is false") + case <-time.After(100 * time.Millisecond): } } +func TestSandboxWatch_DeletedEvent(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_DELETING}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "default", "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev1 := <-w.ResultChan() + assert.Equal(t, EventAdded, ev1.Type) + + ev2 := <-w.ResultChan() + assert.Equal(t, EventDeleted, ev2.Type) + assert.Equal(t, SandboxDeleting, ev2.Object.Status.Phase) +} + // --- T027: GetLogs tests --- func TestSandboxGetLogs(t *testing.T) { diff --git a/openshell/v1/types/errors.go b/openshell/v1/types/errors.go index e7ab817..8a8d9f4 100644 --- a/openshell/v1/types/errors.go +++ b/openshell/v1/types/errors.go @@ -60,13 +60,17 @@ func (c ErrorCode) String() string { type StatusError struct { Code ErrorCode Message string - Details map[string]string + Cause error } func (e *StatusError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) } +func (e *StatusError) Unwrap() error { + return e.Cause +} + // IsNotFound returns true if the error indicates a resource was not found. func IsNotFound(err error) bool { return hasCode(err, ErrorNotFound) diff --git a/openshell/v1/types/provider.go b/openshell/v1/types/provider.go index 70add8b..7b8e4c2 100644 --- a/openshell/v1/types/provider.go +++ b/openshell/v1/types/provider.go @@ -24,4 +24,15 @@ type ProviderSpec struct { Credentials map[string]string Config map[string]string CredentialExpiresAt map[string]time.Time + ProfileWorkspace string + CredentialHandles map[string]CredentialHandle +} + +// CredentialHandle is an opaque handle for a provider credential stored by +// gateway credential storage. Handles are created by OpenShell and are not +// accepted as user-authored input. +type CredentialHandle struct { + Driver string + Handle string + Metadata map[string]string } diff --git a/openshell/v1/types/watch.go b/openshell/v1/types/watch.go index d97e07e..0a8c9d4 100644 --- a/openshell/v1/types/watch.go +++ b/openshell/v1/types/watch.go @@ -7,6 +7,7 @@ package types type Event[T any] struct { Type EventType Object T + Err error } // WatchInterface delivers a stream of typed events. Modeled after diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 1fc22a9..b990f05 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -68,6 +68,17 @@ message Workspace { WorkspaceStatus status = 2; } +// Opaque handle for a provider credential stored by gateway credential storage. +// Handles are created by OpenShell and must not be authored by users. +message CredentialHandle { + // Internal storage owner or credential driver that owns this handle. + string driver = 1; + // Owner-owned opaque handle string. + string handle = 2; + // Owner-owned non-secret metadata. + map metadata = 3; +} + // Provider model stored by OpenShell. message Provider { // Kubernetes-style metadata (id, name, labels, timestamps, resource version). @@ -85,4 +96,7 @@ message Provider { // Empty string = platform/global scope. Must be empty or match // metadata.workspace; cross-workspace references are rejected. string profile_workspace = 6; + // Opaque handles for secret values stored through gateway credential storage. + // This map is internal gateway state and is not accepted as user-authored input. + map credential_handles = 7; } diff --git a/proto/datamodelv1/datamodel.pb.go b/proto/datamodelv1/datamodel.pb.go index fc1ca51..639d67b 100644 --- a/proto/datamodelv1/datamodel.pb.go +++ b/proto/datamodelv1/datamodel.pb.go @@ -297,6 +297,71 @@ func (x *Workspace) GetStatus() *WorkspaceStatus { return nil } +// Opaque handle for a provider credential stored by gateway credential storage. +// Handles are created by OpenShell and must not be authored by users. +type CredentialHandle struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Internal storage owner or credential driver that owns this handle. + Driver string `protobuf:"bytes,1,opt,name=driver,proto3" json:"driver,omitempty"` + // Owner-owned opaque handle string. + Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"` + // Owner-owned non-secret metadata. + Metadata map[string]string `protobuf:"bytes,3,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialHandle) Reset() { + *x = CredentialHandle{} + mi := &file_datamodel_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialHandle) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialHandle) ProtoMessage() {} + +func (x *CredentialHandle) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CredentialHandle.ProtoReflect.Descriptor instead. +func (*CredentialHandle) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{3} +} + +func (x *CredentialHandle) GetDriver() string { + if x != nil { + return x.Driver + } + return "" +} + +func (x *CredentialHandle) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *CredentialHandle) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + // Provider model stored by OpenShell. type Provider struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -315,13 +380,16 @@ type Provider struct { // Empty string = platform/global scope. Must be empty or match // metadata.workspace; cross-workspace references are rejected. ProfileWorkspace string `protobuf:"bytes,6,opt,name=profile_workspace,json=profileWorkspace,proto3" json:"profile_workspace,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Opaque handles for secret values stored through gateway credential storage. + // This map is internal gateway state and is not accepted as user-authored input. + CredentialHandles map[string]*CredentialHandle `protobuf:"bytes,7,rep,name=credential_handles,json=credentialHandles,proto3" json:"credential_handles,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Provider) Reset() { *x = Provider{} - mi := &file_datamodel_proto_msgTypes[3] + mi := &file_datamodel_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -333,7 +401,7 @@ func (x *Provider) String() string { func (*Provider) ProtoMessage() {} func (x *Provider) ProtoReflect() protoreflect.Message { - mi := &file_datamodel_proto_msgTypes[3] + mi := &file_datamodel_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -346,7 +414,7 @@ func (x *Provider) ProtoReflect() protoreflect.Message { // Deprecated: Use Provider.ProtoReflect.Descriptor instead. func (*Provider) Descriptor() ([]byte, []int) { - return file_datamodel_proto_rawDescGZIP(), []int{3} + return file_datamodel_proto_rawDescGZIP(), []int{4} } func (x *Provider) GetMetadata() *ObjectMeta { @@ -391,6 +459,13 @@ func (x *Provider) GetProfileWorkspace() string { return "" } +func (x *Provider) GetCredentialHandles() map[string]*CredentialHandle { + if x != nil { + return x.CredentialHandles + } + return nil +} + var File_datamodel_proto protoreflect.FileDescriptor const file_datamodel_proto_rawDesc = "" + @@ -416,14 +491,22 @@ const file_datamodel_proto_rawDesc = "" + "\x05phase\x18\x01 \x01(\x0e2&.openshell.datamodel.v1.WorkspacePhaseR\x05phase\"\x8c\x01\n" + "\tWorkspace\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12?\n" + - "\x06status\x18\x02 \x01(\v2'.openshell.datamodel.v1.WorkspaceStatusR\x06status\"\xe7\x04\n" + + "\x06status\x18\x02 \x01(\v2'.openshell.datamodel.v1.WorkspaceStatusR\x06status\"\xd3\x01\n" + + "\x10CredentialHandle\x12\x16\n" + + "\x06driver\x18\x01 \x01(\tR\x06driver\x12\x16\n" + + "\x06handle\x18\x02 \x01(\tR\x06handle\x12R\n" + + "\bmetadata\x18\x03 \x03(\v26.openshell.datamodel.v1.CredentialHandle.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbf\x06\n" + "\bProvider\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12Y\n" + "\vcredentials\x18\x03 \x03(\v21.openshell.datamodel.v1.Provider.CredentialsEntryB\x04\x88\xb5\x18\x01R\vcredentials\x12D\n" + "\x06config\x18\x04 \x03(\v2,.openshell.datamodel.v1.Provider.ConfigEntryR\x06config\x12t\n" + "\x18credential_expires_at_ms\x18\x05 \x03(\v2;.openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12+\n" + - "\x11profile_workspace\x18\x06 \x01(\tR\x10profileWorkspace\x1a>\n" + + "\x11profile_workspace\x18\x06 \x01(\tR\x10profileWorkspace\x12f\n" + + "\x12credential_handles\x18\a \x03(\v27.openshell.datamodel.v1.Provider.CredentialHandlesEntryR\x11credentialHandles\x1a>\n" + "\x10CredentialsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a9\n" + @@ -432,7 +515,10 @@ const file_datamodel_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01*n\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1an\n" + + "\x16CredentialHandlesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12>\n" + + "\x05value\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x05value:\x028\x01*n\n" + "\x0eWorkspacePhase\x12\x1f\n" + "\x1bWORKSPACE_PHASE_UNSPECIFIED\x10\x00\x12\x1a\n" + "\x16WORKSPACE_PHASE_ACTIVE\x10\x01\x12\x1f\n" + @@ -451,34 +537,40 @@ func file_datamodel_proto_rawDescGZIP() []byte { } var file_datamodel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_datamodel_proto_goTypes = []any{ - (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase - (*ObjectMeta)(nil), // 1: openshell.datamodel.v1.ObjectMeta - (*WorkspaceStatus)(nil), // 2: openshell.datamodel.v1.WorkspaceStatus - (*Workspace)(nil), // 3: openshell.datamodel.v1.Workspace - (*Provider)(nil), // 4: openshell.datamodel.v1.Provider - nil, // 5: openshell.datamodel.v1.ObjectMeta.LabelsEntry - nil, // 6: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - nil, // 7: openshell.datamodel.v1.Provider.CredentialsEntry - nil, // 8: openshell.datamodel.v1.Provider.ConfigEntry - nil, // 9: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase + (*ObjectMeta)(nil), // 1: openshell.datamodel.v1.ObjectMeta + (*WorkspaceStatus)(nil), // 2: openshell.datamodel.v1.WorkspaceStatus + (*Workspace)(nil), // 3: openshell.datamodel.v1.Workspace + (*CredentialHandle)(nil), // 4: openshell.datamodel.v1.CredentialHandle + (*Provider)(nil), // 5: openshell.datamodel.v1.Provider + nil, // 6: openshell.datamodel.v1.ObjectMeta.LabelsEntry + nil, // 7: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + nil, // 8: openshell.datamodel.v1.CredentialHandle.MetadataEntry + nil, // 9: openshell.datamodel.v1.Provider.CredentialsEntry + nil, // 10: openshell.datamodel.v1.Provider.ConfigEntry + nil, // 11: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + nil, // 12: openshell.datamodel.v1.Provider.CredentialHandlesEntry } var file_datamodel_proto_depIdxs = []int32{ - 5, // 0: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry - 6, // 1: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - 0, // 2: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase - 1, // 3: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 2, // 4: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus - 1, // 5: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 7, // 6: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry - 8, // 7: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry - 9, // 8: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry - 9, // [9:9] is the sub-list for method output_type - 9, // [9:9] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 6, // 0: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry + 7, // 1: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + 0, // 2: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase + 1, // 3: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 2, // 4: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus + 8, // 5: openshell.datamodel.v1.CredentialHandle.metadata:type_name -> openshell.datamodel.v1.CredentialHandle.MetadataEntry + 1, // 6: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 9, // 7: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry + 10, // 8: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry + 11, // 9: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + 12, // 10: openshell.datamodel.v1.Provider.credential_handles:type_name -> openshell.datamodel.v1.Provider.CredentialHandlesEntry + 4, // 11: openshell.datamodel.v1.Provider.CredentialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_datamodel_proto_init() } @@ -492,7 +584,7 @@ func file_datamodel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc)), NumEnums: 1, - NumMessages: 9, + NumMessages: 12, NumExtensions: 0, NumServices: 0, },