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
64 changes: 50 additions & 14 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,32 +77,68 @@ jobs:
- name: Init and start spice app (Windows)
if: matrix.os == 'windows-latest'
run: |
spice install
spice init spice_qs
cd spice_qs
spice add spiceai/quickstart
Start-Process -FilePath "spice" -ArgumentList "run" -RedirectStandardOutput "spice.log" -RedirectStandardError "spice.err.log"
# Wait for Spice to be ready
Write-Host "Waiting for Spice to be ready..."
shell: pwsh

- name: Test
if: matrix.os != 'windows-latest'
env:
SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }}
run: go test -v ./...

- name: Start runtime and test (Windows)
if: matrix.os == 'windows-latest'
env:
SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }}
run: |
$spicedPath = Join-Path $HOME ".spice\bin\spiced"
$spicedDir = Join-Path (Get-Location) "spice_qs"
$logPath = Join-Path $spicedDir "spice.log"
$errPath = Join-Path $spicedDir "spice.err.log"
$proc = Start-Process -FilePath $spicedPath -WorkingDirectory $spicedDir -RedirectStandardOutput $logPath -RedirectStandardError $errPath -PassThru
Write-Host "Started spiced PID=$($proc.Id)"
# Wait for Spice HTTP to be ready
Write-Host "Waiting for Spice HTTP to be ready..."
for ($i = 1; $i -le 60; $i++) {
try {
$response = Invoke-WebRequest -Uri "http://localhost:8090/v1/ready" -UseBasicParsing -ErrorAction Stop
$response = Invoke-WebRequest -Uri "http://127.0.0.1:8090/v1/ready" -UseBasicParsing -ErrorAction Stop
if ($response.Content -match "ready") {
Write-Host "Spice is ready!"
Write-Host "Spice HTTP is ready!"
break
}
} catch {
# Ignore errors, keep waiting
}
Write-Host "Waiting... ($i/60)"
} catch { }
Write-Host "Waiting HTTP... ($i/60)"
Start-Sleep -Seconds 1
}
# Wait for Spice Flight (gRPC) to be ready by checking the TCP port
Write-Host "Waiting for Spice Flight to be ready..."
for ($i = 1; $i -le 60; $i++) {
$tcp = New-Object System.Net.Sockets.TcpClient
try {
$tcp.Connect("127.0.0.1", 50051)
if ($tcp.Connected) {
Write-Host "Spice Flight is ready!"
$tcp.Close()
break
}
} catch { }
finally { $tcp.Close() }
Write-Host "Waiting Flight... ($i/60)"
Start-Sleep -Seconds 1
}
try {
go test -v ./...
$testExit = $LASTEXITCODE
} finally {
Write-Host "Stopping spiced PID=$($proc.Id)"
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
exit $testExit
shell: pwsh

- name: Test
env:
SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }}
run: go test -v ./...

- name: Print Spice logs (Unix)
if: always() && matrix.os != 'windows-latest'
run: |
Expand Down
76 changes: 75 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package gospice

import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"math"
"net/http"
"os"
"strings"
"time"

Expand Down Expand Up @@ -42,6 +44,10 @@ type SpiceClient struct {
backoffPolicy backoff.BackOff
maxRetries uint
userAgent string

tlsClientCertFile string
tlsClientKeyFile string
tlsRootCertFile string
}

// NewSpiceClient creates a new SpiceClient
Expand Down Expand Up @@ -114,6 +120,32 @@ func WithSpiceCloudAddress() SpiceClientModifier {
}
}

// WithTLSClientCertificate configures the client to present a client certificate
// during the TLS handshake for mutual TLS (mTLS) authentication.
// Both certFile and keyFile must be PEM-encoded.
func WithTLSClientCertificate(certFile, keyFile string) SpiceClientModifier {
return func(c *SpiceClient) error {
if certFile == "" || keyFile == "" {
return fmt.Errorf("both certFile and keyFile are required for mTLS")
}
c.tlsClientCertFile = certFile
c.tlsClientKeyFile = keyFile
return nil
}
}

// WithTLSRootCertificate configures the client to use a custom CA certificate
// file for server verification instead of (in addition to) the system certificate store.
func WithTLSRootCertificate(caFile string) SpiceClientModifier {
return func(c *SpiceClient) error {
if caFile == "" {
return fmt.Errorf("caFile is required")
}
c.tlsRootCertFile = caFile
return nil
}
}

// Init initializes the SpiceClient
func (c *SpiceClient) Init(opts ...SpiceClientModifier) error {
for _, opt := range opts {
Expand All @@ -128,13 +160,42 @@ func (c *SpiceClient) Init(opts ...SpiceClientModifier) error {
return fmt.Errorf("error getting system cert pool: %w", err)
}

if c.tlsRootCertFile != "" {
caPem, err := os.ReadFile(c.tlsRootCertFile)
if err != nil {
return fmt.Errorf("error reading TLS root certificate '%s': %w", c.tlsRootCertFile, err)
}
if !systemCertPool.AppendCertsFromPEM(caPem) {
return fmt.Errorf("failed to append CA certificate from '%s'", c.tlsRootCertFile)
}
}

flightClient, err := c.createClient(c.flightAddress, systemCertPool)
if err != nil {
return fmt.Errorf("error creating Spice Flight client: %w", err)
}

c.flightClient = flightClient

// Update the HTTP client transport with the same TLS configuration
// (custom CA and/or client certificate) used by the Flight client.
httpTlsConfig := &tls.Config{
RootCAs: systemCertPool,
MinVersion: tls.VersionTLS12,
}
if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" {
clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile)
if err != nil {
return fmt.Errorf("error loading client certificate for HTTP mTLS: %w", err)
}
httpTlsConfig.Certificates = []tls.Certificate{clientCert}
}
c.httpClient.Transport = &http.Transport{
MaxIdleConnsPerHost: 10,
DisableCompression: false,
TLSClientConfig: httpTlsConfig,
}

// Initialize ADBC client - non-fatal, will be initialized lazily if needed
// This allows health checks to work even if ADBC connection fails initially
_ = c.initADBC()
Expand Down Expand Up @@ -226,7 +287,20 @@ func (c *SpiceClient) createClient(address string, systemCertPool *x509.CertPool
address = strings.TrimPrefix(address, "grpc://")
grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(systemCertPool, "")))
tlsConfig := &tls.Config{
RootCAs: systemCertPool,
MinVersion: tls.VersionTLS12,
}

if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" {
clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile)
if err != nil {
return nil, fmt.Errorf("error loading client certificate for mTLS: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{clientCert}
}

grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
}

client, err := flight.NewClientWithMiddleware(
Expand Down
4 changes: 2 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func LoadConfig() ClientConfig {

func LoadLocalConfig() ClientConfig {
return ClientConfig{
HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://localhost:8090"),
FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://localhost:50051"),
HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://127.0.0.1:8090"),
FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://127.0.0.1:50051"),
}
}

Expand Down
Loading