Skip to content
Draft
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
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ module github.com/micromize-dev/micromize
go 1.25.5

require (
github.com/cilium/ebpf v0.20.0
github.com/docker/cli v29.2.0+incompatible
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8
github.com/inspektor-gadget/inspektor-gadget v0.49.1
github.com/opencontainers/image-spec v1.1.1
github.com/quay/claircore v1.5.45
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
golang.org/x/sync v0.19.0
oras.land/oras-go/v2 v2.6.0
)

Expand All @@ -21,7 +26,6 @@ require (
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cilium/ebpf v0.20.0 // indirect
github.com/cloudflare/cbpfc v0.0.0-20240920015331-ff978e94500b // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/containerd v1.7.30 // indirect
Expand All @@ -38,7 +42,6 @@ require (
github.com/cyphar/filepath-securejoin v0.5.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-connections v0.6.0 // indirect
Expand All @@ -62,7 +65,6 @@ require (
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
Expand Down Expand Up @@ -98,7 +100,6 @@ require (
github.com/notaryproject/notation-plugin-framework-go v1.0.0 // indirect
github.com/notaryproject/tspclient-go v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect
Expand Down Expand Up @@ -139,7 +140,6 @@ require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
Expand Down
172 changes: 139 additions & 33 deletions internal/operators/operators.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func NewImaOperator() igoperators.DataOperator {
slog.Debug("Creating IMA operator")
opPriority := math.MaxInt
sbomFetcher := sbom.NewFetcher()
innerMaps := &sync.Map{} // mntns_id -> *ebpf.Map (for cleanup)
digestState := newDigestState()

operatorOptions := []simple.Option{
simple.WithPriority(opPriority),
Expand Down Expand Up @@ -103,9 +103,9 @@ func NewImaOperator() igoperators.DataOperator {
}
switch eventType {
case "CREATED":
handleContainerCreated(ctx, gadgetCtx, sbomFetcher, innerMaps, containerConfigField, containerIDField, mntnsIDField, data)
case "REMOVED":
handleContainerRemoved(gadgetCtx, innerMaps, mntnsIDField, data)
handleContainerCreated(ctx, gadgetCtx, sbomFetcher, digestState, containerConfigField, containerIDField, mntnsIDField, data)
case "DELETED":
handleContainerRemoved(gadgetCtx, digestState, mntnsIDField, data)
}
return nil
}, opPriority); err != nil {
Expand All @@ -117,7 +117,28 @@ func NewImaOperator() igoperators.DataOperator {
return simple.New("imaOperator", operatorOptions...)
}

func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetContext, fetcher *sbom.Fetcher, innerMaps *sync.Map, configField datasource.FieldAccessor, containerIDField datasource.FieldAccessor, mntnsIDField datasource.FieldAccessor, data datasource.Data) {
// digestMapEntry tracks a shared inner BPF map for a given image digest.
type digestMapEntry struct {
innerMap *ebpf.Map
refCount int
}

// digestTracker manages the mapping between containers (mntns_id) and their
// image digests, with reference counting for shared inner BPF maps.
type digestTracker struct {
mu sync.Mutex
digestEntries map[string]*digestMapEntry // digest string → entry
containerDigests map[uint64]string // mntns_id → digest string
}

func newDigestState() *digestTracker {
return &digestTracker{
digestEntries: make(map[string]*digestMapEntry),
containerDigests: make(map[uint64]string),
}
}

func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetContext, fetcher *sbom.Fetcher, dt *digestTracker, configField datasource.FieldAccessor, containerIDField datasource.FieldAccessor, mntnsIDField datasource.FieldAccessor, data datasource.Data) {
ociConfig, err := configField.String(data)
if err != nil {
slog.Debug("Failed to read container_config field", "error", err)
Expand All @@ -139,29 +160,74 @@ func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetCon

imageRef = sbom.NormalizeImageRef(imageRef)

if mntnsIDField == nil {
slog.Debug("mntns_id field not available, cannot populate BPF maps")
return
}

mntnsID, err := mntnsIDField.Uint64(data)
if err != nil {
slog.Error("Failed to read mntns_id field", "error", err)
return
}

// Resolve image digest (lightweight HEAD request).
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

sbomData, err := fetcher.FetchForImage(fetchCtx, imageRef)
digest, err := sbom.ResolveDigest(fetchCtx, imageRef)
if err != nil {
slog.Error("Failed to resolve image digest", "image", imageRef, "error", err)
return
}

// Check if we already have an inner map for this digest (another
// container with the same image is already running).
dt.mu.Lock()
if entry, ok := dt.digestEntries[digest]; ok {
entry.refCount++
dt.containerDigests[mntnsID] = digest
innerMap := entry.innerMap
dt.mu.Unlock()

// Reuse the same inner map FD in expected_hashes for this mntns_id.
insertSharedInnerMap(gadgetCtx, mntnsID, innerMap)
slog.Debug("Reusing existing inner map", "image", imageRef, "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount)
return
}
dt.mu.Unlock()

// First container with this digest — fetch and parse the SBOM.
// Use FetchForDigest to avoid a redundant HEAD request.
files, err := fetcher.FetchForDigest(fetchCtx, imageRef, digest)
if err != nil {
slog.Error("Failed to fetch SBOM", "error", err)
return
}
if sbomData != nil {
slog.Debug("SBOM fetched for container image", "image", imageRef, "size", len(sbomData))

files, err := sbom.ParseFiles(sbomData)
if err != nil {
slog.Error("Failed to parse SBOM files", "error", err)
return
}
if len(files) > 0 {
slog.Debug("SBOM fetched for container image", "image", imageRef, "digest", digest, "files", len(files))
for _, f := range files {
slog.Debug("SBOM binary file", "image", imageRef, "file", f.FileName, "sha256", f.SHA256)
}

if mntnsIDField != nil && len(files) > 0 {
populateExpectedHashes(gadgetCtx, innerMaps, mntnsIDField, data, files)
// Re-check under lock: another goroutine may have created the
// entry while we were fetching.
dt.mu.Lock()
if entry, ok := dt.digestEntries[digest]; ok {
entry.refCount++
dt.containerDigests[mntnsID] = digest
innerMap := entry.innerMap
dt.mu.Unlock()

insertSharedInnerMap(gadgetCtx, mntnsID, innerMap)
slog.Debug("Reusing inner map (created during fetch)", "image", imageRef, "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount)
return
}
// Hold the lock through populateExpectedHashes to prevent
// two goroutines from both creating inner maps for the same digest.
populateExpectedHashes(gadgetCtx, dt, mntnsID, digest, files)
dt.mu.Unlock()
}
}

Expand All @@ -173,7 +239,7 @@ const (
maxFilepathLen = 64
)

func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync.Map, mntnsIDField datasource.FieldAccessor, data datasource.Data, files []sbom.FileInfo) {
func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, dt *digestTracker, mntnsID uint64, digest string, files []sbom.FileInfo) {
outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName)
if !ok {
slog.Debug("expected_hashes map not available in gadget context, skipping map population")
Expand All @@ -186,13 +252,7 @@ func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync
return
}

mntnsID, err := mntnsIDField.Uint64(data)
if err != nil {
slog.Error("Failed to read mntns_id field", "error", err)
return
}

// Create a new inner map for this mount namespace
// Create a new inner map for this digest
innerMapSpec := &ebpf.MapSpec{
Type: ebpf.Hash,
KeySize: uint32(maxFilepathLen),
Expand Down Expand Up @@ -241,20 +301,41 @@ func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync
}
}

// Insert the inner map into the outer map keyed by mntns_id
// Insert the inner map into expected_hashes keyed by mntns_id
if err := outerMap.Put(mntnsID, uint32(innerMap.FD())); err != nil {
slog.Error("Failed to insert inner map into expected_hashes", "mntns_id", mntnsID, "error", err)
innerMap.Close()
return
}

// Track the inner map for cleanup on container removal
innerMaps.Store(mntnsID, innerMap)
// Track the inner map by digest for sharing with future containers.
// Caller holds dt.mu.
dt.digestEntries[digest] = &digestMapEntry{innerMap: innerMap, refCount: 1}
dt.containerDigests[mntnsID] = digest

slog.Debug("Populated expected_hashes map", "mntns_id", mntnsID, "entries", len(files))
slog.Debug("Populated expected_hashes map", "digest", digest, "mntns_id", mntnsID, "entries", len(files))
}

func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync.Map, mntnsIDField datasource.FieldAccessor, data datasource.Data) {
// insertSharedInnerMap inserts an existing inner map into expected_hashes
// for a new container that shares the same image digest.
func insertSharedInnerMap(gadgetCtx igoperators.GadgetContext, mntnsID uint64, innerMap *ebpf.Map) {
outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName)
if !ok {
slog.Debug("expected_hashes map not available in gadget context")
return
}
outerMap, ok := outerMapVar.(*ebpf.Map)
if !ok || outerMap == nil {
slog.Debug("expected_hashes map is not a valid *ebpf.Map")
return
}

if err := outerMap.Put(mntnsID, uint32(innerMap.FD())); err != nil {
slog.Error("Failed to insert shared inner map into expected_hashes", "mntns_id", mntnsID, "error", err)
}
}

func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, dt *digestTracker, mntnsIDField datasource.FieldAccessor, data datasource.Data) {
if mntnsIDField == nil {
slog.Debug("mntns_id field not available, cannot clean up expected_hashes for removed container")
return
Expand All @@ -266,6 +347,7 @@ func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync
return
}

// Remove mntns_id from expected_hashes BPF map
outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName)
if !ok {
return
Expand All @@ -279,13 +361,37 @@ func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync
slog.Debug("Failed to delete entry from expected_hashes", "mntns_id", mntnsID, "error", err)
}

if val, loaded := innerMaps.LoadAndDelete(mntnsID); loaded {
if m, ok := val.(*ebpf.Map); ok && m != nil {
m.Close()
}
// Decrement ref count; close inner map when last container is removed
dt.mu.Lock()
digest, ok := dt.containerDigests[mntnsID]
if !ok {
dt.mu.Unlock()
return
}
delete(dt.containerDigests, mntnsID)

entry, exists := dt.digestEntries[digest]
if !exists {
dt.mu.Unlock()
return
}

entry.refCount--
if entry.refCount > 0 {
dt.mu.Unlock()
slog.Debug("Decremented digest refCount", "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount)
return
}

// Last container using this digest — clean up the inner map.
delete(dt.digestEntries, digest)
dt.mu.Unlock()

if entry.innerMap != nil {
entry.innerMap.Close()
}

slog.Info("Cleaned up expected_hashes for removed container", "mntns_id", mntnsID)
slog.Info("Cleaned up shared inner map for last container", "digest", digest, "mntns_id", mntnsID)
}

// Event type constants matching include/micromize/event_types.h
Expand Down
68 changes: 68 additions & 0 deletions internal/operators/operators_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright The micromize authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package operators

import (
"testing"
)

func TestDigestTracker_RefCounting(t *testing.T) {
dt := newDigestState()
digest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

// Simulate first container
dt.mu.Lock()
dt.digestEntries[digest] = &digestMapEntry{innerMap: nil, refCount: 1}
dt.containerDigests[1000] = digest
dt.mu.Unlock()

// Simulate second container with same digest
dt.mu.Lock()
entry := dt.digestEntries[digest]
entry.refCount++
dt.containerDigests[2000] = digest
dt.mu.Unlock()

if entry.refCount != 2 {
t.Fatalf("expected refCount 2, got %d", entry.refCount)
}

// Remove first container
dt.mu.Lock()
d, ok := dt.containerDigests[1000]
if !ok || d != digest {
t.Fatal("container 1000 not found in containerDigests")
}
delete(dt.containerDigests, 1000)
dt.digestEntries[digest].refCount--
dt.mu.Unlock()

if dt.digestEntries[digest].refCount != 1 {
t.Fatalf("expected refCount 1, got %d", dt.digestEntries[digest].refCount)
}

// Remove second container
dt.mu.Lock()
delete(dt.containerDigests, 2000)
dt.digestEntries[digest].refCount--
if dt.digestEntries[digest].refCount == 0 {
delete(dt.digestEntries, digest)
}
dt.mu.Unlock()

if _, exists := dt.digestEntries[digest]; exists {
t.Fatal("expected digest entry to be removed after last container")
}
}
Loading