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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions internal/elasticsearch/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,37 @@ func (client *Client) CheckHealth(ctx context.Context) error {
return nil
}

type Info struct {
Name string `json:"name"`
ClusterName string `json:"cluster_name"`
ClusterUUID string `json:"cluster_uuid"`
Version struct {
Number string `json:"number"`
BuildFlavor string `json:"build_flavor"`
} `json:"version`
}

// Info gets cluster information and metadata.
func (client *Client) Info(ctx context.Context) (*Info, error) {
resp, err := client.Client.Info(client.Client.Info.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("error getting cluster info: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get cluster info: %s", resp.String())
}

var info Info
err = json.NewDecoder(resp.Body).Decode(&info)
if err != nil {
return nil, fmt.Errorf("error decoding cluster info: %w", err)
}

return &info, nil
}

// IsFailureStoreAvailable checks if the failure store is available.
func (client *Client) IsFailureStoreAvailable(ctx context.Context) (bool, error) {
// FIXME: Using the low-level transport till the API SDK supports the failure store.
Expand Down
7 changes: 7 additions & 0 deletions internal/elasticsearch/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ func TestClusterHealth(t *testing.T) {
}
}

func TestClusterInfo(t *testing.T) {
client := test.NewClient(t, "./testdata/elasticsearch-9-info")
info, err := client.Info(context.Background())
require.NoError(t, err)
assert.Equal(t, "9.0.0-SNAPSHOT", info.Version.Number)
}

func writeCACertFile(t *testing.T, cert *x509.Certificate) string {
var d bytes.Buffer
err := pem.Encode(&d, &pem.Block{
Expand Down
119 changes: 119 additions & 0 deletions internal/elasticsearch/testdata/elasticsearch-9-info.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
transfer_encoding: []
trailer: {}
host: ""
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Authorization:
- Basic ZWxhc3RpYzpjaGFuZ2VtZQ==
User-Agent:
- go-elasticsearch/7.17.10 (linux amd64; Go 1.23.4)
X-Elastic-Client-Meta:
- es=7.17.10,go=1.23.4,t=7.17.10,hc=1.23.4
url: https://127.0.0.1:9200/
method: GET
response:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
transfer_encoding: []
trailer: {}
content_length: 547
uncompressed: false
body: |
{
"name" : "da1c4d2b1379",
"cluster_name" : "elasticsearch",
"cluster_uuid" : "KPcvXrW1Rnega2GT0sn3mg",
"version" : {
"number" : "9.0.0-SNAPSHOT",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "873dc52360a9265824a70b3113c3dd350ff9249a",
"build_date" : "2025-01-13T13:20:37.908330789Z",
"build_snapshot" : true,
"lucene_version" : "10.0.0",
"minimum_wire_compatibility_version" : "8.18.0",
"minimum_index_compatibility_version" : "8.0.0"
},
"tagline" : "You Know, for Search"
}
headers:
Content-Length:
- "547"
Content-Type:
- application/json
X-Elastic-Product:
- Elasticsearch
status: 200 OK
code: 200
duration: 3.494911ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
transfer_encoding: []
trailer: {}
host: ""
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Authorization:
- Basic ZWxhc3RpYzpjaGFuZ2VtZQ==
User-Agent:
- go-elasticsearch/7.17.10 (linux amd64; Go 1.23.4)
X-Elastic-Client-Meta:
- es=7.17.10,go=1.23.4,t=7.17.10,hc=1.23.4
url: https://127.0.0.1:9200/
method: GET
response:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
transfer_encoding: []
trailer: {}
content_length: 547
uncompressed: false
body: |
{
"name" : "da1c4d2b1379",
"cluster_name" : "elasticsearch",
"cluster_uuid" : "KPcvXrW1Rnega2GT0sn3mg",
"version" : {
"number" : "9.0.0-SNAPSHOT",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "873dc52360a9265824a70b3113c3dd350ff9249a",
"build_date" : "2025-01-13T13:20:37.908330789Z",
"build_snapshot" : true,
"lucene_version" : "10.0.0",
"minimum_wire_compatibility_version" : "8.18.0",
"minimum_index_compatibility_version" : "8.0.0"
},
"tagline" : "You Know, for Search"
}
headers:
Content-Length:
- "547"
Content-Type:
- application/json
X-Elastic-Product:
- Elasticsearch
status: 200 OK
code: 200
duration: 474.3µs
128 changes: 128 additions & 0 deletions internal/fleetserver/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package fleetserver

import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"

"github.com/elastic/elastic-package/internal/certs"
"github.com/elastic/elastic-package/internal/logger"
)

// Client is a client for Fleet Server API. This API only supports authentication with API
// keys, though some endpoints are also available without any authentication.
type Client struct {
address string
apiKey string

certificateAuthority string
tlSkipVerify bool

http *http.Client
httpClientSetup func(*http.Client) *http.Client
}

type ClientOption func(*Client)

func NewClient(address string, opts ...ClientOption) (*Client, error) {
client := Client{
address: address,
}

for _, opt := range opts {
opt(&client)
}

httpClient, err := client.httpClient()
if err != nil {
return nil, fmt.Errorf("cannot create HTTP client: %w", err)
}
client.http = httpClient
return &client, nil
}

// APIKey option sets the API key to be used by the client for authentication.
func APIKey(apiKey string) ClientOption {
return func(c *Client) {
c.apiKey = apiKey
}
}

// TLSSkipVerify option disables TLS verification.
func TLSSkipVerify() ClientOption {
return func(c *Client) {
c.tlSkipVerify = true
}
}

// CertificateAuthority sets the certificate authority to be used by the client.
func CertificateAuthority(certificateAuthority string) ClientOption {
return func(c *Client) {
c.certificateAuthority = certificateAuthority
}
}

// HTTPClientSetup adds an initializing function for the http client.
func HTTPClientSetup(setup func(*http.Client) *http.Client) ClientOption {
return func(c *Client) {
c.httpClientSetup = setup
}
}

func (c *Client) httpClient() (*http.Client, error) {
client := &http.Client{}
if c.tlSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
} else if c.certificateAuthority != "" {
rootCAs, err := certs.SystemPoolWithCACertificate(c.certificateAuthority)
if err != nil {
return nil, fmt.Errorf("reading CA certificate: %w", err)
}
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: rootCAs},
}
}

