From f003bee4cafaca84480151072c5dc21c0a59f016 Mon Sep 17 00:00:00 2001 From: louis-cqrl Date: Thu, 16 Jul 2026 16:18:29 +0200 Subject: [PATCH] feat(privateactionrunner): add com.datadoghq.agent bundle with read-only actions Add a new Private Action Runner bundle, com.datadoghq.agent, whose actions operate on the local datadog-agent through its authenticated IPC HTTP API, reusing the same command API the agent's own CLI subcommands use (the pattern established by com.datadoghq.remoteaction.networkconfigmanagement). This first change adds the read-only actions: - getStatus: GET /agent/status (optionally a single section) - getDiagnose: POST /agent/diagnose (runs the agent's diagnose suites) - getConfig: GET /agent/config (full config) or a single key Each action reuses the IPC HTTP client already threaded into the bundle registry for the NCM bundle, ignores any Action Platform connection credential (the target is always the local agent), and returns the agent response as a JSON object. The bundle is registered in both the default and kubeapiserver registries. --- pkg/privateactionrunner/bundles/BUILD.bazel | 1 + .../bundles/agent/BUILD.bazel | 23 +++++ .../bundles/agent/agentapi.go | 74 ++++++++++++++++ .../bundles/agent/config.go | 83 ++++++++++++++++++ .../bundles/agent/diagnose.go | 86 +++++++++++++++++++ .../bundles/agent/entrypoint.go | 33 +++++++ .../bundles/agent/status.go | 74 ++++++++++++++++ pkg/privateactionrunner/bundles/registry.go | 2 + .../bundles/registry_kubeapiserver.go | 2 + 9 files changed, 378 insertions(+) create mode 100644 pkg/privateactionrunner/bundles/agent/BUILD.bazel create mode 100644 pkg/privateactionrunner/bundles/agent/agentapi.go create mode 100644 pkg/privateactionrunner/bundles/agent/config.go create mode 100644 pkg/privateactionrunner/bundles/agent/diagnose.go create mode 100644 pkg/privateactionrunner/bundles/agent/entrypoint.go create mode 100644 pkg/privateactionrunner/bundles/agent/status.go diff --git a/pkg/privateactionrunner/bundles/BUILD.bazel b/pkg/privateactionrunner/bundles/BUILD.bazel index b87b0c6eaa9f..4200fecda0a3 100644 --- a/pkg/privateactionrunner/bundles/BUILD.bazel +++ b/pkg/privateactionrunner/bundles/BUILD.bazel @@ -13,6 +13,7 @@ go_library( "//comp/forwarder/eventplatform/def", "//comp/networkpath/traceroute/def", "//pkg/privateactionrunner/adapters/config", + "//pkg/privateactionrunner/bundles/agent", "//pkg/privateactionrunner/bundles/gitlab/branches", "//pkg/privateactionrunner/bundles/gitlab/commits", "//pkg/privateactionrunner/bundles/gitlab/customattributes", diff --git a/pkg/privateactionrunner/bundles/agent/BUILD.bazel b/pkg/privateactionrunner/bundles/agent/BUILD.bazel new file mode 100644 index 000000000000..45f3c549828e --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "agent", + srcs = [ + "agentapi.go", + "config.go", + "diagnose.go", + "entrypoint.go", + "status.go", + ], + importpath = "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/agent", + visibility = ["//visibility:public"], + deps = [ + "//comp/core/diagnose/def", + "//comp/core/ipc/def", + "//comp/core/ipc/httphelpers", + "//pkg/config/setup", + "//pkg/privateactionrunner/libs/privateconnection", + "//pkg/privateactionrunner/types", + "@in_yaml_go_yaml_v3//:yaml", + ], +) diff --git a/pkg/privateactionrunner/bundles/agent/agentapi.go b/pkg/privateactionrunner/bundles/agent/agentapi.go new file mode 100644 index 000000000000..87770b7fb29b --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/agentapi.go @@ -0,0 +1,74 @@ +// 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 2026-present Datadog, Inc. + +// Package com_datadoghq_agent provides Private Action Runner actions that +// operate on the local datadog-agent through its authenticated IPC HTTP API. +// +// The actions in this bundle run inside the Private Action Runner process, +// which is co-located with the core agent, and reach the agent the same way the +// agent's own CLI subcommands do: over the local HTTPS command API, reusing the +// agent's IPC auth token and certificate. They deliberately ignore any Action +// Platform connection credential — the target is always the local agent. +package com_datadoghq_agent + +import ( + "encoding/json" + "fmt" + "net" + "strconv" + + "go.yaml.in/yaml/v3" + + pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup" +) + +// agentBaseURL returns the base URL (scheme + host + port) of the local agent's +// IPC command API, e.g. "https://127.0.0.1:5001". +func agentBaseURL() (string, error) { + ipcAddress, err := pkgconfigsetup.GetIPCAddress(pkgconfigsetup.Datadog()) + if err != nil { + return "", fmt.Errorf("failed to get agent IPC address: %w", err) + } + port := pkgconfigsetup.Datadog().GetInt("cmd_port") + return "https://" + net.JoinHostPort(ipcAddress, strconv.Itoa(port)), nil +} + +// decodeAgentObject decodes an agent JSON response into a map. Action outputs +// are surfaced to callers as a protobuf struct, which must be a JSON object at +// the top level, so responses that decode to a non-object (or that are not JSON +// at all) are wrapped so the action always returns an object. +func decodeAgentObject(b []byte) map[string]interface{} { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return map[string]interface{}{"raw": string(b)} + } + if obj, ok := v.(map[string]interface{}); ok { + return obj + } + return map[string]interface{}{"value": v} +} + +// decodeAgentValue decodes an agent JSON response into an arbitrary value, +// falling back to the raw string when the body is not valid JSON. +func decodeAgentValue(b []byte) interface{} { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return string(b) + } + return v +} + +// decodeAgentYAML decodes a YAML agent response (such as the full agent +// configuration returned by /agent/config) into a JSON-serializable object. It +// falls back to returning the raw text under a "raw" key when the body is not a +// YAML mapping. yaml/v3 decodes mappings with string keys, so the result +// marshals cleanly to JSON. +func decodeAgentYAML(b []byte) map[string]interface{} { + var v map[string]interface{} + if err := yaml.Unmarshal(b, &v); err != nil || v == nil { + return map[string]interface{}{"raw": string(b)} + } + return v +} diff --git a/pkg/privateactionrunner/bundles/agent/config.go b/pkg/privateactionrunner/bundles/agent/config.go new file mode 100644 index 000000000000..691cbd0cd56a --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/config.go @@ -0,0 +1,83 @@ +// 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 2026-present Datadog, Inc. + +package com_datadoghq_agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + + ipc "github.com/DataDog/datadog-agent/comp/core/ipc/def" + ipchttp "github.com/DataDog/datadog-agent/comp/core/ipc/httphelpers" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/libs/privateconnection" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/types" +) + +// GetConfigHandler handles the getConfig action, returning either the full +// resolved agent configuration or a single configuration value as JSON. +type GetConfigHandler struct { + ipcClient ipc.HTTPClient +} + +// NewGetConfigHandler creates a new GetConfigHandler. +func NewGetConfigHandler(client ipc.HTTPClient) *GetConfigHandler { + return &GetConfigHandler{ipcClient: client} +} + +// GetConfigInputs defines the inputs for the getConfig action. +type GetConfigInputs struct { + // Key optionally selects a single configuration setting (for example + // "log_level"). When empty the full resolved configuration is returned. + Key string `json:"key"` +} + +// Run executes the getConfig action. +func (h *GetConfigHandler) Run( + ctx context.Context, + task *types.Task, + _ *privateconnection.PrivateCredentials, +) (interface{}, error) { + if h.ipcClient == nil { + return nil, errors.New("getConfig: IPC client is not available") + } + + inputs, err := types.ExtractInputs[GetConfigInputs](task) + if err != nil { + return nil, fmt.Errorf("getConfig: failed to parse inputs: %w", err) + } + + base, err := agentBaseURL() + if err != nil { + return nil, fmt.Errorf("getConfig: %w", err) + } + + if inputs.Key != "" { + configURL := base + "/agent/config/" + url.PathEscape(inputs.Key) + resp, err := h.ipcClient.Get(configURL, ipchttp.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("getConfig: request to agent failed: %w", err) + } + // The per-key endpoint responds with {"value": }; surface that + // value directly rather than nesting it under a second "value" field. + var wrapper map[string]interface{} + if jsonErr := json.Unmarshal(resp, &wrapper); jsonErr == nil { + if v, ok := wrapper["value"]; ok { + return map[string]interface{}{"key": inputs.Key, "value": v}, nil + } + } + return map[string]interface{}{"key": inputs.Key, "value": decodeAgentValue(resp)}, nil + } + + // The full-config endpoint returns scrubbed YAML, not JSON. + configURL := base + "/agent/config" + resp, err := h.ipcClient.Get(configURL, ipchttp.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("getConfig: request to agent failed: %w", err) + } + return decodeAgentYAML(resp), nil +} diff --git a/pkg/privateactionrunner/bundles/agent/diagnose.go b/pkg/privateactionrunner/bundles/agent/diagnose.go new file mode 100644 index 000000000000..08ba4d36a438 --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/diagnose.go @@ -0,0 +1,86 @@ +// 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 2026-present Datadog, Inc. + +package com_datadoghq_agent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + diagnose "github.com/DataDog/datadog-agent/comp/core/diagnose/def" + ipc "github.com/DataDog/datadog-agent/comp/core/ipc/def" + ipchttp "github.com/DataDog/datadog-agent/comp/core/ipc/httphelpers" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/libs/privateconnection" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/types" +) + +// GetDiagnoseHandler handles the getDiagnose action, running the agent's +// in-process diagnose suites and returning the result as JSON. +type GetDiagnoseHandler struct { + ipcClient ipc.HTTPClient +} + +// NewGetDiagnoseHandler creates a new GetDiagnoseHandler. +func NewGetDiagnoseHandler(client ipc.HTTPClient) *GetDiagnoseHandler { + return &GetDiagnoseHandler{ipcClient: client} +} + +// GetDiagnoseInputs defines the inputs for the getDiagnose action. +type GetDiagnoseInputs struct { + // Verbose enables verbose diagnosis output. + Verbose bool `json:"verbose"` + // Include optionally restricts the run to the named suites. When empty all + // suites run. Unknown suite names are rejected by the agent. + Include []string `json:"include"` + // Exclude optionally skips the named suites. + Exclude []string `json:"exclude"` +} + +// Run executes the getDiagnose action. +func (h *GetDiagnoseHandler) Run( + ctx context.Context, + task *types.Task, + _ *privateconnection.PrivateCredentials, +) (interface{}, error) { + if h.ipcClient == nil { + return nil, errors.New("getDiagnose: IPC client is not available") + } + + inputs, err := types.ExtractInputs[GetDiagnoseInputs](task) + if err != nil { + return nil, fmt.Errorf("getDiagnose: failed to parse inputs: %w", err) + } + + cfg := diagnose.Config{ + Verbose: inputs.Verbose, + Include: inputs.Include, + Exclude: inputs.Exclude, + } + body, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("getDiagnose: failed to encode diagnose config: %w", err) + } + + base, err := agentBaseURL() + if err != nil { + return nil, fmt.Errorf("getDiagnose: %w", err) + } + diagnoseURL := base + "/agent/diagnose" + + resp, err := h.ipcClient.Post(diagnoseURL, "application/json", bytes.NewBuffer(body), ipchttp.WithContext(ctx)) + if err != nil { + msg := strings.TrimSpace(string(resp)) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("getDiagnose: request to agent failed: %s", msg) + } + + return decodeAgentObject(resp), nil +} diff --git a/pkg/privateactionrunner/bundles/agent/entrypoint.go b/pkg/privateactionrunner/bundles/agent/entrypoint.go new file mode 100644 index 000000000000..1207057dd897 --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/entrypoint.go @@ -0,0 +1,33 @@ +// 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 2026-present Datadog, Inc. + +package com_datadoghq_agent + +import ( + ipc "github.com/DataDog/datadog-agent/comp/core/ipc/def" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/types" +) + +// AgentBundle holds the actions that operate on the local datadog-agent. +type AgentBundle struct { + actions map[string]types.Action +} + +// NewAgent creates a new agent bundle. The provided IPC HTTP client is used to +// reach the local agent's authenticated command API. +func NewAgent(client ipc.HTTPClient) types.Bundle { + return &AgentBundle{ + actions: map[string]types.Action{ + "getStatus": NewGetStatusHandler(client), + "getDiagnose": NewGetDiagnoseHandler(client), + "getConfig": NewGetConfigHandler(client), + }, + } +} + +// GetAction returns the action with the given name. +func (b *AgentBundle) GetAction(name string) types.Action { + return b.actions[name] +} diff --git a/pkg/privateactionrunner/bundles/agent/status.go b/pkg/privateactionrunner/bundles/agent/status.go new file mode 100644 index 000000000000..08c0b0c1ba28 --- /dev/null +++ b/pkg/privateactionrunner/bundles/agent/status.go @@ -0,0 +1,74 @@ +// 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 2026-present Datadog, Inc. + +package com_datadoghq_agent + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + ipc "github.com/DataDog/datadog-agent/comp/core/ipc/def" + ipchttp "github.com/DataDog/datadog-agent/comp/core/ipc/httphelpers" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/libs/privateconnection" + "github.com/DataDog/datadog-agent/pkg/privateactionrunner/types" +) + +// GetStatusHandler handles the getStatus action, returning the local agent's +// status as JSON. +type GetStatusHandler struct { + ipcClient ipc.HTTPClient +} + +// NewGetStatusHandler creates a new GetStatusHandler. +func NewGetStatusHandler(client ipc.HTTPClient) *GetStatusHandler { + return &GetStatusHandler{ipcClient: client} +} + +// GetStatusInputs defines the inputs for the getStatus action. +type GetStatusInputs struct { + // Section optionally restricts the status to a single named section (for + // example "collector"). When empty the full status is returned. + Section string `json:"section"` +} + +// Run executes the getStatus action. +func (h *GetStatusHandler) Run( + ctx context.Context, + task *types.Task, + _ *privateconnection.PrivateCredentials, +) (interface{}, error) { + if h.ipcClient == nil { + return nil, errors.New("getStatus: IPC client is not available") + } + + inputs, err := types.ExtractInputs[GetStatusInputs](task) + if err != nil { + return nil, fmt.Errorf("getStatus: failed to parse inputs: %w", err) + } + + base, err := agentBaseURL() + if err != nil { + return nil, fmt.Errorf("getStatus: %w", err) + } + + path := "/agent/status" + if inputs.Section != "" { + path = "/agent/status/section/" + url.PathEscape(inputs.Section) + } + statusURL := base + path + "?format=json" + + resp, err := h.ipcClient.Get(statusURL, ipchttp.WithContext(ctx)) + if err != nil { + if msg := strings.TrimSpace(string(resp)); msg != "" { + return nil, fmt.Errorf("getStatus: request to agent failed: %s", msg) + } + return nil, fmt.Errorf("getStatus: request to agent failed: %w", err) + } + + return decodeAgentObject(resp), nil +} diff --git a/pkg/privateactionrunner/bundles/registry.go b/pkg/privateactionrunner/bundles/registry.go index 7c71cd6778ab..7c2a1b613c66 100644 --- a/pkg/privateactionrunner/bundles/registry.go +++ b/pkg/privateactionrunner/bundles/registry.go @@ -12,6 +12,7 @@ import ( eventplatform "github.com/DataDog/datadog-agent/comp/forwarder/eventplatform/def" traceroute "github.com/DataDog/datadog-agent/comp/networkpath/traceroute/def" "github.com/DataDog/datadog-agent/pkg/privateactionrunner/adapters/config" + com_datadoghq_agent "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/agent" com_datadoghq_gitlab_branches "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/branches" com_datadoghq_gitlab_commits "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/commits" com_datadoghq_gitlab_customattributes "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/customattributes" @@ -59,6 +60,7 @@ type Registry struct { func NewRegistry(configuration *config.Config, traceroute traceroute.Component, eventPlatform eventplatform.Component, ipcClient ipc.HTTPClient, encryptionStore *encryptioncontext.Store) *Registry { return &Registry{ Bundles: map[string]types.Bundle{ + "com.datadoghq.agent": com_datadoghq_agent.NewAgent(ipcClient), "com.datadoghq.gitlab.branches": com_datadoghq_gitlab_branches.NewGitlabBranches(), "com.datadoghq.gitlab.commits": com_datadoghq_gitlab_commits.NewGitlabCommits(), "com.datadoghq.gitlab.customattributes": com_datadoghq_gitlab_customattributes.NewGitlabCustomAttributes(), diff --git a/pkg/privateactionrunner/bundles/registry_kubeapiserver.go b/pkg/privateactionrunner/bundles/registry_kubeapiserver.go index 86f7108d8528..a9af34dc156e 100644 --- a/pkg/privateactionrunner/bundles/registry_kubeapiserver.go +++ b/pkg/privateactionrunner/bundles/registry_kubeapiserver.go @@ -13,6 +13,7 @@ import ( eventplatform "github.com/DataDog/datadog-agent/comp/forwarder/eventplatform/def" traceroute "github.com/DataDog/datadog-agent/comp/networkpath/traceroute/def" "github.com/DataDog/datadog-agent/pkg/privateactionrunner/adapters/config" + com_datadoghq_agent "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/agent" com_datadoghq_gitlab_branches "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/branches" com_datadoghq_gitlab_commits "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/commits" com_datadoghq_gitlab_customattributes "github.com/DataDog/datadog-agent/pkg/privateactionrunner/bundles/gitlab/customattributes" @@ -59,6 +60,7 @@ type Registry struct { func NewRegistry(configuration *config.Config, traceroute traceroute.Component, eventPlatform eventplatform.Component, ipcClient ipc.HTTPClient, encryptionStore *encryptioncontext.Store) *Registry { return &Registry{ Bundles: map[string]types.Bundle{ + "com.datadoghq.agent": com_datadoghq_agent.NewAgent(ipcClient), "com.datadoghq.remoteaction": com_datadoghq_remoteaction.NewRemoteAction(configuration), "com.datadoghq.remoteaction.internal": com_datadoghq_remoteaction_internal.NewInternal(encryptionStore), "com.datadoghq.remoteaction.networks": com_datadoghq_remoteaction_networks.NewNetworks(traceroute, eventPlatform),