Skip to content

Conversation

@szkiba
Copy link
Contributor

@szkiba szkiba commented Nov 10, 2025

Description

This PR adds support for subcommand extensions, allowing external modules to register custom subcommands under the k6 x namespace.

Changes

Core Extension Framework

  • Added SubcommandExtension type to ext/ext.go
    • New extension type alongside JS, Output, and SecretSource extensions
    • Properly initialized in the extension registry

Subcommand Package (subcommand/extension.go)

  • New subcommand package for registering subcommand extensions
    • Constructor function type that creates cobra.Command instances
    • RegisterExtension function for registering subcommands during init
    • Comprehensive package and function documentation
    • Receives GlobalState for access to k6 runtime state
    • Warning: GlobalState is read-only and must not be modified

Integration with k6 CLI (internal/cmd/)

New k6 x Command Namespace

  • Added getX function in internal/cmd/subcommand.go
    • Creates the x parent command for all extension subcommands
    • Short description: "Extension subcommands"
    • Long description explains the namespace purpose
    • Only added to root command if extensions are registered

Extension Loading

  • Added extensionSubcommands iterator in internal/cmd/subcommand.go

    • Discovers and loads registered subcommand extensions
    • Prevents duplicate command registration
    • Logs warnings for conflicts with built-in commands
  • Added getCmdForExtension helper with strict validation

    • Panics on invalid constructor type - extensions must implement subcommand.Constructor
    • Panics on name mismatch - command name must match extension name
    • Fail-fast validation ensures configuration errors are caught early
  • Integrated into root command (internal/cmd/root.go)

    • Extension subcommands loaded under k6 x namespace
    • Automatic integration on k6 startup

Testing

Comprehensive Test Coverage (internal/cmd/subcommand_test.go)

  • TestExtensionSubcommands - 5 test cases covering:

    1. Returns all extension subcommands
    2. Filters out already defined commands
    3. Prevents duplicate extensions
    4. Returns commands with correct properties
    5. Skips subcommand when name doesn't match extension name (validates panic prevention)
  • TestXCommandHelpDisplayCommands - Verifies help output

    • Tests that all registered extensions appear in k6 x help
    • Validates command descriptions
  • Test infrastructure (internal/cmd/root_test.go)

    • Added registerTestSubcommandExtensions helper with sync.Once
    • Registers 3 test extensions for use across test suites
    • Updated existing tests to verify x command presence

Important Notes

  • Constructor validation uses panics - misconfigured extensions will fail fast at startup, not at runtime
  • GlobalState is read-only - extensions must not modify it to avoid core instability
  • Name matching is enforced - command name must match extension registration name
  • Namespace isolation - all extension subcommands live under k6 x

Use Case

Extensions register subcommands during init:

package myextension

import (
    "github.com/spf13/cobra"
    "go.k6.io/k6/cmd/state"
    "go.k6.io/k6/subcommand"
)

func init() {
    subcommand.RegisterExtension("my-tool", newCommand)
}

func newCommand(gs *state.GlobalState) *cobra.Command {
    return &cobra.Command{
        Use:   "my-tool",
        Short: "My custom tool",
        Run: func(cmd *cobra.Command, args []string) {
            // Access k6 state via gs (read-only)
            gs.Logger.Info("Running my-tool")
            // Custom logic here
        },
    }
}

Users invoke:

k6 x my-tool

Closes #5398

@szkiba szkiba linked an issue Nov 10, 2025 that may be closed by this pull request
@szkiba szkiba marked this pull request as ready for review November 10, 2025 15:53
@szkiba szkiba requested a review from a team as a code owner November 10, 2025 15:53
@szkiba szkiba requested review from ankur22, inancgumus and oleiade and removed request for a team November 10, 2025 15:53
@szkiba szkiba temporarily deployed to azure-trusted-signing November 10, 2025 15:57 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 10, 2025 15:59 — with GitHub Actions Inactive
@szkiba szkiba requested a review from mstoykov November 10, 2025 16:02
Copy link
Contributor

@ankur22 ankur22 left a comment

Choose a reason for hiding this comment

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

Generally looks good to me, but i'm not an expert in this particular area of k6.

One thing that I think might be worth doing is adding tests for the failure cases, e.g. when duplicate sub command extensions are added.

@szkiba szkiba temporarily deployed to azure-trusted-signing November 12, 2025 11:15 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 12, 2025 11:18 — with GitHub Actions Inactive
@szkiba
Copy link
Contributor Author

szkiba commented Nov 13, 2025

Generally looks good to me, but i'm not an expert in this particular area of k6.

One thing that I think might be worth doing is adding tests for the failure cases, e.g. when duplicate sub command extensions are added.

@ankur22, Thank you, you are absolutely right. I added a few tests to the extensionSubcommands() function, including testing for duplication.

