Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2dc2ac2
Add version and hostname indexes to cache
nicholasmarais1158 Sep 16, 2025
5af5151
Add `ListBotInstancesV2` rpc and use request options
nicholasmarais1158 Sep 17, 2025
b295941
Add v2 bot instance list endpoint
nicholasmarais1158 Sep 17, 2025
144c659
Use v2 endpoint in web UI
nicholasmarais1158 Sep 17, 2025
ce44caf
Pass signal through to support aborting requests
nicholasmarais1158 Sep 17, 2025
877813f
Fix comment typo
nicholasmarais1158 Sep 18, 2025
3562bf5
Rename util func
nicholasmarais1158 Sep 18, 2025
3bd44e6
Add expression parser
nicholasmarais1158 Sep 19, 2025
cc720dd
Contribute `to_string` function to default parser
nicholasmarais1158 Sep 19, 2025
3b4c162
Add API support for `query` filter
nicholasmarais1158 Sep 19, 2025
48207a6
Fix `SearchPanel` submit with advanced toggle
nicholasmarais1158 Sep 19, 2025
88a29f5
Add advanced search to web UI
nicholasmarais1158 Sep 19, 2025
77faebc
Deprecate `ListBotInstances` rpc
nicholasmarais1158 Sep 22, 2025
3cde394
Encode hostname in cache key
nicholasmarais1158 Sep 22, 2025
ba5d656
Address pre-release sorting in version numbers
nicholasmarais1158 Sep 22, 2025
55d2cca
Rename bot instance cache utils
nicholasmarais1158 Sep 22, 2025
09a48d4
Merge branch 'master' into nicholasmarais1158/feat/mwi-bot-instances-v2
nicholasmarais1158 Sep 22, 2025
044bef3
Fix lint deprecation warnings
nicholasmarais1158 Sep 22, 2025
04d30a4
Extract filter fields to message
nicholasmarais1158 Sep 24, 2025
2fbd797
Replace `fmt.Sprintf("%06d", ...)`
nicholasmarais1158 Sep 24, 2025
019340f
Update invalid sort field error
nicholasmarais1158 Sep 24, 2025
d1fb2f4
Fallback to v1 endpoint if possible
nicholasmarais1158 Sep 24, 2025
129a4a5
Use `strcase` for case-insensitive compare
nicholasmarais1158 Sep 24, 2025
e153fa9
Backend results are filtered by bot name so no need to re-filter in `…
nicholasmarais1158 Sep 24, 2025
4717dba
Merge branch 'master' into nicholasmarais1158/feat/mwi-bot-instances-v2
nicholasmarais1158 Sep 24, 2025
15e32e8
Use `t.Context()`
nicholasmarais1158 Sep 24, 2025
322a395
Remove expression methods
nicholasmarais1158 Sep 24, 2025
38f5d66
Remove unnecessary fallback comments
nicholasmarais1158 Sep 24, 2025
bc59499
Return early if only bot name filter is required (backend only)
nicholasmarais1158 Sep 24, 2025
e4b2bec
Merge branch 'nicholasmarais1158/feat/mwi-bot-instances-v2' into nich…
nicholasmarais1158 Sep 24, 2025
d2f4da2
Fix lint
nicholasmarais1158 Sep 24, 2025
56689e1
Merge branch 'master' into nicholasmarais1158/feat/mwi-bot-instances-…
nicholasmarais1158 Sep 25, 2025
0c7add8
replace `to_string` with `equals` (version type only)
nicholasmarais1158 Sep 25, 2025
7fcfc0c
Fix comment
nicholasmarais1158 Sep 25, 2025
95734b1
Remove unnecessary `to_string` tests
nicholasmarais1158 Sep 25, 2025
1804b18
Switch to a true equals function
nicholasmarais1158 Sep 25, 2025
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
18 changes: 14 additions & 4 deletions api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions api/proto/teleport/machineid/v1/bot_instance_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ message ListBotInstancesV2Request {
// A search term used to filter the results. If non-empty, it's used to
// match against supported fields.
string search_term = 2;
// A Teleport predicate language query used to filter the results.
string query = 3;
Comment on lines +81 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remember to backport this PR and the ListBotInstancesV2 PR together so there's never any confusion as to whether the query parameter is supported or not.

}
}

Expand Down
1 change: 1 addition & 0 deletions lib/auth/machineid/machineidv1/bot_instance_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func (b *BotInstanceService) ListBotInstancesV2(ctx context.Context, req *pb.Lis
SortDesc: req.GetSortDesc(),
FilterBotName: req.GetFilter().GetBotName(),
FilterSearchTerm: req.GetFilter().GetSearchTerm(),
FilterQuery: req.GetFilter().GetQuery(),
})
if err != nil {
return nil, trace.Wrap(err)
Expand Down
58 changes: 58 additions & 0 deletions lib/auth/machineid/machineidv1/expression/environment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package expression

import (
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
)

// Environment in which expressions will be evaluated.
type Environment struct {
Metadata *headerv1.Metadata
Spec *machineidv1.BotInstanceSpec
LatestHeartbeat *machineidv1.BotInstanceStatusHeartbeat
LatestAuthentication *machineidv1.BotInstanceStatusAuthentication
}

func (e *Environment) GetMetadata() *headerv1.Metadata {
if e == nil {
return nil
}
return e.Metadata
}

func (e *Environment) GetSpec() *machineidv1.BotInstanceSpec {
if e == nil {
return nil
}
return e.Spec
}

func (e *Environment) GetLatestHeartbeat() *machineidv1.BotInstanceStatusHeartbeat {
if e == nil {
return nil
}
return e.LatestHeartbeat
}

func (e *Environment) GetLatestAuthentication() *machineidv1.BotInstanceStatusAuthentication {
if e == nil {
return nil
}
return e.LatestAuthentication
}
139 changes: 139 additions & 0 deletions lib/auth/machineid/machineidv1/expression/expression.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package expression

import (
"github.com/coreos/go-semver/semver"
"github.com/gravitational/trace"

"github.com/gravitational/teleport/lib/expression"
"github.com/gravitational/teleport/lib/utils/typical"
)

func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], error) {
spec := expression.DefaultParserSpec[*Environment]()

spec.Variables = map[string]typical.Variable{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For the Workload Identity expression language, we did build some tooling for having a dynamic variable which can access any field within a protobuf by name - but I think in some ways, I think I may actually prefer what you've done here since it's restricting them to a subset of fields which is a little bit less scary. I'd maybe think about documenting here that this name should match the proto names, so if we ever did switch to that, it would not be a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If we were to maintain the same naming as the proto, we'd need a way to access the most recent heartbeat. I've called this latest_heartbeat and it's pre-selected and stored in the environment. If we switched to a dynamic approach, this wouldn't be backwards compatible.

Copy link
Copy Markdown
Contributor

@strideynet strideynet Sep 24, 2025

Choose a reason for hiding this comment

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

That makes sense to me - I'd misremembered latest_heartbeat as actually being a field on the proto that existed 😅 Happy for us to depart from matching the protobuf if it's got a legitimate reason - and in this case I think it does.

"name": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetMetadata().GetName(), nil
}),
"metadata.name": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetMetadata().GetName(), nil
}),
"spec.bot_name": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetSpec().GetBotName(), nil
}),
"spec.instance_id": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetSpec().GetInstanceId(), nil
}),
"status.latest_heartbeat.architecture": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetLatestHeartbeat().GetArchitecture(), nil
}),
"status.latest_heartbeat.os": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetLatestHeartbeat().GetOs(), nil
}),
"status.latest_heartbeat.hostname": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetLatestHeartbeat().GetHostname(), nil
}),
"status.latest_heartbeat.one_shot": typical.DynamicVariable(func(env *Environment) (bool, error) {
return env.GetLatestHeartbeat().GetOneShot(), nil
}),
"status.latest_heartbeat.version": typical.DynamicVariable(func(env *Environment) (*semver.Version, error) {
if env.GetLatestHeartbeat().GetVersion() == "" {
return nil, nil
}
return semver.NewVersion(env.LatestHeartbeat.Version)
}),
"status.latest_authentication.join_method": typical.DynamicVariable(func(env *Environment) (string, error) {
return env.GetLatestAuthentication().GetJoinMethod(), nil
}),
}

// e.g. `more_than(status.latest_heartbeat.version, "19.0.0")`
spec.Functions["more_than"] = typical.BinaryFunction[*Environment](semverGt)
// e.g. `less_than(status.latest_heartbeat.version, "19.0.2")`
spec.Functions["less_than"] = typical.BinaryFunction[*Environment](semverLt)
// e.g. `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")`
spec.Functions["between"] = typical.TernaryFunction[*Environment](semverBetween)
// e.g. `equals(status.latest_heartbeat.version, "19.1.0")`
spec.Functions["equals"] = typical.BinaryFunction[*Environment](semverEq)

