diff --git a/.github/workflows/proto-ci.yml b/.github/workflows/proto-ci.yml index e030233..d363545 100644 --- a/.github/workflows/proto-ci.yml +++ b/.github/workflows/proto-ci.yml @@ -22,6 +22,9 @@ jobs: fetch-depth: 0 - uses: bufbuild/buf-setup-action@v1 + with: + version: "1.73.0" + github_token: ${{ secrets.GITHUB_TOKEN }} - name: buf lint run: buf lint proto @@ -36,6 +39,9 @@ jobs: - uses: actions/checkout@v4 - uses: bufbuild/buf-setup-action@v1 + with: + version: "1.73.0" + github_token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/setup-go@v5 with: @@ -47,12 +53,12 @@ jobs: - name: Install Go plugins run: | - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Install Python plugins - run: pip install grpcio-tools mypy-protobuf + run: pip install 'grpcio-tools==1.84.0' 'mypy-protobuf==5.1.0' - name: Generate Go SDK run: | diff --git a/Makefile b/Makefile index a01e92b..3b58dc5 100644 --- a/Makefile +++ b/Makefile @@ -18,16 +18,21 @@ $(VENV_PYTHON): python3 -m venv $(VENV) $(VENV_STAMP): $(VENV_PYTHON) - $(VENV_PYTHON) -m pip install --upgrade pip grpcio-tools mypy-protobuf + $(VENV_PYTHON) -m pip install --upgrade pip 'grpcio-tools==1.84.0' 'mypy-protobuf==5.1.0' touch $(VENV_STAMP) .PHONY: venv venv: $(VENV_STAMP) +# Keep in sync with .github/workflows/proto-ci.yml. Unpinned @latest on +# CI vs an older local plugin rewrites the SDK and fails generate-check. +PROTOC_GEN_GO := google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 +PROTOC_GEN_GO_GRPC := google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 + .PHONY: tools tools: $(VENV_STAMP) - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + go install $(PROTOC_GEN_GO) + go install $(PROTOC_GEN_GO_GRPC) .PHONY: lint lint: @@ -42,6 +47,8 @@ generate: generate-go generate-python .PHONY: generate-go generate-go: + go install $(PROTOC_GEN_GO) + go install $(PROTOC_GEN_GO_GRPC) PATH="$(GOBIN):$$PATH" buf generate proto ./scripts/add-license-headers.sh sdk/go diff --git a/proto/hal/adapters/common/v1/common.proto b/proto/hal/adapters/common/v1/common.proto index 5b7f5ec..95778a1 100644 --- a/proto/hal/adapters/common/v1/common.proto +++ b/proto/hal/adapters/common/v1/common.proto @@ -32,7 +32,7 @@ message HealthCheckResponse { // Health of the physical hardware behind the adapter, as reported by the // vendor's own driver/API for that device -- not just process liveness. // Implementations query whatever the vendor exposes and translate it into -// an UP / DOWN / DEGRADED-style result, which may be informed by the +// an UP / DOWN / DEGRADED / IDLE-style result, which may be informed by the // device's own lifecycle state enum. message ResourceHealthCheckRequest { string product_id = 1; diff --git a/sdk/go/hal/adapters/common/v1/common.pb.go b/sdk/go/hal/adapters/common/v1/common.pb.go index b6589ab..6fc37d1 100644 --- a/sdk/go/hal/adapters/common/v1/common.pb.go +++ b/sdk/go/hal/adapters/common/v1/common.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.12 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: hal/adapters/common/v1/common.proto @@ -110,7 +110,7 @@ func (x *HealthCheckResponse) GetMessage() string { // Health of the physical hardware behind the adapter, as reported by the // vendor's own driver/API for that device -- not just process liveness. // Implementations query whatever the vendor exposes and translate it into -// an UP / DOWN / DEGRADED-style result, which may be informed by the +// an UP / DOWN / DEGRADED / IDLE-style result, which may be informed by the // device's own lifecycle state enum. type ResourceHealthCheckRequest struct { state protoimpl.MessageState `protogen:"open.v1"` diff --git a/sdk/go/hal/adapters/source/v1/source.pb.go b/sdk/go/hal/adapters/source/v1/source.pb.go index 29b5684..3df1ee5 100644 --- a/sdk/go/hal/adapters/source/v1/source.pb.go +++ b/sdk/go/hal/adapters/source/v1/source.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.12 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: hal/adapters/source/v1/source.proto diff --git a/sdk/go/hal/adapters/switch/v1/switch.pb.go b/sdk/go/hal/adapters/switch/v1/switch.pb.go index 7eb7766..85e8403 100644 --- a/sdk/go/hal/adapters/switch/v1/switch.pb.go +++ b/sdk/go/hal/adapters/switch/v1/switch.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.12 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: hal/adapters/switch/v1/switch.proto diff --git a/sdk/go/hal/adapters/timetagger/v1/timetagger.pb.go b/sdk/go/hal/adapters/timetagger/v1/timetagger.pb.go index ad07f61..37cbeb2 100644 --- a/sdk/go/hal/adapters/timetagger/v1/timetagger.pb.go +++ b/sdk/go/hal/adapters/timetagger/v1/timetagger.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.12 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: hal/adapters/timetagger/v1/timetagger.proto diff --git a/test/conformance/README.md b/test/conformance/README.md index dcb82ee..efe67f4 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -6,6 +6,7 @@ These tests are used to verify the basic runtime validation of an adapters imple - Adapters returns valid responses as per the HAL API specification - All required fields are present and correctly formatted as per of the response structure - Optional RPCs if implemented by the server are verified or skipped if unimplemented. +- Command RPCs (`Initialize`, `StartEmission`, `StartDataCollection`, …) skip on `FAILED_PRECONDITION` when the adapter refuses because of its current lifecycle state. Probe RPCs (`HealthCheck`, `ResourceHealthCheck`, `GetProductInfo`, `GetStatus`) must still succeed in any state. These tests are designed to be run against a running adapter instance. They do not start the adapters themselves. Please see the instruction below for running the tests with a specific adapter instance. diff --git a/test/conformance/proto_constants.py b/test/conformance/proto_constants.py index 2950f93..6237c76 100644 --- a/test/conformance/proto_constants.py +++ b/test/conformance/proto_constants.py @@ -23,8 +23,10 @@ RESOURCE_HEALTH_UP = "UP" RESOURCE_HEALTH_DOWN = "DOWN" RESOURCE_HEALTH_DEGRADED = "DEGRADED" +RESOURCE_HEALTH_IDLE = "IDLE" VALID_RESOURCE_HEALTH_STATUSES = { RESOURCE_HEALTH_UP, RESOURCE_HEALTH_DOWN, - RESOURCE_HEALTH_DEGRADED + RESOURCE_HEALTH_DEGRADED, + RESOURCE_HEALTH_IDLE } diff --git a/test/conformance/source/test_source.py b/test/conformance/source/test_source.py index b14a0c3..d7bc26f 100644 --- a/test/conformance/source/test_source.py +++ b/test/conformance/source/test_source.py @@ -12,6 +12,7 @@ from utils import ( validate_response_field, validate_response_fields, + check_command_rpc, check_optional_rpc, ) @@ -151,7 +152,7 @@ def test_initialize(self, client, source_kind): request = source_pb2.InitializeRequest( **rpc_request(source_kind, "Initialize") ) - response = client.Initialize(request) + response = check_command_rpc(lambda: client.Initialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -162,7 +163,7 @@ def test_deinitialize(self, client, source_kind): request = source_pb2.DeinitializeRequest( **rpc_request(source_kind, "Deinitialize") ) - response = client.Deinitialize(request) + response = check_command_rpc(lambda: client.Deinitialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -201,7 +202,7 @@ def test_set_config(self, client, source_kind): request = source_pb2.SetConfigRequest( **rpc_request(source_kind, "SetConfig") ) - response = client.SetConfig(request) + response = check_command_rpc(lambda: client.SetConfig(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -212,7 +213,7 @@ def test_start_emission(self, client, source_kind): request = source_pb2.StartEmissionRequest( **rpc_request(source_kind, "StartEmission") ) - response = client.StartEmission(request) + response = check_command_rpc(lambda: client.StartEmission(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -223,7 +224,7 @@ def test_stop_emission(self, client, source_kind): request = source_pb2.StopEmissionRequest( **rpc_request(source_kind, "StopEmission") ) - response = client.StopEmission(request) + response = check_command_rpc(lambda: client.StopEmission(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False diff --git a/test/conformance/switch/test_switch.py b/test/conformance/switch/test_switch.py index 4735f90..b428141 100644 --- a/test/conformance/switch/test_switch.py +++ b/test/conformance/switch/test_switch.py @@ -12,6 +12,7 @@ from utils import ( validate_response_field, validate_response_fields, + check_command_rpc, check_optional_rpc, ) @@ -152,7 +153,7 @@ def test_initialize(self, client, switch_kind): request = switch_pb2.InitializeRequest( **rpc_request(switch_kind, "Initialize") ) - response = client.Initialize(request) + response = check_command_rpc(lambda: client.Initialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -163,7 +164,7 @@ def test_deinitialize(self, client, switch_kind): request = switch_pb2.DeinitializeRequest( **rpc_request(switch_kind, "Deinitialize") ) - response = client.Deinitialize(request) + response = check_command_rpc(lambda: client.Deinitialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -201,7 +202,7 @@ def test_set_config(self, client, switch_kind): request = switch_pb2.SetConfigRequest( **rpc_request(switch_kind, "SetConfig") ) - response = client.SetConfig(request) + response = check_command_rpc(lambda: client.SetConfig(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -212,7 +213,7 @@ def test_commit_connection(self, client, switch_kind): request = switch_pb2.CommitConnectionRequest( **rpc_request(switch_kind, "CommitConnection") ) - response = client.CommitConnection(request) + response = check_command_rpc(lambda: client.CommitConnection(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -223,7 +224,7 @@ def test_clear_connections(self, client, switch_kind): request = switch_pb2.ClearConnectionsRequest( **rpc_request(switch_kind, "ClearConnections") ) - response = client.ClearConnections(request) + response = check_command_rpc(lambda: client.ClearConnections(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False diff --git a/test/conformance/timetagger/test_timetagger.py b/test/conformance/timetagger/test_timetagger.py index 0d65a28..e549b40 100644 --- a/test/conformance/timetagger/test_timetagger.py +++ b/test/conformance/timetagger/test_timetagger.py @@ -12,6 +12,7 @@ from utils import ( validate_response_field, validate_response_fields, + check_command_rpc, check_optional_rpc, ) @@ -151,7 +152,7 @@ def test_initialize(self, client, timetagger_kind): request = timetagger_pb2.InitializeRequest( **rpc_request(timetagger_kind, "Initialize") ) - response = client.Initialize(request) + response = check_command_rpc(lambda: client.Initialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -162,7 +163,7 @@ def test_deinitialize(self, client, timetagger_kind): request = timetagger_pb2.DeinitializeRequest( **rpc_request(timetagger_kind, "Deinitialize") ) - response = client.Deinitialize(request) + response = check_command_rpc(lambda: client.Deinitialize(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -200,7 +201,7 @@ def test_set_config(self, client, timetagger_kind): request = timetagger_pb2.SetConfigRequest( **rpc_request(timetagger_kind, "SetConfig") ) - response = client.SetConfig(request) + response = check_command_rpc(lambda: client.SetConfig(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -211,7 +212,7 @@ def test_start_data_collection(self, client, timetagger_kind): request = timetagger_pb2.StartDataCollectionRequest( **rpc_request(timetagger_kind, "StartDataCollection") ) - response = client.StartDataCollection(request) + response = check_command_rpc(lambda: client.StartDataCollection(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False @@ -222,7 +223,7 @@ def test_stop_data_collection(self, client, timetagger_kind): request = timetagger_pb2.StopDataCollectionRequest( **rpc_request(timetagger_kind, "StopDataCollection") ) - response = client.StopDataCollection(request) + response = check_command_rpc(lambda: client.StopDataCollection(request)) assert response is not None validate_response_field( response, "message", str, allow_empty=False diff --git a/test/conformance/utils.py b/test/conformance/utils.py index 84016de..989ba98 100644 --- a/test/conformance/utils.py +++ b/test/conformance/utils.py @@ -4,6 +4,7 @@ """Utility functions for conformance tests""" import grpc +import pytest def is_unimplemented(error): @@ -22,6 +23,56 @@ def is_unimplemented(error): return isinstance(error, grpc.RpcError) and error.code() == grpc.StatusCode.UNIMPLEMENTED +def is_failed_precondition(error): + """ + Check if a gRPC error is FAILED_PRECONDITION status. + + Args: + error: The exception to check + + Returns: + bool: True if error is FAILED_PRECONDITION, False otherwise + + Note: + Command RPCs may return FAILED_PRECONDITION when the adapter's + current lifecycle state does not allow the call. Conformance tests + skip rather than fail in that case. + """ + return ( + isinstance(error, grpc.RpcError) + and error.code() == grpc.StatusCode.FAILED_PRECONDITION + ) + + +def check_command_rpc(rpc_call): + """ + Call a required command RPC (Initialize, StartEmission, ...). + + Args: + rpc_call: Callable that makes the RPC call + + Returns: + The protobuf response on OK. + + Raises: + grpc.RpcError: If the RPC fails with any status other than + FAILED_PRECONDITION. + + Note: + These tests check the HAL wire contract, not adapter functionality. + FAILED_PRECONDITION means the adapter refused because of its own + state; skip so third-party adapters with different sequences still + pass. Probe RPCs (HealthCheck, ResourceHealthCheck, GetProductInfo, + GetStatus) must not use this helper — they must succeed in any state. + """ + try: + return rpc_call() + except grpc.RpcError as e: + if is_failed_precondition(e): + pytest.skip(e.details() or "FAILED_PRECONDITION") + raise + + def check_optional_rpc(rpc_call): """ Helper to test optional RPCs that may return UNIMPLEMENTED. @@ -36,10 +87,13 @@ def check_optional_rpc(rpc_call): Raises: grpc.RpcError: If RPC fails with any status other than UNIMPLEMENTED - + or FAILED_PRECONDITION. + Note: Optional RPCs in HAL API may return UNIMPLEMENTED. This helper treats both successful responses and UNIMPLEMENTED as acceptable outcomes. + FAILED_PRECONDITION (implemented, but not allowed in the current + adapter state) skips the test. """ try: response = rpc_call() @@ -47,6 +101,8 @@ def check_optional_rpc(rpc_call): except grpc.RpcError as e: if is_unimplemented(e): return False, None + if is_failed_precondition(e): + pytest.skip(e.details() or "FAILED_PRECONDITION") raise