@szkiba szkiba temporarily deployed to azure-trusted-signing November 13, 2025 06:40 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 13, 2025 06:42 — with GitHub Actions Inactive
oleiade
oleiade previously approved these changes Nov 13, 2025
Copy link
Contributor

@oleiade oleiade left a comment

Choose a reason for hiding this comment

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

Left a couple of non-blocking comments behind. This looks pretty good, and I'm excited to try it out 👏🏻

ext/ext.go Outdated
mx.RLock()
defer mx.RUnlock()

js, out := extensions[JSExtension], extensions[OutputExtension]
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make sense to also add the Subcommand extensions here? That way users using a subcommand extension can see it listed by the version command when extensions are included?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@oleiade you are absolutely right. I will add.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@oleiade, done, could you approve again please

@mstoykov mstoykov added this to the v1.5.0 milestone Nov 17, 2025
@szkiba szkiba temporarily deployed to azure-trusted-signing November 17, 2025 14:51 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 17, 2025 14:54 — with GitHub Actions Inactive
oleiade
oleiade previously approved these changes Nov 18, 2025
Copy link
Contributor

@mstoykov mstoykov left a comment

Choose a reason for hiding this comment

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

Most of my critique will be on the ... idea of this feature and less on the actual implementation. I have comments on the implementation taht IMO should be implemented before it is merged, but that is not my bigger problem. Specifically having prefix for the subcommands.

AFAIK v2 will break all extensions unless we do something, and even then it likely will do so. As such I am okay with us having extensions endpoints, with the understanding that v2 might drop some of them.

For me this seems to be a feature we want for ... some use cases that I will categorize mostly as:

  1. other programs that someone wants to run parallel to k6 or to do somethign with k6 - IMO those will be better off as separate programs
  2. things that interact with k6 directly. For some of those this makes some sense, but even for xk6-dashboard - it likely should be split in the output and a dashboard management system. Which makes it a simple output and a separate command.

The use case for making k6 cloud not part of core IMO:

  1. will baloon the public API to proportions that will completely make any upside totally irrelevant
  2. not being part of core ... isn't really a thing that IMO will make it easier to develop, more stable and w/e

As a whole it seems to just be "a nice to have".

A nice to have that IMO will again expose us to a lot of maintaince burden and IMO won't change who maintaince it and what the work on it needs to be.

On this same topic, I feel like this will benefit from a vision on how it will work with the catalog? I do not see how this will wokr in the cloud at all.

But it will be nice to have an idea of will k6 x-httpbin do something with a k6 without it ? Will it go fetch a new binary with a command ? Will it not, would it tell you about it ?

