diff --git a/cmd/generateEvent.go b/cmd/generateEvent.go new file mode 100644 index 0000000000..c720c3b0a2 --- /dev/null +++ b/cmd/generateEvent.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + + "github.com/SAP/jenkins-library/pkg/command" + piperConfig "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/piperutils" + "github.com/SAP/jenkins-library/pkg/telemetry" +) + +type generateEventUtils interface { + command.ExecRunner + + FileExists(filename string) (bool, error) + + // Add more methods here, or embed additional interfaces, or remove/replace as required. + // The generateEventUtils interface should be descriptive of your runtime dependencies, + // i.e. include everything you need to be able to mock in tests. + // Unit tests shall be executable in parallel (not depend on global state), and don't (re-)test dependencies. +} + +type generateEventUtilsBundle struct { + *command.Command + *piperutils.Files + + // Embed more structs as necessary to implement methods or interfaces you add to generateEventUtils. + // Structs embedded in this way must each have a unique set of methods attached. + // If there is no struct which implements the method you need, attach the method to + // generateEventUtilsBundle and forward to the implementation of the dependency. +} + +func newGenerateEventUtils() generateEventUtils { + utils := generateEventUtilsBundle{ + Command: &command.Command{}, + Files: &piperutils.Files{}, + } + // Reroute command output to logging framework + utils.Stdout(log.Writer()) + utils.Stderr(log.Writer()) + return &utils +} + +func generateEvent(config generateEventOptions, telemetryData *telemetry.CustomData) { + // Utils can be used wherever the command.ExecRunner interface is expected. + // It can also be used for example as a mavenExecRunner. + utils := newGenerateEventUtils() + + // For HTTP calls import piperhttp "github.com/SAP/jenkins-library/pkg/http" + // and use a &piperhttp.Client{} in a custom system + // Example: step checkmarxExecuteScan.go + + // Error situations should be bubbled up until they reach the line below which will then stop execution + // through the log.Entry().Fatal() call leading to an os.Exit(1) in the end. + err := runGenerateEvent(&config, telemetryData, utils) + if err != nil { + log.Entry().WithError(err).Fatal("step execution failed") + } +} + +func runGenerateEvent(config *generateEventOptions, StetelemetryData *telemetry.CustomData, utils generateEventUtils) error { + log.Entry().WithField("LogField", "Log field content").Info("This is just a demo for a simple step.") + + vaultCreds := piperConfig.VaultCredentials{ + AppRoleID: GeneralConfig.VaultRoleID, + AppRoleSecretID: GeneralConfig.VaultRoleSecretID, + VaultToken: GeneralConfig.VaultToken, + } + // GeneralConfig VaultServerURL and VaultNamespace are empty swicthing to stepConfig + var vaultConfig = map[string]interface{}{ + "vaultServerUrl": config.VaultServerURL, + "vaultNamespace": config.VaultNamespace, + } + + stepConfig := piperConfig.StepConfig{ + Config: vaultConfig, + } + // Generating vault client + vaultClient, err := piperConfig.GetVaultClientFromConfig(stepConfig, vaultCreds) + if err != nil { + log.Entry().WithError(err).Fatal("getting vault client failed") + } + // Getting oidc token and setting it in environment variable + _, err = vaultClient.GetOidcTokenByValidation(GeneralConfig.HookConfig.OidcConfig.RoleID) + if err != nil { + log.Entry().WithError(err).Fatal("getting oidc token failed") + } + // Example of calling methods from external dependencies directly on utils: + exists, err := utils.FileExists("file.txt") + if err != nil { + // It is good practice to set an error category. + // Most likely you want to do this at the place where enough context is known. + log.SetErrorCategory(log.ErrorConfiguration) + // Always wrap non-descriptive errors to enrich them with context for when they appear in the log: + return fmt.Errorf("failed to check for important file: %w", err) + } + if !exists { + log.SetErrorCategory(log.ErrorConfiguration) + return fmt.Errorf("cannot run without important file") + } + + return nil +} diff --git a/cmd/generateEvent_generated.go b/cmd/generateEvent_generated.go new file mode 100644 index 0000000000..9a1f5ff277 --- /dev/null +++ b/cmd/generateEvent_generated.go @@ -0,0 +1,216 @@ +// Code generated by piper's step-generator. DO NOT EDIT. + +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/splunk" + "github.com/SAP/jenkins-library/pkg/telemetry" + "github.com/SAP/jenkins-library/pkg/validation" + "github.com/spf13/cobra" +) + +type generateEventOptions struct { + VaultAppRoleID string `json:"vaultAppRoleID,omitempty"` + VaultAppRoleSecretID string `json:"vaultAppRoleSecretID,omitempty"` + VaultBasePath string `json:"vaultBasePath,omitempty"` + VaultPipelineName string `json:"vaultPipelineName,omitempty"` + VaultNamespace string `json:"vaultNamespace,omitempty"` + VaultToken string `json:"vaultToken,omitempty"` + VaultServerURL string `json:"vaultServerUrl,omitempty"` +} + +// GenerateEventCommand generateEvent +func GenerateEventCommand() *cobra.Command { + const STEP_NAME = "generateEvent" + + metadata := generateEventMetadata() + var stepConfig generateEventOptions + var startTime time.Time + var logCollector *log.CollectorHook + var splunkClient *splunk.Splunk + telemetryClient := &telemetry.Telemetry{} + + var createGenerateEventCmd = &cobra.Command{ + Use: STEP_NAME, + Short: "generateEvent", + Long: ``, + PreRunE: func(cmd *cobra.Command, _ []string) error { + startTime = time.Now() + log.SetStepName(STEP_NAME) + log.SetVerbose(GeneralConfig.Verbose) + + GeneralConfig.GitHubAccessTokens = ResolveAccessTokens(GeneralConfig.GitHubTokens) + + path, _ := os.Getwd() + fatalHook := &log.FatalHook{CorrelationID: GeneralConfig.CorrelationID, Path: path} + log.RegisterHook(fatalHook) + + err := PrepareConfig(cmd, &metadata, STEP_NAME, &stepConfig, config.OpenPiperFile) + if err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + + if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 { + sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID) + log.RegisterHook(&sentryHook) + } + + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 || len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient = &splunk.Splunk{} + logCollector = &log.CollectorHook{CorrelationID: GeneralConfig.CorrelationID} + log.RegisterHook(logCollector) + } + + if err = log.RegisterANSHookIfConfigured(GeneralConfig.CorrelationID); err != nil { + log.Entry().WithError(err).Warn("failed to set up SAP Alert Notification Service log hook") + } + + validation, err := validation.New(validation.WithJSONNamesForStructFields(), validation.WithPredefinedErrorMessages()) + if err != nil { + return err + } + if err = validation.ValidateStruct(stepConfig); err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + + return nil + }, + Run: func(_ *cobra.Command, _ []string) { + stepTelemetryData := telemetry.CustomData{} + stepTelemetryData.ErrorCode = "1" + handler := func() { + config.RemoveVaultSecretFiles() + stepTelemetryData.Duration = fmt.Sprintf("%v", time.Since(startTime).Milliseconds()) + stepTelemetryData.ErrorCategory = log.GetErrorCategory().String() + stepTelemetryData.PiperCommitHash = GitCommit + telemetryClient.SetData(&stepTelemetryData) + telemetryClient.Send() + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.Dsn, + GeneralConfig.HookConfig.SplunkConfig.Token, + GeneralConfig.HookConfig.SplunkConfig.Index, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + if len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblToken, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblIndex, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + } + log.DeferExitHandler(handler) + defer handler() + telemetryClient.Initialize(GeneralConfig.NoTelemetry, STEP_NAME, GeneralConfig.HookConfig.PendoConfig.Token) + generateEvent(stepConfig, &stepTelemetryData) + stepTelemetryData.ErrorCode = "0" + log.Entry().Info("SUCCESS") + }, + } + + addGenerateEventFlags(createGenerateEventCmd, &stepConfig) + return createGenerateEventCmd +} + +func addGenerateEventFlags(cmd *cobra.Command, stepConfig *generateEventOptions) { + cmd.Flags().StringVar(&stepConfig.VaultAppRoleID, "vaultAppRoleID", os.Getenv("PIPER_vaultAppRoleID"), "") + cmd.Flags().StringVar(&stepConfig.VaultAppRoleSecretID, "vaultAppRoleSecretID", os.Getenv("PIPER_vaultAppRoleSecretID"), "") + cmd.Flags().StringVar(&stepConfig.VaultBasePath, "vaultBasePath", os.Getenv("PIPER_vaultBasePath"), "") + cmd.Flags().StringVar(&stepConfig.VaultPipelineName, "vaultPipelineName", os.Getenv("PIPER_vaultPipelineName"), "") + cmd.Flags().StringVar(&stepConfig.VaultNamespace, "vaultNamespace", os.Getenv("PIPER_vaultNamespace"), "") + cmd.Flags().StringVar(&stepConfig.VaultToken, "vaultToken", os.Getenv("PIPER_vaultToken"), "") + cmd.Flags().StringVar(&stepConfig.VaultServerURL, "vaultServerUrl", os.Getenv("PIPER_vaultServerUrl"), "") + +} + +// retrieve step metadata +func generateEventMetadata() config.StepData { + var theMetaData = config.StepData{ + Metadata: config.StepMetadata{ + Name: "generateEvent", + Aliases: []config.Alias{}, + Description: "generateEvent", + }, + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + { + Name: "vaultAppRoleID", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultAppRoleID"), + }, + { + Name: "vaultAppRoleSecretID", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultAppRoleSecretID"), + }, + { + Name: "vaultBasePath", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultBasePath"), + }, + { + Name: "vaultPipelineName", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultPipelineName"), + }, + { + Name: "vaultNamespace", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultNamespace"), + }, + { + Name: "vaultToken", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultToken"), + }, + { + Name: "vaultServerUrl", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultServerUrl"), + }, + }, + }, + }, + } + return theMetaData +} diff --git a/cmd/generateEvent_generated_test.go b/cmd/generateEvent_generated_test.go new file mode 100644 index 0000000000..4b7f8c2379 --- /dev/null +++ b/cmd/generateEvent_generated_test.go @@ -0,0 +1,20 @@ +//go:build unit +// +build unit + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateEventCommand(t *testing.T) { + t.Parallel() + + testCmd := GenerateEventCommand() + + // only high level testing performed - details are tested in step generation procedure + assert.Equal(t, "generateEvent", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/generateEvent_test.go b/cmd/generateEvent_test.go new file mode 100644 index 0000000000..2032407a0b --- /dev/null +++ b/cmd/generateEvent_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/SAP/jenkins-library/pkg/mock" + "github.com/stretchr/testify/assert" + "testing" +) + +type generateEventMockUtils struct { + *mock.ExecMockRunner + *mock.FilesMock +} + +func newGenerateEventTestsUtils() generateEventMockUtils { + utils := generateEventMockUtils{ + ExecMockRunner: &mock.ExecMockRunner{}, + FilesMock: &mock.FilesMock{}, + } + return utils +} + +func TestRunGenerateEvent(t *testing.T) { + t.Parallel() + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + + utils := newGenerateEventTestsUtils() + utils.AddFile("file.txt", []byte("dummy content")) + + // test + err := runGenerateEvent(nil, utils) + + // assert + assert.NoError(t, err) + }) + + t.Run("error path", func(t *testing.T) { + t.Parallel() + // init + utils := newGenerateEventTestsUtils() + + // test + err := runGenerateEvent(nil, utils) + + // assert + assert.EqualError(t, err, "cannot run without important file") + }) +} diff --git a/cmd/metadata_generated.go b/cmd/metadata_generated.go index a7d08435ce..521e3c48c5 100644 --- a/cmd/metadata_generated.go +++ b/cmd/metadata_generated.go @@ -64,6 +64,7 @@ func GetAllStepMetadata() map[string]config.StepData { "gctsExecuteABAPQualityChecks": gctsExecuteABAPQualityChecksMetadata(), "gctsExecuteABAPUnitTests": gctsExecuteABAPUnitTestsMetadata(), "gctsRollback": gctsRollbackMetadata(), + "generateEvent": generateEventMetadata(), "githubCheckBranchProtection": githubCheckBranchProtectionMetadata(), "githubCommentIssue": githubCommentIssueMetadata(), "githubCreateIssue": githubCreateIssueMetadata(), diff --git a/cmd/piper.go b/cmd/piper.go index 7989aa485b..8c4760c068 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -54,6 +54,7 @@ type HookConfiguration struct { SentryConfig SentryConfiguration `json:"sentry,omitempty"` SplunkConfig SplunkConfiguration `json:"splunk,omitempty"` PendoConfig PendoConfiguration `json:"pendo,omitempty"` + OidcConfig OidcConfiguration `json:"oidc,omitempty"` } // SentryConfiguration defines the configuration options for the Sentry logging system @@ -76,6 +77,11 @@ type PendoConfiguration struct { Token string `json:"token,omitempty"` } +// OidcConfiguration defines the configuration options for the OpenID Connect authentication system +type OidcConfiguration struct { + RoleID string `json:",roleID,omitempty"` +} + var rootCmd = &cobra.Command{ Use: "piper", Short: "Executes CI/CD steps from project 'Piper' ", diff --git a/pkg/config/config.go b/pkg/config/config.go index 4190428728..f413a145ca 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -257,11 +257,13 @@ func (c *Config) GetStepConfig(flagValues map[string]interface{}, paramJSON stri // check whether vault should be skipped if skip, ok := stepConfig.Config["skipVault"].(bool); !ok || !skip { // fetch secrets from vault - vaultClient, err := getVaultClientFromConfig(stepConfig, c.vaultCredentials) + vaultClient, err := GetVaultClientFromConfig(stepConfig, c.vaultCredentials) if err != nil { return StepConfig{}, err } if vaultClient != nil { + roleID := stepConfig.HookConfig["oidc"].(map[string]interface{})["roleID"].(string) + vaultClient.GetOidcTokenByValidation(roleID) defer vaultClient.MustRevokeToken() resolveAllVaultReferences(&stepConfig, vaultClient, append(parameters, ReportingParameters.Parameters...)) resolveVaultTestCredentialsWrapper(&stepConfig, vaultClient) diff --git a/pkg/config/vault.go b/pkg/config/vault.go index 417156c191..4ddede472d 100644 --- a/pkg/config/vault.go +++ b/pkg/config/vault.go @@ -79,6 +79,7 @@ type VaultCredentials struct { type vaultClient interface { GetKvSecret(string) (map[string]string, error) MustRevokeToken() + GetOidcTokenByValidation(string) (string, error) } func (s *StepConfig) mixinVaultConfig(parameters []StepParameters, configs ...map[string]interface{}) { @@ -91,7 +92,7 @@ func (s *StepConfig) mixinVaultConfig(parameters []StepParameters, configs ...ma } } -func getVaultClientFromConfig(config StepConfig, creds VaultCredentials) (vaultClient, error) { +func GetVaultClientFromConfig(config StepConfig, creds VaultCredentials) (vaultClient, error) { address, addressOk := config.Config["vaultServerUrl"].(string) // if vault isn't used it's not an error if !addressOk || creds.VaultToken == "" && (creds.AppRoleID == "" || creds.AppRoleSecretID == "") { diff --git a/pkg/vault/client.go b/pkg/vault/client.go index 76e1018c3d..ae74f1f71a 100644 --- a/pkg/vault/client.go +++ b/pkg/vault/client.go @@ -2,10 +2,12 @@ package vault import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" "net/http" + "os" "path" "strconv" "strings" @@ -34,6 +36,12 @@ type logicalClient interface { Write(string, map[string]interface{}) (*api.Secret, error) } +type VaultCredentials struct { + AppRoleID string + AppRoleSecretID string + VaultToken string +} + // NewClient instantiates a Client and sets the specified token func NewClient(config *Config, token string) (Client, error) { if config == nil { @@ -406,3 +414,84 @@ func (v *Client) lookupSecretID(secretID, appRolePath string) (map[string]interf return secret.Data, nil } + +// GetOidcToken returns the genrated oidc token and set the token in the env +func (v Client) getOidcToken(roleID string) (string, error) { + oidcPath := sanitizePath(path.Join("identity/oidc/token/", roleID)) + c := v.lClient + jwt, err := c.Read(oidcPath) + if err != nil { + return "", err + } + // Set the OIDC token in the env + PIPER_OIDCIdentityToken := jwt.Data["token"].(string) + os.Setenv("PIPER_OIDCIdentityToken", PIPER_OIDCIdentityToken) + return PIPER_OIDCIdentityToken, nil +} + +// getJWTTokenPayload returns the payload of the JWT token using base64 decoding +func getJWTTokenPayload(token string) ([]byte, error) { + // Split the token into parts by "." + parts := strings.Split(token, ".") + if len(parts) >= 2 { + // Decode the payload part + substr := parts[1] + decodedBytes, err := base64.RawStdEncoding.DecodeString(substr) + if err != nil { + fmt.Println("Error decoding base64:", err) + return nil, err + } + return decodedBytes, nil + } else { + return nil, fmt.Errorf("Not a valid JWT token") + } +} + +// JwtPayload struct +type JwtPayload struct { + Expire int64 `json:"exp"` +} + +// validateOidcToken validate the oidc token and return true if the token is expired +func validateOidcToken(token string) bool { + // Get the payload of the token + playoad, err := getJWTTokenPayload(token) + if err != nil { + fmt.Println("Error decoding token:", err) + return false + } + // Unmarshal the payload to JwtPayload struct + var jwtPayload JwtPayload + err = json.Unmarshal(playoad, &jwtPayload) + if err != nil { + fmt.Println("Error decoding token:", err) + return false + } + // Convert the given timestamp to time.Time + ExpireTime := time.Unix(jwtPayload.Expire, 0) + + // Get the current time + currentTime := time.Now() + + // Check if the token is expired + return ExpireTime.Before(currentTime) +} + +// GetOidcTokenByValidation pubic function to returns the token if token is expired then get a new token else return old token +func (v Client) GetOidcTokenByValidation(roleID string) (string, error) { + // Get the oidc token from env + token := os.Getenv("PIPER_OIDCIdentityToken") + if token != "" { + // validate the token and return if it is not expired + if validateOidcToken(token) { + return token, nil + } + } + // Regenerate a new oidc token if the token is expired + token, err := v.getOidcToken(roleID) + if token == "" || err != nil { + log.Entry().Error("Failed to get OIDC token") + return "", err + } + return token, nil +} diff --git a/resources/metadata/generateEvent.yaml b/resources/metadata/generateEvent.yaml new file mode 100644 index 0000000000..fc6ddd3464 --- /dev/null +++ b/resources/metadata/generateEvent.yaml @@ -0,0 +1,63 @@ +metadata: + name: generateEvent + description: generateEvent + longDescription: | +spec: + inputs: + params: + - name: vaultAppRoleID + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultAppRoleSecretID + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultBasePath + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultPipelineName + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultNamespace + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultToken + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + - name: vaultServerUrl + type: "string" + description: + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS