Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Extension installation** — install extensions with `dtctl create extension`; `--hub-extension <id>` installs a Hub catalog extension (optionally pin a release with `--version`); `-f <file.zip>` uploads a custom extension package; `--dry-run` previews without applying; requires the `extensions:definitions:write` token scope
- **Extended `describe extension` command** — `--monitoring-configuration-schema` outputs the JSON Schema for monitoring configurations of a specific extension version; `--active-gate-groups` lists available ActiveGate groups for a version; `--no-fluff` strips `documentation`, `displayName`, and `customMessage` fields from schema output (use with `--monitoring-configuration-schema`)
- **`enable gcp|azure monitoring` command** — new `dtctl enable` verb that completes cloud monitoring onboarding in one step: optionally updates the linked connection credentials (service account for GCP; directory/application ID for Azure) and enables the monitoring config; `--serviceAccountId`, `--directoryId`, `--applicationId` are all optional — if omitted, only the enabled state is toggled; supports `--dry-run`
- **Cloud monitoring configs created as disabled** — `dtctl create gcp monitoring` and `dtctl create azure monitoring` now create configs in a disabled state (`enabled: false`); use `dtctl enable gcp|azure monitoring` to enable

Expand Down
3 changes: 2 additions & 1 deletion cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ apply is idempotent (creates if new, updates if existing).
Supported resources:
workflows (wf) dashboards (dash, db) notebooks (nb)
slos settings buckets (bkt)
edgeconnect (ec) lookup-tables (lu)`,
edgeconnect (ec) lookup-tables (lu) extensions (ext)`,
Example: ` # Create a workflow from a YAML file
dtctl create workflow -f workflow.yaml

Expand Down Expand Up @@ -48,4 +48,5 @@ func init() {
createCmd.AddCommand(createBreakpointCmd)
createCmd.AddCommand(createSegmentCmd)
createCmd.AddCommand(createAnomalyDetectorCmd)
createCmd.AddCommand(createExtensionCmd)
}
125 changes: 125 additions & 0 deletions cmd/create_extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/dynatrace-oss/dtctl/pkg/output"
"github.com/dynatrace-oss/dtctl/pkg/resources/extension"
"github.com/dynatrace-oss/dtctl/pkg/safety"
)

// createExtensionCmd installs an extension — either a custom zip upload or a Hub extension.
var createExtensionCmd = &cobra.Command{
Use: "extension",
Aliases: []string{"ext"},
Short: "Install an Extensions 2.0 extension",
Long: `Install an Extensions 2.0 extension into the Dynatrace environment.

Two installation modes are supported:

1. Upload a custom extension from a local zip file:
dtctl create extension -f custom-extension.zip

2. Install a Dynatrace Hub extension by its catalog ID:
dtctl create extension --hub-extension <id> [--version <version>]

If --version is omitted, the latest available Hub release is installed.

Examples:
# Upload a custom extension package
dtctl create extension -f my-extension.zip

# Install a Hub extension (latest version)
dtctl create extension --hub-extension com.dynatrace.extension.host-monitoring

# Install a specific version of a Hub extension
dtctl create extension --hub-extension com.dynatrace.extension.host-monitoring --version 1.2.3

