Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce initial wasm support #2818

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open

Conversation

mauriciovasquezbernal
Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal commented May 7, 2024

This PR introduces wasm support to handle events in user space.

With this support, gadgets can implement wasm modules that are able to:

  • Add new data sources
  • Add new fields to existing datasources
  • Change the value of fields

Based on initial POC by @flyth in https://github.com/inspektor-gadget/inspektor-gadget/tree/michael/oci/wasm.

TODO

Issues to open after merging

Testing

Use the trace_exec gadget modified on this PR or follow the hello-world-gadget-wasm guide to learn more about this.

Fixes #2202

Copy link
Member

@flyth flyth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a first pass, looking good so far. Will take another look later on.

I really like the "hello world" folllow-up!

gadgets/trace_dns/program.go Outdated Show resolved Hide resolved
docs/devel/hello-world-gadget-wasm.md Outdated Show resolved Hide resolved
docs/devel/hello-world-gadget-wasm.md Show resolved Hide resolved
docs/reference/wasm.md Outdated Show resolved Hide resolved
docs/reference/wasm.md Outdated Show resolved Hide resolved
pkg/operators/wasm/fields.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Show resolved Hide resolved
@mauriciovasquezbernal
Copy link
Member Author

I think it's ready for review.

  • I added some tests (they depend on API: Support running gadgets from files and memory #2853 as they're run from a file)
  • I'd love to get feedback on the "image/build: Use correct Inspektor Gadget code in in-tree gadgets" commit. I tried different approaches and none convinced me 100%.

Copy link
Member

@blanquicet blanquicet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the introduction of the wasm operator in the first commit. I have a couple of comments but the code is already pretty well structured and clear. It was easy to review it. Thanks!

pkg/operators/wasm/wasm.go Show resolved Hide resolved
Comment on lines +81 to +99
err := i.init(gadgetCtx)
if err != nil {
return fmt.Errorf("initializing wasm: %w", err)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why operator initialization isn't done in the InstantiateImageOperator phase as for ebpf operator?

IMO, it makes no sense to succeed in the instantiation phase but then fail in the prepare phase because we wasn't able to read the program from the image or because the wasm module doesn't export the malloc function. I see the Prepare more like a phase where we prepare complementary parts that the operator will use while running. For instance, for the ebpf operator, we subscribe the converters, create the tchandler, network-tracer and uprobe-tracer, but everything regarding the ebpf operator itself was already initialized before, so we already know the program is valid and can be run.

Additionally, It'll prevent us from carrying the oras targer and image descriptor in wasmOperatorInstance, but just the WASM module already instantiated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not totally clear to me what are different phases for. Let's ask @flyth about this one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somehow thought I had already answered to this, sorry.

This has a short overview of the lifecycle hooks and what should be done when. It's crucial that this operator forwards the init in the instantiate phase, as that is the only phase that will be called for GetGadgetInfo(). It is the phase in which all new data sources and alterations to existing ones need to be done in order for all operators to pick them up. The Prepare() phase is an optional phase that you can use to subscribe to datasources - it should be forwarded to Wasm as well.

i.mod = mod

// We need to call malloc on the guest to pass strings
i.guestMalloc = mod.ExportedFunction("malloc")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Perhaps, it makes more sense to add this function in wasm: Expose field functions to guest commit, as you did for dsCallback.

i.mod = mod

// We need to call malloc on the guest to pass strings
i.guestMalloc = mod.ExportedFunction("malloc")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we should check also here that the module exports init, start and stop functions and don't even instantiate the wasm operator if it doesn't. This reinforce my suggestion to move the init function into InstantiateImageOperator.

pkg/operators/wasm/wasm.go Show resolved Hide resolved
Copy link
Member

@blanquicet blanquicet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more comments from "wasm: Expose gadgetLog function to guest" commit.

pkg/operators/wasm/wasm.go Show resolved Hide resolved
pkg/operators/wasm/helpers.go Outdated Show resolved Hide resolved
pkg/operators/wasm/log.go Show resolved Hide resolved
pkg/operators/wasm/log.go Outdated Show resolved Hide resolved
Comment on lines 32 to 58
switch stack[0] {
case 0:
i.logger.Error(buf)
case 1:
i.logger.Warn(buf)
case 2:
i.logger.Info(buf)
case 3:
i.logger.Debug(buf)
case 4:
i.logger.Trace(buf)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be conceptually wrong to import github.com/inspektor-gadget/inspektor-gadget/pkg/operators/wasm/api as igwapi here and do this to keep them synchronized?

Suggested change
switch stack[0] {
case 0:
i.logger.Error(buf)
case 1:
i.logger.Warn(buf)
case 2:
i.logger.Info(buf)
case 3:
i.logger.Debug(buf)
case 4:
i.logger.Trace(buf)
}
switch logLevel {
case uint32(igwapi.ErrorLevel):
i.logger.Error(buf)
case uint32(igwapi.WarnLevel):
i.logger.Warn(buf)
case uint32(igwapi.InfoLevel):
i.logger.Info(buf)
case uint32(igwapi.DebugLevel):
i.logger.Debug(buf)
case uint32(igwapi.TraceLevel):
i.logger.Trace(buf)
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too comfortable importing the API package in the wasm operator, even if it works, it's weird. Also, there are some private constants that we won't be able to use, and I don't want to expose them to the users.

I also tried to define a common package with all consts, but then it became a mess as users will have to do something like api.Log(const.ErrorLevel..., and private consts will need to be exposed.

I added a test to check that different constants are kept in sync.

pkg/operators/wasm/api/helpers.go Outdated Show resolved Hide resolved
pkg/operators/wasm/log.go Show resolved Hide resolved

var errInvalidPtr = errors.New("invalid pointer")

func bufFromStack(m wapi.Module, val uint64) ([]byte, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if it'd make sense also here to create a new type bufPtr here that implements String() and Bytes(), but maybe it's not worth it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean something like type bufPtr uint64 and implement those two methods on this? (as done in pkg/operators/wasm/api/helpers.go). I don't see a clear advantage in this case.

Copy link
Member

@blanquicet blanquicet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments about the datasource functions. Many of them are more questions than comments 🙂

pkg/operators/wasm/datasource.go Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
pkg/operators/wasm/wasm.go Outdated Show resolved Hide resolved
func (i *wasmOperatorInstance) newDataSource(ctx context.Context, m wapi.Module, stack []uint64) {
dsName, err := stringFromStack(m, stack[0])
if err != nil {
i.logger.Warnf("reading string from stack: %v", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's too generic and the same log is printed in a couple of situation in this same package. It's a log not and returned error, so IMO it should contain a little bit more context. Don't you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the name of the function to the log message. Did you have something else in mind?

pkg/operators/wasm/datasource.go Outdated Show resolved Hide resolved
Comment on lines 230 to 258
type subscriptionType uint32

const (
subscriptionTypeData subscriptionType = 0
subscriptionTypeArray subscriptionType = 1
subscriptionTypePacket subscriptionType = 2
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we expose these so that both API and operator can use the same consts?

pkg/operators/wasm/datasource.go Show resolved Hide resolved
pkg/operators/wasm/datasource.go Outdated Show resolved Hide resolved
Copy link
Member

@eiffel-fl eiffel-fl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!

I will take a deeper look later, but here are some initial comments.

Best regards.

pkg/operators/wasm/wasm.go Show resolved Hide resolved
pkg/operators/wasm/wasm.go Show resolved Hide resolved
pkg/operators/wasm/wasm.go Show resolved Hide resolved
Comment on lines 114 to 206
reader, err := oci.GetContentFromDescriptor(gadgetCtx.Context(), i.desc)
if err != nil {
return fmt.Errorf("getting wasm program: %w", err)
}

wasmProgram, err := io.ReadAll(reader)
if err != nil {
reader.Close()
return fmt.Errorf("reading wasm program: %w", err)
}
reader.Close()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a déjà-vu for this code.
Should we create a common function shared by both eBPF and WASM layers?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do it., but TBH I don't think reducing 10 lines of duplicated code makes a big change. Would you mind opening an issue if you care about it more?

pkg/operators/wasm/wasm.go Show resolved Hide resolved
pkg/operators/wasm/api/datasource.go Outdated Show resolved Hide resolved
pkg/operators/wasm/datasource.go Outdated Show resolved Hide resolved
pkg/operators/wasm/datasource.go Show resolved Hide resolved
Comment on lines 120 to 143
handleIndex := i.lastHandleIndex
handleIndex++

// look for a free index in the map
for {
// zero is reserved
if handleIndex == 0 {
handleIndex++
}

if _, ok := i.handleMap[handleIndex]; !ok {
// register new entry
i.handleMap[handleIndex] = obj
i.lastHandleIndex = handleIndex
return handleIndex
}
handleIndex++
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand this code.
Despite having a map, you are looping on index, like an array, so you are doing O(n).
Instead of using this index, cannot you generate some random number?
Or using some hash as key, this way you guarantee uniqueness?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite having a map, you are looping on index, like an array, so you are doing O(n)

I think it's only O(n) in the worst case when the map is almost full and it has to check all possible combinations before finding the only available slot. In the first 2^32 calls to addHandle() the algorithm will return in O(1).

Instead of using this index, cannot you generate some random number?

I thought about this idea but I found some issues:

  • The returned handled will be random, I think it's easier to understand sequential handles (like file descriptors in linux).
  • If there's a collision we'll have to retry. If the map has few available slots, it'll require a lot of retries. Even if I think it shouldn't be a real issue given that wasm modules should use only few handles.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Despite having a map, you are looping on index, like an array, so you are doing O(n)

I think it's only O(n) in the worst case when the map is almost full and it has to check all possible combinations before finding the only available slot. In the first 2^32 calls to addHandle() the algorithm will return in O(1).

O(something) is always about the worst case.

Instead of using this index, cannot you generate some random number?

I thought about this idea but I found some issues:

* The returned handled will be random, I think it's easier to understand sequential handles (like file descriptors in linux).

Good idea, how is this implemented in Linux? I suppose we can take some inspiration from there.

* If there's a collision we'll have to retry. If the map has few available slots, it'll require a lot of retries. Even if I think it shouldn't be a real issue given that wasm modules should use only few handles.

Instead of random number, you can compute a hash and you would not have to deal with this issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH I think the current implementation works fine and we don't need to complicate it anymore. I looked at the implementation on Linux and AFAIU it's more complex, they for sure need to handle scalability problems we don't have.

Something I can make, is to limit the maximum number of opened handles a gadget can have, something like 4k should be more than enough.

pkg/operators/wasm/wasm.go Show resolved Hide resolved
Copy link
Member

@alban alban left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR. Some comments for the communication between the wasm host and guest.

pkg/operators/wasm/helpers.go Show resolved Hide resolved

// bufPtr encodes the pointer and length of a buffer as a uint64
// The pointer is stored in the lower 32 bits and the length in the upper 32 bits
type bufPtr uint64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it makes it easier to cast bufPtr into uint64 to be able to pass it as argument of the //go:wasmimport functions:

gadgetLog(uint32(level), uint64(stringToBufPtr(message)))

pkg/operators/wasm/api/log.go Outdated Show resolved Hide resolved
pkg/operators/wasm/fields.go Show resolved Hide resolved
pkg/operators/wasm/fields.go Show resolved Hide resolved
// See the License for the specific language governing permissions and
// limitations under the License.

package api
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add package documentation in pkg/operators/wasm/api/doc.go explaining that this package is meant to be used by Wasm modules and not by the core IG code nor external applications using IG Go packages.

I think it should be at a different pkg/ directory to emphasize this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added doc.go and moved it to pkg/apis/wasm, any thoughts on that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds fine to me as long as the doc.go explains what it is for. Did you forget to run git-add?

If we ever implement the same library in Rust, where would it go?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you forget to run git-add?

I just pushed.

If we ever implement the same library in Rust, where would it go?

I have no idea. Perhaps this api should be outside pkg and go to a new folder on the root? So we can have a single folder with all implementations.

gadgets-api? wasm-api? ..

  • go
  • rust
  • ...

- `kind` (u32): Kind of access: How to read the field.

Return value:
- Value of the field.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add explanation:

  • If the returned value is of type String or Bytes, it will be allocated inside the wasm guest memory by calling the function malloc. The Wasm module must either provide its own implementation of malloc or be compiled against a library which provides it such as libc. It is the responsibility of the caller to free the allocation.
  • The reference Wasm guest Go library "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/wasm/api" automatically frees the memory as appropriate so if your Wasm module uses that reference implementation, you don't have to call free.
  • The function returns 0 in case of errors (ambiguous with scalar types like u32).

I would feel better with a prototype such as:

fieldAccessorGet(u32 acc, u32 data, u32 kind, u32 *error, u32 *buf, u32 buf_len)

Then:

  • the caller can know the error
    • no ambiguity: is it an error or is it u32(0)?
  • the caller is responsible for allocating the buffer (with malloc or something else).
    • IG does not have call malloc
    • the wasm module does not have to use cgo and export malloc
  • IG just has to check that buf_len is big enough to copy what it wants to copy.

pkg/operators/wasm/api/fields.go Outdated Show resolved Hide resolved
pkg/operators/wasm/api/fields.go Outdated Show resolved Hide resolved
Comment on lines +49 to +51
env.NewFunctionBuilder().
WithGoModuleFunction(wapi.GoModuleFunc(fn), params, results).
Export(name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how to enable future API changes. If we want to change fieldAccessorSet, will we define fieldAccessorSetV2, fieldAccessorSetV3 etc.?

The wasm operator handles wasm programs present on the gadget.
This commit introduces the operator, next commits will implement
functions that are exposed to the wasm guests.

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Signed-off-by: Michael Friese <mfriese@microsoft.com>
Add function used to emit log messages to the gadget logger
instance.

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Signed-off-by: Michael Friese <mfriese@microsoft.com>
This commit also introduces the handle logic: A way to expose
host objects to the guest by using handles.

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Signed-off-by: Michael Friese <mfriese@microsoft.com>
Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Signed-off-by: Michael Friese <mfriese@microsoft.com>
Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Building in-tree gadgets is a bit more complicated as we want
them to use the current files to be sure they take any
changes we're working on. (It's specially relevant
for the api exposed to gadgets in pkg/operators/wasm/api/)

One big challenge is that (ideally) the build process should continue
to work when using a container or when building locally on the host (--local).

This commit achieves it by using a replace directive with a relative
path to the inspektor-gadget folder. Then the build command
expects a new --inspektor-gadget-path argument to mount the inspektor-gadget
path in the container.

This commit isn't totally clean, however other solutions seemed even more complicated:
- Copy the inspektor gadget source code to the builder image
 - pros:
  - We don't need to mount anything on the container
 - cons:
  - The ebpf builder image will be implicitly associated with an inspektor gadget version
- Always use --local for building in-tree gadgets
 - pros:
  - We don't need to worry about this issue at all
 - cons:
  - Complicates the CI as we'll need to install all dependencies locally to compile gadgets

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
The args field is encoded by the eBPF program as:
arg0\0arg1\0arg2, this commit implements the logic
to decode and convert it to a string using wasm.

TODO: The datasource doesn't support arrays yet, hence we have to
join the args in a single string. This could be wrong as it's
possible to execute a process with arguments that contain spaces.

TODO2: Are the args size and args count fields really needed? Can't we
imply them?, like two consecutive null bytes means the string ends, etc.

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Add some tests that rely on gadgets stored as tar images

TODO:
- how to keep these .tar files updated?
- Why the tar files change each time make is executed? (timestamp?)
There are some constants that must be in sync between the host and
the guest. This test checks that them have the same value in both
places to avoid nasty surprises.

Signed-off-by: Mauricio Vásquez <mauriciov@microsoft.com>
Copy link
Member

@eiffel-fl eiffel-fl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!

I have some comments, it is looking nonetheless!

Best regards.

Comment on lines +143 to +171
func (ds DataSource) Subscribe(cb DataFunc, priority uint32) error {
dsSubscriptionCtr++
dsSubcriptions[dsSubscriptionCtr] = subscription{typ: subscriptionTypeData, cb: cb}
ret := dataSourceSubscribe(uint32(ds), uint32(subscriptionTypeData), priority, dsSubscriptionCtr)
if ret != 0 {
return fmt.Errorf("subscribing to datasource")
}
return nil
}

func (ds DataSource) SubscribeArray(cb ArrayFunc, priority uint32) error {
dsSubscriptionCtr++
dsSubcriptions[dsSubscriptionCtr] = subscription{typ: subscriptionTypeArray, cb: cb}
ret := dataSourceSubscribe(uint32(ds), uint32(subscriptionTypeArray), priority, dsSubscriptionCtr)
if ret != 0 {
return fmt.Errorf("subscribing to datasource")
}
return nil
}

func (ds DataSource) SubscribePacket(cb PacketFunc, priority uint32) error {
dsSubscriptionCtr++
dsSubcriptions[dsSubscriptionCtr] = subscription{typ: subscriptionTypePacket, cb: cb}
ret := dataSourceSubscribe(uint32(ds), uint32(subscriptionTypePacket), priority, dsSubscriptionCtr)
if ret != 0 {
return fmt.Errorf("subscribing to datasource")
}
return nil
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These functions can definitely be factorized.


ds, ok := getHandle[datasource.DataSource](i, dsHandle)
if !ok {
stack[0] = 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if we should not do this once and update the value in case of success?


t, ok := val.(T)
if !ok {
i.logger.Warnf("handle %d is not of type %T", handleID, empty)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way to add the current type, in a expected %T, got %T fashion?

wapi.ValueTypeI32, // Kind
wapi.ValueTypeI64, // Value
},
[]wapi.ValueType{},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand as you are actually using stack[0] in this function.

@@ -36,7 +38,9 @@ build: $(GADGETS)
.PHONY: $(GADGETS)
$(GADGETS):
@echo "Building $@"
@sudo -E IG_EXPERIMENTAL=true $(IG) image build \
@sudo -E IG_EXPERIMENTAL=true \
INSPEKTOR_GADGET_PATH=$(shell realpath $(ROOT_DIR)/..) \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +4 to +6
fields \
dataarray \
badguest \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way to bake these in the golang test?
I would like to avoid adding binaries stuff in the source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[RFE] handling events in userspace with wasm
5 participants