Comment on lines +13 to +36
// extensionSubcommands returns an iterator over all registered subcommand extensions
// that are not already defined in the given slice of commands.
func extensionSubcommands(gs *state.GlobalState, defined []*cobra.Command) iter.Seq[*cobra.Command] {
already := make(map[string]struct{}, len(defined))
for _, cmd := range defined {
already[cmd.Name()] = struct{}{}
}

return func(yield func(*cobra.Command) bool) {
for _, extension := range ext.Get(ext.SubcommandExtension) {
if _, exists := already[extension.Name]; exists {
gs.Logger.WithFields(logrus.Fields{"name": extension.Name, "path": extension.Path}).
Warnf("subcommand already exists")
continue
}

already[extension.Name] = struct{}{}

if !yield(getCmdForExtension(extension, gs)) {
break
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems to be used only in tests and should then be put in the tests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here it is used to add registered subcommands to the root command: root.go:90-92

Comment on lines +30 to +32
func RegisterExtension(name string, c Constructor) {
ext.Register(name, ext.SubcommandExtension, c)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I do think that it is a requirement that we do not let people just have any names, but similar to js extension have a prefix that they need to have. As otherwise any new command in core will break an extension that uses that same command name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are absolutely right. I missed the namespace for output extensions before. I also thought about the namespace for subcommand extensions, but I haven't found a good solution until now.
The cobra.Command#CommandPath() function gave me an idea of ​​what a good namespace solution would be for subcommand extensions: instead of registering the subcommands of the extensions under the root command, we should create a subcommand called x and register the subcommands of the extensions as its subcommands. The usage for an httpbin subcommand would look like this:

k6 x httpbin

That is, the x subcommand itself would be the namespace that would ensure that the subcommands of the extensions don't conflict with the future k6 subcommands.
What do you think?

Comment on lines 14 to 28
// Constructor is a function type that creates a new cobra.Command for a subcommand extension.
// It receives a GlobalState instance that provides access to configuration, logging,
// file system, and other shared k6 runtime state. The returned Command will be
// integrated into k6's CLI as a subcommand.
type Constructor func(*state.GlobalState) *cobra.Command

// RegisterExtension registers a subcommand extension with the given name and constructor function.
//
// The name parameter specifies the subcommand name that users will invoke (e.g., "k6 <name>").
// The constructor function will be called when k6 initializes to create the cobra.Command
// instance for this subcommand.
//
// This function must be called during package initialization (typically in an init() function)
// and will panic if a subcommand with the same name is already registered.
//
Copy link
Contributor

Choose a reason for hiding this comment

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

I would prefer that we have a warning here that modifying the GlobalState is not intended and likely will lead to not working k6.

I do not really like that we provide basically full access to extensions, and this doesn't make this any better.

As usual most k6 APIs are meant for internal usage, and their usage for extensions was experimental and we are now continuing without actually making this different.

This is slightly a mute poitn as I do expect k6 v2 will break eveyr extension whether on purpose or by accident, so 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, a warning would be useful that it is not the intention to modify the GlobalState. I will add a sentence about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a warning: extension.go:19-21

Comment on lines 48 to 52
// Validate that the command's name matches the extension name.
if cmd.Name() != extension.Name {
gs.Logger.WithFields(logrus.Fields{"name": extension.Name, "path": extension.Path}).
Fatalf("subcommand's command name (%s) does not match the extension name (%s)", cmd.Name(), extension.Name)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I am not a fan of using Fatal as a way to return errors and ending execution. It would be a lot nicer to just use errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't really like the use of Fatal log either. Upon closer inspection, this is not a runtime error, but a faulty extension implementation. In this case, I think the use of panic is justified, I changed it to panic.
(there is an example of panic being used in JavaScript extension registration in case of duplicate registration)

@szkiba
Copy link
Contributor Author

szkiba commented Nov 21, 2025

@mstoykov thank you for the valuable and inspiring review, I appreciate it.

To summarize my answers:

I have comments on the implementation that IMO should be implemented before it is merged, but that is not my bigger problem. Specifically having prefix for the subcommands.

In short, what I replied to the comment:
You are right about the prefix, more precisely, to generalize a bit that the subcommands created by the extensions should be namespaced. I suggest that a subcommand named x should have the subcommands created by the extensions as its subcommands. That is, for example, the usage of the httpbin subcommand would look like this:

k6 x httpbin

This solution ensures that the subcommands created by the extensions will not conflict with the future subcommands of k6. The x subcommand functions as a namespace. This is in line with the "k6/x" prefix of JavaScript extensions.

On this same topic, I feel like this will benefit from a vision on how it will work with the catalog? I do not see how this will wokr in the cloud at all.

But it will be nice to have an idea of ​​will k6 x-httpbin do something with a k6 without it ? Will it go fetch a new binary with a command ? Will it not, would it tell you about it ?

A very good question. The answer is the same as for output extensions. k6x originally parsed the k6 command line and was able to build output extensions on the fly. Automatic Extension Resolution (aka binary provisioning) does not currently support output extensions.

Support for subcommand extensions can then be easily implemented in the above-mentioned namespace (x command). From the x subcommand it can be seen that a subcommand extension reference follows and the corresponding extension can be searched in the catalog (which of course will require a subcommands property in the catalog).

In short: by parsing the command line, support for subcommand extensions in AER can be implemented.

In the cloud, I think support for subcommand extensions in AER is not relevant.

@szkiba
Copy link
Contributor Author

szkiba commented Nov 21, 2025

@mstoykov, The x subcommand has been introduced as a namespace for extension subcommands.

@szkiba szkiba requested review from mstoykov and oleiade November 21, 2025 08:01
@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 08:05 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 08:07 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 09:17 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 09:20 — with GitHub Actions Inactive
@pablochacin
Copy link
Contributor

@mstoykov

On this same topic, I feel like this will benefit from a vision on how it will work with the catalog? I do not see how this will wokr in the cloud at all.

I don't think we are supporting extensions ONLY for the cloud. My understanding is that we still want to have a thriving OSS extensions ecosystem.

other programs that someone wants to run parallel to k6 or to do somethign with k6 - IMO those will be better off as separate programs

My experience as an extension developer is that having to distribute another binary to supplement an extension is not the best user experience. In my case, it was to help people check the configuration for the disruptor. something like k6 xk6-disruptor setup would be a nice addition, much better than distributing a xk6-disruptor-setup binary.

@pablochacin
Copy link
Contributor

Will it go fetch a new binary with a command ? Will it not, would it tell you about it ?

A very good question. The answer is the same as for output extensions.

Agree here. I started looking at this issue for output extensions. As I understand it, we have the output in the consolidated config, including those defined in the CLI flags. We also have the outputs for extension in the catalog, so we could map the output name to the extension in the catalog.

Maybe we could do something similar with subcommands: parse the CLI and collect the name of the subcommand and make it available in the consolidated config.

@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 14:10 — with GitHub Actions Inactive
@szkiba szkiba temporarily deployed to azure-trusted-signing November 21, 2025 14:12 — with GitHub Actions Inactive
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.

Add Subcommand Extension

5 participants