diff --git a/aws-source/adapters/ecs-task_test.go b/aws-source/adapters/ecs-task_test.go index 9179758e..d149d316 100644 --- a/aws-source/adapters/ecs-task_test.go +++ b/aws-source/adapters/ecs-task_test.go @@ -124,6 +124,7 @@ func TestTaskGetInputMapper(t *testing.T) { if input == nil { t.Fatal("input is nil") + return } if *input.Cluster != "test-ECSCluster-Bt4SqcM3CURk" { diff --git a/aws-source/build/package/Dockerfile b/aws-source/build/package/Dockerfile index e6cd5153..6f9aba9d 100644 --- a/aws-source/build/package/Dockerfile +++ b/aws-source/build/package/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor -RUN apk add --no-cache git +RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source aws-source/main.go -FROM alpine:3.23 +FROM alpine:3.23.3 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 diff --git a/cmd/auth_client_test.go b/cmd/auth_client_test.go index ec4516fc..eaf03177 100644 --- a/cmd/auth_client_test.go +++ b/cmd/auth_client_test.go @@ -208,6 +208,7 @@ func TestNewRetryableHTTPClientRespectsProxy(t *testing.T) { if httpTransport == nil { t.Fatal("Could not get http.Transport") + return } // Verify proxy function is set to ProxyFromEnvironment diff --git a/cmd/changes_get_change.go b/cmd/changes_get_change.go index 7bf1a03d..30cded83 100644 --- a/cmd/changes_get_change.go +++ b/cmd/changes_get_change.go @@ -5,7 +5,6 @@ import ( "fmt" "slices" "strings" - "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" @@ -72,51 +71,12 @@ func GetChange(cmd *cobra.Command, args []string) error { } client := AuthenticatedChangesClient(ctx, oi) -fetch: - for { - changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ - Msg: &sdp.GetChangeRequest{ - UUID: changeUuid[:], - }, - }) - if err != nil || changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { - return loggedError{ - err: err, - fields: lf, - message: "failed to get change", - } - } - ch := changeRes.Msg.GetChange() - md := ch.GetMetadata() - if md == nil || md.GetChangeAnalysisStatus() == nil { - return loggedError{ - err: fmt.Errorf("change metadata or change analysis status is nil"), - fields: lf, - message: "failed to get change", - } - } - status := md.GetChangeAnalysisStatus().GetStatus() - switch status { - case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: - break fetch - case sdp.ChangeAnalysisStatus_STATUS_ERROR: - return loggedError{ - err: fmt.Errorf("change analysis completed with error status"), - fields: lf, - message: "change analysis failed", - } - case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: - log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Waiting for change analysis to complete") - } - time.Sleep(3 * time.Second) - if ctx.Err() != nil { - return loggedError{ - err: ctx.Err(), - fields: lf, - message: "context cancelled", - } + if viper.GetBool("wait") { + if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { + return err } } + app, _ = strings.CutSuffix(app, "/") // get the change var format sdp.ChangeOutputFormat @@ -184,7 +144,8 @@ func init() { getChangeCmd.PersistentFlags().String("status", "CHANGE_STATUS_DEFINING", "The expected status of the change. Use this with --ticket-link to get the first change with that status for a given ticket link. Allowed values: CHANGE_STATUS_DEFINING (ready for analysis/analysis in progress), CHANGE_STATUS_HAPPENING (deployment in progress), CHANGE_STATUS_DONE (deployment completed)") getChangeCmd.PersistentFlags().String("frontend", "", "The frontend base URL") - _ = submitPlanCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.") // MarkDeprecated only errors if the flag doesn't exist, we fall back to using app + cobra.CheckErr(getChangeCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) + getChangeCmd.PersistentFlags().Bool("wait", true, "Wait for analysis to complete before returning. Set to false to return immediately with the current status.") getChangeCmd.PersistentFlags().String("format", "json", "How to render the change. Possible values: json, markdown") getChangeCmd.PersistentFlags().StringSlice("risk-levels", []string{"high", "medium", "low"}, "Only show changes with the specified risk levels. Allowed values: high, medium, low") } diff --git a/cmd/changes_get_change_test.go b/cmd/changes_get_change_test.go index 55353e56..c014bf08 100644 --- a/cmd/changes_get_change_test.go +++ b/cmd/changes_get_change_test.go @@ -7,6 +7,20 @@ import ( "github.com/overmindtech/cli/go/sdp-go" ) +func TestGetChangeCmdHasWaitFlag(t *testing.T) { + t.Parallel() + + flag := getChangeCmd.PersistentFlags().Lookup("wait") + if flag == nil { + t.Error("Expected wait flag to be registered on get-change command") + return + } + + if flag.DefValue != "true" { + t.Errorf("Expected wait flag default value to be 'true', got %q", flag.DefValue) + } +} + func TestValidateChangeStatus(t *testing.T) { tests := []struct { name string diff --git a/cmd/changes_get_signals.go b/cmd/changes_get_signals.go index 03bf9ff3..0b0ac386 100644 --- a/cmd/changes_get_signals.go +++ b/cmd/changes_get_signals.go @@ -3,7 +3,6 @@ package cmd import ( _ "embed" "fmt" - "time" "connectrpc.com/connect" "github.com/overmindtech/cli/go/sdp-go" @@ -55,51 +54,10 @@ func GetSignals(cmd *cobra.Command, args []string) error { } client := AuthenticatedChangesClient(ctx, oi) -fetch: - for { - changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ - Msg: &sdp.GetChangeRequest{ - UUID: changeUuid[:], - }, - }) - if err != nil || changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { - return loggedError{ - err: err, - fields: lf, - message: "failed to get change", - } - } - ch := changeRes.Msg.GetChange() - md := ch.GetMetadata() - if md == nil || md.GetChangeAnalysisStatus() == nil { - return loggedError{ - err: fmt.Errorf("change metadata or change analysis status is nil"), - fields: lf, - message: "failed to get change", - } - } - status := md.GetChangeAnalysisStatus().GetStatus() - switch status { - case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: - break fetch - case sdp.ChangeAnalysisStatus_STATUS_ERROR: - return loggedError{ - err: fmt.Errorf("change analysis completed with error status"), - fields: lf, - message: "change analysis failed", - } - case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: - log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Waiting for change analysis to complete") - } - time.Sleep(3 * time.Second) - if ctx.Err() != nil { - return loggedError{ - err: ctx.Err(), - fields: lf, - message: "context cancelled", - } - } + if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { + return err } + // get the change signals var format sdp.ChangeOutputFormat switch viper.GetString("format") { @@ -140,6 +98,6 @@ func init() { getSignalsCmd.PersistentFlags().String("status", "CHANGE_STATUS_DEFINING", "The expected status of the change. Use this with --ticket-link to get the first change with that status for a given ticket link. Allowed values: CHANGE_STATUS_DEFINING (ready for analysis/analysis in progress), CHANGE_STATUS_HAPPENING (deployment in progress), CHANGE_STATUS_DONE (deployment completed)") getSignalsCmd.PersistentFlags().String("frontend", "", "The frontend base URL") - _ = getSignalsCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.") // MarkDeprecated only errors if the flag doesn't exist, we fall back to using app + cobra.CheckErr(getSignalsCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) getSignalsCmd.PersistentFlags().String("format", "json", "How to render the signals. Possible values: json, markdown") } diff --git a/cmd/changes_start_analysis.go b/cmd/changes_start_analysis.go new file mode 100644 index 00000000..73c7ac91 --- /dev/null +++ b/cmd/changes_start_analysis.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/tracing" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// startAnalysisCmd represents the start-analysis command +var startAnalysisCmd = &cobra.Command{ + Use: "start-analysis {--ticket-link URL | --uuid ID | --change URL}", + Short: "Triggers analysis on a change with previously stored planned changes", + Long: `Triggers analysis on a change that has previously stored planned changes. + +This command is used in multi-plan workflows (e.g., Atlantis parallel planning) where +multiple terraform plans are submitted independently using 'submit-plan --no-start', +and then analysis is triggered once all plans are submitted. + +The change must be in DEFINING status and must have at least one planned change stored.`, + PreRun: PreRunSetup, + RunE: StartAnalysis, +} + +func StartAnalysis(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + app := viper.GetString("app") + + ctx, oi, _, err := login(ctx, cmd, []string{"changes:write", "sources:read"}, nil) + if err != nil { + return err + } + + lf := log.Fields{} + + changeUUID, err := getChangeUUIDAndCheckStatus(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), true) + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "failed to identify change", + } + } + + lf["change"] = changeUUID.String() + + analysisConfig, err := buildAnalysisConfig(ctx, lf) + if err != nil { + return err + } + + client := AuthenticatedChangesClient(ctx, oi) + + resp, err := client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ + Msg: &sdp.StartChangeAnalysisRequest{ + ChangeUUID: changeUUID[:], + ChangingItems: nil, // uses pre-stored items from AddPlannedChanges + BlastRadiusConfigOverride: analysisConfig.BlastRadiusConfig, + RoutineChangesConfigOverride: analysisConfig.RoutineChangesConfig, + GithubOrganisationProfileOverride: analysisConfig.GithubOrgProfile, + Knowledge: analysisConfig.KnowledgeFiles, + PostGithubComment: viper.GetBool("comment"), + }, + }) + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "failed to start change analysis", + } + } + + app, _ = strings.CutSuffix(app, "/") + changeUrl := fmt.Sprintf("%v/changes/%v?utm_source=cli&cli_version=%v", app, changeUUID, tracing.Version()) + log.WithContext(ctx).WithFields(lf).WithField("change-url", changeUrl).Info("Change analysis started") + + if viper.GetBool("comment") { + fmt.Printf("CHANGE_URL='%s'\n", changeUrl) + fmt.Printf("GITHUB_APP_ACTIVE='%v'\n", resp.Msg.GetGithubAppActive()) + } else { + fmt.Println(changeUrl) + } + + if viper.GetBool("wait") { + log.WithContext(ctx).WithFields(lf).Info("Waiting for analysis to complete") + return waitForChangeAnalysis(ctx, client, changeUUID, lf) + } + + return nil +} + +func init() { + changesCmd.AddCommand(startAnalysisCmd) + + addAPIFlags(startAnalysisCmd) + addChangeUuidFlags(startAnalysisCmd) + addAnalysisFlags(startAnalysisCmd) + + startAnalysisCmd.PersistentFlags().Bool("wait", false, "Wait for analysis to complete before returning.") +} diff --git a/cmd/changes_start_analysis_test.go b/cmd/changes_start_analysis_test.go new file mode 100644 index 00000000..9b80d3c0 --- /dev/null +++ b/cmd/changes_start_analysis_test.go @@ -0,0 +1,236 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func TestAddAnalysisFlags(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + addAnalysisFlags(cmd) + + tests := []struct { + name string + flagName string + flagType string + }{ + {"blast-radius-link-depth", "blast-radius-link-depth", "int32"}, + {"blast-radius-max-items", "blast-radius-max-items", "int32"}, + {"blast-radius-max-time", "blast-radius-max-time", "duration"}, + {"change-analysis-target-duration", "change-analysis-target-duration", "duration"}, + {"signal-config", "signal-config", "string"}, + {"comment", "comment", "bool"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := cmd.PersistentFlags().Lookup(tt.flagName) + if flag == nil { + t.Errorf("Expected flag %q to be registered", tt.flagName) + return + } + if flag.Value.Type() != tt.flagType { + t.Errorf("Expected flag %q to have type %q, got %q", tt.flagName, tt.flagType, flag.Value.Type()) + } + }) + } + + // Verify blast-radius-max-time is deprecated + flag := cmd.PersistentFlags().Lookup("blast-radius-max-time") + if flag == nil { + t.Error("Expected blast-radius-max-time flag to be registered") + return + } + if flag.Deprecated == "" { + t.Error("Expected blast-radius-max-time flag to be deprecated") + } +} + +func TestBuildAnalysisConfigWithNoFlags(t *testing.T) { + // Reset viper to ensure clean state + viper.Reset() + + ctx := context.Background() + lf := log.Fields{} + + // When no flags are set, buildAnalysisConfig should succeed with nil/empty configs + config, err := buildAnalysisConfig(ctx, lf) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config == nil { + t.Fatal("Expected config to be non-nil") + } + + // BlastRadiusConfig should be nil when no flags are set + if config.BlastRadiusConfig != nil { + t.Errorf("Expected BlastRadiusConfig to be nil when no flags are set") + } + + // RoutineChangesConfig should be nil when no signal config file exists + if config.RoutineChangesConfig != nil { + t.Errorf("Expected RoutineChangesConfig to be nil when no signal config exists") + } + + // GithubOrgProfile should be nil when no signal config file exists + if config.GithubOrgProfile != nil { + t.Errorf("Expected GithubOrgProfile to be nil when no signal config exists") + } +} + +func TestBuildAnalysisConfigWithBlastRadiusFlags(t *testing.T) { + // Reset viper to ensure clean state + viper.Reset() + + viper.Set("blast-radius-link-depth", int32(5)) + viper.Set("blast-radius-max-items", int32(1000)) + + ctx := context.Background() + lf := log.Fields{} + + config, err := buildAnalysisConfig(ctx, lf) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config == nil { + t.Fatal("Expected config to be non-nil") + } + + if config.BlastRadiusConfig == nil { + t.Fatal("Expected BlastRadiusConfig to be non-nil") + } + + if config.BlastRadiusConfig.GetLinkDepth() != 5 { + t.Errorf("Expected LinkDepth to be 5, got %d", config.BlastRadiusConfig.GetLinkDepth()) + } + + if config.BlastRadiusConfig.GetMaxItems() != 1000 { + t.Errorf("Expected MaxItems to be 1000, got %d", config.BlastRadiusConfig.GetMaxItems()) + } +} + +func TestBuildAnalysisConfigWithInvalidSignalConfigPath(t *testing.T) { + // Reset viper to ensure clean state + viper.Reset() + + // Set a non-existent signal config path + viper.Set("signal-config", "/nonexistent/path/signal-config.yaml") + + ctx := context.Background() + lf := log.Fields{} + + _, err := buildAnalysisConfig(ctx, lf) + if err == nil { + t.Fatal("Expected error for invalid signal config path") + } +} + +func TestBuildAnalysisConfigWithValidSignalConfig(t *testing.T) { + // Reset viper to ensure clean state + viper.Reset() + + // Create a temporary signal config file with valid content + tempDir := t.TempDir() + signalConfigPath := filepath.Join(tempDir, "signal-config.yaml") + signalConfigContent := `routine_changes_config: + sensitivity: 0 + duration_in_days: 1 + events_per_day: 1 +` + err := os.WriteFile(signalConfigPath, []byte(signalConfigContent), 0644) + if err != nil { + t.Fatalf("Failed to create temp signal config: %v", err) + } + + viper.Set("signal-config", signalConfigPath) + + ctx := context.Background() + lf := log.Fields{} + + config, err := buildAnalysisConfig(ctx, lf) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if config == nil { + t.Fatal("Expected config to be non-nil") + } + + // The signal config should be loaded + if config.RoutineChangesConfig == nil { + t.Error("Expected RoutineChangesConfig to be non-nil when signal config is loaded") + } +} + +func TestStartAnalysisCmdFlags(t *testing.T) { + t.Parallel() + + // Verify the command has the expected flags registered + tests := []struct { + name string + flagName string + }{ + {"wait flag", "wait"}, + {"ticket-link flag", "ticket-link"}, + {"uuid flag", "uuid"}, + {"change flag", "change"}, + {"app flag", "app"}, + {"timeout flag", "timeout"}, + // Analysis flags + {"blast-radius-link-depth", "blast-radius-link-depth"}, + {"blast-radius-max-items", "blast-radius-max-items"}, + {"signal-config", "signal-config"}, + {"comment flag", "comment"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := startAnalysisCmd.PersistentFlags().Lookup(tt.flagName) + if flag == nil { + // Check the parent command's flags + flag = startAnalysisCmd.Flags().Lookup(tt.flagName) + } + if flag == nil { + t.Errorf("Expected flag %q to be registered on start-analysis command", tt.flagName) + } + }) + } +} + +func TestSubmitPlanCmdHasCommentFlag(t *testing.T) { + t.Parallel() + + flag := submitPlanCmd.PersistentFlags().Lookup("comment") + if flag == nil { + t.Error("Expected comment flag to be registered on submit-plan command") + return + } + + if flag.DefValue != "false" { + t.Errorf("Expected comment flag default value to be 'false', got %q", flag.DefValue) + } +} + +func TestSubmitPlanCmdHasNoStartFlag(t *testing.T) { + t.Parallel() + + flag := submitPlanCmd.PersistentFlags().Lookup("no-start") + if flag == nil { + t.Error("Expected no-start flag to be registered on submit-plan command") + return + } + + if flag.DefValue != "false" { + t.Errorf("Expected no-start flag default value to be 'false', got %q", flag.DefValue) + } +} diff --git a/cmd/changes_start_change.go b/cmd/changes_start_change.go index 5d757aef..9877354d 100644 --- a/cmd/changes_start_change.go +++ b/cmd/changes_start_change.go @@ -1,7 +1,6 @@ package cmd import ( - "fmt" "time" "connectrpc.com/connect" @@ -45,50 +44,8 @@ func StartChange(cmd *cobra.Command, args []string) error { // wait for change analysis to complete (poll GetChange by change_analysis_status) client := AuthenticatedChangesClient(ctx, oi) -fetch: - for { - changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ - Msg: &sdp.GetChangeRequest{ - UUID: changeUuid[:], - }, - }) - if err != nil || changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { - return loggedError{ - err: err, - fields: lf, - message: "failed to get change", - } - } - ch := changeRes.Msg.GetChange() - md := ch.GetMetadata() - if md == nil || md.GetChangeAnalysisStatus() == nil { - return loggedError{ - err: fmt.Errorf("change metadata or change analysis status is nil"), - fields: lf, - message: "failed to get change", - } - } - status := md.GetChangeAnalysisStatus().GetStatus() - switch status { - case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: - break fetch - case sdp.ChangeAnalysisStatus_STATUS_ERROR: - return loggedError{ - err: fmt.Errorf("change analysis completed with error status"), - fields: lf, - message: "change analysis failed", - } - case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: - log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Waiting for change analysis to complete") - } - time.Sleep(3 * time.Second) - if ctx.Err() != nil { - return loggedError{ - err: ctx.Err(), - fields: lf, - message: "context cancelled", - } - } + if err := waitForChangeAnalysis(ctx, client, changeUuid, lf); err != nil { + return err } // Call the simple RPC (enqueues a background job and returns immediately) diff --git a/cmd/changes_submit_plan.go b/cmd/changes_submit_plan.go index d3d8171d..a127a636 100644 --- a/cmd/changes_submit_plan.go +++ b/cmd/changes_submit_plan.go @@ -11,9 +11,9 @@ import ( "connectrpc.com/connect" "github.com/google/uuid" - "github.com/overmindtech/cli/knowledge" "github.com/overmindtech/cli/tfutils" "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -22,7 +22,7 @@ import ( // submitPlanCmd represents the submit-plan command var submitPlanCmd = &cobra.Command{ - Use: "submit-plan [--title TITLE] [--description DESCRIPTION] [--ticket-link URL] FILE [FILE ...]", + Use: "submit-plan [--no-start] [--title TITLE] [--description DESCRIPTION] [--ticket-link URL] FILE [FILE ...]", Short: "Creates a new Change from a given terraform plan file", Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -258,62 +258,65 @@ func SubmitPlan(cmd *cobra.Command, args []string) error { log.WithContext(ctx).WithFields(lf).Info("Re-using change") } - // Set up the blast radius preset if specified - maxDepth := viper.GetInt32("blast-radius-link-depth") - maxItems := viper.GetInt32("blast-radius-max-items") - maxTime := viper.GetDuration("blast-radius-max-time") - changeAnalysisTargetDuration := viper.GetDuration("change-analysis-target-duration") + var githubAppActive bool - blastRadiusConfigOverride, err := createBlastRadiusConfig(maxDepth, maxItems, maxTime, changeAnalysisTargetDuration) - if err != nil { - return err - } - - // setup the signal config if specified, or found in the default location - // order of precedence: flag > default config file - signalConfigPath := viper.GetString("signal-config") - signalConfigOverride, err := checkForAndLoadSignalConfigFile(ctx, lf, signalConfigPath) - if err != nil { - return loggedError{ - err: err, - fields: lf, - message: "Failed to load signal config", + if viper.GetBool("no-start") { + if viper.GetBool("comment") { + log.WithContext(ctx).WithFields(lf).Info("--comment has no effect with --no-start; pass --comment to start-analysis instead") + } + // Store planned changes without starting analysis (multi-plan workflow) + _, err = client.AddPlannedChanges(ctx, &connect.Request[sdp.AddPlannedChangesRequest]{ + Msg: &sdp.AddPlannedChangesRequest{ + ChangeUUID: changeUUID[:], + ChangingItems: plannedChanges, + }, + }) + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "Failed to store planned changes", + } + } + log.WithContext(ctx).WithFields(lf).Info("Stored planned changes without starting analysis") + } else { + // Build analysis config and start analysis (default behavior) + analysisConfig, err := buildAnalysisConfig(ctx, lf) + if err != nil { + return err } - } - - var githubOrganisationProfileOverride *sdp.GithubOrganisationProfile - var routineChangesConfigOverride *sdp.RoutineChangesConfig - if signalConfigOverride != nil { - githubOrganisationProfileOverride = signalConfigOverride.GithubOrganisationProfile - routineChangesConfigOverride = signalConfigOverride.RoutineChangesConfig - } - // Discover and convert knowledge files - knowledgeDir := knowledge.FindKnowledgeDir(".") - sdpKnowledge := knowledge.DiscoverAndConvert(ctx, knowledgeDir) - - _, err = client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ - Msg: &sdp.StartChangeAnalysisRequest{ - ChangeUUID: changeUUID[:], - ChangingItems: plannedChanges, - BlastRadiusConfigOverride: blastRadiusConfigOverride, - RoutineChangesConfigOverride: routineChangesConfigOverride, - GithubOrganisationProfileOverride: githubOrganisationProfileOverride, - Knowledge: sdpKnowledge, - }, - }) - if err != nil { - return loggedError{ - err: err, - fields: lf, - message: "Failed to start change analysis", + resp, err := client.StartChangeAnalysis(ctx, &connect.Request[sdp.StartChangeAnalysisRequest]{ + Msg: &sdp.StartChangeAnalysisRequest{ + ChangeUUID: changeUUID[:], + ChangingItems: plannedChanges, + BlastRadiusConfigOverride: analysisConfig.BlastRadiusConfig, + RoutineChangesConfigOverride: analysisConfig.RoutineChangesConfig, + GithubOrganisationProfileOverride: analysisConfig.GithubOrgProfile, + Knowledge: analysisConfig.KnowledgeFiles, + PostGithubComment: viper.GetBool("comment"), + }, + }) + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "Failed to start change analysis", + } } + githubAppActive = resp.Msg.GetGithubAppActive() } app, _ = strings.CutSuffix(app, "/") - changeUrl := fmt.Sprintf("%v/changes/%v", app, changeUUID) + changeUrl := fmt.Sprintf("%v/changes/%v?utm_source=cli&cli_version=%v", app, changeUUID, tracing.Version()) log.WithContext(ctx).WithFields(lf).WithField("change-url", changeUrl).Info("Change ready") - fmt.Println(changeUrl) + + if viper.GetBool("comment") { + fmt.Printf("CHANGE_URL='%s'\n", changeUrl) + fmt.Printf("GITHUB_APP_ACTIVE='%v'\n", githubAppActive) + } else { + fmt.Println(changeUrl) + } return nil } @@ -392,16 +395,12 @@ func init() { addAPIFlags(submitPlanCmd) addChangeCreationFlags(submitPlanCmd) + addAnalysisFlags(submitPlanCmd) submitPlanCmd.PersistentFlags().String("frontend", "", "The frontend base URL") - _ = submitPlanCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.") // MarkDeprecated only errors if the flag doesn't exist, we fall back to using app + cobra.CheckErr(submitPlanCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.")) - submitPlanCmd.PersistentFlags().Int32("blast-radius-link-depth", 0, "Used in combination with '--blast-radius-max-items' to customise how many levels are traversed when calculating the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") - submitPlanCmd.PersistentFlags().Int32("blast-radius-max-items", 0, "Used in combination with '--blast-radius-link-depth' to customise how many items are included in the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") - - submitPlanCmd.PersistentFlags().Duration("blast-radius-max-time", 0, "Maximum time duration for blast radius calculation (e.g., '5m', '15m', '30m'). When the time limit is reached, the analysis continues with risks identified up to that point. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") - _ = submitPlanCmd.PersistentFlags().MarkDeprecated("blast-radius-max-time", "This flag is no longer used and will be removed in a future release. Use the '--change-analysis-target-duration' flag instead.") - submitPlanCmd.PersistentFlags().Duration("change-analysis-target-duration", 0, "Target duration for change analysis planning (e.g., '5m', '15m', '30m'). This is NOT a hard deadline - the blast radius phase uses 67% of this target to stop gracefully. The job can run slightly past this target and is only hard-stopped at 30 minutes. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") submitPlanCmd.PersistentFlags().String("auto-tag-rules", "", "The path to the auto-tag rules file. If not provided, it will check the default location which is '.overmind/auto-tag-rules.yaml'. If no rules are found locally, the rules configured through the UI are used.") - submitPlanCmd.PersistentFlags().String("signal-config", "", "The path to the signal config file. If not provided, it will check the default location which is '.overmind/signal-config.yaml'. If no config is found locally, the config configured through the UI is used.") + + submitPlanCmd.PersistentFlags().Bool("no-start", false, "Store the planned changes without starting analysis. Use with 'start-analysis' to trigger analysis later.") } diff --git a/cmd/explore.go b/cmd/explore.go index 76711032..d3b671c8 100644 --- a/cmd/explore.go +++ b/cmd/explore.go @@ -666,7 +666,7 @@ func init() { // hidden flag to enable Azure preview support exploreCmd.PersistentFlags().Bool("enable-azure-preview", false, "Enable Azure source support (preview feature).") - exploreCmd.PersistentFlags().MarkHidden("enable-azure-preview") //nolint:errcheck // not possible to error + cobra.CheckErr(exploreCmd.PersistentFlags().MarkHidden("enable-azure-preview")) } // unifiedGCPConfigs collates the given GCP configs by project ID. diff --git a/cmd/explore_test.go b/cmd/explore_test.go index a57ec4ae..561a5631 100644 --- a/cmd/explore_test.go +++ b/cmd/explore_test.go @@ -60,6 +60,7 @@ func TestUnifiedGCPConfigs(t *testing.T) { if foundConfig == nil { t.Fatalf("Could not find config for project %s in result", originalConfig.ProjectID) + return } if !reflect.DeepEqual(foundConfig.Regions, originalConfig.Regions) { @@ -115,9 +116,11 @@ func TestUnifiedGCPConfigs(t *testing.T) { if unifiedConfig == nil { t.Fatal("Could not find unified-project config in result") + return } if differentConfig == nil { t.Fatal("Could not find different-project config in result") + return } // Verify unified config has all regions @@ -315,9 +318,11 @@ func TestUnifiedAzureConfigs(t *testing.T) { if unifiedConfig == nil { t.Fatal("Could not find config for subscription 00000000-0000-0000-0000-000000000001 in result") + return } if differentConfig == nil { t.Fatal("Could not find config for subscription 00000000-0000-0000-0000-000000000002 in result") + return } // Verify the first config was kept (tenant-first, client-first) diff --git a/cmd/flags.go b/cmd/flags.go index 5a4f3b2d..19b426ec 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -1,11 +1,18 @@ package cmd import ( + "context" "fmt" "strconv" "strings" + "time" + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/overmindtech/cli/knowledge" "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdp-go/sdpconnect" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -116,3 +123,126 @@ func addTerraformBaseFlags(cmd *cobra.Command) { cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-profile")) cmd.PersistentFlags().Bool("only-use-managed-sources", false, "Set this to skip local autoconfiguration and only use the managed sources as configured in Overmind.") } + +// Adds analysis-related flags (blast radius config, signal config) to a command. +// These flags are shared between submit-plan and start-analysis commands. +func addAnalysisFlags(cmd *cobra.Command) { + cmd.PersistentFlags().Int32("blast-radius-link-depth", 0, "Used in combination with '--blast-radius-max-items' to customise how many levels are traversed when calculating the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") + cmd.PersistentFlags().Int32("blast-radius-max-items", 0, "Used in combination with '--blast-radius-link-depth' to customise how many items are included in the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") + cmd.PersistentFlags().Duration("blast-radius-max-time", 0, "Maximum time duration for blast radius calculation (e.g., '5m', '15m', '30m'). When the time limit is reached, the analysis continues with risks identified up to that point. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") + cobra.CheckErr(cmd.PersistentFlags().MarkDeprecated("blast-radius-max-time", "This flag is no longer used and will be removed in a future release. Use the '--change-analysis-target-duration' flag instead.")) + cmd.PersistentFlags().Duration("change-analysis-target-duration", 0, "Target duration for change analysis planning (e.g., '5m', '15m', '30m'). This is NOT a hard deadline - the blast radius phase uses 67% of this target to stop gracefully. The job can run slightly past this target and is only hard-stopped at 30 minutes. Defaults to the account level settings (QUICK: 10m, DETAILED: 15m, FULL: 30m). Valid range: 1m to 30m.") + cmd.PersistentFlags().String("signal-config", "", "The path to the signal config file. If not provided, it will check the default location which is '.overmind/signal-config.yaml'. If no config is found locally, the config configured through the UI is used.") + cmd.PersistentFlags().Bool("comment", false, "Request the GitHub App to post analysis results as a PR comment. Requires the account to have the Overmind GitHub App installed with pull_requests:write.") +} + +// AnalysisConfig holds all the configuration needed to start change analysis. +type AnalysisConfig struct { + BlastRadiusConfig *sdp.BlastRadiusConfig + RoutineChangesConfig *sdp.RoutineChangesConfig + GithubOrgProfile *sdp.GithubOrganisationProfile + KnowledgeFiles []*sdp.Knowledge +} + +// buildAnalysisConfig reads viper flags and builds the analysis configuration +// used by StartChangeAnalysis. This includes blast radius config, routine changes +// config, github org profile, and knowledge files. +func buildAnalysisConfig(ctx context.Context, lf log.Fields) (*AnalysisConfig, error) { + maxDepth := viper.GetInt32("blast-radius-link-depth") + maxItems := viper.GetInt32("blast-radius-max-items") + maxTime := viper.GetDuration("blast-radius-max-time") + changeAnalysisTargetDuration := viper.GetDuration("change-analysis-target-duration") + + blastRadiusConfig, err := createBlastRadiusConfig(maxDepth, maxItems, maxTime, changeAnalysisTargetDuration) + if err != nil { + return nil, err + } + + signalConfigPath := viper.GetString("signal-config") + signalConfigOverride, err := checkForAndLoadSignalConfigFile(ctx, lf, signalConfigPath) + if err != nil { + return nil, loggedError{ + err: err, + fields: lf, + message: "Failed to load signal config", + } + } + + var githubOrgProfile *sdp.GithubOrganisationProfile + var routineChangesConfig *sdp.RoutineChangesConfig + if signalConfigOverride != nil { + githubOrgProfile = signalConfigOverride.GithubOrganisationProfile + routineChangesConfig = signalConfigOverride.RoutineChangesConfig + } + + knowledgeDir := knowledge.FindKnowledgeDir(".") + knowledgeFiles := knowledge.DiscoverAndConvert(ctx, knowledgeDir) + + return &AnalysisConfig{ + BlastRadiusConfig: blastRadiusConfig, + RoutineChangesConfig: routineChangesConfig, + GithubOrgProfile: githubOrgProfile, + KnowledgeFiles: knowledgeFiles, + }, nil +} + +// waitForChangeAnalysis polls the change until analysis reaches a terminal status +// (STATUS_DONE, STATUS_SKIPPED, or STATUS_ERROR). It returns nil on successful +// completion, or an error if analysis failed or was cancelled. +func waitForChangeAnalysis(ctx context.Context, client sdpconnect.ChangesServiceClient, changeUUID uuid.UUID, lf log.Fields) error { + for { + changeRes, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ + Msg: &sdp.GetChangeRequest{ + UUID: changeUUID[:], + }, + }) + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "failed to get change", + } + } + if changeRes.Msg == nil || changeRes.Msg.GetChange() == nil { + return loggedError{ + err: fmt.Errorf("unexpected nil response from GetChange"), + fields: lf, + message: "failed to get change", + } + } + + ch := changeRes.Msg.GetChange() + md := ch.GetMetadata() + if md == nil || md.GetChangeAnalysisStatus() == nil { + return loggedError{ + err: fmt.Errorf("change metadata or change analysis status is nil"), + fields: lf, + message: "failed to get change analysis status", + } + } + + status := md.GetChangeAnalysisStatus().GetStatus() + switch status { + case sdp.ChangeAnalysisStatus_STATUS_DONE, sdp.ChangeAnalysisStatus_STATUS_SKIPPED: + log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Change analysis complete") + return nil + case sdp.ChangeAnalysisStatus_STATUS_ERROR: + return loggedError{ + err: fmt.Errorf("change analysis completed with error status"), + fields: lf, + message: "change analysis failed", + } + case sdp.ChangeAnalysisStatus_STATUS_UNSPECIFIED, sdp.ChangeAnalysisStatus_STATUS_INPROGRESS: + log.WithContext(ctx).WithFields(lf).WithField("status", status.String()).Info("Waiting for change analysis to complete") + } + + time.Sleep(3 * time.Second) + if ctx.Err() != nil { + return loggedError{ + err: ctx.Err(), + fields: lf, + message: "context cancelled", + } + } + } +} diff --git a/cmd/knowledge_list.go b/cmd/knowledge_list.go index 44bdfc47..c6d041c9 100644 --- a/cmd/knowledge_list.go +++ b/cmd/knowledge_list.go @@ -107,5 +107,5 @@ func init() { knowledgeCmd.AddCommand(knowledgeListCmd) knowledgeListCmd.Flags().String("dir", ".", "Directory to start searching from") - knowledgeListCmd.Flags().MarkHidden("dir") //nolint:errcheck // not possible to error + cobra.CheckErr(knowledgeListCmd.Flags().MarkHidden("dir")) } diff --git a/cmd/terraform.go b/cmd/terraform.go index 0ea59a9e..f42b43a1 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -35,7 +35,7 @@ func init() { // hidden flag to enable Azure preview support terraformCmd.PersistentFlags().Bool("enable-azure-preview", false, "Enable Azure source support (preview feature).") - terraformCmd.PersistentFlags().MarkHidden("enable-azure-preview") //nolint:errcheck // not possible to error + cobra.CheckErr(terraformCmd.PersistentFlags().MarkHidden("enable-azure-preview")) } var applyOnlyArgs = []string{ diff --git a/cmd/terraform_plan.go b/cmd/terraform_plan.go index 0bf762af..b7c67e7e 100644 --- a/cmd/terraform_plan.go +++ b/cmd/terraform_plan.go @@ -19,6 +19,7 @@ import ( "github.com/overmindtech/cli/knowledge" "github.com/overmindtech/cli/tfutils" "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/tracing" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -445,12 +446,17 @@ retryLoop: Bold(true). Render(r.GetTitle()) - bits = append(bits, (fmt.Sprintf("%v%v\n\n%v\n\n", + bits = append(bits, fmt.Sprintf("%v%v\n\n%v", title, severity, - wordwrap.String(r.GetDescription(), min(160, pterm.GetTerminalWidth()-4))))) + wordwrap.String(r.GetDescription(), min(160, pterm.GetTerminalWidth()-4)))) + + riskUUID, _ := uuid.FromBytes(r.GetUUID()) + riskURL := fmt.Sprintf("%v/blast-radius?selectedRisk=%v&utm_source=cli&cli_version=%v", changeUrl.String(), riskUUID.String(), tracing.Version()) + bits = append(bits, fmt.Sprintf("%v\n\n", osc8Hyperlink(riskURL, "View risk ↗"))) } - bits = append(bits, fmt.Sprintf("\nCheck the blast radius graph and risks at:\n%v\n\n", changeUrl.String())) + changeURLWithUTM := fmt.Sprintf("%v?utm_source=cli&cli_version=%v", changeUrl.String(), tracing.Version()) + bits = append(bits, fmt.Sprintf("\nView the blast radius graph and risks:\n%v\n\n", osc8Hyperlink(changeURLWithUTM, "Open in Overmind ↗"))) } pterm.Fprintln(multi.NewWriter(), strings.Join(bits, "\n")) @@ -458,6 +464,54 @@ retryLoop: return nil } +// supportsOSCHyperlinks checks if the terminal likely supports OSC 8 hyperlinks. +// Combines a TTY check with environment-based heuristics. +func supportsOSCHyperlinks() bool { + if fi, err := os.Stdout.Stat(); err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return false + } + return envSupportsOSCHyperlinks() +} + +// envSupportsOSCHyperlinks checks environment variables to determine if the terminal +// likely supports OSC 8 hyperlinks. Split out from supportsOSCHyperlinks so that tests +// can exercise the env heuristics in isolation — go test pipes stdout, so the +// TTY check in supportsOSCHyperlinks always fails under test. +func envSupportsOSCHyperlinks() bool { + if os.Getenv("CI") != "" { + return false + } + if term := os.Getenv("TERM"); term == "dumb" { + return false + } + if strings.HasPrefix(os.Getenv("TERM"), "screen") && os.Getenv("TMUX") == "" { + return false + } + if os.Getenv("TERM_PROGRAM") != "" { + return true + } + if os.Getenv("VTE_VERSION") != "" { + return true + } + if os.Getenv("TERM") == "xterm-kitty" { + return true + } + if strings.Contains(os.Getenv("TERM"), "256color") { + return true + } + return false +} + +// osc8Hyperlink returns an OSC 8 hyperlink if the terminal supports it, otherwise +// the raw URL. Supported by iTerm2, GNOME Terminal, Windows Terminal, WezTerm, +// kitty, Alacritty; degrades gracefully in unsupported terminals. +func osc8Hyperlink(url, text string) string { + if supportsOSCHyperlinks() { + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) + } + return url +} + // getTicketLinkFromPlan reads the plan file to create a unique hash to identify this change func getTicketLinkFromPlan(planFile string) (string, error) { plan, err := os.ReadFile(planFile) //nolint:gosec // G703: planFile is from the local user's CLI args; reading their chosen file is the intended behavior of this CLI tool diff --git a/cmd/terraform_plan_test.go b/cmd/terraform_plan_test.go new file mode 100644 index 00000000..9f330321 --- /dev/null +++ b/cmd/terraform_plan_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "testing" + + lipgloss "charm.land/lipgloss/v2" + "github.com/muesli/reflow/wordwrap" +) + +func TestOSC8Hyperlink(t *testing.T) { + t.Parallel() + + url := "https://app.overmind.tech/changes/abc/blast-radius?selectedRisk=xyz&utm_source=cli&cli_version=0.42.0" + text := "View risk ↗" + + // In tests, stdout is not a TTY, so supportsOSCHyperlinks() returns false + // and osc8Hyperlink falls back to the raw URL. + result := osc8Hyperlink(url, text) + if result != url { + t.Errorf("osc8Hyperlink() = %q, want raw URL %q when stdout is not a TTY", result, url) + } +} + +func TestEnvSupportsOSC8(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {"CI disables", map[string]string{"CI": "true"}, false}, + {"dumb terminal", map[string]string{"TERM": "dumb"}, false}, + {"screen without tmux", map[string]string{"TERM": "screen"}, false}, + {"screen with tmux and 256color", map[string]string{"TERM": "screen-256color", "TMUX": "/tmp/tmux-1000/default,12345,0"}, true}, + {"TERM_PROGRAM set", map[string]string{"TERM_PROGRAM": "iTerm.app"}, true}, + {"VTE_VERSION set", map[string]string{"VTE_VERSION": "6800"}, true}, + {"xterm-kitty", map[string]string{"TERM": "xterm-kitty"}, true}, + {"256color", map[string]string{"TERM": "xterm-256color"}, true}, + {"no signals", map[string]string{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("TERM", "") + t.Setenv("TMUX", "") + t.Setenv("TERM_PROGRAM", "") + t.Setenv("VTE_VERSION", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + if got := envSupportsOSCHyperlinks(); got != tt.want { + t.Errorf("envSupportsOSCHyperlinks() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestRenderRiskPreview prints an exact replica of the CLI risk output using +// the real lipgloss styles and theme. Run from an interactive terminal with: +// +// go test ./cli/cmd/ -run TestRenderRiskPreview -v +// +// This is a visual inspection test, not an assertion-based test. It formats the +// OSC 8 escape directly because go test pipes stdout through the test runner, +// which fails the TTY check in supportsOSCHyperlinks. The real CLI runs in the user's +// terminal where the TTY check passes naturally. +func TestRenderRiskPreview(t *testing.T) { + if os.Getenv("CI") == "true" { + t.Skip("visual inspection test — skipped in CI") + } + InitPalette() + + changeURL := "https://app.overmind.tech/changes/d7f79e24-d123-40f2-9f5d-7296cff5fc7b" + cliVersion := "0.42.0" + + type fakeRisk struct { + title string + description string + severity string + riskUUID string + } + + risks := []fakeRisk{ + { + title: "Security group opens port 22 to 0.0.0.0/0", + description: "Opening SSH to all IPs exposes the instance to brute-force attacks and unauthorized access. The security group sg-0abc123 allows inbound TCP/22 from 0.0.0.0/0, making it reachable from any IP on the internet.", + severity: "high", + riskUUID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, + { + title: "Load balancer target group has no health check", + description: "Without health checks, traffic may be routed to unhealthy instances causing user-facing errors. Target group arn:aws:elasticloadbalancing:us-east-1:123456:tg/my-tg has no health check configured.", + severity: "medium", + riskUUID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + }, + { + title: "Route table change may affect private subnet connectivity", + description: "Modifying route table rtb-0def456 could disrupt connectivity for instances in subnet-789ghi that rely on the NAT gateway for outbound traffic.", + severity: "low", + riskUUID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + }, + } + + osc8 := func(url, text string) string { + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) + } + + bits := []string{"", ""} + bits = append(bits, styleH1().Render("Potential Risks")) + bits = append(bits, "") + + for _, r := range risks { + var severity string + switch r.severity { + case "high": + severity = lipgloss.NewStyle(). + Background(ColorPalette.BgDanger). + Foreground(ColorPalette.LabelTitle). + Padding(0, 1). + Bold(true). + Render("High ‼") + case "medium": + severity = lipgloss.NewStyle(). + Background(ColorPalette.BgWarning). + Foreground(ColorPalette.LabelTitle). + Padding(0, 1). + Render("Medium !") + case "low": + severity = lipgloss.NewStyle(). + Background(ColorPalette.LabelBase). + Foreground(ColorPalette.LabelTitle). + Padding(0, 1). + Render("Low ⓘ ") + } + + title := lipgloss.NewStyle(). + Foreground(ColorPalette.BgMain). + PaddingRight(1). + Bold(true). + Render(r.title) + + bits = append(bits, fmt.Sprintf("%v%v\n\n%v", + title, + severity, + wordwrap.String(r.description, 160))) + + riskURL := fmt.Sprintf("%v/blast-radius?selectedRisk=%v&utm_source=cli&cli_version=%v", changeURL, r.riskUUID, cliVersion) + bits = append(bits, fmt.Sprintf("%v\n\n", osc8(riskURL, "View risk ↗"))) + } + + changeURLWithUTM := fmt.Sprintf("%v?utm_source=cli&cli_version=%v", changeURL, cliVersion) + bits = append(bits, fmt.Sprintf("\nView the blast radius graph and risks:\n%v\n\n", osc8(changeURLWithUTM, "Open in Overmind ↗"))) + + fmt.Println(strings.Join(bits, "\n")) +} diff --git a/go.mod b/go.mod index 51b9b4a6..0d293a59 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( charm.land/lipgloss/v2 v2.0.0 cloud.google.com/go/aiplatform v1.119.0 cloud.google.com/go/auth v0.18.2 + cloud.google.com/go/auth/oauth2adapt v0.2.8 cloud.google.com/go/bigquery v1.74.0 cloud.google.com/go/bigtable v1.42.0 cloud.google.com/go/certificatemanager v1.9.6 @@ -47,18 +48,18 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7 v7.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.0.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9 v9.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5 v5.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2 v2.1.0 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3 v3.0.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/MrAlias/otel-schema-utils v0.4.0-alpha - github.com/auth0/go-jwt-middleware/v2 v2.3.1 + github.com/auth0/go-jwt-middleware/v3 v3.0.0 github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2/config v1.32.11 github.com/aws/aws-sdk-go-v2/credentials v1.19.11 @@ -105,6 +106,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.30.0 github.com/hashicorp/terraform-plugin-testing v1.14.0 github.com/jedib0t/go-pretty/v6 v6.7.8 + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/micahhausler/aws-iam-policy v0.4.4 github.com/miekg/dns v1.1.72 github.com/mitchellh/go-homedir v1.1.0 @@ -146,7 +148,6 @@ require ( golang.org/x/text v0.34.0 gonum.org/v1/gonum v0.17.0 google.golang.org/api v0.269.0 - google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 @@ -156,6 +157,7 @@ require ( k8s.io/apimachinery v0.35.2 k8s.io/client-go v0.35.2 sigs.k8s.io/kind v0.31.0 + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) require ( @@ -164,10 +166,10 @@ require ( atomicgo.dev/schedule v0.1.0 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 cloud.google.com/go/longrunning v0.8.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect @@ -212,6 +214,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/containerd/console v1.0.4 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -265,7 +268,13 @@ require ( github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.3 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.12 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -293,12 +302,14 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect + github.com/valyala/fastjson v1.6.7 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect @@ -329,15 +340,14 @@ require ( golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect - gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index ebb34705..e7c3971c 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v3 v3.4.0/go.mod h1:Bb7kqorvA2acMCNFac+2ldoQWi7QrcMdH+9Gg9C7fSM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0 h1:8xYBtaMs3Msy1bFYTVrVFBh05JUGNMMP/v3z3x5hoIw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan v1.2.0/go.mod h1:bXxc3uCnIUCh68pl4njcH45qUgRuR0kZfR6v06k18/A= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1 h1:1kpY4qe+BGAH2ykv4baVSqyx+AY5VjXeJ15SldlU6hs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.1/go.mod h1:nT6cWpWdUt+g81yuKmjeYPUtI73Ak3yQIT4PVVsCEEQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.0.1 h1:nFZ7AvJqTpWobmnZlprsK6GucrByFsXWB+DwkhRxM9I= @@ -179,8 +181,8 @@ github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJE github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= -github.com/auth0/go-jwt-middleware/v2 v2.3.1 h1:lbDyWE9aLydb3zrank+Gufb9qGJN9u//7EbJK07pRrw= -github.com/auth0/go-jwt-middleware/v2 v2.3.1/go.mod h1:mqVr0gdB5zuaFyQFWMJH/c/2hehNjbYUD4i8Dpyf+Hc= +github.com/auth0/go-jwt-middleware/v3 v3.0.0 h1:+rvUPCT+VbAuK4tpS13fWfZrMyqTwLopt3VoY0Y7kvA= +github.com/auth0/go-jwt-middleware/v3 v3.0.0/go.mod h1:iU42jqjRyeKbf9YYSnRnolr836gk6Ty/jnUNuVq2b0o= github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ= @@ -321,6 +323,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= @@ -516,6 +520,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs= +github.com/lestrrat-go/httprc/v3 v3.0.3/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -629,6 +647,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= @@ -678,6 +698,8 @@ github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 h1:H8wwQwTe5sL6x30z7 github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2/go.mod h1:/kR4beFhlz2g+V5ik8jW+3PMiMQAPt29y6K64NNY53c= github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -858,8 +880,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= -gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= diff --git a/go/auth/middleware.go b/go/auth/middleware.go index 0241af4a..f4509360 100644 --- a/go/auth/middleware.go +++ b/go/auth/middleware.go @@ -11,9 +11,9 @@ import ( "strings" "time" - jwtmiddleware "github.com/auth0/go-jwt-middleware/v2" - "github.com/auth0/go-jwt-middleware/v2/jwks" - "github.com/auth0/go-jwt-middleware/v2/validator" + jwtmiddleware "github.com/auth0/go-jwt-middleware/v3" + "github.com/auth0/go-jwt-middleware/v3/jwks" + "github.com/auth0/go-jwt-middleware/v3/validator" "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" @@ -42,6 +42,11 @@ type UserTokenContextKey struct{} // This will be the auth0 `user_id` from the tokens `sub` claim. type CurrentSubjectContextKey struct{} +// ValidatedClaimsContextKey stores the full *validator.ValidatedClaims in +// context. In v3 the middleware's context key is unexported, so we use our own +// for code that needs the full validated claims (e.g. token expiry lookup). +type ValidatedClaimsContextKey struct{} + // MiddlewareConfig Configuration for the auth middleware type MiddlewareConfig struct { Auth0Domain string @@ -214,7 +219,7 @@ func WithAccount(account string) OverrideAuthOptionFunc { } // Sets the auth info in the context directly from the validated claims produced -// by the `github.com/auth0/go-jwt-middleware/v2/validator` package. This is +// by the `github.com/auth0/go-jwt-middleware/v3/validator` package. This is // essentially what the middleware already does when receiving a request, and // therefore should only be used in exceptional circumstances, like testing, when the // middleware is not being used. @@ -224,7 +229,7 @@ func WithAccount(account string) OverrideAuthOptionFunc { func WithValidatedClaims(claims *validator.ValidatedClaims) OverrideAuthOptionFunc { return func(ctx context.Context) context.Context { customClaims := claims.CustomClaims.(*CustomClaims) - ctx = context.WithValue(ctx, jwtmiddleware.ContextKey{}, claims) + ctx = context.WithValue(ctx, ValidatedClaimsContextKey{}, claims) ctx = context.WithValue(ctx, CustomClaimsContextKey{}, customClaims) ctx = context.WithValue(ctx, CurrentSubjectContextKey{}, claims.RegisteredClaims.Subject) ctx = context.WithValue(ctx, AccountNameContextKey{}, customClaims.AccountName) @@ -282,9 +287,23 @@ func withCustomClaims(modify func(*CustomClaims)) OverrideAuthOptionFunc { // // This middleware also extract custom claims form the token and stores them in // CustomClaimsContextKey +// +// NOTE: This function uses log.Fatalf for startup-time configuration errors +// because its signature returns http.Handler, not (http.Handler, error). +// Propagating errors would require changing every caller of NewAuthMiddleware. func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Handler { - if config.Auth0Domain == "" && config.IssuerURL == "" && config.Auth0Audience == "" { - log.Fatalf("Auth0 configuration is missing") + if config.BypassAuth { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + span := trace.SpanFromContext(r.Context()) + span.SetAttributes(attribute.Bool("ovm.auth.bypass", true)) + ctx := OverrideAuth(r.Context(), WithBypassScopeCheck()) + next.ServeHTTP(w, r.Clone(ctx)) + }) + } + + if config.Auth0Audience == "" || (config.Auth0Domain == "" && config.IssuerURL == "") { + log.Fatalf("Auth0 configuration is incomplete: audience=%q, domain=%q, issuerURL=%q", + config.Auth0Audience, config.Auth0Domain, config.IssuerURL) } var issuerURL *url.URL @@ -299,22 +318,26 @@ func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Ha log.Fatalf("Failed to parse the issuer url: %v", err) } - provider := jwks.NewCachingProvider(issuerURL, 5*time.Minute) + provider, err := jwks.NewCachingProvider( + jwks.WithIssuerURL(issuerURL), + jwks.WithCacheTTL(5*time.Minute), + ) + if err != nil { + log.Fatalf("Failed to set up the jwks provider: %v", err) + } jwtValidator, err := validator.New( - provider.KeyFunc, - validator.RS256, - issuerURL.String(), - []string{config.Auth0Audience}, - validator.WithCustomClaims( - func() validator.CustomClaims { - return &CustomClaims{} - }, - ), + validator.WithKeyFunc(provider.KeyFunc), + validator.WithAlgorithm(validator.RS256), + validator.WithIssuer(issuerURL.String()), + validator.WithAudience(config.Auth0Audience), + validator.WithCustomClaims(func() *CustomClaims { + return &CustomClaims{} + }), validator.WithAllowedClockSkew(time.Minute), ) if err != nil { - log.Fatalf("Failed to set up the jwt validator") + log.Fatalf("Failed to set up the jwt validator: %v", err) } errorHandler := func(w http.ResponseWriter, r *http.Request, err error) { @@ -382,17 +405,24 @@ func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Ha tokenExtractor := jwtmiddleware.MultiTokenExtractor(extractors...) - middleware := jwtmiddleware.New( - jwtValidator.ValidateToken, + middleware, err := jwtmiddleware.New( + jwtmiddleware.WithValidator(jwtValidator), jwtmiddleware.WithErrorHandler(errorHandler), jwtmiddleware.WithTokenExtractor(tokenExtractor), ) + if err != nil { + log.Fatalf("Failed to set up the jwt middleware: %v", err) + } jwtValidationMiddleware := middleware.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // extract account name and setup otel attributes after the JWT was validated, but before the actual handler runs - claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims) + claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context()) + if err != nil { + errorHandler(w, r, fmt.Errorf("error getting validated claims: %w", err)) + return + } - token, err := tokenExtractor(r) + extractedToken, err := tokenExtractor(r) // we should never hit this as the middleware wouldn't call the handler if err != nil { // This is not ErrJWTMissing because an error here means that the @@ -412,7 +442,8 @@ func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Ha // note that the values are looked up in last-in-first-out order, so // there is an absolutely minor perf optimisation to have the context // values set in ascending order of access frequency. - ctx = context.WithValue(ctx, UserTokenContextKey{}, token) + ctx = context.WithValue(ctx, UserTokenContextKey{}, extractedToken.Token) + ctx = context.WithValue(ctx, ValidatedClaimsContextKey{}, claims) ctx = context.WithValue(ctx, CustomClaimsContextKey{}, customClaims) ctx = context.WithValue(ctx, CurrentSubjectContextKey{}, claims.RegisteredClaims.Subject) ctx = context.WithValue(ctx, AccountNameContextKey{}, customClaims.AccountName) @@ -445,14 +476,8 @@ func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Ha var shouldBypass bool - // If config.BypassAuth is true then bypass - if config.BypassAuth { - shouldBypass = true - } - - // If we aren't bypassing always and we have a regex then check if we - // should bypass - if !shouldBypass && config.BypassAuthForPaths != nil { + // Check if the request path matches the bypass regex + if config.BypassAuthForPaths != nil { shouldBypass = config.BypassAuthForPaths.MatchString(r.URL.Path) if shouldBypass { span.SetAttributes(attribute.String("ovm.auth.bypassedPath", r.URL.Path)) @@ -475,6 +500,48 @@ func ensureValidTokenHandler(config MiddlewareConfig, next http.Handler) http.Ha }) } +// WithResourceMetadata wraps a handler to include RFC 9728 resource_metadata +// in the WWW-Authenticate header on 401 responses, enabling MCP clients to +// discover the authorization server via Protected Resource Metadata. +func WithResourceMetadata(resourceMetadataURL string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(&resourceMetadataWriter{ + ResponseWriter: w, + resourceMetadataURL: resourceMetadataURL, + }, r) + }) +} + +type resourceMetadataWriter struct { + http.ResponseWriter + resourceMetadataURL string + wroteHeader bool +} + +func (w *resourceMetadataWriter) WriteHeader(statusCode int) { + if !w.wroteHeader { + w.wroteHeader = true + if statusCode == http.StatusUnauthorized { + w.ResponseWriter.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Bearer resource_metadata=%q`, w.resourceMetadataURL)) + } + } + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *resourceMetadataWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + +// Unwrap returns the underlying ResponseWriter, enabling http.ResponseController +// and middleware that check for optional interfaces (Flusher, Hijacker, etc.). +func (w *resourceMetadataWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + // CustomClaims contains custom data we want from the token. type CustomClaims struct { Scope string `json:"scope"` diff --git a/go/auth/middleware_test.go b/go/auth/middleware_test.go index b0231711..f0618753 100644 --- a/go/auth/middleware_test.go +++ b/go/auth/middleware_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/auth0/go-jwt-middleware/v2/validator" + "github.com/auth0/go-jwt-middleware/v3/validator" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" log "github.com/sirupsen/logrus" @@ -691,6 +691,95 @@ func (s *TestJWTServer) Start(ctx context.Context) string { return s.server.URL } +func TestWithResourceMetadata(t *testing.T) { + t.Parallel() + + prmURL := "https://api.example.com/.well-known/oauth-protected-resource/area51/mcp" + + t.Run("adds WWW-Authenticate on 401", func(t *testing.T) { + t.Parallel() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"JWT is missing."}`)) + }) + + handler := WithResourceMetadata(prmURL, inner) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/area51/mcp", nil) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } + + wwwAuth := rr.Header().Get("WWW-Authenticate") + expected := `Bearer resource_metadata="` + prmURL + `"` + if wwwAuth != expected { + t.Errorf("expected WWW-Authenticate %q, got %q", expected, wwwAuth) + } + }) + + t.Run("no WWW-Authenticate on 200", func(t *testing.T) { + t.Parallel() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := WithResourceMetadata(prmURL, inner) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/area51/mcp", nil) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { + t.Errorf("expected no WWW-Authenticate header, got %q", wwwAuth) + } + }) + + t.Run("no WWW-Authenticate on 403", func(t *testing.T) { + t.Parallel() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + handler := WithResourceMetadata(prmURL, inner) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/area51/mcp", nil) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rr.Code) + } + + if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { + t.Errorf("expected no WWW-Authenticate header on 403, got %q", wwwAuth) + } + }) + + t.Run("implicit 200 from Write without WriteHeader", func(t *testing.T) { + t.Parallel() + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + handler := WithResourceMetadata(prmURL, inner) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/area51/mcp", nil) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + if wwwAuth := rr.Header().Get("WWW-Authenticate"); wwwAuth != "" { + t.Errorf("expected no WWW-Authenticate header, got %q", wwwAuth) + } + }) +} + func TestConnectErrorHandling(t *testing.T) { // Create a test JWT server server, err := NewTestJWTServer() diff --git a/go/discovery/adapter_test.go b/go/discovery/adapter_test.go index fcf0ec91..98f5ebf0 100644 --- a/go/discovery/adapter_test.go +++ b/go/discovery/adapter_test.go @@ -708,6 +708,7 @@ func TestNewQueryResultStream(t *testing.T) { // Test Initialization if stream == nil { t.Fatal("Expected stream to be initialized, got nil") + return } if stream.itemHandler == nil || stream.errHandler == nil { t.Fatal("Expected handlers to be set") diff --git a/go/sdp-go/changes.pb.go b/go/sdp-go/changes.pb.go index 51259dff..2d4c3d7f 100644 --- a/go/sdp-go/changes.pb.go +++ b/go/sdp-go/changes.pb.go @@ -535,7 +535,7 @@ func (x StartChangeResponse_State) Number() protoreflect.EnumNumber { // Deprecated: Use StartChangeResponse_State.Descriptor instead. func (StartChangeResponse_State) EnumDescriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{86, 0} + return file_changes_proto_rawDescGZIP(), []int{88, 0} } type EndChangeResponse_State int32 @@ -591,7 +591,7 @@ func (x EndChangeResponse_State) Number() protoreflect.EnumNumber { // Deprecated: Use EndChangeResponse_State.Descriptor instead. func (EndChangeResponse_State) EnumDescriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{88, 0} + return file_changes_proto_rawDescGZIP(), []int{90, 0} } type Risk_Severity int32 @@ -643,7 +643,7 @@ func (x Risk_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use Risk_Severity.Descriptor instead. func (Risk_Severity) EnumDescriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{91, 0} + return file_changes_proto_rawDescGZIP(), []int{93, 0} } type ChangeAnalysisStatus_Status int32 @@ -698,7 +698,7 @@ func (x ChangeAnalysisStatus_Status) Number() protoreflect.EnumNumber { // Deprecated: Use ChangeAnalysisStatus_Status.Descriptor instead. func (ChangeAnalysisStatus_Status) EnumDescriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{92, 0} + return file_changes_proto_rawDescGZIP(), []int{94, 0} } type LabelRule struct { @@ -3176,9 +3176,13 @@ type StartChangeAnalysisRequest struct { // github organisation profile to use for this change GithubOrganisationProfileOverride *GithubOrganisationProfile `protobuf:"bytes,6,opt,name=githubOrganisationProfileOverride,proto3,oneof" json:"githubOrganisationProfileOverride,omitempty"` // Knowledge to be used for change analysis - Knowledge []*Knowledge `protobuf:"bytes,7,rep,name=knowledge,proto3" json:"knowledge,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Knowledge []*Knowledge `protobuf:"bytes,7,rep,name=knowledge,proto3" json:"knowledge,omitempty"` + // When true, the backend will attempt to post analysis results as a GitHub + // PR comment via the installed GitHub App. Requires the account to have a + // GitHub App installation with pull_requests:write permission. + PostGithubComment bool `protobuf:"varint,8,opt,name=post_github_comment,json=postGithubComment,proto3" json:"post_github_comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartChangeAnalysisRequest) Reset() { @@ -3253,12 +3257,23 @@ func (x *StartChangeAnalysisRequest) GetKnowledge() []*Knowledge { return nil } +func (x *StartChangeAnalysisRequest) GetPostGithubComment() bool { + if x != nil { + return x.PostGithubComment + } + return false +} + // StartChangeAnalysisResponse is used to signal that the change analysis has been successfully started // we use HTTP response codes to signal errors type StartChangeAnalysisResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // True when the account has a GitHub App installation with sufficient + // permissions to post PR comments. The CLI/Action can use this to decide + // whether it needs to post its own comment. + GithubAppActive bool `protobuf:"varint,1,opt,name=github_app_active,json=githubAppActive,proto3" json:"github_app_active,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartChangeAnalysisResponse) Reset() { @@ -3291,6 +3306,107 @@ func (*StartChangeAnalysisResponse) Descriptor() ([]byte, []int) { return file_changes_proto_rawDescGZIP(), []int{43} } +func (x *StartChangeAnalysisResponse) GetGithubAppActive() bool { + if x != nil { + return x.GithubAppActive + } + return false +} + +// AddPlannedChangesRequest appends a batch of planned changes to an existing +// change without triggering analysis. Used by multi-plan workflows (e.g. +// Atlantis parallel planning) where each plan step submits independently. +type AddPlannedChangesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The change to append items to + ChangeUUID []byte `protobuf:"bytes,1,opt,name=changeUUID,proto3" json:"changeUUID,omitempty"` + // The planned change items to append + ChangingItems []*MappedItemDiff `protobuf:"bytes,2,rep,name=changingItems,proto3" json:"changingItems,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddPlannedChangesRequest) Reset() { + *x = AddPlannedChangesRequest{} + mi := &file_changes_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddPlannedChangesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddPlannedChangesRequest) ProtoMessage() {} + +func (x *AddPlannedChangesRequest) ProtoReflect() protoreflect.Message { + mi := &file_changes_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddPlannedChangesRequest.ProtoReflect.Descriptor instead. +func (*AddPlannedChangesRequest) Descriptor() ([]byte, []int) { + return file_changes_proto_rawDescGZIP(), []int{44} +} + +func (x *AddPlannedChangesRequest) GetChangeUUID() []byte { + if x != nil { + return x.ChangeUUID + } + return nil +} + +func (x *AddPlannedChangesRequest) GetChangingItems() []*MappedItemDiff { + if x != nil { + return x.ChangingItems + } + return nil +} + +// AddPlannedChangesResponse is intentionally empty; errors use ConnectRPC codes. +type AddPlannedChangesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddPlannedChangesResponse) Reset() { + *x = AddPlannedChangesResponse{} + mi := &file_changes_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddPlannedChangesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddPlannedChangesResponse) ProtoMessage() {} + +func (x *AddPlannedChangesResponse) ProtoReflect() protoreflect.Message { + mi := &file_changes_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddPlannedChangesResponse.ProtoReflect.Descriptor instead. +func (*AddPlannedChangesResponse) Descriptor() ([]byte, []int) { + return file_changes_proto_rawDescGZIP(), []int{45} +} + type ListHomeChangesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Pagination *PaginationRequest `protobuf:"bytes,1,opt,name=pagination,proto3" json:"pagination,omitempty"` @@ -3301,7 +3417,7 @@ type ListHomeChangesRequest struct { func (x *ListHomeChangesRequest) Reset() { *x = ListHomeChangesRequest{} - mi := &file_changes_proto_msgTypes[44] + mi := &file_changes_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3313,7 +3429,7 @@ func (x *ListHomeChangesRequest) String() string { func (*ListHomeChangesRequest) ProtoMessage() {} func (x *ListHomeChangesRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[44] + mi := &file_changes_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3326,7 +3442,7 @@ func (x *ListHomeChangesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListHomeChangesRequest.ProtoReflect.Descriptor instead. func (*ListHomeChangesRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{44} + return file_changes_proto_rawDescGZIP(), []int{46} } func (x *ListHomeChangesRequest) GetPagination() *PaginationRequest { @@ -3360,7 +3476,7 @@ type ChangeFiltersRequest struct { func (x *ChangeFiltersRequest) Reset() { *x = ChangeFiltersRequest{} - mi := &file_changes_proto_msgTypes[45] + mi := &file_changes_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3372,7 +3488,7 @@ func (x *ChangeFiltersRequest) String() string { func (*ChangeFiltersRequest) ProtoMessage() {} func (x *ChangeFiltersRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[45] + mi := &file_changes_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3385,7 +3501,7 @@ func (x *ChangeFiltersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeFiltersRequest.ProtoReflect.Descriptor instead. func (*ChangeFiltersRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{45} + return file_changes_proto_rawDescGZIP(), []int{47} } func (x *ChangeFiltersRequest) GetRepos() []string { @@ -3440,7 +3556,7 @@ type ListHomeChangesResponse struct { func (x *ListHomeChangesResponse) Reset() { *x = ListHomeChangesResponse{} - mi := &file_changes_proto_msgTypes[46] + mi := &file_changes_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3452,7 +3568,7 @@ func (x *ListHomeChangesResponse) String() string { func (*ListHomeChangesResponse) ProtoMessage() {} func (x *ListHomeChangesResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[46] + mi := &file_changes_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3465,7 +3581,7 @@ func (x *ListHomeChangesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListHomeChangesResponse.ProtoReflect.Descriptor instead. func (*ListHomeChangesResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{46} + return file_changes_proto_rawDescGZIP(), []int{48} } func (x *ListHomeChangesResponse) GetChanges() []*ChangeSummary { @@ -3490,7 +3606,7 @@ type PopulateChangeFiltersRequest struct { func (x *PopulateChangeFiltersRequest) Reset() { *x = PopulateChangeFiltersRequest{} - mi := &file_changes_proto_msgTypes[47] + mi := &file_changes_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3502,7 +3618,7 @@ func (x *PopulateChangeFiltersRequest) String() string { func (*PopulateChangeFiltersRequest) ProtoMessage() {} func (x *PopulateChangeFiltersRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[47] + mi := &file_changes_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3515,7 +3631,7 @@ func (x *PopulateChangeFiltersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PopulateChangeFiltersRequest.ProtoReflect.Descriptor instead. func (*PopulateChangeFiltersRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{47} + return file_changes_proto_rawDescGZIP(), []int{49} } type PopulateChangeFiltersResponse struct { @@ -3528,7 +3644,7 @@ type PopulateChangeFiltersResponse struct { func (x *PopulateChangeFiltersResponse) Reset() { *x = PopulateChangeFiltersResponse{} - mi := &file_changes_proto_msgTypes[48] + mi := &file_changes_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3540,7 +3656,7 @@ func (x *PopulateChangeFiltersResponse) String() string { func (*PopulateChangeFiltersResponse) ProtoMessage() {} func (x *PopulateChangeFiltersResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[48] + mi := &file_changes_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3553,7 +3669,7 @@ func (x *PopulateChangeFiltersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PopulateChangeFiltersResponse.ProtoReflect.Descriptor instead. func (*PopulateChangeFiltersResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{48} + return file_changes_proto_rawDescGZIP(), []int{50} } func (x *PopulateChangeFiltersResponse) GetRepos() []string { @@ -3584,7 +3700,7 @@ type ItemDiffSummary struct { func (x *ItemDiffSummary) Reset() { *x = ItemDiffSummary{} - mi := &file_changes_proto_msgTypes[49] + mi := &file_changes_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3596,7 +3712,7 @@ func (x *ItemDiffSummary) String() string { func (*ItemDiffSummary) ProtoMessage() {} func (x *ItemDiffSummary) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[49] + mi := &file_changes_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3609,7 +3725,7 @@ func (x *ItemDiffSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ItemDiffSummary.ProtoReflect.Descriptor instead. func (*ItemDiffSummary) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{49} + return file_changes_proto_rawDescGZIP(), []int{51} } func (x *ItemDiffSummary) GetItem() *Reference { @@ -3658,7 +3774,7 @@ type ItemDiff struct { func (x *ItemDiff) Reset() { *x = ItemDiff{} - mi := &file_changes_proto_msgTypes[50] + mi := &file_changes_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3670,7 +3786,7 @@ func (x *ItemDiff) String() string { func (*ItemDiff) ProtoMessage() {} func (x *ItemDiff) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[50] + mi := &file_changes_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3683,7 +3799,7 @@ func (x *ItemDiff) ProtoReflect() protoreflect.Message { // Deprecated: Use ItemDiff.ProtoReflect.Descriptor instead. func (*ItemDiff) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{50} + return file_changes_proto_rawDescGZIP(), []int{52} } func (x *ItemDiff) GetItem() *Reference { @@ -3737,7 +3853,7 @@ type EnrichedTags struct { func (x *EnrichedTags) Reset() { *x = EnrichedTags{} - mi := &file_changes_proto_msgTypes[51] + mi := &file_changes_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3749,7 +3865,7 @@ func (x *EnrichedTags) String() string { func (*EnrichedTags) ProtoMessage() {} func (x *EnrichedTags) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[51] + mi := &file_changes_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3762,7 +3878,7 @@ func (x *EnrichedTags) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrichedTags.ProtoReflect.Descriptor instead. func (*EnrichedTags) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{51} + return file_changes_proto_rawDescGZIP(), []int{53} } func (x *EnrichedTags) GetTagValue() map[string]*TagValue { @@ -3787,7 +3903,7 @@ type TagValue struct { func (x *TagValue) Reset() { *x = TagValue{} - mi := &file_changes_proto_msgTypes[52] + mi := &file_changes_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3799,7 +3915,7 @@ func (x *TagValue) String() string { func (*TagValue) ProtoMessage() {} func (x *TagValue) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[52] + mi := &file_changes_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3812,7 +3928,7 @@ func (x *TagValue) ProtoReflect() protoreflect.Message { // Deprecated: Use TagValue.ProtoReflect.Descriptor instead. func (*TagValue) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{52} + return file_changes_proto_rawDescGZIP(), []int{54} } func (x *TagValue) GetValue() isTagValue_Value { @@ -3866,7 +3982,7 @@ type UserTagValue struct { func (x *UserTagValue) Reset() { *x = UserTagValue{} - mi := &file_changes_proto_msgTypes[53] + mi := &file_changes_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3878,7 +3994,7 @@ func (x *UserTagValue) String() string { func (*UserTagValue) ProtoMessage() {} func (x *UserTagValue) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[53] + mi := &file_changes_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3891,7 +4007,7 @@ func (x *UserTagValue) ProtoReflect() protoreflect.Message { // Deprecated: Use UserTagValue.ProtoReflect.Descriptor instead. func (*UserTagValue) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{53} + return file_changes_proto_rawDescGZIP(), []int{55} } func (x *UserTagValue) GetValue() string { @@ -3913,7 +4029,7 @@ type AutoTagValue struct { func (x *AutoTagValue) Reset() { *x = AutoTagValue{} - mi := &file_changes_proto_msgTypes[54] + mi := &file_changes_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3925,7 +4041,7 @@ func (x *AutoTagValue) String() string { func (*AutoTagValue) ProtoMessage() {} func (x *AutoTagValue) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[54] + mi := &file_changes_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3938,7 +4054,7 @@ func (x *AutoTagValue) ProtoReflect() protoreflect.Message { // Deprecated: Use AutoTagValue.ProtoReflect.Descriptor instead. func (*AutoTagValue) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{54} + return file_changes_proto_rawDescGZIP(), []int{56} } func (x *AutoTagValue) GetValue() string { @@ -3981,7 +4097,7 @@ type Label struct { func (x *Label) Reset() { *x = Label{} - mi := &file_changes_proto_msgTypes[55] + mi := &file_changes_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3993,7 +4109,7 @@ func (x *Label) String() string { func (*Label) ProtoMessage() {} func (x *Label) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[55] + mi := &file_changes_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4006,7 +4122,7 @@ func (x *Label) ProtoReflect() protoreflect.Message { // Deprecated: Use Label.ProtoReflect.Descriptor instead. func (*Label) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{55} + return file_changes_proto_rawDescGZIP(), []int{57} } func (x *Label) GetType() LabelType { @@ -4105,7 +4221,7 @@ type ChangeSummary struct { func (x *ChangeSummary) Reset() { *x = ChangeSummary{} - mi := &file_changes_proto_msgTypes[56] + mi := &file_changes_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4117,7 +4233,7 @@ func (x *ChangeSummary) String() string { func (*ChangeSummary) ProtoMessage() {} func (x *ChangeSummary) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[56] + mi := &file_changes_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4130,7 +4246,7 @@ func (x *ChangeSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeSummary.ProtoReflect.Descriptor instead. func (*ChangeSummary) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{56} + return file_changes_proto_rawDescGZIP(), []int{58} } func (x *ChangeSummary) GetUUID() []byte { @@ -4273,7 +4389,7 @@ type Change struct { func (x *Change) Reset() { *x = Change{} - mi := &file_changes_proto_msgTypes[57] + mi := &file_changes_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4285,7 +4401,7 @@ func (x *Change) String() string { func (*Change) ProtoMessage() {} func (x *Change) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[57] + mi := &file_changes_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4298,7 +4414,7 @@ func (x *Change) ProtoReflect() protoreflect.Message { // Deprecated: Use Change.ProtoReflect.Descriptor instead. func (*Change) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{57} + return file_changes_proto_rawDescGZIP(), []int{59} } func (x *Change) GetMetadata() *ChangeMetadata { @@ -4366,7 +4482,7 @@ type ChangeMetadata struct { func (x *ChangeMetadata) Reset() { *x = ChangeMetadata{} - mi := &file_changes_proto_msgTypes[58] + mi := &file_changes_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4378,7 +4494,7 @@ func (x *ChangeMetadata) String() string { func (*ChangeMetadata) ProtoMessage() {} func (x *ChangeMetadata) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[58] + mi := &file_changes_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4391,7 +4507,7 @@ func (x *ChangeMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeMetadata.ProtoReflect.Descriptor instead. func (*ChangeMetadata) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{58} + return file_changes_proto_rawDescGZIP(), []int{60} } func (x *ChangeMetadata) GetUUID() []byte { @@ -4596,7 +4712,7 @@ type ChangeProperties struct { func (x *ChangeProperties) Reset() { *x = ChangeProperties{} - mi := &file_changes_proto_msgTypes[59] + mi := &file_changes_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4608,7 +4724,7 @@ func (x *ChangeProperties) String() string { func (*ChangeProperties) ProtoMessage() {} func (x *ChangeProperties) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[59] + mi := &file_changes_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4621,7 +4737,7 @@ func (x *ChangeProperties) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeProperties.ProtoReflect.Descriptor instead. func (*ChangeProperties) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{59} + return file_changes_proto_rawDescGZIP(), []int{61} } func (x *ChangeProperties) GetTitle() string { @@ -4755,7 +4871,7 @@ type GithubChangeInfo struct { func (x *GithubChangeInfo) Reset() { *x = GithubChangeInfo{} - mi := &file_changes_proto_msgTypes[60] + mi := &file_changes_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4767,7 +4883,7 @@ func (x *GithubChangeInfo) String() string { func (*GithubChangeInfo) ProtoMessage() {} func (x *GithubChangeInfo) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[60] + mi := &file_changes_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4780,7 +4896,7 @@ func (x *GithubChangeInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use GithubChangeInfo.ProtoReflect.Descriptor instead. func (*GithubChangeInfo) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{60} + return file_changes_proto_rawDescGZIP(), []int{62} } func (x *GithubChangeInfo) GetAuthorUsername() string { @@ -4820,7 +4936,7 @@ type ListChangesRequest struct { func (x *ListChangesRequest) Reset() { *x = ListChangesRequest{} - mi := &file_changes_proto_msgTypes[61] + mi := &file_changes_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4832,7 +4948,7 @@ func (x *ListChangesRequest) String() string { func (*ListChangesRequest) ProtoMessage() {} func (x *ListChangesRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[61] + mi := &file_changes_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4845,7 +4961,7 @@ func (x *ListChangesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListChangesRequest.ProtoReflect.Descriptor instead. func (*ListChangesRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{61} + return file_changes_proto_rawDescGZIP(), []int{63} } type ListChangesResponse struct { @@ -4857,7 +4973,7 @@ type ListChangesResponse struct { func (x *ListChangesResponse) Reset() { *x = ListChangesResponse{} - mi := &file_changes_proto_msgTypes[62] + mi := &file_changes_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4869,7 +4985,7 @@ func (x *ListChangesResponse) String() string { func (*ListChangesResponse) ProtoMessage() {} func (x *ListChangesResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[62] + mi := &file_changes_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4882,7 +4998,7 @@ func (x *ListChangesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListChangesResponse.ProtoReflect.Descriptor instead. func (*ListChangesResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{62} + return file_changes_proto_rawDescGZIP(), []int{64} } func (x *ListChangesResponse) GetChanges() []*Change { @@ -4902,7 +5018,7 @@ type ListChangesByStatusRequest struct { func (x *ListChangesByStatusRequest) Reset() { *x = ListChangesByStatusRequest{} - mi := &file_changes_proto_msgTypes[63] + mi := &file_changes_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4914,7 +5030,7 @@ func (x *ListChangesByStatusRequest) String() string { func (*ListChangesByStatusRequest) ProtoMessage() {} func (x *ListChangesByStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[63] + mi := &file_changes_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4927,7 +5043,7 @@ func (x *ListChangesByStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListChangesByStatusRequest.ProtoReflect.Descriptor instead. func (*ListChangesByStatusRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{63} + return file_changes_proto_rawDescGZIP(), []int{65} } func (x *ListChangesByStatusRequest) GetStatus() ChangeStatus { @@ -4946,7 +5062,7 @@ type ListChangesByStatusResponse struct { func (x *ListChangesByStatusResponse) Reset() { *x = ListChangesByStatusResponse{} - mi := &file_changes_proto_msgTypes[64] + mi := &file_changes_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4958,7 +5074,7 @@ func (x *ListChangesByStatusResponse) String() string { func (*ListChangesByStatusResponse) ProtoMessage() {} func (x *ListChangesByStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[64] + mi := &file_changes_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4971,7 +5087,7 @@ func (x *ListChangesByStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListChangesByStatusResponse.ProtoReflect.Descriptor instead. func (*ListChangesByStatusResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{64} + return file_changes_proto_rawDescGZIP(), []int{66} } func (x *ListChangesByStatusResponse) GetChanges() []*Change { @@ -4991,7 +5107,7 @@ type CreateChangeRequest struct { func (x *CreateChangeRequest) Reset() { *x = CreateChangeRequest{} - mi := &file_changes_proto_msgTypes[65] + mi := &file_changes_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5003,7 +5119,7 @@ func (x *CreateChangeRequest) String() string { func (*CreateChangeRequest) ProtoMessage() {} func (x *CreateChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[65] + mi := &file_changes_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5016,7 +5132,7 @@ func (x *CreateChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateChangeRequest.ProtoReflect.Descriptor instead. func (*CreateChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{65} + return file_changes_proto_rawDescGZIP(), []int{67} } func (x *CreateChangeRequest) GetProperties() *ChangeProperties { @@ -5035,7 +5151,7 @@ type CreateChangeResponse struct { func (x *CreateChangeResponse) Reset() { *x = CreateChangeResponse{} - mi := &file_changes_proto_msgTypes[66] + mi := &file_changes_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5047,7 +5163,7 @@ func (x *CreateChangeResponse) String() string { func (*CreateChangeResponse) ProtoMessage() {} func (x *CreateChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[66] + mi := &file_changes_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5060,7 +5176,7 @@ func (x *CreateChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateChangeResponse.ProtoReflect.Descriptor instead. func (*CreateChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{66} + return file_changes_proto_rawDescGZIP(), []int{68} } func (x *CreateChangeResponse) GetChange() *Change { @@ -5085,7 +5201,7 @@ type GetChangeRequest struct { func (x *GetChangeRequest) Reset() { *x = GetChangeRequest{} - mi := &file_changes_proto_msgTypes[67] + mi := &file_changes_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5097,7 +5213,7 @@ func (x *GetChangeRequest) String() string { func (*GetChangeRequest) ProtoMessage() {} func (x *GetChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[67] + mi := &file_changes_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5110,7 +5226,7 @@ func (x *GetChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeRequest.ProtoReflect.Descriptor instead. func (*GetChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{67} + return file_changes_proto_rawDescGZIP(), []int{69} } func (x *GetChangeRequest) GetUUID() []byte { @@ -5136,7 +5252,7 @@ type GetChangeByTicketLinkRequest struct { func (x *GetChangeByTicketLinkRequest) Reset() { *x = GetChangeByTicketLinkRequest{} - mi := &file_changes_proto_msgTypes[68] + mi := &file_changes_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5148,7 +5264,7 @@ func (x *GetChangeByTicketLinkRequest) String() string { func (*GetChangeByTicketLinkRequest) ProtoMessage() {} func (x *GetChangeByTicketLinkRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[68] + mi := &file_changes_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5161,7 +5277,7 @@ func (x *GetChangeByTicketLinkRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeByTicketLinkRequest.ProtoReflect.Descriptor instead. func (*GetChangeByTicketLinkRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{68} + return file_changes_proto_rawDescGZIP(), []int{70} } func (x *GetChangeByTicketLinkRequest) GetTicketLink() string { @@ -5191,7 +5307,7 @@ type GetChangeSummaryRequest struct { func (x *GetChangeSummaryRequest) Reset() { *x = GetChangeSummaryRequest{} - mi := &file_changes_proto_msgTypes[69] + mi := &file_changes_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5203,7 +5319,7 @@ func (x *GetChangeSummaryRequest) String() string { func (*GetChangeSummaryRequest) ProtoMessage() {} func (x *GetChangeSummaryRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[69] + mi := &file_changes_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5216,7 +5332,7 @@ func (x *GetChangeSummaryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeSummaryRequest.ProtoReflect.Descriptor instead. func (*GetChangeSummaryRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{69} + return file_changes_proto_rawDescGZIP(), []int{71} } func (x *GetChangeSummaryRequest) GetUUID() []byte { @@ -5255,15 +5371,18 @@ func (x *GetChangeSummaryRequest) GetAppURL() string { } type GetChangeSummaryResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Change string `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Change string `protobuf:"bytes,1,opt,name=change,proto3" json:"change,omitempty"` + // True when the GitHub App has successfully posted (or updated) a PR + // comment for this change. Allows the CLI/Action to skip its own comment. + GithubAppCommentPosted bool `protobuf:"varint,2,opt,name=github_app_comment_posted,json=githubAppCommentPosted,proto3" json:"github_app_comment_posted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetChangeSummaryResponse) Reset() { *x = GetChangeSummaryResponse{} - mi := &file_changes_proto_msgTypes[70] + mi := &file_changes_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5275,7 +5394,7 @@ func (x *GetChangeSummaryResponse) String() string { func (*GetChangeSummaryResponse) ProtoMessage() {} func (x *GetChangeSummaryResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[70] + mi := &file_changes_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5288,7 +5407,7 @@ func (x *GetChangeSummaryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeSummaryResponse.ProtoReflect.Descriptor instead. func (*GetChangeSummaryResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{70} + return file_changes_proto_rawDescGZIP(), []int{72} } func (x *GetChangeSummaryResponse) GetChange() string { @@ -5298,6 +5417,13 @@ func (x *GetChangeSummaryResponse) GetChange() string { return "" } +func (x *GetChangeSummaryResponse) GetGithubAppCommentPosted() bool { + if x != nil { + return x.GithubAppCommentPosted + } + return false +} + type GetChangeSignalsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` UUID []byte `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` @@ -5309,7 +5435,7 @@ type GetChangeSignalsRequest struct { func (x *GetChangeSignalsRequest) Reset() { *x = GetChangeSignalsRequest{} - mi := &file_changes_proto_msgTypes[71] + mi := &file_changes_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5321,7 +5447,7 @@ func (x *GetChangeSignalsRequest) String() string { func (*GetChangeSignalsRequest) ProtoMessage() {} func (x *GetChangeSignalsRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[71] + mi := &file_changes_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5334,7 +5460,7 @@ func (x *GetChangeSignalsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeSignalsRequest.ProtoReflect.Descriptor instead. func (*GetChangeSignalsRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{71} + return file_changes_proto_rawDescGZIP(), []int{73} } func (x *GetChangeSignalsRequest) GetUUID() []byte { @@ -5360,7 +5486,7 @@ type GetChangeSignalsResponse struct { func (x *GetChangeSignalsResponse) Reset() { *x = GetChangeSignalsResponse{} - mi := &file_changes_proto_msgTypes[72] + mi := &file_changes_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5372,7 +5498,7 @@ func (x *GetChangeSignalsResponse) String() string { func (*GetChangeSignalsResponse) ProtoMessage() {} func (x *GetChangeSignalsResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[72] + mi := &file_changes_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5385,7 +5511,7 @@ func (x *GetChangeSignalsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeSignalsResponse.ProtoReflect.Descriptor instead. func (*GetChangeSignalsResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{72} + return file_changes_proto_rawDescGZIP(), []int{74} } func (x *GetChangeSignalsResponse) GetSignals() string { @@ -5404,7 +5530,7 @@ type GetChangeResponse struct { func (x *GetChangeResponse) Reset() { *x = GetChangeResponse{} - mi := &file_changes_proto_msgTypes[73] + mi := &file_changes_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5416,7 +5542,7 @@ func (x *GetChangeResponse) String() string { func (*GetChangeResponse) ProtoMessage() {} func (x *GetChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[73] + mi := &file_changes_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5429,7 +5555,7 @@ func (x *GetChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeResponse.ProtoReflect.Descriptor instead. func (*GetChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{73} + return file_changes_proto_rawDescGZIP(), []int{75} } func (x *GetChangeResponse) GetChange() *Change { @@ -5449,7 +5575,7 @@ type GetChangeRisksRequest struct { func (x *GetChangeRisksRequest) Reset() { *x = GetChangeRisksRequest{} - mi := &file_changes_proto_msgTypes[74] + mi := &file_changes_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5461,7 +5587,7 @@ func (x *GetChangeRisksRequest) String() string { func (*GetChangeRisksRequest) ProtoMessage() {} func (x *GetChangeRisksRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[74] + mi := &file_changes_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5474,7 +5600,7 @@ func (x *GetChangeRisksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeRisksRequest.ProtoReflect.Descriptor instead. func (*GetChangeRisksRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{74} + return file_changes_proto_rawDescGZIP(), []int{76} } func (x *GetChangeRisksRequest) GetUUID() []byte { @@ -5502,7 +5628,7 @@ type ChangeRiskMetadata struct { func (x *ChangeRiskMetadata) Reset() { *x = ChangeRiskMetadata{} - mi := &file_changes_proto_msgTypes[75] + mi := &file_changes_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5514,7 +5640,7 @@ func (x *ChangeRiskMetadata) String() string { func (*ChangeRiskMetadata) ProtoMessage() {} func (x *ChangeRiskMetadata) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[75] + mi := &file_changes_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5527,7 +5653,7 @@ func (x *ChangeRiskMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeRiskMetadata.ProtoReflect.Descriptor instead. func (*ChangeRiskMetadata) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{75} + return file_changes_proto_rawDescGZIP(), []int{77} } func (x *ChangeRiskMetadata) GetChangeAnalysisStatus() *ChangeAnalysisStatus { @@ -5574,7 +5700,7 @@ type GetChangeRisksResponse struct { func (x *GetChangeRisksResponse) Reset() { *x = GetChangeRisksResponse{} - mi := &file_changes_proto_msgTypes[76] + mi := &file_changes_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5586,7 +5712,7 @@ func (x *GetChangeRisksResponse) String() string { func (*GetChangeRisksResponse) ProtoMessage() {} func (x *GetChangeRisksResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[76] + mi := &file_changes_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5599,7 +5725,7 @@ func (x *GetChangeRisksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetChangeRisksResponse.ProtoReflect.Descriptor instead. func (*GetChangeRisksResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{76} + return file_changes_proto_rawDescGZIP(), []int{78} } func (x *GetChangeRisksResponse) GetChangeRiskMetadata() *ChangeRiskMetadata { @@ -5620,7 +5746,7 @@ type UpdateChangeRequest struct { func (x *UpdateChangeRequest) Reset() { *x = UpdateChangeRequest{} - mi := &file_changes_proto_msgTypes[77] + mi := &file_changes_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5632,7 +5758,7 @@ func (x *UpdateChangeRequest) String() string { func (*UpdateChangeRequest) ProtoMessage() {} func (x *UpdateChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[77] + mi := &file_changes_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5645,7 +5771,7 @@ func (x *UpdateChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateChangeRequest.ProtoReflect.Descriptor instead. func (*UpdateChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{77} + return file_changes_proto_rawDescGZIP(), []int{79} } func (x *UpdateChangeRequest) GetUUID() []byte { @@ -5671,7 +5797,7 @@ type UpdateChangeResponse struct { func (x *UpdateChangeResponse) Reset() { *x = UpdateChangeResponse{} - mi := &file_changes_proto_msgTypes[78] + mi := &file_changes_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5683,7 +5809,7 @@ func (x *UpdateChangeResponse) String() string { func (*UpdateChangeResponse) ProtoMessage() {} func (x *UpdateChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[78] + mi := &file_changes_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5696,7 +5822,7 @@ func (x *UpdateChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateChangeResponse.ProtoReflect.Descriptor instead. func (*UpdateChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{78} + return file_changes_proto_rawDescGZIP(), []int{80} } func (x *UpdateChangeResponse) GetChange() *Change { @@ -5716,7 +5842,7 @@ type DeleteChangeRequest struct { func (x *DeleteChangeRequest) Reset() { *x = DeleteChangeRequest{} - mi := &file_changes_proto_msgTypes[79] + mi := &file_changes_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5728,7 +5854,7 @@ func (x *DeleteChangeRequest) String() string { func (*DeleteChangeRequest) ProtoMessage() {} func (x *DeleteChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[79] + mi := &file_changes_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5741,7 +5867,7 @@ func (x *DeleteChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteChangeRequest.ProtoReflect.Descriptor instead. func (*DeleteChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{79} + return file_changes_proto_rawDescGZIP(), []int{81} } func (x *DeleteChangeRequest) GetUUID() []byte { @@ -5761,7 +5887,7 @@ type ListChangesBySnapshotUUIDRequest struct { func (x *ListChangesBySnapshotUUIDRequest) Reset() { *x = ListChangesBySnapshotUUIDRequest{} - mi := &file_changes_proto_msgTypes[80] + mi := &file_changes_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5773,7 +5899,7 @@ func (x *ListChangesBySnapshotUUIDRequest) String() string { func (*ListChangesBySnapshotUUIDRequest) ProtoMessage() {} func (x *ListChangesBySnapshotUUIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[80] + mi := &file_changes_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5786,7 +5912,7 @@ func (x *ListChangesBySnapshotUUIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListChangesBySnapshotUUIDRequest.ProtoReflect.Descriptor instead. func (*ListChangesBySnapshotUUIDRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{80} + return file_changes_proto_rawDescGZIP(), []int{82} } func (x *ListChangesBySnapshotUUIDRequest) GetUUID() []byte { @@ -5805,7 +5931,7 @@ type ListChangesBySnapshotUUIDResponse struct { func (x *ListChangesBySnapshotUUIDResponse) Reset() { *x = ListChangesBySnapshotUUIDResponse{} - mi := &file_changes_proto_msgTypes[81] + mi := &file_changes_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5817,7 +5943,7 @@ func (x *ListChangesBySnapshotUUIDResponse) String() string { func (*ListChangesBySnapshotUUIDResponse) ProtoMessage() {} func (x *ListChangesBySnapshotUUIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[81] + mi := &file_changes_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5830,7 +5956,7 @@ func (x *ListChangesBySnapshotUUIDResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ListChangesBySnapshotUUIDResponse.ProtoReflect.Descriptor instead. func (*ListChangesBySnapshotUUIDResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{81} + return file_changes_proto_rawDescGZIP(), []int{83} } func (x *ListChangesBySnapshotUUIDResponse) GetChanges() []*Change { @@ -5848,7 +5974,7 @@ type DeleteChangeResponse struct { func (x *DeleteChangeResponse) Reset() { *x = DeleteChangeResponse{} - mi := &file_changes_proto_msgTypes[82] + mi := &file_changes_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5860,7 +5986,7 @@ func (x *DeleteChangeResponse) String() string { func (*DeleteChangeResponse) ProtoMessage() {} func (x *DeleteChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[82] + mi := &file_changes_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5873,7 +5999,7 @@ func (x *DeleteChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteChangeResponse.ProtoReflect.Descriptor instead. func (*DeleteChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{82} + return file_changes_proto_rawDescGZIP(), []int{84} } type RefreshStateRequest struct { @@ -5884,7 +6010,7 @@ type RefreshStateRequest struct { func (x *RefreshStateRequest) Reset() { *x = RefreshStateRequest{} - mi := &file_changes_proto_msgTypes[83] + mi := &file_changes_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5896,7 +6022,7 @@ func (x *RefreshStateRequest) String() string { func (*RefreshStateRequest) ProtoMessage() {} func (x *RefreshStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[83] + mi := &file_changes_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5909,7 +6035,7 @@ func (x *RefreshStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshStateRequest.ProtoReflect.Descriptor instead. func (*RefreshStateRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{83} + return file_changes_proto_rawDescGZIP(), []int{85} } type RefreshStateResponse struct { @@ -5920,7 +6046,7 @@ type RefreshStateResponse struct { func (x *RefreshStateResponse) Reset() { *x = RefreshStateResponse{} - mi := &file_changes_proto_msgTypes[84] + mi := &file_changes_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5932,7 +6058,7 @@ func (x *RefreshStateResponse) String() string { func (*RefreshStateResponse) ProtoMessage() {} func (x *RefreshStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[84] + mi := &file_changes_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5945,7 +6071,7 @@ func (x *RefreshStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RefreshStateResponse.ProtoReflect.Descriptor instead. func (*RefreshStateResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{84} + return file_changes_proto_rawDescGZIP(), []int{86} } type StartChangeRequest struct { @@ -5957,7 +6083,7 @@ type StartChangeRequest struct { func (x *StartChangeRequest) Reset() { *x = StartChangeRequest{} - mi := &file_changes_proto_msgTypes[85] + mi := &file_changes_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5969,7 +6095,7 @@ func (x *StartChangeRequest) String() string { func (*StartChangeRequest) ProtoMessage() {} func (x *StartChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[85] + mi := &file_changes_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5982,7 +6108,7 @@ func (x *StartChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartChangeRequest.ProtoReflect.Descriptor instead. func (*StartChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{85} + return file_changes_proto_rawDescGZIP(), []int{87} } func (x *StartChangeRequest) GetChangeUUID() []byte { @@ -6003,7 +6129,7 @@ type StartChangeResponse struct { func (x *StartChangeResponse) Reset() { *x = StartChangeResponse{} - mi := &file_changes_proto_msgTypes[86] + mi := &file_changes_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6015,7 +6141,7 @@ func (x *StartChangeResponse) String() string { func (*StartChangeResponse) ProtoMessage() {} func (x *StartChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[86] + mi := &file_changes_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6028,7 +6154,7 @@ func (x *StartChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartChangeResponse.ProtoReflect.Descriptor instead. func (*StartChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{86} + return file_changes_proto_rawDescGZIP(), []int{88} } func (x *StartChangeResponse) GetState() StartChangeResponse_State { @@ -6061,7 +6187,7 @@ type EndChangeRequest struct { func (x *EndChangeRequest) Reset() { *x = EndChangeRequest{} - mi := &file_changes_proto_msgTypes[87] + mi := &file_changes_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6073,7 +6199,7 @@ func (x *EndChangeRequest) String() string { func (*EndChangeRequest) ProtoMessage() {} func (x *EndChangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[87] + mi := &file_changes_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6086,7 +6212,7 @@ func (x *EndChangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EndChangeRequest.ProtoReflect.Descriptor instead. func (*EndChangeRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{87} + return file_changes_proto_rawDescGZIP(), []int{89} } func (x *EndChangeRequest) GetChangeUUID() []byte { @@ -6107,7 +6233,7 @@ type EndChangeResponse struct { func (x *EndChangeResponse) Reset() { *x = EndChangeResponse{} - mi := &file_changes_proto_msgTypes[88] + mi := &file_changes_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6119,7 +6245,7 @@ func (x *EndChangeResponse) String() string { func (*EndChangeResponse) ProtoMessage() {} func (x *EndChangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[88] + mi := &file_changes_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6132,7 +6258,7 @@ func (x *EndChangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EndChangeResponse.ProtoReflect.Descriptor instead. func (*EndChangeResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{88} + return file_changes_proto_rawDescGZIP(), []int{90} } func (x *EndChangeResponse) GetState() EndChangeResponse_State { @@ -6164,7 +6290,7 @@ type StartChangeSimpleResponse struct { func (x *StartChangeSimpleResponse) Reset() { *x = StartChangeSimpleResponse{} - mi := &file_changes_proto_msgTypes[89] + mi := &file_changes_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6176,7 +6302,7 @@ func (x *StartChangeSimpleResponse) String() string { func (*StartChangeSimpleResponse) ProtoMessage() {} func (x *StartChangeSimpleResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[89] + mi := &file_changes_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6189,7 +6315,7 @@ func (x *StartChangeSimpleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartChangeSimpleResponse.ProtoReflect.Descriptor instead. func (*StartChangeSimpleResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{89} + return file_changes_proto_rawDescGZIP(), []int{91} } type EndChangeSimpleResponse struct { @@ -6204,7 +6330,7 @@ type EndChangeSimpleResponse struct { func (x *EndChangeSimpleResponse) Reset() { *x = EndChangeSimpleResponse{} - mi := &file_changes_proto_msgTypes[90] + mi := &file_changes_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6216,7 +6342,7 @@ func (x *EndChangeSimpleResponse) String() string { func (*EndChangeSimpleResponse) ProtoMessage() {} func (x *EndChangeSimpleResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[90] + mi := &file_changes_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6229,7 +6355,7 @@ func (x *EndChangeSimpleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EndChangeSimpleResponse.ProtoReflect.Descriptor instead. func (*EndChangeSimpleResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{90} + return file_changes_proto_rawDescGZIP(), []int{92} } func (x *EndChangeSimpleResponse) GetQueued() bool { @@ -6259,7 +6385,7 @@ type Risk struct { func (x *Risk) Reset() { *x = Risk{} - mi := &file_changes_proto_msgTypes[91] + mi := &file_changes_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6271,7 +6397,7 @@ func (x *Risk) String() string { func (*Risk) ProtoMessage() {} func (x *Risk) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[91] + mi := &file_changes_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6284,7 +6410,7 @@ func (x *Risk) ProtoReflect() protoreflect.Message { // Deprecated: Use Risk.ProtoReflect.Descriptor instead. func (*Risk) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{91} + return file_changes_proto_rawDescGZIP(), []int{93} } func (x *Risk) GetUUID() []byte { @@ -6331,7 +6457,7 @@ type ChangeAnalysisStatus struct { func (x *ChangeAnalysisStatus) Reset() { *x = ChangeAnalysisStatus{} - mi := &file_changes_proto_msgTypes[92] + mi := &file_changes_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6343,7 +6469,7 @@ func (x *ChangeAnalysisStatus) String() string { func (*ChangeAnalysisStatus) ProtoMessage() {} func (x *ChangeAnalysisStatus) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[92] + mi := &file_changes_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6356,7 +6482,7 @@ func (x *ChangeAnalysisStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeAnalysisStatus.ProtoReflect.Descriptor instead. func (*ChangeAnalysisStatus) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{92} + return file_changes_proto_rawDescGZIP(), []int{94} } func (x *ChangeAnalysisStatus) GetStatus() ChangeAnalysisStatus_Status { @@ -6377,7 +6503,7 @@ type GenerateRiskFixRequest struct { func (x *GenerateRiskFixRequest) Reset() { *x = GenerateRiskFixRequest{} - mi := &file_changes_proto_msgTypes[93] + mi := &file_changes_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6389,7 +6515,7 @@ func (x *GenerateRiskFixRequest) String() string { func (*GenerateRiskFixRequest) ProtoMessage() {} func (x *GenerateRiskFixRequest) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[93] + mi := &file_changes_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6402,7 +6528,7 @@ func (x *GenerateRiskFixRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GenerateRiskFixRequest.ProtoReflect.Descriptor instead. func (*GenerateRiskFixRequest) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{93} + return file_changes_proto_rawDescGZIP(), []int{95} } func (x *GenerateRiskFixRequest) GetRiskUUID() []byte { @@ -6422,7 +6548,7 @@ type GenerateRiskFixResponse struct { func (x *GenerateRiskFixResponse) Reset() { *x = GenerateRiskFixResponse{} - mi := &file_changes_proto_msgTypes[94] + mi := &file_changes_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6434,7 +6560,7 @@ func (x *GenerateRiskFixResponse) String() string { func (*GenerateRiskFixResponse) ProtoMessage() {} func (x *GenerateRiskFixResponse) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[94] + mi := &file_changes_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6447,7 +6573,7 @@ func (x *GenerateRiskFixResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GenerateRiskFixResponse.ProtoReflect.Descriptor instead. func (*GenerateRiskFixResponse) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{94} + return file_changes_proto_rawDescGZIP(), []int{96} } func (x *GenerateRiskFixResponse) GetFixSuggestion() string { @@ -6477,7 +6603,7 @@ type ChangeMetadata_HealthChange struct { func (x *ChangeMetadata_HealthChange) Reset() { *x = ChangeMetadata_HealthChange{} - mi := &file_changes_proto_msgTypes[97] + mi := &file_changes_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6489,7 +6615,7 @@ func (x *ChangeMetadata_HealthChange) String() string { func (*ChangeMetadata_HealthChange) ProtoMessage() {} func (x *ChangeMetadata_HealthChange) ProtoReflect() protoreflect.Message { - mi := &file_changes_proto_msgTypes[97] + mi := &file_changes_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6502,7 +6628,7 @@ func (x *ChangeMetadata_HealthChange) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeMetadata_HealthChange.ProtoReflect.Descriptor instead. func (*ChangeMetadata_HealthChange) Descriptor() ([]byte, []int) { - return file_changes_proto_rawDescGZIP(), []int{58, 0} + return file_changes_proto_rawDescGZIP(), []int{60, 0} } func (x *ChangeMetadata_HealthChange) GetAdded() int32 { @@ -6706,7 +6832,7 @@ const file_changes_proto_rawDesc = "" + "\x0emapping_status\x18\x04 \x01(\x0e2 .changes.MappedItemMappingStatusH\x02R\rmappingStatus\x88\x01\x01B\x0f\n" + "\r_mappingQueryB\x0f\n" + "\r_mappingErrorB\x11\n" + - "\x0f_mapping_status\"\xd3\x04\n" + + "\x0f_mapping_status\"\x83\x05\n" + "\x1aStartChangeAnalysisRequest\x12\x1e\n" + "\n" + "changeUUID\x18\x01 \x01(\fR\n" + @@ -6715,11 +6841,19 @@ const file_changes_proto_rawDesc = "" + "\x19blastRadiusConfigOverride\x18\x03 \x01(\v2\x19.config.BlastRadiusConfigH\x00R\x19blastRadiusConfigOverride\x88\x01\x01\x12e\n" + "\x1croutineChangesConfigOverride\x18\x05 \x01(\v2\x1c.config.RoutineChangesConfigH\x01R\x1croutineChangesConfigOverride\x88\x01\x01\x12t\n" + "!githubOrganisationProfileOverride\x18\x06 \x01(\v2!.config.GithubOrganisationProfileH\x02R!githubOrganisationProfileOverride\x88\x01\x01\x120\n" + - "\tknowledge\x18\a \x03(\v2\x12.changes.KnowledgeR\tknowledgeB\x1c\n" + + "\tknowledge\x18\a \x03(\v2\x12.changes.KnowledgeR\tknowledge\x12.\n" + + "\x13post_github_comment\x18\b \x01(\bR\x11postGithubCommentB\x1c\n" + "\x1a_blastRadiusConfigOverrideB\x1f\n" + "\x1d_routineChangesConfigOverrideB$\n" + - "\"_githubOrganisationProfileOverrideJ\x04\b\x04\x10\x05\"\x1d\n" + - "\x1bStartChangeAnalysisResponse\"\x96\x01\n" + + "\"_githubOrganisationProfileOverrideJ\x04\b\x04\x10\x05\"I\n" + + "\x1bStartChangeAnalysisResponse\x12*\n" + + "\x11github_app_active\x18\x01 \x01(\bR\x0fgithubAppActive\"y\n" + + "\x18AddPlannedChangesRequest\x12\x1e\n" + + "\n" + + "changeUUID\x18\x01 \x01(\fR\n" + + "changeUUID\x12=\n" + + "\rchangingItems\x18\x02 \x03(\v2\x17.changes.MappedItemDiffR\rchangingItems\"\x1b\n" + + "\x19AddPlannedChangesResponse\"\x96\x01\n" + "\x16ListHomeChangesRequest\x122\n" + "\n" + "pagination\x18\x01 \x01(\v2\x12.PaginationRequestR\n" + @@ -6899,9 +7033,10 @@ const file_changes_proto_rawDesc = "" + "\x04slim\x18\x02 \x01(\bR\x04slim\x12K\n" + "\x12changeOutputFormat\x18\x03 \x01(\x0e2\x1b.changes.ChangeOutputFormatR\x12changeOutputFormat\x12F\n" + "\x12riskSeverityFilter\x18\x04 \x03(\x0e2\x16.changes.Risk.SeverityR\x12riskSeverityFilter\x12\x16\n" + - "\x06appURL\x18\x05 \x01(\tR\x06appURL\"2\n" + + "\x06appURL\x18\x05 \x01(\tR\x06appURL\"m\n" + "\x18GetChangeSummaryResponse\x12\x16\n" + - "\x06change\x18\x01 \x01(\tR\x06change\"z\n" + + "\x06change\x18\x01 \x01(\tR\x06change\x129\n" + + "\x19github_app_comment_posted\x18\x02 \x01(\bR\x16githubAppCommentPosted\"z\n" + "\x17GetChangeSignalsRequest\x12\x12\n" + "\x04UUID\x18\x01 \x01(\fR\x04UUID\x12K\n" + "\x12changeOutputFormat\x18\x02 \x01(\x0e2\x1b.changes.ChangeOutputFormatR\x12changeOutputFormat\"4\n" + @@ -7038,7 +7173,7 @@ const file_changes_proto_rawDesc = "" + "\x16CHANGE_STATUS_DEFINING\x10\x01\x12\x1b\n" + "\x17CHANGE_STATUS_HAPPENING\x10\x02\x12 \n" + "\x18CHANGE_STATUS_PROCESSING\x10\x03\x1a\x02\b\x01\x12\x16\n" + - "\x12CHANGE_STATUS_DONE\x10\x042\xad\x10\n" + + "\x12CHANGE_STATUS_DONE\x10\x042\x89\x11\n" + "\x0eChangesService\x12H\n" + "\vListChanges\x12\x1b.changes.ListChangesRequest\x1a\x1c.changes.ListChangesResponse\x12`\n" + "\x13ListChangesByStatus\x12#.changes.ListChangesByStatusRequest\x1a$.changes.ListChangesByStatusResponse\x12K\n" + @@ -7063,7 +7198,8 @@ const file_changes_proto_rawDesc = "" + "\x15PopulateChangeFilters\x12%.changes.PopulateChangeFiltersRequest\x1a&.changes.PopulateChangeFiltersResponse\x12T\n" + "\x0fGenerateRiskFix\x12\x1f.changes.GenerateRiskFixRequest\x1a .changes.GenerateRiskFixResponse\x12c\n" + "\x14GetHypothesesDetails\x12$.changes.GetHypothesesDetailsRequest\x1a%.changes.GetHypothesesDetailsResponse\x12W\n" + - "\x10GetChangeSignals\x12 .changes.GetChangeSignalsRequest\x1a!.changes.GetChangeSignalsResponse2\xfc\x04\n" + + "\x10GetChangeSignals\x12 .changes.GetChangeSignalsRequest\x1a!.changes.GetChangeSignalsResponse\x12Z\n" + + "\x11AddPlannedChanges\x12!.changes.AddPlannedChangesRequest\x1a\".changes.AddPlannedChangesResponse2\xfc\x04\n" + "\fLabelService\x12Q\n" + "\x0eListLabelRules\x12\x1e.changes.ListLabelRulesRequest\x1a\x1f.changes.ListLabelRulesResponse\x12T\n" + "\x0fCreateLabelRule\x12\x1f.changes.CreateLabelRuleRequest\x1a .changes.CreateLabelRuleResponse\x12K\n" + @@ -7086,7 +7222,7 @@ func file_changes_proto_rawDescGZIP() []byte { } var file_changes_proto_enumTypes = make([]protoimpl.EnumInfo, 12) -var file_changes_proto_msgTypes = make([]protoimpl.MessageInfo, 99) +var file_changes_proto_msgTypes = make([]protoimpl.MessageInfo, 101) var file_changes_proto_goTypes = []any{ (MappedItemTimelineStatus)(0), // 0: changes.MappedItemTimelineStatus (MappedItemMappingStatus)(0), // 1: changes.MappedItemMappingStatus @@ -7144,80 +7280,82 @@ var file_changes_proto_goTypes = []any{ (*MappedItemDiff)(nil), // 53: changes.MappedItemDiff (*StartChangeAnalysisRequest)(nil), // 54: changes.StartChangeAnalysisRequest (*StartChangeAnalysisResponse)(nil), // 55: changes.StartChangeAnalysisResponse - (*ListHomeChangesRequest)(nil), // 56: changes.ListHomeChangesRequest - (*ChangeFiltersRequest)(nil), // 57: changes.ChangeFiltersRequest - (*ListHomeChangesResponse)(nil), // 58: changes.ListHomeChangesResponse - (*PopulateChangeFiltersRequest)(nil), // 59: changes.PopulateChangeFiltersRequest - (*PopulateChangeFiltersResponse)(nil), // 60: changes.PopulateChangeFiltersResponse - (*ItemDiffSummary)(nil), // 61: changes.ItemDiffSummary - (*ItemDiff)(nil), // 62: changes.ItemDiff - (*EnrichedTags)(nil), // 63: changes.EnrichedTags - (*TagValue)(nil), // 64: changes.TagValue - (*UserTagValue)(nil), // 65: changes.UserTagValue - (*AutoTagValue)(nil), // 66: changes.AutoTagValue - (*Label)(nil), // 67: changes.Label - (*ChangeSummary)(nil), // 68: changes.ChangeSummary - (*Change)(nil), // 69: changes.Change - (*ChangeMetadata)(nil), // 70: changes.ChangeMetadata - (*ChangeProperties)(nil), // 71: changes.ChangeProperties - (*GithubChangeInfo)(nil), // 72: changes.GithubChangeInfo - (*ListChangesRequest)(nil), // 73: changes.ListChangesRequest - (*ListChangesResponse)(nil), // 74: changes.ListChangesResponse - (*ListChangesByStatusRequest)(nil), // 75: changes.ListChangesByStatusRequest - (*ListChangesByStatusResponse)(nil), // 76: changes.ListChangesByStatusResponse - (*CreateChangeRequest)(nil), // 77: changes.CreateChangeRequest - (*CreateChangeResponse)(nil), // 78: changes.CreateChangeResponse - (*GetChangeRequest)(nil), // 79: changes.GetChangeRequest - (*GetChangeByTicketLinkRequest)(nil), // 80: changes.GetChangeByTicketLinkRequest - (*GetChangeSummaryRequest)(nil), // 81: changes.GetChangeSummaryRequest - (*GetChangeSummaryResponse)(nil), // 82: changes.GetChangeSummaryResponse - (*GetChangeSignalsRequest)(nil), // 83: changes.GetChangeSignalsRequest - (*GetChangeSignalsResponse)(nil), // 84: changes.GetChangeSignalsResponse - (*GetChangeResponse)(nil), // 85: changes.GetChangeResponse - (*GetChangeRisksRequest)(nil), // 86: changes.GetChangeRisksRequest - (*ChangeRiskMetadata)(nil), // 87: changes.ChangeRiskMetadata - (*GetChangeRisksResponse)(nil), // 88: changes.GetChangeRisksResponse - (*UpdateChangeRequest)(nil), // 89: changes.UpdateChangeRequest - (*UpdateChangeResponse)(nil), // 90: changes.UpdateChangeResponse - (*DeleteChangeRequest)(nil), // 91: changes.DeleteChangeRequest - (*ListChangesBySnapshotUUIDRequest)(nil), // 92: changes.ListChangesBySnapshotUUIDRequest - (*ListChangesBySnapshotUUIDResponse)(nil), // 93: changes.ListChangesBySnapshotUUIDResponse - (*DeleteChangeResponse)(nil), // 94: changes.DeleteChangeResponse - (*RefreshStateRequest)(nil), // 95: changes.RefreshStateRequest - (*RefreshStateResponse)(nil), // 96: changes.RefreshStateResponse - (*StartChangeRequest)(nil), // 97: changes.StartChangeRequest - (*StartChangeResponse)(nil), // 98: changes.StartChangeResponse - (*EndChangeRequest)(nil), // 99: changes.EndChangeRequest - (*EndChangeResponse)(nil), // 100: changes.EndChangeResponse - (*StartChangeSimpleResponse)(nil), // 101: changes.StartChangeSimpleResponse - (*EndChangeSimpleResponse)(nil), // 102: changes.EndChangeSimpleResponse - (*Risk)(nil), // 103: changes.Risk - (*ChangeAnalysisStatus)(nil), // 104: changes.ChangeAnalysisStatus - (*GenerateRiskFixRequest)(nil), // 105: changes.GenerateRiskFixRequest - (*GenerateRiskFixResponse)(nil), // 106: changes.GenerateRiskFixResponse - nil, // 107: changes.EnrichedTags.TagValueEntry - nil, // 108: changes.ChangeSummary.TagsEntry - (*ChangeMetadata_HealthChange)(nil), // 109: changes.ChangeMetadata.HealthChange - nil, // 110: changes.ChangeProperties.TagsEntry - (*timestamppb.Timestamp)(nil), // 111: google.protobuf.Timestamp - (*Edge)(nil), // 112: Edge - (*Query)(nil), // 113: Query - (*QueryError)(nil), // 114: QueryError - (*BlastRadiusConfig)(nil), // 115: config.BlastRadiusConfig - (*RoutineChangesConfig)(nil), // 116: config.RoutineChangesConfig - (*GithubOrganisationProfile)(nil), // 117: config.GithubOrganisationProfile - (*PaginationRequest)(nil), // 118: PaginationRequest - (SortOrder)(0), // 119: SortOrder - (*PaginationResponse)(nil), // 120: PaginationResponse - (*Reference)(nil), // 121: Reference - (Health)(0), // 122: Health - (*Item)(nil), // 123: Item + (*AddPlannedChangesRequest)(nil), // 56: changes.AddPlannedChangesRequest + (*AddPlannedChangesResponse)(nil), // 57: changes.AddPlannedChangesResponse + (*ListHomeChangesRequest)(nil), // 58: changes.ListHomeChangesRequest + (*ChangeFiltersRequest)(nil), // 59: changes.ChangeFiltersRequest + (*ListHomeChangesResponse)(nil), // 60: changes.ListHomeChangesResponse + (*PopulateChangeFiltersRequest)(nil), // 61: changes.PopulateChangeFiltersRequest + (*PopulateChangeFiltersResponse)(nil), // 62: changes.PopulateChangeFiltersResponse + (*ItemDiffSummary)(nil), // 63: changes.ItemDiffSummary + (*ItemDiff)(nil), // 64: changes.ItemDiff + (*EnrichedTags)(nil), // 65: changes.EnrichedTags + (*TagValue)(nil), // 66: changes.TagValue + (*UserTagValue)(nil), // 67: changes.UserTagValue + (*AutoTagValue)(nil), // 68: changes.AutoTagValue + (*Label)(nil), // 69: changes.Label + (*ChangeSummary)(nil), // 70: changes.ChangeSummary + (*Change)(nil), // 71: changes.Change + (*ChangeMetadata)(nil), // 72: changes.ChangeMetadata + (*ChangeProperties)(nil), // 73: changes.ChangeProperties + (*GithubChangeInfo)(nil), // 74: changes.GithubChangeInfo + (*ListChangesRequest)(nil), // 75: changes.ListChangesRequest + (*ListChangesResponse)(nil), // 76: changes.ListChangesResponse + (*ListChangesByStatusRequest)(nil), // 77: changes.ListChangesByStatusRequest + (*ListChangesByStatusResponse)(nil), // 78: changes.ListChangesByStatusResponse + (*CreateChangeRequest)(nil), // 79: changes.CreateChangeRequest + (*CreateChangeResponse)(nil), // 80: changes.CreateChangeResponse + (*GetChangeRequest)(nil), // 81: changes.GetChangeRequest + (*GetChangeByTicketLinkRequest)(nil), // 82: changes.GetChangeByTicketLinkRequest + (*GetChangeSummaryRequest)(nil), // 83: changes.GetChangeSummaryRequest + (*GetChangeSummaryResponse)(nil), // 84: changes.GetChangeSummaryResponse + (*GetChangeSignalsRequest)(nil), // 85: changes.GetChangeSignalsRequest + (*GetChangeSignalsResponse)(nil), // 86: changes.GetChangeSignalsResponse + (*GetChangeResponse)(nil), // 87: changes.GetChangeResponse + (*GetChangeRisksRequest)(nil), // 88: changes.GetChangeRisksRequest + (*ChangeRiskMetadata)(nil), // 89: changes.ChangeRiskMetadata + (*GetChangeRisksResponse)(nil), // 90: changes.GetChangeRisksResponse + (*UpdateChangeRequest)(nil), // 91: changes.UpdateChangeRequest + (*UpdateChangeResponse)(nil), // 92: changes.UpdateChangeResponse + (*DeleteChangeRequest)(nil), // 93: changes.DeleteChangeRequest + (*ListChangesBySnapshotUUIDRequest)(nil), // 94: changes.ListChangesBySnapshotUUIDRequest + (*ListChangesBySnapshotUUIDResponse)(nil), // 95: changes.ListChangesBySnapshotUUIDResponse + (*DeleteChangeResponse)(nil), // 96: changes.DeleteChangeResponse + (*RefreshStateRequest)(nil), // 97: changes.RefreshStateRequest + (*RefreshStateResponse)(nil), // 98: changes.RefreshStateResponse + (*StartChangeRequest)(nil), // 99: changes.StartChangeRequest + (*StartChangeResponse)(nil), // 100: changes.StartChangeResponse + (*EndChangeRequest)(nil), // 101: changes.EndChangeRequest + (*EndChangeResponse)(nil), // 102: changes.EndChangeResponse + (*StartChangeSimpleResponse)(nil), // 103: changes.StartChangeSimpleResponse + (*EndChangeSimpleResponse)(nil), // 104: changes.EndChangeSimpleResponse + (*Risk)(nil), // 105: changes.Risk + (*ChangeAnalysisStatus)(nil), // 106: changes.ChangeAnalysisStatus + (*GenerateRiskFixRequest)(nil), // 107: changes.GenerateRiskFixRequest + (*GenerateRiskFixResponse)(nil), // 108: changes.GenerateRiskFixResponse + nil, // 109: changes.EnrichedTags.TagValueEntry + nil, // 110: changes.ChangeSummary.TagsEntry + (*ChangeMetadata_HealthChange)(nil), // 111: changes.ChangeMetadata.HealthChange + nil, // 112: changes.ChangeProperties.TagsEntry + (*timestamppb.Timestamp)(nil), // 113: google.protobuf.Timestamp + (*Edge)(nil), // 114: Edge + (*Query)(nil), // 115: Query + (*QueryError)(nil), // 116: QueryError + (*BlastRadiusConfig)(nil), // 117: config.BlastRadiusConfig + (*RoutineChangesConfig)(nil), // 118: config.RoutineChangesConfig + (*GithubOrganisationProfile)(nil), // 119: config.GithubOrganisationProfile + (*PaginationRequest)(nil), // 120: PaginationRequest + (SortOrder)(0), // 121: SortOrder + (*PaginationResponse)(nil), // 122: PaginationResponse + (*Reference)(nil), // 123: Reference + (Health)(0), // 124: Health + (*Item)(nil), // 125: Item } var file_changes_proto_depIdxs = []int32{ 13, // 0: changes.LabelRule.metadata:type_name -> changes.LabelRuleMetadata 14, // 1: changes.LabelRule.properties:type_name -> changes.LabelRuleProperties - 111, // 2: changes.LabelRuleMetadata.createdAt:type_name -> google.protobuf.Timestamp - 111, // 3: changes.LabelRuleMetadata.updatedAt:type_name -> google.protobuf.Timestamp + 113, // 2: changes.LabelRuleMetadata.createdAt:type_name -> google.protobuf.Timestamp + 113, // 3: changes.LabelRuleMetadata.updatedAt:type_name -> google.protobuf.Timestamp 12, // 4: changes.ListLabelRulesResponse.rules:type_name -> changes.LabelRule 14, // 5: changes.CreateLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties 12, // 6: changes.CreateLabelRuleResponse.rule:type_name -> changes.LabelRule @@ -7225,16 +7363,16 @@ var file_changes_proto_depIdxs = []int32{ 14, // 8: changes.UpdateLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties 12, // 9: changes.UpdateLabelRuleResponse.rule:type_name -> changes.LabelRule 14, // 10: changes.TestLabelRuleRequest.properties:type_name -> changes.LabelRuleProperties - 67, // 11: changes.TestLabelRuleResponse.label:type_name -> changes.Label - 111, // 12: changes.ReapplyLabelRuleInTimeRangeRequest.startAt:type_name -> google.protobuf.Timestamp - 111, // 13: changes.ReapplyLabelRuleInTimeRangeRequest.endAt:type_name -> google.protobuf.Timestamp + 69, // 11: changes.TestLabelRuleResponse.label:type_name -> changes.Label + 113, // 12: changes.ReapplyLabelRuleInTimeRangeRequest.startAt:type_name -> google.protobuf.Timestamp + 113, // 13: changes.ReapplyLabelRuleInTimeRangeRequest.endAt:type_name -> google.protobuf.Timestamp 33, // 14: changes.GetHypothesesDetailsResponse.hypotheses:type_name -> changes.HypothesesDetails 2, // 15: changes.HypothesesDetails.status:type_name -> changes.HypothesisStatus 29, // 16: changes.HypothesesDetails.knowledgeUsed:type_name -> changes.KnowledgeReference 36, // 17: changes.GetChangeTimelineV2Response.entries:type_name -> changes.ChangeTimelineEntryV2 3, // 18: changes.ChangeTimelineEntryV2.status:type_name -> changes.ChangeTimelineEntryStatus - 111, // 19: changes.ChangeTimelineEntryV2.startedAt:type_name -> google.protobuf.Timestamp - 111, // 20: changes.ChangeTimelineEntryV2.endedAt:type_name -> google.protobuf.Timestamp + 113, // 19: changes.ChangeTimelineEntryV2.startedAt:type_name -> google.protobuf.Timestamp + 113, // 20: changes.ChangeTimelineEntryV2.endedAt:type_name -> google.protobuf.Timestamp 39, // 21: changes.ChangeTimelineEntryV2.mappedItems:type_name -> changes.MappedItemsTimelineEntry 40, // 22: changes.ChangeTimelineEntryV2.calculatedBlastRadius:type_name -> changes.CalculatedBlastRadiusTimelineEntry 45, // 23: changes.ChangeTimelineEntryV2.calculatedRisks:type_name -> changes.CalculatedRisksTimelineEntry @@ -7250,152 +7388,155 @@ var file_changes_proto_depIdxs = []int32{ 44, // 33: changes.FormHypothesesTimelineEntry.hypotheses:type_name -> changes.HypothesisSummary 44, // 34: changes.InvestigateHypothesesTimelineEntry.hypotheses:type_name -> changes.HypothesisSummary 2, // 35: changes.HypothesisSummary.status:type_name -> changes.HypothesisStatus - 103, // 36: changes.CalculatedRisksTimelineEntry.risks:type_name -> changes.Risk - 67, // 37: changes.CalculatedLabelsTimelineEntry.labels:type_name -> changes.Label + 105, // 36: changes.CalculatedRisksTimelineEntry.risks:type_name -> changes.Risk + 69, // 37: changes.CalculatedLabelsTimelineEntry.labels:type_name -> changes.Label 48, // 38: changes.ChangeValidationTimelineEntry.validationChecklist:type_name -> changes.ChangeValidationCategory - 62, // 39: changes.GetDiffResponse.expectedItems:type_name -> changes.ItemDiff - 62, // 40: changes.GetDiffResponse.unexpectedItems:type_name -> changes.ItemDiff - 112, // 41: changes.GetDiffResponse.edges:type_name -> Edge - 62, // 42: changes.GetDiffResponse.missingItems:type_name -> changes.ItemDiff - 61, // 43: changes.ListChangingItemsSummaryResponse.items:type_name -> changes.ItemDiffSummary - 62, // 44: changes.MappedItemDiff.item:type_name -> changes.ItemDiff - 113, // 45: changes.MappedItemDiff.mappingQuery:type_name -> Query - 114, // 46: changes.MappedItemDiff.mappingError:type_name -> QueryError + 64, // 39: changes.GetDiffResponse.expectedItems:type_name -> changes.ItemDiff + 64, // 40: changes.GetDiffResponse.unexpectedItems:type_name -> changes.ItemDiff + 114, // 41: changes.GetDiffResponse.edges:type_name -> Edge + 64, // 42: changes.GetDiffResponse.missingItems:type_name -> changes.ItemDiff + 63, // 43: changes.ListChangingItemsSummaryResponse.items:type_name -> changes.ItemDiffSummary + 64, // 44: changes.MappedItemDiff.item:type_name -> changes.ItemDiff + 115, // 45: changes.MappedItemDiff.mappingQuery:type_name -> Query + 116, // 46: changes.MappedItemDiff.mappingError:type_name -> QueryError 1, // 47: changes.MappedItemDiff.mapping_status:type_name -> changes.MappedItemMappingStatus 53, // 48: changes.StartChangeAnalysisRequest.changingItems:type_name -> changes.MappedItemDiff - 115, // 49: changes.StartChangeAnalysisRequest.blastRadiusConfigOverride:type_name -> config.BlastRadiusConfig - 116, // 50: changes.StartChangeAnalysisRequest.routineChangesConfigOverride:type_name -> config.RoutineChangesConfig - 117, // 51: changes.StartChangeAnalysisRequest.githubOrganisationProfileOverride:type_name -> config.GithubOrganisationProfile + 117, // 49: changes.StartChangeAnalysisRequest.blastRadiusConfigOverride:type_name -> config.BlastRadiusConfig + 118, // 50: changes.StartChangeAnalysisRequest.routineChangesConfigOverride:type_name -> config.RoutineChangesConfig + 119, // 51: changes.StartChangeAnalysisRequest.githubOrganisationProfileOverride:type_name -> config.GithubOrganisationProfile 30, // 52: changes.StartChangeAnalysisRequest.knowledge:type_name -> changes.Knowledge - 118, // 53: changes.ListHomeChangesRequest.pagination:type_name -> PaginationRequest - 57, // 54: changes.ListHomeChangesRequest.filters:type_name -> changes.ChangeFiltersRequest - 10, // 55: changes.ChangeFiltersRequest.risks:type_name -> changes.Risk.Severity - 7, // 56: changes.ChangeFiltersRequest.statuses:type_name -> changes.ChangeStatus - 119, // 57: changes.ChangeFiltersRequest.sortOrder:type_name -> SortOrder - 68, // 58: changes.ListHomeChangesResponse.changes:type_name -> changes.ChangeSummary - 120, // 59: changes.ListHomeChangesResponse.pagination:type_name -> PaginationResponse - 121, // 60: changes.ItemDiffSummary.item:type_name -> Reference - 4, // 61: changes.ItemDiffSummary.status:type_name -> changes.ItemDiffStatus - 122, // 62: changes.ItemDiffSummary.healthAfter:type_name -> Health - 121, // 63: changes.ItemDiff.item:type_name -> Reference - 4, // 64: changes.ItemDiff.status:type_name -> changes.ItemDiffStatus - 123, // 65: changes.ItemDiff.before:type_name -> Item - 123, // 66: changes.ItemDiff.after:type_name -> Item - 121, // 67: changes.ItemDiff.mappedItemRef:type_name -> Reference - 107, // 68: changes.EnrichedTags.tagValue:type_name -> changes.EnrichedTags.TagValueEntry - 65, // 69: changes.TagValue.userTagValue:type_name -> changes.UserTagValue - 66, // 70: changes.TagValue.autoTagValue:type_name -> changes.AutoTagValue - 6, // 71: changes.Label.type:type_name -> changes.LabelType - 7, // 72: changes.ChangeSummary.status:type_name -> changes.ChangeStatus - 111, // 73: changes.ChangeSummary.createdAt:type_name -> google.protobuf.Timestamp - 108, // 74: changes.ChangeSummary.tags:type_name -> changes.ChangeSummary.TagsEntry - 63, // 75: changes.ChangeSummary.enrichedTags:type_name -> changes.EnrichedTags - 67, // 76: changes.ChangeSummary.labels:type_name -> changes.Label - 72, // 77: changes.ChangeSummary.githubChangeInfo:type_name -> changes.GithubChangeInfo - 70, // 78: changes.Change.metadata:type_name -> changes.ChangeMetadata - 71, // 79: changes.Change.properties:type_name -> changes.ChangeProperties - 111, // 80: changes.ChangeMetadata.createdAt:type_name -> google.protobuf.Timestamp - 111, // 81: changes.ChangeMetadata.updatedAt:type_name -> google.protobuf.Timestamp - 7, // 82: changes.ChangeMetadata.status:type_name -> changes.ChangeStatus - 109, // 83: changes.ChangeMetadata.UnknownHealthChange:type_name -> changes.ChangeMetadata.HealthChange - 109, // 84: changes.ChangeMetadata.OkHealthChange:type_name -> changes.ChangeMetadata.HealthChange - 109, // 85: changes.ChangeMetadata.WarningHealthChange:type_name -> changes.ChangeMetadata.HealthChange - 109, // 86: changes.ChangeMetadata.ErrorHealthChange:type_name -> changes.ChangeMetadata.HealthChange - 109, // 87: changes.ChangeMetadata.PendingHealthChange:type_name -> changes.ChangeMetadata.HealthChange - 72, // 88: changes.ChangeMetadata.githubChangeInfo:type_name -> changes.GithubChangeInfo - 104, // 89: changes.ChangeMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus - 62, // 90: changes.ChangeProperties.plannedChanges:type_name -> changes.ItemDiff - 110, // 91: changes.ChangeProperties.tags:type_name -> changes.ChangeProperties.TagsEntry - 63, // 92: changes.ChangeProperties.enrichedTags:type_name -> changes.EnrichedTags - 67, // 93: changes.ChangeProperties.labels:type_name -> changes.Label - 69, // 94: changes.ListChangesResponse.changes:type_name -> changes.Change - 7, // 95: changes.ListChangesByStatusRequest.status:type_name -> changes.ChangeStatus - 69, // 96: changes.ListChangesByStatusResponse.changes:type_name -> changes.Change - 71, // 97: changes.CreateChangeRequest.properties:type_name -> changes.ChangeProperties - 69, // 98: changes.CreateChangeResponse.change:type_name -> changes.Change - 5, // 99: changes.GetChangeSummaryRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat - 10, // 100: changes.GetChangeSummaryRequest.riskSeverityFilter:type_name -> changes.Risk.Severity - 5, // 101: changes.GetChangeSignalsRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat - 69, // 102: changes.GetChangeResponse.change:type_name -> changes.Change - 104, // 103: changes.ChangeRiskMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus - 103, // 104: changes.ChangeRiskMetadata.risks:type_name -> changes.Risk - 87, // 105: changes.GetChangeRisksResponse.changeRiskMetadata:type_name -> changes.ChangeRiskMetadata - 71, // 106: changes.UpdateChangeRequest.properties:type_name -> changes.ChangeProperties - 69, // 107: changes.UpdateChangeResponse.change:type_name -> changes.Change - 69, // 108: changes.ListChangesBySnapshotUUIDResponse.changes:type_name -> changes.Change - 8, // 109: changes.StartChangeResponse.state:type_name -> changes.StartChangeResponse.State - 9, // 110: changes.EndChangeResponse.state:type_name -> changes.EndChangeResponse.State - 10, // 111: changes.Risk.severity:type_name -> changes.Risk.Severity - 121, // 112: changes.Risk.relatedItems:type_name -> Reference - 11, // 113: changes.ChangeAnalysisStatus.status:type_name -> changes.ChangeAnalysisStatus.Status - 64, // 114: changes.EnrichedTags.TagValueEntry.value:type_name -> changes.TagValue - 73, // 115: changes.ChangesService.ListChanges:input_type -> changes.ListChangesRequest - 75, // 116: changes.ChangesService.ListChangesByStatus:input_type -> changes.ListChangesByStatusRequest - 77, // 117: changes.ChangesService.CreateChange:input_type -> changes.CreateChangeRequest - 79, // 118: changes.ChangesService.GetChange:input_type -> changes.GetChangeRequest - 80, // 119: changes.ChangesService.GetChangeByTicketLink:input_type -> changes.GetChangeByTicketLinkRequest - 81, // 120: changes.ChangesService.GetChangeSummary:input_type -> changes.GetChangeSummaryRequest - 34, // 121: changes.ChangesService.GetChangeTimelineV2:input_type -> changes.GetChangeTimelineV2Request - 86, // 122: changes.ChangesService.GetChangeRisks:input_type -> changes.GetChangeRisksRequest - 89, // 123: changes.ChangesService.UpdateChange:input_type -> changes.UpdateChangeRequest - 91, // 124: changes.ChangesService.DeleteChange:input_type -> changes.DeleteChangeRequest - 92, // 125: changes.ChangesService.ListChangesBySnapshotUUID:input_type -> changes.ListChangesBySnapshotUUIDRequest - 95, // 126: changes.ChangesService.RefreshState:input_type -> changes.RefreshStateRequest - 97, // 127: changes.ChangesService.StartChange:input_type -> changes.StartChangeRequest - 99, // 128: changes.ChangesService.EndChange:input_type -> changes.EndChangeRequest - 97, // 129: changes.ChangesService.StartChangeSimple:input_type -> changes.StartChangeRequest - 99, // 130: changes.ChangesService.EndChangeSimple:input_type -> changes.EndChangeRequest - 56, // 131: changes.ChangesService.ListHomeChanges:input_type -> changes.ListHomeChangesRequest - 54, // 132: changes.ChangesService.StartChangeAnalysis:input_type -> changes.StartChangeAnalysisRequest - 51, // 133: changes.ChangesService.ListChangingItemsSummary:input_type -> changes.ListChangingItemsSummaryRequest - 49, // 134: changes.ChangesService.GetDiff:input_type -> changes.GetDiffRequest - 59, // 135: changes.ChangesService.PopulateChangeFilters:input_type -> changes.PopulateChangeFiltersRequest - 105, // 136: changes.ChangesService.GenerateRiskFix:input_type -> changes.GenerateRiskFixRequest - 31, // 137: changes.ChangesService.GetHypothesesDetails:input_type -> changes.GetHypothesesDetailsRequest - 83, // 138: changes.ChangesService.GetChangeSignals:input_type -> changes.GetChangeSignalsRequest - 15, // 139: changes.LabelService.ListLabelRules:input_type -> changes.ListLabelRulesRequest - 17, // 140: changes.LabelService.CreateLabelRule:input_type -> changes.CreateLabelRuleRequest - 19, // 141: changes.LabelService.GetLabelRule:input_type -> changes.GetLabelRuleRequest - 21, // 142: changes.LabelService.UpdateLabelRule:input_type -> changes.UpdateLabelRuleRequest - 23, // 143: changes.LabelService.DeleteLabelRule:input_type -> changes.DeleteLabelRuleRequest - 25, // 144: changes.LabelService.TestLabelRule:input_type -> changes.TestLabelRuleRequest - 27, // 145: changes.LabelService.ReapplyLabelRuleInTimeRange:input_type -> changes.ReapplyLabelRuleInTimeRangeRequest - 74, // 146: changes.ChangesService.ListChanges:output_type -> changes.ListChangesResponse - 76, // 147: changes.ChangesService.ListChangesByStatus:output_type -> changes.ListChangesByStatusResponse - 78, // 148: changes.ChangesService.CreateChange:output_type -> changes.CreateChangeResponse - 85, // 149: changes.ChangesService.GetChange:output_type -> changes.GetChangeResponse - 85, // 150: changes.ChangesService.GetChangeByTicketLink:output_type -> changes.GetChangeResponse - 82, // 151: changes.ChangesService.GetChangeSummary:output_type -> changes.GetChangeSummaryResponse - 35, // 152: changes.ChangesService.GetChangeTimelineV2:output_type -> changes.GetChangeTimelineV2Response - 88, // 153: changes.ChangesService.GetChangeRisks:output_type -> changes.GetChangeRisksResponse - 90, // 154: changes.ChangesService.UpdateChange:output_type -> changes.UpdateChangeResponse - 94, // 155: changes.ChangesService.DeleteChange:output_type -> changes.DeleteChangeResponse - 93, // 156: changes.ChangesService.ListChangesBySnapshotUUID:output_type -> changes.ListChangesBySnapshotUUIDResponse - 96, // 157: changes.ChangesService.RefreshState:output_type -> changes.RefreshStateResponse - 98, // 158: changes.ChangesService.StartChange:output_type -> changes.StartChangeResponse - 100, // 159: changes.ChangesService.EndChange:output_type -> changes.EndChangeResponse - 101, // 160: changes.ChangesService.StartChangeSimple:output_type -> changes.StartChangeSimpleResponse - 102, // 161: changes.ChangesService.EndChangeSimple:output_type -> changes.EndChangeSimpleResponse - 58, // 162: changes.ChangesService.ListHomeChanges:output_type -> changes.ListHomeChangesResponse - 55, // 163: changes.ChangesService.StartChangeAnalysis:output_type -> changes.StartChangeAnalysisResponse - 52, // 164: changes.ChangesService.ListChangingItemsSummary:output_type -> changes.ListChangingItemsSummaryResponse - 50, // 165: changes.ChangesService.GetDiff:output_type -> changes.GetDiffResponse - 60, // 166: changes.ChangesService.PopulateChangeFilters:output_type -> changes.PopulateChangeFiltersResponse - 106, // 167: changes.ChangesService.GenerateRiskFix:output_type -> changes.GenerateRiskFixResponse - 32, // 168: changes.ChangesService.GetHypothesesDetails:output_type -> changes.GetHypothesesDetailsResponse - 84, // 169: changes.ChangesService.GetChangeSignals:output_type -> changes.GetChangeSignalsResponse - 16, // 170: changes.LabelService.ListLabelRules:output_type -> changes.ListLabelRulesResponse - 18, // 171: changes.LabelService.CreateLabelRule:output_type -> changes.CreateLabelRuleResponse - 20, // 172: changes.LabelService.GetLabelRule:output_type -> changes.GetLabelRuleResponse - 22, // 173: changes.LabelService.UpdateLabelRule:output_type -> changes.UpdateLabelRuleResponse - 24, // 174: changes.LabelService.DeleteLabelRule:output_type -> changes.DeleteLabelRuleResponse - 26, // 175: changes.LabelService.TestLabelRule:output_type -> changes.TestLabelRuleResponse - 28, // 176: changes.LabelService.ReapplyLabelRuleInTimeRange:output_type -> changes.ReapplyLabelRuleInTimeRangeResponse - 146, // [146:177] is the sub-list for method output_type - 115, // [115:146] is the sub-list for method input_type - 115, // [115:115] is the sub-list for extension type_name - 115, // [115:115] is the sub-list for extension extendee - 0, // [0:115] is the sub-list for field type_name + 53, // 53: changes.AddPlannedChangesRequest.changingItems:type_name -> changes.MappedItemDiff + 120, // 54: changes.ListHomeChangesRequest.pagination:type_name -> PaginationRequest + 59, // 55: changes.ListHomeChangesRequest.filters:type_name -> changes.ChangeFiltersRequest + 10, // 56: changes.ChangeFiltersRequest.risks:type_name -> changes.Risk.Severity + 7, // 57: changes.ChangeFiltersRequest.statuses:type_name -> changes.ChangeStatus + 121, // 58: changes.ChangeFiltersRequest.sortOrder:type_name -> SortOrder + 70, // 59: changes.ListHomeChangesResponse.changes:type_name -> changes.ChangeSummary + 122, // 60: changes.ListHomeChangesResponse.pagination:type_name -> PaginationResponse + 123, // 61: changes.ItemDiffSummary.item:type_name -> Reference + 4, // 62: changes.ItemDiffSummary.status:type_name -> changes.ItemDiffStatus + 124, // 63: changes.ItemDiffSummary.healthAfter:type_name -> Health + 123, // 64: changes.ItemDiff.item:type_name -> Reference + 4, // 65: changes.ItemDiff.status:type_name -> changes.ItemDiffStatus + 125, // 66: changes.ItemDiff.before:type_name -> Item + 125, // 67: changes.ItemDiff.after:type_name -> Item + 123, // 68: changes.ItemDiff.mappedItemRef:type_name -> Reference + 109, // 69: changes.EnrichedTags.tagValue:type_name -> changes.EnrichedTags.TagValueEntry + 67, // 70: changes.TagValue.userTagValue:type_name -> changes.UserTagValue + 68, // 71: changes.TagValue.autoTagValue:type_name -> changes.AutoTagValue + 6, // 72: changes.Label.type:type_name -> changes.LabelType + 7, // 73: changes.ChangeSummary.status:type_name -> changes.ChangeStatus + 113, // 74: changes.ChangeSummary.createdAt:type_name -> google.protobuf.Timestamp + 110, // 75: changes.ChangeSummary.tags:type_name -> changes.ChangeSummary.TagsEntry + 65, // 76: changes.ChangeSummary.enrichedTags:type_name -> changes.EnrichedTags + 69, // 77: changes.ChangeSummary.labels:type_name -> changes.Label + 74, // 78: changes.ChangeSummary.githubChangeInfo:type_name -> changes.GithubChangeInfo + 72, // 79: changes.Change.metadata:type_name -> changes.ChangeMetadata + 73, // 80: changes.Change.properties:type_name -> changes.ChangeProperties + 113, // 81: changes.ChangeMetadata.createdAt:type_name -> google.protobuf.Timestamp + 113, // 82: changes.ChangeMetadata.updatedAt:type_name -> google.protobuf.Timestamp + 7, // 83: changes.ChangeMetadata.status:type_name -> changes.ChangeStatus + 111, // 84: changes.ChangeMetadata.UnknownHealthChange:type_name -> changes.ChangeMetadata.HealthChange + 111, // 85: changes.ChangeMetadata.OkHealthChange:type_name -> changes.ChangeMetadata.HealthChange + 111, // 86: changes.ChangeMetadata.WarningHealthChange:type_name -> changes.ChangeMetadata.HealthChange + 111, // 87: changes.ChangeMetadata.ErrorHealthChange:type_name -> changes.ChangeMetadata.HealthChange + 111, // 88: changes.ChangeMetadata.PendingHealthChange:type_name -> changes.ChangeMetadata.HealthChange + 74, // 89: changes.ChangeMetadata.githubChangeInfo:type_name -> changes.GithubChangeInfo + 106, // 90: changes.ChangeMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus + 64, // 91: changes.ChangeProperties.plannedChanges:type_name -> changes.ItemDiff + 112, // 92: changes.ChangeProperties.tags:type_name -> changes.ChangeProperties.TagsEntry + 65, // 93: changes.ChangeProperties.enrichedTags:type_name -> changes.EnrichedTags + 69, // 94: changes.ChangeProperties.labels:type_name -> changes.Label + 71, // 95: changes.ListChangesResponse.changes:type_name -> changes.Change + 7, // 96: changes.ListChangesByStatusRequest.status:type_name -> changes.ChangeStatus + 71, // 97: changes.ListChangesByStatusResponse.changes:type_name -> changes.Change + 73, // 98: changes.CreateChangeRequest.properties:type_name -> changes.ChangeProperties + 71, // 99: changes.CreateChangeResponse.change:type_name -> changes.Change + 5, // 100: changes.GetChangeSummaryRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat + 10, // 101: changes.GetChangeSummaryRequest.riskSeverityFilter:type_name -> changes.Risk.Severity + 5, // 102: changes.GetChangeSignalsRequest.changeOutputFormat:type_name -> changes.ChangeOutputFormat + 71, // 103: changes.GetChangeResponse.change:type_name -> changes.Change + 106, // 104: changes.ChangeRiskMetadata.changeAnalysisStatus:type_name -> changes.ChangeAnalysisStatus + 105, // 105: changes.ChangeRiskMetadata.risks:type_name -> changes.Risk + 89, // 106: changes.GetChangeRisksResponse.changeRiskMetadata:type_name -> changes.ChangeRiskMetadata + 73, // 107: changes.UpdateChangeRequest.properties:type_name -> changes.ChangeProperties + 71, // 108: changes.UpdateChangeResponse.change:type_name -> changes.Change + 71, // 109: changes.ListChangesBySnapshotUUIDResponse.changes:type_name -> changes.Change + 8, // 110: changes.StartChangeResponse.state:type_name -> changes.StartChangeResponse.State + 9, // 111: changes.EndChangeResponse.state:type_name -> changes.EndChangeResponse.State + 10, // 112: changes.Risk.severity:type_name -> changes.Risk.Severity + 123, // 113: changes.Risk.relatedItems:type_name -> Reference + 11, // 114: changes.ChangeAnalysisStatus.status:type_name -> changes.ChangeAnalysisStatus.Status + 66, // 115: changes.EnrichedTags.TagValueEntry.value:type_name -> changes.TagValue + 75, // 116: changes.ChangesService.ListChanges:input_type -> changes.ListChangesRequest + 77, // 117: changes.ChangesService.ListChangesByStatus:input_type -> changes.ListChangesByStatusRequest + 79, // 118: changes.ChangesService.CreateChange:input_type -> changes.CreateChangeRequest + 81, // 119: changes.ChangesService.GetChange:input_type -> changes.GetChangeRequest + 82, // 120: changes.ChangesService.GetChangeByTicketLink:input_type -> changes.GetChangeByTicketLinkRequest + 83, // 121: changes.ChangesService.GetChangeSummary:input_type -> changes.GetChangeSummaryRequest + 34, // 122: changes.ChangesService.GetChangeTimelineV2:input_type -> changes.GetChangeTimelineV2Request + 88, // 123: changes.ChangesService.GetChangeRisks:input_type -> changes.GetChangeRisksRequest + 91, // 124: changes.ChangesService.UpdateChange:input_type -> changes.UpdateChangeRequest + 93, // 125: changes.ChangesService.DeleteChange:input_type -> changes.DeleteChangeRequest + 94, // 126: changes.ChangesService.ListChangesBySnapshotUUID:input_type -> changes.ListChangesBySnapshotUUIDRequest + 97, // 127: changes.ChangesService.RefreshState:input_type -> changes.RefreshStateRequest + 99, // 128: changes.ChangesService.StartChange:input_type -> changes.StartChangeRequest + 101, // 129: changes.ChangesService.EndChange:input_type -> changes.EndChangeRequest + 99, // 130: changes.ChangesService.StartChangeSimple:input_type -> changes.StartChangeRequest + 101, // 131: changes.ChangesService.EndChangeSimple:input_type -> changes.EndChangeRequest + 58, // 132: changes.ChangesService.ListHomeChanges:input_type -> changes.ListHomeChangesRequest + 54, // 133: changes.ChangesService.StartChangeAnalysis:input_type -> changes.StartChangeAnalysisRequest + 51, // 134: changes.ChangesService.ListChangingItemsSummary:input_type -> changes.ListChangingItemsSummaryRequest + 49, // 135: changes.ChangesService.GetDiff:input_type -> changes.GetDiffRequest + 61, // 136: changes.ChangesService.PopulateChangeFilters:input_type -> changes.PopulateChangeFiltersRequest + 107, // 137: changes.ChangesService.GenerateRiskFix:input_type -> changes.GenerateRiskFixRequest + 31, // 138: changes.ChangesService.GetHypothesesDetails:input_type -> changes.GetHypothesesDetailsRequest + 85, // 139: changes.ChangesService.GetChangeSignals:input_type -> changes.GetChangeSignalsRequest + 56, // 140: changes.ChangesService.AddPlannedChanges:input_type -> changes.AddPlannedChangesRequest + 15, // 141: changes.LabelService.ListLabelRules:input_type -> changes.ListLabelRulesRequest + 17, // 142: changes.LabelService.CreateLabelRule:input_type -> changes.CreateLabelRuleRequest + 19, // 143: changes.LabelService.GetLabelRule:input_type -> changes.GetLabelRuleRequest + 21, // 144: changes.LabelService.UpdateLabelRule:input_type -> changes.UpdateLabelRuleRequest + 23, // 145: changes.LabelService.DeleteLabelRule:input_type -> changes.DeleteLabelRuleRequest + 25, // 146: changes.LabelService.TestLabelRule:input_type -> changes.TestLabelRuleRequest + 27, // 147: changes.LabelService.ReapplyLabelRuleInTimeRange:input_type -> changes.ReapplyLabelRuleInTimeRangeRequest + 76, // 148: changes.ChangesService.ListChanges:output_type -> changes.ListChangesResponse + 78, // 149: changes.ChangesService.ListChangesByStatus:output_type -> changes.ListChangesByStatusResponse + 80, // 150: changes.ChangesService.CreateChange:output_type -> changes.CreateChangeResponse + 87, // 151: changes.ChangesService.GetChange:output_type -> changes.GetChangeResponse + 87, // 152: changes.ChangesService.GetChangeByTicketLink:output_type -> changes.GetChangeResponse + 84, // 153: changes.ChangesService.GetChangeSummary:output_type -> changes.GetChangeSummaryResponse + 35, // 154: changes.ChangesService.GetChangeTimelineV2:output_type -> changes.GetChangeTimelineV2Response + 90, // 155: changes.ChangesService.GetChangeRisks:output_type -> changes.GetChangeRisksResponse + 92, // 156: changes.ChangesService.UpdateChange:output_type -> changes.UpdateChangeResponse + 96, // 157: changes.ChangesService.DeleteChange:output_type -> changes.DeleteChangeResponse + 95, // 158: changes.ChangesService.ListChangesBySnapshotUUID:output_type -> changes.ListChangesBySnapshotUUIDResponse + 98, // 159: changes.ChangesService.RefreshState:output_type -> changes.RefreshStateResponse + 100, // 160: changes.ChangesService.StartChange:output_type -> changes.StartChangeResponse + 102, // 161: changes.ChangesService.EndChange:output_type -> changes.EndChangeResponse + 103, // 162: changes.ChangesService.StartChangeSimple:output_type -> changes.StartChangeSimpleResponse + 104, // 163: changes.ChangesService.EndChangeSimple:output_type -> changes.EndChangeSimpleResponse + 60, // 164: changes.ChangesService.ListHomeChanges:output_type -> changes.ListHomeChangesResponse + 55, // 165: changes.ChangesService.StartChangeAnalysis:output_type -> changes.StartChangeAnalysisResponse + 52, // 166: changes.ChangesService.ListChangingItemsSummary:output_type -> changes.ListChangingItemsSummaryResponse + 50, // 167: changes.ChangesService.GetDiff:output_type -> changes.GetDiffResponse + 62, // 168: changes.ChangesService.PopulateChangeFilters:output_type -> changes.PopulateChangeFiltersResponse + 108, // 169: changes.ChangesService.GenerateRiskFix:output_type -> changes.GenerateRiskFixResponse + 32, // 170: changes.ChangesService.GetHypothesesDetails:output_type -> changes.GetHypothesesDetailsResponse + 86, // 171: changes.ChangesService.GetChangeSignals:output_type -> changes.GetChangeSignalsResponse + 57, // 172: changes.ChangesService.AddPlannedChanges:output_type -> changes.AddPlannedChangesResponse + 16, // 173: changes.LabelService.ListLabelRules:output_type -> changes.ListLabelRulesResponse + 18, // 174: changes.LabelService.CreateLabelRule:output_type -> changes.CreateLabelRuleResponse + 20, // 175: changes.LabelService.GetLabelRule:output_type -> changes.GetLabelRuleResponse + 22, // 176: changes.LabelService.UpdateLabelRule:output_type -> changes.UpdateLabelRuleResponse + 24, // 177: changes.LabelService.DeleteLabelRule:output_type -> changes.DeleteLabelRuleResponse + 26, // 178: changes.LabelService.TestLabelRule:output_type -> changes.TestLabelRuleResponse + 28, // 179: changes.LabelService.ReapplyLabelRuleInTimeRange:output_type -> changes.ReapplyLabelRuleInTimeRangeResponse + 148, // [148:180] is the sub-list for method output_type + 116, // [116:148] is the sub-list for method input_type + 116, // [116:116] is the sub-list for extension type_name + 116, // [116:116] is the sub-list for extension extendee + 0, // [0:116] is the sub-list for field type_name } func init() { file_changes_proto_init() } @@ -7422,21 +7563,21 @@ func file_changes_proto_init() { file_changes_proto_msgTypes[26].OneofWrappers = []any{} file_changes_proto_msgTypes[41].OneofWrappers = []any{} file_changes_proto_msgTypes[42].OneofWrappers = []any{} - file_changes_proto_msgTypes[44].OneofWrappers = []any{} - file_changes_proto_msgTypes[45].OneofWrappers = []any{} - file_changes_proto_msgTypes[50].OneofWrappers = []any{} - file_changes_proto_msgTypes[52].OneofWrappers = []any{ + file_changes_proto_msgTypes[46].OneofWrappers = []any{} + file_changes_proto_msgTypes[47].OneofWrappers = []any{} + file_changes_proto_msgTypes[52].OneofWrappers = []any{} + file_changes_proto_msgTypes[54].OneofWrappers = []any{ (*TagValue_UserTagValue)(nil), (*TagValue_AutoTagValue)(nil), } - file_changes_proto_msgTypes[58].OneofWrappers = []any{} + file_changes_proto_msgTypes[60].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_changes_proto_rawDesc), len(file_changes_proto_rawDesc)), NumEnums: 12, - NumMessages: 99, + NumMessages: 101, NumExtensions: 0, NumServices: 2, }, diff --git a/go/sdp-go/sdpconnect/changes.connect.go b/go/sdp-go/sdpconnect/changes.connect.go index 2988499a..e9c7978e 100644 --- a/go/sdp-go/sdpconnect/changes.connect.go +++ b/go/sdp-go/sdpconnect/changes.connect.go @@ -106,6 +106,9 @@ const ( // ChangesServiceGetChangeSignalsProcedure is the fully-qualified name of the ChangesService's // GetChangeSignals RPC. ChangesServiceGetChangeSignalsProcedure = "/changes.ChangesService/GetChangeSignals" + // ChangesServiceAddPlannedChangesProcedure is the fully-qualified name of the ChangesService's + // AddPlannedChanges RPC. + ChangesServiceAddPlannedChangesProcedure = "/changes.ChangesService/AddPlannedChanges" // LabelServiceListLabelRulesProcedure is the fully-qualified name of the LabelService's // ListLabelRules RPC. LabelServiceListLabelRulesProcedure = "/changes.LabelService/ListLabelRules" @@ -199,6 +202,11 @@ type ChangesServiceClient interface { // - Individual custom signals // This is similar to GetChangeSummary but focused on signals data GetChangeSignals(context.Context, *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) + // Appends planned changes to an existing change without starting analysis. + // The change must be in CHANGE_STATUS_DEFINING. Each call inserts a new batch + // of items; call StartChangeAnalysis (with empty changingItems) to trigger + // analysis on all accumulated items. + AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) } // NewChangesServiceClient constructs a client for the changes.ChangesService service. By default, @@ -356,6 +364,12 @@ func NewChangesServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(changesServiceMethods.ByName("GetChangeSignals")), connect.WithClientOptions(opts...), ), + addPlannedChanges: connect.NewClient[sdp_go.AddPlannedChangesRequest, sdp_go.AddPlannedChangesResponse]( + httpClient, + baseURL+ChangesServiceAddPlannedChangesProcedure, + connect.WithSchema(changesServiceMethods.ByName("AddPlannedChanges")), + connect.WithClientOptions(opts...), + ), } } @@ -385,6 +399,7 @@ type changesServiceClient struct { generateRiskFix *connect.Client[sdp_go.GenerateRiskFixRequest, sdp_go.GenerateRiskFixResponse] getHypothesesDetails *connect.Client[sdp_go.GetHypothesesDetailsRequest, sdp_go.GetHypothesesDetailsResponse] getChangeSignals *connect.Client[sdp_go.GetChangeSignalsRequest, sdp_go.GetChangeSignalsResponse] + addPlannedChanges *connect.Client[sdp_go.AddPlannedChangesRequest, sdp_go.AddPlannedChangesResponse] } // ListChanges calls changes.ChangesService.ListChanges. @@ -507,6 +522,11 @@ func (c *changesServiceClient) GetChangeSignals(ctx context.Context, req *connec return c.getChangeSignals.CallUnary(ctx, req) } +// AddPlannedChanges calls changes.ChangesService.AddPlannedChanges. +func (c *changesServiceClient) AddPlannedChanges(ctx context.Context, req *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) { + return c.addPlannedChanges.CallUnary(ctx, req) +} + // ChangesServiceHandler is an implementation of the changes.ChangesService service. type ChangesServiceHandler interface { // Lists all changes @@ -577,6 +597,11 @@ type ChangesServiceHandler interface { // - Individual custom signals // This is similar to GetChangeSummary but focused on signals data GetChangeSignals(context.Context, *connect.Request[sdp_go.GetChangeSignalsRequest]) (*connect.Response[sdp_go.GetChangeSignalsResponse], error) + // Appends planned changes to an existing change without starting analysis. + // The change must be in CHANGE_STATUS_DEFINING. Each call inserts a new batch + // of items; call StartChangeAnalysis (with empty changingItems) to trigger + // analysis on all accumulated items. + AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) } // NewChangesServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -730,6 +755,12 @@ func NewChangesServiceHandler(svc ChangesServiceHandler, opts ...connect.Handler connect.WithSchema(changesServiceMethods.ByName("GetChangeSignals")), connect.WithHandlerOptions(opts...), ) + changesServiceAddPlannedChangesHandler := connect.NewUnaryHandler( + ChangesServiceAddPlannedChangesProcedure, + svc.AddPlannedChanges, + connect.WithSchema(changesServiceMethods.ByName("AddPlannedChanges")), + connect.WithHandlerOptions(opts...), + ) return "/changes.ChangesService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ChangesServiceListChangesProcedure: @@ -780,6 +811,8 @@ func NewChangesServiceHandler(svc ChangesServiceHandler, opts ...connect.Handler changesServiceGetHypothesesDetailsHandler.ServeHTTP(w, r) case ChangesServiceGetChangeSignalsProcedure: changesServiceGetChangeSignalsHandler.ServeHTTP(w, r) + case ChangesServiceAddPlannedChangesProcedure: + changesServiceAddPlannedChangesHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -885,6 +918,10 @@ func (UnimplementedChangesServiceHandler) GetChangeSignals(context.Context, *con return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.GetChangeSignals is not implemented")) } +func (UnimplementedChangesServiceHandler) AddPlannedChanges(context.Context, *connect.Request[sdp_go.AddPlannedChangesRequest]) (*connect.Response[sdp_go.AddPlannedChangesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("changes.ChangesService.AddPlannedChanges is not implemented")) +} + // LabelServiceClient is a client for the changes.LabelService service. type LabelServiceClient interface { // Lists all label rules for an account diff --git a/go/sdpcache/bolt_cache.go b/go/sdpcache/bolt_cache.go index de81b8d6..a024a229 100644 --- a/go/sdpcache/bolt_cache.go +++ b/go/sdpcache/bolt_cache.go @@ -31,6 +31,16 @@ var ( deletedBytesKey = []byte("deletedBytes") ) +// cacheOpenOptions are the bbolt options used for every Open call in this +// package. Since this is a cache layer, crash durability is unnecessary: +// - NoSync skips fdatasync per commit, removing the single-writer bottleneck. +// - NoFreelistSync skips persisting the freelist, reducing write amplification. +var cacheOpenOptions = &bbolt.Options{ + Timeout: 5 * time.Second, + NoSync: true, + NoFreelistSync: true, +} + // DefaultCompactThreshold is the default threshold for triggering compaction (100MB) const DefaultCompactThreshold = 100 * 1024 * 1024 @@ -234,9 +244,7 @@ func NewBoltCache(path string, opts ...BoltCacheOption) (*BoltCache, error) { } // bbolt.Open will open an existing file if present, or create a new one - db, err := bbolt.Open(path, 0o600, &bbolt.Options{ - Timeout: 5 * time.Second, - }) + db, err := bbolt.Open(path, 0o600, cacheOpenOptions) if err != nil { return nil, fmt.Errorf("failed to open bolt database: %w", err) } @@ -457,7 +465,7 @@ func (c *BoltCache) deleteCacheFileLocked(ctx context.Context, span trace.Span) c.resetDeletedBytes() // Reopen the database - db, err := bbolt.Open(c.path, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + db, err := bbolt.Open(c.path, 0o600, cacheOpenOptions) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to reopen database") @@ -1341,13 +1349,13 @@ func (c *BoltCache) compact(ctx context.Context) error { } // Open the destination database - dstDB, err := bbolt.Open(tempPath, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + dstDB, err := bbolt.Open(tempPath, 0o600, cacheOpenOptions) if err != nil { if isDiskFullError(err) { // Attempt cleanup first - use locked version since we already hold the lock c.purgeLocked(ctx, time.Now()) // Try again - dstDB, err = bbolt.Open(tempPath, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + dstDB, err = bbolt.Open(tempPath, 0o600, cacheOpenOptions) if err != nil { return handleDiskFull(err, "temp database creation") } @@ -1364,7 +1372,7 @@ func (c *BoltCache) compact(ctx context.Context) error { // Attempt cleanup first - use locked version since we already hold the lock c.purgeLocked(ctx, time.Now()) // Try compaction again - dstDB2, retryErr := bbolt.Open(tempPath, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + dstDB2, retryErr := bbolt.Open(tempPath, 0o600, cacheOpenOptions) if retryErr != nil { return handleDiskFull(retryErr, "temp database creation after cleanup") } @@ -1395,12 +1403,12 @@ func (c *BoltCache) compact(ctx context.Context) error { // Replace the old file with the compacted one if err := os.Rename(tempPath, c.path); err != nil { // Try to reopen the original database - c.db, _ = bbolt.Open(c.path, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + c.db, _ = bbolt.Open(c.path, 0o600, cacheOpenOptions) return handleDiskFull(err, "rename") } // Reopen the database - db, err := bbolt.Open(c.path, 0o600, &bbolt.Options{Timeout: 5 * time.Second}) + db, err := bbolt.Open(c.path, 0o600, cacheOpenOptions) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "failed to reopen database") diff --git a/k8s-source/adapters/endpoints.go b/k8s-source/adapters/endpoints.go index 9fa04764..14045e0e 100644 --- a/k8s-source/adapters/endpoints.go +++ b/k8s-source/adapters/endpoints.go @@ -7,7 +7,6 @@ // and acceptable. When the SDK eventually drops support for v1.Endpoints we // will need to split out version-specific builds of the k8s-source. -//nolint:staticcheck // See note at top of file package adapters import ( @@ -18,7 +17,7 @@ import ( "k8s.io/client-go/kubernetes" ) -func EndpointsExtractor(resource *v1.Endpoints, scope string) ([]*sdp.LinkedItemQuery, error) { +func EndpointsExtractor(resource *v1.Endpoints, scope string) ([]*sdp.LinkedItemQuery, error) { //nolint:staticcheck,nolintlint // SA1019: v1.Endpoints deprecated; see note at top of file queries := make([]*sdp.LinkedItemQuery, 0) sd, err := ParseScope(scope, true) @@ -72,15 +71,15 @@ func EndpointsExtractor(resource *v1.Endpoints, scope string) ([]*sdp.LinkedItem } func newEndpointsAdapter(cs *kubernetes.Clientset, cluster string, namespaces []string, cache sdpcache.Cache) discovery.ListableAdapter { - return &KubeTypeAdapter[*v1.Endpoints, *v1.EndpointsList]{ + return &KubeTypeAdapter[*v1.Endpoints, *v1.EndpointsList]{ //nolint:staticcheck,nolintlint // SA1019: v1.Endpoints deprecated; see note at top of file ClusterName: cluster, Namespaces: namespaces, TypeName: "Endpoints", - NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Endpoints, *v1.EndpointsList] { + NamespacedInterfaceBuilder: func(namespace string) ItemInterface[*v1.Endpoints, *v1.EndpointsList] { //nolint:staticcheck,nolintlint // SA1019 return cs.CoreV1().Endpoints(namespace) }, - ListExtractor: func(list *v1.EndpointsList) ([]*v1.Endpoints, error) { - extracted := make([]*v1.Endpoints, len(list.Items)) + ListExtractor: func(list *v1.EndpointsList) ([]*v1.Endpoints, error) { //nolint:staticcheck,nolintlint // SA1019 + extracted := make([]*v1.Endpoints, len(list.Items)) //nolint:staticcheck,nolintlint // SA1019 for i := range list.Items { extracted[i] = &list.Items[i] diff --git a/k8s-source/build/package/Dockerfile b/k8s-source/build/package/Dockerfile index f579cf84..566ca10e 100644 --- a/k8s-source/build/package/Dockerfile +++ b/k8s-source/build/package/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_VERSION ARG BUILD_COMMIT # required for accessing the private dependencies and generating version descriptor -RUN apk add --no-cache git curl +RUN apk upgrade --no-cache && apk add --no-cache git curl WORKDIR /workspace @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source k8s-source/main.go -FROM alpine:3.23 +FROM alpine:3.23.3 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 diff --git a/sources/azure/build/package/Dockerfile b/sources/azure/build/package/Dockerfile index 1fc2d532..a31baa68 100644 --- a/sources/azure/build/package/Dockerfile +++ b/sources/azure/build/package/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor -RUN apk add --no-cache git +RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source sources/azure/main.go -FROM alpine:3.23 +FROM alpine:3.23.3 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 diff --git a/sources/azure/clients/compute-disk-access-private-endpoint-connection-client.go b/sources/azure/clients/compute-disk-access-private-endpoint-connection-client.go new file mode 100644 index 00000000..a47bab1e --- /dev/null +++ b/sources/azure/clients/compute-disk-access-private-endpoint-connection-client.go @@ -0,0 +1,35 @@ +package clients + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" +) + +//go:generate mockgen -destination=../shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go -package=mocks -source=compute-disk-access-private-endpoint-connection-client.go + +// ComputeDiskAccessPrivateEndpointConnectionsPager is a type alias for the generic Pager interface with disk access private endpoint connection list response type. +type ComputeDiskAccessPrivateEndpointConnectionsPager = Pager[armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse] + +// ComputeDiskAccessPrivateEndpointConnectionsClient is an interface for interacting with Azure disk access private endpoint connections. +type ComputeDiskAccessPrivateEndpointConnectionsClient interface { + Get(ctx context.Context, resourceGroupName string, diskAccessName string, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) + NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) ComputeDiskAccessPrivateEndpointConnectionsPager +} + +type computeDiskAccessPrivateEndpointConnectionsClient struct { + client *armcompute.DiskAccessesClient +} + +func (c *computeDiskAccessPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName string, diskAccessName string, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) { + return c.client.GetAPrivateEndpointConnection(ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName, nil) +} + +func (c *computeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) ComputeDiskAccessPrivateEndpointConnectionsPager { + return c.client.NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName, options) +} + +// NewComputeDiskAccessPrivateEndpointConnectionsClient creates a new ComputeDiskAccessPrivateEndpointConnectionsClient from the Azure SDK DiskAccesses client. +func NewComputeDiskAccessPrivateEndpointConnectionsClient(client *armcompute.DiskAccessesClient) ComputeDiskAccessPrivateEndpointConnectionsClient { + return &computeDiskAccessPrivateEndpointConnectionsClient{client: client} +} diff --git a/sources/azure/clients/default-security-rules-client.go b/sources/azure/clients/default-security-rules-client.go new file mode 100644 index 00000000..612876fb --- /dev/null +++ b/sources/azure/clients/default-security-rules-client.go @@ -0,0 +1,35 @@ +package clients + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" +) + +//go:generate mockgen -destination=../shared/mocks/mock_default_security_rules_client.go -package=mocks -source=default-security-rules-client.go + +// DefaultSecurityRulesPager is a type alias for the generic Pager interface with default security rules list response type. +type DefaultSecurityRulesPager = Pager[armnetwork.DefaultSecurityRulesClientListResponse] + +// DefaultSecurityRulesClient is an interface for interacting with Azure NSG default security rules (child of network security group). +type DefaultSecurityRulesClient interface { + Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) + NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) DefaultSecurityRulesPager +} + +type defaultSecurityRulesClient struct { + client *armnetwork.DefaultSecurityRulesClient +} + +func (c *defaultSecurityRulesClient) Get(ctx context.Context, resourceGroupName string, networkSecurityGroupName string, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) { + return c.client.Get(ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) +} + +func (c *defaultSecurityRulesClient) NewListPager(resourceGroupName string, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) DefaultSecurityRulesPager { + return c.client.NewListPager(resourceGroupName, networkSecurityGroupName, options) +} + +// NewDefaultSecurityRulesClient creates a new DefaultSecurityRulesClient from the Azure SDK client. +func NewDefaultSecurityRulesClient(client *armnetwork.DefaultSecurityRulesClient) DefaultSecurityRulesClient { + return &defaultSecurityRulesClient{client: client} +} diff --git a/sources/azure/clients/elastic-san-client.go b/sources/azure/clients/elastic-san-client.go new file mode 100644 index 00000000..6e708767 --- /dev/null +++ b/sources/azure/clients/elastic-san-client.go @@ -0,0 +1,35 @@ +package clients + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" +) + +//go:generate mockgen -destination=../shared/mocks/mock_elastic_san_client.go -package=mocks -source=elastic-san-client.go + +// ElasticSanPager is a type alias for the generic Pager interface with Elastic SAN list response type. +type ElasticSanPager = Pager[armelasticsan.ElasticSansClientListByResourceGroupResponse] + +// ElasticSanClient is an interface for interacting with Azure Elastic SAN (pool) resources. +type ElasticSanClient interface { + Get(ctx context.Context, resourceGroupName string, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) + NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) ElasticSanPager +} + +type elasticSanClient struct { + client *armelasticsan.ElasticSansClient +} + +func (c *elasticSanClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) { + return c.client.Get(ctx, resourceGroupName, elasticSanName, options) +} + +func (c *elasticSanClient) NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) ElasticSanPager { + return c.client.NewListByResourceGroupPager(resourceGroupName, options) +} + +// NewElasticSanClient creates a new ElasticSanClient from the Azure SDK client. +func NewElasticSanClient(client *armelasticsan.ElasticSansClient) ElasticSanClient { + return &elasticSanClient{client: client} +} diff --git a/sources/azure/clients/elastic-san-volume-group-client.go b/sources/azure/clients/elastic-san-volume-group-client.go new file mode 100644 index 00000000..24164f5a --- /dev/null +++ b/sources/azure/clients/elastic-san-volume-group-client.go @@ -0,0 +1,35 @@ +package clients + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" +) + +//go:generate mockgen -destination=../shared/mocks/mock_elastic_san_volume_group_client.go -package=mocks -source=elastic-san-volume-group-client.go + +// ElasticSanVolumeGroupPager is a type alias for the generic Pager interface with volume group list response type. +type ElasticSanVolumeGroupPager = Pager[armelasticsan.VolumeGroupsClientListByElasticSanResponse] + +// ElasticSanVolumeGroupClient is an interface for interacting with Azure Elastic SAN volume groups. +type ElasticSanVolumeGroupClient interface { + Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) + NewListByElasticSanPager(resourceGroupName string, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) ElasticSanVolumeGroupPager +} + +type elasticSanVolumeGroupClient struct { + client *armelasticsan.VolumeGroupsClient +} + +func (c *elasticSanVolumeGroupClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) { + return c.client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, options) +} + +func (c *elasticSanVolumeGroupClient) NewListByElasticSanPager(resourceGroupName string, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) ElasticSanVolumeGroupPager { + return c.client.NewListByElasticSanPager(resourceGroupName, elasticSanName, options) +} + +// NewElasticSanVolumeGroupClient creates a new ElasticSanVolumeGroupClient from the Azure SDK client. +func NewElasticSanVolumeGroupClient(client *armelasticsan.VolumeGroupsClient) ElasticSanVolumeGroupClient { + return &elasticSanVolumeGroupClient{client: client} +} diff --git a/sources/azure/clients/elastic-san-volume-snapshot-client.go b/sources/azure/clients/elastic-san-volume-snapshot-client.go new file mode 100644 index 00000000..b1306edf --- /dev/null +++ b/sources/azure/clients/elastic-san-volume-snapshot-client.go @@ -0,0 +1,35 @@ +package clients + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" +) + +//go:generate mockgen -destination=../shared/mocks/mock_elastic_san_volume_snapshot_client.go -package=mocks -source=elastic-san-volume-snapshot-client.go + +// ElasticSanVolumeSnapshotPager is a type alias for the generic Pager interface with volume snapshot list response type. +type ElasticSanVolumeSnapshotPager = Pager[armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse] + +// ElasticSanVolumeSnapshotClient is an interface for interacting with Azure Elastic SAN volume snapshots. +type ElasticSanVolumeSnapshotClient interface { + Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) + ListByVolumeGroup(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) ElasticSanVolumeSnapshotPager +} + +type elasticSanVolumeSnapshotClient struct { + client *armelasticsan.VolumeSnapshotsClient +} + +func (c *elasticSanVolumeSnapshotClient) Get(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) { + return c.client.Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) +} + +func (c *elasticSanVolumeSnapshotClient) ListByVolumeGroup(ctx context.Context, resourceGroupName string, elasticSanName string, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) ElasticSanVolumeSnapshotPager { + return c.client.NewListByVolumeGroupPager(resourceGroupName, elasticSanName, volumeGroupName, options) +} + +// NewElasticSanVolumeSnapshotClient creates a new ElasticSanVolumeSnapshotClient from the Azure SDK client. +func NewElasticSanVolumeSnapshotClient(client *armelasticsan.VolumeSnapshotsClient) ElasticSanVolumeSnapshotClient { + return &elasticSanVolumeSnapshotClient{client: client} +} diff --git a/sources/azure/manual/adapters.go b/sources/azure/manual/adapters.go index aaeba7cb..5f164a81 100644 --- a/sources/azure/manual/adapters.go +++ b/sources/azure/manual/adapters.go @@ -15,6 +15,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/postgresql/armpostgresqlflexibleservers/v5" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage/v3" @@ -236,6 +237,11 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred return nil, fmt.Errorf("failed to create security rules client: %w", err) } + defaultSecurityRulesClient, err := armnetwork.NewDefaultSecurityRulesClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create default security rules client: %w", err) + } + applicationGatewaysClient, err := armnetwork.NewApplicationGatewaysClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create application gateways client: %w", err) @@ -412,6 +418,21 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred return nil, fmt.Errorf("failed to create snapshots client: %w", err) } + elasticSansClient, err := armelasticsan.NewElasticSansClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create elastic sans client: %w", err) + } + + elasticSanVolumeSnapshotsClient, err := armelasticsan.NewVolumeSnapshotsClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create elastic san volume snapshots client: %w", err) + } + + elasticSanVolumeGroupsClient, err := armelasticsan.NewVolumeGroupsClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create elastic san volume groups client: %w", err) + } + sharedGalleryImagesClient, err := armcompute.NewSharedGalleryImagesClient(subscriptionID, cred, nil) if err != nil { return nil, fmt.Errorf("failed to create shared gallery images client: %w", err) @@ -588,6 +609,10 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred clients.NewSecurityRulesClient(securityRulesClient), resourceGroupScopes, ), cache), + sources.WrapperToAdapter(NewNetworkDefaultSecurityRule( + clients.NewDefaultSecurityRulesClient(defaultSecurityRulesClient), + resourceGroupScopes, + ), cache), sources.WrapperToAdapter(NewNetworkApplicationGateway( clients.NewApplicationGatewaysClient(applicationGatewaysClient), resourceGroupScopes, @@ -656,6 +681,10 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred clients.NewDiskAccessesClient(diskAccessesClient), resourceGroupScopes, ), cache), + sources.WrapperToAdapter(NewComputeDiskAccessPrivateEndpointConnection( + clients.NewComputeDiskAccessPrivateEndpointConnectionsClient(diskAccessesClient), + resourceGroupScopes, + ), cache), sources.WrapperToAdapter(NewComputeDedicatedHostGroup( clients.NewDedicatedHostGroupsClient(dedicatedHostGroupsClient), resourceGroupScopes, @@ -692,6 +721,18 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred clients.NewSnapshotsClient(snapshotsClient), resourceGroupScopes, ), cache), + sources.WrapperToAdapter(NewElasticSan( + clients.NewElasticSanClient(elasticSansClient), + resourceGroupScopes, + ), cache), + sources.WrapperToAdapter(NewElasticSanVolumeSnapshot( + clients.NewElasticSanVolumeSnapshotClient(elasticSanVolumeSnapshotsClient), + resourceGroupScopes, + ), cache), + sources.WrapperToAdapter(NewElasticSanVolumeGroup( + clients.NewElasticSanVolumeGroupClient(elasticSanVolumeGroupsClient), + resourceGroupScopes, + ), cache), ) } @@ -753,6 +794,7 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred sources.WrapperToAdapter(NewNetworkNetworkSecurityGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkApplicationSecurityGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkSecurityRule(nil, placeholderResourceGroupScopes), noOpCache), + sources.WrapperToAdapter(NewNetworkDefaultSecurityRule(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkRouteTable(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkApplicationGateway(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewNetworkVirtualNetworkGateway(nil, placeholderResourceGroupScopes), noOpCache), @@ -771,6 +813,7 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred sources.WrapperToAdapter(NewComputeVirtualMachineExtension(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeProximityPlacementGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDiskAccess(nil, placeholderResourceGroupScopes), noOpCache), + sources.WrapperToAdapter(NewComputeDiskAccessPrivateEndpointConnection(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDedicatedHostGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeDedicatedHost(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeCapacityReservationGroup(nil, placeholderResourceGroupScopes), noOpCache), @@ -780,6 +823,9 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred sources.WrapperToAdapter(NewComputeGallery(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeGalleryImage(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeSnapshot(nil, placeholderResourceGroupScopes), noOpCache), + sources.WrapperToAdapter(NewElasticSan(nil, placeholderResourceGroupScopes), noOpCache), + sources.WrapperToAdapter(NewElasticSanVolumeSnapshot(nil, placeholderResourceGroupScopes), noOpCache), + sources.WrapperToAdapter(NewElasticSanVolumeGroup(nil, placeholderResourceGroupScopes), noOpCache), sources.WrapperToAdapter(NewComputeSharedGalleryImage(nil, subscriptionID), noOpCache), sources.WrapperToAdapter(NewNetworkPrivateEndpoint(nil, placeholderResourceGroupScopes), noOpCache), ) diff --git a/sources/azure/manual/compute-disk-access-private-endpoint-connection.go b/sources/azure/manual/compute-disk-access-private-endpoint-connection.go new file mode 100644 index 00000000..429364d9 --- /dev/null +++ b/sources/azure/manual/compute-disk-access-private-endpoint-connection.go @@ -0,0 +1,235 @@ +package manual + +import ( + "context" + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/shared" +) + +var ComputeDiskAccessPrivateEndpointConnectionLookupByName = shared.NewItemTypeLookup("name", azureshared.ComputeDiskAccessPrivateEndpointConnection) + +type computeDiskAccessPrivateEndpointConnectionWrapper struct { + client clients.ComputeDiskAccessPrivateEndpointConnectionsClient + + *azureshared.MultiResourceGroupBase +} + +// NewComputeDiskAccessPrivateEndpointConnection returns a SearchableWrapper for Azure disk access private endpoint connections. +func NewComputeDiskAccessPrivateEndpointConnection(client clients.ComputeDiskAccessPrivateEndpointConnectionsClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { + return &computeDiskAccessPrivateEndpointConnectionWrapper{ + client: client, + MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( + resourceGroupScopes, + sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, + azureshared.ComputeDiskAccessPrivateEndpointConnection, + ), + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 2 { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: "Get requires 2 query parts: diskAccessName and privateEndpointConnectionName", + Scope: scope, + ItemType: s.Type(), + } + } + diskAccessName := queryParts[0] + connectionName := queryParts[1] + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + resp, err := s.client.Get(ctx, rgScope.ResourceGroup, diskAccessName, connectionName) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(&resp.PrivateEndpointConnection, diskAccessName, connectionName, scope) + if sdpErr != nil { + return nil, sdpErr + } + return item, nil +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) GetLookups() sources.ItemTypeLookups { + return sources.ItemTypeLookups{ + ComputeDiskAccessLookupByName, + ComputeDiskAccessPrivateEndpointConnectionLookupByName, + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 1 { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: "Search requires 1 query part: diskAccessName", + Scope: scope, + ItemType: s.Type(), + } + } + diskAccessName := queryParts[0] + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + pager := s.client.NewListPrivateEndpointConnectionsPager(rgScope.ResourceGroup, diskAccessName, nil) + + var items []*sdp.Item + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + for _, conn := range page.Value { + if conn == nil || conn.Name == nil { + continue + } + + item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, diskAccessName, *conn.Name, scope) + if sdpErr != nil { + return nil, sdpErr + } + items = append(items, item) + } + } + + return items, nil +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { + if len(queryParts) < 1 { + stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: diskAccessName"), scope, s.Type())) + return + } + diskAccessName := queryParts[0] + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, s.Type())) + return + } + pager := s.client.NewListPrivateEndpointConnectionsPager(rgScope.ResourceGroup, diskAccessName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, s.Type())) + return + } + for _, conn := range page.Value { + if conn == nil || conn.Name == nil { + continue + } + item, sdpErr := s.azurePrivateEndpointConnectionToSDPItem(conn, diskAccessName, *conn.Name, scope) + if sdpErr != nil { + stream.SendError(sdpErr) + continue + } + cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) + stream.SendItem(item) + } + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) SearchLookups() []sources.ItemTypeLookups { + return []sources.ItemTypeLookups{ + { + ComputeDiskAccessLookupByName, + }, + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) PotentialLinks() map[shared.ItemType]bool { + return map[shared.ItemType]bool{ + azureshared.ComputeDiskAccess: true, + azureshared.NetworkPrivateEndpoint: true, + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) azurePrivateEndpointConnectionToSDPItem(conn *armcompute.PrivateEndpointConnection, diskAccessName, connectionName, scope string) (*sdp.Item, *sdp.QueryError) { + attributes, err := shared.ToAttributesWithExclude(conn) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(diskAccessName, connectionName)) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + sdpItem := &sdp.Item{ + Type: azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), + UniqueAttribute: "uniqueAttr", + Attributes: attributes, + Scope: scope, + } + + // Health from provisioning state + if conn.Properties != nil && conn.Properties.ProvisioningState != nil { + switch *conn.Properties.ProvisioningState { + case armcompute.PrivateEndpointConnectionProvisioningStateSucceeded: + sdpItem.Health = sdp.Health_HEALTH_OK.Enum() + case armcompute.PrivateEndpointConnectionProvisioningStateCreating, + armcompute.PrivateEndpointConnectionProvisioningStateDeleting: + sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() + case armcompute.PrivateEndpointConnectionProvisioningStateFailed: + sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() + default: + sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() + } + } + + // Link to parent Disk Access + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ComputeDiskAccess.String(), + Method: sdp.QueryMethod_GET, + Query: diskAccessName, + Scope: scope, + }, + }) + + // Link to Network Private Endpoint when present (may be in different resource group) + if conn.Properties != nil && conn.Properties.PrivateEndpoint != nil && conn.Properties.PrivateEndpoint.ID != nil { + peID := *conn.Properties.PrivateEndpoint.ID + peName := azureshared.ExtractResourceName(peID) + if peName != "" { + linkedScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(peID); extractedScope != "" { + linkedScope = extractedScope + } + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkPrivateEndpoint.String(), + Method: sdp.QueryMethod_GET, + Query: peName, + Scope: linkedScope, + }, + }) + } + } + + return sdpItem, nil +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) IAMPermissions() []string { + return []string{ + "Microsoft.Compute/diskAccesses/privateEndpointConnections/read", + } +} + +func (s computeDiskAccessPrivateEndpointConnectionWrapper) PredefinedRole() string { + return "Reader" +} diff --git a/sources/azure/manual/compute-disk-access-private-endpoint-connection_test.go b/sources/azure/manual/compute-disk-access-private-endpoint-connection_test.go new file mode 100644 index 00000000..9418325b --- /dev/null +++ b/sources/azure/manual/compute-disk-access-private-endpoint-connection_test.go @@ -0,0 +1,322 @@ +package manual_test + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" + "go.uber.org/mock/gomock" + + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + "github.com/overmindtech/cli/sources/azure/manual" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/azure/shared/mocks" + "github.com/overmindtech/cli/sources/shared" +) + +type mockComputeDiskAccessPrivateEndpointConnectionsPager struct { + pages []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse + index int +} + +func (m *mockComputeDiskAccessPrivateEndpointConnectionsPager) More() bool { + return m.index < len(m.pages) +} + +func (m *mockComputeDiskAccessPrivateEndpointConnectionsPager) NextPage(ctx context.Context) (armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse, error) { + if m.index >= len(m.pages) { + return armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +type testComputeDiskAccessPrivateEndpointConnectionsClient struct { + *mocks.MockComputeDiskAccessPrivateEndpointConnectionsClient + pager clients.ComputeDiskAccessPrivateEndpointConnectionsPager +} + +func (t *testComputeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName string, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) clients.ComputeDiskAccessPrivateEndpointConnectionsPager { + return t.pager +} + +func TestComputeDiskAccessPrivateEndpointConnection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + subscriptionID := "test-subscription" + resourceGroup := "test-rg" + diskAccessName := "test-disk-access" + connectionName := "test-pec" + + t.Run("Get", func(t *testing.T) { + conn := createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, "") + + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, connectionName).Return( + armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{ + PrivateEndpointConnection: *conn, + }, nil) + + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(diskAccessName, connectionName) + sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + if sdpItem.GetType() != azureshared.ComputeDiskAccessPrivateEndpointConnection.String() { + t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), sdpItem.GetType()) + } + + if sdpItem.GetUniqueAttribute() != "uniqueAttr" { + t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) + } + + if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(diskAccessName, connectionName) { + t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(diskAccessName, connectionName), sdpItem.UniqueAttributeValue()) + } + + if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { + t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) + } + + if err := sdpItem.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + + t.Run("StaticTests", func(t *testing.T) { + linkedQueries := sdpItem.GetLinkedItemQueries() + if len(linkedQueries) < 1 { + t.Fatalf("Expected at least 1 linked query, got: %d", len(linkedQueries)) + } + + foundDiskAccess := false + for _, lq := range linkedQueries { + if lq.GetQuery().GetType() == azureshared.ComputeDiskAccess.String() { + foundDiskAccess = true + if lq.GetQuery().GetMethod() != sdp.QueryMethod_GET { + t.Errorf("Expected ComputeDiskAccess link method GET, got %v", lq.GetQuery().GetMethod()) + } + if lq.GetQuery().GetQuery() != diskAccessName { + t.Errorf("Expected ComputeDiskAccess query %s, got %s", diskAccessName, lq.GetQuery().GetQuery()) + } + } + } + if !foundDiskAccess { + t.Error("Expected linked query to ComputeDiskAccess") + } + }) + }) + + t.Run("Get_WithPrivateEndpointLink", func(t *testing.T) { + peID := "/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe" + conn := createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, peID) + + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, connectionName).Return( + armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{ + PrivateEndpointConnection: *conn, + }, nil) + + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(diskAccessName, connectionName) + sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + foundPrivateEndpoint := false + for _, lq := range sdpItem.GetLinkedItemQueries() { + if lq.GetQuery().GetType() == azureshared.NetworkPrivateEndpoint.String() { + foundPrivateEndpoint = true + if lq.GetQuery().GetQuery() != "test-pe" { + t.Errorf("Expected NetworkPrivateEndpoint query 'test-pe', got %s", lq.GetQuery().GetQuery()) + } + break + } + } + if !foundPrivateEndpoint { + t.Error("Expected linked query to NetworkPrivateEndpoint when PrivateEndpoint ID is set") + } + }) + + t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} + + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], diskAccessName, true) + if qErr == nil { + t.Error("Expected error when providing insufficient query parts, but got nil") + } + }) + + t.Run("Search", func(t *testing.T) { + conn1 := createAzureComputeDiskAccessPrivateEndpointConnection("pec-1", "") + conn2 := createAzureComputeDiskAccessPrivateEndpointConnection("pec-2", "") + + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + mockPager := &mockComputeDiskAccessPrivateEndpointConnectionsPager{ + pages: []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{ + { + PrivateEndpointConnectionListResult: armcompute.PrivateEndpointConnectionListResult{ + Value: []*armcompute.PrivateEndpointConnection{conn1, conn2}, + }, + }, + }, + } + + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{ + MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient, + pager: mockPager, + } + + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable, ok := adapter.(discovery.SearchableAdapter) + if !ok { + t.Fatalf("Adapter does not support Search operation") + } + + sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], diskAccessName, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 2 { + t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) + } + + for _, item := range sdpItems { + if err := item.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + if item.GetType() != azureshared.ComputeDiskAccessPrivateEndpointConnection.String() { + t.Errorf("Expected type %s, got %s", azureshared.ComputeDiskAccessPrivateEndpointConnection.String(), item.GetType()) + } + } + }) + + t.Run("Search_NilNameSkipped", func(t *testing.T) { + validConn := createAzureComputeDiskAccessPrivateEndpointConnection("valid-pec", "") + + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + mockPager := &mockComputeDiskAccessPrivateEndpointConnectionsPager{ + pages: []armcompute.DiskAccessesClientListPrivateEndpointConnectionsResponse{ + { + PrivateEndpointConnectionListResult: armcompute.PrivateEndpointConnectionListResult{ + Value: []*armcompute.PrivateEndpointConnection{ + {Name: nil}, + validConn, + }, + }, + }, + }, + } + + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{ + MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient, + pager: mockPager, + } + + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable, ok := adapter.(discovery.SearchableAdapter) + if !ok { + t.Fatalf("Adapter does not support Search operation") + } + + sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], diskAccessName, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 1 { + t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) + } + if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(diskAccessName, "valid-pec") { + t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(diskAccessName, "valid-pec"), sdpItems[0].UniqueAttributeValue()) + } + }) + + t.Run("Search_InvalidQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} + + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + + _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) + if qErr == nil { + t.Error("Expected error when providing no query parts, but got nil") + } + }) + + t.Run("ErrorHandling_Get", func(t *testing.T) { + expectedErr := errors.New("private endpoint connection not found") + + mockClient := mocks.NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, diskAccessName, "nonexistent-pec").Return( + armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse{}, expectedErr) + + testClient := &testComputeDiskAccessPrivateEndpointConnectionsClient{MockComputeDiskAccessPrivateEndpointConnectionsClient: mockClient} + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(diskAccessName, "nonexistent-pec") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when getting non-existent private endpoint connection, but got nil") + } + }) + + t.Run("PotentialLinks", func(t *testing.T) { + wrapper := manual.NewComputeDiskAccessPrivateEndpointConnection(nil, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + links := wrapper.PotentialLinks() + if !links[azureshared.ComputeDiskAccess] { + t.Error("Expected ComputeDiskAccess in PotentialLinks") + } + if !links[azureshared.NetworkPrivateEndpoint] { + t.Error("Expected NetworkPrivateEndpoint in PotentialLinks") + } + }) +} + +func createAzureComputeDiskAccessPrivateEndpointConnection(connectionName, privateEndpointID string) *armcompute.PrivateEndpointConnection { + state := armcompute.PrivateEndpointConnectionProvisioningStateSucceeded + status := armcompute.PrivateEndpointServiceConnectionStatusApproved + conn := &armcompute.PrivateEndpointConnection{ + ID: new("/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Compute/diskAccesses/test-disk-access/privateEndpointConnections/" + connectionName), + Name: new(connectionName), + Type: new("Microsoft.Compute/diskAccesses/privateEndpointConnections"), + Properties: &armcompute.PrivateEndpointConnectionProperties{ + ProvisioningState: &state, + PrivateLinkServiceConnectionState: &armcompute.PrivateLinkServiceConnectionState{ + Status: &status, + }, + }, + } + if privateEndpointID != "" { + conn.Properties.PrivateEndpoint = &armcompute.PrivateEndpoint{ + ID: new(privateEndpointID), + } + } + return conn +} diff --git a/sources/azure/manual/elastic-san-volume-group.go b/sources/azure/manual/elastic-san-volume-group.go new file mode 100644 index 00000000..16dde194 --- /dev/null +++ b/sources/azure/manual/elastic-san-volume-group.go @@ -0,0 +1,366 @@ +package manual + +import ( + "context" + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/shared" + "github.com/overmindtech/cli/sources/stdlib" +) + +type elasticSanVolumeGroupWrapper struct { + client clients.ElasticSanVolumeGroupClient + *azureshared.MultiResourceGroupBase +} + +func NewElasticSanVolumeGroup(client clients.ElasticSanVolumeGroupClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { + return &elasticSanVolumeGroupWrapper{ + client: client, + MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( + resourceGroupScopes, + sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, + azureshared.ElasticSanVolumeGroup, + ), + } +} + +func (e elasticSanVolumeGroupWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 2 { + return nil, azureshared.QueryError(errors.New("Get requires 2 query parts: elasticSanName and volumeGroupName"), scope, e.Type()) + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) + } + volumeGroupName := queryParts[1] + if volumeGroupName == "" { + return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, e.Type()) + } + + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + resp, err := e.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + return e.azureVolumeGroupToSDPItem(&resp.VolumeGroup, elasticSanName, volumeGroupName, scope) +} + +func (e elasticSanVolumeGroupWrapper) GetLookups() sources.ItemTypeLookups { + return sources.ItemTypeLookups{ + ElasticSanLookupByName, + ElasticSanVolumeGroupLookupByName, + } +} + +func (e elasticSanVolumeGroupWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 1 { + return nil, azureshared.QueryError(errors.New("Search requires 1 query part: elasticSanName"), scope, e.Type()) + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) + } + + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + pager := e.client.NewListByElasticSanPager(rgScope.ResourceGroup, elasticSanName, nil) + + var items []*sdp.Item + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + for _, vg := range page.Value { + if vg.Name == nil { + continue + } + item, sdpErr := e.azureVolumeGroupToSDPItem(vg, elasticSanName, *vg.Name, scope) + if sdpErr != nil { + return nil, sdpErr + } + items = append(items, item) + } + } + return items, nil +} + +func (e elasticSanVolumeGroupWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { + if len(queryParts) < 1 { + stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: elasticSanName"), scope, e.Type())) + return + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + stream.SendError(azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type())) + return + } + + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, e.Type())) + return + } + pager := e.client.NewListByElasticSanPager(rgScope.ResourceGroup, elasticSanName, nil) + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, e.Type())) + return + } + for _, vg := range page.Value { + if vg.Name == nil { + continue + } + item, sdpErr := e.azureVolumeGroupToSDPItem(vg, elasticSanName, *vg.Name, scope) + if sdpErr != nil { + stream.SendError(sdpErr) + continue + } + cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) + stream.SendItem(item) + } + } +} + +func (e elasticSanVolumeGroupWrapper) SearchLookups() []sources.ItemTypeLookups { + return []sources.ItemTypeLookups{ + {ElasticSanLookupByName}, + } +} + +func (e elasticSanVolumeGroupWrapper) azureVolumeGroupToSDPItem(vg *armelasticsan.VolumeGroup, elasticSanName, volumeGroupName, scope string) (*sdp.Item, *sdp.QueryError) { + if vg.Name == nil { + return nil, azureshared.QueryError(errors.New("volume group name is nil"), scope, e.Type()) + } + attributes, err := shared.ToAttributesWithExclude(vg, "tags") + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(elasticSanName, volumeGroupName)) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + + item := &sdp.Item{ + Type: azureshared.ElasticSanVolumeGroup.String(), + UniqueAttribute: "uniqueAttr", + Attributes: attributes, + Scope: scope, + LinkedItemQueries: []*sdp.LinkedItemQuery{}, + } + + // Link to parent Elastic SAN + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSan.String(), + Method: sdp.QueryMethod_GET, + Query: elasticSanName, + Scope: scope, + }, + }) + + // Link to User Assigned Identities from top-level Identity (map keys are ARM resource IDs) + if vg.Identity != nil && vg.Identity.UserAssignedIdentities != nil { + for identityResourceID := range vg.Identity.UserAssignedIdentities { + if identityResourceID == "" { + continue + } + identityName := azureshared.ExtractResourceName(identityResourceID) + if identityName != "" { + linkedScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { + linkedScope = extractedScope + } + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), + Method: sdp.QueryMethod_GET, + Query: identityName, + Scope: linkedScope, + }, + }) + } + } + } + + // Link to Private Endpoints via PrivateEndpointConnections + if vg.Properties != nil && vg.Properties.PrivateEndpointConnections != nil { + for _, pec := range vg.Properties.PrivateEndpointConnections { + if pec != nil && pec.Properties != nil && pec.Properties.PrivateEndpoint != nil && pec.Properties.PrivateEndpoint.ID != nil { + peName := azureshared.ExtractResourceName(*pec.Properties.PrivateEndpoint.ID) + if peName != "" { + linkedScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(*pec.Properties.PrivateEndpoint.ID); extractedScope != "" { + linkedScope = extractedScope + } + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkPrivateEndpoint.String(), + Method: sdp.QueryMethod_GET, + Query: peName, + Scope: linkedScope, + }, + }) + } + } + } + } + + // Link to child Volume Snapshots (SEARCH by parent Elastic SAN + Volume Group) + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSanVolumeSnapshot.String(), + Method: sdp.QueryMethod_SEARCH, + Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), + Scope: scope, + }, + }) + + // Link to child Volumes (SEARCH by parent Elastic SAN + Volume Group) + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSanVolume.String(), + Method: sdp.QueryMethod_SEARCH, + Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), + Scope: scope, + }, + }) + + // Link to subnets from NetworkACLs virtual network rules + if vg.Properties != nil && vg.Properties.NetworkACLs != nil && vg.Properties.NetworkACLs.VirtualNetworkRules != nil { + for _, rule := range vg.Properties.NetworkACLs.VirtualNetworkRules { + if rule != nil && rule.VirtualNetworkResourceID != nil && *rule.VirtualNetworkResourceID != "" { + subnetID := *rule.VirtualNetworkResourceID + params := azureshared.ExtractPathParamsFromResourceID(subnetID, []string{"virtualNetworks", "subnets"}) + if len(params) >= 2 && params[0] != "" && params[1] != "" { + linkedScope := azureshared.ExtractScopeFromResourceID(subnetID) + if linkedScope == "" { + linkedScope = scope + } + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkSubnet.String(), + Method: sdp.QueryMethod_GET, + Query: shared.CompositeLookupKey(params[0], params[1]), + Scope: linkedScope, + }, + }) + } + } + } + } + + // Link to Key Vault and encryption identity from EncryptionProperties + if vg.Properties != nil && vg.Properties.EncryptionProperties != nil { + enc := vg.Properties.EncryptionProperties + // Link to User Assigned Identity used for encryption (same pattern as storage-account.go) + if enc.EncryptionIdentity != nil && enc.EncryptionIdentity.EncryptionUserAssignedIdentity != nil { + identityResourceID := *enc.EncryptionIdentity.EncryptionUserAssignedIdentity + identityName := azureshared.ExtractResourceName(identityResourceID) + if identityName != "" { + linkedScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(identityResourceID); extractedScope != "" { + linkedScope = extractedScope + } + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ManagedIdentityUserAssignedIdentity.String(), + Method: sdp.QueryMethod_GET, + Query: identityName, + Scope: linkedScope, + }, + }) + } + } + // Link to Key Vault and DNS from KeyVaultURI (DNS-resolvable hostname) + if enc.KeyVaultProperties != nil && enc.KeyVaultProperties.KeyVaultURI != nil && *enc.KeyVaultProperties.KeyVaultURI != "" { + keyVaultURI := *enc.KeyVaultProperties.KeyVaultURI + vaultName := azureshared.ExtractVaultNameFromURI(keyVaultURI) + if vaultName != "" { + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.KeyVaultVault.String(), + Method: sdp.QueryMethod_GET, + Query: vaultName, + Scope: scope, // Key Vault URI does not contain resource group + }, + }) + } + if dnsName := azureshared.ExtractDNSFromURL(keyVaultURI); dnsName != "" { + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: stdlib.NetworkDNS.String(), + Method: sdp.QueryMethod_SEARCH, + Query: dnsName, + Scope: "global", + }, + }) + } + } + } + + // Health from provisioning state + if vg.Properties != nil && vg.Properties.ProvisioningState != nil { + switch *vg.Properties.ProvisioningState { + case armelasticsan.ProvisioningStatesSucceeded: + item.Health = sdp.Health_HEALTH_OK.Enum() + case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, + armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: + item.Health = sdp.Health_HEALTH_PENDING.Enum() + case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, + armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: + item.Health = sdp.Health_HEALTH_ERROR.Enum() + default: + item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() + } + } + + return item, nil +} + +func (e elasticSanVolumeGroupWrapper) PotentialLinks() map[shared.ItemType]bool { + return map[shared.ItemType]bool{ + azureshared.ElasticSan: true, + azureshared.ElasticSanVolume: true, + azureshared.ElasticSanVolumeSnapshot: true, + azureshared.NetworkPrivateEndpoint: true, + azureshared.NetworkSubnet: true, + azureshared.KeyVaultVault: true, + azureshared.ManagedIdentityUserAssignedIdentity: true, + stdlib.NetworkDNS: true, + } +} + +// ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/elastic_san_volume_group +func (e elasticSanVolumeGroupWrapper) TerraformMappings() []*sdp.TerraformMapping { + return []*sdp.TerraformMapping{ + { + TerraformMethod: sdp.QueryMethod_GET, + TerraformQueryMap: "azurerm_elastic_san_volume_group.id", + }, + } +} + +func (e elasticSanVolumeGroupWrapper) IAMPermissions() []string { + return []string{ + "Microsoft.ElasticSan/elasticSans/volumegroups/read", + } +} + +func (e elasticSanVolumeGroupWrapper) PredefinedRole() string { + return "Reader" +} diff --git a/sources/azure/manual/elastic-san-volume-group_test.go b/sources/azure/manual/elastic-san-volume-group_test.go new file mode 100644 index 00000000..baa27c54 --- /dev/null +++ b/sources/azure/manual/elastic-san-volume-group_test.go @@ -0,0 +1,222 @@ +package manual_test + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "go.uber.org/mock/gomock" + + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/manual" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/azure/shared/mocks" + "github.com/overmindtech/cli/sources/shared" +) + +// mockElasticSanVolumeGroupPager is a simple mock implementation of ElasticSanVolumeGroupPager +type mockElasticSanVolumeGroupPager struct { + pages []armelasticsan.VolumeGroupsClientListByElasticSanResponse + index int +} + +func (m *mockElasticSanVolumeGroupPager) More() bool { + return m.index < len(m.pages) +} + +func (m *mockElasticSanVolumeGroupPager) NextPage(ctx context.Context) (armelasticsan.VolumeGroupsClientListByElasticSanResponse, error) { + if m.index >= len(m.pages) { + return armelasticsan.VolumeGroupsClientListByElasticSanResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +func createAzureElasticSanVolumeGroup(name string) *armelasticsan.VolumeGroup { + provisioningState := armelasticsan.ProvisioningStatesSucceeded + return &armelasticsan.VolumeGroup{ + ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/" + name), + Name: new(name), + Type: new("Microsoft.ElasticSan/elasticSans/volumegroups"), + Properties: &armelasticsan.VolumeGroupProperties{ + ProvisioningState: &provisioningState, + }, + } +} + +func TestElasticSanVolumeGroup(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + subscriptionID := "test-subscription" + resourceGroup := "test-rg" + elasticSanName := "test-elastic-san" + volumeGroupName := "test-volume-group" + + t.Run("Get", func(t *testing.T) { + vg := createAzureElasticSanVolumeGroup(volumeGroupName) + + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return( + armelasticsan.VolumeGroupsClientGetResponse{ + VolumeGroup: *vg, + }, nil) + + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) + sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + if sdpItem.GetType() != azureshared.ElasticSanVolumeGroup.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeGroup.String(), sdpItem.GetType()) + } + + if sdpItem.GetUniqueAttribute() != "uniqueAttr" { + t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) + } + + expectedUnique := shared.CompositeLookupKey(elasticSanName, volumeGroupName) + if sdpItem.UniqueAttributeValue() != expectedUnique { + t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) + } + + if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { + t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) + } + + if err := sdpItem.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + + t.Run("StaticTests", func(t *testing.T) { + scope := subscriptionID + "." + resourceGroup + queryTests := shared.QueryTests{ + {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, + {ExpectedType: azureshared.ElasticSanVolumeSnapshot.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, + {ExpectedType: azureshared.ElasticSanVolume.String(), ExpectedMethod: sdp.QueryMethod_SEARCH, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, + } + shared.RunStaticTests(t, adapter, sdpItem, queryTests) + }) + }) + + t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], elasticSanName, true) + if qErr == nil { + t.Error("Expected error when providing insufficient query parts, but got nil") + } + }) + + t.Run("GetWithEmptyName", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, "") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when volume group name is empty, but got nil") + } + }) + + t.Run("ErrorHandling", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, "nonexistent", nil).Return( + armelasticsan.VolumeGroupsClientGetResponse{}, errors.New("volume group not found")) + + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, "nonexistent") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when resource not found, but got nil") + } + }) + + t.Run("Search", func(t *testing.T) { + vg1 := createAzureElasticSanVolumeGroup("vg-1") + vg2 := createAzureElasticSanVolumeGroup("vg-2") + + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + mockPager := &mockElasticSanVolumeGroupPager{ + pages: []armelasticsan.VolumeGroupsClientListByElasticSanResponse{ + { + VolumeGroupList: armelasticsan.VolumeGroupList{ + Value: []*armelasticsan.VolumeGroup{vg1, vg2}, + }, + }, + }, + } + mockClient.EXPECT().NewListByElasticSanPager(resourceGroup, elasticSanName, nil).Return(mockPager) + + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable, ok := adapter.(discovery.SearchableAdapter) + if !ok { + t.Fatalf("Adapter does not support Search operation") + } + + query := elasticSanName + items, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if len(items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(items)) + } + for _, item := range items { + if err := item.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + } + }) + + t.Run("SearchStream", func(t *testing.T) { + vg := createAzureElasticSanVolumeGroup("stream-vg") + mockClient := mocks.NewMockElasticSanVolumeGroupClient(ctrl) + mockPager := &mockElasticSanVolumeGroupPager{ + pages: []armelasticsan.VolumeGroupsClientListByElasticSanResponse{ + { + VolumeGroupList: armelasticsan.VolumeGroupList{ + Value: []*armelasticsan.VolumeGroup{vg}, + }, + }, + }, + } + mockClient.EXPECT().NewListByElasticSanPager(resourceGroup, elasticSanName, nil).Return(mockPager) + + wrapper := manual.NewElasticSanVolumeGroup(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + streamable, ok := adapter.(discovery.SearchStreamableAdapter) + if !ok { + t.Fatalf("Adapter does not support SearchStream operation") + } + + query := elasticSanName + stream := discovery.NewRecordingQueryResultStream() + streamable.SearchStream(ctx, wrapper.Scopes()[0], query, true, stream) + items := stream.GetItems() + if len(items) != 1 { + t.Fatalf("Expected 1 item from stream, got %d", len(items)) + } + if items[0].GetType() != azureshared.ElasticSanVolumeGroup.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeGroup.String(), items[0].GetType()) + } + }) +} diff --git a/sources/azure/manual/elastic-san-volume-snapshot.go b/sources/azure/manual/elastic-san-volume-snapshot.go new file mode 100644 index 00000000..0c0bdbad --- /dev/null +++ b/sources/azure/manual/elastic-san-volume-snapshot.go @@ -0,0 +1,271 @@ +package manual + +import ( + "context" + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/shared" +) + +var ( + ElasticSanLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSan) + ElasticSanVolumeGroupLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSanVolumeGroup) + ElasticSanVolumeSnapshotLookupByName = shared.NewItemTypeLookup("name", azureshared.ElasticSanVolumeSnapshot) +) + +type elasticSanVolumeSnapshotWrapper struct { + client clients.ElasticSanVolumeSnapshotClient + *azureshared.MultiResourceGroupBase +} + +func NewElasticSanVolumeSnapshot(client clients.ElasticSanVolumeSnapshotClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { + return &elasticSanVolumeSnapshotWrapper{ + client: client, + MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( + resourceGroupScopes, + sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, + azureshared.ElasticSanVolumeSnapshot, + ), + } +} + +func (s elasticSanVolumeSnapshotWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 3 { + return nil, azureshared.QueryError(errors.New("Get requires 3 query parts: elasticSanName, volumeGroupName and snapshotName"), scope, s.Type()) + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type()) + } + volumeGroupName := queryParts[1] + if volumeGroupName == "" { + return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type()) + } + snapshotName := queryParts[2] + if snapshotName == "" { + return nil, azureshared.QueryError(errors.New("snapshotName cannot be empty"), scope, s.Type()) + } + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + resp, err := s.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, snapshotName, nil) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + return s.azureSnapshotToSDPItem(&resp.Snapshot, elasticSanName, volumeGroupName, snapshotName, scope) +} + +func (s elasticSanVolumeSnapshotWrapper) GetLookups() sources.ItemTypeLookups { + return sources.ItemTypeLookups{ + ElasticSanLookupByName, + ElasticSanVolumeGroupLookupByName, + ElasticSanVolumeSnapshotLookupByName, + } +} + +func (s elasticSanVolumeSnapshotWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 2 { + return nil, azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, s.Type()) + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type()) + } + volumeGroupName := queryParts[1] + if volumeGroupName == "" { + return nil, azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type()) + } + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + pager := s.client.ListByVolumeGroup(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) + + var items []*sdp.Item + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + for _, snapshot := range page.Value { + if snapshot.Name == nil { + continue + } + item, sdpErr := s.azureSnapshotToSDPItem(snapshot, elasticSanName, volumeGroupName, *snapshot.Name, scope) + if sdpErr != nil { + return nil, sdpErr + } + items = append(items, item) + } + } + return items, nil +} + +func (s elasticSanVolumeSnapshotWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { + if len(queryParts) < 2 { + stream.SendError(azureshared.QueryError(errors.New("Search requires 2 query parts: elasticSanName and volumeGroupName"), scope, s.Type())) + return + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + stream.SendError(azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, s.Type())) + return + } + volumeGroupName := queryParts[1] + if volumeGroupName == "" { + stream.SendError(azureshared.QueryError(errors.New("volumeGroupName cannot be empty"), scope, s.Type())) + return + } + + rgScope, err := s.ResourceGroupScopeFromScope(scope) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, s.Type())) + return + } + pager := s.client.ListByVolumeGroup(ctx, rgScope.ResourceGroup, elasticSanName, volumeGroupName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, s.Type())) + return + } + for _, snapshot := range page.Value { + if snapshot.Name == nil { + continue + } + item, sdpErr := s.azureSnapshotToSDPItem(snapshot, elasticSanName, volumeGroupName, *snapshot.Name, scope) + if sdpErr != nil { + stream.SendError(sdpErr) + continue + } + cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) + stream.SendItem(item) + } + } +} + +func (s elasticSanVolumeSnapshotWrapper) SearchLookups() []sources.ItemTypeLookups { + return []sources.ItemTypeLookups{ + { + ElasticSanLookupByName, + ElasticSanVolumeGroupLookupByName, + }, + } +} + +func (s elasticSanVolumeSnapshotWrapper) azureSnapshotToSDPItem(snapshot *armelasticsan.Snapshot, elasticSanName, volumeGroupName, snapshotName, scope string) (*sdp.Item, *sdp.QueryError) { + if snapshot.Name == nil { + return nil, azureshared.QueryError(errors.New("snapshot name is nil"), scope, s.Type()) + } + attributes, err := shared.ToAttributesWithExclude(snapshot, "tags") + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName)) + if err != nil { + return nil, azureshared.QueryError(err, scope, s.Type()) + } + + sdpItem := &sdp.Item{ + Type: azureshared.ElasticSanVolumeSnapshot.String(), + UniqueAttribute: "uniqueAttr", + Attributes: attributes, + Scope: scope, + LinkedItemQueries: []*sdp.LinkedItemQuery{}, + } + + // Link to parent Elastic SAN + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSan.String(), + Method: sdp.QueryMethod_GET, + Query: elasticSanName, + Scope: scope, + }, + }) + + // Link to parent Volume Group + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSanVolumeGroup.String(), + Method: sdp.QueryMethod_GET, + Query: shared.CompositeLookupKey(elasticSanName, volumeGroupName), + Scope: scope, + }, + }) + + // Link to source volume from CreationData.SourceID + if snapshot.Properties != nil && snapshot.Properties.CreationData != nil && snapshot.Properties.CreationData.SourceID != nil && *snapshot.Properties.CreationData.SourceID != "" { + sourceID := *snapshot.Properties.CreationData.SourceID + parts := azureshared.ExtractPathParamsFromResourceID(sourceID, []string{"elasticSans", "volumegroups", "volumes"}) + if len(parts) >= 3 { + extractedScope := azureshared.ExtractScopeFromResourceID(sourceID) + if extractedScope == "" { + extractedScope = scope + } + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSanVolume.String(), + Method: sdp.QueryMethod_GET, + Query: shared.CompositeLookupKey(parts[0], parts[1], parts[2]), + Scope: extractedScope, + }, + }) + } + } + + if snapshot.Properties != nil && snapshot.Properties.ProvisioningState != nil { + switch *snapshot.Properties.ProvisioningState { + case armelasticsan.ProvisioningStatesSucceeded: + sdpItem.Health = sdp.Health_HEALTH_OK.Enum() + case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, + armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: + sdpItem.Health = sdp.Health_HEALTH_PENDING.Enum() + case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, + armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: + sdpItem.Health = sdp.Health_HEALTH_ERROR.Enum() + default: + sdpItem.Health = sdp.Health_HEALTH_UNKNOWN.Enum() + } + } + + return sdpItem, nil +} + +func (s elasticSanVolumeSnapshotWrapper) PotentialLinks() map[shared.ItemType]bool { + return map[shared.ItemType]bool{ + azureshared.ElasticSan: true, + azureshared.ElasticSanVolumeGroup: true, + azureshared.ElasticSanVolume: true, + } +} + +func (s elasticSanVolumeSnapshotWrapper) TerraformMappings() []*sdp.TerraformMapping { + return []*sdp.TerraformMapping{ + { + TerraformMethod: sdp.QueryMethod_GET, + TerraformQueryMap: "azurerm_elastic_san_volume_snapshot.id", + }, + } +} + +func (s elasticSanVolumeSnapshotWrapper) IAMPermissions() []string { + return []string{ + "Microsoft.ElasticSan/elasticSans/volumegroups/snapshots/read", + } +} + +func (s elasticSanVolumeSnapshotWrapper) PredefinedRole() string { + return "Reader" +} diff --git a/sources/azure/manual/elastic-san-volume-snapshot_test.go b/sources/azure/manual/elastic-san-volume-snapshot_test.go new file mode 100644 index 00000000..83595296 --- /dev/null +++ b/sources/azure/manual/elastic-san-volume-snapshot_test.go @@ -0,0 +1,223 @@ +package manual_test + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "go.uber.org/mock/gomock" + + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/manual" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/azure/shared/mocks" + "github.com/overmindtech/cli/sources/shared" +) + +// mockElasticSanVolumeSnapshotPager is a simple mock implementation of ElasticSanVolumeSnapshotPager +type mockElasticSanVolumeSnapshotPager struct { + pages []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse + index int +} + +func (m *mockElasticSanVolumeSnapshotPager) More() bool { + return m.index < len(m.pages) +} + +func (m *mockElasticSanVolumeSnapshotPager) NextPage(ctx context.Context) (armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse, error) { + if m.index >= len(m.pages) { + return armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +func createAzureElasticSanSnapshot(name string) *armelasticsan.Snapshot { + provisioningState := armelasticsan.ProvisioningStatesSucceeded + return &armelasticsan.Snapshot{ + ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/es/volumegroups/vg/snapshots/" + name), + Name: new(name), + Type: new("Microsoft.ElasticSan/elasticSans/volumegroups/snapshots"), + Properties: &armelasticsan.SnapshotProperties{ + ProvisioningState: &provisioningState, + CreationData: &armelasticsan.SnapshotCreationData{}, + }, + } +} + +func TestElasticSanVolumeSnapshot(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + subscriptionID := "test-subscription" + resourceGroup := "test-rg" + elasticSanName := "test-elastic-san" + volumeGroupName := "test-volume-group" + snapshotName := "test-snapshot" + + t.Run("Get", func(t *testing.T) { + snapshot := createAzureElasticSanSnapshot(snapshotName) + + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, snapshotName, nil).Return( + armelasticsan.VolumeSnapshotsClientGetResponse{ + Snapshot: *snapshot, + }, nil) + + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName) + sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + if sdpItem.GetType() != azureshared.ElasticSanVolumeSnapshot.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeSnapshot.String(), sdpItem.GetType()) + } + + if sdpItem.GetUniqueAttribute() != "uniqueAttr" { + t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) + } + + expectedUnique := shared.CompositeLookupKey(elasticSanName, volumeGroupName, snapshotName) + if sdpItem.UniqueAttributeValue() != expectedUnique { + t.Errorf("Expected unique attribute value %s, got %s", expectedUnique, sdpItem.UniqueAttributeValue()) + } + + if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { + t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) + } + + if err := sdpItem.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + + t.Run("StaticTests", func(t *testing.T) { + scope := subscriptionID + "." + resourceGroup + queryTests := shared.QueryTests{ + {ExpectedType: azureshared.ElasticSan.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: elasticSanName, ExpectedScope: scope}, + {ExpectedType: azureshared.ElasticSanVolumeGroup.String(), ExpectedMethod: sdp.QueryMethod_GET, ExpectedQuery: shared.CompositeLookupKey(elasticSanName, volumeGroupName), ExpectedScope: scope}, + } + shared.RunStaticTests(t, adapter, sdpItem, queryTests) + }) + }) + + t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], elasticSanName, true) + if qErr == nil { + t.Error("Expected error when providing insufficient query parts, but got nil") + } + }) + + t.Run("GetWithEmptyName", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when snapshot name is empty, but got nil") + } + }) + + t.Run("ErrorHandling", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, volumeGroupName, "nonexistent", nil).Return( + armelasticsan.VolumeSnapshotsClientGetResponse{}, errors.New("snapshot not found")) + + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName, "nonexistent") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when resource not found, but got nil") + } + }) + + t.Run("Search", func(t *testing.T) { + snapshot1 := createAzureElasticSanSnapshot("snap-1") + snapshot2 := createAzureElasticSanSnapshot("snap-2") + + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + mockPager := &mockElasticSanVolumeSnapshotPager{ + pages: []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{ + { + SnapshotList: armelasticsan.SnapshotList{ + Value: []*armelasticsan.Snapshot{snapshot1, snapshot2}, + }, + }, + }, + } + mockClient.EXPECT().ListByVolumeGroup(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) + + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable, ok := adapter.(discovery.SearchableAdapter) + if !ok { + t.Fatalf("Adapter does not support Search operation") + } + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) + items, err := searchable.Search(ctx, wrapper.Scopes()[0], query, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if len(items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(items)) + } + for _, item := range items { + if err := item.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + } + }) + + t.Run("SearchStream", func(t *testing.T) { + snapshot := createAzureElasticSanSnapshot("stream-snap") + mockClient := mocks.NewMockElasticSanVolumeSnapshotClient(ctrl) + mockPager := &mockElasticSanVolumeSnapshotPager{ + pages: []armelasticsan.VolumeSnapshotsClientListByVolumeGroupResponse{ + { + SnapshotList: armelasticsan.SnapshotList{ + Value: []*armelasticsan.Snapshot{snapshot}, + }, + }, + }, + } + mockClient.EXPECT().ListByVolumeGroup(ctx, resourceGroup, elasticSanName, volumeGroupName, nil).Return(mockPager) + + wrapper := manual.NewElasticSanVolumeSnapshot(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + streamable, ok := adapter.(discovery.SearchStreamableAdapter) + if !ok { + t.Fatalf("Adapter does not support SearchStream operation") + } + + query := shared.CompositeLookupKey(elasticSanName, volumeGroupName) + stream := discovery.NewRecordingQueryResultStream() + streamable.SearchStream(ctx, wrapper.Scopes()[0], query, true, stream) + items := stream.GetItems() + if len(items) != 1 { + t.Fatalf("Expected 1 item from stream, got %d", len(items)) + } + if items[0].GetType() != azureshared.ElasticSanVolumeSnapshot.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSanVolumeSnapshot.String(), items[0].GetType()) + } + }) +} diff --git a/sources/azure/manual/elastic-san.go b/sources/azure/manual/elastic-san.go new file mode 100644 index 00000000..fe498f94 --- /dev/null +++ b/sources/azure/manual/elastic-san.go @@ -0,0 +1,217 @@ +package manual + +import ( + "context" + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/shared" +) + +type elasticSanWrapper struct { + client clients.ElasticSanClient + + *azureshared.MultiResourceGroupBase +} + +func NewElasticSan(client clients.ElasticSanClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.ListableWrapper { + return &elasticSanWrapper{ + client: client, + MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( + resourceGroupScopes, + sdp.AdapterCategory_ADAPTER_CATEGORY_STORAGE, + azureshared.ElasticSan, + ), + } +} + +// ref: https://learn.microsoft.com/en-us/rest/api/elasticsan/elastic-sans/list-by-resource-group +func (e elasticSanWrapper) List(ctx context.Context, scope string) ([]*sdp.Item, *sdp.QueryError) { + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + pager := e.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) + + var items []*sdp.Item + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + for _, elasticSan := range page.Value { + if elasticSan.Name == nil { + continue + } + item, sdpErr := e.azureElasticSanToSDPItem(elasticSan, scope) + if sdpErr != nil { + return nil, sdpErr + } + items = append(items, item) + } + } + + return items, nil +} + +func (e elasticSanWrapper) ListStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string) { + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, e.Type())) + return + } + pager := e.client.NewListByResourceGroupPager(rgScope.ResourceGroup, nil) + + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, e.Type())) + return + } + + for _, elasticSan := range page.Value { + if elasticSan.Name == nil { + continue + } + item, sdpErr := e.azureElasticSanToSDPItem(elasticSan, scope) + if sdpErr != nil { + stream.SendError(sdpErr) + continue + } + cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) + stream.SendItem(item) + } + } +} + +// ref: https://learn.microsoft.com/en-us/rest/api/elasticsan/elastic-sans/get +func (e elasticSanWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 1 { + return nil, azureshared.QueryError(errors.New("queryParts must be at least 1 and be the Elastic SAN name"), scope, e.Type()) + } + elasticSanName := queryParts[0] + if elasticSanName == "" { + return nil, azureshared.QueryError(errors.New("elasticSanName cannot be empty"), scope, e.Type()) + } + + rgScope, err := e.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + resp, err := e.client.Get(ctx, rgScope.ResourceGroup, elasticSanName, nil) + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + return e.azureElasticSanToSDPItem(&resp.ElasticSan, scope) +} + +func (e elasticSanWrapper) azureElasticSanToSDPItem(elasticSan *armelasticsan.ElasticSan, scope string) (*sdp.Item, *sdp.QueryError) { + if elasticSan.Name == nil { + return nil, azureshared.QueryError(errors.New("elasticSan name is nil"), scope, e.Type()) + } + attributes, err := shared.ToAttributesWithExclude(elasticSan, "tags") + if err != nil { + return nil, azureshared.QueryError(err, scope, e.Type()) + } + + item := &sdp.Item{ + Type: azureshared.ElasticSan.String(), + UniqueAttribute: "name", + Attributes: attributes, + Scope: scope, + Tags: azureshared.ConvertAzureTags(elasticSan.Tags), + LinkedItemQueries: []*sdp.LinkedItemQuery{}, + } + + // Link to Private Endpoints via PrivateEndpointConnections + if elasticSan.Properties != nil && elasticSan.Properties.PrivateEndpointConnections != nil { + for _, pec := range elasticSan.Properties.PrivateEndpointConnections { + if pec != nil && pec.Properties != nil && pec.Properties.PrivateEndpoint != nil && pec.Properties.PrivateEndpoint.ID != nil { + peName := azureshared.ExtractResourceName(*pec.Properties.PrivateEndpoint.ID) + if peName != "" { + linkedScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(*pec.Properties.PrivateEndpoint.ID); extractedScope != "" { + linkedScope = extractedScope + } + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkPrivateEndpoint.String(), + Method: sdp.QueryMethod_GET, + Query: peName, + Scope: linkedScope, + }, + }) + } + } + } + } + + // Link to child Volume Groups (SEARCH by parent Elastic SAN name) + if elasticSan.Name != nil && *elasticSan.Name != "" { + item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.ElasticSanVolumeGroup.String(), + Method: sdp.QueryMethod_SEARCH, + Query: *elasticSan.Name, + Scope: scope, + }, + }) + } + + // Health from provisioning state + if elasticSan.Properties != nil && elasticSan.Properties.ProvisioningState != nil { + switch *elasticSan.Properties.ProvisioningState { + case armelasticsan.ProvisioningStatesSucceeded: + item.Health = sdp.Health_HEALTH_OK.Enum() + case armelasticsan.ProvisioningStatesCreating, armelasticsan.ProvisioningStatesUpdating, armelasticsan.ProvisioningStatesDeleting, + armelasticsan.ProvisioningStatesPending, armelasticsan.ProvisioningStatesRestoring: + item.Health = sdp.Health_HEALTH_PENDING.Enum() + case armelasticsan.ProvisioningStatesFailed, armelasticsan.ProvisioningStatesCanceled, + armelasticsan.ProvisioningStatesDeleted, armelasticsan.ProvisioningStatesInvalid: + item.Health = sdp.Health_HEALTH_ERROR.Enum() + default: + item.Health = sdp.Health_HEALTH_UNKNOWN.Enum() + } + } + + return item, nil +} + +func (e elasticSanWrapper) GetLookups() sources.ItemTypeLookups { + return sources.ItemTypeLookups{ + ElasticSanLookupByName, // defined in elastic-san-volume-snapshot.go + } +} + +func (e elasticSanWrapper) PotentialLinks() map[shared.ItemType]bool { + return shared.NewItemTypesSet( + azureshared.ElasticSanVolumeGroup, + azureshared.NetworkPrivateEndpoint, + ) +} + +// ref: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/elastic_san +func (e elasticSanWrapper) TerraformMappings() []*sdp.TerraformMapping { + return []*sdp.TerraformMapping{ + { + TerraformMethod: sdp.QueryMethod_GET, + TerraformQueryMap: "azurerm_elastic_san.name", + }, + } +} + +func (e elasticSanWrapper) IAMPermissions() []string { + return []string{ + "Microsoft.ElasticSan/elasticSans/read", + } +} + +func (e elasticSanWrapper) PredefinedRole() string { + return "Reader" +} diff --git a/sources/azure/manual/elastic-san_test.go b/sources/azure/manual/elastic-san_test.go new file mode 100644 index 00000000..670214ec --- /dev/null +++ b/sources/azure/manual/elastic-san_test.go @@ -0,0 +1,322 @@ +package manual_test + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + "go.uber.org/mock/gomock" + + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/manual" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/azure/shared/mocks" + "github.com/overmindtech/cli/sources/shared" +) + +func createAzureElasticSan(name string) *armelasticsan.ElasticSan { + baseSize := int64(1) + extendedSize := int64(2) + provisioningState := armelasticsan.ProvisioningStatesSucceeded + return &armelasticsan.ElasticSan{ + ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.ElasticSan/elasticSans/" + name), + Name: new(name), + Location: new("eastus"), + Type: new("Microsoft.ElasticSan/elasticSans"), + Tags: map[string]*string{"env": new("test")}, + Properties: &armelasticsan.Properties{ + BaseSizeTiB: &baseSize, + ExtendedCapacitySizeTiB: &extendedSize, + ProvisioningState: &provisioningState, + VolumeGroupCount: new(int64(0)), + }, + } +} + +func createAzureElasticSanWithPrivateEndpoint(name, subscriptionID, resourceGroup string) *armelasticsan.ElasticSan { + es := createAzureElasticSan(name) + es.Properties.PrivateEndpointConnections = []*armelasticsan.PrivateEndpointConnection{ + { + ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.ElasticSan/elasticSans/" + name + "/privateEndpointConnections/pec-1"), + Name: new("pec-1"), + Properties: &armelasticsan.PrivateEndpointConnectionProperties{ + PrivateEndpoint: &armelasticsan.PrivateEndpoint{ + ID: new("/subscriptions/" + subscriptionID + "/resourceGroups/" + resourceGroup + "/providers/Microsoft.Network/privateEndpoints/test-pe"), + }, + }, + }, + } + return es +} + +type mockElasticSanPager struct { + pages []armelasticsan.ElasticSansClientListByResourceGroupResponse + index int +} + +func (m *mockElasticSanPager) More() bool { + return m.index < len(m.pages) +} + +func (m *mockElasticSanPager) NextPage(ctx context.Context) (armelasticsan.ElasticSansClientListByResourceGroupResponse, error) { + if m.index >= len(m.pages) { + return armelasticsan.ElasticSansClientListByResourceGroupResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +func TestElasticSan(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + subscriptionID := "test-subscription" + resourceGroup := "test-rg" + scope := subscriptionID + "." + resourceGroup + + t.Run("Get", func(t *testing.T) { + elasticSanName := "test-elastic-san" + es := createAzureElasticSan(elasticSanName) + + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, nil).Return( + armelasticsan.ElasticSansClientGetResponse{ + ElasticSan: *es, + }, nil) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + sdpItem, qErr := adapter.Get(ctx, scope, elasticSanName, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + if sdpItem.GetType() != azureshared.ElasticSan.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSan.String(), sdpItem.GetType()) + } + + if sdpItem.GetUniqueAttribute() != "name" { + t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute()) + } + + if sdpItem.UniqueAttributeValue() != elasticSanName { + t.Errorf("Expected unique attribute value %s, got %s", elasticSanName, sdpItem.UniqueAttributeValue()) + } + + if sdpItem.GetTags()["env"] != "test" { + t.Errorf("Expected tag 'env=test', got: %v", sdpItem.GetTags()["env"]) + } + + t.Run("StaticTests", func(t *testing.T) { + // ElasticSanVolumeGroup SEARCH link (parent→child); no private endpoints in createAzureElasticSan + shared.RunStaticTests(t, adapter, sdpItem, shared.QueryTests{ + { + ExpectedType: azureshared.ElasticSanVolumeGroup.String(), + ExpectedMethod: sdp.QueryMethod_SEARCH, + ExpectedQuery: elasticSanName, + ExpectedScope: scope, + }, + }) + }) + }) + + t.Run("GetWithPrivateEndpointLink", func(t *testing.T) { + elasticSanName := "test-elastic-san-pe" + es := createAzureElasticSanWithPrivateEndpoint(elasticSanName, subscriptionID, resourceGroup) + + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, elasticSanName, nil).Return( + armelasticsan.ElasticSansClientGetResponse{ + ElasticSan: *es, + }, nil) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + sdpItem, qErr := adapter.Get(ctx, scope, elasticSanName, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + queryTests := shared.QueryTests{ + { + ExpectedType: azureshared.ElasticSanVolumeGroup.String(), + ExpectedMethod: sdp.QueryMethod_SEARCH, + ExpectedQuery: elasticSanName, + ExpectedScope: scope, + }, + { + ExpectedType: azureshared.NetworkPrivateEndpoint.String(), + ExpectedMethod: sdp.QueryMethod_GET, + ExpectedQuery: "test-pe", + ExpectedScope: scope, + }, + } + shared.RunStaticTests(t, adapter, sdpItem, queryTests) + }) + + t.Run("List", func(t *testing.T) { + es1 := createAzureElasticSan("es-1") + es2 := createAzureElasticSan("es-2") + + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockPager := &mockElasticSanPager{ + pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ + {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es1, es2}}}, + }, + } + mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + listable, ok := adapter.(discovery.ListableAdapter) + if !ok { + t.Fatalf("Adapter does not support List operation") + } + + sdpItems, err := listable.List(ctx, scope, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 2 { + t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) + } + + for _, item := range sdpItems { + if item.Validate() != nil { + t.Fatalf("Expected no validation error, got: %v", item.Validate()) + } + } + }) + + t.Run("ListStream", func(t *testing.T) { + es := createAzureElasticSan("es-stream") + + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockPager := &mockElasticSanPager{ + pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ + {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es}}}, + }, + } + mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + wg := &sync.WaitGroup{} + wg.Add(1) + + var items []*sdp.Item + mockItemHandler := func(item *sdp.Item) { + items = append(items, item) + wg.Done() + } + + var errs []error + mockErrorHandler := func(err error) { + errs = append(errs, err) + } + + stream := discovery.NewQueryResultStream(mockItemHandler, mockErrorHandler) + + listStreamable, ok := adapter.(discovery.ListStreamableAdapter) + if !ok { + t.Fatalf("Adapter does not support ListStream operation") + } + + listStreamable.ListStream(ctx, scope, true, stream) + wg.Wait() + + if len(errs) != 0 { + t.Fatalf("Expected no errors, got: %v", errs) + } + + if len(items) != 1 { + t.Fatalf("Expected 1 item, got: %d", len(items)) + } + + if items[0].GetType() != azureshared.ElasticSan.String() { + t.Errorf("Expected type %s, got %s", azureshared.ElasticSan.String(), items[0].GetType()) + } + }) + + t.Run("ListWithNilName", func(t *testing.T) { + es1 := createAzureElasticSan("es-1") + esNilName := &armelasticsan.ElasticSan{ + Name: nil, + Location: new("eastus"), + Tags: map[string]*string{"env": new("test")}, + } + + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockPager := &mockElasticSanPager{ + pages: []armelasticsan.ElasticSansClientListByResourceGroupResponse{ + {List: armelasticsan.List{Value: []*armelasticsan.ElasticSan{es1, esNilName}}}, + }, + } + mockClient.EXPECT().NewListByResourceGroupPager(resourceGroup, nil).Return(mockPager) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + listable, ok := adapter.(discovery.ListableAdapter) + if !ok { + t.Fatalf("Adapter does not support List operation") + } + + sdpItems, err := listable.List(ctx, scope, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 1 { + t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) + } + }) + + t.Run("ErrorHandling", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, "nonexistent", nil).Return( + armelasticsan.ElasticSansClientGetResponse{}, errors.New("elastic san not found")) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, scope, "nonexistent", true) + if qErr == nil { + t.Error("Expected error when getting non-existent Elastic SAN, but got nil") + } + }) + + t.Run("GetWithEmptyName", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanClient(ctrl) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, scope, "", true) + if qErr == nil { + t.Error("Expected error when getting Elastic SAN with empty name, but got nil") + } + }) + + t.Run("GetWithInsufficientQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockElasticSanClient(ctrl) + + wrapper := manual.NewElasticSan(mockClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + _, qErr := wrapper.Get(ctx, scope) + if qErr == nil { + t.Error("Expected error when getting Elastic SAN with insufficient query parts, but got nil") + } + }) +} diff --git a/sources/azure/manual/network-default-security-rule.go b/sources/azure/manual/network-default-security-rule.go new file mode 100644 index 00000000..84ed19f2 --- /dev/null +++ b/sources/azure/manual/network-default-security-rule.go @@ -0,0 +1,262 @@ +package manual + +import ( + "context" + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" + "github.com/overmindtech/cli/go/discovery" + "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/shared" + "github.com/overmindtech/cli/sources/stdlib" +) + +var NetworkDefaultSecurityRuleLookupByUniqueAttr = shared.NewItemTypeLookup("uniqueAttr", azureshared.NetworkDefaultSecurityRule) + +type networkDefaultSecurityRuleWrapper struct { + client clients.DefaultSecurityRulesClient + *azureshared.MultiResourceGroupBase +} + +// NewNetworkDefaultSecurityRule creates a new networkDefaultSecurityRuleWrapper instance (SearchableWrapper: child of network security group). +func NewNetworkDefaultSecurityRule(client clients.DefaultSecurityRulesClient, resourceGroupScopes []azureshared.ResourceGroupScope) sources.SearchableWrapper { + return &networkDefaultSecurityRuleWrapper{ + client: client, + MultiResourceGroupBase: azureshared.NewMultiResourceGroupBase( + resourceGroupScopes, + sdp.AdapterCategory_ADAPTER_CATEGORY_NETWORK, + azureshared.NetworkDefaultSecurityRule, + ), + } +} + +func (n networkDefaultSecurityRuleWrapper) Get(ctx context.Context, scope string, queryParts ...string) (*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 2 { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: "Get requires 2 query parts: networkSecurityGroupName and defaultSecurityRuleName", + Scope: scope, + ItemType: n.Type(), + } + } + nsgName := queryParts[0] + ruleName := queryParts[1] + if ruleName == "" { + return nil, azureshared.QueryError(errors.New("default security rule name cannot be empty"), scope, n.Type()) + } + + rgScope, err := n.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + resp, err := n.client.Get(ctx, rgScope.ResourceGroup, nsgName, ruleName, nil) + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + + return n.azureDefaultSecurityRuleToSDPItem(&resp.SecurityRule, nsgName, ruleName, scope) +} + +func (n networkDefaultSecurityRuleWrapper) GetLookups() sources.ItemTypeLookups { + return sources.ItemTypeLookups{ + NetworkNetworkSecurityGroupLookupByName, + NetworkDefaultSecurityRuleLookupByUniqueAttr, + } +} + +func (n networkDefaultSecurityRuleWrapper) Search(ctx context.Context, scope string, queryParts ...string) ([]*sdp.Item, *sdp.QueryError) { + if len(queryParts) < 1 { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: "Search requires 1 query part: networkSecurityGroupName", + Scope: scope, + ItemType: n.Type(), + } + } + nsgName := queryParts[0] + + rgScope, err := n.ResourceGroupScopeFromScope(scope) + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) + + var items []*sdp.Item + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + for _, rule := range page.Value { + if rule == nil || rule.Name == nil { + continue + } + item, sdpErr := n.azureDefaultSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) + if sdpErr != nil { + return nil, sdpErr + } + items = append(items, item) + } + } + return items, nil +} + +func (n networkDefaultSecurityRuleWrapper) SearchStream(ctx context.Context, stream discovery.QueryResultStream, cache sdpcache.Cache, cacheKey sdpcache.CacheKey, scope string, queryParts ...string) { + if len(queryParts) < 1 { + stream.SendError(azureshared.QueryError(errors.New("Search requires 1 query part: networkSecurityGroupName"), scope, n.Type())) + return + } + nsgName := queryParts[0] + + rgScope, err := n.ResourceGroupScopeFromScope(scope) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, n.Type())) + return + } + pager := n.client.NewListPager(rgScope.ResourceGroup, nsgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + stream.SendError(azureshared.QueryError(err, scope, n.Type())) + return + } + for _, rule := range page.Value { + if rule == nil || rule.Name == nil { + continue + } + item, sdpErr := n.azureDefaultSecurityRuleToSDPItem(rule, nsgName, *rule.Name, scope) + if sdpErr != nil { + stream.SendError(sdpErr) + continue + } + cache.StoreItem(ctx, item, shared.DefaultCacheDuration, cacheKey) + stream.SendItem(item) + } + } +} + +func (n networkDefaultSecurityRuleWrapper) SearchLookups() []sources.ItemTypeLookups { + return []sources.ItemTypeLookups{ + {NetworkNetworkSecurityGroupLookupByName}, + } +} + +func (n networkDefaultSecurityRuleWrapper) azureDefaultSecurityRuleToSDPItem(rule *armnetwork.SecurityRule, nsgName, ruleName, scope string) (*sdp.Item, *sdp.QueryError) { + attributes, err := shared.ToAttributesWithExclude(rule, "tags") + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + + err = attributes.Set("uniqueAttr", shared.CompositeLookupKey(nsgName, ruleName)) + if err != nil { + return nil, azureshared.QueryError(err, scope, n.Type()) + } + + sdpItem := &sdp.Item{ + Type: azureshared.NetworkDefaultSecurityRule.String(), + UniqueAttribute: "uniqueAttr", + Attributes: attributes, + Scope: scope, + } + + // Link to parent Network Security Group + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkNetworkSecurityGroup.String(), + Method: sdp.QueryMethod_GET, + Query: nsgName, + Scope: scope, + }, + }) + + if rule.Properties != nil { + // Link to SourceApplicationSecurityGroups + if rule.Properties.SourceApplicationSecurityGroups != nil { + for _, asgRef := range rule.Properties.SourceApplicationSecurityGroups { + if asgRef != nil && asgRef.ID != nil { + asgName := azureshared.ExtractResourceName(*asgRef.ID) + if asgName != "" { + linkScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { + linkScope = extractedScope + } + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkApplicationSecurityGroup.String(), + Method: sdp.QueryMethod_GET, + Query: asgName, + Scope: linkScope, + }, + }) + } + } + } + } + + // Link to DestinationApplicationSecurityGroups + if rule.Properties.DestinationApplicationSecurityGroups != nil { + for _, asgRef := range rule.Properties.DestinationApplicationSecurityGroups { + if asgRef != nil && asgRef.ID != nil { + asgName := azureshared.ExtractResourceName(*asgRef.ID) + if asgName != "" { + linkScope := scope + if extractedScope := azureshared.ExtractScopeFromResourceID(*asgRef.ID); extractedScope != "" { + linkScope = extractedScope + } + sdpItem.LinkedItemQueries = append(sdpItem.LinkedItemQueries, &sdp.LinkedItemQuery{ + Query: &sdp.Query{ + Type: azureshared.NetworkApplicationSecurityGroup.String(), + Method: sdp.QueryMethod_GET, + Query: asgName, + Scope: linkScope, + }, + }) + } + } + } + } + + // Link to stdlib.NetworkIP for source/destination address prefixes when they are IPs or CIDRs + if rule.Properties.SourceAddressPrefix != nil { + appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.SourceAddressPrefix) + } + for _, p := range rule.Properties.SourceAddressPrefixes { + if p != nil { + appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) + } + } + if rule.Properties.DestinationAddressPrefix != nil { + appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *rule.Properties.DestinationAddressPrefix) + } + for _, p := range rule.Properties.DestinationAddressPrefixes { + if p != nil { + appendIPOrCIDRLinkIfValid(&sdpItem.LinkedItemQueries, *p) + } + } + } + + return sdpItem, nil +} + +func (n networkDefaultSecurityRuleWrapper) PotentialLinks() map[shared.ItemType]bool { + return shared.NewItemTypesSet( + azureshared.NetworkNetworkSecurityGroup, + azureshared.NetworkApplicationSecurityGroup, + stdlib.NetworkIP, + ) +} + +// ref: https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get#defaultsecurityrules +func (n networkDefaultSecurityRuleWrapper) IAMPermissions() []string { + return []string{ + "Microsoft.Network/networkSecurityGroups/defaultSecurityRules/read", + } +} + +func (n networkDefaultSecurityRuleWrapper) PredefinedRole() string { + return "Reader" +} diff --git a/sources/azure/manual/network-default-security-rule_test.go b/sources/azure/manual/network-default-security-rule_test.go new file mode 100644 index 00000000..d900b59b --- /dev/null +++ b/sources/azure/manual/network-default-security-rule_test.go @@ -0,0 +1,300 @@ +package manual_test + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" + "go.uber.org/mock/gomock" + + "github.com/overmindtech/cli/go/discovery" + sdp "github.com/overmindtech/cli/go/sdp-go" + "github.com/overmindtech/cli/go/sdpcache" + "github.com/overmindtech/cli/sources" + "github.com/overmindtech/cli/sources/azure/clients" + "github.com/overmindtech/cli/sources/azure/manual" + azureshared "github.com/overmindtech/cli/sources/azure/shared" + "github.com/overmindtech/cli/sources/azure/shared/mocks" + "github.com/overmindtech/cli/sources/shared" +) + +type mockDefaultSecurityRulesPager struct { + pages []armnetwork.DefaultSecurityRulesClientListResponse + index int +} + +func (m *mockDefaultSecurityRulesPager) More() bool { + return m.index < len(m.pages) +} + +func (m *mockDefaultSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.DefaultSecurityRulesClientListResponse, error) { + if m.index >= len(m.pages) { + return armnetwork.DefaultSecurityRulesClientListResponse{}, errors.New("no more pages") + } + page := m.pages[m.index] + m.index++ + return page, nil +} + +type errorDefaultSecurityRulesPager struct{} + +func (e *errorDefaultSecurityRulesPager) More() bool { + return true +} + +func (e *errorDefaultSecurityRulesPager) NextPage(ctx context.Context) (armnetwork.DefaultSecurityRulesClientListResponse, error) { + return armnetwork.DefaultSecurityRulesClientListResponse{}, errors.New("pager error") +} + +type testDefaultSecurityRulesClient struct { + *mocks.MockDefaultSecurityRulesClient + pager clients.DefaultSecurityRulesPager +} + +func (t *testDefaultSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) clients.DefaultSecurityRulesPager { + return t.pager +} + +func TestNetworkDefaultSecurityRule(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + subscriptionID := "test-subscription" + resourceGroup := "test-rg" + nsgName := "test-nsg" + ruleName := "AllowVnetInBound" + + t.Run("Get", func(t *testing.T) { + rule := createAzureDefaultSecurityRule(ruleName, nsgName) + + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, ruleName, nil).Return( + armnetwork.DefaultSecurityRulesClientGetResponse{ + SecurityRule: rule, + }, nil) + + testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(nsgName, ruleName) + sdpItem, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr != nil { + t.Fatalf("Expected no error, got: %v", qErr) + } + + if sdpItem.GetType() != azureshared.NetworkDefaultSecurityRule.String() { + t.Errorf("Expected type %s, got %s", azureshared.NetworkDefaultSecurityRule, sdpItem.GetType()) + } + + if sdpItem.GetUniqueAttribute() != "uniqueAttr" { + t.Errorf("Expected unique attribute 'uniqueAttr', got %s", sdpItem.GetUniqueAttribute()) + } + + if sdpItem.UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, ruleName) { + t.Errorf("Expected unique attribute value %s, got %s", shared.CompositeLookupKey(nsgName, ruleName), sdpItem.UniqueAttributeValue()) + } + + if sdpItem.GetScope() != subscriptionID+"."+resourceGroup { + t.Errorf("Expected scope %s, got %s", subscriptionID+"."+resourceGroup, sdpItem.GetScope()) + } + + if err := sdpItem.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + + t.Run("StaticTests", func(t *testing.T) { + queryTests := shared.QueryTests{ + { + ExpectedType: azureshared.NetworkNetworkSecurityGroup.String(), + ExpectedMethod: sdp.QueryMethod_GET, + ExpectedQuery: nsgName, + ExpectedScope: subscriptionID + "." + resourceGroup, + }, + } + shared.RunStaticTests(t, adapter, sdpItem, queryTests) + }) + }) + + t.Run("Get_EmptyRuleName", func(t *testing.T) { + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(nsgName, "") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when rule name is empty, but got nil") + } + }) + + t.Run("Get_InsufficientQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], nsgName, true) + if qErr == nil { + t.Error("Expected error when providing insufficient query parts, but got nil") + } + }) + + t.Run("Search", func(t *testing.T) { + rule1 := createAzureDefaultSecurityRule("AllowVnetInBound", nsgName) + rule2 := createAzureDefaultSecurityRule("AllowAzureLoadBalancerInBound", nsgName) + + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + mockPager := &mockDefaultSecurityRulesPager{ + pages: []armnetwork.DefaultSecurityRulesClientListResponse{ + { + SecurityRuleListResult: armnetwork.SecurityRuleListResult{ + Value: []*armnetwork.SecurityRule{&rule1, &rule2}, + }, + }, + }, + } + + testClient := &testDefaultSecurityRulesClient{ + MockDefaultSecurityRulesClient: mockClient, + pager: mockPager, + } + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable, ok := adapter.(discovery.SearchableAdapter) + if !ok { + t.Fatalf("Adapter does not support Search operation") + } + + sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 2 { + t.Fatalf("Expected 2 items, got: %d", len(sdpItems)) + } + + for _, item := range sdpItems { + if err := item.Validate(); err != nil { + t.Fatalf("Expected no validation error, got: %v", err) + } + if item.GetType() != azureshared.NetworkDefaultSecurityRule.String() { + t.Errorf("Expected type %s, got %s", azureshared.NetworkDefaultSecurityRule, item.GetType()) + } + } + }) + + t.Run("Search_InvalidQueryParts", func(t *testing.T) { + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + + _, qErr := wrapper.Search(ctx, wrapper.Scopes()[0]) + if qErr == nil { + t.Error("Expected error when providing no query parts, but got nil") + } + }) + + t.Run("Search_RuleWithNilName", func(t *testing.T) { + validRule := createAzureDefaultSecurityRule("AllowVnetInBound", nsgName) + + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + mockPager := &mockDefaultSecurityRulesPager{ + pages: []armnetwork.DefaultSecurityRulesClientListResponse{ + { + SecurityRuleListResult: armnetwork.SecurityRuleListResult{ + Value: []*armnetwork.SecurityRule{ + {Name: nil, ID: new(string)}, + &validRule, + }, + }, + }, + }, + } + + testClient := &testDefaultSecurityRulesClient{ + MockDefaultSecurityRulesClient: mockClient, + pager: mockPager, + } + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable := adapter.(discovery.SearchableAdapter) + sdpItems, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(sdpItems) != 1 { + t.Fatalf("Expected 1 item (nil name skipped), got: %d", len(sdpItems)) + } + if sdpItems[0].UniqueAttributeValue() != shared.CompositeLookupKey(nsgName, "AllowVnetInBound") { + t.Errorf("Expected unique value %s, got %s", shared.CompositeLookupKey(nsgName, "AllowVnetInBound"), sdpItems[0].UniqueAttributeValue()) + } + }) + + t.Run("ErrorHandling_Get", func(t *testing.T) { + expectedErr := errors.New("default security rule not found") + + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + mockClient.EXPECT().Get(ctx, resourceGroup, nsgName, "nonexistent-rule", nil).Return( + armnetwork.DefaultSecurityRulesClientGetResponse{}, expectedErr) + + testClient := &testDefaultSecurityRulesClient{MockDefaultSecurityRulesClient: mockClient} + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + query := shared.CompositeLookupKey(nsgName, "nonexistent-rule") + _, qErr := adapter.Get(ctx, wrapper.Scopes()[0], query, true) + if qErr == nil { + t.Error("Expected error when getting non-existent rule, but got nil") + } + }) + + t.Run("ErrorHandling_Search", func(t *testing.T) { + mockClient := mocks.NewMockDefaultSecurityRulesClient(ctrl) + testClient := &testDefaultSecurityRulesClient{ + MockDefaultSecurityRulesClient: mockClient, + pager: &errorDefaultSecurityRulesPager{}, + } + + wrapper := manual.NewNetworkDefaultSecurityRule(testClient, []azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, resourceGroup)}) + adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache()) + + searchable := adapter.(discovery.SearchableAdapter) + _, err := searchable.Search(ctx, wrapper.Scopes()[0], nsgName, true) + if err == nil { + t.Error("Expected error from pager when NextPage returns an error, but got nil") + } + }) +} + +func createAzureDefaultSecurityRule(ruleName, nsgName string) armnetwork.SecurityRule { + idStr := "/subscriptions/test-subscription/resourceGroups/test-rg/providers/Microsoft.Network/networkSecurityGroups/" + nsgName + "/defaultSecurityRules/" + ruleName + typeStr := "Microsoft.Network/networkSecurityGroups/defaultSecurityRules" + access := armnetwork.SecurityRuleAccessAllow + direction := armnetwork.SecurityRuleDirectionInbound + protocol := armnetwork.SecurityRuleProtocolAsterisk + priority := int32(65000) + return armnetwork.SecurityRule{ + ID: &idStr, + Name: &ruleName, + Type: &typeStr, + Properties: &armnetwork.SecurityRulePropertiesFormat{ + Access: &access, + Direction: &direction, + Protocol: &protocol, + Priority: &priority, + }, + } +} diff --git a/sources/azure/shared/item-types.go b/sources/azure/shared/item-types.go index aae8f3f7..6f05bbc0 100644 --- a/sources/azure/shared/item-types.go +++ b/sources/azure/shared/item-types.go @@ -176,6 +176,9 @@ var ( BatchBatchDetector = shared.NewItemType(Azure, Batch, BatchDetector) // ElasticSAN item types + ElasticSan = shared.NewItemType(Azure, ElasticSAN, ElasticSanResource) + ElasticSanVolumeGroup = shared.NewItemType(Azure, ElasticSAN, VolumeGroup) + ElasticSanVolume = shared.NewItemType(Azure, ElasticSAN, Volume) ElasticSanVolumeSnapshot = shared.NewItemType(Azure, ElasticSAN, VolumeSnapshot) // Authorization item types diff --git a/sources/azure/shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go b/sources/azure/shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go new file mode 100644 index 00000000..2b30a74e --- /dev/null +++ b/sources/azure/shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: compute-disk-access-private-endpoint-connection-client.go +// +// Generated by this command: +// +// mockgen -destination=../shared/mocks/mock_compute_disk_access_private_endpoint_connection_client.go -package=mocks -source=compute-disk-access-private-endpoint-connection-client.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + armcompute "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v7" + clients "github.com/overmindtech/cli/sources/azure/clients" + gomock "go.uber.org/mock/gomock" +) + +// MockComputeDiskAccessPrivateEndpointConnectionsClient is a mock of ComputeDiskAccessPrivateEndpointConnectionsClient interface. +type MockComputeDiskAccessPrivateEndpointConnectionsClient struct { + ctrl *gomock.Controller + recorder *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder + isgomock struct{} +} + +// MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder is the mock recorder for MockComputeDiskAccessPrivateEndpointConnectionsClient. +type MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder struct { + mock *MockComputeDiskAccessPrivateEndpointConnectionsClient +} + +// NewMockComputeDiskAccessPrivateEndpointConnectionsClient creates a new mock instance. +func NewMockComputeDiskAccessPrivateEndpointConnectionsClient(ctrl *gomock.Controller) *MockComputeDiskAccessPrivateEndpointConnectionsClient { + mock := &MockComputeDiskAccessPrivateEndpointConnectionsClient{ctrl: ctrl} + mock.recorder = &MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) EXPECT() *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) Get(ctx context.Context, resourceGroupName, diskAccessName, privateEndpointConnectionName string) (armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName) + ret0, _ := ret[0].(armcompute.DiskAccessesClientGetAPrivateEndpointConnectionResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder) Get(ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockComputeDiskAccessPrivateEndpointConnectionsClient)(nil).Get), ctx, resourceGroupName, diskAccessName, privateEndpointConnectionName) +} + +// NewListPrivateEndpointConnectionsPager mocks base method. +func (m *MockComputeDiskAccessPrivateEndpointConnectionsClient) NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName string, options *armcompute.DiskAccessesClientListPrivateEndpointConnectionsOptions) clients.ComputeDiskAccessPrivateEndpointConnectionsPager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListPrivateEndpointConnectionsPager", resourceGroupName, diskAccessName, options) + ret0, _ := ret[0].(clients.ComputeDiskAccessPrivateEndpointConnectionsPager) + return ret0 +} + +// NewListPrivateEndpointConnectionsPager indicates an expected call of NewListPrivateEndpointConnectionsPager. +func (mr *MockComputeDiskAccessPrivateEndpointConnectionsClientMockRecorder) NewListPrivateEndpointConnectionsPager(resourceGroupName, diskAccessName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPrivateEndpointConnectionsPager", reflect.TypeOf((*MockComputeDiskAccessPrivateEndpointConnectionsClient)(nil).NewListPrivateEndpointConnectionsPager), resourceGroupName, diskAccessName, options) +} diff --git a/sources/azure/shared/mocks/mock_default_security_rules_client.go b/sources/azure/shared/mocks/mock_default_security_rules_client.go new file mode 100644 index 00000000..e1f5088e --- /dev/null +++ b/sources/azure/shared/mocks/mock_default_security_rules_client.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: default-security-rules-client.go +// +// Generated by this command: +// +// mockgen -destination=../shared/mocks/mock_default_security_rules_client.go -package=mocks -source=default-security-rules-client.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + armnetwork "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9" + clients "github.com/overmindtech/cli/sources/azure/clients" + gomock "go.uber.org/mock/gomock" +) + +// MockDefaultSecurityRulesClient is a mock of DefaultSecurityRulesClient interface. +type MockDefaultSecurityRulesClient struct { + ctrl *gomock.Controller + recorder *MockDefaultSecurityRulesClientMockRecorder + isgomock struct{} +} + +// MockDefaultSecurityRulesClientMockRecorder is the mock recorder for MockDefaultSecurityRulesClient. +type MockDefaultSecurityRulesClientMockRecorder struct { + mock *MockDefaultSecurityRulesClient +} + +// NewMockDefaultSecurityRulesClient creates a new mock instance. +func NewMockDefaultSecurityRulesClient(ctrl *gomock.Controller) *MockDefaultSecurityRulesClient { + mock := &MockDefaultSecurityRulesClient{ctrl: ctrl} + mock.recorder = &MockDefaultSecurityRulesClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDefaultSecurityRulesClient) EXPECT() *MockDefaultSecurityRulesClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockDefaultSecurityRulesClient) Get(ctx context.Context, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName string, options *armnetwork.DefaultSecurityRulesClientGetOptions) (armnetwork.DefaultSecurityRulesClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) + ret0, _ := ret[0].(armnetwork.DefaultSecurityRulesClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockDefaultSecurityRulesClientMockRecorder) Get(ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDefaultSecurityRulesClient)(nil).Get), ctx, resourceGroupName, networkSecurityGroupName, defaultSecurityRuleName, options) +} + +// NewListPager mocks base method. +func (m *MockDefaultSecurityRulesClient) NewListPager(resourceGroupName, networkSecurityGroupName string, options *armnetwork.DefaultSecurityRulesClientListOptions) clients.DefaultSecurityRulesPager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListPager", resourceGroupName, networkSecurityGroupName, options) + ret0, _ := ret[0].(clients.DefaultSecurityRulesPager) + return ret0 +} + +// NewListPager indicates an expected call of NewListPager. +func (mr *MockDefaultSecurityRulesClientMockRecorder) NewListPager(resourceGroupName, networkSecurityGroupName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListPager", reflect.TypeOf((*MockDefaultSecurityRulesClient)(nil).NewListPager), resourceGroupName, networkSecurityGroupName, options) +} diff --git a/sources/azure/shared/mocks/mock_elastic_san_client.go b/sources/azure/shared/mocks/mock_elastic_san_client.go new file mode 100644 index 00000000..516f9fa1 --- /dev/null +++ b/sources/azure/shared/mocks/mock_elastic_san_client.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: elastic-san-client.go +// +// Generated by this command: +// +// mockgen -destination=../shared/mocks/mock_elastic_san_client.go -package=mocks -source=elastic-san-client.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + clients "github.com/overmindtech/cli/sources/azure/clients" + gomock "go.uber.org/mock/gomock" +) + +// MockElasticSanClient is a mock of ElasticSanClient interface. +type MockElasticSanClient struct { + ctrl *gomock.Controller + recorder *MockElasticSanClientMockRecorder + isgomock struct{} +} + +// MockElasticSanClientMockRecorder is the mock recorder for MockElasticSanClient. +type MockElasticSanClientMockRecorder struct { + mock *MockElasticSanClient +} + +// NewMockElasticSanClient creates a new mock instance. +func NewMockElasticSanClient(ctrl *gomock.Controller) *MockElasticSanClient { + mock := &MockElasticSanClient{ctrl: ctrl} + mock.recorder = &MockElasticSanClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockElasticSanClient) EXPECT() *MockElasticSanClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockElasticSanClient) Get(ctx context.Context, resourceGroupName, elasticSanName string, options *armelasticsan.ElasticSansClientGetOptions) (armelasticsan.ElasticSansClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, options) + ret0, _ := ret[0].(armelasticsan.ElasticSansClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockElasticSanClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanClient)(nil).Get), ctx, resourceGroupName, elasticSanName, options) +} + +// NewListByResourceGroupPager mocks base method. +func (m *MockElasticSanClient) NewListByResourceGroupPager(resourceGroupName string, options *armelasticsan.ElasticSansClientListByResourceGroupOptions) clients.ElasticSanPager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListByResourceGroupPager", resourceGroupName, options) + ret0, _ := ret[0].(clients.ElasticSanPager) + return ret0 +} + +// NewListByResourceGroupPager indicates an expected call of NewListByResourceGroupPager. +func (mr *MockElasticSanClientMockRecorder) NewListByResourceGroupPager(resourceGroupName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByResourceGroupPager", reflect.TypeOf((*MockElasticSanClient)(nil).NewListByResourceGroupPager), resourceGroupName, options) +} diff --git a/sources/azure/shared/mocks/mock_elastic_san_volume_group_client.go b/sources/azure/shared/mocks/mock_elastic_san_volume_group_client.go new file mode 100644 index 00000000..7805e687 --- /dev/null +++ b/sources/azure/shared/mocks/mock_elastic_san_volume_group_client.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: elastic-san-volume-group-client.go +// +// Generated by this command: +// +// mockgen -destination=../shared/mocks/mock_elastic_san_volume_group_client.go -package=mocks -source=elastic-san-volume-group-client.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + clients "github.com/overmindtech/cli/sources/azure/clients" + gomock "go.uber.org/mock/gomock" +) + +// MockElasticSanVolumeGroupClient is a mock of ElasticSanVolumeGroupClient interface. +type MockElasticSanVolumeGroupClient struct { + ctrl *gomock.Controller + recorder *MockElasticSanVolumeGroupClientMockRecorder + isgomock struct{} +} + +// MockElasticSanVolumeGroupClientMockRecorder is the mock recorder for MockElasticSanVolumeGroupClient. +type MockElasticSanVolumeGroupClientMockRecorder struct { + mock *MockElasticSanVolumeGroupClient +} + +// NewMockElasticSanVolumeGroupClient creates a new mock instance. +func NewMockElasticSanVolumeGroupClient(ctrl *gomock.Controller) *MockElasticSanVolumeGroupClient { + mock := &MockElasticSanVolumeGroupClient{ctrl: ctrl} + mock.recorder = &MockElasticSanVolumeGroupClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockElasticSanVolumeGroupClient) EXPECT() *MockElasticSanVolumeGroupClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockElasticSanVolumeGroupClient) Get(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName string, options *armelasticsan.VolumeGroupsClientGetOptions) (armelasticsan.VolumeGroupsClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, volumeGroupName, options) + ret0, _ := ret[0].(armelasticsan.VolumeGroupsClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockElasticSanVolumeGroupClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanVolumeGroupClient)(nil).Get), ctx, resourceGroupName, elasticSanName, volumeGroupName, options) +} + +// NewListByElasticSanPager mocks base method. +func (m *MockElasticSanVolumeGroupClient) NewListByElasticSanPager(resourceGroupName, elasticSanName string, options *armelasticsan.VolumeGroupsClientListByElasticSanOptions) clients.ElasticSanVolumeGroupPager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListByElasticSanPager", resourceGroupName, elasticSanName, options) + ret0, _ := ret[0].(clients.ElasticSanVolumeGroupPager) + return ret0 +} + +// NewListByElasticSanPager indicates an expected call of NewListByElasticSanPager. +func (mr *MockElasticSanVolumeGroupClientMockRecorder) NewListByElasticSanPager(resourceGroupName, elasticSanName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByElasticSanPager", reflect.TypeOf((*MockElasticSanVolumeGroupClient)(nil).NewListByElasticSanPager), resourceGroupName, elasticSanName, options) +} diff --git a/sources/azure/shared/mocks/mock_elastic_san_volume_snapshot_client.go b/sources/azure/shared/mocks/mock_elastic_san_volume_snapshot_client.go new file mode 100644 index 00000000..89dab0b5 --- /dev/null +++ b/sources/azure/shared/mocks/mock_elastic_san_volume_snapshot_client.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: elastic-san-volume-snapshot-client.go +// +// Generated by this command: +// +// mockgen -destination=../shared/mocks/mock_elastic_san_volume_snapshot_client.go -package=mocks -source=elastic-san-volume-snapshot-client.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + armelasticsan "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/elasticsan/armelasticsan" + clients "github.com/overmindtech/cli/sources/azure/clients" + gomock "go.uber.org/mock/gomock" +) + +// MockElasticSanVolumeSnapshotClient is a mock of ElasticSanVolumeSnapshotClient interface. +type MockElasticSanVolumeSnapshotClient struct { + ctrl *gomock.Controller + recorder *MockElasticSanVolumeSnapshotClientMockRecorder + isgomock struct{} +} + +// MockElasticSanVolumeSnapshotClientMockRecorder is the mock recorder for MockElasticSanVolumeSnapshotClient. +type MockElasticSanVolumeSnapshotClientMockRecorder struct { + mock *MockElasticSanVolumeSnapshotClient +} + +// NewMockElasticSanVolumeSnapshotClient creates a new mock instance. +func NewMockElasticSanVolumeSnapshotClient(ctrl *gomock.Controller) *MockElasticSanVolumeSnapshotClient { + mock := &MockElasticSanVolumeSnapshotClient{ctrl: ctrl} + mock.recorder = &MockElasticSanVolumeSnapshotClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockElasticSanVolumeSnapshotClient) EXPECT() *MockElasticSanVolumeSnapshotClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockElasticSanVolumeSnapshotClient) Get(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName, snapshotName string, options *armelasticsan.VolumeSnapshotsClientGetOptions) (armelasticsan.VolumeSnapshotsClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) + ret0, _ := ret[0].(armelasticsan.VolumeSnapshotsClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockElasticSanVolumeSnapshotClientMockRecorder) Get(ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockElasticSanVolumeSnapshotClient)(nil).Get), ctx, resourceGroupName, elasticSanName, volumeGroupName, snapshotName, options) +} + +// ListByVolumeGroup mocks base method. +func (m *MockElasticSanVolumeSnapshotClient) ListByVolumeGroup(ctx context.Context, resourceGroupName, elasticSanName, volumeGroupName string, options *armelasticsan.VolumeSnapshotsClientListByVolumeGroupOptions) clients.ElasticSanVolumeSnapshotPager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByVolumeGroup", ctx, resourceGroupName, elasticSanName, volumeGroupName, options) + ret0, _ := ret[0].(clients.ElasticSanVolumeSnapshotPager) + return ret0 +} + +// ListByVolumeGroup indicates an expected call of ListByVolumeGroup. +func (mr *MockElasticSanVolumeSnapshotClientMockRecorder) ListByVolumeGroup(ctx, resourceGroupName, elasticSanName, volumeGroupName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByVolumeGroup", reflect.TypeOf((*MockElasticSanVolumeSnapshotClient)(nil).ListByVolumeGroup), ctx, resourceGroupName, elasticSanName, volumeGroupName, options) +} diff --git a/sources/azure/shared/models.go b/sources/azure/shared/models.go index a2a12cb6..2dab0456 100644 --- a/sources/azure/shared/models.go +++ b/sources/azure/shared/models.go @@ -223,7 +223,10 @@ const ( BatchDetector shared.Resource = "batch-detector" // ElasticSAN resources - VolumeSnapshot shared.Resource = "elastic-san-volume-snapshot" + ElasticSanResource shared.Resource = "elastic-san" + VolumeGroup shared.Resource = "volume-group" + Volume shared.Resource = "volume" + VolumeSnapshot shared.Resource = "elastic-san-volume-snapshot" // Authorization resources RoleAssignment shared.Resource = "role-assignment" diff --git a/sources/azure/shared/utils.go b/sources/azure/shared/utils.go index a06ed1a2..605b5f4d 100644 --- a/sources/azure/shared/utils.go +++ b/sources/azure/shared/utils.go @@ -48,9 +48,13 @@ func GetResourceIDPathKeys(resourceType string) []string { "azure-network-virtual-network-peering": {"virtualNetworks", "virtualNetworkPeerings"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}/virtualNetworkPeerings/{peeringName}", "azure-network-route": {"routeTables", "routes"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/routeTables/{routeTableName}/routes/{routeName}", "azure-network-security-rule": {"networkSecurityGroups", "securityRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkSecurityGroups/{nsgName}/securityRules/{ruleName}", + "azure-network-default-security-rule": {"networkSecurityGroups", "defaultSecurityRules"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/networkSecurityGroups/{nsgName}/defaultSecurityRules/{ruleName}", "azure-batch-batch-application": {"batchAccounts", "applications"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Batch/batchAccounts/{accountName}/applications/{applicationName}", "azure-batch-batch-pool": {"batchAccounts", "pools"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Batch/batchAccounts/{accountName}/pools/{poolName}", "azure-network-dns-record-set": {"dnszones"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/dnszones/{zoneName}/{recordType}/{relativeRecordSetName}" + "azure-elasticsan-elastic-san-volume-group": {"elasticSans", "volumegroups"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}" + "azure-elasticsan-elastic-san-volume-snapshot": {"elasticSans", "volumegroups", "snapshots"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ElasticSan/elasticSans/{elasticSanName}/volumegroups/{volumeGroupName}/snapshots/{snapshotName}" + "azure-compute-disk-access-private-endpoint-connection": {"diskAccesses", "privateEndpointConnections"}, // "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/diskAccesses/{diskAccessName}/privateEndpointConnections/{connectionName}" } if keys, ok := pathKeysMap[resourceType]; ok { diff --git a/sources/gcp/build/package/Dockerfile b/sources/gcp/build/package/Dockerfile index bfd42f67..f90fad0a 100644 --- a/sources/gcp/build/package/Dockerfile +++ b/sources/gcp/build/package/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_VERSION ARG BUILD_COMMIT # required for generating the version descriptor -RUN apk add --no-cache git +RUN apk upgrade --no-cache && apk add --no-cache git WORKDIR /workspace @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source sources/gcp/main.go -FROM alpine:3.23 +FROM alpine:3.23.3 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534 diff --git a/sources/gcp/proc/proc_test.go b/sources/gcp/proc/proc_test.go index c5476e72..710db195 100644 --- a/sources/gcp/proc/proc_test.go +++ b/sources/gcp/proc/proc_test.go @@ -408,6 +408,7 @@ func TestNewProjectHealthChecker(t *testing.T) { if checker == nil { t.Fatal("expected checker to be non-nil") + return } if len(checker.projectIDs) != len(tt.projectIDs) { diff --git a/sources/snapshot/adapters/adapter_test.go b/sources/snapshot/adapters/adapter_test.go index 7952ca2b..bd55f35b 100644 --- a/sources/snapshot/adapters/adapter_test.go +++ b/sources/snapshot/adapters/adapter_test.go @@ -272,6 +272,7 @@ func TestNewSnapshotAdapter(t *testing.T) { adapter := NewSnapshotAdapter(index, "ec2-instance", []string{"us-east-1", "us-west-2"}) if adapter == nil { t.Fatal("Expected adapter, got nil") + return } if adapter.index != index { t.Error("Expected adapter to store index reference") diff --git a/sources/snapshot/adapters/index_test.go b/sources/snapshot/adapters/index_test.go index a5774e65..da01d495 100644 --- a/sources/snapshot/adapters/index_test.go +++ b/sources/snapshot/adapters/index_test.go @@ -70,6 +70,7 @@ func TestNewSnapshotIndex(t *testing.T) { if index == nil { t.Fatal("Expected index to be non-nil") + return } // Verify all items are indexed diff --git a/sources/transformer.go b/sources/transformer.go index 6cca672a..2a978d17 100644 --- a/sources/transformer.go +++ b/sources/transformer.go @@ -302,7 +302,25 @@ func (s *standardAdapterCore) Get(ctx context.Context, scope string, query strin return cachedItem[0], nil } - queryParts := strings.Split(query, shared.QuerySeparator) + var queryParts []string + if s.sourceType == string(azureshared.Azure) && strings.HasPrefix(query, "/subscriptions/") { + // Terraform mapping may pass full Azure resource ID; extract query parts by type. + if azureshared.GetResourceIDPathKeys(s.wrapper.Type()) == nil { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: fmt.Sprintf("no path keys defined for resource type %s to extract from query %s", s.wrapper.Type(), query), + } + } + queryParts = azureshared.ExtractPathParamsFromResourceIDByType(s.wrapper.Type(), query) + if queryParts == nil { + return nil, &sdp.QueryError{ + ErrorType: sdp.QueryError_OTHER, + ErrorString: fmt.Sprintf("failed to extract query parts from resource ID for resource type %s (invalid or unsupported format): %s", s.wrapper.Type(), query), + } + } + } else { + queryParts = strings.Split(query, shared.QuerySeparator) + } if len(queryParts) != len(s.wrapper.GetLookups()) { return nil, fmt.Errorf( "invalid query format: %s, expected: %s", diff --git a/stdlib-source/build/package/Dockerfile b/stdlib-source/build/package/Dockerfile index 4c8d1112..0ea8527d 100644 --- a/stdlib-source/build/package/Dockerfile +++ b/stdlib-source/build/package/Dockerfile @@ -6,7 +6,7 @@ ARG BUILD_VERSION ARG BUILD_COMMIT # required for accessing the private dependencies and generating version descriptor -RUN apk add --no-cache git curl +RUN apk upgrade --no-cache && apk add --no-cache git curl WORKDIR /workspace @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X github.com/overmindtech/cli/go/tracing.version=${BUILD_VERSION} -X github.com/overmindtech/cli/go/tracing.commit=${BUILD_COMMIT}" -o source stdlib-source/main.go -FROM alpine:3.23 +FROM alpine:3.23.3 WORKDIR / COPY --from=builder /workspace/source . USER 65534:65534