Skip to content
Open
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
9 changes: 9 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,15 @@ func main() {
ruleBindingCache.AddNotifier(&ruleBindingNotify)

cpc := containerprofilecache.NewContainerProfileCache(cfg, storageClient, k8sObjectCache, metricsProvider)
if cfg.HostMonitoringEnabled {
cpc.SetHostLabelSource(func(ctx context.Context) (map[string]string, error) {
node, err := k8sClient.GetKubernetesClient().CoreV1().Nodes().Get(ctx, cfg.NodeName, metav1.GetOptions{})
if err != nil {
return nil, err
}
return node.Labels, nil
})
}
cpc.Start(ctx)
if cpm, ok := containerProfileManager.(*containerprofilemanagerv1.ContainerProfileManager); ok {
cpm.SetCompletionNotifier(cpc)
Expand Down
48 changes: 48 additions & 0 deletions docs/features/host-profile-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Host profile binding

In Kubernetes, enable `hostMonitoringEnabled` and
`applicationProfileServiceEnabled` to learn the host's ContainerProfile. Enable
`runtimeDetectionEnabled` to evaluate rules against it. Profiles are stored in
the node-agent deployment namespace.

To use an authored ContainerProfile for a host, create it in that namespace and
label the Node running the agent:

```sh
kubectl label node <node-name> kubescape.io/user-defined-profile=<profile-name> --overwrite
```

The profile cache reads the Node label at registration and on its refresh ticks.
Changing the label selects a different authored profile; removing it returns to
the learned profile. A failed Node lookup retains the last observed binding;
it does not count as label removal. The agent needs `get` permission on Nodes,
which the test chart already grants.

Host network observations are learned for the synthetic `host` container.
Network tracing must also be enabled (`networkServiceEnabled` or
`runtimeDetectionEnabled`, with `dNetwork` disabled).
Ordinary Pods using `hostNetwork` remain excluded from network profile learning.
The test chart enables host context for R0001, R0002, R0003, R0004 and R0040.
Other rule deployments must opt those rules into `context:host` themselves.
Required-profile rules remain suppressed while their baseline is unavailable.

## Identity and configuration compatibility

Binding a Node label does not rename existing profiles. The shared host identity
continues to use the existing synthetic `host` namespace and
`wlid://cluster-unknown/namespace-host/host-<hostID>`; the Kubernetes storage
namespace is the agent deployment namespace. See
[host monitoring completion](host-monitoring-completion.md) for the identity
builder and learning lifecycle.

A Node identity with the actual cluster name, node artifact labels and a
hostname selector would change profile keys and downstream lookup/cleanup
contracts. Such a migration needs coordinated storage and consumer support,
plus an explicit policy for existing baselines and node replacement. It should
not be introduced by changing the builder in place.

There is no separate `hostProfileServiceEnabled` switch. The existing host
monitoring and application profiling switches continue to control collection.
An independent host profiling switch could later provide host/container cost
control, but its default must preserve existing host learning, and disabling
learning must have defined behavior for bound profiles and required rules.
9 changes: 5 additions & 4 deletions pkg/containerprofilemanager/v1/event_reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/DmitriyVTitov/size"
"github.com/armosec/armoapi-go/armotypes"
mapset "github.com/deckarep/golang-set/v2"
"github.com/goradd/maps"
"github.com/kubescape/go-logger"
Expand Down Expand Up @@ -372,7 +373,7 @@ func (cpm *ContainerProfileManager) ReportIdentifiedCallStack(containerID string

// ReportNetworkEvent reports a network event for a container
func (cpm *ContainerProfileManager) ReportNetworkEvent(containerID string, event utils.NetworkEvent) {
if !cpm.isValidNetworkEvent(event) {
if !cpm.isValidNetworkEvent(containerID, event) {
return
}

Expand Down Expand Up @@ -468,7 +469,7 @@ func (cpm *ContainerProfileManager) ReportSyscalls(containerID string, syscalls
}

// isValidNetworkEvent checks if the network event is valid for processing
func (cpm *ContainerProfileManager) isValidNetworkEvent(event utils.NetworkEvent) bool {
func (cpm *ContainerProfileManager) isValidNetworkEvent(containerID string, event utils.NetworkEvent) bool {
pktType := event.GetPktType()
// Unknown type, shouldn't happen
if pktType != utils.HostPktType && pktType != utils.OutgoingPktType {
Expand All @@ -481,8 +482,8 @@ func (cpm *ContainerProfileManager) isValidNetworkEvent(event utils.NetworkEvent
return false
}

// Ignore host netns
if event.GetHostNetwork() {
// Learn host traffic only in the host profile, not in profiles for hostNetwork pods.
if event.GetHostNetwork() && containerID != armotypes.HostContainerID {
return false
}

Expand Down
62 changes: 62 additions & 0 deletions pkg/containerprofilemanager/v1/host_network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package containerprofilemanager

import (
"testing"

"github.com/armosec/armoapi-go/armotypes"
"github.com/inspektor-gadget/inspektor-gadget/pkg/types"
"github.com/kubescape/node-agent/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReportNetworkEventHostNetwork(t *testing.T) {
for _, tc := range []struct {
name string
containerID string
hostNetwork bool
pktType string
podHostIP string
wantStored bool
}{
{name: "host egress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.OutgoingPktType, wantStored: true},
{name: "host ingress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.HostPktType, wantStored: true},
{name: "hostNetwork pod egress", containerID: "hostnet-pod", hostNetwork: true, pktType: utils.OutgoingPktType},
{name: "hostNetwork pod ingress", containerID: "hostnet-pod", hostNetwork: true, pktType: utils.HostPktType},
{name: "ordinary pod", containerID: "pod", pktType: utils.OutgoingPktType, wantStored: true},
{name: "host unknown packet type", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: "UNKNOWN"},
{name: "host localhost ingress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.HostPktType, podHostIP: "10.0.0.5"},
} {
t.Run(tc.name, func(t *testing.T) {
cpm, entry := newTestManager(t, tc.containerID)
event := &utils.StructEvent{
DstEndpoint: types.L3Endpoint{Addr: "10.0.0.5"},
DstPort: 8080,
Proto: "tcp",
PktType: tc.pktType,
PodHostIP: tc.podHostIP,
HostNetwork: tc.hostNetwork,
}

cpm.ReportNetworkEvent(tc.containerID, event)

if !tc.wantStored {
assert.Nil(t, entry.data.networks)
assert.Zero(t, entry.data.size.Load())
return
}
require.NotNil(t, entry.data.networks)
require.Equal(t, 1, entry.data.networks.Cardinality())
assert.True(t, entry.data.networks.Contains(NetworkEvent{
Port: 8080,
Protocol: "tcp",
PktType: tc.pktType,
Destination: Destination{IPAddress: "10.0.0.5"},
}))
assert.Positive(t, entry.data.size.Load())

// Accepted host observations must reach the same flush set as pod observations.
assert.Equal(t, entry.data.networks, entry.data.networkEventsForFlush(false))
})
}
}
16 changes: 15 additions & 1 deletion pkg/containerprofilemanager/v1/host_profile_content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/armosec/armoapi-go/armotypes"
containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection"
"github.com/inspektor-gadget/inspektor-gadget/pkg/types"
"github.com/kubescape/node-agent/pkg/config"
"github.com/kubescape/node-agent/pkg/dnsmanager"
"github.com/kubescape/node-agent/pkg/hostidentity"
Expand All @@ -30,7 +31,7 @@ import (
// TestContainerCallback_HostReachesStorage's (pkg/objectcache/
// containerprofilecache/host_shared_data_test.go): that test only exercises
// the READ/overlay cache path (GetContainerProfile being called at all). This
// test drives real events (exec/open/syscall/capability) through this
// test drives real events (exec/open/syscall/capability/network) through this
// package's WRITE path -- ContainerProfileManager, the component that
// actually assembles and submits the CR -- for the host pseudo-container, and
// asserts on the resulting CR's Spec content.
Expand Down Expand Up @@ -131,6 +132,13 @@ func TestHostContainerProfile_ContentPopulated(t *testing.T) {
})
cpm.ReportSyscalls(armotypes.HostContainerID, []string{"execve", "openat"})
cpm.ReportCapability(armotypes.HostContainerID, "CAP_SYS_ADMIN")
cpm.ReportNetworkEvent(armotypes.HostContainerID, &utils.StructEvent{
DstEndpoint: types.L3Endpoint{Addr: "203.0.113.10"},
DstPort: 443,
Proto: "tcp",
PktType: utils.OutgoingPktType,
HostNetwork: true,
})

// Force an immediate save (mirrors the termination/max-time forced-save
// callers already in monitoring.go) rather than waiting on the real
Expand Down Expand Up @@ -163,4 +171,10 @@ func TestHostContainerProfile_ContentPopulated(t *testing.T) {
assert.NotEmpty(t, cr.Spec.Opens, "host CR must carry real open data, not an empty shell")
assert.NotEmpty(t, cr.Spec.Syscalls, "host CR must carry real syscall data, not an empty shell")
assert.NotEmpty(t, cr.Spec.Capabilities, "host CR must carry real capability data, not an empty shell")
require.Len(t, cr.Spec.Egress, 1, "host network observations must reach the stored profile")
assert.Equal(t, "203.0.113.10", cr.Spec.Egress[0].IPAddress)
require.Len(t, cr.Spec.Egress[0].Ports, 1)
require.NotNil(t, cr.Spec.Egress[0].Ports[0].Port)
assert.Equal(t, int32(443), *cr.Spec.Egress[0].Ports[0].Port)
assert.Equal(t, "tcp", string(cr.Spec.Egress[0].Ports[0].Protocol))
}
48 changes: 47 additions & 1 deletion pkg/objectcache/containerprofilecache/containerprofilecache.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ type CachedContainerProfile struct {
// reconciler to re-fetch on refresh.
UserCPRef *namespacedName

// Retain host metadata for retry when an unbound host has no learned CP yet.
hostRetry *pendingContainer

// CPName is the storage name of the ContainerProfile. Populated at
// addContainer time so the reconciler can re-fetch without re-querying
// shared data (which may have been evicted from K8sObjectCache by then).
Expand Down Expand Up @@ -129,6 +132,9 @@ type ContainerProfileCacheImpl struct {
k8sObjectCache objectcache.K8sObjectCache
metricsManager metricsmanager.MetricsManager

// Installed before Start; nil for sources without Kubernetes Nodes.
hostLabelSource func(context.Context) (map[string]string, error)

reconcileEvery time.Duration
rpcBudget time.Duration

Expand Down Expand Up @@ -198,6 +204,23 @@ func NewContainerProfileCache(cfg config.Config, storageClient storage.ProfileCl
return c
}

// SetHostLabelSource installs the Kubernetes Node label reader before Start or
// ContainerCallback. Errors preserve the current binding; an empty label map
// removes it. Authored host profiles live in the agent's namespace.
func (c *ContainerProfileCacheImpl) SetHostLabelSource(fn func(context.Context) (map[string]string, error)) {
c.hostLabelSource = fn
}

func (c *ContainerProfileCacheImpl) hostProfileLabel(ctx context.Context) (string, error) {
var labels map[string]string
err := c.refreshRPC(ctx, func(ctx context.Context) error {
var err error
labels, err = c.hostLabelSource(ctx)
return err
})
return labels[helpersv1.UserDefinedProfileMetadataKey], err
}

// refreshRPC calls fn with a context bounded by c.rpcBudget, enforcing a
// per-call SLO so a slow API server cannot stall a full reconciler burst.
func (c *ContainerProfileCacheImpl) refreshRPC(ctx context.Context, fn func(context.Context) error) error {
Expand Down Expand Up @@ -400,6 +423,13 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
sharedData *objectcache.WatchedContainerData,
cpName, workloadName string,
) bool {
if utils.IsHostContainer(container) && container.Runtime.ContainerName == "" {
// GetHostAsContainer has no runtime name. Authored sections and later
// refreshes use the synthetic host name without mutating collection data.
containerCopy := *container
containerCopy.Runtime.ContainerName = "host"
container = &containerCopy
}
ns := container.K8s.Namespace

// Fetch consolidated ContainerProfile. The storage server aggregates the
Expand Down Expand Up @@ -435,6 +465,15 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
// drives the reconciler to retry the CP on every tick until it materialises.
var userDefinedCP *v1beta1.ContainerProfile
overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey]
if utils.IsHostContainer(container) && c.hostLabelSource != nil {
var err error
overlayName, err = c.hostProfileLabel(ctx)
if err != nil {
logger.L().Debug("host Node labels unavailable; deferring profile population", helpers.Error(err))
return false
}
hasOverlay = overlayName != ""
Comment thread
matthyx marked this conversation as resolved.
}
// resolvedOverlayName is the ContainerProfile name the label ultimately
// resolves to; it is recorded in entry.UserCPRef so refreshOneEntry re-fetches
// the SAME object every tick. It defaults to the bare label value — the
Expand All @@ -450,6 +489,10 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
// pods). A transient error is NOT a fallback trigger — the CP is left nil
// for this tick and the bare name stays the retry target.
perContainerName := overlayName + "-" + container.Runtime.ContainerName
if utils.IsHostContainer(container) {
// A Node has one host profile; its label names that object directly.
perContainerName = overlayName
}
var userCPErr error
_ = c.refreshRPC(ctx, func(rctx context.Context) error {
userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, perContainerName)
Expand All @@ -458,7 +501,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
switch {
case userCPErr == nil && userDefinedCP != nil:
resolvedOverlayName = perContainerName
case apierrors.IsNotFound(userCPErr):
case apierrors.IsNotFound(userCPErr) && perContainerName != overlayName:
// Fall back to the bare overlay name (single-container convention).
userDefinedCP = nil
var bareErr error
Expand Down Expand Up @@ -586,6 +629,9 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry(
// WorkloadName is the synthesize-name source refreshOneEntry uses when it
// rebuilds an entry whose consolidated CP is not yet in storage.
entry.WorkloadName = workloadName
if utils.IsHostContainer(container) {
entry.hostRetry = &pendingContainer{container: container, sharedData: sharedData, cpName: cpName, workloadName: workloadName}
}

// When the overlay label is set, ALWAYS record UserCPRef so the reconciler
// keeps probing for the user-authored ContainerProfile on every tick — even
Expand Down
Loading
Loading