diff --git a/cmd/npmExecuteScripts.go b/cmd/npmExecuteScripts.go index 7a7f460e3..aa5c0cc36 100644 --- a/cmd/npmExecuteScripts.go +++ b/cmd/npmExecuteScripts.go @@ -41,5 +41,22 @@ func runNpmExecuteScripts(npmExecutor npm.Executor, config *npmExecuteScriptsOpt } } - return npmExecutor.RunScriptsInAllPackages(config.RunScripts, nil, config.ScriptOptions, config.VirtualFrameBuffer, config.BuildDescriptorExcludeList, config.BuildDescriptorList) + err := npmExecutor.RunScriptsInAllPackages(config.RunScripts, nil, config.ScriptOptions, config.VirtualFrameBuffer, config.BuildDescriptorExcludeList, config.BuildDescriptorList) + if err != nil { + return err + } + + if config.Publish { + packageJSONFiles, err := npmExecutor.FindPackageJSONFilesWithExcludes(config.BuildDescriptorExcludeList) + if err != nil { + return err + } + + err = npmExecutor.PublishAllPackages(packageJSONFiles, config.RepositoryURL, config.RepositoryUsername, config.RepositoryPassword) + if err != nil { + return err + } + } + + return nil } diff --git a/cmd/npmExecuteScripts_generated.go b/cmd/npmExecuteScripts_generated.go index 749e83d9a..17db4789b 100644 --- a/cmd/npmExecuteScripts_generated.go +++ b/cmd/npmExecuteScripts_generated.go @@ -23,6 +23,10 @@ type npmExecuteScriptsOptions struct { BuildDescriptorExcludeList []string `json:"buildDescriptorExcludeList,omitempty"` BuildDescriptorList []string `json:"buildDescriptorList,omitempty"` CreateBOM bool `json:"createBOM,omitempty"` + Publish bool `json:"publish,omitempty"` + RepositoryURL string `json:"repositoryUrl,omitempty"` + RepositoryPassword string `json:"repositoryPassword,omitempty"` + RepositoryUsername string `json:"repositoryUsername,omitempty"` } // NpmExecuteScriptsCommand Execute npm run scripts on all npm packages in a project @@ -54,6 +58,8 @@ func NpmExecuteScriptsCommand() *cobra.Command { log.SetErrorCategory(log.ErrorConfiguration) return err } + log.RegisterSecret(stepConfig.RepositoryPassword) + log.RegisterSecret(stepConfig.RepositoryUsername) if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 { sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID) @@ -108,6 +114,10 @@ func addNpmExecuteScriptsFlags(cmd *cobra.Command, stepConfig *npmExecuteScripts cmd.Flags().StringSliceVar(&stepConfig.BuildDescriptorExcludeList, "buildDescriptorExcludeList", []string{`deployment/**`}, "List of build descriptors and therefore modules to exclude from execution of the npm scripts. The elements can either be a path to the build descriptor or a pattern.") cmd.Flags().StringSliceVar(&stepConfig.BuildDescriptorList, "buildDescriptorList", []string{}, "List of build descriptors and therefore modules for execution of the npm scripts. The elements have to be paths to the build descriptors. **If set, buildDescriptorExcludeList will be ignored.**") cmd.Flags().BoolVar(&stepConfig.CreateBOM, "createBOM", false, "Create a BOM xml using CycloneDX.") + cmd.Flags().BoolVar(&stepConfig.Publish, "publish", false, "Configures npm to publish the artifact to a repository.") + cmd.Flags().StringVar(&stepConfig.RepositoryURL, "repositoryUrl", os.Getenv("PIPER_repositoryUrl"), "Url to the repository to which the project artifacts should be published.") + cmd.Flags().StringVar(&stepConfig.RepositoryPassword, "repositoryPassword", os.Getenv("PIPER_repositoryPassword"), "Password for the repository to which the project artifacts should be published.") + cmd.Flags().StringVar(&stepConfig.RepositoryUsername, "repositoryUsername", os.Getenv("PIPER_repositoryUsername"), "Username for the repository to which the project artifacts should be published.") } @@ -197,6 +207,57 @@ func npmExecuteScriptsMetadata() config.StepData { Aliases: []config.Alias{}, Default: false, }, + { + Name: "publish", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"STEPS", "STAGES", "PARAMETERS"}, + Type: "bool", + Mandatory: false, + Aliases: []config.Alias{}, + Default: false, + }, + { + Name: "repositoryUrl", + ResourceRef: []config.ResourceReference{ + { + Name: "commonPipelineEnvironment", + Param: "custom/repositoryUrl", + }, + }, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_repositoryUrl"), + }, + { + Name: "repositoryPassword", + ResourceRef: []config.ResourceReference{ + { + Name: "commonPipelineEnvironment", + Param: "custom/repositoryPassword", + }, + }, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_repositoryPassword"), + }, + { + Name: "repositoryUsername", + ResourceRef: []config.ResourceReference{ + { + Name: "commonPipelineEnvironment", + Param: "custom/repositoryUsername", + }, + }, + Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_repositoryUsername"), + }, }, }, Containers: []config.Container{ diff --git a/pkg/npm/config.go b/pkg/npm/config.go new file mode 100644 index 000000000..ae50a0823 --- /dev/null +++ b/pkg/npm/config.go @@ -0,0 +1,50 @@ +package npm + +import ( + "io/ioutil" + "path/filepath" + "strings" + + "github.com/magiconair/properties" + "github.com/pkg/errors" +) + +const ( + configFilename = ".npmrc" +) + +var ( + propertiesLoadFile = properties.LoadFile +) + +func NewNPMRC(path string) NPMRC { + if !strings.HasSuffix(path, configFilename) { + path = filepath.Join(path, configFilename) + } + return NPMRC{filepath: path, values: properties.NewProperties()} +} + +type NPMRC struct { + filepath string + values *properties.Properties +} + +func (rc *NPMRC) Write() error { + if err := ioutil.WriteFile(rc.filepath, []byte(rc.values.String()), 0644); err != nil { + return errors.Wrapf(err, "failed to write %s", rc.filepath) + } + return nil +} + +func (rc *NPMRC) Load() error { + values, err := propertiesLoadFile(rc.filepath, properties.UTF8) + if err != nil { + return err + } + rc.values = values + return nil +} + +func (rc *NPMRC) Set(key, value string) { + rc.values.Set(key, value) +} diff --git a/pkg/npm/config_test.go b/pkg/npm/config_test.go new file mode 100644 index 000000000..3be82efb2 --- /dev/null +++ b/pkg/npm/config_test.go @@ -0,0 +1,58 @@ +package npm + +import ( + "reflect" + "testing" + + "github.com/magiconair/properties" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewNPMRC(t *testing.T) { + type args struct { + path string + } + tests := []struct { + name string + args args + want string + }{ + {name: "current dir", args: args{""}, want: configFilename}, + {name: "sub dir", args: args{mock.Anything}, want: mock.Anything + "/.npmrc"}, + {name: "file path in current dir", args: args{".npmrc"}, want: ".npmrc"}, + {name: "file path in sub dir", args: args{mock.Anything + "/.npmrc"}, want: mock.Anything + "/.npmrc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewNPMRC(tt.args.path); !reflect.DeepEqual(got.filepath, tt.want) { + t.Errorf("NewNPMRC().filepath = %v, want %v", got.filepath, tt.want) + } + }) + } +} + +func mockLoadProperties(t *testing.T, result *properties.Properties, err error) func(filename string, enc properties.Encoding) (*properties.Properties, error) { + return func(filename string, enc properties.Encoding) (*properties.Properties, error) { + return result, err + } +} + +func TestLoad(t *testing.T) { + // init + config := NewNPMRC("") + + new := properties.NewProperties() + new.Set("test", "anything") + propertiesLoadFile = mockLoadProperties(t, new, nil) + require.NotEmpty(t, new.Keys()) + + require.Empty(t, config.values.Keys()) + // test + err := config.Load() + // assert + assert.NoError(t, err) + assert.NotEmpty(t, config.values.Keys()) + +} diff --git a/pkg/npm/ignore.go b/pkg/npm/ignore.go new file mode 100644 index 000000000..e86a88e10 --- /dev/null +++ b/pkg/npm/ignore.go @@ -0,0 +1,56 @@ +package npm + +import ( + "bufio" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" +) + +const ( + ignoreFilename = ".npmignore" +) + +func NewNPMIgnore(path string) NPMIgnore { + if !strings.HasSuffix(path, ignoreFilename) { + path = filepath.Join(path, ignoreFilename) + } + return NPMIgnore{filepath: path, values: []string{}} +} + +type NPMIgnore struct { + filepath string + values []string +} + +func (ignorefile *NPMIgnore) Write() error { + content := strings.Join(ignorefile.values, "\n") + + if err := ioutil.WriteFile(ignorefile.filepath, []byte(content+"\n"), 0644); err != nil { + return errors.Wrapf(err, "failed to write %s", ignorefile.filepath) + } + return nil +} + +func (ignorefile *NPMIgnore) Load() error { + file, err := os.Open(ignorefile.filepath) + if err != nil { + return err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + ignorefile.values = lines + return scanner.Err() +} + +func (ignorefile *NPMIgnore) Add(value string) { + ignorefile.values = append(ignorefile.values, value) +} diff --git a/pkg/npm/mock.go b/pkg/npm/mock.go index 8b018541b..95b876775 100644 --- a/pkg/npm/mock.go +++ b/pkg/npm/mock.go @@ -120,3 +120,8 @@ func (n *NpmExecutorMock) SetNpmRegistries() error { func (n *NpmExecutorMock) CreateBOM(packageJSONFiles []string) error { return nil } + +// CreateBOM mock implementation +func (n *NpmExecutorMock) PublishAllPackages(packageJSONFiles []string, registry, username, password string) error { + return nil +} diff --git a/pkg/npm/npm.go b/pkg/npm/npm.go index 4356f0036..11cf33620 100644 --- a/pkg/npm/npm.go +++ b/pkg/npm/npm.go @@ -26,6 +26,7 @@ type Executor interface { FindPackageJSONFilesWithScript(packageJSONFiles []string, script string) ([]string, error) RunScriptsInAllPackages(runScripts []string, runOptions []string, scriptOptions []string, virtualFrameBuffer bool, excludeList []string, packagesList []string) error InstallAllDependencies(packageJSONFiles []string) error + PublishAllPackages(packageJSONFiles []string, registry, username, password string) error SetNpmRegistries() error CreateBOM(packageJSONFiles []string) error } diff --git a/pkg/npm/publish.go b/pkg/npm/publish.go new file mode 100644 index 000000000..a826ff9f4 --- /dev/null +++ b/pkg/npm/publish.go @@ -0,0 +1,94 @@ +package npm + +import ( + "fmt" + "path/filepath" + + "github.com/pkg/errors" + + "github.com/SAP/jenkins-library/pkg/log" + CredentialUtils "github.com/SAP/jenkins-library/pkg/piperutils" + FileUtils "github.com/SAP/jenkins-library/pkg/piperutils" +) + +// PublishAllPackages executes npm publish for all package.json files defined in packageJSONFiles list +func (exec *Execute) PublishAllPackages(packageJSONFiles []string, registry, username, password string) error { + for _, packageJSON := range packageJSONFiles { + fileExists, err := exec.Utils.FileExists(packageJSON) + if err != nil { + return fmt.Errorf("cannot check if '%s' exists: %w", packageJSON, err) + } + if !fileExists { + return fmt.Errorf("package.json file '%s' not found: %w", packageJSON, err) + } + + err = exec.publish(packageJSON, registry, username, password) + if err != nil { + return err + } + } + return nil +} + +// publish executes npm publish for package.json +func (exec *Execute) publish(packageJSON, registry, username, password string) error { + execRunner := exec.Utils.GetExecRunner() + + npmignore := NewNPMIgnore(filepath.Dir(packageJSON)) + if exists, err := FileUtils.FileExists(npmignore.filepath); exists { + if err != nil { + return errors.Wrapf(err, "failed to check for existing %s file", npmignore.filepath) + } + log.Entry().Debugf("loading existing %s file", npmignore.filepath) + if err = npmignore.Load(); err != nil { + return errors.Wrapf(err, "failed to read existing %s file", npmignore.filepath) + } + } else { + log.Entry().Debug("creating .npmignore file") + } + log.Entry().Debug("adding **/piper") + npmignore.Add("**/piper") + log.Entry().Debug("adding **/sap-piper") + npmignore.Add("**/sap-piper") + // update .npmrc + if err := npmignore.Write(); err != nil { + return errors.Wrapf(err, "failed to update %s file", npmignore.filepath) + } + + if len(registry) > 0 { + npmrc := NewNPMRC(filepath.Dir(packageJSON)) + // check existing .npmrc file + if exists, err := FileUtils.FileExists(npmrc.filepath); exists { + if err != nil { + return errors.Wrapf(err, "failed to check for existing %s file", npmrc.filepath) + } + log.Entry().Debugf("loading existing %s file", npmrc.filepath) + if err = npmrc.Load(); err != nil { + return errors.Wrapf(err, "failed to read existing %s file", npmrc.filepath) + } + } else { + log.Entry().Debug("creating .npmrc file") + } + // set registry + log.Entry().Debugf("adding registry %s", registry) + npmrc.Set("registry", registry) + // set registry auth + if len(username) > 0 && len(password) > 0 { + log.Entry().Debug("adding registry credentials") + npmrc.Set("_auth", CredentialUtils.EncodeUsernamePassword(username, password)) + npmrc.Set("always-auth", "true") + } + // update .npmrc + if err := npmrc.Write(); err != nil { + return errors.Wrapf(err, "failed to update %s file", npmrc.filepath) + } + } else { + log.Entry().Debug("no registry provided") + } + + err := execRunner.RunExecutable("npm", "publish", filepath.Dir(packageJSON)) + if err != nil { + return err + } + return nil +} diff --git a/pkg/piperutils/credentials.go b/pkg/piperutils/credentials.go new file mode 100644 index 000000000..b8f0bc1ea --- /dev/null +++ b/pkg/piperutils/credentials.go @@ -0,0 +1,14 @@ +package piperutils + +import ( + "encoding/base64" + "fmt" +) + +func EncodeString(token string) string { + return base64.StdEncoding.EncodeToString([]byte(token)) +} + +func EncodeUsernamePassword(username, password string) string { + return EncodeString(fmt.Sprintf("%s:%s", username, password)) +} diff --git a/pkg/piperutils/credentials_test.go b/pkg/piperutils/credentials_test.go new file mode 100644 index 000000000..821dbba5d --- /dev/null +++ b/pkg/piperutils/credentials_test.go @@ -0,0 +1,46 @@ +package piperutils + +import ( + "testing" +) + +func TestEncodeUsernamePassword(t *testing.T) { + type args struct { + username string + password string + } + tests := []struct { + name string + args args + want string + }{ + {args: args{username: "anything", password: "something"}, want: "YW55dGhpbmc6c29tZXRoaW5n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EncodeUsernamePassword(tt.args.username, tt.args.password); got != tt.want { + t.Errorf("EncodeUsernamePassword() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEncodeToken(t *testing.T) { + type args struct { + token string + } + tests := []struct { + name string + args args + want string + }{ + {args: args{token: "anything"}, want: "YW55dGhpbmc="}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EncodeString(tt.args.token); got != tt.want { + t.Errorf("EncodeToken() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/resources/metadata/npmExecuteScripts.yaml b/resources/metadata/npmExecuteScripts.yaml index 61e5b6a73..75564c4ff 100644 --- a/resources/metadata/npmExecuteScripts.yaml +++ b/resources/metadata/npmExecuteScripts.yaml @@ -74,6 +74,48 @@ spec: - STAGES - PARAMETERS default: false + - name: publish + type: bool + description: Configures npm to publish the artifact to a repository. + scope: + - STEPS + - STAGES + - PARAMETERS + - name: repositoryUrl + type: string + description: Url to the repository to which the project artifacts should be published. + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + resourceRef: + - name: commonPipelineEnvironment + param: custom/repositoryUrl + - name: repositoryPassword + type: string + description: Password for the repository to which the project artifacts should be published. + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + secret: true + resourceRef: + - name: commonPipelineEnvironment + param: custom/repositoryPassword + - name: repositoryUsername + type: string + description: Username for the repository to which the project artifacts should be published. + scope: + - GENERAL + - PARAMETERS + - STAGES + - STEPS + secret: true + resourceRef: + - name: commonPipelineEnvironment + param: custom/repositoryUsername containers: - name: node image: node:lts-stretch