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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ nfpms:
lintian_overrides:
- statically-linked-binary

dockers_v2:
- id: docker
images:
- ghcr.io/timescale/tiger-cli
tags:
- "{{ .Version }}"
- '{{ if eq .Prerelease "" }}{{ .Major }}{{ end }}'
- '{{ if eq .Prerelease "" }}{{ .Major }}.{{ .Minor }}{{ end }}'
- '{{ if eq .Prerelease "" }}latest{{ end }}'
labels:
org.opencontainers.image.created: "{{.Date}}"
org.opencontainers.image.name: "{{.ProjectName}}"
org.opencontainers.image.title: "{{.ProjectName}}"
org.opencontainers.image.revision: "{{.FullCommit}}"
org.opencontainers.image.source: "https://github.com/timescale/tiger-cli"
org.opencontainers.image.url: "https://github.com/timescale/tiger-cli"
org.opencontainers.image.version: "{{.Version}}"
io.modelcontextprotocol.server.name: "io.github.timescale/tiger-cli"
build_args:
BINARY_SOURCE: release
platforms:
- linux/amd64
- linux/arm64

# S3 Blob Storage Configuration
blobs:
Expand Down
57 changes: 57 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
ARG GO_VERSION=1.25
ARG BINARY_SOURCE=builder

# When performing a multi-platform build, leverage Go's built-in support for
# cross-compilation instead of relying on emulation (which is much slower).
# See: https://docs.docker.com/build/building/multi-platform/#cross-compiling-a-go-application
FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS builder
ARG TARGETOS
ARG TARGETARCH

# Download dependencies to local module cache
WORKDIR /src
RUN --mount=type=bind,source=go.sum,target=go.sum \
--mount=type=bind,source=go.mod,target=go.mod \
--mount=type=cache,target=/go/pkg/mod \
go mod download -x

# Build static executable
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -o /bin/tiger ./cmd/tiger

# When building Docker images via GoReleaser, the binaries are built externally
# and copied in. See: https://goreleaser.com/customization/dockers_v2/
FROM scratch AS release
ARG TARGETPLATFORM
COPY ${TARGETPLATFORM}/tiger /bin/tiger

# Either the 'builder' or 'release' stage, depending on whether we're building
# the binaries in Docker or outside via GoReleaser.
FROM ${BINARY_SOURCE} AS binary_source

# Create final alpine image
FROM alpine:3.23 AS final

# Install psql for sake of `tiger db connect`
RUN apk add --update --no-cache postgresql-client

# Create non-root user/group
RUN addgroup -g 1000 tiger && adduser -u 1000 -G tiger -D tiger
USER tiger
WORKDIR /home/tiger

# Set env vars to control default Tiger CLI behavior
ENV TIGER_PASSWORD_STORAGE=pgpass
ENV TIGER_CONFIG_DIR=/home/tiger/.config/tiger

# Declare config file mount points
VOLUME /home/tiger/.config/tiger
VOLUME /home/tiger/.pgpass

# Copy binary to final image
COPY --from=binary_source /bin/tiger /usr/local/bin/tiger
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take it that an env var isn't allowed here, to just use --from=${BINARY_SOURCE} and eliminate an extra stage? It's a weird looking dance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's right, you can't use an env var in --from=. That was the first thing I tried, but Docker returned an error that specifically suggested I change it to this pattern 🤷‍♂️


ENTRYPOINT ["tiger"]
CMD ["mcp", "start"]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ Environment variables override configuration file values. All variables use the
- `TIGER_DOCS_MCP` - Enable/disable docs MCP proxy
- `TIGER_OUTPUT` - Output format: `json`, `yaml`, or `table`
- `TIGER_PASSWORD_STORAGE` - Password storage method: `keyring`, `pgpass`, or `none`
- `TIGER_PUBLIC_KEY` - Public key to use for authentication (takes priority over stored credentials)
- `TIGER_SECRET_KEY` - Secret key to use for authentication (takes priority over stored credentials)
- `TIGER_SERVICE_ID` - Default service ID
- `TIGER_VERSION_CHECK_INTERVAL` - How often the CLI will check for new versions, 0 to disable

Expand Down
23 changes: 23 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
tiger-cli:
build:
context: .
dockerfile: ./Dockerfile
develop:
watch:
- action: rebuild
path: ./
image: ghcr.io/timescale/tiger-cli:${TAG:-latest}
container_name: tiger-cli
environment:
- TIGER_PUBLIC_KEY
- TIGER_SECRET_KEY
ports:
- 8080:8080
volumes:
- type: bind
source: ${HOME}/.config/tiger
target: /home/tiger/.config/tiger
- type: bind
source: ${HOME}/.pgpass
target: /home/tiger/.pgpass
24 changes: 3 additions & 21 deletions internal/tiger/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ type Analytics struct {
client *api.ClientWithResponses
}

