feat: implement join token management CLI
Some checks are pending
default / default (push) Waiting to run
default / e2e-backups (push) Blocked by required conditions
default / e2e-cluster-import (push) Blocked by required conditions
default / e2e-forced-removal (push) Blocked by required conditions
default / e2e-omni-upgrade (push) Blocked by required conditions
default / e2e-scaling (push) Blocked by required conditions
default / e2e-short (push) Blocked by required conditions
default / e2e-short-secureboot (push) Blocked by required conditions
default / e2e-templates (push) Blocked by required conditions
default / e2e-upgrades (push) Blocked by required conditions
default / e2e-workload-proxy (push) Blocked by required conditions

The commands added:
```
omnictl jointoken create
omnictl jointoken delete
omnictl jointoken renew
omnictl jointoken revoke
omnictl jointoken unrevoke
omnictl jointoken make-default
```

Fixes: https://github.com/siderolabs/omni/issues/1093

Signed-off-by: Artem Chernyshev <artem.chernyshev@talos-systems.com>
This commit is contained in:
Artem Chernyshev 2025-07-28 18:23:32 +03:00
parent 4b0c32aaf5
commit 0591d2eeba
No known key found for this signature in database
GPG Key ID: E084A2DF1143C14D
7 changed files with 346 additions and 14 deletions

View File

@ -20,6 +20,7 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/siderolabs/omni/client/api/omni/management"
)
@ -400,3 +401,21 @@ func (client *ClusterClient) KubernetesSyncManifests(ctx context.Context, dryRun
}
}
}
// CreateJoinToken creates a join token and returns it's ID.
func (client *Client) CreateJoinToken(ctx context.Context, name string, ttl time.Duration) (string, error) {
var expirationTime *timestamppb.Timestamp
if ttl > 0 {
expirationTime = timestamppb.New(time.Now().Add(ttl))
}
resp, err := client.conn.CreateJoinToken(ctx, &management.CreateJoinTokenRequest{
Name: name,
ExpirationTime: expirationTime,
})
if err != nil {
return "", err
}
return resp.Id, nil
}

View File