return typical.NewParser[*Environment, bool](spec)
}

func semverGt(a, b any) (bool, error) {
va, err := toSemver(a)
if va == nil || err != nil {
return false, err
}
vb, err := toSemver(b)
if vb == nil || err != nil {
return false, err
}
return va.Compare(*vb) > 0, nil
}

func semverLt(a, b any) (bool, error) {
va, err := toSemver(a)
if va == nil || err != nil {
return false, err
}
vb, err := toSemver(b)
if vb == nil || err != nil {
return false, err
}
return va.Compare(*vb) < 0, nil
}

func semverEq(a, b any) (bool, error) {
va, err := toSemver(a)
if va == nil || err != nil {
return false, err
}
vb, err := toSemver(b)
if vb == nil || err != nil {
return false, err
}
return va.Compare(*vb) == 0, nil
}

func semverBetween(c, a, b any) (bool, error) {
gt, err := semverGt(c, a)
if err != nil {
return false, err
}
eq, err := semverEq(c, a)
if err != nil {
return false, err
}
lt, err := semverLt(c, b)
if err != nil {
return false, err
}
return (gt || eq) && lt, nil
}

func toSemver(anyV any) (*semver.Version, error) {
switch v := anyV.(type) {
case *semver.Version:
return v, nil
case string:
return semver.NewVersion(v)
default:
return nil, trace.BadParameter("type %T cannot be parsed as semver.Version", v)
}
}
Loading
Loading