Skip to content

Commit

Permalink
Add a config endpoint on a new api server (#21025)
Browse files Browse the repository at this point in the history
Add a config endpoint on a new api server
  • Loading branch information
pgimalac authored Dec 5, 2023
1 parent 9836156 commit 766196e
Show file tree
Hide file tree
Showing 11 changed files with 613 additions and 152 deletions.
118 changes: 118 additions & 0 deletions cmd/agent/api/internal/config/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

// Package config defines the config endpoint of the IPC API Server.
package config

import (
"encoding/json"
"expvar"
"fmt"
"net/http"

gorilla "github.com/gorilla/mux"

"github.com/DataDog/datadog-agent/pkg/config"
"github.com/DataDog/datadog-agent/pkg/util/log"
)

type authorizedSet map[string]struct{}

var authorizedConfigPathsCore = authorizedSet{
"api_key": {},
}

type configEndpoint struct {
cfg config.Reader
authorizedConfigPaths authorizedSet

// runtime metrics about the config endpoint usage
expvars *expvar.Map
successExpvar expvar.Map
unauthorizedExpvar expvar.Map
unsetExpvar expvar.Map
failedExpvar expvar.Map
}

func (c *configEndpoint) serveHTTP(w http.ResponseWriter, r *http.Request) {
body, statusCode, err := c.getConfigValueAsJSON(r)
if err != nil {
http.Error(w, err.Error(), statusCode)
return
}

w.WriteHeader(statusCode)
_, err = w.Write(body)
if err != nil {
log.Warnf("config endpoint: could not write response body: %v", err)
}
}

// GetConfigEndpointMuxCore builds and returns the mux for the config endpoint with default values
// for the core agent
func GetConfigEndpointMuxCore() *gorilla.Router {
return GetConfigEndpointMux(config.Datadog, authorizedConfigPathsCore, "core")
}

// GetConfigEndpointMux builds and returns the mux for the config endpoint, with the given config,
// authorized paths, and expvar namespace
func GetConfigEndpointMux(cfg config.Reader, authorizedConfigPaths authorizedSet, expvarNamespace string) *gorilla.Router {
mux, _ := getConfigEndpoint(cfg, authorizedConfigPaths, expvarNamespace)
return mux
}

// getConfigEndpoint builds and returns the mux and the endpoint state.
func getConfigEndpoint(cfg config.Reader, authorizedConfigPaths authorizedSet, expvarNamespace string) (*gorilla.Router, *configEndpoint) {
configEndpoint := &configEndpoint{
cfg: cfg,
authorizedConfigPaths: authorizedConfigPaths,
expvars: expvar.NewMap(expvarNamespace + "_config_endpoint"),
}

for name, expv := range map[string]*expvar.Map{
"success": &configEndpoint.successExpvar,
"unauthorized": &configEndpoint.unauthorizedExpvar,
"unset": &configEndpoint.unsetExpvar,
"failed": &configEndpoint.failedExpvar,
} {
configEndpoint.expvars.Set(name, expv)
}

configEndpointHandler := http.HandlerFunc(configEndpoint.serveHTTP)
configEndpointMux := gorilla.NewRouter()
configEndpointMux.HandleFunc("/", configEndpointHandler).Methods("GET")
configEndpointMux.HandleFunc("/{path}", configEndpointHandler).Methods("GET")

return configEndpointMux, configEndpoint
}

// returns the marshalled JSON value of the config path requested
// or an error and http status code in case of failure
func (c *configEndpoint) getConfigValueAsJSON(r *http.Request) ([]byte, int, error) {
vars := gorilla.Vars(r)
path := vars["path"]

if _, ok := c.authorizedConfigPaths[path]; !ok {
c.unauthorizedExpvar.Add(path, 1)
log.Warnf("config endpoint received a request from '%s' for config '%s' which is not allowed", r.RemoteAddr, path)
return nil, http.StatusForbidden, fmt.Errorf("querying config value '%s' is not allowed", path)
}

log.Debug("config endpoint received a request from '%s' for config '%s'", r.RemoteAddr, path)
value := c.cfg.Get(path)
if value == nil {
c.unsetExpvar.Add(path, 1)
return nil, http.StatusNotFound, fmt.Errorf("no runtime setting found for %s", path)
}

body, err := json.Marshal(value)
if err != nil {
c.failedExpvar.Add(path, 1)
return nil, http.StatusInternalServerError, fmt.Errorf("could not marshal config value of '%s': %v", path, err)
}

c.successExpvar.Add(path, 1)
return body, http.StatusOK, nil
}
146 changes: 146 additions & 0 deletions cmd/agent/api/internal/config/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

package config

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/DataDog/datadog-agent/pkg/config"
)

type testCase struct {
name string
authorized bool
existing bool
expectedStatus int
}

type expvals struct {
Success map[string]int `json:"success"`
Failed map[string]int `json:"failed"`
Unauthorized map[string]int `json:"unauthorized"`
Unset map[string]int `json:"unset"`
}

func testConfigValue(t *testing.T, configEndpoint *configEndpoint, server *httptest.Server, configName string, expectedStatus int) {
t.Helper()

beforeVars := getExpvals(t, configEndpoint)
resp, err := server.Client().Get(server.URL + "/" + configName)
require.NoError(t, err)
defer resp.Body.Close()

afterVars := getExpvals(t, configEndpoint)
checkExpvars(t, beforeVars, afterVars, configName, expectedStatus)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

require.Equal(t, expectedStatus, resp.StatusCode, string(body))

if resp.StatusCode != http.StatusOK {
return
}

var configValue interface{}
err = json.Unmarshal(body, &configValue)
require.NoError(t, err)

require.EqualValues(t, configEndpoint.cfg.Get(configName), configValue)
}