@ -0,0 +1,274 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package omnictl
import (
"context"
"fmt"
"os"
"text/tabwriter"
"time"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/siderolabs/omni/client/pkg/client"
"github.com/siderolabs/omni/client/pkg/omni/resources"
"github.com/siderolabs/omni/client/pkg/omni/resources/siderolink"
"github.com/siderolabs/omni/client/pkg/omnictl/internal/access"
)
var (
joinTokenCreateFlags struct {
role string
useUserRole bool
ttl time.Duration
}
joinTokenRenewFlags struct {
ttl time.Duration
}
// joinTokenCmd represents the jointoken command.
joinTokenCmd = &cobra.Command{
Use: "jointoken",
Aliases: []string{"jt"},
Short: "Manage join tokens",
}
joinTokenCreateCmd = &cobra.Command{
Use: "create <name>",
Aliases: []string{"c"},
Short: "Create a join token",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
name := args[0]
return access.WithClient(func(ctx context.Context, client *client.Client) error {
token, err := client.Management().CreateJoinToken(ctx, name, joinTokenCreateFlags.ttl)
if err != nil {
return err
}
fmt.Println(token)
return nil
})
},
}
joinTokenRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Aliases: []string{"r"},
Short: "Revoke a join token",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
return access.WithClient(func(ctx context.Context, client *client.Client) error {
_, err := safe.StateUpdateWithConflicts(
ctx,
client.Omni().State(),
siderolink.NewJoinToken(resources.DefaultNamespace, id).Metadata(),
func(res *siderolink.JoinToken) error {
res.TypedSpec().Value.Revoked = true
return nil
},
)
if err != nil {
return err
}
fmt.Printf("token %q was revoked\n", id)
return nil
})
},
}
joinTokenUnrevokeCmd = &cobra.Command{
Use: "unrevoke <id>",
Aliases: []string{"ur"},
Short: "Unrevoke a join token",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
return access.WithClient(func(ctx context.Context, client *client.Client) error {
_, err := safe.StateUpdateWithConflicts(
ctx,
client.Omni().State(),
siderolink.NewJoinToken(resources.DefaultNamespace, id).Metadata(),
func(res *siderolink.JoinToken) error {
res.TypedSpec().Value.Revoked = false
return nil
},
)
if err != nil {
return err
}
fmt.Printf("token %q was unrevoked\n", id)
return nil
})
},
}
joinTokenMakeDefaultCmd = &cobra.Command{
Use: "make-default <id>",
Aliases: []string{"md"},
Short: "Make the token default one",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
return access.WithClient(func(ctx context.Context, client *client.Client) error {
_, err := safe.StateUpdateWithConflicts(
ctx,
client.Omni().State(),
siderolink.NewDefaultJoinToken().Metadata(),
func(res *siderolink.DefaultJoinToken) error {
res.TypedSpec().Value.TokenId = id
return nil
},
)
if err != nil {
return err
}
fmt.Printf("token %q is now default\n", id)
return nil
})
},
}
joinTokenRenewCmd = &cobra.Command{
Use: "renew <id>",
Aliases: []string{"r"},
Short: "Renew a join token",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
if joinTokenRenewFlags.ttl == 0 {
return fmt.Errorf("ttl should be greater than 0")
}
return access.WithClient(func(ctx context.Context, client *client.Client) error {
_, err := safe.StateUpdateWithConflicts(
ctx,
client.Omni().State(),
siderolink.NewJoinToken(resources.DefaultNamespace, id).Metadata(),
func(res *siderolink.JoinToken) error {
res.TypedSpec().Value.ExpirationTime = timestamppb.New(time.Now().Add(joinTokenRenewFlags.ttl))
return nil
},
)
if err != nil {
return err
}
fmt.Printf("token %q was renewed, new ttl is %s\n", id, joinTokenRenewFlags.ttl)
return nil
})
},
}
joinTokenListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List join tokens",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
return access.WithClient(func(ctx context.Context, client *client.Client) error {
joinTokens, err := safe.ReaderListAll[*siderolink.JoinTokenStatus](ctx, client.Omni().State())
if err != nil {
return err
}
writer := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintf(writer, "ID\tNAME\tSTATE\tEXPIRATION\tUSE COUNT\tDEFAULT\n") //nolint:errcheck
for token := range joinTokens.All() {
var isDefault string
if token.TypedSpec().Value.IsDefault {
isDefault = "*"
}
expirationTime := "never"
if token.TypedSpec().Value.ExpirationTime != nil {
expirationTime = token.TypedSpec().Value.ExpirationTime.AsTime().String()
}
if _, err = fmt.Fprintf(
writer,
"%s\t%s\t%s\t%s\t%d\t%s\n",
token.Metadata().ID(),
token.TypedSpec().Value.Name,
token.TypedSpec().Value.State.String(),
expirationTime,
token.TypedSpec().Value.UseCount,
isDefault,
); err != nil {
return err
}
}
return writer.Flush()
})
},
}
joinTokenDeleteCmd = &cobra.Command{
Use: "delete <name>",
Aliases: []string{"d"},
Short: "Delete a join token",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
id := args[0]
return access.WithClient(func(ctx context.Context, client *client.Client) error {
err := client.Omni().State().TeardownAndDestroy(ctx, siderolink.NewJoinToken(resources.DefaultNamespace, id).Metadata())
if err != nil {
return fmt.Errorf("failed to delete a join token: %w", err)
}
fmt.Printf("deleted join token: %s\n", id)
return nil
})
},
}
)
func init() {
RootCmd.AddCommand(joinTokenCmd)
joinTokenCmd.AddCommand(joinTokenCreateCmd)
joinTokenCmd.AddCommand(joinTokenListCmd)
joinTokenCmd.AddCommand(joinTokenDeleteCmd)
joinTokenCmd.AddCommand(joinTokenRevokeCmd)
joinTokenCmd.AddCommand(joinTokenMakeDefaultCmd)
joinTokenCmd.AddCommand(joinTokenUnrevokeCmd)
joinTokenCmd.AddCommand(joinTokenRenewCmd)
joinTokenCreateCmd.Flags().DurationVarP(&joinTokenCreateFlags.ttl, "ttl", "t", 0, "TTL for the join token")
joinTokenRenewCmd.Flags().DurationVarP(&joinTokenRenewFlags.ttl, "ttl", "t", 0, "TTL for the join token")
joinTokenRenewCmd.MarkFlagRequired("ttl") //nolint:errcheck
}

View File