// New initializes a new [Analytics] instance.
// New initializes a new [Analytics] instance. The [config.Config] parameters
// is required, but the others are optional. Analytics won't be sent if the
// [api.ClientWithResponses] is nil.
func New(cfg *config.Config, client *api.ClientWithResponses, projectID string) *Analytics {
return &Analytics{
config: cfg,
Expand All @@ -46,26 +48,6 @@ func New(cfg *config.Config, client *api.ClientWithResponses, projectID string)
}
}

// TryInit tries to load credentials to initialize an [Analytics]
// instance. It returns an instance with a nil client if credentials do not
// exist or it otherwise fails to create a new client. This function is
// intended to be used when the caller does not otherwise need an API client to
// function, but would use one if available to track analytics events.
// Otherwise, call NewAnalytics directly.
func TryInit(cfg *config.Config) *Analytics {
apiKey, projectID, err := config.GetCredentials()
if err != nil {
return New(cfg, nil, "")
}

client, err := api.NewTigerClient(cfg, apiKey)
if err != nil {
return New(cfg, nil, projectID)
}

return New(cfg, client, projectID)
}

// Option is a function that modifies analytics event properties. Options are
// passed to Track and Identify methods to customize the data sent with events.
type Option func(properties map[string]any)
Expand Down
76 changes: 16 additions & 60 deletions internal/tiger/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
Expand All @@ -15,15 +16,14 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"

"github.com/timescale/tiger-cli/internal/tiger/analytics"
"github.com/timescale/tiger-cli/internal/tiger/api"
"github.com/timescale/tiger-cli/internal/tiger/common"
"github.com/timescale/tiger-cli/internal/tiger/config"
"github.com/timescale/tiger-cli/internal/tiger/util"
)

// validateAndGetAuthInfo can be overridden for testing
var validateAndGetAuthInfo = validateAndGetAuthInfoImpl
// validateAPIKey can be overridden for testing
var validateAPIKey = common.ValidateAPIKey

// nextStepsMessage is the message shown after successful login
const nextStepsMessage = `
Expand Down Expand Up @@ -118,9 +118,15 @@ Examples:
// Combine the keys in the format "public:secret" for storage
apiKey := fmt.Sprintf("%s:%s", creds.publicKey, creds.secretKey)

// Create API client
client, err := api.NewTigerClient(cfg, apiKey)
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}

// Validate the API key and get auth info by calling the /auth/info endpoint
fmt.Fprintln(cmd.OutOrStdout(), "Validating API key...")
authInfo, err := validateAndGetAuthInfo(cmd.Context(), cfg, apiKey)
authInfo, err := validateAPIKey(cmd.Context(), cfg, client)
if err != nil {
return fmt.Errorf("API key validation failed: %w", err)
}
Expand Down Expand Up @@ -178,28 +184,20 @@ func buildStatusCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true

// Get config
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

apiKey, _, err := config.GetCredentials()
// Load config and API client
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
if errors.Is(err, config.ErrNotLoggedIn) {
return common.ExitWithCode(common.ExitAuthenticationError, config.ErrNotLoggedIn)
}
return err
}

// Create API client
client, err := api.NewTigerClient(cfg, apiKey)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
}

// Make API call to get auth information
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()

resp, err := client.GetAuthInfoWithResponse(ctx)
resp, err := cfg.Client.GetAuthInfoWithResponse(ctx)
if err != nil {
return fmt.Errorf("failed to get auth information: %w", err)
}
Expand Down Expand Up @@ -316,45 +314,3 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti

return creds, nil
}

// validateAndGetAuthInfoImpl validates the API key and returns authentication information
// by calling the /auth/info endpoint.
func validateAndGetAuthInfoImpl(ctx context.Context, cfg *config.Config, apiKey string) (*api.AuthInfo, error) {
client, err := api.NewTigerClient(cfg, apiKey)
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

// Call the /auth/info endpoint to validate credentials and get auth info
resp, err := client.GetAuthInfoWithResponse(ctx)
if err != nil {
return nil, fmt.Errorf("API call failed: %w", err)
}

// Check the response status
if resp.StatusCode() != 200 {
if resp.JSON4XX != nil {
return nil, resp.JSON4XX
}
return nil, fmt.Errorf("unexpected API response: %d", resp.StatusCode())
}

if resp.JSON200 == nil {
return nil, fmt.Errorf("empty response from API")
}

authInfo := resp.JSON200

// Identify the user with analytics
a := analytics.New(cfg, client, authInfo.ApiKey.Project.Id)
a.Identify(
analytics.Property("userId", authInfo.ApiKey.IssuingUser.Id),
analytics.Property("email", string(authInfo.ApiKey.IssuingUser.Email)),
analytics.Property("planType", authInfo.ApiKey.Project.PlanType),
)

return authInfo, nil
}
Loading