func TestConfigEndpoint(t *testing.T) {
t.Run("core_config", func(t *testing.T) {
cfg, server, configEndpoint := getConfigServer(t, authorizedConfigPathsCore)
for configName := range authorizedConfigPathsCore {
var expectedStatus int
if cfg.IsSet(configName) {
expectedStatus = http.StatusOK
} else {
expectedStatus = http.StatusNotFound
}
testConfigValue(t, configEndpoint, server, configName, expectedStatus)
}
})

for _, testCase := range []testCase{
{"authorized_existing_config", true, true, http.StatusOK},
{"authorized_missing_config", true, false, http.StatusNotFound},
{"unauthorized_existing_config", false, true, http.StatusForbidden},
{"unauthorized_missing_config", false, false, http.StatusForbidden},
} {
t.Run(testCase.name, func(t *testing.T) {
configName := "my.config.value"
authorizedConfigPaths := authorizedSet{}
if testCase.authorized {
authorizedConfigPaths[configName] = struct{}{}
}
cfg, server, configEndpoint := getConfigServer(t, authorizedConfigPaths)
if testCase.existing {
cfg.SetWithoutSource(configName, "some_value")
}
testConfigValue(t, configEndpoint, server, configName, testCase.expectedStatus)
})
}

t.Run("authorized_not_marshallable", func(t *testing.T) {
configName := "my.config.value"
cfg, server, configEndpoint := getConfigServer(t, authorizedSet{configName: {}})
cfg.SetWithoutSource(configName, make(chan int))
testConfigValue(t, configEndpoint, server, configName, http.StatusInternalServerError)
})
}

func checkExpvars(t *testing.T, beforeVars, afterVars expvals, configName string, expectedStatus int) {
t.Helper()

switch expectedStatus {
case http.StatusOK:
beforeVars.Success[configName]++
case http.StatusNotFound:
beforeVars.Unset[configName]++
case http.StatusForbidden:
beforeVars.Unauthorized[configName]++
case http.StatusInternalServerError:
beforeVars.Failed[configName]++
default:
t.Fatalf("unexpected status: %d", expectedStatus)
}

require.EqualValues(t, beforeVars, afterVars)
}

func getConfigServer(t *testing.T, authorizedConfigPaths map[string]struct{}) (*config.MockConfig, *httptest.Server, *configEndpoint) {
t.Helper()

cfg := config.Mock(t)
configEndpointMux, configEndpoint := getConfigEndpoint(cfg, authorizedConfigPaths, t.Name())
server := httptest.NewServer(configEndpointMux)
t.Cleanup(server.Close)

return cfg, server, configEndpoint
}

func getExpvals(t *testing.T, configEndpoint *configEndpoint) expvals {
t.Helper()

vars := expvals{}
// error on unknown fields
dec := json.NewDecoder(strings.NewReader(configEndpoint.expvars.String()))
dec.DisallowUnknownFields()
err := dec.Decode(&vars)
require.NoError(t, err)
require.False(t, dec.More())

return vars
}
20 changes: 15 additions & 5 deletions cmd/agent/api/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package api
import (
"fmt"
"net"
"strconv"

"github.com/DataDog/datadog-agent/pkg/config"
)
Expand All @@ -22,10 +23,19 @@ func getIPCAddressPort() (string, error) {
}

// getListener returns a listening connection
func getListener() (net.Listener, error) {
address, err := getIPCAddressPort()
if err != nil {
return nil, err
}
func getListener(address string) (net.Listener, error) {
return net.Listen("tcp", address)
}

// returns whether the IPC server is enabled, and if so its host and host:port
func getIPCServerAddressPort() (string, string, bool) {
ipcServerPort := config.Datadog.GetInt("agent_ipc_port")
if ipcServerPort == 0 {
return "", "", false
}

ipcServerHost := config.Datadog.GetString("agent_ipc_host")
ipcServerHostPort := net.JoinHostPort(ipcServerHost, strconv.Itoa(ipcServerPort))

return ipcServerHost, ipcServerHostPort, true
}
40 changes: 40 additions & 0 deletions cmd/agent/api/listener_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

package api

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/DataDog/datadog-agent/pkg/config"
)

func TestGetIPCServerAddressPort(t *testing.T) {
t.Run("default", func(t *testing.T) {
config.Mock(t)
_, _, enabled := getIPCServerAddressPort()
require.False(t, enabled)
})

t.Run("enabled", func(t *testing.T) {
cfg := config.Mock(t)
cfg.SetWithoutSource("agent_ipc_port", 1234)

host, hostPort, enabled := getIPCServerAddressPort()
require.Equal(t, "localhost", host)
require.Equal(t, "localhost:1234", hostPort)
require.True(t, enabled)
})

t.Run("disabled", func(t *testing.T) {
cfg := config.Mock(t)
cfg.SetWithoutSource("agent_ipc_port", 0)

_, _, enabled := getIPCServerAddressPort()
require.False(t, enabled)
})
}
Loading

0 comments on commit 766196e

Please sign in to comment.