@ -94,8 +94,8 @@ func JoinTokenValidationOptions(st state.State) []validated.StateOption {
return joinTokenValidationOptions(st)
}
func DefaultJoinTokenValidationOptions() []validated.StateOption {
return defaultJoinTokenValidationOptions()
func DefaultJoinTokenValidationOptions(st state.State) []validated.StateOption {
return defaultJoinTokenValidationOptions(st)
}
func ImportedClusterSecretValidationOptions(st state.State, clusterImportEnabled bool) []validated.StateOption {

View File

@ -400,7 +400,7 @@ func NewRuntime(talosClientFactory *talos.ClientFactory, dnsService *dns.Service
infraMachineConfigValidationOptions(cachedState),
nodeForceDestroyRequestValidationOptions(cachedState),
joinTokenValidationOptions(cachedState),
defaultJoinTokenValidationOptions(),
defaultJoinTokenValidationOptions(cachedState),
importedClusterSecretValidationOptions(cachedState, config.Config.Features.EnableClusterImport),
)

View File

@ -1176,9 +1176,26 @@ func joinTokenValidationOptions(st state.State) []validated.StateOption {
}
}
func defaultJoinTokenValidationOptions() []validated.StateOption {
func defaultJoinTokenValidationOptions(st state.State) []validated.StateOption {
validateToken := func(ctx context.Context, id string) error {
_, err := safe.ReaderGetByID[*siderolink.JoinToken](ctx, st, id)
if err != nil {
if state.IsNotFoundError(err) {
return fmt.Errorf("no token with id %q exists", id)
}
return err
}
return nil
}
return []validated.StateOption{
validated.WithUpdateValidations(validated.NewUpdateValidationForType(func(_ context.Context, _, res *siderolink.DefaultJoinToken, _ ...state.UpdateOption) error {
validated.WithUpdateValidations(validated.NewUpdateValidationForType(func(ctx context.Context, _, res *siderolink.DefaultJoinToken, _ ...state.UpdateOption) error {
if err := validateToken(ctx, res.TypedSpec().Value.TokenId); err != nil {
return err
}
if res.Metadata().Phase() == resource.PhaseTearingDown {
if res.Metadata().ID() != siderolink.DefaultJoinTokenID {
return nil
@ -1190,7 +1207,11 @@ func defaultJoinTokenValidationOptions() []validated.StateOption {
return nil
})),
validated.WithDestroyValidations(validated.NewDestroyValidationForType(
func(_ context.Context, _ resource.Pointer, res *siderolink.DefaultJoinToken, _ ...state.DestroyOption) error {
func(ctx context.Context, _ resource.Pointer, res *siderolink.DefaultJoinToken, _ ...state.DestroyOption) error {
if err := validateToken(ctx, res.TypedSpec().Value.TokenId); err != nil {
return err
}
if res.Metadata().ID() != siderolink.DefaultJoinTokenID {
return nil
}

View File

@ -1474,11 +1474,19 @@ func TestDefaultJoinTokenValidation(t *testing.T) {
t.Cleanup(cancel)
innerSt := state.WrapCore(namespaced.NewState(inmem.Build))
st := validated.NewState(innerSt, omni.DefaultJoinTokenValidationOptions()...)
st := validated.NewState(innerSt, omni.DefaultJoinTokenValidationOptions(innerSt)...)
wrappedState := state.WrapCore(st)
defaultToken := siderolink.NewDefaultJoinToken()
joinToken := siderolink.NewJoinToken(resources.DefaultNamespace, "mm")
require.NoError(t, st.Create(ctx, joinToken))
joinToken = siderolink.NewJoinToken(resources.DefaultNamespace, "mmmm")
require.NoError(t, st.Create(ctx, joinToken))
defaultToken.TypedSpec().Value.TokenId = "mm"
require.NoError(t, wrappedState.Create(ctx, defaultToken))
@ -1491,6 +1499,14 @@ func TestDefaultJoinTokenValidation(t *testing.T) {
assert.NoError(t, err)
_, err = safe.StateUpdateWithConflicts(ctx, wrappedState, defaultToken.Metadata(), func(token *siderolink.DefaultJoinToken) error {
token.TypedSpec().Value.TokenId = "mmmmmm"
return nil
})
assert.Error(t, err)
_, err = wrappedState.Teardown(ctx, defaultToken.Metadata())
assert.ErrorContains(t, err, "destroying")

View File

@ -638,8 +638,9 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client
joinToken := siderolink.NewJoinToken(resources.DefaultNamespace, uuid.New().String())
defaultJoinToken := siderolink.NewDefaultJoinToken()
*defaultJoinToken.Metadata() = resource.NewMetadata(resources.DefaultNamespace, siderolink.DefaultJoinTokenType, uuid.New().String(), resource.VersionUndefined)
defaultJoinToken, err := safe.StateGetByID[*siderolink.DefaultJoinToken](rootCtx, rootCli.Omni().State(), siderolink.DefaultJoinTokenID)
require.NoError(t, err)
importedClusterSecret := omni.NewImportedClusterSecrets(resources.DefaultNamespace, cluster.Metadata().ID())
@ -1225,6 +1226,7 @@ func AssertResourceAuthz(rootCtx context.Context, rootCli *client.Client, client
"NotFoundError": "doesn't exist",
"ValidationError": "failed to validate",
"UnsupportedError": "unsupported resource type",
"AlreadyExists(DefaultJoinToken)": "resource DefaultJoinTokens.omni.sidero.dev(default/default@1) already exists",
"AlreadyExists(AccessPolicy)": "resource AccessPolicies.omni.sidero.dev(default/access-policy@undefined) already exists",
"VersionConflict(AccessPolicy)": "failed to update: resource AccessPolicies.omni.sidero.dev(default/access-policy@1) update conflict: expected version",
}