├── .dockerignore ├── .mockery.yaml ├── .gitignore ├── Dockerfile ├── internal ├── mcp │ ├── transport_stdio_test.go │ ├── mocks_test.go │ ├── server_test.go │ ├── server.go │ ├── transport_sse_test.go │ └── tools.go └── spot │ ├── data.go │ ├── types.go │ ├── score.go │ ├── data_test.go │ └── client.go ├── .github └── workflows │ ├── release.yaml │ ├── docker.yaml │ ├── ci.yaml │ └── auto-release.yaml ├── go.mod ├── cmd └── spotinfo │ └── mocks_test.go ├── CLAUDE.md ├── Makefile ├── README.md ├── .golangci.yaml ├── docs ├── aws-spot-placement-scores.md ├── data-sources.md ├── usage.md ├── mcp-server.md ├── claude-desktop-setup.md ├── examples.md └── troubleshooting.md ├── go.sum └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | # binaries 2 | .bin 3 | 4 | # coverage 5 | .cover 6 | 7 | # IDE 8 | .idea 9 | .vscode 10 | 11 | # CI/CD 12 | .github 13 | 14 | # helper files 15 | .gitignore 16 | *.todo 17 | 18 | # documentation 19 | *.md 20 | docs 21 | 22 | # customization and secrets 23 | .env 24 | 25 | # license 26 | LICENSE 27 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | dir: "{{.InterfaceDir}}" 2 | filename: "mocks_test.go" 3 | inpackage: true 4 | template: testify 5 | packages: 6 | spotinfo/internal/spot: 7 | interfaces: 8 | advisorProvider: 9 | pricingProvider: 10 | scoreProvider: 11 | awsAPIProvider: 12 | spotinfo/cmd/spotinfo: 13 | interfaces: 14 | spotClient: 15 | spotinfo/internal/mcp: 16 | interfaces: 17 | spotClient: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # code coverage 2 | .cover 3 | coverage.html 4 | coverage.out 5 | 6 | # git repo 7 | .git 8 | 9 | # IDE customization 10 | .idea 11 | .vscode 12 | 13 | # binaries 14 | .bin 15 | 16 | # env customization 17 | .env 18 | 19 | # claude settings 20 | .claude 21 | 22 | # temporary 23 | **/.DS_Store 24 | **/debug 25 | **/debug.test 26 | 27 | # ignore data file, downloaded during make 28 | public/spot/data/*.json 29 | public/spot/data/*.json-e 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:experimental 2 | 3 | # 4 | # ----- Go Builder Image ------ 5 | # 6 | FROM --platform=${BUILDPLATFORM} golang:1.24-alpine AS builder 7 | 8 | # Only install essential tools for building 9 | RUN apk add --no-cache git make ca-certificates 10 | 11 | # 12 | # ----- Build and Test Image ----- 13 | # 14 | FROM --platform=${BUILDPLATFORM} builder AS build 15 | 16 | # passed by buildkit 17 | ARG TARGETOS 18 | ARG TARGETARCH 19 | 20 | # set working directory 21 | RUN mkdir -p /go/src/app 22 | WORKDIR /go/src/app 23 | 24 | # load dependency 25 | COPY go.mod . 26 | COPY go.sum . 27 | RUN --mount=type=cache,target=/go/mod go mod download 28 | 29 | # copy sources 30 | COPY . . 31 | 32 | # test and build 33 | RUN --mount=type=cache,target=/root/.cache/go-build TARGETOS=${TARGETOS} TARGETARCH=${TARGETARCH} make build 34 | 35 | 36 | 37 | # 38 | # ------ spotinfo release Docker image ------ 39 | # 40 | FROM scratch 41 | 42 | # copy CA certificates 43 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 44 | 45 | # this is the last command since it's never cached 46 | COPY --from=build /go/src/app/.bin/spotinfo /spotinfo 47 | 48 | ENTRYPOINT ["/spotinfo"] -------------------------------------------------------------------------------- /internal/mcp/transport_stdio_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "spotinfo/internal/spot" 12 | ) 13 | 14 | // TestStdioTransport_ContextCancellation tests that stdio transport respects context cancellation 15 | func TestStdioTransport_ContextCancellation(t *testing.T) { 16 | cfg := Config{ 17 | Version: "1.0.0", 18 | Logger: slog.Default(), 19 | SpotClient: spot.New(), 20 | } 21 | 22 | server, err := NewServer(cfg) 23 | require.NoError(t, err) 24 | 25 | // Create context that we'll cancel quickly 26 | ctx, cancel := context.WithCancel(context.Background()) 27 | 28 | // Start server in goroutine 29 | done := make(chan error, 1) 30 | go func() { 31 | done <- server.ServeStdio(ctx) 32 | }() 33 | 34 | // Cancel context immediately (stdio will block waiting for input) 35 | cancel() 36 | 37 | // Server should shut down gracefully 38 | select { 39 | case err := <-done: 40 | // We expect some error due to context cancellation or stdin handling 41 | t.Logf("ServeStdio returned: %v", err) 42 | case <-time.After(2 * time.Second): 43 | t.Fatal("server did not shut down within timeout") 44 | } 45 | } 46 | 47 | // Note: Full stdio protocol testing should be done in integration tests, 48 | // not unit tests, as it requires actual stdin/stdout handling and is 49 | // difficult to mock properly without overly complex test setup. 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+*' 7 | - '[0-9]+.[0-9]+.[0-9]+*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 20 17 | permissions: 18 | contents: write 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: '1.24' 30 | 31 | - name: Get version 32 | id: version 33 | run: | 34 | TAG_NAME=${GITHUB_REF#refs/tags/} 35 | # Add 'v' prefix if not present for release name 36 | if [[ $TAG_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then 37 | RELEASE_VERSION="v${TAG_NAME}" 38 | else 39 | RELEASE_VERSION="${TAG_NAME}" 40 | fi 41 | echo "VERSION=${TAG_NAME}" >> $GITHUB_OUTPUT 42 | echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_OUTPUT 43 | 44 | - name: Update embedded data 45 | run: make update-data update-price 46 | 47 | - name: Build release binaries 48 | run: make release VERSION=${{ steps.version.outputs.VERSION }} GITHUB_RELEASE=${{ steps.version.outputs.RELEASE_VERSION }} 49 | 50 | - name: Create release 51 | run: | 52 | gh release create ${{ steps.version.outputs.VERSION }} \ 53 | --title "${{ steps.version.outputs.RELEASE_VERSION }}" \ 54 | --generate-notes \ 55 | .bin/* 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - 'v[0-9]+.[0-9]+.[0-9]+*' 8 | - '[0-9]+.[0-9]+.[0-9]+*' 9 | pull_request: 10 | branches: [ master ] 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | docker: 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 30 24 | permissions: 25 | contents: read 26 | packages: write 27 | id-token: write 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Log in to Container Registry 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ${{ env.REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Extract metadata 44 | id: meta 45 | uses: docker/metadata-action@v5 46 | with: 47 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 48 | tags: | 49 | type=ref,event=branch 50 | type=ref,event=pr 51 | type=semver,pattern={{version}} 52 | type=semver,pattern={{major}}.{{minor}} 53 | type=semver,pattern={{major}} 54 | type=raw,value=latest,enable={{is_default_branch}} 55 | 56 | - name: Build and push Docker image 57 | uses: docker/build-push-action@v5 58 | with: 59 | context: . 60 | platforms: linux/amd64,linux/arm64 61 | push: ${{ github.event_name != 'pull_request' }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module spotinfo 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.36.6 7 | github.com/aws/aws-sdk-go-v2/config v1.29.18 8 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.236.0 9 | github.com/jedib0t/go-pretty/v6 v6.6.8 10 | github.com/mark3labs/mcp-go v0.35.0 11 | github.com/spf13/cast v1.9.2 12 | github.com/stretchr/testify v1.10.0 13 | github.com/urfave/cli/v2 v2.27.7 14 | ) 15 | 16 | require ( 17 | github.com/aws/aws-sdk-go-v2/credentials v1.17.71 // indirect 18 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect 20 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect 27 | github.com/aws/smithy-go v1.22.5 // indirect 28 | github.com/bluele/gcache v0.0.2 29 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/google/uuid v1.6.0 // indirect 32 | github.com/mattn/go-runewidth v0.0.16 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/rivo/uniseg v0.4.7 // indirect 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 36 | github.com/stretchr/objx v0.5.2 // indirect 37 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect 38 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 39 | golang.org/x/sys v0.33.0 // indirect 40 | golang.org/x/text v0.26.0 // indirect 41 | golang.org/x/time v0.12.0 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 15 17 | permissions: 18 | contents: read 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v4 26 | with: 27 | go-version: '1.24' 28 | 29 | - name: Cache Go modules 30 | uses: actions/cache@v4 31 | with: 32 | path: | 33 | ~/.cache/go-build 34 | ~/go/pkg/mod 35 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 36 | restore-keys: | 37 | ${{ runner.os }}-go- 38 | 39 | - name: Download dependencies 40 | run: go mod download 41 | 42 | - name: Update embedded data 43 | run: make update-data update-price 44 | 45 | - name: Run tests with coverage 46 | run: go test -cover -v ./... 47 | 48 | - name: Run golangci-lint 49 | run: make lint 50 | 51 | - name: Build binary 52 | run: make build 53 | 54 | build-matrix: 55 | runs-on: ubuntu-latest 56 | timeout-minutes: 10 57 | needs: test 58 | if: github.event_name == 'push' 59 | permissions: 60 | contents: read 61 | 62 | strategy: 63 | matrix: 64 | os: [linux, darwin, windows] 65 | arch: [amd64, arm64] 66 | exclude: 67 | - os: windows 68 | arch: arm64 69 | 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v4 73 | 74 | - name: Set up Go 75 | uses: actions/setup-go@v4 76 | with: 77 | go-version: '1.24' 78 | 79 | - name: Update embedded data 80 | run: make update-data update-price 81 | 82 | - name: Build for ${{ matrix.os }}/${{ matrix.arch }} 83 | env: 84 | GOOS: ${{ matrix.os }} 85 | GOARCH: ${{ matrix.arch }} 86 | run: make build -------------------------------------------------------------------------------- /cmd/spotinfo/mocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "spotinfo/internal/spot" 10 | 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockspotClient creates a new instance of mockspotClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockspotClient(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockspotClient { 20 | mock := &mockspotClient{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockspotClient is an autogenerated mock type for the spotClient type 29 | type mockspotClient struct { 30 | mock.Mock 31 | } 32 | 33 | type mockspotClient_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockspotClient) EXPECT() *mockspotClient_Expecter { 38 | return &mockspotClient_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // GetSpotSavings provides a mock function for the type mockspotClient 42 | func (_mock *mockspotClient) GetSpotSavings(ctx context.Context, opts ...spot.GetSpotSavingsOption) ([]spot.Advice, error) { 43 | var tmpRet mock.Arguments 44 | if len(opts) > 0 { 45 | tmpRet = _mock.Called(ctx, opts) 46 | } else { 47 | tmpRet = _mock.Called(ctx) 48 | } 49 | ret := tmpRet 50 | 51 | if len(ret) == 0 { 52 | panic("no return value specified for GetSpotSavings") 53 | } 54 | 55 | var r0 []spot.Advice 56 | var r1 error 57 | if returnFunc, ok := ret.Get(0).(func(context.Context, ...spot.GetSpotSavingsOption) ([]spot.Advice, error)); ok { 58 | return returnFunc(ctx, opts...) 59 | } 60 | if returnFunc, ok := ret.Get(0).(func(context.Context, ...spot.GetSpotSavingsOption) []spot.Advice); ok { 61 | r0 = returnFunc(ctx, opts...) 62 | } else { 63 | if ret.Get(0) != nil { 64 | r0 = ret.Get(0).([]spot.Advice) 65 | } 66 | } 67 | if returnFunc, ok := ret.Get(1).(func(context.Context, ...spot.GetSpotSavingsOption) error); ok { 68 | r1 = returnFunc(ctx, opts...) 69 | } else { 70 | r1 = ret.Error(1) 71 | } 72 | return r0, r1 73 | } 74 | 75 | // mockspotClient_GetSpotSavings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSpotSavings' 76 | type mockspotClient_GetSpotSavings_Call struct { 77 | *mock.Call 78 | } 79 | 80 | // GetSpotSavings is a helper method to define mock.On call 81 | // - ctx context.Context 82 | // - opts ...spot.GetSpotSavingsOption 83 | func (_e *mockspotClient_Expecter) GetSpotSavings(ctx interface{}, opts ...interface{}) *mockspotClient_GetSpotSavings_Call { 84 | return &mockspotClient_GetSpotSavings_Call{Call: _e.mock.On("GetSpotSavings", 85 | append([]interface{}{ctx}, opts...)...)} 86 | } 87 | 88 | func (_c *mockspotClient_GetSpotSavings_Call) Run(run func(ctx context.Context, opts ...spot.GetSpotSavingsOption)) *mockspotClient_GetSpotSavings_Call { 89 | _c.Call.Run(func(args mock.Arguments) { 90 | var arg0 context.Context 91 | if args[0] != nil { 92 | arg0 = args[0].(context.Context) 93 | } 94 | var arg1 []spot.GetSpotSavingsOption 95 | var variadicArgs []spot.GetSpotSavingsOption 96 | if len(args) > 1 { 97 | variadicArgs = args[1].([]spot.GetSpotSavingsOption) 98 | } 99 | arg1 = variadicArgs 100 | run( 101 | arg0, 102 | arg1..., 103 | ) 104 | }) 105 | return _c 106 | } 107 | 108 | func (_c *mockspotClient_GetSpotSavings_Call) Return(advices []spot.Advice, err error) *mockspotClient_GetSpotSavings_Call { 109 | _c.Call.Return(advices, err) 110 | return _c 111 | } 112 | 113 | func (_c *mockspotClient_GetSpotSavings_Call) RunAndReturn(run func(ctx context.Context, opts ...spot.GetSpotSavingsOption) ([]spot.Advice, error)) *mockspotClient_GetSpotSavings_Call { 114 | _c.Call.Return(run) 115 | return _c 116 | } 117 | -------------------------------------------------------------------------------- /internal/mcp/mocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package mcp 6 | 7 | import ( 8 | "context" 9 | "spotinfo/internal/spot" 10 | 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockspotClient creates a new instance of mockspotClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockspotClient(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockspotClient { 20 | mock := &mockspotClient{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockspotClient is an autogenerated mock type for the spotClient type 29 | type mockspotClient struct { 30 | mock.Mock 31 | } 32 | 33 | type mockspotClient_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockspotClient) EXPECT() *mockspotClient_Expecter { 38 | return &mockspotClient_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // GetSpotSavings provides a mock function for the type mockspotClient 42 | func (_mock *mockspotClient) GetSpotSavings(ctx context.Context, opts ...spot.GetSpotSavingsOption) ([]spot.Advice, error) { 43 | var tmpRet mock.Arguments 44 | if len(opts) > 0 { 45 | tmpRet = _mock.Called(ctx, opts) 46 | } else { 47 | tmpRet = _mock.Called(ctx) 48 | } 49 | ret := tmpRet 50 | 51 | if len(ret) == 0 { 52 | panic("no return value specified for GetSpotSavings") 53 | } 54 | 55 | var r0 []spot.Advice 56 | var r1 error 57 | if returnFunc, ok := ret.Get(0).(func(context.Context, ...spot.GetSpotSavingsOption) ([]spot.Advice, error)); ok { 58 | return returnFunc(ctx, opts...) 59 | } 60 | if returnFunc, ok := ret.Get(0).(func(context.Context, ...spot.GetSpotSavingsOption) []spot.Advice); ok { 61 | r0 = returnFunc(ctx, opts...) 62 | } else { 63 | if ret.Get(0) != nil { 64 | r0 = ret.Get(0).([]spot.Advice) 65 | } 66 | } 67 | if returnFunc, ok := ret.Get(1).(func(context.Context, ...spot.GetSpotSavingsOption) error); ok { 68 | r1 = returnFunc(ctx, opts...) 69 | } else { 70 | r1 = ret.Error(1) 71 | } 72 | return r0, r1 73 | } 74 | 75 | // mockspotClient_GetSpotSavings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSpotSavings' 76 | type mockspotClient_GetSpotSavings_Call struct { 77 | *mock.Call 78 | } 79 | 80 | // GetSpotSavings is a helper method to define mock.On call 81 | // - ctx context.Context 82 | // - opts ...spot.GetSpotSavingsOption 83 | func (_e *mockspotClient_Expecter) GetSpotSavings(ctx interface{}, opts ...interface{}) *mockspotClient_GetSpotSavings_Call { 84 | return &mockspotClient_GetSpotSavings_Call{Call: _e.mock.On("GetSpotSavings", 85 | append([]interface{}{ctx}, opts...)...)} 86 | } 87 | 88 | func (_c *mockspotClient_GetSpotSavings_Call) Run(run func(ctx context.Context, opts ...spot.GetSpotSavingsOption)) *mockspotClient_GetSpotSavings_Call { 89 | _c.Call.Run(func(args mock.Arguments) { 90 | var arg0 context.Context 91 | if args[0] != nil { 92 | arg0 = args[0].(context.Context) 93 | } 94 | var arg1 []spot.GetSpotSavingsOption 95 | var variadicArgs []spot.GetSpotSavingsOption 96 | if len(args) > 1 { 97 | variadicArgs = args[1].([]spot.GetSpotSavingsOption) 98 | } 99 | arg1 = variadicArgs 100 | run( 101 | arg0, 102 | arg1..., 103 | ) 104 | }) 105 | return _c 106 | } 107 | 108 | func (_c *mockspotClient_GetSpotSavings_Call) Return(advices []spot.Advice, err error) *mockspotClient_GetSpotSavings_Call { 109 | _c.Call.Return(advices, err) 110 | return _c 111 | } 112 | 113 | func (_c *mockspotClient_GetSpotSavings_Call) RunAndReturn(run func(ctx context.Context, opts ...spot.GetSpotSavingsOption) ([]spot.Advice, error)) *mockspotClient_GetSpotSavings_Call { 114 | _c.Call.Return(run) 115 | return _c 116 | } 117 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | `spotinfo` is a Go CLI tool that provides command-line access to AWS EC2 Spot Instance pricing and interruption data. It uses embedded AWS data feeds as fallback when network connectivity is unavailable. 8 | 9 | ## Development Commands 10 | 11 | ### Building 12 | - `make build` - Build binary for current OS/arch 13 | - `make all` - Build with full pipeline (update data, format, lint, test, build) 14 | - `make release` - Build binaries for multiple platforms 15 | 16 | ### Testing 17 | - `make test` - Run tests with formatting 18 | - `make test-verbose` - Run tests with verbose output and coverage 19 | - `make test-race` - Run tests with race detector 20 | - `make test-coverage` - Run tests with coverage reporting 21 | 22 | ### Code Quality 23 | - `make lint` - Run golangci-lint with config from `.golangci.yaml` 24 | - `make fmt` - Run gofmt on all source files 25 | 26 | ### Data Updates 27 | - `make update-data` - Update embedded Spot Advisor data from AWS 28 | - `make update-price` - Update embedded spot pricing data from AWS 29 | 30 | ### Dependencies 31 | - `make check-deps` - Verify system has required dependencies (wget) 32 | - `make setup-tools` - Install all development tools 33 | 34 | ## Architecture 35 | 36 | ### Core Components 37 | - `cmd/main.go` - CLI entry point using urfave/cli/v2 38 | - `public/spot/` - Core business logic package 39 | - `info.go` - Spot advisor data processing and filtering 40 | - `price.go` - Spot pricing data processing 41 | - `data/` - Embedded JSON data files from AWS feeds 42 | 43 | ### Data Sources 44 | The tool uses two AWS public data feeds: 45 | 1. Spot Instance Advisor data: `https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json` 46 | 2. Spot pricing data: `http://spot-price.s3.amazonaws.com/spot.js` 47 | 48 | Both are embedded in the binary during build for offline capability. 49 | 50 | ### Key Libraries 51 | - `github.com/urfave/cli/v2` - CLI framework 52 | - `github.com/jedib0t/go-pretty/v6` - Table formatting 53 | - `github.com/pkg/errors` - Error handling 54 | - `github.com/stretchr/testify` - Testing framework with assertions 55 | 56 | ## Build Requirements 57 | - Go 1.24+ 58 | - wget (for data updates) 59 | - golangci-lint (installed via make setup-tools) 60 | 61 | ## Output Formats 62 | The CLI supports multiple output formats: number, text, json, table, csv 63 | 64 | ## CI/CD Pipeline 65 | 66 | ### GitHub Actions Workflows 67 | - **ci.yaml**: Modern CI with Go 1.24, tests, linting, matrix builds for all platforms 68 | - **release.yaml**: Tag-triggered releases with binary uploads using standard Go toolchain 69 | - **docker.yaml**: Multi-arch Docker images published to GitHub Container Registry (ghcr.io) 70 | - **auto-release.yaml**: Quarterly automated releases with smart change detection and semantic versioning 71 | 72 | ### Docker 73 | - **Build**: `docker build -t spotinfo .` (uses Go 1.24 and `make build`) 74 | - **Multi-arch**: Supports linux/amd64 and linux/arm64 platforms 75 | - **Registry**: Published to `ghcr.io/alexei-led/spotinfo` 76 | - **Base**: Uses scratch image with ca-certificates for minimal attack surface 77 | 78 | ### Release Process 79 | 1. **Manual Release**: Create and push a tag starting with 'v' (e.g., `git tag v1.2.3 && git push origin v1.2.3`) 80 | 2. **Automated Release**: Runs quarterly (Jan/Apr/Jul/Oct) with automatic version bumping 81 | 3. **Artifacts**: Cross-platform binaries for Linux/macOS/Windows on AMD64/ARM64 82 | 83 | ## Testing 84 | - **Framework**: Uses testify for assertions and test structure 85 | - **Parallel Execution**: Tests run in parallel for better performance 86 | - **Resilient Patterns**: Tests are resilient to data changes from external feeds 87 | - **Coverage**: Comprehensive test coverage with `make test-coverage` 88 | 89 | ## Development Guidance 90 | - Use `make` commands for all development tasks 91 | - Run `make test-verbose` before committing changes 92 | - Update embedded data with `make update-data update-price` when needed 93 | - Follow Go 1.24 best practices and modern testing patterns 94 | - NEVER add Claude as co-author to git commit message -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for spotinfo 2 | 3 | # Build variables 4 | MODULE = $(shell go list -m) 5 | VERSION ?= $(shell git describe --tags --always --dirty --match="v*" 2> /dev/null || echo v0) 6 | DATE ?= $(shell date +%FT%T%z) 7 | COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null) 8 | BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) 9 | 10 | # Build flags 11 | LDFLAGS = -X main.Version=$(VERSION) -X main.BuildDate=$(DATE) -X main.GitCommit=$(COMMIT) -X main.GitBranch=$(BRANCH) -X main.GitHubRelease=$(GITHUB_RELEASE) 12 | 13 | # Directories 14 | BIN_DIR = .bin 15 | 16 | # Release platforms 17 | PLATFORMS = darwin linux windows 18 | ARCHITECTURES = amd64 arm64 19 | 20 | # Data URLs 21 | SPOT_ADVISOR_URL = "https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json" 22 | SPOT_PRICE_URL = "http://spot-price.s3.amazonaws.com/spot.js" 23 | 24 | # Go environment 25 | export GO111MODULE=on 26 | export CGO_ENABLED=0 27 | 28 | .PHONY: all build test test-verbose test-race test-coverage lint fmt clean help version 29 | .PHONY: update-data update-price check-deps setup-tools release 30 | 31 | # Default target 32 | all: build 33 | 34 | # Build binary for current platform 35 | build: update-data update-price 36 | @echo "Building binary..." 37 | @go build -tags release -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(shell basename $(MODULE)) ./cmd/spotinfo 38 | 39 | # Test targets (no formatting requirement) 40 | test: 41 | @echo "Running tests..." 42 | @go test ./... 43 | 44 | test-verbose: 45 | @echo "Running tests with verbose output..." 46 | @go test -v ./... 47 | 48 | test-race: 49 | @echo "Running tests with race detector..." 50 | @go test -race ./... 51 | 52 | test-coverage: 53 | @echo "Running tests with coverage..." 54 | @go test -covermode=atomic -coverprofile=coverage.out ./... 55 | @go tool cover -html=coverage.out -o coverage.html 56 | @go tool cover -func=coverage.out 57 | 58 | # Code quality 59 | lint: setup-tools 60 | @echo "Running linter..." 61 | @golangci-lint run -v -c .golangci.yaml ./... 62 | 63 | fmt: 64 | @echo "Formatting code..." 65 | @go fmt ./... 66 | 67 | # Data updates 68 | check-deps: 69 | @command -v wget > /dev/null 2>&1 || (echo "Error: wget is required" && exit 1) 70 | @echo "Dependencies satisfied" 71 | 72 | update-data: check-deps 73 | @echo "Updating spot advisor data..." 74 | @mkdir -p public/spot/data 75 | @wget -nv $(SPOT_ADVISOR_URL) -O public/spot/data/spot-advisor-data.json 76 | 77 | update-price: check-deps 78 | @echo "Updating spot pricing data..." 79 | @mkdir -p public/spot/data 80 | @wget -nv $(SPOT_PRICE_URL) -O public/spot/data/spot-price-data.json 81 | @sed -i'' -e "s/callback(//g" public/spot/data/spot-price-data.json 82 | @sed -i'' -e "s/);//g" public/spot/data/spot-price-data.json 83 | 84 | # Development tools 85 | setup-tools: 86 | @echo "Installing development tools..." 87 | @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest 88 | 89 | # Multi-platform release 90 | release: clean 91 | @echo "Building release binaries..." 92 | @for os in $(PLATFORMS); do \ 93 | for arch in $(ARCHITECTURES); do \ 94 | if [ "$$arch" = "arm64" ] && [ "$$os" = "windows" ]; then continue; fi; \ 95 | echo "Building $$os/$$arch..."; \ 96 | GOOS=$$os GOARCH=$$arch go build \ 97 | -tags release \ 98 | -ldflags "$(LDFLAGS)" \ 99 | -o $(BIN_DIR)/$(shell basename $(MODULE))_$${os}_$${arch} \ 100 | ./cmd/spotinfo; \ 101 | done; \ 102 | done 103 | 104 | # Cleanup 105 | clean: 106 | @echo "Cleaning up..." 107 | @rm -rf $(BIN_DIR) 108 | @rm -f coverage.out coverage.html 109 | 110 | # Utility targets 111 | version: 112 | @echo $(VERSION) 113 | 114 | help: 115 | @echo "Available targets:" 116 | @echo " build Build binary for current platform" 117 | @echo " test Run tests" 118 | @echo " test-verbose Run tests with verbose output" 119 | @echo " test-race Run tests with race detector" 120 | @echo " test-coverage Run tests with coverage report" 121 | @echo " lint Run golangci-lint" 122 | @echo " fmt Format Go code" 123 | @echo " update-data Update embedded spot advisor data" 124 | @echo " update-price Update embedded spot pricing data" 125 | @echo " release Build binaries for all platforms" 126 | @echo " clean Remove build artifacts" 127 | @echo " setup-tools Install development tools" 128 | @echo " version Show version" 129 | @echo " help Show this help" -------------------------------------------------------------------------------- /.github/workflows/auto-release.yaml: -------------------------------------------------------------------------------- 1 | name: Quarterly Auto Release 2 | on: 3 | schedule: 4 | # Run at 9 AM UTC on 1st of January, April, July, October (every 3 months) 5 | - cron: '0 9 1 1,4,7,10 *' 6 | workflow_dispatch: 7 | inputs: 8 | version_type: 9 | description: 'Version increment type' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | concurrency: 19 | group: ${{ github.workflow }} 20 | cancel-in-progress: false 21 | 22 | permissions: 23 | contents: write 24 | jobs: 25 | auto-release: 26 | runs-on: ubuntu-latest 27 | timeout-minutes: 20 28 | permissions: 29 | contents: write 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Set up Go 37 | uses: actions/setup-go@v4 38 | with: 39 | go-version: '1.24' 40 | - name: Update embedded data 41 | run: make update-data update-price 42 | - name: Run tests 43 | run: make test-verbose 44 | - name: Get latest tag 45 | id: get-latest-tag 46 | run: | 47 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 48 | echo "latest-tag=${LATEST_TAG}" >> $GITHUB_OUTPUT 49 | echo "Latest tag: ${LATEST_TAG}" 50 | - name: Calculate next version 51 | id: next-version 52 | run: | 53 | LATEST_TAG="${{ steps.get-latest-tag.outputs.latest-tag }}" 54 | VERSION_TYPE="${{ github.event.inputs.version_type || 'patch' }}" 55 | 56 | # Remove 'v' prefix and split version 57 | VERSION=${LATEST_TAG#v} 58 | IFS='.' read -ra VERSION_PARTS <<< "$VERSION" 59 | 60 | MAJOR=${VERSION_PARTS[0]:-0} 61 | MINOR=${VERSION_PARTS[1]:-0} 62 | PATCH=${VERSION_PARTS[2]:-0} 63 | 64 | case $VERSION_TYPE in 65 | major) NEW_VERSION="v$((MAJOR + 1)).0.0" ;; 66 | minor) NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0" ;; 67 | patch) NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;; 68 | esac 69 | 70 | echo "new-version=${NEW_VERSION}" >> $GITHUB_OUTPUT 71 | echo "Next version: ${NEW_VERSION}" 72 | - name: Check for changes since last release 73 | id: check-changes 74 | run: | 75 | LATEST_TAG="${{ steps.get-latest-tag.outputs.latest-tag }}" 76 | if git rev-list ${LATEST_TAG}..HEAD --count | grep -q "^0$"; then 77 | echo "No changes since last release" 78 | echo "has-changes=false" >> $GITHUB_OUTPUT 79 | else 80 | CHANGE_COUNT=$(git rev-list ${LATEST_TAG}..HEAD --count) 81 | echo "Found ${CHANGE_COUNT} changes since last release" 82 | echo "has-changes=true" >> $GITHUB_OUTPUT 83 | fi 84 | - name: Generate changelog 85 | if: steps.check-changes.outputs.has-changes == 'true' 86 | id: changelog 87 | run: | 88 | LATEST_TAG="${{ steps.get-latest-tag.outputs.latest-tag }}" 89 | CHANGELOG=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges | head -20) 90 | 91 | # Save changelog to file for multiline output 92 | echo "${CHANGELOG}" > changelog.txt 93 | 94 | echo "Generated changelog with $(echo "${CHANGELOG}" | wc -l) entries" 95 | - name: "Create and push tag" 96 | if: steps.check-changes.outputs.has-changes == 'true' 97 | run: | 98 | NEW_VERSION="${{ steps.next-version.outputs.new-version }}" 99 | LATEST_TAG="${{ steps.get-latest-tag.outputs.latest-tag }}" 100 | 101 | git config user.name "github-actions[bot]" 102 | git config user.email "github-actions[bot]@users.noreply.github.com" 103 | 104 | # Create annotated tag with changelog 105 | TAG_MESSAGE="Automated quarterly release ${NEW_VERSION} 106 | 107 | Changes since ${LATEST_TAG}: 108 | $(cat changelog.txt)" 109 | 110 | git tag -a "${NEW_VERSION}" -m "${TAG_MESSAGE}" 111 | git push origin "${NEW_VERSION}" 112 | echo "✅ Created and pushed tag: ${NEW_VERSION}" 113 | - name: Summary 114 | run: | 115 | if [[ "${{ steps.check-changes.outputs.has-changes }}" == "true" ]]; then 116 | echo "🚀 Successfully created release tag: ${{ steps.next-version.outputs.new-version }}" 117 | echo "📦 Release workflow will be triggered automatically" 118 | echo "🔗 Check releases at: https://github.com/${{ github.repository }}/releases" 119 | else 120 | echo "ℹ️ No changes detected since last release (${{ steps.get-latest-tag.outputs.latest-tag }})" 121 | echo "⏭️ Skipping tag creation - no release needed" 122 | fi 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/alexei-led/spotinfo/actions/workflows/ci.yaml/badge.svg)](https://github.com/alexei-led/spotinfo/actions/workflows/ci.yaml) [![Docker](https://github.com/alexei-led/spotinfo/actions/workflows/docker.yaml/badge.svg)](https://github.com/alexei-led/spotinfo/actions/workflows/docker.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/alexei-led/spotinfo)](https://goreportcard.com/report/github.com/alexei-led/spotinfo) [![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-blue)](docs/mcp-server.md) 2 | 3 | # spotinfo 4 | 5 | **Command-line tool for AWS EC2 Spot Instance exploration with placement score analysis** 6 | 7 | `spotinfo` is a powerful CLI tool and [Model Context Protocol (MCP) server](#mcp-integration) that provides comprehensive AWS EC2 Spot Instance information, including real-time placement scores, pricing data, and interruption rates. Perfect for DevOps engineers optimizing cloud infrastructure costs. 8 | 9 | ## Key Features 10 | 11 | ### 🎯 **AWS Spot Placement Scores** 12 | - **Real-time placement scores** (1-10 scale) for launch success probability 13 | - **Regional and AZ-level analysis** with visual indicators (🟢🟡🔴) 14 | - **Smart contextual scoring** - scores reflect entire request success likelihood 15 | - **Freshness tracking** with cache optimization 16 | 17 | ### 🔍 **Advanced Filtering & Analysis** 18 | - **Regex-powered** instance type matching (`t3.*`, `^(m5|c5)\.(large|xlarge)$`) 19 | - **Multi-dimensional filtering** by vCPU, memory, price, regions, and placement scores 20 | - **Cross-region comparison** with `--region all` support 21 | - **Flexible sorting** by price, reliability, savings, or placement scores 22 | 23 | ### 📊 **Multiple Output Formats** 24 | - **Visual formats**: Table with emoji indicators, plain text 25 | - **Data formats**: JSON, CSV for automation and scripting 26 | - **Clean separation**: Visual indicators only in human-readable formats 27 | 28 | ### 🌐 **Network Resilience** 29 | - **Embedded data** for offline functionality 30 | - **Graceful fallbacks** when AWS APIs are unavailable 31 | - **Real-time API integration** with intelligent caching 32 | 33 | ## Quick Start 34 | 35 | ### Installation 36 | 37 | ```bash 38 | # macOS with Homebrew 39 | brew tap alexei-led/spotinfo 40 | brew install spotinfo 41 | 42 | # Linux/Windows: Download from releases 43 | curl -L https://github.com/alexei-led/spotinfo/releases/latest/download/spotinfo_linux_amd64.tar.gz | tar xz 44 | 45 | # Docker 46 | docker pull ghcr.io/alexei-led/spotinfo:latest 47 | ``` 48 | 49 | **Supported platforms**: macOS, Linux, Windows on AMD64/ARM64 50 | 51 | ### Basic Usage 52 | 53 | ```bash 54 | # Get placement scores for instances 55 | spotinfo --type "m5.large" --with-score 56 | 57 | # Find high-reliability instances with budget constraints 58 | spotinfo --cpu 4 --memory 16 --with-score --min-score 8 --price 0.30 59 | 60 | # Compare across regions with AZ-level details 61 | spotinfo --type "t3.*" --with-score --az --region "us-east-1" --region "eu-west-1" 62 | 63 | # Export data for automation 64 | spotinfo --type "c5.*" --with-score --min-score 7 --output json 65 | ``` 66 | 67 | ### New Placement Score Flags 68 | 69 | | Flag | Description | 70 | |------|-------------| 71 | | `--with-score` | Enable real-time placement score fetching | 72 | | `--az` | Get AZ-level scores instead of regional | 73 | | `--min-score N` | Filter instances with score ≥ N (1-10) | 74 | | `--sort score` | Sort by placement score | 75 | 76 | 📖 **Complete reference**: [Usage Guide](docs/usage.md) | [Examples](docs/examples.md) 77 | 78 | ## MCP Integration 79 | 80 | `spotinfo` functions as a **[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server**, enabling AI assistants to directly query AWS Spot Instance data through natural language. 81 | 82 | ### Quick Setup with Claude Desktop 83 | 84 | ```json 85 | { 86 | "mcpServers": { 87 | "spotinfo": { 88 | "command": "spotinfo", 89 | "args": ["--mcp"] 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | **Ask Claude**: *"Find cheapest t3 instances with placement score >7"* or *"Compare m5.large prices across US regions"* 96 | 97 | 🤖 **Full setup guide**: [MCP Server Documentation](docs/mcp-server.md) 98 | 99 | ## Understanding AWS Spot Placement Scores 100 | 101 | **🚨 Key Insight**: Placement scores are **contextual** - they evaluate success probability for your entire request, not individual instance types. 102 | 103 | ```bash 104 | # Lower score (limited flexibility) 105 | spotinfo --type "t3.micro" --with-score 106 | # Score: 🔴 3 107 | 108 | # Higher score (flexible options) 109 | spotinfo --type "t3.*" --with-score 110 | # Score: 🟢 9 111 | ``` 112 | 113 | This is **expected AWS behavior** - providing multiple instance types gives AWS more fulfillment options. 114 | 115 | 📚 **Learn more**: [AWS Spot Placement Scores](docs/aws-spot-placement-scores.md) 116 | 117 | ## Documentation 118 | 119 | | Document | Description | 120 | |----------|-------------| 121 | | **[Usage Guide](docs/usage.md)** | Complete CLI reference with all flags and examples | 122 | | **[AWS Spot Placement Scores](docs/aws-spot-placement-scores.md)** | Deep dive into placement scores with visual guides | 123 | | **[Examples & Use Cases](docs/examples.md)** | Real-world DevOps scenarios and automation patterns | 124 | | **[MCP Server Setup](docs/mcp-server.md)** | Model Context Protocol integration guide | 125 | | **[Data Sources](docs/data-sources.md)** | AWS data feeds, caching strategy, and troubleshooting | 126 | 127 | ## Development 128 | 129 | **Requirements**: Go 1.24+, make, golangci-lint 130 | 131 | ```bash 132 | # Build and test 133 | make all 134 | 135 | # Update embedded data 136 | make update-data update-price 137 | 138 | # Docker build 139 | docker buildx build --platform=linux/arm64,linux/amd64 -t spotinfo . 140 | ``` 141 | 142 | **CI/CD**: Automated testing, linting, releases, and multi-arch Docker builds via GitHub Actions 143 | 144 | ## Contributing 145 | 146 | Contributions welcome! Please read the development commands in [CLAUDE.md](CLAUDE.md) and ensure all tests pass. 147 | 148 | ## License 149 | 150 | Apache 2.0 License - see [LICENSE](LICENSE) for details. 151 | 152 | -------------------------------------------------------------------------------- /internal/mcp/server_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "spotinfo/internal/spot" 13 | ) 14 | 15 | // TestNewServer tests server creation with different configurations 16 | func TestNewServer(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | cfg Config 20 | wantErr bool 21 | }{ 22 | { 23 | name: "valid configuration", 24 | cfg: Config{ 25 | Version: "1.0.0", 26 | Logger: slog.Default(), 27 | SpotClient: spot.New(), 28 | }, 29 | wantErr: false, 30 | }, 31 | { 32 | name: "missing logger uses default", 33 | cfg: Config{ 34 | Version: "1.0.0", 35 | SpotClient: spot.New(), 36 | }, 37 | wantErr: false, 38 | }, 39 | { 40 | name: "nil spot client is allowed", 41 | cfg: Config{ 42 | Version: "1.0.0", 43 | Logger: slog.Default(), 44 | }, 45 | wantErr: false, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | server, err := NewServer(tt.cfg) 52 | 53 | if tt.wantErr { 54 | assert.Error(t, err) 55 | assert.Nil(t, server) 56 | } else { 57 | assert.NoError(t, err) 58 | assert.NotNil(t, server) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | // TestServerToolRegistration verifies tools are registered during server creation 65 | func TestServerToolRegistration(t *testing.T) { 66 | cfg := Config{ 67 | Version: "1.0.0", 68 | Logger: slog.Default(), 69 | SpotClient: spot.New(), 70 | } 71 | 72 | server, err := NewServer(cfg) 73 | require.NoError(t, err) 74 | require.NotNil(t, server) 75 | 76 | // The server should have registered tools - we verify this by ensuring 77 | // the MCP server was created (tools registration happens in NewServer) 78 | assert.NotNil(t, server.mcpServer) 79 | } 80 | 81 | // TestServeStdio_ContextCancellation tests that stdio server respects context cancellation 82 | func TestServeStdio_ContextCancellation(t *testing.T) { 83 | cfg := Config{ 84 | Version: "1.0.0", 85 | Logger: slog.Default(), 86 | SpotClient: spot.New(), 87 | } 88 | 89 | server, err := NewServer(cfg) 90 | require.NoError(t, err) 91 | 92 | // Create context that we'll cancel 93 | ctx, cancel := context.WithCancel(context.Background()) 94 | 95 | // Start server in goroutine 96 | done := make(chan error, 1) 97 | go func() { 98 | done <- server.ServeStdio(ctx) 99 | }() 100 | 101 | // Give server time to start 102 | time.Sleep(50 * time.Millisecond) 103 | 104 | // Cancel context 105 | cancel() 106 | 107 | // Server should shut down gracefully 108 | select { 109 | case err := <-done: 110 | // Any error is acceptable here since we're testing cancellation behavior 111 | t.Logf("ServeStdio returned with: %v", err) 112 | case <-time.After(2 * time.Second): 113 | t.Fatal("server did not shut down within timeout") 114 | } 115 | } 116 | 117 | // TestServeSSE_ContextCancellation tests that SSE server respects context cancellation 118 | func TestServeSSE_ContextCancellation(t *testing.T) { 119 | cfg := Config{ 120 | Version: "1.0.0", 121 | Logger: slog.Default(), 122 | SpotClient: spot.New(), 123 | } 124 | 125 | server, err := NewServer(cfg) 126 | require.NoError(t, err) 127 | 128 | // Create context that we'll cancel 129 | ctx, cancel := context.WithCancel(context.Background()) 130 | 131 | // Start server in goroutine 132 | done := make(chan error, 1) 133 | go func() { 134 | // Use port 0 to let OS choose available port 135 | done <- server.ServeSSE(ctx, "0") 136 | }() 137 | 138 | // Give server time to start 139 | time.Sleep(50 * time.Millisecond) 140 | 141 | // Cancel context 142 | cancel() 143 | 144 | // Server should shut down gracefully 145 | select { 146 | case err := <-done: 147 | // Should get context cancellation error 148 | assert.Error(t, err) 149 | t.Logf("ServeSSE returned with: %v", err) 150 | case <-time.After(2 * time.Second): 151 | t.Fatal("server did not shut down within timeout") 152 | } 153 | } 154 | 155 | // TestServeSSE_InvalidPort tests error handling for invalid port 156 | func TestServeSSE_InvalidPort(t *testing.T) { 157 | cfg := Config{ 158 | Version: "1.0.0", 159 | Logger: slog.Default(), 160 | SpotClient: spot.New(), 161 | } 162 | 163 | server, err := NewServer(cfg) 164 | require.NoError(t, err) 165 | 166 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 167 | defer cancel() 168 | 169 | // Use invalid port 170 | err = server.ServeSSE(ctx, "invalid-port") 171 | assert.Error(t, err) 172 | } 173 | 174 | // TestServerConcurrentAccess tests that multiple operations can be performed concurrently 175 | func TestServerConcurrentAccess(t *testing.T) { 176 | cfg := Config{ 177 | Version: "1.0.0", 178 | Logger: slog.Default(), 179 | SpotClient: spot.New(), 180 | } 181 | 182 | server, err := NewServer(cfg) 183 | require.NoError(t, err) 184 | 185 | const numOperations = 5 186 | done := make(chan error, numOperations) 187 | 188 | // Perform concurrent server operations 189 | for i := 0; i < numOperations; i++ { 190 | go func() { 191 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 192 | defer cancel() 193 | 194 | // Each goroutine tries to start SSE server on different port 195 | port := "0" // Let OS choose port 196 | err := server.ServeSSE(ctx, port) 197 | done <- err 198 | }() 199 | } 200 | 201 | // Collect results - should either timeout or fail with port binding 202 | for i := 0; i < numOperations; i++ { 203 | err := <-done 204 | // Any error is acceptable - we're testing concurrent access doesn't panic 205 | t.Logf("Operation %d returned: %v", i, err) 206 | } 207 | } 208 | 209 | // BenchmarkServerCreation benchmarks server creation performance 210 | func BenchmarkServerCreation(b *testing.B) { 211 | cfg := Config{ 212 | Version: "1.0.0", 213 | Logger: slog.Default(), 214 | SpotClient: spot.New(), 215 | } 216 | 217 | b.ResetTimer() 218 | for i := 0; i < b.N; i++ { 219 | server, err := NewServer(cfg) 220 | if err != nil { 221 | b.Fatal(err) 222 | } 223 | _ = server 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /internal/mcp/server.go: -------------------------------------------------------------------------------- 1 | // Package mcp provides Model Context Protocol server implementation for spotinfo. 2 | package mcp 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | 12 | "spotinfo/internal/spot" 13 | ) 14 | 15 | // Constants for MCP server configuration 16 | const ( 17 | defaultMaxInterruptionRateParam = 100 18 | defaultLimitParam = 10 19 | maxLimitParam = 50 20 | totalMCPTools = 2 21 | maxScoreValue = 10 22 | maxScoreTimeoutSeconds = 300 23 | ) 24 | 25 | // spotClient interface defined close to consumer for testing (following codebase patterns) 26 | type spotClient interface { 27 | GetSpotSavings(ctx context.Context, opts ...spot.GetSpotSavingsOption) ([]spot.Advice, error) 28 | } 29 | 30 | // Server wraps the MCP server with spotinfo-specific configuration 31 | type Server struct { 32 | mcpServer *server.MCPServer 33 | logger *slog.Logger 34 | spotClient spotClient 35 | } 36 | 37 | // Config holds MCP server configuration 38 | type Config struct { 39 | Logger *slog.Logger 40 | SpotClient spotClient 41 | Version string 42 | Transport string 43 | Port string 44 | } 45 | 46 | // NewServer creates a new MCP server instance with spotinfo tools 47 | func NewServer(cfg Config) (*Server, error) { 48 | if cfg.Logger == nil { 49 | cfg.Logger = slog.Default() 50 | } 51 | 52 | // Create MCP server with tool capabilities 53 | mcpServer := server.NewMCPServer( 54 | "spotinfo", 55 | cfg.Version, 56 | server.WithToolCapabilities(true), 57 | server.WithLogging(), 58 | ) 59 | 60 | s := &Server{ 61 | mcpServer: mcpServer, 62 | logger: cfg.Logger, 63 | spotClient: cfg.SpotClient, 64 | } 65 | 66 | // Register tools 67 | s.registerTools() 68 | 69 | return s, nil 70 | } 71 | 72 | // registerTools registers all spotinfo MCP tools 73 | func (s *Server) registerTools() { 74 | s.logger.Debug("registering MCP tools") 75 | 76 | // Register find_spot_instances tool - combines search and lookup functionality 77 | findSpotInstancesTool := mcp.NewTool("find_spot_instances", 78 | mcp.WithDescription("Search for AWS EC2 Spot Instance options based on requirements. Returns pricing, savings, and interruption data."), 79 | mcp.WithArray("regions", 80 | mcp.Description("AWS regions to search (e.g., ['us-east-1', 'eu-west-1']). Use ['all'] or omit to search all regions"), 81 | mcp.Items(map[string]any{"type": "string"})), 82 | mcp.WithString("instance_types", 83 | mcp.Description("Instance type pattern - exact type (e.g., 'm5.large') or pattern (e.g., 't3.*', 'm5.*')")), 84 | mcp.WithNumber("min_vcpu", 85 | mcp.Description("Minimum number of vCPUs required"), 86 | mcp.DefaultNumber(0)), 87 | mcp.WithNumber("min_memory_gb", 88 | mcp.Description("Minimum memory in gigabytes"), 89 | mcp.DefaultNumber(0)), 90 | mcp.WithNumber("max_price_per_hour", 91 | mcp.Description("Maximum spot price per hour in USD"), 92 | mcp.DefaultNumber(0)), 93 | mcp.WithNumber("max_interruption_rate", 94 | mcp.Description("Maximum acceptable interruption rate percentage (0-100)"), 95 | mcp.DefaultNumber(defaultMaxInterruptionRateParam)), 96 | mcp.WithString("sort_by", 97 | mcp.Description("Sort results by: 'price' (cheapest first), 'reliability' (lowest interruption first), 'savings' (highest savings first), 'score' (highest score first)"), 98 | mcp.DefaultString("reliability")), 99 | mcp.WithNumber("limit", 100 | mcp.Description("Maximum number of results to return"), 101 | mcp.DefaultNumber(defaultLimitParam), 102 | mcp.Max(maxLimitParam)), 103 | mcp.WithBoolean("with_score", 104 | mcp.Description("Include AWS spot placement scores (experimental)"), 105 | mcp.DefaultBool(false)), 106 | mcp.WithNumber("min_score", 107 | mcp.Description("Filter: minimum spot placement score (1-10)"), 108 | mcp.DefaultNumber(0), 109 | mcp.Min(0), 110 | mcp.Max(maxScoreValue)), 111 | mcp.WithBoolean("az", 112 | mcp.Description("Request AZ-level scores instead of region-level (use with --with-score)"), 113 | mcp.DefaultBool(false)), 114 | mcp.WithNumber("score_timeout", 115 | mcp.Description("Timeout for score enrichment in seconds"), 116 | mcp.DefaultNumber(spot.DefaultScoreTimeoutSeconds), 117 | mcp.Min(1), 118 | mcp.Max(maxScoreTimeoutSeconds)), 119 | ) 120 | 121 | findSpotInstancesHandler := NewFindSpotInstancesTool(s.spotClient, s.logger) 122 | s.mcpServer.AddTool(findSpotInstancesTool, findSpotInstancesHandler.Handle) 123 | 124 | // Register list_spot_regions tool 125 | listSpotRegionsTool := mcp.NewTool("list_spot_regions", 126 | mcp.WithDescription("List all AWS regions where EC2 Spot Instances are available"), 127 | mcp.WithBoolean("include_names", 128 | mcp.Description("Include human-readable region names (e.g., 'US East (N. Virginia)')"), 129 | mcp.DefaultBool(true)), 130 | ) 131 | 132 | listSpotRegionsHandler := NewListSpotRegionsTool(s.spotClient, s.logger) 133 | s.mcpServer.AddTool(listSpotRegionsTool, listSpotRegionsHandler.Handle) 134 | 135 | s.logger.Info("MCP tools registered", slog.Int("count", totalMCPTools)) 136 | } 137 | 138 | // ServeStdio starts the MCP server with stdio transport 139 | func (s *Server) ServeStdio(ctx context.Context) error { 140 | s.logger.Info("starting MCP server with stdio transport") 141 | 142 | // Use the global ServeStdio function 143 | return server.ServeStdio(s.mcpServer) 144 | } 145 | 146 | // ServeSSE starts the MCP server with SSE transport on specified port 147 | func (s *Server) ServeSSE(ctx context.Context, port string) error { 148 | s.logger.Info("starting MCP server with SSE transport", slog.String("port", port)) 149 | 150 | // Create SSE server using the built-in mcp-go library support 151 | sseServer := server.NewSSEServer(s.mcpServer) 152 | 153 | // Start SSE server - this will block until context is cancelled or error occurs 154 | errChan := make(chan error, 1) 155 | go func() { 156 | errChan <- sseServer.Start(":" + port) 157 | }() 158 | 159 | // Wait for context cancellation or server error 160 | select { 161 | case <-ctx.Done(): 162 | s.logger.Info("SSE server context cancelled, shutting down") 163 | return ctx.Err() 164 | case err := <-errChan: 165 | if err != nil { 166 | s.logger.Error("SSE server failed", slog.Any("error", err)) 167 | return fmt.Errorf("SSE server failed: %w", err) 168 | } 169 | return nil 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | settings: 5 | govet: 6 | enable: 7 | - shadow 8 | - fieldalignment 9 | gocyclo: 10 | min-complexity: 15 11 | cyclop: 12 | max-complexity: 15 13 | package-average: 10.0 14 | dupl: 15 | threshold: 100 16 | goconst: 17 | min-len: 2 18 | min-occurrences: 2 19 | misspell: 20 | locale: US 21 | gocritic: 22 | enabled-tags: 23 | - diagnostic 24 | - experimental 25 | - opinionated 26 | - performance 27 | - style 28 | disabled-checks: 29 | - dupImport # https://github.com/go-critic/go-critic/issues/845 30 | - ifElseChain 31 | - octalLiteral 32 | - rangeValCopy 33 | - unnamedResult 34 | - whyNoLint 35 | - wrapperFunc 36 | funlen: 37 | lines: 120 38 | statements: 50 39 | gocognit: 40 | min-complexity: 20 41 | lll: 42 | line-length: 120 43 | varnamelen: 44 | min-name-length: 2 45 | ignore-names: 46 | - err 47 | - i 48 | - j 49 | - k 50 | - v 51 | - id 52 | - ok 53 | - db 54 | nolintlint: 55 | allow-unused: false 56 | require-explanation: false 57 | require-specific: true 58 | exhaustive: 59 | default-signifies-exhaustive: true 60 | depguard: 61 | rules: 62 | main: 63 | files: 64 | - $all 65 | allow: 66 | - $gostd 67 | - spotinfo/internal/spot 68 | - spotinfo/internal 69 | - spotinfo/internal/mcp 70 | - github.com/jedib0t/go-pretty/v6 71 | - github.com/urfave/cli/v2 72 | - github.com/stretchr/testify 73 | - github.com/mark3labs/mcp-go 74 | - github.com/spf13/cast 75 | - github.com/bluele/gcache 76 | - golang.org/x/time 77 | - github.com/aws/aws-sdk-go-v2 78 | - github.com/aws/smithy-go 79 | - github.com/jedib0t/go-pretty/v6/table 80 | - github.com/jedib0t/go-pretty/v6/text 81 | testifylint: 82 | enable-all: true 83 | tagalign: 84 | align: true 85 | sort: true 86 | 87 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 88 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 89 | default: none 90 | enable: 91 | # Essential bug-finding linters 92 | - errcheck # Check for unchecked errors 93 | - gosec # Security issues 94 | - govet # Go vet built-in checks 95 | - ineffassign # Ineffective assignments 96 | - staticcheck # Comprehensive static analysis 97 | - unused # Unused code 98 | - errorlint # Error wrapping issues 99 | 100 | # Code quality and best practices 101 | - bodyclose # HTTP response body close check 102 | - contextcheck # Context usage patterns 103 | - copyloopvar # Loop variable copying issues 104 | - cyclop # Cyclomatic complexity 105 | - depguard # Import restrictions 106 | - dupl # Code duplication 107 | - exhaustive # Switch statement exhaustiveness 108 | - funlen # Function length 109 | - goconst # Repeated strings that could be constants 110 | - gocritic # Comprehensive go code critic 111 | - gocyclo # Cyclomatic complexity 112 | - makezero # Slice initialization 113 | - nakedret # Naked returns 114 | - nestif # Nested if statements 115 | - nilerr # Nil error returns 116 | - nilnil # Nil error and value returns 117 | # - noctx # HTTP without context - disabled: too verbose for initialization and error handling code 118 | - predeclared # Predeclared identifier shadowing 119 | - rowserrcheck # SQL rows.Err() check 120 | - sqlclosecheck # SQL close checks 121 | - unconvert # Unnecessary conversions 122 | - unparam # Unused parameters 123 | - wastedassign # Wasted assignments 124 | 125 | # Performance-related 126 | - prealloc # Slice preallocation 127 | 128 | # Additional best practices (keep important ones) 129 | - durationcheck # Duration multiplication 130 | - errname # Error naming conventions 131 | - interfacebloat # Interface size 132 | - maintidx # Maintainability index 133 | - mirror # Wrong mirror patterns 134 | - mnd # Magic numbers 135 | - nilnesserr # Nil check patterns 136 | - nosprintfhostport # Sprintf host:port misuse 137 | - reassign # Package variable reassignment 138 | - forcetypeassert # Type assertion without check 139 | 140 | # Explicitly disabled linters with reasons 141 | # - gochecknoglobals # Too restrictive for CLI apps that need global flags/config 142 | # - gocognit # Redundant with cyclop 143 | # - godox # TODO comments are fine during development 144 | # - goheader # Not needed for this project 145 | # - gomoddirectives # Too restrictive for development 146 | # - gosmopolitan # Not needed for this project 147 | # - forbidigo # Too restrictive 148 | # - iface # Too opinionated 149 | # - inamedparam # Too verbose 150 | # - intrange # Go 1.22+ feature not always available 151 | 152 | exclusions: 153 | rules: 154 | - path: '(.+)\.go$' 155 | text: Using the variable on range scope `tt` in function literal 156 | - path: '(.+)_test\.go' 157 | linters: 158 | - funlen 159 | - gocyclo 160 | - cyclop 161 | - dupl 162 | - lll 163 | - varnamelen 164 | - exhaustruct 165 | - nolintlint 166 | - testpackage 167 | - wsl 168 | - govet 169 | - errcheck 170 | - goconst 171 | - gocritic 172 | - staticcheck 173 | - unused 174 | - gosec 175 | - path: 'mocks_test\.go' 176 | linters: 177 | - stylecheck 178 | - revive 179 | - golint 180 | - staticcheck 181 | - unused 182 | - deadcode 183 | - typecheck 184 | - path: 'cmd/main\.go' 185 | linters: 186 | - exhaustruct 187 | - linters: 188 | - lll 189 | source: "^//go:generate " 190 | 191 | formatters: 192 | enable: 193 | - gci 194 | - gofmt 195 | - goimports 196 | settings: 197 | gci: 198 | sections: 199 | - standard 200 | - default 201 | - prefix(spotinfo) 202 | goimports: 203 | local-prefixes: 204 | - spotinfo 205 | 206 | -------------------------------------------------------------------------------- /docs/aws-spot-placement-scores.md: -------------------------------------------------------------------------------- 1 | # AWS Spot Placement Scores 2 | 3 | ## Overview 4 | 5 | AWS Spot Placement Scores provide insights into the likelihood of successfully launching Spot instances in different regions and availability zones. This feature helps DevOps engineers make data-driven decisions when selecting spot instances for cost optimization. 6 | 7 | ## What are Spot Placement Scores? 8 | 9 | Spot Placement Scores are **1-10 ratings** provided by AWS that indicate: 10 | - **10**: Very high likelihood of successful spot instance launch 11 | - **1**: Very low likelihood of successful spot instance launch 12 | 13 | The scores are calculated by AWS based on: 14 | - Current spot capacity availability 15 | - Historical demand patterns 16 | - Instance type popularity 17 | - Regional capacity distribution 18 | 19 | ## Critical Understanding: Contextual Scoring 20 | 21 | **🚨 Important**: Spot Placement Scores are **contextual** - they represent the likelihood of fulfilling your **entire request**, not individual instance types. 22 | 23 | ```mermaid 24 | graph TD 25 | A[AWS Spot Placement Score API] --> B{Request Type} 26 | B -->|Single Instance| C[Score for t3.micro only
Score: 3 🔴] 27 | B -->|Multiple Types| D[Score for ALL types together
t3.micro, t3.small, t3.medium
Score: 9 🟢] 28 | 29 | C --> E[Limited flexibility
Only one option] 30 | D --> F[High flexibility
Multiple fallback options] 31 | 32 | E --> G[Lower success probability] 33 | F --> H[Higher success probability] 34 | 35 | style C fill:#ffcccc 36 | style D fill:#ccffcc 37 | style G fill:#ffcccc 38 | style H fill:#ccffcc 39 | ``` 40 | 41 | ### Why Scores Differ by Query Context 42 | 43 | | Query Type | What AWS Evaluates | Example Score | 44 | |------------|-------------------|---------------| 45 | | `--type "t3.micro"` | Can I fulfill **only** t3.micro? | 3 🔴 (Limited options) | 46 | | `--type "t3.*"` | Can I fulfill **any** t3 instance type? | 9 🟢 (Flexible options) | 47 | 48 | This is **expected behavior** - providing multiple instance types gives AWS more flexibility to fulfill your request. 49 | 50 | ## Visual Score Indicators 51 | 52 | `spotinfo` provides intuitive visual indicators for quick assessment: 53 | 54 | | Score Range | Indicator | Meaning | Recommendation | 55 | |-------------|-----------|---------|----------------| 56 | | 8-10 | 🟢 | Excellent | Highly recommended | 57 | | 5-7 | 🟡 | Moderate | Consider alternatives | 58 | | 1-4 | 🔴 | Poor | High risk of interruption | 59 | | Unknown | ❓ | No data | Proceed with caution | 60 | 61 | ## Regional vs Availability Zone Scores 62 | 63 | ### Regional Scores 64 | - Evaluate placement likelihood across **entire regions** 65 | - Best for general capacity planning 66 | - Header: "Placement Score (Regional)" 67 | 68 | ### AZ-Level Scores 69 | - Evaluate placement likelihood for **specific availability zones** 70 | - Best for precise deployment targeting 71 | - Header: "Placement Score (AZ)" 72 | - Format: `us-east-1a:🟢 9` 73 | 74 | ## Usage Examples 75 | 76 | ### Basic Score Queries 77 | 78 | ```bash 79 | # Get regional placement scores 80 | spotinfo --type "m5.large" --with-score --region "us-east-1" 81 | 82 | # Get AZ-level placement scores 83 | spotinfo --type "m5.large" --with-score --az --region "us-east-1" 84 | ``` 85 | 86 | ### Filtering by Score Thresholds 87 | 88 | ```bash 89 | # Find instances with excellent placement scores (8+) 90 | spotinfo --type "t3.*" --with-score --min-score 8 91 | 92 | # High-reliability instances for production 93 | spotinfo --type "m5.*" --with-score --min-score 7 --sort score --order desc 94 | ``` 95 | 96 | ### Strategic Instance Selection 97 | 98 | ```bash 99 | # Single instance assessment (precise evaluation) 100 | spotinfo --type "c5.xlarge" --with-score --region "us-east-1" 101 | 102 | # Flexible deployment options (higher scores expected) 103 | spotinfo --type "c5\.(large|xlarge|2xlarge)" --with-score --region "us-east-1" 104 | ``` 105 | 106 | ## Output Formats 107 | 108 | ### Visual Formats (with emojis) 109 | - **Table**: `🟢 9` (includes visual indicators) 110 | - **Text**: `score=us-east-1a:🟢 9` 111 | 112 | ### Data-Only Formats (automation-friendly) 113 | - **CSV**: `us-east-1a:9` (no emojis) 114 | - **JSON**: `{"region_score": 9, "score_fetched_at": "2025-01-26T..."}` 115 | 116 | ## Score Freshness Tracking 117 | 118 | Placement scores include timestamp tracking for cache freshness: 119 | 120 | ```json 121 | { 122 | "region_score": 9, 123 | "score_fetched_at": "2025-01-26T10:45:02.844335+03:00" 124 | } 125 | ``` 126 | 127 | Stale scores (>30 minutes) are indicated with an asterisk: `🟢 9*` 128 | 129 | ## Command Reference 130 | 131 | | Flag | Description | Example | 132 | |------|-------------|---------| 133 | | `--with-score` | Enable placement score fetching | `--with-score` | 134 | | `--az` | Get AZ-level scores instead of regional | `--with-score --az` | 135 | | `--min-score N` | Filter instances with score ≥ N | `--min-score 7` | 136 | | `--sort score` | Sort results by placement score | `--sort score --order desc` | 137 | | `--score-timeout N` | Set API timeout in seconds | `--score-timeout 30` | 138 | 139 | ## DevOps Best Practices 140 | 141 | ### For Production Workloads 142 | ```bash 143 | # High-reliability instances with flexibility 144 | spotinfo --type "m5\.(large|xlarge|2xlarge)" --with-score --min-score 7 --region "us-east-1" 145 | ``` 146 | 147 | ### For Cost-Optimized Development 148 | ```bash 149 | # Find cheapest instances with acceptable reliability 150 | spotinfo --type "t3.*" --with-score --min-score 5 --sort price --order asc 151 | ``` 152 | 153 | ### For Multi-Region Deployment 154 | ```bash 155 | # Compare placement scores across regions 156 | spotinfo --type "c5.large" --with-score --region "us-east-1" --region "eu-west-1" 157 | ``` 158 | 159 | ## Permissions Requirements 160 | 161 | ### IAM Policy 162 | ```json 163 | { 164 | "Version": "2012-10-17", 165 | "Statement": [ 166 | { 167 | "Effect": "Allow", 168 | "Action": "ec2:GetSpotPlacementScores", 169 | "Resource": "*" 170 | } 171 | ] 172 | } 173 | ``` 174 | 175 | ### AWS Organizations (SCP) 176 | Ensure no Service Control Policy blocks `ec2:GetSpotPlacementScores`. SCPs override IAM permissions. 177 | 178 | ## Troubleshooting 179 | 180 | ### Common Issues 181 | 182 | **Different scores for same instance type:** 183 | - ✅ **Expected behavior** - scores are contextual to the entire request 184 | - Single vs multiple instance queries will yield different scores 185 | 186 | **Permission errors:** 187 | - Check IAM policy includes `ec2:GetSpotPlacementScores` 188 | - Verify no SCP blocks the action (common in AWS Organizations) 189 | 190 | **API timeouts:** 191 | - Increase timeout: `--score-timeout 60` 192 | - The tool falls back to embedded data if API unavailable 193 | 194 | ### Fallback Behavior 195 | 196 | If AWS API is unavailable, `spotinfo` uses deterministic mock scores for demonstration purposes. Real deployments should use actual AWS placement scores. 197 | 198 | ## Data Sources 199 | 200 | Placement scores are fetched from: 201 | - **Primary**: AWS `GetSpotPlacementScores` API 202 | - **Fallback**: Deterministic mock provider (for offline functionality) 203 | 204 | See [Data Sources](data-sources.md) for complete information. -------------------------------------------------------------------------------- /internal/spot/data.go: -------------------------------------------------------------------------------- 1 | package spot 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | //go:embed data/spot-advisor-data.json 17 | var embeddedSpotData string 18 | 19 | //go:embed data/spot-price-data.json 20 | var embeddedPriceData string 21 | 22 | const ( 23 | spotAdvisorJSONURL = "https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json" 24 | spotPriceJSURL = "https://spot-price.s3.amazonaws.com/spot.js" 25 | responsePrefix = "callback(" 26 | responseSuffix = ");" 27 | httpTimeout = 5 * time.Second 28 | ) 29 | 30 | // awsSpotPricingRegions maps non-standard region codes to AWS region codes. 31 | var awsSpotPricingRegions = map[string]string{ 32 | "us-east": "us-east-1", 33 | "us-west": "us-west-1", 34 | "eu-ireland": "eu-west-1", 35 | "apac-sin": "ap-southeast-1", 36 | "apac-syd": "ap-southeast-2", 37 | "apac-tokyo": "ap-northeast-1", 38 | } 39 | 40 | // minRange maps interruption range max values to min values 41 | var minRange = map[int]int{5: 0, 11: 6, 16: 12, 22: 17, 100: 23} //nolint:mnd 42 | 43 | // fetchAdvisorData retrieves spot advisor data from AWS or falls back to embedded data. 44 | func fetchAdvisorData(ctx context.Context) (*advisorData, error) { 45 | client := &http.Client{Timeout: httpTimeout} 46 | 47 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotAdvisorJSONURL, http.NoBody) 48 | if err != nil { 49 | // If request creation fails, try embedded data 50 | return loadEmbeddedAdvisorData() 51 | } 52 | 53 | resp, err := client.Do(req) 54 | if err != nil { 55 | slog.Warn("failed to fetch advisor data from AWS, using embedded data", 56 | slog.String("url", spotAdvisorJSONURL), 57 | slog.Any("error", err)) 58 | return loadEmbeddedAdvisorData() 59 | } 60 | defer func() { _ = resp.Body.Close() }() 61 | 62 | if resp.StatusCode != http.StatusOK { 63 | slog.Warn("non-200 response from AWS advisor API, using embedded data", 64 | slog.Int("status_code", resp.StatusCode)) 65 | return loadEmbeddedAdvisorData() 66 | } 67 | 68 | body, err := io.ReadAll(resp.Body) 69 | if err != nil { 70 | slog.Warn("failed to read advisor response body, using embedded data", 71 | slog.Any("error", err)) 72 | return loadEmbeddedAdvisorData() 73 | } 74 | 75 | var result advisorData 76 | err = json.Unmarshal(body, &result) 77 | if err != nil { 78 | slog.Warn("failed to parse advisor data from AWS, using embedded data", 79 | slog.Any("error", err)) 80 | return loadEmbeddedAdvisorData() 81 | } 82 | 83 | slog.Debug("successfully fetched advisor data from AWS") 84 | return &result, nil 85 | } 86 | 87 | // loadEmbeddedAdvisorData loads embedded advisor data as fallback. 88 | func loadEmbeddedAdvisorData() (*advisorData, error) { 89 | var result advisorData 90 | err := json.Unmarshal([]byte(embeddedSpotData), &result) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to parse embedded spot data: %w", err) 93 | } 94 | 95 | result.Embedded = true 96 | slog.Debug("using embedded advisor data") 97 | return &result, nil 98 | } 99 | 100 | // fetchPricingData retrieves spot pricing data from AWS or falls back to embedded data. 101 | func fetchPricingData(ctx context.Context, useEmbedded bool) (*rawPriceData, error) { 102 | if useEmbedded { 103 | return loadEmbeddedPricingData() 104 | } 105 | 106 | client := &http.Client{Timeout: httpTimeout} 107 | 108 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotPriceJSURL, http.NoBody) 109 | if err != nil { 110 | // If request creation fails, try embedded data 111 | return loadEmbeddedPricingData() 112 | } 113 | 114 | resp, err := client.Do(req) 115 | if err != nil { 116 | slog.Warn("failed to fetch pricing data from AWS, using embedded data", 117 | slog.String("url", spotPriceJSURL), 118 | slog.Any("error", err)) 119 | return loadEmbeddedPricingData() 120 | } 121 | defer func() { _ = resp.Body.Close() }() 122 | 123 | if resp.StatusCode != http.StatusOK { 124 | slog.Warn("non-200 response from AWS pricing API, using embedded data", 125 | slog.Int("status_code", resp.StatusCode)) 126 | return loadEmbeddedPricingData() 127 | } 128 | 129 | bodyBytes, err := io.ReadAll(resp.Body) 130 | if err != nil { 131 | slog.Warn("failed to read pricing response body, using embedded data", 132 | slog.Any("error", err)) 133 | return loadEmbeddedPricingData() 134 | } 135 | 136 | // Process JSONP response 137 | bodyString := strings.TrimPrefix(string(bodyBytes), responsePrefix) 138 | bodyString = strings.TrimSuffix(bodyString, responseSuffix) 139 | 140 | var result rawPriceData 141 | err = json.Unmarshal([]byte(bodyString), &result) 142 | if err != nil { 143 | slog.Warn("failed to parse pricing data from AWS, using embedded data", 144 | slog.Any("error", err)) 145 | return loadEmbeddedPricingData() 146 | } 147 | 148 | slog.Debug("successfully fetched pricing data from AWS") 149 | normalizeRegions(&result) 150 | return &result, nil 151 | } 152 | 153 | // loadEmbeddedPricingData loads embedded pricing data as fallback. 154 | func loadEmbeddedPricingData() (*rawPriceData, error) { 155 | var result rawPriceData 156 | err := json.Unmarshal([]byte(embeddedPriceData), &result) 157 | if err != nil { 158 | return nil, fmt.Errorf("failed to parse embedded spot price data: %w", err) 159 | } 160 | 161 | result.Embedded = true 162 | slog.Debug("using embedded pricing data") 163 | normalizeRegions(&result) 164 | return &result, nil 165 | } 166 | 167 | // normalizeRegions normalizes region codes in the pricing data. 168 | func normalizeRegions(result *rawPriceData) { 169 | for index, r := range result.Config.Regions { 170 | if awsRegion, ok := awsSpotPricingRegions[r.Region]; ok { 171 | result.Config.Regions[index].Region = awsRegion 172 | } 173 | } 174 | } 175 | 176 | // convertRawPriceData converts raw pricing data to a more usable format. 177 | func convertRawPriceData(raw *rawPriceData) *spotPriceData { 178 | pricing := &spotPriceData{ 179 | Region: make(map[string]regionPrice), 180 | } 181 | 182 | for _, region := range raw.Config.Regions { 183 | rp := regionPrice{ 184 | Instance: make(map[string]instancePrice), 185 | } 186 | 187 | for _, it := range region.InstanceTypes { 188 | for _, size := range it.Sizes { 189 | var ip instancePrice 190 | 191 | for _, os := range size.ValueColumns { 192 | price, err := strconv.ParseFloat(os.Prices.USD, 64) 193 | if err != nil { 194 | price = 0 195 | } 196 | 197 | if os.Name == "mswin" { 198 | ip.Windows = price 199 | } else { 200 | ip.Linux = price 201 | } 202 | } 203 | 204 | rp.Instance[size.Size] = ip 205 | } 206 | } 207 | 208 | pricing.Region[region.Region] = rp 209 | } 210 | 211 | return pricing 212 | } 213 | 214 | // getSpotInstancePrice retrieves the spot price for a specific instance. 215 | func (s *spotPriceData) getSpotInstancePrice(instance, region, os string) (float64, error) { 216 | rp, ok := s.Region[region] 217 | if !ok { 218 | return 0, fmt.Errorf("no pricing data for region: %v", region) 219 | } 220 | 221 | price, ok := rp.Instance[instance] 222 | if !ok { 223 | return 0, fmt.Errorf("no pricing data for instance: %v", instance) 224 | } 225 | 226 | if os == "windows" { 227 | return price.Windows, nil 228 | } 229 | 230 | return price.Linux, nil 231 | } 232 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU= 2 | github.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= 3 | github.com/aws/aws-sdk-go-v2/config v1.29.18 h1:x4T1GRPnqKV8HMJOMtNktbpQMl3bIsfx8KbqmveUO2I= 4 | github.com/aws/aws-sdk-go-v2/config v1.29.18/go.mod h1:bvz8oXugIsH8K7HLhBv06vDqnFv3NsGDt2Znpk7zmOU= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.17.71 h1:r2w4mQWnrTMJjOyIsZtGp3R3XGY3nqHn8C26C2lQWgA= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.17.71/go.mod h1:E7VF3acIup4GB5ckzbKFrCK0vTvEQxOxgdq4U3vcMCY= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 h1:D9ixiWSG4lyUBL2DDNK924Px9V/NBVpML90MHqyTADY= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33/go.mod h1:caS/m4DI+cij2paz3rtProRBI4s/+TCiWoaWZuQ9010= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.236.0 h1:p9VAk1AO/UDMq4sYtsxMbZqoJIXtCZmLolsPTc3rP/w= 16 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.236.0/go.mod h1:K7qdQFo+lbGM48aPEyoPfy/VN/xNOA4o8GGczfSXNcQ= 17 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 h1:vvbXsA2TVO80/KT7ZqCbx934dt6PY+vQ8hZpUZ/cpYg= 20 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18/go.mod h1:m2JJHledjBGNMsLOF1g9gbAxprzq3KjC8e4lxtn+eWg= 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc= 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4/go.mod h1:8Mm5VGYwtm+r305FfPSuc+aFkrypeylGYhFim6XEPoc= 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI3iesuacjP51Mv1s= 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk= 27 | github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= 28 | github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 29 | github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= 30 | github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= 31 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 32 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 33 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 36 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 37 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 38 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= 42 | github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= 43 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 44 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 45 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 46 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 47 | github.com/mark3labs/mcp-go v0.35.0 h1:eh5bJGGVkNEaehCbPmAFqFgk/SB18YvxmsR2rnPm8BQ= 48 | github.com/mark3labs/mcp-go v0.35.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= 49 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 50 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 51 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 53 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 54 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 55 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 56 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 57 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 58 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 59 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 60 | github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= 61 | github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 62 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 63 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 64 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 65 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 66 | github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 67 | github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 68 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= 69 | github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 70 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 71 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 72 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 73 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 74 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 75 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 76 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 77 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 79 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 80 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 81 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 82 | -------------------------------------------------------------------------------- /docs/data-sources.md: -------------------------------------------------------------------------------- 1 | # Data Sources 2 | 3 | ## Overview 4 | 5 | `spotinfo` combines multiple data sources to provide comprehensive AWS EC2 Spot Instance information, including pricing, interruption rates, and placement scores. 6 | 7 | ## Primary Data Sources 8 | 9 | ### 1. AWS Spot Instance Advisor Data 10 | - **Source**: [AWS Spot Advisor JSON feed](https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json) 11 | - **Maintained by**: AWS team 12 | - **Update frequency**: Regularly updated by AWS 13 | - **Contains**: 14 | - Instance specifications (vCPU, memory, EMR compatibility) 15 | - Interruption frequency ranges 16 | - Savings percentages compared to on-demand pricing 17 | - Regional availability data 18 | 19 | ### 2. AWS Spot Pricing Data 20 | - **Source**: [AWS Spot Pricing JS callback file](http://spot-price.s3.amazonaws.com/spot.js) 21 | - **Maintained by**: AWS team 22 | - **Update frequency**: Regularly updated by AWS 23 | - **Contains**: 24 | - Current spot prices by region and instance type 25 | - Operating system pricing variations (Linux/Windows) 26 | - Historical pricing trends 27 | 28 | ### 3. AWS Spot Placement Scores API 29 | - **Source**: AWS `GetSpotPlacementScores` API 30 | - **Access**: Real-time API calls (requires IAM permissions) 31 | - **Contains**: 32 | - Regional placement scores (1-10 scale) 33 | - Availability zone-level placement scores 34 | - Likelihood of successful spot instance launch 35 | - Contextual scoring based on request composition 36 | 37 | ## Data Flow Architecture 38 | 39 | ```mermaid 40 | graph TB 41 | A[AWS Spot Advisor
JSON Feed] --> D[Data Aggregation] 42 | B[AWS Spot Pricing
JS Feed] --> D 43 | C[AWS Placement Scores
API] --> D 44 | 45 | D --> E[spotinfo Engine] 46 | 47 | E --> F[Embedded Data
Fallback] 48 | E --> G[Cached Results] 49 | 50 | G --> H[CLI Output] 51 | F --> H 52 | 53 | style A fill:#e1f5fe 54 | style B fill:#e1f5fe 55 | style C fill:#fff3e0 56 | style F fill:#f3e5f5 57 | style G fill:#e8f5e8 58 | ``` 59 | 60 | ## Network Resilience 61 | 62 | ### Embedded Data 63 | - **Purpose**: Ensure functionality without network connectivity 64 | - **Implementation**: Data is [embedded](https://golang.org/pkg/embed) into the binary during build 65 | - **Coverage**: Complete spot advisor and pricing data snapshot 66 | - **Update process**: Refreshed during each build via `make update-data` 67 | 68 | ### Fallback Strategy 69 | 1. **Primary**: Fetch fresh data from AWS feeds 70 | 2. **Secondary**: Use embedded data if network unavailable 71 | 3. **Placement Scores**: Graceful degradation to mock scores if API inaccessible 72 | 73 | ## Data Processing Pipeline 74 | 75 | ### 1. Data Fetching 76 | ```go 77 | // Pseudo-code flow 78 | func fetchData() { 79 | advisorData := fetchFromURL("https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json") 80 | if advisorData == nil { 81 | advisorData = loadEmbeddedAdvisorData() 82 | } 83 | 84 | pricingData := fetchFromURL("http://spot-price.s3.amazonaws.com/spot.js") 85 | if pricingData == nil { 86 | pricingData = loadEmbeddedPricingData() 87 | } 88 | } 89 | ``` 90 | 91 | ### 2. Data Transformation 92 | - **JSON parsing**: Convert AWS JSON format to internal structures 93 | - **Price extraction**: Parse JavaScript callback format for pricing 94 | - **Data normalization**: Standardize formats across sources 95 | - **Validation**: Ensure data integrity and completeness 96 | 97 | ### 3. Data Enrichment 98 | - **Instance type mapping**: Combine advisor and pricing data 99 | - **Score integration**: Add placement scores when requested 100 | - **Regional filtering**: Apply user-specified region constraints 101 | - **Specification filtering**: Apply CPU, memory, and price filters 102 | 103 | ## Cache Strategy 104 | 105 | ### Placement Score Caching 106 | - **Cache duration**: 10 minutes 107 | - **Cache key format**: `region:az_flag:instance_types` 108 | - **Purpose**: Reduce AWS API calls and improve performance 109 | - **Implementation**: LRU cache with expiration 110 | 111 | ### Data Freshness Tracking 112 | - **Timestamp tracking**: Record when data was last fetched 113 | - **Freshness indicators**: Visual indicators for stale data (>30 minutes) 114 | - **JSON metadata**: Include `score_fetched_at` timestamps in output 115 | 116 | ## Data Accuracy and Limitations 117 | 118 | ### Spot Advisor Data 119 | - **Accuracy**: High - directly from AWS 120 | - **Limitations**: 121 | - Static snapshot updated periodically by AWS 122 | - May not reflect real-time market conditions 123 | - Regional variations in update frequency 124 | 125 | ### Spot Pricing Data 126 | - **Accuracy**: High - current market prices 127 | - **Limitations**: 128 | - Prices change frequently 129 | - Some regions may have delayed updates 130 | - Embedded data becomes stale over time 131 | 132 | ### Placement Scores 133 | - **Accuracy**: Real-time from AWS API 134 | - **Limitations**: 135 | - Requires proper IAM permissions 136 | - May be restricted by Service Control Policies 137 | - Contextual scoring can be confusing to users 138 | - API rate limits apply 139 | 140 | ## Data Update Process 141 | 142 | ### Build-Time Updates 143 | ```bash 144 | # Update embedded data during build 145 | make update-data # Updates spot advisor data 146 | make update-price # Updates spot pricing data 147 | make build # Embeds fresh data in binary 148 | ``` 149 | 150 | ### Runtime Data Flow 151 | 1. **Startup**: Load embedded data as baseline 152 | 2. **Network fetch**: Attempt to fetch fresh data from AWS feeds 153 | 3. **Merge**: Combine fresh data with embedded fallback 154 | 4. **API calls**: Fetch placement scores on demand (if enabled) 155 | 5. **Cache**: Store results for performance optimization 156 | 157 | ## Monitoring and Observability 158 | 159 | ### Data Source Health 160 | - **Connection testing**: Verify AWS feed accessibility 161 | - **Data validation**: Ensure JSON structure integrity 162 | - **Fallback detection**: Log when embedded data is used 163 | 164 | ### Performance Metrics 165 | - **Fetch duration**: Monitor AWS feed response times 166 | - **Cache hit rate**: Track placement score cache effectiveness 167 | - **API quota usage**: Monitor placement score API consumption 168 | 169 | ## Security Considerations 170 | 171 | ### API Access 172 | - **IAM permissions**: Requires `ec2:GetSpotPlacementScores` permission 173 | - **Credential management**: Uses AWS SDK default credential chain 174 | - **Network security**: HTTPS for advisor data, HTTP for pricing (AWS provided) 175 | 176 | ### Data Privacy 177 | - **No personal data**: All data is public AWS pricing information 178 | - **No data retention**: Only temporary caching for performance 179 | - **No external transmission**: Data stays within AWS and local system 180 | 181 | ## Troubleshooting Data Issues 182 | 183 | ### Common Problems 184 | 185 | **Stale pricing data:** 186 | ```bash 187 | # Force fresh data fetch 188 | make update-data update-price build 189 | ``` 190 | 191 | **Missing placement scores:** 192 | ```bash 193 | # Verify API permissions 194 | aws ec2 get-spot-placement-scores --instance-types t3.micro --target-capacity 1 --region us-east-1 195 | ``` 196 | 197 | **Network connectivity issues:** 198 | - Tool automatically falls back to embedded data 199 | - Check network connectivity to `spot-bid-advisor.s3.amazonaws.com` 200 | - Verify firewall settings for outbound HTTPS 201 | 202 | **Permission errors:** 203 | - Check IAM policy includes `ec2:GetSpotPlacementScores` 204 | - Verify no Service Control Policy blocks the action 205 | - Test with AWS CLI: `aws sts get-caller-identity` 206 | 207 | ## See Also 208 | 209 | - [AWS Spot Placement Scores](aws-spot-placement-scores.md) - Detailed placement score documentation 210 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions 211 | - [Usage Guide](usage.md) - Command reference and examples -------------------------------------------------------------------------------- /internal/spot/types.go: -------------------------------------------------------------------------------- 1 | // Package spot provides functionality for retrieving AWS EC2 Spot instance pricing and advice. 2 | package spot 3 | 4 | import ( 5 | "sort" 6 | "time" 7 | ) 8 | 9 | // Range represents an interruption range for spot instances. 10 | type Range struct { 11 | Label string `json:"label"` 12 | Min int `json:"min"` 13 | Max int `json:"max"` 14 | } 15 | 16 | // TypeInfo contains instance type details: vCPU cores, memory, and EMR compatibility. 17 | type TypeInfo struct { 18 | Cores int `json:"cores"` 19 | EMR bool `json:"emr"` 20 | RAM float32 `json:"ram_gb"` //nolint:tagliatelle 21 | } 22 | 23 | // Advice represents spot price advice including interruption range and savings. 24 | type Advice struct { //nolint:govet 25 | Region string `json:"region"` 26 | Instance string `json:"instance"` 27 | InstanceType string `json:"instance_type"` 28 | Range Range `json:"range"` 29 | Savings int `json:"savings"` 30 | Info TypeInfo `json:"info"` 31 | Price float64 `json:"price"` 32 | ZonePrice map[string]float64 `json:"zone_price,omitempty"` 33 | RegionScore *int `json:"region_score,omitempty"` 34 | ZoneScores map[string]int `json:"zone_scores,omitempty"` 35 | ScoreFetchedAt *time.Time `json:"score_fetched_at,omitempty"` 36 | } 37 | 38 | // SortBy defines the sorting criteria for advice results. 39 | type SortBy int 40 | 41 | const ( 42 | // SortByRange sorts by frequency of interruption. 43 | SortByRange SortBy = iota 44 | // SortByInstance sorts by instance type (lexicographical). 45 | SortByInstance 46 | // SortBySavings sorts by savings percentage. 47 | SortBySavings 48 | // SortByPrice sorts by spot price. 49 | SortByPrice 50 | // SortByRegion sorts by AWS region name. 51 | SortByRegion 52 | // SortByScore sorts by spot placement score. 53 | SortByScore 54 | ) 55 | 56 | // ByRange implements sort.Interface based on the Range.Min field. 57 | type ByRange []Advice 58 | 59 | func (a ByRange) Len() int { return len(a) } 60 | func (a ByRange) Less(i, j int) bool { return a[i].Range.Min < a[j].Range.Min } 61 | func (a ByRange) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 62 | 63 | // ByInstance implements sort.Interface based on the Instance field. 64 | type ByInstance []Advice 65 | 66 | func (a ByInstance) Len() int { return len(a) } 67 | func (a ByInstance) Less(i, j int) bool { return a[i].Instance < a[j].Instance } 68 | func (a ByInstance) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 69 | 70 | // BySavings implements sort.Interface based on the Savings field. 71 | type BySavings []Advice 72 | 73 | func (a BySavings) Len() int { return len(a) } 74 | func (a BySavings) Less(i, j int) bool { return a[i].Savings < a[j].Savings } 75 | func (a BySavings) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 76 | 77 | // ByPrice implements sort.Interface based on the Price field. 78 | type ByPrice []Advice 79 | 80 | func (a ByPrice) Len() int { return len(a) } 81 | func (a ByPrice) Less(i, j int) bool { return a[i].Price < a[j].Price } 82 | func (a ByPrice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 83 | 84 | // ByRegion implements sort.Interface based on the Region field. 85 | type ByRegion []Advice 86 | 87 | func (a ByRegion) Len() int { return len(a) } 88 | func (a ByRegion) Less(i, j int) bool { return a[i].Region < a[j].Region } 89 | func (a ByRegion) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 90 | 91 | // ByScore implements sort.Interface based on the RegionScore field with nil-safe comparison. 92 | type ByScore []Advice 93 | 94 | func (a ByScore) Len() int { return len(a) } 95 | func (a ByScore) Less(i, j int) bool { 96 | // Handle nil scores safely 97 | if a[i].RegionScore == nil && a[j].RegionScore == nil { 98 | return false // Both nil, maintain order 99 | } 100 | if a[i].RegionScore == nil { 101 | return false // nil scores go to end 102 | } 103 | if a[j].RegionScore == nil { 104 | return true // non-nil before nil 105 | } 106 | return *a[i].RegionScore > *a[j].RegionScore // Higher scores first 107 | } 108 | func (a ByScore) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 109 | 110 | // sortAdvices sorts the advice slice according to the specified criteria. 111 | func sortAdvices(advices []Advice, sortBy SortBy, sortDesc bool) { 112 | var data sort.Interface 113 | 114 | switch sortBy { 115 | case SortByRange: 116 | data = ByRange(advices) 117 | case SortByInstance: 118 | data = ByInstance(advices) 119 | case SortBySavings: 120 | data = BySavings(advices) 121 | case SortByPrice: 122 | data = ByPrice(advices) 123 | case SortByRegion: 124 | data = ByRegion(advices) 125 | case SortByScore: 126 | data = ByScore(advices) 127 | default: 128 | data = ByRange(advices) 129 | } 130 | 131 | if sortDesc { 132 | data = sort.Reverse(data) 133 | } 134 | 135 | sort.Sort(data) 136 | } 137 | 138 | // filterByMinScore filters advices to only include those with a minimum region score. 139 | func filterByMinScore(advices []Advice, minScore int) []Advice { 140 | var filtered []Advice 141 | for _, adv := range advices { 142 | if adv.RegionScore != nil && *adv.RegionScore >= minScore { 143 | filtered = append(filtered, adv) 144 | } 145 | } 146 | return filtered 147 | } 148 | 149 | // interruptionRange represents AWS spot instance interruption frequency ranges. 150 | type interruptionRange struct { 151 | Label string `json:"label"` 152 | Index int `json:"index"` 153 | Dots int `json:"dots"` 154 | Max int `json:"max"` 155 | } 156 | 157 | // instanceType represents AWS EC2 instance type specifications. 158 | type instanceType struct { 159 | Cores int `json:"cores"` 160 | EMR bool `json:"emr"` 161 | RAM float32 `json:"ram_gb"` //nolint:tagliatelle 162 | } 163 | 164 | // spotAdvice represents spot pricing advice for a specific instance type. 165 | type spotAdvice struct { 166 | Range int `json:"r"` 167 | Savings int `json:"s"` 168 | } 169 | 170 | // osTypes represents spot pricing data by operating system. 171 | type osTypes struct { 172 | Windows map[string]spotAdvice `json:"Windows"` //nolint:tagliatelle 173 | Linux map[string]spotAdvice `json:"Linux"` //nolint:tagliatelle 174 | } 175 | 176 | // advisorData represents the complete AWS spot advisor dataset. 177 | type advisorData struct { //nolint:govet // Field alignment is less important than JSON tag clarity 178 | Embedded bool // true if loaded from embedded copy 179 | Ranges []interruptionRange `json:"ranges"` 180 | Regions map[string]osTypes `json:"spot_advisor"` //nolint:tagliatelle 181 | InstanceTypes map[string]instanceType `json:"instance_types"` //nolint:tagliatelle 182 | } 183 | 184 | // rawPriceData represents the raw AWS spot pricing data structure. 185 | type rawPriceData struct { //nolint:govet 186 | Embedded bool `json:"-"` // true if loaded from embedded copy 187 | Config config `json:"config"` 188 | } 189 | 190 | type config struct { 191 | Rate string `json:"rate"` 192 | ValueColumns []string `json:"valueColumns"` 193 | Currencies []string `json:"currencies"` 194 | Regions []regionConfig `json:"regions"` 195 | } 196 | 197 | type regionConfig struct { 198 | Region string `json:"region"` 199 | InstanceTypes []instanceTypeConfig `json:"instanceTypes"` 200 | } 201 | 202 | type instanceTypeConfig struct { 203 | Type string `json:"type"` 204 | Sizes []sizeConfig `json:"sizes"` 205 | } 206 | 207 | type sizeConfig struct { 208 | Size string `json:"size"` 209 | ValueColumns []valueColumnConfig `json:"valueColumns"` 210 | } 211 | 212 | type valueColumnConfig struct { 213 | Name string `json:"name"` 214 | Prices priceConfig `json:"prices"` 215 | } 216 | 217 | type priceConfig struct { 218 | USD string `json:"USD"` //nolint:tagliatelle 219 | } 220 | 221 | // instancePrice represents pricing for an instance type by OS. 222 | type instancePrice struct { 223 | Linux float64 224 | Windows float64 225 | } 226 | 227 | // regionPrice represents pricing data for a region. 228 | type regionPrice struct { 229 | Instance map[string]instancePrice 230 | } 231 | 232 | // spotPriceData represents processed spot pricing data. 233 | type spotPriceData struct { 234 | Region map[string]regionPrice 235 | } 236 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## Command Overview 4 | 5 | `spotinfo` is a command-line tool for exploring AWS EC2 Spot instances with advanced filtering, sorting, and placement score analysis capabilities. 6 | 7 | ## Basic Syntax 8 | 9 | ```bash 10 | spotinfo [global options] 11 | ``` 12 | 13 | ## Global Options 14 | 15 | ### Instance Selection 16 | | Flag | Description | Example | 17 | |------|-------------|---------| 18 | | `--type value` | EC2 instance type (supports RE2 regex) | `--type "m5.large"` or `--type "t3.*"` | 19 | | `--os value` | Operating system filter | `--os linux` (default) or `--os windows` | 20 | 21 | ### Geographic Filtering 22 | | Flag | Description | Example | 23 | |------|-------------|---------| 24 | | `--region value` | AWS regions (can be used multiple times) | `--region us-east-1 --region us-west-2` | 25 | | | Use "all" for all regions | `--region all` | 26 | 27 | ### Resource Filtering 28 | | Flag | Description | Example | 29 | |------|-------------|---------| 30 | | `--cpu value` | Minimum vCPU cores | `--cpu 4` | 31 | | `--memory value` | Minimum memory in GiB | `--memory 16` | 32 | | `--price value` | Maximum price per hour (USD) | `--price 0.50` | 33 | 34 | ### AWS Spot Placement Scores 35 | | Flag | Description | Example | 36 | |------|-------------|---------| 37 | | `--with-score` | Enable placement score fetching | `--with-score` | 38 | | `--az` | Request AZ-level scores (use with --with-score) | `--with-score --az` | 39 | | `--min-score value` | Minimum placement score (1-10) | `--min-score 7` | 40 | | `--score-timeout value` | Timeout for score API in seconds | `--score-timeout 30` | 41 | 42 | ### Sorting and Output 43 | | Flag | Description | Example | 44 | |------|-------------|---------| 45 | | `--sort value` | Sort by: interruption, type, savings, price, region, score | `--sort score` | 46 | | `--order value` | Sort order: asc or desc | `--order desc` | 47 | | `--output value` | Output format: table, json, csv, text, number | `--output json` | 48 | 49 | ### System Options 50 | | Flag | Description | Example | 51 | |------|-------------|---------| 52 | | `--mcp` | Run as MCP server instead of CLI | `--mcp` | 53 | | `--debug` | Enable debug logging | `--debug` | 54 | | `--quiet` | Quiet mode (errors only) | `--quiet` | 55 | | `--json-log` | Output logs in JSON format | `--json-log` | 56 | | `--help, -h` | Show help | `--help` | 57 | | `--version, -v` | Print version | `--version` | 58 | 59 | ## Output Formats 60 | 61 | ### Table Format (Default) 62 | Human-readable table with visual indicators: 63 | ```bash 64 | spotinfo --type "t3.micro" --with-score 65 | ``` 66 | ``` 67 | ┌───────────────┬──────┬────────────┬────────────────────────┬──────────┬────────────────────────────┐ 68 | │ INSTANCE INFO │ VCPU │ MEMORY GIB │ SAVINGS OVER ON-DEMAND │ USD/HOUR │ PLACEMENT SCORE (REGIONAL) │ 69 | ├───────────────┼──────┼────────────┼────────────────────────┼──────────┼────────────────────────────┤ 70 | │ t3.micro │ 2 │ 1 │ 68% │ 0.0043 │ 🟢 9 │ 71 | └───────────────┴──────┴────────────┴────────────────────────┴──────────┴────────────────────────────┘ 72 | ``` 73 | 74 | ### JSON Format 75 | Structured data for automation: 76 | ```bash 77 | spotinfo --type "t3.micro" --with-score --output json 78 | ``` 79 | ```json 80 | [ 81 | { 82 | "region": "us-east-1", 83 | "instance": "t3.micro", 84 | "instance_type": "t3.micro", 85 | "range": { 86 | "label": "<5%", 87 | "min": 0, 88 | "max": 5 89 | }, 90 | "savings": 68, 91 | "info": { 92 | "cores": 2, 93 | "emr": false, 94 | "ram_gb": 1 95 | }, 96 | "price": 0.0043, 97 | "region_score": 9, 98 | "score_fetched_at": "2025-01-26T10:45:02.844335+03:00" 99 | } 100 | ] 101 | ``` 102 | 103 | ### CSV Format 104 | Data-only format without visual indicators: 105 | ```bash 106 | spotinfo --type "t3.micro" --with-score --output csv 107 | ``` 108 | ``` 109 | Instance Info,vCPU,Memory GiB,Savings over On-Demand,Frequency of interruption,USD/Hour,Placement Score (Regional) 110 | t3.micro,2,1,68,<5%,0.0043,9 111 | ``` 112 | 113 | ### Text Format 114 | Plain text for scripting: 115 | ```bash 116 | spotinfo --type "t3.micro" --with-score --output text 117 | ``` 118 | ``` 119 | type=t3.micro, vCPU=2, memory=1GiB, saving=68%, interruption='<5%', price=0.00, score=🟢 9 120 | ``` 121 | 122 | ### Number Format 123 | Single value for automation: 124 | ```bash 125 | spotinfo --type "t3.micro" --output number 126 | ``` 127 | ``` 128 | 68 129 | ``` 130 | 131 | ## Usage Patterns 132 | 133 | ### Quick Instance Assessment 134 | ```bash 135 | # Basic instance information 136 | spotinfo --type "m5.large" 137 | 138 | # With placement scores 139 | spotinfo --type "m5.large" --with-score 140 | ``` 141 | 142 | ### Production Planning 143 | ```bash 144 | # High-reliability instances 145 | spotinfo --type "m5.*" --with-score --min-score 8 --region "us-east-1" 146 | 147 | # Cross-region comparison 148 | spotinfo --type "c5.xlarge" --with-score --region "us-east-1" --region "eu-west-1" 149 | ``` 150 | 151 | ### Cost Optimization 152 | ```bash 153 | # Cheapest instances with good reliability 154 | spotinfo --type "t3.*" --with-score --min-score 6 --sort price --order asc 155 | 156 | # Budget constraints 157 | spotinfo --cpu 4 --memory 16 --price 0.20 --with-score 158 | ``` 159 | 160 | ### Advanced Filtering 161 | ```bash 162 | # Regex patterns 163 | spotinfo --type "^(m5|c5)\.(large|xlarge)$" --with-score 164 | 165 | # Combined filters 166 | spotinfo --type "r5.*" --cpu 8 --memory 64 --with-score --min-score 7 167 | ``` 168 | 169 | ### Availability Zone Analysis 170 | ```bash 171 | # AZ-level placement scores 172 | spotinfo --type "m5.large" --with-score --az --region "us-east-1" 173 | 174 | # Compare AZ vs regional scores 175 | spotinfo --type "c5.xlarge" --with-score --region "us-east-1" 176 | spotinfo --type "c5.xlarge" --with-score --az --region "us-east-1" 177 | ``` 178 | 179 | ## Automation Examples 180 | 181 | ### Shell Scripts 182 | ```bash 183 | #!/bin/bash 184 | # Find best instance for requirements 185 | BEST_INSTANCE=$(spotinfo --cpu 4 --memory 16 --with-score --min-score 8 \ 186 | --sort price --order asc --output json | jq -r '.[0].instance') 187 | echo "Recommended instance: $BEST_INSTANCE" 188 | ``` 189 | 190 | ### CI/CD Integration 191 | ```bash 192 | # Cost validation in deployment pipeline 193 | MAX_COST="0.50" 194 | INSTANCE_COST=$(spotinfo --type "m5.xlarge" --region "us-east-1" --output number) 195 | if (( $(echo "$INSTANCE_COST > $MAX_COST" | bc -l) )); then 196 | echo "Instance cost exceeds budget: $INSTANCE_COST > $MAX_COST" 197 | exit 1 198 | fi 199 | ``` 200 | 201 | ### Infrastructure as Code 202 | ```bash 203 | # Generate Terraform variables 204 | spotinfo --type "c5.*" --with-score --min-score 7 --output json > spot_instances.json 205 | ``` 206 | 207 | ## Exit Codes 208 | 209 | | Code | Description | 210 | |------|-------------| 211 | | 0 | Success | 212 | | 1 | General error (invalid arguments, API failure, etc.) | 213 | 214 | ## Environment Variables 215 | 216 | | Variable | Description | Default | 217 | |----------|-------------|---------| 218 | | `SPOTINFO_MODE` | Set to "mcp" to enable MCP server mode | CLI mode | 219 | | `MCP_TRANSPORT` | MCP transport method | "stdio" | 220 | | `MCP_PORT` | Port for SSE transport | "8080" | 221 | 222 | ## Performance Considerations 223 | 224 | - **Caching**: Placement scores are cached for 10 minutes 225 | - **Rate Limiting**: AWS API calls are rate-limited (10 requests/second) 226 | - **Timeout**: Default score timeout is 30 seconds 227 | - **Large Queries**: Use `--region` filtering for faster results with large instance type patterns 228 | 229 | ## Error Handling 230 | 231 | Common error scenarios and solutions: 232 | 233 | ```bash 234 | # Invalid instance type pattern 235 | spotinfo --type "[invalid-regex" 236 | # Error: error parsing regexp: missing closing ] 237 | 238 | # Insufficient permissions 239 | spotinfo --with-score --region "us-west-2" 240 | # Error: You are not authorized to perform: ec2:GetSpotPlacementScores 241 | 242 | # Network timeout 243 | spotinfo --with-score --score-timeout 60 244 | # Increases timeout for slow connections 245 | ``` 246 | 247 | ## See Also 248 | 249 | - [AWS Spot Placement Scores](aws-spot-placement-scores.md) - Detailed placement score documentation 250 | - [Examples](examples.md) - Real-world usage examples 251 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions 252 | - [MCP Server](mcp-server.md) - Model Context Protocol integration -------------------------------------------------------------------------------- /docs/mcp-server.md: -------------------------------------------------------------------------------- 1 | # Model Context Protocol (MCP) Server 2 | 3 | ## Overview 4 | 5 | The `spotinfo` tool functions as a **Model Context Protocol (MCP) server**, enabling AI assistants like Claude to directly query AWS EC2 Spot Instance data. This provides a seamless way for AI agents to access real-time spot pricing and interruption data for infrastructure recommendations. 6 | 7 | ## What is MCP? 8 | 9 | The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard that allows AI assistants to securely connect to external data sources and tools. By running `spotinfo` in MCP mode, you can: 10 | 11 | - **Ask Claude for spot recommendations**: "Find me the cheapest t3 instances with <10% interruption rate" 12 | - **Get real-time pricing**: "What's the current m5.large spot price in us-east-1?" 13 | - **Compare across regions**: "Show me r5.xlarge prices across all US regions" 14 | - **Infrastructure planning**: Use AI to analyze and recommend optimal spot instance configurations 15 | 16 | ## Quick Start with Claude Desktop 17 | 18 | ### 1. Install spotinfo 19 | 20 | ```bash 21 | # macOS with Homebrew 22 | brew tap alexei-led/spotinfo 23 | brew install spotinfo 24 | 25 | # Or download from releases page 26 | curl -L https://github.com/alexei-led/spotinfo/releases/latest/download/spotinfo_linux_amd64.tar.gz | tar xz 27 | ``` 28 | 29 | ### 2. Add to Claude Desktop Configuration 30 | 31 | Open Claude Desktop settings and add to your `claude_desktop_config.json`: 32 | 33 | ```json 34 | { 35 | "mcpServers": { 36 | "spotinfo": { 37 | "command": "spotinfo", 38 | "args": ["--mcp"] 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | ### 3. Restart Claude Desktop 45 | 46 | Restart Claude Desktop and start asking about AWS Spot Instances! 47 | 48 | ## Available MCP Tools 49 | 50 | ### `find_spot_instances` 51 | 52 | Search for AWS EC2 Spot Instance options based on requirements. 53 | 54 | **Parameters:** 55 | - `regions` (optional): AWS regions to search (e.g., `["us-east-1", "eu-west-1"]`). Use `["all"]` for all regions 56 | - `instance_types` (optional): Instance type pattern (e.g., `"m5.large"`, `"t3.*"`) 57 | - `min_vcpu` (optional): Minimum vCPUs required 58 | - `min_memory_gb` (optional): Minimum memory in gigabytes 59 | - `max_price_per_hour` (optional): Maximum spot price per hour in USD 60 | - `max_interruption_rate` (optional): Maximum interruption rate percentage (0-100) 61 | - `sort_by` (optional): Sort by `"price"`, `"reliability"`, or `"savings"` (default: `"reliability"`) 62 | - `limit` (optional): Maximum results to return (default: 10, max: 50) 63 | 64 | **Response:** Array of spot instances with pricing, savings, interruption data, and specs. 65 | 66 | ### `list_spot_regions` 67 | 68 | List all AWS regions where EC2 Spot Instances are available. 69 | 70 | **Parameters:** 71 | - `include_names` (optional): Include human-readable region names (default: true) 72 | 73 | **Response:** Array of available region codes and total count. 74 | 75 | ## Configuration Options 76 | 77 | ### Environment Variables 78 | 79 | | Variable | Description | Default | 80 | |----------|-------------|---------| 81 | | `SPOTINFO_MODE` | Set to "mcp" to enable MCP server mode | CLI mode | 82 | | `MCP_TRANSPORT` | Transport method | "stdio" | 83 | | `MCP_PORT` | Port for SSE transport | "8080" | 84 | 85 | ### Command Line Flags 86 | 87 | ```bash 88 | # Start MCP server with stdio transport (for Claude Desktop) 89 | spotinfo --mcp 90 | 91 | # Or using environment variable 92 | SPOTINFO_MODE=mcp spotinfo 93 | ``` 94 | 95 | ## Example Usage 96 | 97 | Once configured with Claude Desktop, you can ask natural language questions: 98 | 99 | ### Example 1: Finding Cost-Effective Instances 100 | 101 | **Human**: Find me the 5 cheapest t3 instances globally with less than 10% interruption rate 102 | 103 | **Claude**: I'll search for t3 instances with low interruption rates and sort by price. 104 | 105 | ```json 106 | { 107 | "instance_types": "t3.*", 108 | "max_interruption_rate": 10, 109 | "sort_by": "price", 110 | "limit": 5 111 | } 112 | ``` 113 | 114 | **Results**: Found 5 t3 instances under $0.05/hour with <10% interruption rates: 115 | - t3.nano in ap-south-1: $0.0017/hour (5-10% interruption) 116 | - t3.micro in ap-south-1: $0.0033/hour (<5% interruption) 117 | - ... 118 | 119 | ### Example 2: Regional Comparison 120 | 121 | **Human**: Compare m5.large spot prices across US East regions 122 | 123 | **Claude**: I'll check m5.large pricing in US East regions for you. 124 | 125 | ```json 126 | { 127 | "regions": ["us-east-1", "us-east-2"], 128 | "instance_types": "m5.large" 129 | } 130 | ``` 131 | 132 | **Results**: m5.large spot prices in US East: 133 | - us-east-1: $0.0928/hour (70% savings, <5% interruption) 134 | - us-east-2: $0.1024/hour (68% savings, <5% interruption) 135 | 136 | ### Example 3: Infrastructure Planning 137 | 138 | **Human**: I need instances with at least 16 vCPUs and 64GB RAM for machine learning workloads. What are my most reliable options under $1/hour? 139 | 140 | **Claude**: I'll find high-spec instances optimized for reliability within your budget. 141 | 142 | ```json 143 | { 144 | "min_vcpu": 16, 145 | "min_memory_gb": 64, 146 | "max_price_per_hour": 1.0, 147 | "sort_by": "reliability", 148 | "limit": 10 149 | } 150 | ``` 151 | 152 | **Results**: Found 8 instances meeting your criteria, with r5.4xlarge and m5.4xlarge offering the best reliability... 153 | 154 | ## Advanced Configuration 155 | 156 | ### Claude Desktop Configuration (macOS) 157 | 158 | Configuration file location: `~/Library/Application Support/Claude/claude_desktop_config.json` 159 | 160 | ```json 161 | { 162 | "mcpServers": { 163 | "spotinfo": { 164 | "command": "/opt/homebrew/bin/spotinfo", 165 | "args": ["--mcp"], 166 | "env": { 167 | "AWS_REGION": "us-east-1" 168 | } 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | ### Claude Desktop Configuration (Windows) 175 | 176 | Configuration file location: `%APPDATA%\Claude\claude_desktop_config.json` 177 | 178 | ```json 179 | { 180 | "mcpServers": { 181 | "spotinfo": { 182 | "command": "C:\\Program Files\\spotinfo\\spotinfo.exe", 183 | "args": ["--mcp"] 184 | } 185 | } 186 | } 187 | ``` 188 | 189 | ### Claude Desktop Configuration (Linux) 190 | 191 | Configuration file location: `~/.config/claude-desktop/claude_desktop_config.json` 192 | 193 | ```json 194 | { 195 | "mcpServers": { 196 | "spotinfo": { 197 | "command": "/usr/local/bin/spotinfo", 198 | "args": ["--mcp"] 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | ## Troubleshooting 205 | 206 | ### Common Issues 207 | 208 | **Claude can't find spotinfo tools:** 209 | - Verify `spotinfo --mcp` runs without errors 210 | - Check the binary path in your configuration 211 | - Restart Claude Desktop after configuration changes 212 | 213 | **Permission denied errors:** 214 | - Ensure the spotinfo binary is executable: `chmod +x /path/to/spotinfo` 215 | - Check file paths in configuration are correct 216 | 217 | **No data returned:** 218 | - The tool uses embedded data and works offline 219 | - Check if specific regions/instance types exist with CLI: `spotinfo --type=m5.large --region=us-east-1` 220 | 221 | ### Debug Mode 222 | 223 | ```bash 224 | # Test MCP server manually 225 | spotinfo --mcp 226 | # Should start server and wait for input 227 | 228 | # Test with MCP Inspector (requires Node.js) 229 | npx @modelcontextprotocol/inspector spotinfo --mcp 230 | ``` 231 | 232 | ### Verification Steps 233 | 234 | 1. **Test CLI mode first**: 235 | ```bash 236 | spotinfo --type "t3.micro" --region "us-east-1" 237 | ``` 238 | 239 | 2. **Test MCP mode**: 240 | ```bash 241 | spotinfo --mcp 242 | # Should start and wait for JSON-RPC input 243 | ``` 244 | 245 | 3. **Verify Claude Desktop config**: 246 | - Check file exists and is valid JSON 247 | - Verify binary path is correct 248 | - Restart Claude Desktop 249 | 250 | 4. **Check logs**: 251 | - Enable debug mode: `spotinfo --mcp --debug` 252 | - Check Claude Desktop logs for MCP connection issues 253 | 254 | ## Server Capabilities 255 | 256 | ### Protocol Details 257 | 258 | - **Protocol Version**: `2024-11-05` 259 | - **Server Name**: `spotinfo` 260 | - **Transport**: `stdio` (Claude Desktop compatible) 261 | - **Capabilities**: `tools` 262 | 263 | ### Response Format 264 | 265 | All responses follow MCP specification: 266 | 267 | ```json 268 | { 269 | "jsonrpc": "2.0", 270 | "result": { 271 | "content": [ 272 | { 273 | "type": "text", 274 | "text": "Found 5 matching spot instances..." 275 | } 276 | ] 277 | }, 278 | "id": "request-id" 279 | } 280 | ``` 281 | 282 | ## Benefits of MCP Integration 283 | 284 | 1. **Natural Language Interface**: Ask questions about spot instances in plain English 285 | 2. **Intelligent Recommendations**: Claude can analyze your requirements and suggest optimal configurations 286 | 3. **Real-time Data**: Access current spot pricing and interruption data 287 | 4. **Cross-region Analysis**: Easily compare options across multiple AWS regions 288 | 5. **Automated Decision Making**: Use Claude's reasoning to optimize cost vs. reliability trade-offs 289 | 290 | The MCP integration transforms `spotinfo` from a CLI tool into an intelligent infrastructure advisor, making AWS Spot Instance selection more accessible and efficient. 291 | 292 | ## API Reference 293 | 294 | For complete MCP tool specifications, see [API Reference](api-reference.md). 295 | 296 | ## See Also 297 | 298 | - [Claude Desktop Setup](claude-desktop-setup.md) - Detailed setup instructions 299 | - [Usage Guide](usage.md) - CLI command reference 300 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions -------------------------------------------------------------------------------- /docs/claude-desktop-setup.md: -------------------------------------------------------------------------------- 1 | # Claude Desktop Integration Guide 2 | 3 | This guide provides detailed instructions for integrating `spotinfo` with Claude Desktop using the Model Context Protocol (MCP). After setup, you'll be able to ask Claude natural language questions about AWS EC2 Spot Instances. 4 | 5 | ## Prerequisites 6 | 7 | - **Claude Desktop** installed and running 8 | - **spotinfo** binary installed and accessible 9 | - Basic familiarity with JSON configuration files 10 | 11 | ## Step 1: Install spotinfo 12 | 13 | ### Option A: macOS with Homebrew (Recommended) 14 | ```bash 15 | brew tap alexei-led/spotinfo 16 | brew install spotinfo 17 | ``` 18 | 19 | ### Option B: Download from Releases 20 | 1. Visit the [releases page](https://github.com/alexei-led/spotinfo/releases) 21 | 2. Download the appropriate binary for your platform: 22 | - **macOS Intel**: `spotinfo_darwin_amd64.tar.gz` 23 | - **macOS Apple Silicon**: `spotinfo_darwin_arm64.tar.gz` 24 | - **Windows Intel/AMD**: `spotinfo_windows_amd64.zip` 25 | - **Windows ARM**: `spotinfo_windows_arm64.zip` 26 | - **Linux Intel/AMD**: `spotinfo_linux_amd64.tar.gz` 27 | - **Linux ARM**: `spotinfo_linux_arm64.tar.gz` 28 | 29 | 3. Extract and install: 30 | ```bash 31 | # Example for macOS/Linux 32 | tar -xzf spotinfo_darwin_amd64.tar.gz 33 | chmod +x spotinfo 34 | sudo mv spotinfo /usr/local/bin/ 35 | ``` 36 | 37 | ### Option C: Build from Source 38 | ```bash 39 | git clone https://github.com/alexei-led/spotinfo.git 40 | cd spotinfo 41 | make build 42 | sudo cp spotinfo /usr/local/bin/ 43 | ``` 44 | 45 | ## Step 2: Verify Installation 46 | 47 | Test that spotinfo is working correctly: 48 | 49 | ```bash 50 | # Test CLI functionality 51 | spotinfo --type=t3.micro --region=us-east-1 --output=json 52 | 53 | # Test MCP server mode 54 | spotinfo --mcp 55 | # Should start and wait for input (press Ctrl+C to exit) 56 | ``` 57 | 58 | ## Step 3: Configure Claude Desktop 59 | 60 | ### Locate Configuration File 61 | 62 | The configuration file location depends on your operating system: 63 | 64 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` 65 | - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` 66 | - **Linux**: `~/.config/Claude/claude_desktop_config.json` 67 | 68 | ### Find Binary Path 69 | 70 | Determine the full path to your spotinfo binary: 71 | 72 | ```bash 73 | # Find the path 74 | which spotinfo 75 | # Output example: /opt/homebrew/bin/spotinfo (macOS Homebrew) 76 | # Output example: /usr/local/bin/spotinfo (manual install) 77 | ``` 78 | 79 | ### Create/Edit Configuration 80 | 81 | Create or edit the Claude Desktop configuration file: 82 | 83 | #### Basic Configuration 84 | ```json 85 | { 86 | "mcpServers": { 87 | "spotinfo": { 88 | "command": "/opt/homebrew/bin/spotinfo", 89 | "args": ["--mcp"] 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | #### Advanced Configuration with Environment Variables 96 | ```json 97 | { 98 | "mcpServers": { 99 | "spotinfo": { 100 | "command": "/opt/homebrew/bin/spotinfo", 101 | "args": ["--mcp"], 102 | "env": { 103 | "AWS_REGION": "us-east-1", 104 | "SPOTINFO_MODE": "mcp" 105 | } 106 | } 107 | } 108 | } 109 | ``` 110 | 111 | ### Platform-Specific Examples 112 | 113 | #### macOS (Homebrew Installation) 114 | ```json 115 | { 116 | "mcpServers": { 117 | "spotinfo": { 118 | "command": "/opt/homebrew/bin/spotinfo", 119 | "args": ["--mcp"] 120 | } 121 | } 122 | } 123 | ``` 124 | 125 | #### macOS (Manual Installation) 126 | ```json 127 | { 128 | "mcpServers": { 129 | "spotinfo": { 130 | "command": "/usr/local/bin/spotinfo", 131 | "args": ["--mcp"] 132 | } 133 | } 134 | } 135 | ``` 136 | 137 | #### Windows 138 | ```json 139 | { 140 | "mcpServers": { 141 | "spotinfo": { 142 | "command": "C:\\Program Files\\spotinfo\\spotinfo.exe", 143 | "args": ["--mcp"] 144 | } 145 | } 146 | } 147 | ``` 148 | 149 | #### Linux 150 | ```json 151 | { 152 | "mcpServers": { 153 | "spotinfo": { 154 | "command": "/usr/local/bin/spotinfo", 155 | "args": ["--mcp"] 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | ## Step 4: Restart Claude Desktop 162 | 163 | After editing the configuration file: 164 | 165 | 1. **Quit Claude Desktop completely** 166 | 2. **Wait a few seconds** 167 | 3. **Launch Claude Desktop again** 168 | 169 | ## Step 5: Test Integration 170 | 171 | Start a conversation with Claude and try these test queries: 172 | 173 | ### Basic Test 174 | ``` 175 | Human: Can you list the available AWS regions for spot instances? 176 | ``` 177 | 178 | Expected: Claude should use the `list_spot_regions` tool and return a list of AWS regions. 179 | 180 | ### Advanced Test 181 | ``` 182 | Human: Find me the cheapest t3.micro instances with less than 10% interruption rate 183 | ``` 184 | 185 | Expected: Claude should use the `find_spot_instances` tool with appropriate filters. 186 | 187 | ## Troubleshooting 188 | 189 | ### Common Issues and Solutions 190 | 191 | #### 1. Claude doesn't see the spotinfo tools 192 | 193 | **Symptoms:** 194 | - Claude responds with "I don't have access to AWS spot instance tools" 195 | - No MCP tools appear in Claude's responses 196 | 197 | **Solutions:** 198 | ```bash 199 | # Test the MCP server directly 200 | spotinfo --mcp 201 | # Should start without errors 202 | 203 | # Check binary permissions 204 | ls -la $(which spotinfo) 205 | # Should show execute permissions 206 | 207 | # Verify configuration syntax 208 | cat ~/Library/Application\ Support/Claude/claude_desktop_config.json | jq . 209 | # Should parse without errors 210 | ``` 211 | 212 | #### 2. Permission denied errors 213 | 214 | **Symptoms:** 215 | - Claude shows connection errors 216 | - Console shows permission denied messages 217 | 218 | **Solutions:** 219 | ```bash 220 | # Make binary executable 221 | chmod +x /path/to/spotinfo 222 | 223 | # For macOS, you might need to allow the binary 224 | spctl --add /path/to/spotinfo 225 | ``` 226 | 227 | #### 3. Binary not found 228 | 229 | **Symptoms:** 230 | - "Command not found" or similar errors 231 | 232 | **Solutions:** 233 | ```bash 234 | # Verify the binary path exists 235 | ls -la /opt/homebrew/bin/spotinfo 236 | 237 | # If not found, reinstall or check installation 238 | which spotinfo 239 | 240 | # Update configuration with correct path 241 | ``` 242 | 243 | #### 4. Configuration file issues 244 | 245 | **Symptoms:** 246 | - Claude Desktop fails to start 247 | - MCP servers don't load 248 | 249 | **Solutions:** 250 | ```bash 251 | # Validate JSON syntax 252 | cat claude_desktop_config.json | jq . 253 | 254 | # Check for common issues: 255 | # - Missing commas 256 | # - Incorrect quotes 257 | # - Wrong file paths 258 | # - Invalid escape characters in Windows paths 259 | ``` 260 | 261 | #### 5. macOS Security Warnings 262 | 263 | **Symptoms:** 264 | - "spotinfo cannot be opened because it is from an unidentified developer" 265 | 266 | **Solutions:** 267 | ```bash 268 | # Remove quarantine attribute 269 | xattr -d com.apple.quarantine /path/to/spotinfo 270 | 271 | # Or allow in Security & Privacy settings: 272 | # System Preferences → Security & Privacy → General → Allow apps downloaded from: Anywhere 273 | ``` 274 | 275 | ## Advanced Configuration 276 | 277 | ### Multiple MCP Servers 278 | You can configure multiple MCP servers alongside spotinfo: 279 | 280 | ```json 281 | { 282 | "mcpServers": { 283 | "spotinfo": { 284 | "command": "/opt/homebrew/bin/spotinfo", 285 | "args": ["--mcp"] 286 | }, 287 | "other-tool": { 288 | "command": "/path/to/other-tool", 289 | "args": ["--mcp"] 290 | } 291 | } 292 | } 293 | ``` 294 | 295 | ### Environment Variables 296 | Configure default AWS region or other settings: 297 | 298 | ```json 299 | { 300 | "mcpServers": { 301 | "spotinfo": { 302 | "command": "/opt/homebrew/bin/spotinfo", 303 | "args": ["--mcp"], 304 | "env": { 305 | "AWS_REGION": "us-west-2", 306 | "SPOTINFO_MODE": "mcp" 307 | } 308 | } 309 | } 310 | } 311 | ``` 312 | 313 | ## Usage Examples 314 | 315 | Once configured, you can ask Claude various questions about AWS Spot Instances: 316 | 317 | ### Cost Optimization 318 | ``` 319 | Human: What are the most cost-effective ways to run a web server on AWS using spot instances? 320 | 321 | Claude: I'll help you find cost-effective spot instances for web server workloads... 322 | ``` 323 | 324 | ### Regional Comparison 325 | ``` 326 | Human: Compare t3.medium spot prices across all US regions and recommend the best option 327 | 328 | Claude: I'll compare t3.medium spot pricing across US regions for you... 329 | ``` 330 | 331 | ### Requirements-Based Search 332 | ``` 333 | Human: I need an instance with at least 8 vCPUs and 32GB RAM for data processing, but I want to keep costs under $0.50/hour. What are my options? 334 | 335 | Claude: I'll search for instances meeting your specifications within budget... 336 | ``` 337 | 338 | ### Infrastructure Planning 339 | ``` 340 | Human: Help me plan a cost-effective Kubernetes cluster using spot instances with different node types 341 | 342 | Claude: I'll help you design a diverse spot instance setup for Kubernetes... 343 | ``` 344 | 345 | ## Verification Steps 346 | 347 | ### 1. Check MCP Server Status 348 | ```bash 349 | # Manual test 350 | spotinfo --mcp 351 | # Should start and display server information 352 | ``` 353 | 354 | ### 2. Test with MCP Inspector 355 | ```bash 356 | # Install MCP Inspector 357 | npm install -g @modelcontextprotocol/inspector 358 | 359 | # Test integration 360 | npx @modelcontextprotocol/inspector spotinfo --mcp 361 | ``` 362 | 363 | ### 3. Claude Desktop Logs 364 | Check Claude Desktop logs for any error messages: 365 | - **macOS**: `~/Library/Logs/Claude/` 366 | - **Windows**: `%LOCALAPPDATA%\Claude\logs\` 367 | 368 | ## Getting Help 369 | 370 | If you encounter issues not covered in this guide: 371 | 372 | 1. **Check the [troubleshooting document](troubleshooting.md)** 373 | 2. **Review the [API reference](api-reference.md)** 374 | 3. **Test with CLI first**: `spotinfo --type=t3.micro --region=us-east-1` 375 | 4. **File an issue**: [GitHub Issues](https://github.com/alexei-led/spotinfo/issues) 376 | 377 | ## Next Steps 378 | 379 | - Explore the [API reference](api-reference.md) for detailed tool documentation 380 | - Review [troubleshooting guide](troubleshooting.md) for common issues 381 | - Learn about advanced usage patterns in the main README 382 | - Consider contributing improvements or reporting bugs 383 | 384 | The integration transforms Claude into an intelligent AWS Spot Instance advisor, making infrastructure decisions more informed and efficient. -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples and Use Cases 2 | 3 | This document provides real-world examples of using `spotinfo` for common DevOps scenarios. 4 | 5 | ## DevOps Use Cases 6 | 7 | ### 1. Production Workload Deployment 8 | 9 | **Scenario**: Deploy a production web application requiring high availability and cost optimization. 10 | 11 | **Requirements**: 12 | - At least 4 vCPU and 16GB RAM 13 | - High placement score (8+) for reliability 14 | - Compare multiple regions 15 | - Budget constraint: $0.30/hour 16 | 17 | ```bash 18 | # Find optimal instances across multiple regions 19 | spotinfo \ 20 | --cpu 4 \ 21 | --memory 16 \ 22 | --with-score \ 23 | --min-score 8 \ 24 | --price 0.30 \ 25 | --region "us-east-1" \ 26 | --region "us-west-2" \ 27 | --region "eu-west-1" \ 28 | --sort score \ 29 | --order desc \ 30 | --output table 31 | ``` 32 | 33 | **Expected Output**: 34 | ``` 35 | ┌───────────────┬──────────┬──────┬────────────┬──────────┬────────────────────────────┐ 36 | │ REGION │ INSTANCE │ VCPU │ MEMORY GIB │ USD/HOUR │ PLACEMENT SCORE (REGIONAL) │ 37 | ├───────────────┼──────────┼──────┼────────────┼──────────┼────────────────────────────┤ 38 | │ us-east-1 │ m5.xlarge│ 4 │ 16 │ 0.0817 │ 🟢 9 │ 39 | │ eu-west-1 │ m5.xlarge│ 4 │ 16 │ 0.0923 │ 🟢 8 │ 40 | └───────────────┴──────────┴──────┴────────────┴──────────┴────────────────────────────┘ 41 | ``` 42 | 43 | ### 2. Development Environment Setup 44 | 45 | **Scenario**: Cost-effective development instances for a team of developers. 46 | 47 | **Requirements**: 48 | - Small instances (t3 family) 49 | - Acceptable reliability (score 5+) 50 | - Multiple options for flexibility 51 | - Lowest cost priority 52 | 53 | ```bash 54 | # Find cheapest t3 instances with decent reliability 55 | spotinfo \ 56 | --type "t3.*" \ 57 | --with-score \ 58 | --min-score 5 \ 59 | --sort price \ 60 | --order asc \ 61 | --region "us-east-1" \ 62 | --output table 63 | ``` 64 | 65 | ### 3. Machine Learning Training Jobs 66 | 67 | **Scenario**: GPU instances for ML training workloads that can handle interruptions. 68 | 69 | **Requirements**: 70 | - GPU instances (p3, g4 families) 71 | - Cost optimization priority 72 | - AZ-level placement for precise targeting 73 | 74 | ```bash 75 | # Compare GPU instances with AZ-level scores 76 | spotinfo \ 77 | --type "(p3|g4).*" \ 78 | --with-score \ 79 | --az \ 80 | --region "us-east-1" \ 81 | --sort price \ 82 | --order asc \ 83 | --output table 84 | ``` 85 | 86 | ### 4. Batch Processing Workloads 87 | 88 | **Scenario**: Large-scale data processing requiring high memory. 89 | 90 | **Requirements**: 91 | - Memory-optimized instances (r5 family) 92 | - At least 64GB RAM 93 | - Regional analysis for capacity planning 94 | 95 | ```bash 96 | # Find high-memory instances across regions 97 | spotinfo \ 98 | --type "r5.*" \ 99 | --memory 64 \ 100 | --with-score \ 101 | --region "all" \ 102 | --sort score \ 103 | --order desc \ 104 | --output json > high_memory_options.json 105 | ``` 106 | 107 | ## Automation Examples 108 | 109 | ### 5. Infrastructure as Code Integration 110 | 111 | **Terraform Variable Generation**: 112 | 113 | ```bash 114 | #!/bin/bash 115 | # Generate Terraform variables for spot instances 116 | 117 | INSTANCE_DATA=$(spotinfo \ 118 | --type "m5\.(large|xlarge)" \ 119 | --with-score \ 120 | --min-score 7 \ 121 | --region "us-east-1" \ 122 | --output json) 123 | 124 | # Extract best instance type 125 | BEST_INSTANCE=$(echo "$INSTANCE_DATA" | jq -r '.[0].instance') 126 | 127 | # Generate Terraform variables 128 | cat > terraform.tfvars < $MAX_SPOT_PRICE" | bc -l) )); then 153 | echo "Spot price $CURRENT_PRICE exceeds budget $MAX_SPOT_PRICE" 154 | exit 1 155 | fi 156 | echo "Spot price validation passed: $CURRENT_PRICE <= $MAX_SPOT_PRICE" 157 | 158 | deploy_infrastructure: 159 | stage: deploy 160 | dependencies: 161 | - validate_spot_cost 162 | script: 163 | - terraform apply -auto-approve 164 | ``` 165 | 166 | ### 7. Cost Monitoring Script 167 | 168 | **Daily Cost Analysis**: 169 | 170 | ```bash 171 | #!/bin/bash 172 | # daily_spot_analysis.sh - Monitor spot instance costs 173 | 174 | DATE=$(date +%Y-%m-%d) 175 | REPORT_FILE="spot_report_$DATE.json" 176 | 177 | # Generate comprehensive spot analysis 178 | spotinfo \ 179 | --type "m5.*" \ 180 | --with-score \ 181 | --region "us-east-1" \ 182 | --region "us-west-2" \ 183 | --region "eu-west-1" \ 184 | --sort price \ 185 | --order asc \ 186 | --output json > "$REPORT_FILE" 187 | 188 | # Extract insights 189 | CHEAPEST=$(jq -r '.[0] | "\(.instance) in \(.region): $\(.price)/hour"' "$REPORT_FILE") 190 | HIGHEST_SCORE=$(jq -r 'sort_by(.region_score) | reverse | .[0] | "\(.instance) in \(.region): score \(.region_score)"' "$REPORT_FILE") 191 | 192 | # Send to monitoring system 193 | cat < "spot_instances_$(date +%Y%m%d).csv" 283 | 284 | # Import into Google Sheets or Excel for further analysis 285 | ``` 286 | 287 | ### 12. JSON Processing with jq 288 | 289 | ```bash 290 | # Extract specific fields for monitoring 291 | spotinfo \ 292 | --type "c5.*" \ 293 | --with-score \ 294 | --region "us-east-1" \ 295 | --output json | \ 296 | jq -r '.[] | select(.region_score >= 8) | 297 | "Instance: \(.instance), Score: \(.region_score), Price: $\(.price)/hour"' 298 | ``` 299 | 300 | ### 13. Text Format for Logging 301 | 302 | ```bash 303 | # Log format for system monitoring 304 | spotinfo \ 305 | --type "t3.medium" \ 306 | --with-score \ 307 | --region "us-east-1" \ 308 | --output text | \ 309 | logger -t "spotinfo" -p user.info 310 | ``` 311 | 312 | ## Integration Patterns 313 | 314 | ### 14. Kubernetes Cluster Autoscaler 315 | 316 | **Node group optimization**: 317 | 318 | ```bash 319 | # Find optimal instance types for Kubernetes node groups 320 | spotinfo \ 321 | --cpu 2 \ 322 | --memory 8 \ 323 | --with-score \ 324 | --min-score 7 \ 325 | --region "us-east-1" \ 326 | --sort price \ 327 | --order asc \ 328 | --output json | \ 329 | jq -r '.[0:3][] | .instance' | \ 330 | tr '\n' ',' | \ 331 | sed 's/,$//' 332 | ``` 333 | 334 | ### 15. AWS Auto Scaling Groups 335 | 336 | **Mixed instance policy configuration**: 337 | 338 | ```bash 339 | # Generate instance types for ASG mixed instance policy 340 | INSTANCE_TYPES=$(spotinfo \ 341 | --type "m5\.(large|xlarge|2xlarge)" \ 342 | --with-score \ 343 | --min-score 6 \ 344 | --region "us-east-1" \ 345 | --output json | \ 346 | jq -r '[.[].instance] | unique | join(",")') 347 | 348 | echo "InstanceTypes: $INSTANCE_TYPES" 349 | ``` 350 | 351 | ## Troubleshooting Examples 352 | 353 | ### 16. Permission Debugging 354 | 355 | ```bash 356 | # Test placement score permissions 357 | if spotinfo --type "t3.micro" --with-score --region "us-east-1" &>/dev/null; then 358 | echo "✅ Placement score permissions working" 359 | else 360 | echo "❌ Placement score permissions failed" 361 | echo "Check IAM policy for ec2:GetSpotPlacementScores" 362 | fi 363 | ``` 364 | 365 | ### 17. Performance Testing 366 | 367 | ```bash 368 | # Measure query performance 369 | time spotinfo --type "m5.*" --region "all" --output json >/dev/null 370 | 371 | # Test with placement scores 372 | time spotinfo --type "m5.*" --with-score --region "us-east-1" --output json >/dev/null 373 | ``` 374 | 375 | These examples demonstrate the flexibility and power of `spotinfo` for various DevOps scenarios. Adapt them to your specific requirements and integrate them into your infrastructure automation workflows. 376 | 377 | ## See Also 378 | 379 | - [Usage Guide](usage.md) - Complete command reference 380 | - [AWS Spot Placement Scores](aws-spot-placement-scores.md) - Detailed placement score documentation 381 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions -------------------------------------------------------------------------------- /internal/spot/score.go: -------------------------------------------------------------------------------- 1 | package spot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | awsconfig "github.com/aws/aws-sdk-go-v2/config" 13 | "github.com/aws/aws-sdk-go-v2/service/ec2" 14 | "github.com/bluele/gcache" 15 | "golang.org/x/time/rate" 16 | ) 17 | 18 | // Constants to replace magic numbers 19 | const ( 20 | // Cache configuration 21 | defaultCacheSize = 1000 22 | defaultCacheExpiration = 10 * time.Minute 23 | defaultRateLimitBurst = 10 24 | 25 | // Rate limiting configuration 26 | rateLimitInterval = 100 * time.Millisecond 27 | 28 | // AWS API configuration 29 | defaultTargetCapacity = 1 30 | defaultMaxResults = 30 31 | maxRetryAttempts = 5 32 | DefaultScoreTimeoutSeconds = 30 33 | defaultScoreTimeout = DefaultScoreTimeoutSeconds * time.Second 34 | 35 | // Mock score range for fallback 36 | minMockScore = 1 37 | maxMockScore = 10 38 | ) 39 | 40 | // awsAPIProvider provides spot placement scores with different implementations. 41 | type awsAPIProvider interface { 42 | fetchScores(ctx context.Context, region string, instanceTypes []string, singleAZ bool) (map[string]int, error) 43 | } 44 | 45 | // awsScoreProvider implements awsAPIProvider using real AWS API calls. 46 | type awsScoreProvider struct { 47 | cfg aws.Config 48 | } 49 | 50 | // mockScoreProvider implements awsAPIProvider using mock scores for fallback. 51 | type mockScoreProvider struct{} 52 | 53 | // CachedScoreData wraps scores with timestamp for freshness tracking. 54 | type CachedScoreData struct { 55 | Scores map[string]int 56 | FetchTime time.Time 57 | } 58 | 59 | // FreshnessLevel indicates how fresh the cached data is. 60 | type FreshnessLevel int 61 | 62 | const ( 63 | // Fresh data is less than 5 minutes old 64 | Fresh FreshnessLevel = iota 65 | // Recent data is between 5 and 30 minutes old 66 | Recent 67 | // Stale data is more than 30 minutes old 68 | Stale 69 | ) 70 | 71 | // GetFreshness returns the freshness level based on the fetch time. 72 | func (c *CachedScoreData) GetFreshness() FreshnessLevel { 73 | age := time.Since(c.FetchTime) 74 | switch { 75 | case age < 5*time.Minute: 76 | return Fresh 77 | case age < 30*time.Minute: 78 | return Recent 79 | default: 80 | return Stale 81 | } 82 | } 83 | 84 | // scoreCache implements the main score caching and rate limiting functionality. 85 | type scoreCache struct { 86 | cache gcache.Cache 87 | limiter *rate.Limiter 88 | provider awsAPIProvider 89 | } 90 | 91 | // newScoreCache creates a new score cache with rate limiting and AWS provider. 92 | // 93 | //nolint:contextcheck // Initialization function appropriately uses context.Background() for AWS config 94 | func newScoreCache() *scoreCache { 95 | cache := gcache.New(defaultCacheSize). 96 | LRU(). 97 | Expiration(defaultCacheExpiration). 98 | Build() 99 | 100 | limiter := rate.NewLimiter(rate.Every(rateLimitInterval), defaultRateLimitBurst) 101 | 102 | // Try to create AWS provider, fallback to mock on error 103 | provider := createAPIProvider() 104 | 105 | return &scoreCache{ 106 | cache: cache, 107 | limiter: limiter, 108 | provider: provider, 109 | } 110 | } 111 | 112 | // createAPIProvider creates an AWS API provider or falls back to mock. 113 | // 114 | //nolint:contextcheck // Initialization function appropriately uses context.Background() for AWS config 115 | func createAPIProvider() awsAPIProvider { 116 | // Try to create AWS provider 117 | if provider, err := newAWSScoreProvider(context.Background()); err == nil { 118 | return provider 119 | } 120 | // Fallback to mock provider 121 | return &mockScoreProvider{} 122 | } 123 | 124 | // newAWSScoreProvider creates a new AWS score provider with proper configuration. 125 | func newAWSScoreProvider(ctx context.Context) (*awsScoreProvider, error) { 126 | ctx, cancel := context.WithTimeout(ctx, defaultScoreTimeout) 127 | defer cancel() 128 | 129 | cfg, err := awsconfig.LoadDefaultConfig(ctx, 130 | awsconfig.WithRetryMode(aws.RetryModeAdaptive), 131 | awsconfig.WithRetryMaxAttempts(maxRetryAttempts), 132 | ) 133 | if err != nil { 134 | return nil, fmt.Errorf("failed to load AWS config: %w", err) 135 | } 136 | 137 | return &awsScoreProvider{cfg: cfg}, nil 138 | } 139 | 140 | // fetchScores implements awsAPIProvider for AWS API calls. 141 | func (p *awsScoreProvider) fetchScores(ctx context.Context, region string, instanceTypes []string, singleAZ bool) (map[string]int, error) { 142 | // Create region-specific client 143 | client := ec2.NewFromConfig(p.cfg, func(o *ec2.Options) { 144 | o.Region = region 145 | }) 146 | 147 | input := &ec2.GetSpotPlacementScoresInput{ 148 | InstanceTypes: instanceTypes, 149 | TargetCapacity: aws.Int32(defaultTargetCapacity), 150 | SingleAvailabilityZone: aws.Bool(singleAZ), 151 | MaxResults: aws.Int32(defaultMaxResults), 152 | } 153 | 154 | scores := make(map[string]int) 155 | paginator := ec2.NewGetSpotPlacementScoresPaginator(client, input) 156 | 157 | for paginator.HasMorePages() { 158 | output, err := paginator.NextPage(ctx) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to get spot placement scores for region %s: %w", region, err) 161 | } 162 | 163 | // Process each score result 164 | for _, result := range output.SpotPlacementScores { 165 | score := int(aws.ToInt32(result.Score)) 166 | 167 | // Map scores to the requested instance types 168 | // AWS may return scores for a subset of requested types 169 | for _, instanceType := range instanceTypes { 170 | // In practice, AWS returns results that correspond to the input 171 | // For simplicity, we'll assign the score to each requested type 172 | if _, exists := scores[instanceType]; !exists { 173 | scores[instanceType] = score 174 | } 175 | } 176 | } 177 | } 178 | 179 | // Fill in any missing instance types with a default score 180 | for _, instanceType := range instanceTypes { 181 | if _, exists := scores[instanceType]; !exists { 182 | // Use a moderate default score if AWS doesn't return data for this type 183 | scores[instanceType] = 5 // Middle of 1-10 range 184 | } 185 | } 186 | 187 | return scores, nil 188 | } 189 | 190 | // fetchScores implements scoreProvider for mock scores. 191 | func (p *mockScoreProvider) fetchScores(ctx context.Context, region string, instanceTypes []string, singleAZ bool) (map[string]int, error) { 192 | scores := make(map[string]int) 193 | for i, instanceType := range instanceTypes { 194 | // Generate deterministic mock scores based on instance type and position 195 | score := (len(instanceType)*7+i*3)%maxMockScore + minMockScore 196 | scores[instanceType] = score 197 | } 198 | return scores, nil 199 | } 200 | 201 | // getCacheKey creates a consistent cache key for region and instance types. 202 | func (sc *scoreCache) getCacheKey(region string, instanceTypes []string, singleAZ bool) string { 203 | sorted := make([]string, len(instanceTypes)) 204 | copy(sorted, instanceTypes) 205 | sort.Strings(sorted) 206 | 207 | azFlag := "region" 208 | if singleAZ { 209 | azFlag = "az" 210 | } 211 | 212 | return fmt.Sprintf("%s:%s:%s", region, azFlag, strings.Join(sorted, ",")) 213 | } 214 | 215 | // getSpotPlacementScores fetches spot placement scores with caching and rate limiting. 216 | func (sc *scoreCache) getSpotPlacementScores(ctx context.Context, region string, 217 | instanceTypes []string, singleAZ bool) (map[string]int, error) { 218 | 219 | cacheKey := sc.getCacheKey(region, instanceTypes, singleAZ) 220 | 221 | // Check cache first 222 | if cached, err := sc.cache.Get(cacheKey); err == nil { 223 | if cachedData, ok := cached.(*CachedScoreData); ok { 224 | return cachedData.Scores, nil 225 | } 226 | } 227 | 228 | // Apply rate limiting 229 | if err := sc.limiter.Wait(ctx); err != nil { 230 | return nil, fmt.Errorf("rate limit wait failed: %w", err) 231 | } 232 | 233 | // Fetch from provider (AWS or mock) 234 | scores, err := sc.provider.fetchScores(ctx, region, instanceTypes, singleAZ) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | // Cache the result with timestamp (ignore error as it's not critical) 240 | cachedData := &CachedScoreData{ 241 | Scores: scores, 242 | FetchTime: time.Now(), 243 | } 244 | _ = sc.cache.Set(cacheKey, cachedData) 245 | 246 | return scores, nil 247 | } 248 | 249 | // enrichWithScores enriches advice slice with spot placement scores. 250 | func (sc *scoreCache) enrichWithScores(ctx context.Context, advices []Advice, 251 | singleAZ bool, timeout time.Duration) error { 252 | 253 | enrichCtx, cancel := context.WithTimeout(ctx, timeout) 254 | defer cancel() 255 | 256 | // Group advices by region for batch processing 257 | regionGroups := make(map[string][]*Advice) 258 | for i := range advices { 259 | region := advices[i].Region 260 | regionGroups[region] = append(regionGroups[region], &advices[i]) 261 | } 262 | 263 | // Process each region in parallel 264 | var wg sync.WaitGroup 265 | var mu sync.Mutex 266 | var errors []string 267 | 268 | for region, regionAdvices := range regionGroups { 269 | wg.Add(1) 270 | go func(r string, advs []*Advice) { 271 | defer wg.Done() 272 | 273 | // Collect unique instance types for this region 274 | instanceTypeSet := make(map[string]bool) 275 | typeToAdvices := make(map[string][]*Advice) 276 | 277 | for _, adv := range advs { 278 | instanceType := adv.InstanceType 279 | if instanceType == "" { 280 | instanceType = adv.Instance 281 | } 282 | 283 | if !instanceTypeSet[instanceType] { 284 | instanceTypeSet[instanceType] = true 285 | } 286 | typeToAdvices[instanceType] = append(typeToAdvices[instanceType], adv) 287 | } 288 | 289 | // Convert set to slice 290 | var instanceTypes []string 291 | for instanceType := range instanceTypeSet { 292 | instanceTypes = append(instanceTypes, instanceType) 293 | } 294 | 295 | // Fetch scores for this region 296 | scores, err := sc.getSpotPlacementScores(enrichCtx, r, instanceTypes, singleAZ) 297 | fetchTime := time.Now() // Capture fetch time for all advices in this region 298 | 299 | mu.Lock() 300 | defer mu.Unlock() 301 | 302 | if err != nil { 303 | errors = append(errors, fmt.Sprintf("region %s: %v", r, err)) 304 | return 305 | } 306 | 307 | // Apply scores to advices 308 | if singleAZ { 309 | // For AZ-level scores, store in ZoneScores map 310 | for instanceType, score := range scores { 311 | for _, adv := range typeToAdvices[instanceType] { 312 | if adv.ZoneScores == nil { 313 | adv.ZoneScores = make(map[string]int) 314 | } 315 | azID := fmt.Sprintf("%sa", r) // Mock AZ: us-east-1a, etc. 316 | adv.ZoneScores[azID] = score 317 | adv.ScoreFetchedAt = &fetchTime 318 | } 319 | } 320 | } else { 321 | // For region-level scores, store in RegionScore field 322 | for instanceType, score := range scores { 323 | for _, adv := range typeToAdvices[instanceType] { 324 | scoreVal := score 325 | adv.RegionScore = &scoreVal 326 | adv.ScoreFetchedAt = &fetchTime 327 | } 328 | } 329 | } 330 | 331 | }(region, regionAdvices) 332 | } 333 | 334 | wg.Wait() 335 | 336 | if len(errors) > 0 { 337 | return fmt.Errorf("score enrichment failed: %s", strings.Join(errors, "; ")) 338 | } 339 | 340 | return nil 341 | } 342 | -------------------------------------------------------------------------------- /internal/spot/data_test.go: -------------------------------------------------------------------------------- 1 | package spot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const ( 14 | testRegionUSEast1 = "us-east-1" 15 | testInstanceT2Micro = "t2.micro" 16 | ) 17 | 18 | func TestFetchAdvisorData_FallbackToEmbedded(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | ctx context.Context 22 | description string 23 | }{ 24 | { 25 | name: "timeout forces fallback", 26 | ctx: func() context.Context { 27 | ctx, _ := context.WithTimeout(context.Background(), 1*time.Millisecond) 28 | return ctx 29 | }(), 30 | description: "very short timeout should force fallback to embedded data", 31 | }, 32 | { 33 | name: "cancelled context forces fallback", 34 | ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()); cancel(); return ctx }(), 35 | description: "cancelled context should force fallback to embedded data", 36 | }, 37 | } 38 | 39 | for _, tt := range tests { 40 | t.Run(tt.name, func(t *testing.T) { 41 | data, err := fetchAdvisorData(tt.ctx) 42 | 43 | // Should successfully get data from embedded fallback 44 | require.NoError(t, err) 45 | assert.NotNil(t, data) 46 | assert.NotNil(t, data.Regions) 47 | assert.NotEmpty(t, data.Regions) 48 | 49 | // Verify we have expected regions in embedded data 50 | assert.Contains(t, data.Regions, testRegionUSEast1) 51 | 52 | // Verify data structure integrity 53 | usEast1 := data.Regions[testRegionUSEast1] 54 | assert.NotNil(t, usEast1.Linux) 55 | assert.NotEmpty(t, usEast1.Linux) 56 | }) 57 | } 58 | } 59 | 60 | func TestFetchPricingData_FallbackToEmbedded(t *testing.T) { 61 | tests := []struct { 62 | name string 63 | useEmbedded bool 64 | ctx context.Context 65 | description string 66 | }{ 67 | { 68 | name: "explicit embedded mode", 69 | useEmbedded: true, 70 | ctx: context.Background(), 71 | description: "useEmbedded=true should load embedded data directly", 72 | }, 73 | { 74 | name: "timeout forces fallback", 75 | useEmbedded: false, 76 | ctx: func() context.Context { 77 | ctx, _ := context.WithTimeout(context.Background(), 1*time.Millisecond) 78 | return ctx 79 | }(), 80 | description: "timeout should force fallback to embedded data", 81 | }, 82 | { 83 | name: "cancelled context forces fallback", 84 | useEmbedded: false, 85 | ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()); cancel(); return ctx }(), 86 | description: "cancelled context should force fallback to embedded data", 87 | }, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | data, err := fetchPricingData(tt.ctx, tt.useEmbedded) 93 | 94 | // Should successfully get data from embedded fallback 95 | require.NoError(t, err) 96 | assert.NotNil(t, data) 97 | assert.NotNil(t, data.Config) 98 | assert.NotEmpty(t, data.Config.Regions) 99 | 100 | // Verify we have expected regions in embedded data 101 | regionFound := false 102 | for _, region := range data.Config.Regions { 103 | if region.Region == testRegionUSEast1 { 104 | regionFound = true 105 | assert.NotEmpty(t, region.InstanceTypes) 106 | break 107 | } 108 | } 109 | assert.True(t, regionFound, "us-east-1 region should be found in embedded pricing data") 110 | }) 111 | } 112 | } 113 | 114 | func TestLoadEmbeddedAdvisorData(t *testing.T) { 115 | data, err := loadEmbeddedAdvisorData() 116 | 117 | require.NoError(t, err) 118 | assert.NotNil(t, data) 119 | assert.NotEmpty(t, data.Regions) 120 | 121 | // Test that we can access specific data 122 | usEast1, exists := data.Regions[testRegionUSEast1] 123 | assert.True(t, exists, "us-east-1 should exist in embedded data") 124 | assert.NotNil(t, usEast1.Linux) 125 | assert.NotEmpty(t, usEast1.Linux) 126 | 127 | // Test that embedded data has expected instance types 128 | found := false 129 | for instanceType := range usEast1.Linux { 130 | if instanceType == testInstanceT2Micro { 131 | found = true 132 | break 133 | } 134 | } 135 | assert.True(t, found, "t2.micro should be found in embedded advisor data") 136 | } 137 | 138 | func TestLoadEmbeddedPricingData(t *testing.T) { 139 | data, err := loadEmbeddedPricingData() 140 | 141 | require.NoError(t, err) 142 | assert.NotNil(t, data) 143 | assert.NotNil(t, data.Config) 144 | assert.NotEmpty(t, data.Config.Regions) 145 | 146 | // Test that we can access specific pricing data 147 | regionFound := false 148 | instanceFound := false 149 | 150 | for _, region := range data.Config.Regions { 151 | if region.Region == testRegionUSEast1 { 152 | regionFound = true 153 | for _, instanceType := range region.InstanceTypes { 154 | for _, size := range instanceType.Sizes { 155 | if size.Size == testInstanceT2Micro { 156 | instanceFound = true 157 | assert.NotEmpty(t, size.ValueColumns) 158 | break 159 | } 160 | } 161 | if instanceFound { 162 | break 163 | } 164 | } 165 | break 166 | } 167 | } 168 | 169 | assert.True(t, regionFound, "us-east-1 should be found in embedded pricing data") 170 | assert.True(t, instanceFound, "t2.micro should be found in embedded pricing data") 171 | } 172 | 173 | func TestFetchAdvisorData_WithValidContext(t *testing.T) { 174 | // Test with a reasonable timeout that might succeed if network is available 175 | // but will fallback gracefully if not 176 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 177 | defer cancel() 178 | 179 | data, err := fetchAdvisorData(ctx) 180 | 181 | // Should always succeed (either from network or fallback) 182 | require.NoError(t, err) 183 | assert.NotNil(t, data) 184 | assert.NotEmpty(t, data.Regions) 185 | } 186 | 187 | func TestFetchPricingData_WithValidContext(t *testing.T) { 188 | // Test with a reasonable timeout that might succeed if network is available 189 | // but will fallback gracefully if not 190 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 191 | defer cancel() 192 | 193 | data, err := fetchPricingData(ctx, false) 194 | 195 | // Should always succeed (either from network or fallback) 196 | require.NoError(t, err) 197 | assert.NotNil(t, data) 198 | assert.NotNil(t, data.Config) 199 | assert.NotEmpty(t, data.Config.Regions) 200 | } 201 | 202 | func TestDefaultAdvisorProvider_Integration(t *testing.T) { 203 | // Test the default advisor provider methods with real embedded data 204 | provider := newDefaultAdvisorProvider(100 * time.Millisecond) 205 | 206 | t.Run("getRegions", func(t *testing.T) { 207 | regions := provider.getRegions() 208 | 209 | assert.NotEmpty(t, regions) 210 | assert.Contains(t, regions, testRegionUSEast1) 211 | assert.Contains(t, regions, "us-west-2") 212 | }) 213 | 214 | t.Run("getRegionAdvice", func(t *testing.T) { 215 | advice, err := provider.getRegionAdvice(testRegionUSEast1, "linux") 216 | 217 | require.NoError(t, err) 218 | assert.NotEmpty(t, advice) 219 | 220 | // Should have some common instance types 221 | assert.Contains(t, advice, testInstanceT2Micro) 222 | 223 | // Verify advice structure 224 | t2micro := advice[testInstanceT2Micro] 225 | assert.GreaterOrEqual(t, t2micro.Range, 0) 226 | assert.GreaterOrEqual(t, t2micro.Savings, 0) 227 | }) 228 | 229 | t.Run("getRegionAdvice_InvalidOS", func(t *testing.T) { 230 | _, err := provider.getRegionAdvice(testRegionUSEast1, "invalid-os") 231 | 232 | assert.Error(t, err) 233 | assert.Contains(t, err.Error(), "invalid instance OS") 234 | }) 235 | 236 | t.Run("getRegionAdvice_InvalidRegion", func(t *testing.T) { 237 | _, err := provider.getRegionAdvice("invalid-region", "linux") 238 | 239 | assert.Error(t, err) 240 | assert.Contains(t, err.Error(), "region not found") 241 | }) 242 | 243 | t.Run("getInstanceType", func(t *testing.T) { 244 | info, err := provider.getInstanceType(testInstanceT2Micro) 245 | 246 | require.NoError(t, err) 247 | assert.Greater(t, info.Cores, 0) 248 | assert.Greater(t, info.RAM, float32(0)) 249 | }) 250 | 251 | t.Run("getInstanceType_NotFound", func(t *testing.T) { 252 | _, err := provider.getInstanceType("invalid.instance") 253 | 254 | assert.Error(t, err) 255 | assert.Contains(t, err.Error(), "instance type not found") 256 | }) 257 | 258 | t.Run("getRange", func(t *testing.T) { 259 | // Test different range indices 260 | tests := []struct { 261 | index int 262 | hasError bool 263 | }{ 264 | {0, false}, // Should be valid 265 | {1, false}, // Should be valid 266 | {2, false}, // Should be valid 267 | {-1, true}, // Should be invalid 268 | {100, true}, // Should be invalid 269 | } 270 | 271 | for _, tt := range tests { 272 | t.Run(fmt.Sprintf("index_%d", tt.index), func(t *testing.T) { 273 | rangeInfo, err := provider.getRange(tt.index) 274 | 275 | if tt.hasError { 276 | assert.Error(t, err) 277 | } else { 278 | require.NoError(t, err) 279 | assert.NotEmpty(t, rangeInfo.Label) 280 | assert.GreaterOrEqual(t, rangeInfo.Min, 0) 281 | assert.GreaterOrEqual(t, rangeInfo.Max, 0) 282 | assert.GreaterOrEqual(t, rangeInfo.Max, rangeInfo.Min) 283 | } 284 | }) 285 | } 286 | }) 287 | } 288 | 289 | func TestDefaultPricingProvider_Integration(t *testing.T) { 290 | // Test the default pricing provider methods with real embedded data 291 | provider := newDefaultPricingProvider(100*time.Millisecond, true) // Force embedded mode 292 | 293 | t.Run("getSpotPrice", func(t *testing.T) { 294 | price, err := provider.getSpotPrice(testInstanceT2Micro, testRegionUSEast1, "linux") 295 | 296 | require.NoError(t, err) 297 | assert.Greater(t, price, 0.0) 298 | assert.Less(t, price, 1.0) // Sanity check - t2.micro should be less than $1/hour 299 | }) 300 | 301 | t.Run("getSpotPrice_NotFound", func(t *testing.T) { 302 | _, err := provider.getSpotPrice("invalid.instance", testRegionUSEast1, "linux") 303 | 304 | assert.Error(t, err) 305 | assert.Contains(t, err.Error(), "no pricing data for instance") 306 | }) 307 | 308 | t.Run("getSpotPrice_InvalidRegion", func(t *testing.T) { 309 | _, err := provider.getSpotPrice(testInstanceT2Micro, "invalid-region", "linux") 310 | 311 | assert.Error(t, err) 312 | assert.Contains(t, err.Error(), "no pricing data for region") 313 | }) 314 | 315 | t.Run("getSpotPrice_WindowsOS", func(t *testing.T) { 316 | price, err := provider.getSpotPrice(testInstanceT2Micro, testRegionUSEast1, "windows") 317 | 318 | // Should succeed and return Windows pricing 319 | require.NoError(t, err) 320 | assert.GreaterOrEqual(t, price, 0.0) // Windows pricing might be 0 or higher 321 | }) 322 | 323 | t.Run("getSpotPrice_InvalidOS_DefaultsToLinux", func(t *testing.T) { 324 | price, err := provider.getSpotPrice(testInstanceT2Micro, testRegionUSEast1, "invalid-os") 325 | 326 | // Should succeed and default to Linux pricing 327 | require.NoError(t, err) 328 | assert.Greater(t, price, 0.0) 329 | 330 | // Should be same as Linux price 331 | linuxPrice, err := provider.getSpotPrice(testInstanceT2Micro, testRegionUSEast1, "linux") 332 | require.NoError(t, err) 333 | assert.Equal(t, linuxPrice, price) 334 | }) 335 | } 336 | 337 | func TestDefaultPricingProvider_NetworkFallback(t *testing.T) { 338 | // Test pricing provider that tries network first but falls back to embedded 339 | provider := newDefaultPricingProvider(1*time.Millisecond, false) // Very short timeout 340 | 341 | price, err := provider.getSpotPrice(testInstanceT2Micro, testRegionUSEast1, "linux") 342 | 343 | // Should still succeed due to fallback 344 | require.NoError(t, err) 345 | assert.Greater(t, price, 0.0) 346 | } 347 | -------------------------------------------------------------------------------- /internal/mcp/transport_sse_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | 17 | "spotinfo/internal/spot" 18 | ) 19 | 20 | // TestSSETransportBasic tests basic SSE transport functionality 21 | func TestSSETransportBasic(t *testing.T) { 22 | cfg := Config{ 23 | Version: "1.0.0", 24 | Logger: slog.Default(), 25 | SpotClient: spot.New(), 26 | } 27 | 28 | server, err := NewServer(cfg) 29 | require.NoError(t, err) 30 | assert.NotNil(t, server) 31 | 32 | // Create context with timeout for the test 33 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 34 | defer cancel() 35 | 36 | // Test SSE server startup and shutdown 37 | errChan := make(chan error, 1) 38 | go func() { 39 | // Use a random port for testing 40 | errChan <- server.ServeSSE(ctx, "0") // Port 0 lets OS choose available port 41 | }() 42 | 43 | // Wait for either timeout or server error 44 | select { 45 | case err := <-errChan: 46 | // If we get context.Canceled, that's expected (timeout) 47 | if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { 48 | t.Errorf("unexpected error: %v", err) 49 | } 50 | case <-time.After(3 * time.Second): 51 | t.Fatal("SSE server did not start or respond within timeout") 52 | } 53 | } 54 | 55 | // TestSSETransportContextCancellation tests graceful shutdown 56 | func TestSSETransportContextCancellation(t *testing.T) { 57 | cfg := Config{ 58 | Version: "1.0.0", 59 | Logger: slog.Default(), 60 | SpotClient: spot.New(), 61 | } 62 | 63 | server, err := NewServer(cfg) 64 | require.NoError(t, err) 65 | 66 | // Create context that we'll cancel 67 | ctx, cancel := context.WithCancel(context.Background()) 68 | 69 | // Start server in goroutine 70 | errChan := make(chan error, 1) 71 | go func() { 72 | errChan <- server.ServeSSE(ctx, "0") 73 | }() 74 | 75 | // Give server time to start 76 | time.Sleep(100 * time.Millisecond) 77 | 78 | // Cancel context to trigger shutdown 79 | cancel() 80 | 81 | // Wait for server to shut down 82 | select { 83 | case err := <-errChan: 84 | // Should get context canceled error 85 | assert.True(t, errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "context canceled")) 86 | case <-time.After(2 * time.Second): 87 | t.Fatal("server did not shut down within timeout") 88 | } 89 | } 90 | 91 | // TestSSETransportPortBinding tests port binding behavior 92 | func TestSSETransportPortBinding(t *testing.T) { 93 | tests := []struct { 94 | name string 95 | port string 96 | expectError bool 97 | }{ 98 | { 99 | name: "valid port", 100 | port: "0", // Let OS choose 101 | expectError: false, 102 | }, 103 | { 104 | name: "invalid port", 105 | port: "invalid", 106 | expectError: true, 107 | }, 108 | { 109 | name: "port too high", 110 | port: "99999", 111 | expectError: true, 112 | }, 113 | } 114 | 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | cfg := Config{ 118 | Version: "1.0.0", 119 | Logger: slog.Default(), 120 | SpotClient: spot.New(), 121 | } 122 | 123 | server, err := NewServer(cfg) 124 | require.NoError(t, err) 125 | 126 | // Create context with short timeout 127 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 128 | defer cancel() 129 | 130 | err = server.ServeSSE(ctx, tt.port) 131 | 132 | if tt.expectError { 133 | assert.Error(t, err) 134 | } else { 135 | // For valid ports, we expect timeout/cancellation, not startup errors 136 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) 137 | } 138 | }) 139 | } 140 | } 141 | 142 | // TestSSETransportWithMockClient tests SSE with mock spot client 143 | func TestSSETransportWithMockClient(t *testing.T) { 144 | mockClient := newMockspotClient(t) 145 | 146 | cfg := Config{ 147 | Version: "1.0.0", 148 | Logger: slog.Default(), 149 | SpotClient: mockClient, 150 | } 151 | 152 | server, err := NewServer(cfg) 153 | require.NoError(t, err) 154 | 155 | // Create context with timeout 156 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 157 | defer cancel() 158 | 159 | // Test that server can start with mock client 160 | errChan := make(chan error, 1) 161 | go func() { 162 | errChan <- server.ServeSSE(ctx, "0") 163 | }() 164 | 165 | select { 166 | case err := <-errChan: 167 | // Should get timeout/cancellation, not startup error 168 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) 169 | case <-time.After(2 * time.Second): 170 | t.Fatal("test timed out") 171 | } 172 | } 173 | 174 | // TestSSEServerCreation tests that SSE server can be created properly 175 | func TestSSEServerCreation(t *testing.T) { 176 | cfg := Config{ 177 | Version: "1.0.0", 178 | Logger: slog.Default(), 179 | SpotClient: spot.New(), 180 | } 181 | 182 | server, err := NewServer(cfg) 183 | require.NoError(t, err) 184 | assert.NotNil(t, server) 185 | assert.NotNil(t, server.mcpServer) 186 | 187 | // Verify we can call SSE method without panicking 188 | ctx, cancel := context.WithCancel(context.Background()) 189 | cancel() // Cancel immediately 190 | 191 | err = server.ServeSSE(ctx, "8080") 192 | assert.Error(t, err) // Should get context canceled error 193 | assert.Contains(t, err.Error(), "context canceled") 194 | } 195 | 196 | // TestSSEConcurrentAccess tests concurrent access to SSE server 197 | func TestSSEConcurrentAccess(t *testing.T) { 198 | cfg := Config{ 199 | Version: "1.0.0", 200 | Logger: slog.Default(), 201 | SpotClient: spot.New(), 202 | } 203 | 204 | server, err := NewServer(cfg) 205 | require.NoError(t, err) 206 | 207 | const numGoroutines = 5 208 | errChan := make(chan error, numGoroutines) 209 | 210 | // Start multiple SSE servers concurrently (they should all fail with port binding) 211 | for i := 0; i < numGoroutines; i++ { 212 | go func() { 213 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 214 | defer cancel() 215 | 216 | // Use same port to force binding conflicts (except first one) 217 | port := "18080" // Use a specific port to create conflicts 218 | err := server.ServeSSE(ctx, port) 219 | errChan <- err 220 | }() 221 | } 222 | 223 | // Collect results 224 | var errors []error 225 | for i := 0; i < numGoroutines; i++ { 226 | err := <-errChan 227 | if err != nil { 228 | errors = append(errors, err) 229 | } 230 | } 231 | 232 | // Should have errors (either timeout or port binding issues) 233 | assert.NotEmpty(t, errors, "should have some errors from concurrent access") 234 | } 235 | 236 | // TestSSEWithDifferentConfigurations tests different server configurations 237 | func TestSSEWithDifferentConfigurations(t *testing.T) { 238 | tests := []struct { 239 | name string 240 | version string 241 | logger *slog.Logger 242 | }{ 243 | { 244 | name: "with version", 245 | version: "2.0.0", 246 | logger: slog.Default(), 247 | }, 248 | { 249 | name: "empty version", 250 | version: "", 251 | logger: slog.Default(), 252 | }, 253 | { 254 | name: "nil logger", 255 | version: "1.0.0", 256 | logger: nil, // Should use default 257 | }, 258 | } 259 | 260 | for _, tt := range tests { 261 | t.Run(tt.name, func(t *testing.T) { 262 | cfg := Config{ 263 | Version: tt.version, 264 | Logger: tt.logger, 265 | SpotClient: spot.New(), 266 | } 267 | 268 | server, err := NewServer(cfg) 269 | require.NoError(t, err) 270 | assert.NotNil(t, server) 271 | 272 | // Test that SSE can start with this configuration 273 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 274 | defer cancel() 275 | 276 | err = server.ServeSSE(ctx, "0") 277 | // Should get timeout, not a configuration error 278 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) 279 | }) 280 | } 281 | } 282 | 283 | // Mock HTTP test to simulate SSE endpoint behavior 284 | func TestSSEEndpointSimulation(t *testing.T) { 285 | // This test simulates what the SSE endpoint would do 286 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 287 | // Set SSE headers 288 | w.Header().Set("Content-Type", "text/event-stream") 289 | w.Header().Set("Cache-Control", "no-cache") 290 | w.Header().Set("Connection", "keep-alive") 291 | 292 | // Write a test event 293 | fmt.Fprintf(w, "data: {\"type\":\"test\",\"message\":\"hello\"}\n\n") 294 | 295 | // Flush if possible 296 | if flusher, ok := w.(http.Flusher); ok { 297 | flusher.Flush() 298 | } 299 | }) 300 | 301 | // Create test server 302 | server := httptest.NewServer(handler) 303 | defer server.Close() 304 | 305 | // Make request to SSE endpoint 306 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) 307 | require.NoError(t, err) 308 | 309 | client := &http.Client{} 310 | resp, err := client.Do(req) 311 | require.NoError(t, err) 312 | defer resp.Body.Close() 313 | 314 | // Verify SSE headers 315 | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) 316 | assert.Equal(t, "no-cache", resp.Header.Get("Cache-Control")) 317 | assert.Equal(t, "keep-alive", resp.Header.Get("Connection")) 318 | 319 | // This test demonstrates the expected behavior of SSE endpoints 320 | // The actual mcp-go library handles this internally 321 | } 322 | 323 | // BenchmarkSSEServerCreation benchmarks server creation performance 324 | func BenchmarkSSEServerCreation(b *testing.B) { 325 | cfg := Config{ 326 | Version: "1.0.0", 327 | Logger: slog.Default(), 328 | SpotClient: spot.New(), 329 | } 330 | 331 | b.ResetTimer() 332 | for i := 0; i < b.N; i++ { 333 | server, err := NewServer(cfg) 334 | if err != nil { 335 | b.Fatal(err) 336 | } 337 | _ = server // Use the server to avoid optimization 338 | } 339 | } 340 | 341 | // TestSSETransportErrorHandling tests error scenarios 342 | func TestSSETransportErrorHandling(t *testing.T) { 343 | tests := []struct { 344 | name string 345 | setupServer func() (*Server, error) 346 | port string 347 | expectedError string 348 | }{ 349 | { 350 | name: "nil spot client", 351 | setupServer: func() (*Server, error) { 352 | cfg := Config{ 353 | Version: "1.0.0", 354 | Logger: slog.Default(), 355 | SpotClient: nil, // nil client 356 | } 357 | return NewServer(cfg) 358 | }, 359 | port: "0", 360 | expectedError: "", // Should not error on creation, only when client is used 361 | }, 362 | { 363 | name: "normal configuration", 364 | setupServer: func() (*Server, error) { 365 | cfg := Config{ 366 | Version: "1.0.0", 367 | Logger: slog.Default(), 368 | SpotClient: spot.New(), 369 | } 370 | return NewServer(cfg) 371 | }, 372 | port: "0", 373 | expectedError: "", // Should work fine 374 | }, 375 | } 376 | 377 | for _, tt := range tests { 378 | t.Run(tt.name, func(t *testing.T) { 379 | server, err := tt.setupServer() 380 | require.NoError(t, err) // Server creation should always succeed 381 | 382 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 383 | defer cancel() 384 | 385 | err = server.ServeSSE(ctx, tt.port) 386 | 387 | if tt.expectedError != "" { 388 | assert.Error(t, err) 389 | assert.Contains(t, err.Error(), tt.expectedError) 390 | } else { 391 | // Should get timeout/cancellation, not an actual error 392 | assert.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) 393 | } 394 | }) 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /internal/mcp/tools.go: -------------------------------------------------------------------------------- 1 | // Package mcp provides MCP tools for spotinfo functionality. 2 | package mcp 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "log/slog" 9 | "time" 10 | 11 | "github.com/mark3labs/mcp-go/mcp" 12 | "github.com/spf13/cast" 13 | 14 | "spotinfo/internal/spot" 15 | ) 16 | 17 | // Constants for configuration values 18 | const ( 19 | defaultLimit = 10 20 | maxLimit = 50 21 | maxInterruption = 100 22 | avgDivisor = 2 23 | maxReliability = 100 24 | ) 25 | 26 | // FindSpotInstancesTool implements the find_spot_instances MCP tool 27 | type FindSpotInstancesTool struct { 28 | client spotClient 29 | logger *slog.Logger 30 | } 31 | 32 | // NewFindSpotInstancesTool creates a new find_spot_instances tool handler 33 | func NewFindSpotInstancesTool(client spotClient, logger *slog.Logger) *FindSpotInstancesTool { 34 | return &FindSpotInstancesTool{ 35 | client: client, 36 | logger: logger, 37 | } 38 | } 39 | 40 | // Handle implements the find_spot_instances tool 41 | func (t *FindSpotInstancesTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { 42 | startTime := time.Now() 43 | t.logger.Debug("handling find_spot_instances request", slog.Any("arguments", req.Params.Arguments)) 44 | 45 | params := parseParameters(req.Params.Arguments) 46 | spotSortBy, sortDesc := convertSortParams(params.sortBy) 47 | 48 | // Build options from parameters 49 | opts := []spot.GetSpotSavingsOption{ 50 | spot.WithRegions(params.regions), 51 | spot.WithPattern(params.instanceTypes), 52 | spot.WithOS("linux"), 53 | spot.WithCPU(params.minVCPU), 54 | spot.WithMemory(params.minMemoryGB), 55 | spot.WithMaxPrice(params.maxPrice), 56 | spot.WithSort(spotSortBy, sortDesc), 57 | } 58 | 59 | // Add score-related options if requested 60 | if params.withScore { 61 | scoreOpts := []spot.GetSpotSavingsOption{ 62 | spot.WithScores(true), 63 | spot.WithSingleAvailabilityZone(params.az), 64 | } 65 | if params.scoreTimeout > 0 { 66 | scoreOpts = append(scoreOpts, spot.WithScoreTimeout(time.Duration(params.scoreTimeout)*time.Second)) 67 | } 68 | opts = append(opts, scoreOpts...) 69 | } 70 | if params.minScore > 0 { 71 | opts = append(opts, spot.WithMinScore(params.minScore)) 72 | } 73 | 74 | advices, err := t.client.GetSpotSavings(ctx, opts...) 75 | if err != nil { 76 | t.logger.Error("failed to get spot savings", slog.Any("error", err)) 77 | return createErrorResult(fmt.Sprintf("Failed to get spot recommendations: %v", err)), nil 78 | } 79 | 80 | filteredAdvices := filterByInterruption(advices, params.maxInterruption) 81 | limitedAdvices := applyLimit(filteredAdvices, params.limit) 82 | response := buildResponse(limitedAdvices, startTime) 83 | 84 | results, ok := response["results"].([]map[string]interface{}) 85 | if !ok { 86 | results = []map[string]interface{}{} 87 | } 88 | t.logger.Debug("find_spot_instances completed", 89 | slog.Int("results", len(results)), 90 | slog.Int64("query_time_ms", time.Since(startTime).Milliseconds())) 91 | 92 | return marshalResponse(response) 93 | } 94 | 95 | // params holds parsed parameters for easier handling 96 | type params struct { //nolint:govet 97 | regions []string 98 | instanceTypes string 99 | sortBy string 100 | maxPrice float64 101 | maxInterruption float64 102 | minVCPU int 103 | minMemoryGB int 104 | limit int 105 | withScore bool 106 | minScore int 107 | az bool 108 | scoreTimeout int 109 | } 110 | 111 | // parseParameters extracts all parameters from the request arguments 112 | func parseParameters(arguments interface{}) *params { 113 | args, ok := arguments.(map[string]interface{}) 114 | if !ok { 115 | args = make(map[string]interface{}) 116 | } 117 | 118 | regions := getStringSliceWithDefault(args, "regions", []string{"all"}) 119 | if len(regions) == 1 && regions[0] == "all" { 120 | regions = []string{"all"} 121 | } 122 | 123 | return ¶ms{ 124 | regions: regions, 125 | instanceTypes: cast.ToString(args["instance_types"]), 126 | minVCPU: cast.ToInt(args["min_vcpu"]), 127 | minMemoryGB: cast.ToInt(args["min_memory_gb"]), 128 | maxPrice: cast.ToFloat64(args["max_price_per_hour"]), 129 | maxInterruption: cast.ToFloat64(args["max_interruption_rate"]), 130 | sortBy: getStringWithDefault(args, "sort_by", "reliability"), 131 | limit: getLimitWithDefault(args, "limit", defaultLimit), 132 | withScore: cast.ToBool(args["with_score"]), 133 | minScore: cast.ToInt(args["min_score"]), 134 | az: cast.ToBool(args["az"]), 135 | scoreTimeout: cast.ToInt(args["score_timeout"]), 136 | } 137 | } 138 | 139 | // convertSortParams converts string sort parameter to internal types 140 | func convertSortParams(sortBy string) (spot.SortBy, bool) { 141 | switch sortBy { 142 | case "price": 143 | return spot.SortByPrice, false 144 | case "reliability": 145 | return spot.SortByRange, false 146 | case "savings": 147 | return spot.SortBySavings, true 148 | case "score": 149 | return spot.SortByScore, false 150 | default: 151 | return spot.SortByRange, false 152 | } 153 | } 154 | 155 | // filterByInterruption filters advices by maximum interruption rate 156 | func filterByInterruption(advices []spot.Advice, maxInterruptionParam float64) []spot.Advice { 157 | if maxInterruptionParam <= 0 || maxInterruptionParam >= maxInterruption { 158 | return advices 159 | } 160 | 161 | filtered := make([]spot.Advice, 0, len(advices)) 162 | for _, advice := range advices { 163 | if calculateAvgInterruption(advice.Range) <= maxInterruptionParam { 164 | filtered = append(filtered, advice) 165 | } 166 | } 167 | return filtered 168 | } 169 | 170 | // applyLimit limits the number of results 171 | func applyLimit(advices []spot.Advice, limit int) []spot.Advice { 172 | if len(advices) <= limit { 173 | return advices 174 | } 175 | return advices[:limit] 176 | } 177 | 178 | // buildResponse creates the response map from filtered advices 179 | func buildResponse(advices []spot.Advice, startTime time.Time) map[string]interface{} { 180 | results := make([]map[string]interface{}, len(advices)) 181 | regionsSearched := make(map[string]bool) 182 | 183 | for i, advice := range advices { 184 | regionsSearched[advice.Region] = true 185 | avgInterruption := calculateAvgInterruption(advice.Range) 186 | 187 | result := map[string]interface{}{ 188 | "instance_type": advice.Instance, 189 | "region": advice.Region, 190 | "spot_price_per_hour": advice.Price, 191 | "spot_price": fmt.Sprintf("$%.4f/hour", advice.Price), 192 | "savings_percentage": advice.Savings, 193 | "savings": fmt.Sprintf("%d%% cheaper than on-demand", advice.Savings), 194 | "interruption_rate": avgInterruption, 195 | "interruption_frequency": advice.Range.Label, 196 | "interruption_range": fmt.Sprintf("%d-%d%%", advice.Range.Min, advice.Range.Max), 197 | "vcpu": advice.Info.Cores, 198 | "memory_gb": advice.Info.RAM, 199 | "specs": fmt.Sprintf("%d vCPU, %.0f GB RAM", advice.Info.Cores, advice.Info.RAM), 200 | "reliability_score": calculateReliabilityScore(avgInterruption), 201 | } 202 | 203 | // Add score-related fields when available 204 | if advice.RegionScore != nil { 205 | result["region_score"] = *advice.RegionScore 206 | } 207 | if len(advice.ZoneScores) > 0 { 208 | result["zone_scores"] = advice.ZoneScores 209 | } 210 | if advice.ScoreFetchedAt != nil { 211 | result["score_fetched_at"] = advice.ScoreFetchedAt.Format(time.RFC3339) 212 | } 213 | 214 | results[i] = result 215 | } 216 | 217 | searchedRegions := make([]string, 0, len(regionsSearched)) 218 | for region := range regionsSearched { 219 | searchedRegions = append(searchedRegions, region) 220 | } 221 | 222 | return map[string]interface{}{ 223 | "results": results, 224 | "metadata": map[string]interface{}{ 225 | "total_results": len(results), 226 | "regions_searched": searchedRegions, 227 | "query_time_ms": time.Since(startTime).Milliseconds(), 228 | "data_source": "embedded", 229 | "data_freshness": "current", 230 | }, 231 | } 232 | } 233 | 234 | // calculateAvgInterruption calculates average interruption rate 235 | func calculateAvgInterruption(r spot.Range) float64 { 236 | return float64(r.Min+r.Max) / avgDivisor 237 | } 238 | 239 | // calculateReliabilityScore creates a reliability score based on interruption frequency 240 | func calculateReliabilityScore(avgInterruption float64) int { 241 | reliabilityScore := maxReliability - avgInterruption 242 | if reliabilityScore < 0 { 243 | reliabilityScore = 0 244 | } 245 | return int(reliabilityScore) 246 | } 247 | 248 | // marshalResponse marshals response to JSON and creates MCP result 249 | func marshalResponse(response interface{}) (*mcp.CallToolResult, error) { 250 | jsonData, err := json.Marshal(response) 251 | if err != nil { 252 | return createErrorResult(fmt.Sprintf("failed to marshal response: %v", err)), nil 253 | } 254 | return mcp.NewToolResultText(string(jsonData)), nil 255 | } 256 | 257 | // createErrorResult creates a standardized error result 258 | func createErrorResult(message string) *mcp.CallToolResult { 259 | return mcp.NewToolResultError(message) 260 | } 261 | 262 | // Helper functions using spf13/cast with defaults 263 | func getStringWithDefault(args map[string]interface{}, key, defaultValue string) string { 264 | if val := cast.ToString(args[key]); val != "" { 265 | return val 266 | } 267 | return defaultValue 268 | } 269 | 270 | func getLimitWithDefault(args map[string]interface{}, key string, defaultValue int) int { 271 | limit := cast.ToInt(args[key]) 272 | if limit <= 0 { 273 | limit = defaultValue 274 | } 275 | if limit > maxLimit { 276 | limit = maxLimit 277 | } 278 | return limit 279 | } 280 | 281 | func getStringSliceWithDefault(args map[string]interface{}, key string, defaultValue []string) []string { 282 | if slice := cast.ToStringSlice(args[key]); len(slice) > 0 { 283 | return slice 284 | } 285 | return defaultValue 286 | } 287 | 288 | // ListSpotRegionsTool implements the list_spot_regions MCP tool 289 | type ListSpotRegionsTool struct { 290 | client spotClient 291 | logger *slog.Logger 292 | } 293 | 294 | // NewListSpotRegionsTool creates a new list_spot_regions tool handler 295 | func NewListSpotRegionsTool(client spotClient, logger *slog.Logger) *ListSpotRegionsTool { 296 | return &ListSpotRegionsTool{ 297 | client: client, 298 | logger: logger, 299 | } 300 | } 301 | 302 | // Handle implements the list_spot_regions tool 303 | func (t *ListSpotRegionsTool) Handle(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { 304 | t.logger.Debug("handling list_spot_regions request") 305 | 306 | regions, err := t.fetchRegions(ctx) 307 | if err != nil { 308 | t.logger.Error("failed to get regions", slog.Any("error", err)) 309 | return createErrorResult(fmt.Sprintf("Failed to retrieve regions: %v", err)), nil 310 | } 311 | 312 | response := map[string]interface{}{ 313 | "regions": regions, 314 | "total": len(regions), 315 | } 316 | 317 | t.logger.Debug("list_spot_regions completed", slog.Int("total", len(regions))) 318 | return marshalResponse(response) 319 | } 320 | 321 | // fetchRegions gets all available regions from the spot client 322 | func (t *ListSpotRegionsTool) fetchRegions(ctx context.Context) ([]string, error) { 323 | opts := []spot.GetSpotSavingsOption{ 324 | spot.WithRegions([]string{"all"}), 325 | spot.WithPattern(""), 326 | spot.WithOS("linux"), 327 | spot.WithSort(spot.SortByRegion, false), 328 | } 329 | 330 | allAdvices, err := t.client.GetSpotSavings(ctx, opts...) 331 | if err != nil { 332 | return nil, err 333 | } 334 | 335 | regionSet := make(map[string]bool) 336 | for _, advice := range allAdvices { 337 | regionSet[advice.Region] = true 338 | } 339 | 340 | regions := make([]string, 0, len(regionSet)) 341 | for region := range regionSet { 342 | regions = append(regions, region) 343 | } 344 | 345 | return regions, nil 346 | } 347 | -------------------------------------------------------------------------------- /internal/spot/client.go: -------------------------------------------------------------------------------- 1 | package spot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | const ( 13 | // DefaultTimeoutSeconds is the default timeout value in seconds. 14 | DefaultTimeoutSeconds = 5 15 | // allRegionsKeyword represents the special "all" regions value. 16 | allRegionsKeyword = "all" 17 | ) 18 | 19 | // getSpotSavingsConfig holds configuration options for GetSpotSavingsWithOptions. 20 | // 21 | //nolint:govet // fieldalignment: small config struct, 8-byte optimization not worth the code churn 22 | type getSpotSavingsConfig struct { 23 | regions []string 24 | pattern string 25 | instanceOS string 26 | scoreTimeout time.Duration 27 | maxPrice float64 28 | cpu int 29 | memory int 30 | minScore int 31 | sortBy SortBy 32 | sortDesc bool 33 | withScores bool 34 | singleAvailabilityZone bool 35 | } 36 | 37 | // GetSpotSavingsOption is a functional option for GetSpotSavingsWithOptions. 38 | type GetSpotSavingsOption func(*getSpotSavingsConfig) 39 | 40 | // WithRegions sets the regions to query. 41 | func WithRegions(regions []string) GetSpotSavingsOption { 42 | return func(cfg *getSpotSavingsConfig) { 43 | cfg.regions = regions 44 | } 45 | } 46 | 47 | // WithPattern sets the instance type pattern filter. 48 | func WithPattern(pattern string) GetSpotSavingsOption { 49 | return func(cfg *getSpotSavingsConfig) { 50 | cfg.pattern = pattern 51 | } 52 | } 53 | 54 | // WithOS sets the operating system filter. 55 | func WithOS(instanceOS string) GetSpotSavingsOption { 56 | return func(cfg *getSpotSavingsConfig) { 57 | cfg.instanceOS = instanceOS 58 | } 59 | } 60 | 61 | // WithCPU sets the minimum CPU requirement. 62 | func WithCPU(cpu int) GetSpotSavingsOption { 63 | return func(cfg *getSpotSavingsConfig) { 64 | cfg.cpu = cpu 65 | } 66 | } 67 | 68 | // WithMemory sets the minimum memory requirement. 69 | func WithMemory(memory int) GetSpotSavingsOption { 70 | return func(cfg *getSpotSavingsConfig) { 71 | cfg.memory = memory 72 | } 73 | } 74 | 75 | // WithMaxPrice sets the maximum price filter. 76 | func WithMaxPrice(maxPrice float64) GetSpotSavingsOption { 77 | return func(cfg *getSpotSavingsConfig) { 78 | cfg.maxPrice = maxPrice 79 | } 80 | } 81 | 82 | // WithSort sets the sorting criteria. 83 | func WithSort(sortBy SortBy, sortDesc bool) GetSpotSavingsOption { 84 | return func(cfg *getSpotSavingsConfig) { 85 | cfg.sortBy = sortBy 86 | cfg.sortDesc = sortDesc 87 | } 88 | } 89 | 90 | // WithScores enables spot placement score enrichment. 91 | func WithScores(enable bool) GetSpotSavingsOption { 92 | return func(cfg *getSpotSavingsConfig) { 93 | cfg.withScores = enable 94 | } 95 | } 96 | 97 | // WithSingleAvailabilityZone enables AZ-level scoring instead of region-level. 98 | func WithSingleAvailabilityZone(enable bool) GetSpotSavingsOption { 99 | return func(cfg *getSpotSavingsConfig) { 100 | cfg.singleAvailabilityZone = enable 101 | } 102 | } 103 | 104 | // WithMinScore sets the minimum score filter. 105 | func WithMinScore(minScore int) GetSpotSavingsOption { 106 | return func(cfg *getSpotSavingsConfig) { 107 | cfg.minScore = minScore 108 | } 109 | } 110 | 111 | // WithScoreTimeout sets the timeout for score enrichment operations. 112 | func WithScoreTimeout(timeout time.Duration) GetSpotSavingsOption { 113 | return func(cfg *getSpotSavingsConfig) { 114 | cfg.scoreTimeout = timeout 115 | } 116 | } 117 | 118 | // Client provides access to AWS EC2 Spot instance pricing and advice. 119 | type Client struct { 120 | advisorProvider advisorProvider 121 | pricingProvider pricingProvider 122 | scoreProvider scoreProvider 123 | timeout time.Duration 124 | useEmbedded bool 125 | } 126 | 127 | // advisorProvider provides access to spot advisor data (private interface close to consumer). 128 | type advisorProvider interface { 129 | getRegions() []string 130 | getRegionAdvice(region, os string) (map[string]spotAdvice, error) 131 | getInstanceType(instance string) (TypeInfo, error) 132 | getRange(index int) (Range, error) 133 | } 134 | 135 | // pricingProvider provides access to spot pricing data (private interface close to consumer). 136 | type pricingProvider interface { 137 | getSpotPrice(instance, region, os string) (float64, error) 138 | } 139 | 140 | // scoreProvider provides access to spot placement scores (private interface close to consumer). 141 | type scoreProvider interface { 142 | enrichWithScores(ctx context.Context, advices []Advice, singleAZ bool, timeout time.Duration) error 143 | } 144 | 145 | // New creates a new spot client with default options. 146 | func New() *Client { 147 | return NewWithOptions(DefaultTimeoutSeconds*time.Second, false) 148 | } 149 | 150 | // NewWithOptions creates a new spot client with custom options. 151 | func NewWithOptions(timeout time.Duration, useEmbedded bool) *Client { 152 | return &Client{ 153 | advisorProvider: newDefaultAdvisorProvider(timeout), 154 | pricingProvider: newDefaultPricingProvider(timeout, useEmbedded), 155 | scoreProvider: newScoreCache(), 156 | timeout: timeout, 157 | useEmbedded: useEmbedded, 158 | } 159 | } 160 | 161 | // NewWithProviders creates a new spot client with custom data providers (for testing). 162 | func NewWithProviders(advisor advisorProvider, pricing pricingProvider) *Client { 163 | return &Client{ 164 | advisorProvider: advisor, 165 | pricingProvider: pricing, 166 | timeout: DefaultTimeoutSeconds * time.Second, 167 | useEmbedded: false, 168 | } 169 | } 170 | 171 | // GetSpotSavings retrieves spot instance advice using functional options. 172 | // 173 | //nolint:gocyclo,cyclop // Complex business logic that benefits from being in a single function 174 | func (c *Client) GetSpotSavings(ctx context.Context, opts ...GetSpotSavingsOption) ([]Advice, error) { 175 | // Default configuration 176 | cfg := &getSpotSavingsConfig{ 177 | instanceOS: "linux", 178 | sortBy: SortByRange, 179 | scoreTimeout: defaultScoreTimeout, 180 | } 181 | 182 | // Apply options 183 | for _, opt := range opts { 184 | opt(cfg) 185 | } 186 | 187 | // Handle "all" regions special case 188 | regions := cfg.regions 189 | if len(regions) == 1 && regions[0] == allRegionsKeyword { 190 | regions = c.advisorProvider.getRegions() 191 | } 192 | 193 | result := make([]Advice, 0) 194 | 195 | for _, region := range regions { 196 | // Get advice for this region and OS 197 | advices, err := c.advisorProvider.getRegionAdvice(region, cfg.instanceOS) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | // Process each instance type 203 | for instance, adv := range advices { 204 | // Match instance type pattern 205 | if cfg.pattern != "" { 206 | matched, err := regexp.MatchString(cfg.pattern, instance) 207 | if err != nil { 208 | return nil, fmt.Errorf("failed to match instance type: %w", err) 209 | } 210 | if !matched { 211 | continue 212 | } 213 | } 214 | 215 | // Filter by CPU and memory requirements 216 | info, err := c.advisorProvider.getInstanceType(instance) 217 | if err != nil { 218 | continue // Skip instances we don't have type info for 219 | } 220 | if (cfg.cpu != 0 && info.Cores < cfg.cpu) || (cfg.memory != 0 && info.RAM < float32(cfg.memory)) { 221 | continue 222 | } 223 | 224 | // Get spot price 225 | spotPrice, err := c.pricingProvider.getSpotPrice(instance, region, cfg.instanceOS) 226 | if err == nil { 227 | // Filter by max price 228 | if cfg.maxPrice != 0 && spotPrice > cfg.maxPrice { 229 | continue 230 | } 231 | } 232 | 233 | // Get range information 234 | rng, err := c.advisorProvider.getRange(adv.Range) 235 | if err != nil { 236 | continue // Skip if we can't get range info 237 | } 238 | 239 | result = append(result, Advice{ 240 | Region: region, 241 | Instance: instance, 242 | InstanceType: instance, // Set InstanceType field 243 | Range: rng, 244 | Savings: adv.Savings, 245 | Info: info, 246 | Price: spotPrice, 247 | }) 248 | } 249 | } 250 | 251 | // Sort results 252 | sortAdvices(result, cfg.sortBy, cfg.sortDesc) 253 | 254 | // Add score enrichment if requested 255 | if cfg.withScores { 256 | err := c.enrichWithScores(ctx, result, cfg.singleAvailabilityZone, cfg.scoreTimeout) 257 | if err != nil { 258 | return nil, fmt.Errorf("score enrichment failed: %w", err) 259 | } 260 | } 261 | 262 | // Filter by minimum score if specified 263 | if cfg.minScore > 0 { 264 | result = filterByMinScore(result, cfg.minScore) 265 | } 266 | 267 | return result, nil 268 | } 269 | 270 | // defaultAdvisorProvider is the default implementation of advisorProvider. 271 | type defaultAdvisorProvider struct { 272 | data *advisorData 273 | err error 274 | timeout time.Duration 275 | once sync.Once 276 | } 277 | 278 | func newDefaultAdvisorProvider(timeout time.Duration) *defaultAdvisorProvider { 279 | return &defaultAdvisorProvider{timeout: timeout} 280 | } 281 | 282 | func (p *defaultAdvisorProvider) loadData() error { 283 | p.once.Do(func() { 284 | p.data, p.err = fetchAdvisorData(context.Background()) 285 | }) 286 | return p.err 287 | } 288 | 289 | func (p *defaultAdvisorProvider) getRegions() []string { 290 | if err := p.loadData(); err != nil { 291 | return nil 292 | } 293 | regions := make([]string, 0, len(p.data.Regions)) 294 | for k := range p.data.Regions { 295 | regions = append(regions, k) 296 | } 297 | return regions 298 | } 299 | 300 | func (p *defaultAdvisorProvider) getRegionAdvice(region, os string) (map[string]spotAdvice, error) { 301 | // Validate OS first before loading data 302 | if !strings.EqualFold("windows", os) && !strings.EqualFold("linux", os) { 303 | return nil, fmt.Errorf("invalid instance OS, must be windows/linux") 304 | } 305 | 306 | if err := p.loadData(); err != nil { 307 | return nil, err 308 | } 309 | 310 | regionData, ok := p.data.Regions[region] 311 | if !ok { 312 | return nil, fmt.Errorf("region not found: %s", region) 313 | } 314 | 315 | var advices map[string]spotAdvice 316 | if strings.EqualFold("windows", os) { 317 | advices = regionData.Windows 318 | } else { 319 | advices = regionData.Linux 320 | } 321 | 322 | return advices, nil 323 | } 324 | 325 | func (p *defaultAdvisorProvider) getInstanceType(instance string) (TypeInfo, error) { 326 | if err := p.loadData(); err != nil { 327 | return TypeInfo{}, err 328 | } 329 | 330 | info, ok := p.data.InstanceTypes[instance] 331 | if !ok { 332 | return TypeInfo{}, fmt.Errorf("instance type not found: %s", instance) 333 | } 334 | 335 | return TypeInfo(info), nil 336 | } 337 | 338 | func (p *defaultAdvisorProvider) getRange(index int) (Range, error) { 339 | if err := p.loadData(); err != nil { 340 | return Range{}, err 341 | } 342 | 343 | if index < 0 || index >= len(p.data.Ranges) { 344 | return Range{}, fmt.Errorf("range index out of bounds: %d", index) 345 | } 346 | 347 | r := p.data.Ranges[index] 348 | return Range{ 349 | Label: r.Label, 350 | Max: r.Max, 351 | Min: minRange[r.Max], 352 | }, nil 353 | } 354 | 355 | // defaultPricingProvider is the default implementation of pricingProvider. 356 | type defaultPricingProvider struct { 357 | data *spotPriceData 358 | err error 359 | timeout time.Duration 360 | useEmbedded bool 361 | once sync.Once 362 | } 363 | 364 | func newDefaultPricingProvider(timeout time.Duration, useEmbedded bool) *defaultPricingProvider { 365 | return &defaultPricingProvider{ 366 | timeout: timeout, 367 | useEmbedded: useEmbedded, 368 | } 369 | } 370 | 371 | func (p *defaultPricingProvider) loadData() error { 372 | p.once.Do(func() { 373 | rawData, err := fetchPricingData(context.Background(), p.useEmbedded) 374 | if err != nil { 375 | p.err = err 376 | return 377 | } 378 | p.data = convertRawPriceData(rawData) 379 | }) 380 | return p.err 381 | } 382 | 383 | func (p *defaultPricingProvider) getSpotPrice(instance, region, os string) (float64, error) { 384 | if err := p.loadData(); err != nil { 385 | return 0, err 386 | } 387 | return p.data.getSpotInstancePrice(instance, region, os) 388 | } 389 | 390 | // enrichWithScores delegates score enrichment to the scoreProvider. 391 | func (c *Client) enrichWithScores(ctx context.Context, advices []Advice, singleAZ bool, timeout time.Duration) error { 392 | if c.scoreProvider == nil { 393 | c.scoreProvider = newScoreCache() 394 | } 395 | return c.scoreProvider.enrichWithScores(ctx, advices, singleAZ, timeout) 396 | } 397 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Guide 2 | 3 | This guide helps you diagnose and resolve common issues with `spotinfo` in both CLI and MCP server modes. 4 | 5 | ## General Diagnostics 6 | 7 | Before diving into specific issues, gather basic information about your setup: 8 | 9 | ### System Information 10 | ```bash 11 | # Check spotinfo version 12 | spotinfo --version 13 | 14 | # Check installation path 15 | which spotinfo 16 | 17 | # Check binary permissions 18 | ls -la $(which spotinfo) 19 | 20 | # Test basic functionality 21 | spotinfo --type=t3.micro --region=us-east-1 --output=json 22 | ``` 23 | 24 | ### MCP Server Test 25 | ```bash 26 | # Test MCP server mode 27 | spotinfo --mcp 28 | # Should start without errors and wait for input (Ctrl+C to exit) 29 | 30 | # Check if server responds to basic input 31 | echo '{"jsonrpc": "2.0", "method": "initialize", "id": 1}' | spotinfo --mcp 32 | ``` 33 | 34 | ## CLI Mode Issues 35 | 36 | ### 1. No Data Returned 37 | 38 | **Symptoms:** 39 | - Empty results or "No instances found" 40 | - Valid filters return no matches 41 | 42 | **Diagnosis:** 43 | ```bash 44 | # Test with minimal filters 45 | spotinfo --region=us-east-1 --output=json 46 | 47 | # Check if specific instance type exists 48 | spotinfo --type=t3.micro --region=us-east-1 49 | 50 | # Test with all regions 51 | spotinfo --type=t3.micro --region=all --limit=1 52 | ``` 53 | 54 | **Solutions:** 55 | - **Expand search criteria**: Remove filters to see if data exists 56 | - **Check region availability**: Some instance types aren't available in all regions 57 | - **Verify instance type spelling**: Use patterns like `t3.*` for family searches 58 | - **Update embedded data**: Newer instance types might not be in embedded data 59 | 60 | ### 2. Slow Performance 61 | 62 | **Symptoms:** 63 | - Commands take longer than 10 seconds 64 | - Timeout errors 65 | 66 | **Diagnosis:** 67 | ```bash 68 | # Test with single region 69 | time spotinfo --type=t3.micro --region=us-east-1 70 | 71 | # Test with multiple regions 72 | time spotinfo --type=t3.micro --region=us-east-1 --region=us-west-2 73 | 74 | # Test with all regions 75 | time spotinfo --type=t3.micro --region=all 76 | ``` 77 | 78 | **Solutions:** 79 | - **Limit regions**: Use specific regions instead of `--region=all` 80 | - **Reduce result set**: Use `--limit` parameter to reduce output 81 | - **Check network**: Slow DNS resolution can affect data fetching 82 | - **Use embedded data**: Network issues cause fallback to embedded data 83 | 84 | ### 3. Invalid Output Format 85 | 86 | **Symptoms:** 87 | - Malformed JSON output 88 | - Unexpected formatting 89 | 90 | **Diagnosis:** 91 | ```bash 92 | # Test different output formats 93 | spotinfo --type=t3.micro --region=us-east-1 --output=table 94 | spotinfo --type=t3.micro --region=us-east-1 --output=json 95 | spotinfo --type=t3.micro --region=us-east-1 --output=csv 96 | 97 | # Validate JSON output 98 | spotinfo --type=t3.micro --region=us-east-1 --output=json | jq . 99 | ``` 100 | 101 | **Solutions:** 102 | - **Check for stderr mixing**: Redirect stderr: `spotinfo ... 2>/dev/null` 103 | - **Update to latest version**: Older versions might have formatting bugs 104 | - **Use alternative format**: Try `table` or `csv` if `json` is problematic 105 | 106 | ## MCP Server Issues 107 | 108 | ### 1. Claude Can't Find Tools 109 | 110 | **Symptoms:** 111 | - Claude responds: "I don't have access to AWS spot instance tools" 112 | - MCP tools don't appear in Claude's capabilities 113 | 114 | **Diagnosis:** 115 | ```bash 116 | # Verify MCP server starts 117 | spotinfo --mcp 118 | # Should show initialization messages 119 | 120 | # Test with MCP Inspector 121 | npx @modelcontextprotocol/inspector spotinfo --mcp 122 | 123 | # Check Claude Desktop configuration 124 | cat ~/Library/Application\ Support/Claude/claude_desktop_config.json 125 | ``` 126 | 127 | **Solutions:** 128 | 1. **Check configuration file syntax**: 129 | ```bash 130 | # Validate JSON 131 | cat claude_desktop_config.json | jq . 132 | ``` 133 | 134 | 2. **Verify binary path**: 135 | ```json 136 | { 137 | "mcpServers": { 138 | "spotinfo": { 139 | "command": "/correct/path/to/spotinfo", 140 | "args": ["--mcp"] 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | 3. **Restart Claude Desktop**: 147 | - Quit completely 148 | - Wait 5 seconds 149 | - Restart 150 | 151 | 4. **Check permissions**: 152 | ```bash 153 | chmod +x /path/to/spotinfo 154 | ``` 155 | 156 | ### 2. Connection Refused/Timeout 157 | 158 | **Symptoms:** 159 | - "Connection refused" errors 160 | - MCP server exits immediately 161 | - Claude shows connection timeout 162 | 163 | **Diagnosis:** 164 | ```bash 165 | # Check if server stays running 166 | timeout 10s spotinfo --mcp 167 | echo "Exit code: $?" 168 | 169 | # Check for error messages 170 | spotinfo --mcp 2>&1 | head -20 171 | 172 | # Test stdio communication 173 | echo '{"jsonrpc": "2.0", "method": "ping", "id": 1}' | spotinfo --mcp 174 | ``` 175 | 176 | **Solutions:** 177 | 1. **Check for port conflicts** (if using SSE mode): 178 | ```bash 179 | # Check if port is in use 180 | lsof -i :8080 181 | ``` 182 | 183 | 2. **Verify environment variables**: 184 | ```bash 185 | export SPOTINFO_MODE=mcp 186 | spotinfo 187 | ``` 188 | 189 | 3. **Check system resources**: 190 | ```bash 191 | # Check available memory 192 | free -h # Linux 193 | vm_stat # macOS 194 | ``` 195 | 196 | ### 3. Partial or No Data in Responses 197 | 198 | **Symptoms:** 199 | - Empty results from MCP tools 200 | - Missing fields in responses 201 | - Tool calls succeed but return no data 202 | 203 | **Diagnosis:** 204 | ```bash 205 | # Test CLI equivalent 206 | spotinfo --type=t3.micro --region=us-east-1 --output=json 207 | 208 | # Test MCP tool directly with Inspector 209 | npx @modelcontextprotocol/inspector spotinfo --mcp 210 | # Then call: find_spot_instances with minimal parameters 211 | ``` 212 | 213 | **Solutions:** 214 | 1. **Check parameter formats**: 215 | ```json 216 | // Correct format 217 | { 218 | "regions": ["us-east-1"], 219 | "instance_types": "t3.*" 220 | } 221 | 222 | // Incorrect format 223 | { 224 | "regions": "us-east-1", 225 | "instance_types": ["t3.micro"] 226 | } 227 | ``` 228 | 229 | 2. **Verify data availability**: 230 | ```bash 231 | # Check if data exists for parameters 232 | spotinfo --type=t3.* --region=us-east-1 --limit=1 233 | ``` 234 | 235 | 3. **Use broader search criteria**: 236 | - Remove restrictive filters 237 | - Increase limit parameter 238 | - Try different regions 239 | 240 | ## Platform-Specific Issues 241 | 242 | ### macOS 243 | 244 | #### 1. "Cannot be opened because it is from an unidentified developer" 245 | 246 | **Solution:** 247 | ```bash 248 | # Remove quarantine 249 | xattr -d com.apple.quarantine /path/to/spotinfo 250 | 251 | # Or allow in System Preferences 252 | # System Preferences → Security & Privacy → General → Allow 253 | ``` 254 | 255 | #### 2. Homebrew Installation Issues 256 | 257 | **Diagnosis:** 258 | ```bash 259 | # Check Homebrew 260 | brew doctor 261 | 262 | # Check tap 263 | brew tap alexei-led/spotinfo 264 | 265 | # Update Homebrew 266 | brew update 267 | ``` 268 | 269 | **Solution:** 270 | ```bash 271 | # Clean reinstall 272 | brew untap alexei-led/spotinfo 273 | brew tap alexei-led/spotinfo 274 | brew install spotinfo 275 | ``` 276 | 277 | ### Windows 278 | 279 | #### 1. PowerShell Execution Policy 280 | 281 | **Symptoms:** 282 | - "Execution of scripts is disabled" errors 283 | 284 | **Solution:** 285 | ```powershell 286 | # Check current policy 287 | Get-ExecutionPolicy 288 | 289 | # Set to allow local scripts 290 | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 291 | ``` 292 | 293 | #### 2. Path Issues 294 | 295 | **Diagnosis:** 296 | ```cmd 297 | # Check if spotinfo is in PATH 298 | where spotinfo 299 | 300 | # Check PATH variable 301 | echo %PATH% 302 | ``` 303 | 304 | **Solution:** 305 | ```cmd 306 | # Add to PATH or use full path in Claude config 307 | # "command": "C:\\Program Files\\spotinfo\\spotinfo.exe" 308 | ``` 309 | 310 | ### Linux 311 | 312 | #### 1. Missing Dependencies 313 | 314 | **Symptoms:** 315 | - "No such file or directory" for existing binary 316 | - Library errors 317 | 318 | **Diagnosis:** 319 | ```bash 320 | # Check binary type 321 | file $(which spotinfo) 322 | 323 | # Check dependencies 324 | ldd $(which spotinfo) 325 | ``` 326 | 327 | **Solution:** 328 | ```bash 329 | # Install missing libraries (Ubuntu/Debian) 330 | sudo apt-get update 331 | sudo apt-get install libc6 332 | 333 | # For older systems, use static binary 334 | curl -L https://github.com/alexei-led/spotinfo/releases/latest/download/spotinfo_linux_amd64_static.tar.gz | tar xz 335 | ``` 336 | 337 | ## Network and Data Issues 338 | 339 | ### 1. AWS Data Feed Unavailable 340 | 341 | **Symptoms:** 342 | - "Failed to fetch data" warnings 343 | - Outdated pricing information 344 | 345 | **Diagnosis:** 346 | ```bash 347 | # Test AWS endpoints 348 | curl -s https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json | head 349 | curl -s http://spot-price.s3.amazonaws.com/spot.js | head 350 | 351 | # Check network connectivity 352 | ping spot-bid-advisor.s3.amazonaws.com 353 | ``` 354 | 355 | **Solutions:** 356 | - **Use embedded data**: spotinfo automatically falls back to embedded data 357 | - **Check proxy settings**: Configure HTTP_PROXY if behind corporate firewall 358 | - **Update embedded data**: Download latest version with updated embedded data 359 | 360 | ### 2. Corporate Firewall Issues 361 | 362 | **Symptoms:** 363 | - Connection timeouts to AWS endpoints 364 | - Proxy authentication errors 365 | 366 | **Solutions:** 367 | ```bash 368 | # Set proxy environment variables 369 | export HTTP_PROXY=http://proxy.company.com:8080 370 | export HTTPS_PROXY=http://proxy.company.com:8080 371 | 372 | # Test with proxy 373 | curl -s --proxy http://proxy.company.com:8080 https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json 374 | ``` 375 | 376 | ## Error Messages and Solutions 377 | 378 | ### "invalid character" in JSON output 379 | 380 | **Cause:** stderr mixed with stdout 381 | 382 | **Solution:** 383 | ```bash 384 | spotinfo --output=json 2>/dev/null 385 | ``` 386 | 387 | ### "no such file or directory" 388 | 389 | **Cause:** Binary not found or wrong path 390 | 391 | **Solution:** 392 | ```bash 393 | # Find correct path 394 | which spotinfo 395 | 396 | # Update configuration with full path 397 | ``` 398 | 399 | ### "permission denied" 400 | 401 | **Cause:** Binary not executable 402 | 403 | **Solution:** 404 | ```bash 405 | chmod +x /path/to/spotinfo 406 | ``` 407 | 408 | ### "context deadline exceeded" 409 | 410 | **Cause:** Network timeout or slow response 411 | 412 | **Solution:** 413 | - Reduce search scope 414 | - Check network connectivity 415 | - Use embedded data mode 416 | 417 | ## Debugging Techniques 418 | 419 | ### 1. Enable Verbose Logging 420 | 421 | ```bash 422 | # Add debug flags (if implemented) 423 | spotinfo --debug --type=t3.micro --region=us-east-1 424 | 425 | # Use strace/dtrace for system call tracing 426 | strace -e network spotinfo --mcp # Linux 427 | dtruss -n spotinfo --mcp # macOS 428 | ``` 429 | 430 | ### 2. Test MCP Protocol Manually 431 | 432 | ```bash 433 | # Send raw MCP messages 434 | echo '{"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {"protocolVersion": "2024-11-05", "capabilities": {}}}' | spotinfo --mcp 435 | 436 | # Test tool calls 437 | echo '{"jsonrpc": "2.0", "method": "tools/call", "id": 2, "params": {"name": "list_spot_regions", "arguments": {}}}' | spotinfo --mcp 438 | ``` 439 | 440 | ### 3. Compare CLI vs MCP Results 441 | 442 | ```bash 443 | # CLI result 444 | spotinfo --type=t3.micro --region=us-east-1 --output=json 445 | 446 | # MCP equivalent - use Inspector to call: 447 | # find_spot_instances with {"instance_types": "t3.micro", "regions": ["us-east-1"]} 448 | ``` 449 | 450 | ## Getting Help 451 | 452 | If issues persist after trying these solutions: 453 | 454 | 1. **Search existing issues**: [GitHub Issues](https://github.com/alexei-led/spotinfo/issues) 455 | 2. **Create detailed bug report**: 456 | - Include spotinfo version (`spotinfo --version`) 457 | - Include platform information (`uname -a`) 458 | - Include error messages and logs 459 | - Include steps to reproduce 460 | 3. **Provide configuration**: Include sanitized Claude Desktop config 461 | 4. **Test with minimal case**: Try to reproduce with simplest possible example 462 | 463 | ## Prevention Tips 464 | 465 | - **Keep updated**: Regularly update spotinfo to latest version 466 | - **Test changes**: Test configuration changes with MCP Inspector first 467 | - **Monitor logs**: Check Claude Desktop logs for warnings 468 | - **Backup config**: Keep backup of working configuration 469 | - **Document setup**: Note any custom configurations or environment variables 470 | 471 | This troubleshooting guide covers the most common issues. For additional help, consult the [Claude Desktop integration guide](claude-desktop-setup.md) and [API reference](api-reference.md). --------------------------------------------------------------------------------