Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ jobs:
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${GITHUB_SHA} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o "dist/${name}/dexcli" ./cmd/dexcli
if [[ "${target}" == "linux/amd64" ]]; then
runtime_version="$("dist/${name}/dexcli" version)"
[[ "${runtime_version}" == "dexcli ${VERSION} "* ]] || {
echo "dexcli runtime version does not match ${VERSION}: ${runtime_version}" >&2
exit 1
}
fi
cp LICENSE LEGACY_NOTICES.md "dist/${name}/"
cp -R LICENSES "dist/${name}/LICENSES"
tar -C "dist/${name}" -czf "dist/${name}.tar.gz" \
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/docker-image-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Verify Server runtime version
env:
SERVER_VERSION: ${{ steps.meta.outputs.tag }}
run: |
docker build \
--build-arg "SERVER_VERSION=${SERVER_VERSION}" \
--file server/Dockerfile \
--tag dex-server-release-check \
.
runtime_version="$(docker run --rm --entrypoint /dex/dex-server dex-server-release-check --version)"
[[ "${runtime_version}" == *"${SERVER_VERSION}"* ]] || {
echo "Server runtime version does not match ${SERVER_VERSION}: ${runtime_version}" >&2
exit 1
}
- name: Build and push
uses: docker/build-push-action@v7
with:
Expand All @@ -59,3 +73,5 @@ jobs:
push: true
platforms: linux/arm64,linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/dex-server:${{ steps.meta.outputs.tag }}
build-args: |
SERVER_VERSION=${{ steps.meta.outputs.tag }}
2 changes: 2 additions & 0 deletions .github/workflows/sdk-python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,15 @@ jobs:
dex/_grpc_errors.py \
dex/_invocation_context.py \
dex/_native.pyi \
dex/_server_protocol.py \
dex/_utils.py \
dex/_value_hydrator.py \
dex/_value_mapper.py \
dex/_worker_dispatcher.py \
dex/_worker_service.py \
dex/attribute.py \
dex/attribute_match.py \
dex/async_worker.py \
dex/blob_cache.py \
dex/channel.py \
dex/client.py \
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/sdk-python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,21 @@ jobs:
args: --release --locked --compatibility pypi --out dist
- name: Smoke test installed wheel
shell: bash
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
run: |
python -m pip install sdk-python/dist/*.whl
python - <<'PY'
import os
import tempfile
from importlib.metadata import version

from dex import BlobCacheConfig, open_blob_cache
from dex._server_protocol import sdk_version

expected_version = os.environ["RELEASE_VERSION"]
assert version("dex-python-sdk") == expected_version
assert sdk_version() == expected_version

with tempfile.TemporaryDirectory() as directory:
cache = open_blob_cache(BlobCacheConfig(directory, 1024 * 1024))
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/sdk-rust-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ jobs:
--stage-protocol
- name: Validate stamped workspace
run: cargo metadata --locked --no-deps --format-version 1
- name: Verify runtime version
env:
DEX_EXPECTED_SDK_VERSION: ${{ steps.release.outputs.version }}
run: >-
cargo test --locked -p dex-sdk
server_protocol::tests::runtime_version_matches_release
-- --exact
- name: Build independently publishable crates
run: |
cargo package --locked --allow-dirty -p dex-blob-cache -p dex-protocol
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/sdk-typescript-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ jobs:
run: npm run typecheck
- name: Build TypeScript
run: npm run build
- name: Verify runtime version
env:
RELEASE_VERSION: ${{ steps.release.outputs.version }}
run: |
node --input-type=module <<'NODE'
import { sdkVersion } from "./dist/src/sdk-version.generated.js";
if (sdkVersion !== process.env.RELEASE_VERSION) {
throw new Error(`runtime version ${sdkVersion} does not match ${process.env.RELEASE_VERSION}`);
}
NODE
- name: Test package
run: node --test dist/test/*.test.js
- name: Inspect package contents
Expand Down
17 changes: 17 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,23 @@ binary does not read `web/`, `node_modules`, or the source tree.
- “attribute index synchronization failed”: operators should verify that Dex
can list and add backend visibility indexes. Workers never require a manual
registration command.

## Version compatibility

`dexcli version` continues to print build metadata. Use `dexcli version check`
to call `GetServerInfo` and compare dexcli's inclusive Server protocol interval
with the Server interval:

```shell
dexcli version check --server dex.example.internal:8801 --output json
```

The command reports both artifact versions, both intervals, the highest common
protocol, and `compatible`. It exits 0 only for a valid overlapping interval.
Connection failures, an unimplemented RPC, invalid intervals, and nonoverlapping
intervals produce a structured error and exit 1. The dexcli artifact version is
injected by the release build and is diagnostic only.

## License

[Sustainable Use License 1.0](LICENSE), with legacy portions under their
Expand Down
12 changes: 9 additions & 3 deletions cli/cmd/dexcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,20 @@ func run(ctx context.Context, args []string) error {
return dev.Execute(ctx, args[1:], os.Stdout, os.Stderr, version)
case "codec-server":
return codecserver.Execute(ctx, args[1:], os.Stdout, os.Stderr)
case "version", "--version", "-v":
case "version":
if len(args) > 1 {
return command.NewApp(os.Stdin, os.Stdout, os.Stderr, version).Execute(ctx, args)
}
fmt.Fprintf(os.Stdout, "dexcli %s (commit %s, built %s)\n", version, commit, date)
return nil
case "--version", "-v":
fmt.Fprintf(os.Stdout, "dexcli %s (commit %s, built %s)\n", version, commit, date)
return nil
case "help", "--help", "-h":
printUsage(os.Stdout)
return nil
default:
return command.NewApp(os.Stdin, os.Stdout, os.Stderr).Execute(ctx, args)
return command.NewApp(os.Stdin, os.Stdout, os.Stderr, version).Execute(ctx, args)
}
}

Expand All @@ -68,5 +74,5 @@ func printUsage(output *os.File) {
fmt.Fprintln(output, " visualize Render a static Flow graph from Go or Python source")
fmt.Fprintln(output, " flow Search, inspect, watch, stop, or reset Flows")
fmt.Fprintln(output, " api List, describe, or call FlowService RPCs")
fmt.Fprintln(output, " version Print version information")
fmt.Fprintln(output, " version Print version information or check Server compatibility")
}
34 changes: 23 additions & 11 deletions cli/internal/command/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ import (
)

type App struct {
stdin io.Reader
stdout io.Writer
stderr io.Writer
getenv func(string) string
openBrowser func(string) error
stdin io.Reader
stdout io.Writer
stderr io.Writer
getenv func(string) string
openBrowser func(string) error
artifactVersion string
}

func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App {
func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, artifactVersions ...string) *App {
if stdin == nil {
panic("command stdin must not be nil")
}
Expand All @@ -35,12 +36,20 @@ func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App {
if stderr == nil {
panic("command stderr must not be nil")
}
artifactVersion := "dev"
if len(artifactVersions) > 1 {
panic("command accepts at most one artifact version")
}
if len(artifactVersions) == 1 {
artifactVersion = artifactVersions[0]
}
return &App{
stdin: stdin,
stdout: stdout,
stderr: stderr,
getenv: os.Getenv,
openBrowser: openVisualizationBrowser,
stdin: stdin,
stdout: stdout,
stderr: stderr,
getenv: os.Getenv,
openBrowser: openVisualizationBrowser,
artifactVersion: artifactVersion,
}
}

Expand Down Expand Up @@ -71,6 +80,8 @@ func (a *App) Execute(ctx context.Context, args []string) error {
return newFlowCommand(a.stdin, a.stdout, a.stderr).Execute(ctx, remaining[1:], options)
case "api":
return newAPICommand(a.stdin, a.stdout, a.stderr).Execute(ctx, remaining[1:], options)
case "version":
return a.executeVersion(ctx, remaining[1:], options)
case "help", "--help", "-h":
a.printUsage()
return nil
Expand Down Expand Up @@ -117,6 +128,7 @@ func (a *App) printUsage() {
fmt.Fprintln(a.stdout, " visualize Render a static Flow graph from Go or Python source")
fmt.Fprintln(a.stdout, " flow Start, operate, inspect, or watch Flows")
fmt.Fprintln(a.stdout, " api List, describe, or call FlowService RPCs")
fmt.Fprintln(a.stdout, " version Check dexcli and Server protocol compatibility")
fmt.Fprintln(a.stdout)
fmt.Fprintln(a.stdout, "Global flags:")
fmt.Fprintln(a.stdout, " --server host:port Dex FlowService target")
Expand Down
132 changes: 132 additions & 0 deletions cli/internal/command/command_integ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ type testFlowService struct {
timeTravelRequest *dexpb.ResetFlowRequest
waitStarted chan struct{}
waitOnce sync.Once
serverInfo *dexpb.ServerInfo
serverInfoFailure error
}

func (s *testFlowService) GetServerInfo(
context.Context,
*emptypb.Empty,
) (*dexpb.ServerInfo, error) {
if s.serverInfoFailure != nil {
return nil, s.serverInfoFailure
}
if s.serverInfo != nil {
return s.serverInfo, nil
}
return &dexpb.ServerInfo{
ServerVersion: "test-server",
MinimumSupportedProtocolVersion: 1,
CurrentProtocolVersion: 1,
}, nil
}

func (s *testFlowService) StartFlow(
Expand Down Expand Up @@ -582,6 +601,119 @@ func TestAPIDescriptorIncludesEveryFlowServiceMethod(t *testing.T) {
}
}

func TestVersionCheckReportsNegotiatedProtocol(t *testing.T) {
_, address := startTestFlowService(t)
result := executeTestCommand(t, nil, "version", "check", "--server", address)
if result["compatible"] != true || result["negotiatedProtocol"] != float64(1) {
t.Fatalf("unexpected version check result: %#v", result)
}
if result["clientVersion"] != "dev" || result["serverVersion"] != "test-server" {
t.Fatalf("unexpected artifact versions: %#v", result)
}
}

func TestVersionCheckRejectsNonoverlappingProtocols(t *testing.T) {
service, address := startTestFlowService(t)
service.serverInfo = &dexpb.ServerInfo{
ServerVersion: "future-server",
MinimumSupportedProtocolVersion: 2,
CurrentProtocolVersion: 2,
}
stdout := &bytes.Buffer{}
app := NewApp(bytes.NewReader(nil), stdout, &bytes.Buffer{}, "test-cli")
err := app.Execute(context.Background(), []string{"version", "check", "--server", address})
if err == nil || ExitCode(err) != 1 {
t.Fatalf("expected compatibility failure, got %v", err)
}
errorOutput := &bytes.Buffer{}
WriteError(errorOutput, err)
var payload map[string]any
if decodeErr := json.Unmarshal(errorOutput.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
errorPayload := payload["error"].(map[string]any)
if errorPayload["kind"] != "compatibility" || errorPayload["compatible"] != false {
t.Fatalf("unexpected structured error: %#v", errorPayload)
}
}

func TestVersionCheckRejectsInvalidAndUnimplementedServerInfo(t *testing.T) {
testCases := []struct {
name string
serverInfo *dexpb.ServerInfo
failure error
reason string
}{
{
name: "zero interval",
serverInfo: &dexpb.ServerInfo{ServerVersion: "invalid"},
reason: "Server protocol interval is invalid",
},
{
name: "reversed interval",
serverInfo: &dexpb.ServerInfo{
ServerVersion: "invalid",
MinimumSupportedProtocolVersion: 2,
CurrentProtocolVersion: 1,
},
reason: "Server protocol interval is invalid",
},
{
name: "unimplemented",
failure: status.Error(codes.Unimplemented, "old Server"),
reason: "GetServerInfo failed",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
service, address := startTestFlowService(t)
service.serverInfo = testCase.serverInfo
service.serverInfoFailure = testCase.failure
app := NewApp(bytes.NewReader(nil), &bytes.Buffer{}, &bytes.Buffer{}, "test-cli")
err := app.Execute(
context.Background(),
[]string{"version", "check", "--server", address},
)
if err == nil || ExitCode(err) != 1 {
t.Fatalf("expected compatibility failure, got %v", err)
}
errorOutput := &bytes.Buffer{}
WriteError(errorOutput, err)
var payload map[string]any
if decodeErr := json.Unmarshal(errorOutput.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
errorPayload := payload["error"].(map[string]any)
if errorPayload["reason"] != testCase.reason {
t.Fatalf("unexpected structured error: %#v", errorPayload)
}
})
}
}

func TestVersionCheckReportsConnectionFailure(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
if err := listener.Close(); err != nil {
t.Fatal(err)
}
app := NewApp(bytes.NewReader(nil), &bytes.Buffer{}, &bytes.Buffer{}, "test-cli")
err = app.Execute(context.Background(), []string{
"version", "check", "--server", address, "--timeout", "100ms",
})
if err == nil || ExitCode(err) != 1 {
t.Fatalf("expected connection failure, got %v", err)
}
errorOutput := &bytes.Buffer{}
WriteError(errorOutput, err)
if !strings.Contains(errorOutput.String(), `"reason":"GetServerInfo failed"`) {
t.Fatalf("unexpected structured error: %s", errorOutput.String())
}
}

func startTestFlowService(t *testing.T) (*testFlowService, string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
Expand Down
6 changes: 5 additions & 1 deletion cli/internal/command/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Error struct {
operation string
kind string
cause error
details map[string]any
}

func newUsageError(operation string, cause error) *Error {
Expand Down Expand Up @@ -79,8 +80,11 @@ func WriteError(output io.Writer, err error) {
"message": commandError.cause.Error(),
},
}
errorPayload := payload["error"].(map[string]any)
for key, value := range commandError.details {
errorPayload[key] = value
}
if rpcStatus, ok := status.FromError(commandError.cause); ok {
errorPayload := payload["error"].(map[string]any)
errorPayload["grpcCode"] = int32(rpcStatus.Code())
errorPayload["grpcCodeName"] = rpcStatus.Code().String()
details := make([]any, 0, len(rpcStatus.Details()))
Expand Down
Loading
Loading