Skip to content

Commit

Permalink
Add Agent license inspect command (#4813)
Browse files Browse the repository at this point in the history
# New Command: `bacalhau agent license inspect`

This PR introduces a new command that allows users to inspect the
license information of a Bacalhau orchestrator node without exposing the
license itself. The command provides a secure way to verify license
status, capabilities, and metadata while maintaining the confidentiality
of the underlying license file.

### Features
- New command: `bacalhau agent license inspect`
- Supports multiple output formats (default, JSON, YAML)
- Displays key license information:
  - Product name
  - License ID
  - Customer ID
  - Expiration date
  - Version
  - Capabilities
  - Custom metadata

### Add License Manager for Node Orchestration

Introduce a LicenseManager component that handles license validation for
orchestrator nodes. Key features:

- Validates node count against licensed limits
- Simple API for license verification and capability checks

Usage example:
```golang
// Initialize license manager (config is bacalhau License config)
// IF any issues, other than expiry date validity, appears during initialization, it will fail
licenseManager, err := licensing.NewLicenseManager(config)

// Get current license claims, it will be nil if no license is configured for the orchestrator
licenseClaims := licenseManager.License()

// Then these helper functions can be called on the licenseClaims struct
licenseClaims.IsExpired() // Returns only a boolean
licenseClaims.MaxNumberOfNodes() // Returns only a number
```

### Security Considerations
- Does not expose the raw license file or cryptographic material
- Only returns parsed, necessary information for verification
- Maintains license confidentiality while providing essential details

### License File Structure
The license file, fed to the orchestrator node config, uses a JSON
format to support future extensibility and dynamic configuration. The
structure is intentionally simple:

```json
{
    "license": "your_license_token_here"
}
```

We chose JSON format for the license file because:
- It allows for easy addition of future configuration options
- Provides a structured way to include additional metadata if needed

### Configuration
To add a license to an orchestrator, you need to configure your
orchestrator node. Here's a sample configuration example:

```yaml
NameProvider: "uuid"
API:
  Port: 1234
Orchestrator:
  Enabled: true
  Auth:
    Token: "your_secret_token_here"
  License:
    LocalPath: "/path/to/your/license.json"
Labels:
  label1: label1Value
  label2: label2Value
```

Key configuration points:
- The `Orchestrator.License.LocalPath` field specifies the path to your
license file
- If no license is configured, the command will return an error message
saying that no license was configured
- If the license if expired, the inspect command will return the same
license details, but will not that it is expired.

### Example Usage
```bash
# Default format
$ bacalhau agent license inspect

# JSON format
$ bacalhau agent license inspect --output=json

# YAML format
$ bacalhau agent license inspect --output=yaml
```

### Example Output

For `bacalhau agent license inspect`:

```bash
Product      = Bacalhau
License ID   = 2d58c7c9-ec29-45a5-a5cd-cb8f7fee6678
Customer ID  = test-customer-id-123
Valid Until  = 2045-07-28
Version      = v1
Expired      = false
Capabilities = max_nodes=1
Metadata     = someMetadata=valueOfSomeMetadata
```

For `bacalhau agent license inspect --output=yaml`:

```yaml
capabilities:
  max_nodes: "1"
customer_id: test-customer-id-123
exp: 2384889682
iat: 1736889682
iss: https://expanso.io/
jti: 2d58c7c9-ec29-45a5-a5cd-cb8f7fee6678
license_id: 2d58c7c9-ec29-45a5-a5cd-cb8f7fee6678
license_type: standard
license_version: v1
metadata:
  someMetadata: valueOfSomeMetadata
product: Bacalhau
sub: test-customer-id-123
```

### Documentation
- Added command documentation with usage examples
- Included field descriptions in help text





Linear:
https://linear.app/expanso/issue/ENG-498/license-path-configuration

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
	- Added a new CLI command to inspect agent license information.
	- Introduced a new API endpoint to retrieve agent license details.
	- Implemented license configuration support for orchestrator nodes.

- **Configuration**
- Added a new configuration option for specifying local license file
path.
- Enhanced configuration to support orchestrator settings with license
metadata.

- **API Enhancements**
	- Created a new method to retrieve license information via API client.
	- Updated Swagger documentation to include license-related endpoints.

- **Testing**
- Added comprehensive integration tests for license inspection
scenarios, including expired licenses.
- Included test cases for various license configuration states and error
handling.
- Enhanced tests for validating license output formats and error
messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Walid Baruni <[email protected]>
  • Loading branch information
jamlo and wdbaruni authored Jan 27, 2025
1 parent 52084a9 commit a088691
Show file tree
Hide file tree
Showing 31 changed files with 1,747 additions and 115 deletions.
104 changes: 104 additions & 0 deletions cmd/cli/agent/license/inspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package license

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/bacalhau-project/bacalhau/cmd/util"
"github.com/bacalhau-project/bacalhau/cmd/util/flags/cliflags"
"github.com/bacalhau-project/bacalhau/cmd/util/output"
"github.com/bacalhau-project/bacalhau/pkg/lib/collections"
"github.com/bacalhau-project/bacalhau/pkg/publicapi/client/v2"
)