# Preview what would be installed (dry run)
dtctl create extension -f my-extension.zip --dry-run
`,
RunE: func(cmd *cobra.Command, args []string) error {
file, _ := cmd.Flags().GetString("file")
hubExtension, _ := cmd.Flags().GetString("hub-extension")
version, _ := cmd.Flags().GetString("version")

// Exactly one of --file or --hub-extension must be provided
if file == "" && hubExtension == "" {
return fmt.Errorf("either --file or --hub-extension is required")
}
if file != "" && hubExtension != "" {
return fmt.Errorf("--file and --hub-extension are mutually exclusive")
}

if file != "" {
return runUploadExtension(file)
}
return runInstallHubExtension(hubExtension, version)
},
}

func runUploadExtension(file string) error {
// Read the zip file
zipData, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read file %q: %w", file, err)
}

if dryRun {
fmt.Printf("Dry run: would upload extension from %s (%d bytes)\n", file, len(zipData))
return nil
}

_, c, err := SetupWithSafety(safety.OperationCreate)
if err != nil {
return err
}

handler := extension.NewHandler(c)
result, err := handler.Upload(filepath.Base(file), zipData)
if err != nil {
return err
}

output.PrintSuccess("Extension uploaded")
output.PrintInfo(" Name: %s", result.ExtensionName)
output.PrintInfo(" Version: %s", result.Version)
return nil
}

func runInstallHubExtension(extensionID, version string) error {
if dryRun {
if version != "" {
fmt.Printf("Dry run: would install Hub extension %q version %s\n", extensionID, version)
} else {
fmt.Printf("Dry run: would install Hub extension %q (latest version)\n", extensionID)
}
return nil
}

_, c, err := SetupWithSafety(safety.OperationCreate)
if err != nil {
return err
}

handler := extension.NewHandler(c)
result, err := handler.InstallFromHub(extensionID, version)
if err != nil {
return err
}

output.PrintSuccess("Hub extension installed")
output.PrintInfo(" Name: %s", result.ExtensionName)
output.PrintInfo(" Version: %s", result.Version)
return nil
}

func init() {
createExtensionCmd.Flags().StringP("file", "f", "", "path to the extension zip file (for custom extension upload)")
createExtensionCmd.Flags().String("hub-extension", "", "Hub extension catalog ID to install (e.g. com.dynatrace.extension.host-monitoring)")
createExtensionCmd.Flags().String("version", "", "version to install (only for --hub-extension; defaults to latest)")
}
78 changes: 78 additions & 0 deletions cmd/describe_extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,27 @@ Examples:

# Describe a specific version
dtctl describe extension com.dynatrace.extension.host-monitoring --version 1.2.3

# Show only the monitoring configuration schema for a specific version
dtctl describe extension com.dynatrace.extension.host-monitoring --version 1.2.3 --monitoring-configuration-schema

# List active gate groups available for a specific version
dtctl describe extension com.dynatrace.extension.host-monitoring --version 1.2.3 --active-gate-groups
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
extensionName := args[0]
versionFlag, _ := cmd.Flags().GetString("version")
monConfigSchema, _ := cmd.Flags().GetBool("monitoring-configuration-schema")
activeGateGroups, _ := cmd.Flags().GetBool("active-gate-groups")
noFluff, _ := cmd.Flags().GetBool("no-fluff")

if monConfigSchema && activeGateGroups {
return fmt.Errorf("--monitoring-configuration-schema and --active-gate-groups are mutually exclusive")
}
if noFluff && !monConfigSchema {
return fmt.Errorf("--no-fluff only applies to --monitoring-configuration-schema")
}

_, c, printer, err := Setup()
if err != nil {
Expand Down Expand Up @@ -91,6 +107,65 @@ Examples:
return fmt.Errorf("no versions found for extension %q", extensionName)
}

// --monitoring-configuration-schema: output only the JSON Schema for monitoring configs
if monConfigSchema {
schema, err := handler.GetMonitoringConfigurationSchema(extensionName, targetVersion)
if err != nil {
return err
}
var schemaObj interface{}
if err := json.Unmarshal(schema, &schemaObj); err != nil {
return fmt.Errorf("failed to parse schema: %w", err)
}
if noFluff {
schemaObj = extension.StripSchemaFluff(schemaObj)
}
// Table format has no structured columns for an arbitrary JSON Schema,
// so print it as indented JSON directly. enrichAgent is skipped because
// there is no printer involved.
if outputFormat == "table" {
indented, err := json.MarshalIndent(schemaObj, "", " ")
if err != nil {
return fmt.Errorf("failed to format schema: %w", err)
}
fmt.Println(string(indented))
return nil
}
enrichAgent(printer, "describe", "extension")
return printer.Print(schemaObj)
}

// --active-gate-groups: output only the active gate groups for this version
if activeGateGroups {
groups, err := handler.GetActiveGateGroups(extensionName, targetVersion)
if err != nil {
return err
}
if outputFormat == "table" {
if len(groups.Items) == 0 {
fmt.Println("No active gate groups found.")
return nil
}
output.DescribeSection(fmt.Sprintf("Active Gate Groups (%d):", len(groups.Items)))
for _, g := range groups.Items {
fmt.Printf(" %s (available: %d)\n", g.GroupName, g.AvailableActiveGates)
for _, ag := range g.ActiveGates {
var errList []interface{}
_ = json.Unmarshal(ag.Errors, &errList)
if len(errList) > 0 {
errBytes, _ := json.Marshal(errList)
fmt.Printf(" - id: %d errors: %s\n", ag.ID, string(errBytes))
} else {
fmt.Printf(" - id: %d\n", ag.ID)
}
}
}
return nil
}
enrichAgent(printer, "describe", "extension")
return printer.PrintList(groups.Items)
}

// Get detailed information for the target version
details, err := handler.GetVersion(extensionName, targetVersion)
if err != nil {
Expand Down Expand Up @@ -239,4 +314,7 @@ Examples:

func init() {
describeExtensionCmd.Flags().String("version", "", "Show details for a specific extension version")
describeExtensionCmd.Flags().Bool("monitoring-configuration-schema", false, "Output only the monitoring configuration schema for this extension version")
describeExtensionCmd.Flags().Bool("active-gate-groups", false, "List active gate groups available for this extension version")
describeExtensionCmd.Flags().Bool("no-fluff", false, "Strip documentation, customMessage, and displayName fields from schema output (use with --monitoring-configuration-schema)")
}
138 changes: 138 additions & 0 deletions cmd/describe_extensions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"encoding/json"
"reflect"
"testing"

"github.com/dynatrace-oss/dtctl/pkg/resources/extension"
)

func TestStripSchemaFluff_RemovesTopLevelKeys(t *testing.T) {
input := map[string]interface{}{
"type": "object",
"displayName": "My Extension",
"documentation": "Some docs",
"customMessage": "A message",
"description": "kept",
}

result := extension.StripSchemaFluff(input).(map[string]interface{})

for _, removed := range []string{"displayName", "documentation", "customMessage"} {
if _, ok := result[removed]; ok {
t.Errorf("expected key %q to be removed but it was not", removed)
}
}
if result["type"] != "object" {
t.Errorf("expected type to be kept, got %v", result["type"])
}
if result["description"] != "kept" {
t.Errorf("expected description to be kept, got %v", result["description"])
}
}

func TestStripSchemaFluff_RecursiveProperties(t *testing.T) {
input := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"host": map[string]interface{}{
"type": "string",
"displayName": "Hostname",
"documentation": "The host.",
"description": "kept",
},
},
}

result := extension.StripSchemaFluff(input).(map[string]interface{})
props := result["properties"].(map[string]interface{})
host := props["host"].(map[string]interface{})

if _, ok := host["displayName"]; ok {
t.Error("expected displayName to be removed from nested property")
}
if _, ok := host["documentation"]; ok {
t.Error("expected documentation to be removed from nested property")
}
if host["type"] != "string" {
t.Errorf("expected type to be kept in nested property, got %v", host["type"])
}
if host["description"] != "kept" {
t.Errorf("expected description to be kept in nested property, got %v", host["description"])
}
}

func TestStripSchemaFluff_RecursiveArray(t *testing.T) {
input := map[string]interface{}{
"type": "array",
"items": []interface{}{
map[string]interface{}{
"type": "string",
"displayName": "Item label",
"description": "kept",
},
},
}

result := extension.StripSchemaFluff(input).(map[string]interface{})
items := result["items"].([]interface{})
item := items[0].(map[string]interface{})

if _, ok := item["displayName"]; ok {
t.Error("expected displayName to be removed from array item")
}
if item["description"] != "kept" {
t.Errorf("expected description to be kept in array item, got %v", item["description"])
}
}

func TestStripSchemaFluff_LeavesPrimitivesUntouched(t *testing.T) {
cases := []interface{}{
"a string",
42,
true,
nil,
}
for _, c := range cases {
result := extension.StripSchemaFluff(c)
if !reflect.DeepEqual(result, c) {
t.Errorf("expected primitive %v to be unchanged, got %v", c, result)
}
}
}

func TestStripSchemaFluff_EmptyObject(t *testing.T) {
input := map[string]interface{}{}
result := extension.StripSchemaFluff(input).(map[string]interface{})
if len(result) != 0 {
t.Errorf("expected empty map to remain empty, got %v", result)
}
}

func TestStripSchemaFluff_NoFluffKeys(t *testing.T) {
input := map[string]interface{}{
"type": "object",
"description": "no fluff here",
"properties": map[string]interface{}{},
}
// Deep-copy via JSON round-trip to compare
before, _ := json.Marshal(input)
result := extension.StripSchemaFluff(input)
after, _ := json.Marshal(result)
if string(before) != string(after) {
t.Errorf("expected no change when no fluff keys present\nbefore: %s\nafter: %s", before, after)
}
}

func TestFluffKeys_ContainsExpectedKeys(t *testing.T) {
expected := []string{"documentation", "customMessage", "displayName"}
for _, k := range expected {
if !extension.FluffKeys[k] {
t.Errorf("expected FluffKeys to contain %q", k)
}
}
if len(extension.FluffKeys) != len(expected) {
t.Errorf("expected FluffKeys to have exactly %d entries, got %d", len(expected), len(extension.FluffKeys))
}
}
Loading