├── .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 | [](https://github.com/alexei-led/spotinfo/actions/workflows/ci.yaml) [](https://github.com/alexei-led/spotinfo/actions/workflows/docker.yaml) [](https://goreportcard.com/report/github.com/alexei-led/spotinfo) [](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).
--------------------------------------------------------------------------------