// AgentLicenseInspectOptions is a struct to support license command
type AgentLicenseInspectOptions struct {
OutputOpts output.NonTabularOutputOptions
}

// NewAgentLicenseInspectOptions returns initialized Options
func NewAgentLicenseInspectOptions() *AgentLicenseInspectOptions {
return &AgentLicenseInspectOptions{
OutputOpts: output.NonTabularOutputOptions{},
}
}

func NewAgentLicenseInspectCmd() *cobra.Command {
o := NewAgentLicenseInspectOptions()
licenseCmd := &cobra.Command{
Use: "inspect",
Short: "Get the agent license information",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := util.SetupRepoConfig(cmd)
if err != nil {
return fmt.Errorf("failed to setup repo: %w", err)
}
api, err := util.GetAPIClientV2(cmd, cfg)
if err != nil {
return fmt.Errorf("failed to create api client: %w", err)
}
return o.runAgentLicense(cmd, api)
},
}
licenseCmd.Flags().AddFlagSet(cliflags.OutputNonTabularFormatFlags(&o.OutputOpts))
return licenseCmd
}

// Run executes license command
func (o *AgentLicenseInspectOptions) runAgentLicense(cmd *cobra.Command, api client.API) error {
ctx := cmd.Context()
response, err := api.Agent().License(ctx)
if err != nil {
return fmt.Errorf("error retrieving agent license: %w", err)
}

// For JSON/YAML output
if o.OutputOpts.Format == output.JSONFormat || o.OutputOpts.Format == output.YAMLFormat {
return output.OutputOneNonTabular(cmd, o.OutputOpts, response.LicenseClaims)
}

// Create header data pairs for key-value output
headerData := []collections.Pair[string, any]{
{Left: "Product", Right: response.Product},
{Left: "License ID", Right: response.LicenseID},
{Left: "Customer ID", Right: response.CustomerID},
{Left: "Valid Until", Right: response.ExpiresAt.Format(time.DateOnly)},
{Left: "Version", Right: response.LicenseVersion},
{Left: "Expired", Right: response.IsExpired()},
}

// Always show Capabilities
capabilitiesStr := "{}"
if len(response.Capabilities) > 0 {
var caps []string
for k, v := range response.Capabilities {
caps = append(caps, fmt.Sprintf("%s=%s", k, v))
}
capabilitiesStr = strings.Join(caps, ", ")
}
headerData = append(headerData, collections.Pair[string, any]{
Left: "Capabilities",
Right: capabilitiesStr,
})

// Always show Metadata
metadataStr := "{}"
if len(response.Metadata) > 0 {
var meta []string
for k, v := range response.Metadata {
meta = append(meta, fmt.Sprintf("%s=%s", k, v))
}
metadataStr = strings.Join(meta, ", ")
}
headerData = append(headerData, collections.Pair[string, any]{
Left: "Metadata",
Right: metadataStr,
})

output.KeyValue(cmd, headerData)
return nil
}
15 changes: 15 additions & 0 deletions cmd/cli/agent/license/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package license