if c.httpClientSetup != nil {
client = c.httpClientSetup(client)
}

return client, nil
}

func (c *Client) httpRequest(ctx context.Context, method, resourcePath string, reqBody io.Reader) (*http.Request, error) {
base, err := url.Parse(c.address)
if err != nil {
return nil, fmt.Errorf("could not create base URL from host: %v: %w", c.address, err)
}

rel, err := url.Parse(resourcePath)
if err != nil {
return nil, fmt.Errorf("could not create relative URL from resource path: %v: %w", resourcePath, err)
}

u := base.JoinPath(rel.EscapedPath())
u.RawQuery = rel.RawQuery

logger.Debugf("%s %s", method, u)

req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
return nil, fmt.Errorf("could not create %v request to Fleet Server API resource: %s: %w", method, resourcePath, err)
}

if c.apiKey != "" {
req.Header.Set("Authorization", "ApiKey "+c.apiKey)
}

return req, nil
}
56 changes: 56 additions & 0 deletions internal/fleetserver/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package fleetserver

import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"

"github.com/elastic/elastic-package/internal/logger"
)

type Status struct {
Name string `json:"name"`
Status string `json:"status"`

// Version is only present if client is authenticated.
Version struct {
Number string `json:"number"`
} `json:"version"`
}

func (c *Client) Status(ctx context.Context) (*Status, error) {
statusURL, err := url.JoinPath(c.address, "/api/status")
if err != nil {
return nil, fmt.Errorf("could not build URL: %w", err)
}
logger.Debugf("GET %s", statusURL)
req, err := c.httpRequest(ctx, "GET", statusURL, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed (url: %s): %w", statusURL, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status code %v", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var status Status
err = json.Unmarshal(body, &status)
if err != nil {
return nil, fmt.Errorf("failed to parse response body: %w", err)
}

return &status, nil
}
Loading