import (
"github.com/spf13/cobra"
)

func NewAgentLicenseRootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "license",
Short: "Commands to interact with the orchestrator license",
}

cmd.AddCommand(NewAgentLicenseInspectCmd())
return cmd
}
2 changes: 2 additions & 0 deletions cmd/cli/agent/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agent

import (
"github.com/bacalhau-project/bacalhau/cmd/cli/agent/license"
"github.com/spf13/cobra"

"github.com/bacalhau-project/bacalhau/cmd/util/hook"
Expand All @@ -17,5 +18,6 @@ func NewCmd() *cobra.Command {
cmd.AddCommand(NewNodeCmd())
cmd.AddCommand(NewVersionCmd())
cmd.AddCommand(NewConfigCmd())
cmd.AddCommand(license.NewAgentLicenseRootCmd())
return cmd
}
3 changes: 2 additions & 1 deletion cmd/cli/license/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func (o *InspectOptions) Run(ctx context.Context, cmd *cobra.Command) error {
}

// Validate the license token
claims, err := validator.ValidateToken(license.License)
claims, err := validator.Validate(license.License)
if err != nil {
return fmt.Errorf("invalid license: %w", err)
}
Expand All @@ -102,6 +102,7 @@ func (o *InspectOptions) Run(ctx context.Context, cmd *cobra.Command) error {
{Left: "Customer ID", Right: claims.CustomerID},
{Left: "Valid Until", Right: claims.ExpiresAt.Format(time.DateOnly)},
{Left: "Version", Right: claims.LicenseVersion},
{Left: "Expired", Right: claims.IsExpired()},
}

// Always show Capabilities
Expand Down
24 changes: 19 additions & 5 deletions cmd/cli/license/inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ License ID = e66d1f3a-a8d8-4d57-8f14-00722844afe2
Customer ID = test-customer-id-123
Valid Until = 2045-07-28
Version = v1
Expired = false
Capabilities = max_nodes=1
Metadata = {}`

Expand Down Expand Up @@ -253,6 +254,7 @@ License ID = e66d1f3a-a8d8-4d57-8f14-00722844afe2
Customer ID = test-customer-id-123
Valid Until = 2045-07-28
Version = v1
Expired = false
Capabilities = max_nodes=1
Metadata = {}`

Expand Down Expand Up @@ -324,6 +326,7 @@ License ID = 2d58c7c9-ec29-45a5-a5cd-cb8f7fee6678
Customer ID = test-customer-id-123
Valid Until = 2045-07-28
Version = v1
Expired = false
Capabilities = max_nodes=1
Metadata = someMetadata=valueOfSomeMetadata`

Expand Down Expand Up @@ -406,7 +409,7 @@ func TestInspectInvalidSignatureLicenseToken(t *testing.T) {

err = cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse license: token signature is invalid")
assert.Contains(t, err.Error(), "invalid license: license validation error: token signature is invalid")
}

func TestInspectExpiredLicenseToken(t *testing.T) {
Expand All @@ -427,8 +430,19 @@ func TestInspectExpiredLicenseToken(t *testing.T) {
cmd.SetArgs([]string{filePath})

err = cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid license: failed to parse license: token has invalid claims: token is expired")
require.NoError(t, err)

output := buf.String()
expectedOutput := `Product = Bacalhau
License ID = 0dd04c84-09b8-4179-88f7-c72a9d56c0a2
Customer ID = test-customer-id-123
Valid Until = 2025-01-07
Version = v1
Expired = true
Capabilities = max_nodes=1
Metadata = someMetadata=valueOfSomeMetadata`

assert.Equal(t, expectedOutput, strings.TrimSpace(output))
}

func TestInspectMalformedLicenseFile(t *testing.T) {
Expand All @@ -448,12 +462,12 @@ func TestInspectMalformedLicenseFile(t *testing.T) {
{
name: "missing license key",
content: `{"some_other_key": "value"}`,
expectedErr: "invalid license: failed to parse license: token is malformed",
expectedErr: "invalid license: license validation error: token is malformed",
},
{
name: "random string as license",
content: `{"license": "some random string"}`,
expectedErr: "invalid license: failed to parse license: token is malformed",
expectedErr: "invalid license: license validation error: token is malformed",
},
}

Expand Down
1 change: 1 addition & 0 deletions pkg/config/types/generated_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const OrchestratorEnabledKey = "Orchestrator.Enabled"
const OrchestratorEvaluationBrokerMaxRetryCountKey = "Orchestrator.EvaluationBroker.MaxRetryCount"
const OrchestratorEvaluationBrokerVisibilityTimeoutKey = "Orchestrator.EvaluationBroker.VisibilityTimeout"
const OrchestratorHostKey = "Orchestrator.Host"
const OrchestratorLicenseLocalPathKey = "Orchestrator.License.LocalPath"
const OrchestratorNodeManagerDisconnectTimeoutKey = "Orchestrator.NodeManager.DisconnectTimeout"
const OrchestratorNodeManagerManualApprovalKey = "Orchestrator.NodeManager.ManualApproval"
const OrchestratorPortKey = "Orchestrator.Port"
Expand Down
1 change: 1 addition & 0 deletions pkg/config/types/generated_descriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ var ConfigDescriptions = map[string]string{
OrchestratorEvaluationBrokerMaxRetryCountKey: "MaxRetryCount specifies the maximum number of times an evaluation can be retried before being marked as failed.",
OrchestratorEvaluationBrokerVisibilityTimeoutKey: "VisibilityTimeout specifies how long an evaluation can be claimed before it's returned to the queue.",
OrchestratorHostKey: "Host specifies the hostname or IP address on which the Orchestrator server listens for compute node connections.",
OrchestratorLicenseLocalPathKey: "LocalPath specifies the local license file path",
OrchestratorNodeManagerDisconnectTimeoutKey: "DisconnectTimeout specifies how long to wait before considering a node disconnected.",
OrchestratorNodeManagerManualApprovalKey: "ManualApproval, if true, requires manual approval for new compute nodes joining the cluster.",
OrchestratorPortKey: "Host specifies the port number on which the Orchestrator server listens for compute node connections.",
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/types/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Orchestrator struct {
EvaluationBroker EvaluationBroker `yaml:"EvaluationBroker,omitempty" json:"EvaluationBroker,omitempty"`
// SupportReverseProxy configures the orchestrator node to run behind a reverse proxy
SupportReverseProxy bool `yaml:"SupportReverseProxy,omitempty" json:"SupportReverseProxy,omitempty"`
// License specifies license configuration for orchestrator node
License License `yaml:"License,omitempty" json:"License,omitempty"`
}

type OrchestratorAuth struct {
Expand Down Expand Up @@ -76,3 +78,8 @@ type EvaluationBroker struct {
// MaxRetryCount specifies the maximum number of times an evaluation can be retried before being marked as failed.
MaxRetryCount int `yaml:"MaxRetryCount,omitempty" json:"MaxRetryCount,omitempty"`
}

type License struct {
// LocalPath specifies the local license file path
LocalPath string `yaml:"LocalPath,omitempty" json:"LocalPath,omitempty"`
}
93 changes: 69 additions & 24 deletions pkg/lib/license/license_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package license

import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"time"

"github.com/MicahParks/keyfunc/v3"
Expand All @@ -28,6 +30,27 @@ type LicenseClaims struct {
Metadata map[string]string `json:"metadata,omitempty"`
}

func (v *LicenseClaims) IsExpired() bool {
if v.ExpiresAt == nil {
return true
}
return v.ExpiresAt.Before(time.Now())
}

func (v *LicenseClaims) MaxNumberOfNodes() int {
maxNodesStr := v.Capabilities["max_nodes"]
if maxNodesStr == "" {
return 0
}

maxNodes, err := strconv.Atoi(maxNodesStr)
if err != nil {
return 0
}

return maxNodes
}

// Ignoring spell check due to the abundance of JWT slugs
// cSpell:disable
//
Expand Down Expand Up @@ -101,42 +124,64 @@ func NewOfflineLicenseValidator() (*LicenseValidator, error) {
return NewLicenseValidatorFromJSON(json.RawMessage(defaultOfflineJWKSVerificationKeys))
}

// ValidateToken validates a license token and returns the claims
func (v *LicenseValidator) ValidateToken(tokenString string) (*LicenseClaims, error) {
var claims LicenseClaims
func (v *LicenseValidator) Validate(tokenString string) (*LicenseClaims, error) {
return v.validateToken(tokenString, false)
}

func (v *LicenseValidator) ValidateStrict(tokenString string) (*LicenseClaims, error) {
return v.validateToken(tokenString, true)
}

// Parse and validate the token
token, err := jwt.ParseWithClaims(tokenString, &claims, v.keyFunc)
func (v *LicenseValidator) validateToken(tokenString string, verifyExpiry bool) (*LicenseClaims, error) {
parsedToken, err := jwt.ParseWithClaims(
tokenString,
&LicenseClaims{},
v.keyFunc,
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
)

// JWT token validation failed
if err != nil {
return nil, fmt.Errorf("failed to parse license: %w", err)
// Check if the error is due to expiration and if we are not verifying expiration
if errors.Is(err, jwt.ErrTokenExpired) && !verifyExpiry {
parsedUnverifiedToken, _, unverifiedErr := jwt.NewParser().ParseUnverified(tokenString, &LicenseClaims{})

if unverifiedErr != nil {
return nil, fmt.Errorf("license validation error: %w", unverifiedErr)
}

licenseClaims, ok := parsedUnverifiedToken.Claims.(*LicenseClaims)
if !ok {
return nil, fmt.Errorf("license validation error: invalid claims")
}

// Validate License specific field values
if unverifiedErr = v.validateAdditionalConstraints(licenseClaims); unverifiedErr != nil {
return nil, fmt.Errorf("license validation error: %w", unverifiedErr)
}

return licenseClaims, nil
}

return nil, fmt.Errorf("license validation error: %w", err)
}

if !token.Valid {
return nil, fmt.Errorf("invalid license")
verifiedLicenseClaims, typeMatches := parsedToken.Claims.(*LicenseClaims)
if !typeMatches {
return nil, fmt.Errorf("license validation error: invalid claims")
}

// Additional validation can be added here
if err := v.validateAdditionalConstraints(&claims); err != nil {
return nil, err
// Validate License specific field values
if err = v.validateAdditionalConstraints(verifiedLicenseClaims); err != nil {
return nil, fmt.Errorf("license validation error: %w", err)
}

return &claims, nil
return verifiedLicenseClaims, nil
}

// validateAdditionalConstraints performs additional business logic validation
func (v *LicenseValidator) validateAdditionalConstraints(claims *LicenseClaims) error {
now := time.Now()

// Check if token is expired
if claims.ExpiresAt != nil && claims.ExpiresAt.Before(now) {
return fmt.Errorf("license has expired")
}

// Check if token is not yet valid
if claims.NotBefore != nil && claims.NotBefore.After(now) {
return fmt.Errorf("license is not yet valid")
}

// Only perform additional validations for v1 licenses
if claims.LicenseVersion == "v1" {
// Verify product name
Expand Down
Loading

0 comments on commit a088691

Please sign in to comment.