mirror of https://github.com/helm/helm.git
				
				
				
			
		
			
				
	
	
		
			210 lines
		
	
	
		
			5.9 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			210 lines
		
	
	
		
			5.9 KiB
		
	
	
	
		
			Go
		
	
	
	
| /*
 | |
| Copyright The Helm Authors.
 | |
| Licensed under the Apache License, Version 2.0 (the "License");
 | |
| you may not use this file except in compliance with the License.
 | |
| You may obtain a copy of the License at
 | |
| 
 | |
| http://www.apache.org/licenses/LICENSE-2.0
 | |
| 
 | |
| Unless required by applicable law or agreed to in writing, software
 | |
| distributed under the License is distributed on an "AS IS" BASIS,
 | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| See the License for the specific language governing permissions and
 | |
| limitations under the License.
 | |
| */
 | |
| 
 | |
| package cmd
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"os"
 | |
| 	"path/filepath"
 | |
| 	"syscall"
 | |
| 
 | |
| 	"github.com/spf13/cobra"
 | |
| 	"golang.org/x/term"
 | |
| 
 | |
| 	"helm.sh/helm/v4/internal/plugin"
 | |
| 	"helm.sh/helm/v4/pkg/cmd/require"
 | |
| 	"helm.sh/helm/v4/pkg/provenance"
 | |
| )
 | |
| 
 | |
| const pluginPackageDesc = `
 | |
| This command packages a Helm plugin directory into a tarball.
 | |
| 
 | |
| By default, the command will generate a provenance file signed with a PGP key.
 | |
| This ensures the plugin can be verified after installation.
 | |
| 
 | |
| Use --sign=false to skip signing (not recommended for distribution).
 | |
| `
 | |
| 
 | |
| type pluginPackageOptions struct {
 | |
| 	sign           bool
 | |
| 	keyring        string
 | |
| 	key            string
 | |
| 	passphraseFile string
 | |
| 	pluginPath     string
 | |
| 	destination    string
 | |
| }
 | |
| 
 | |
| func newPluginPackageCmd(out io.Writer) *cobra.Command {
 | |
| 	o := &pluginPackageOptions{}
 | |
| 
 | |
| 	cmd := &cobra.Command{
 | |
| 		Use:   "package [PATH]",
 | |
| 		Short: "package a plugin directory into a plugin archive",
 | |
| 		Long:  pluginPackageDesc,
 | |
| 		Args:  require.ExactArgs(1),
 | |
| 		RunE: func(_ *cobra.Command, args []string) error {
 | |
| 			o.pluginPath = args[0]
 | |
| 			return o.run(out)
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	f := cmd.Flags()
 | |
| 	f.BoolVar(&o.sign, "sign", true, "use a PGP private key to sign this plugin")
 | |
| 	f.StringVar(&o.key, "key", "", "name of the key to use when signing. Used if --sign is true")
 | |
| 	f.StringVar(&o.keyring, "keyring", defaultKeyring(), "location of a public keyring")
 | |
| 	f.StringVar(&o.passphraseFile, "passphrase-file", "", "location of a file which contains the passphrase for the signing key. Use \"-\" to read from stdin.")
 | |
| 	f.StringVarP(&o.destination, "destination", "d", ".", "location to write the plugin tarball.")
 | |
| 
 | |
| 	return cmd
 | |
| }
 | |
| 
 | |
| func (o *pluginPackageOptions) run(out io.Writer) error {
 | |
| 	// Check if the plugin path exists and is a directory
 | |
| 	fi, err := os.Stat(o.pluginPath)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	if !fi.IsDir() {
 | |
| 		return fmt.Errorf("plugin package only supports directories, not tarballs")
 | |
| 	}
 | |
| 
 | |
| 	// Load and validate plugin metadata
 | |
| 	pluginMeta, err := plugin.LoadDir(o.pluginPath)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("invalid plugin directory: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Create destination directory if needed
 | |
| 	if err := os.MkdirAll(o.destination, 0755); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// If signing is requested, prepare the signer first
 | |
| 	var signer *provenance.Signatory
 | |
| 	if o.sign {
 | |
| 		// Load the signing key
 | |
| 		signer, err = provenance.NewFromKeyring(o.keyring, o.key)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("error reading from keyring: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		// Get passphrase
 | |
| 		passphraseFetcher := o.promptUser
 | |
| 		if o.passphraseFile != "" {
 | |
| 			passphraseFetcher, err = o.passphraseFileFetcher()
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// Decrypt the key
 | |
| 		if err := signer.DecryptKey(passphraseFetcher); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	} else {
 | |
| 		// User explicitly disabled signing
 | |
| 		fmt.Fprintf(out, "WARNING: Skipping plugin signing. This is not recommended for plugins intended for distribution.\n")
 | |
| 	}
 | |
| 
 | |
| 	// Now create the tarball (only after signing prerequisites are met)
 | |
| 	// Use plugin metadata for filename: PLUGIN_NAME-SEMVER.tgz
 | |
| 	metadata := pluginMeta.Metadata()
 | |
| 	filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
 | |
| 	tarballPath := filepath.Join(o.destination, filename)
 | |
| 
 | |
| 	tarFile, err := os.Create(tarballPath)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to create tarball: %w", err)
 | |
| 	}
 | |
| 	defer tarFile.Close()
 | |
| 
 | |
| 	if err := plugin.CreatePluginTarball(o.pluginPath, metadata.Name, tarFile); err != nil {
 | |
| 		os.Remove(tarballPath)
 | |
| 		return fmt.Errorf("failed to create plugin tarball: %w", err)
 | |
| 	}
 | |
| 	tarFile.Close() // Ensure file is closed before signing
 | |
| 
 | |
| 	// If signing was requested, sign the tarball
 | |
| 	if o.sign {
 | |
| 		// Sign the plugin tarball (not the source directory)
 | |
| 		sig, err := plugin.SignPlugin(tarballPath, signer)
 | |
| 		if err != nil {
 | |
| 			os.Remove(tarballPath)
 | |
| 			return fmt.Errorf("failed to sign plugin: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		// Write the signature
 | |
| 		provFile := tarballPath + ".prov"
 | |
| 		if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil {
 | |
| 			os.Remove(tarballPath)
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		fmt.Fprintf(out, "Successfully signed. Signature written to: %s\n", provFile)
 | |
| 	}
 | |
| 
 | |
| 	fmt.Fprintf(out, "Successfully packaged plugin and saved it to: %s\n", tarballPath)
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (o *pluginPackageOptions) promptUser(name string) ([]byte, error) {
 | |
| 	fmt.Printf("Password for key %q >  ", name)
 | |
| 	pw, err := term.ReadPassword(int(syscall.Stdin))
 | |
| 	fmt.Println()
 | |
| 	return pw, err
 | |
| }
 | |
| 
 | |
| func (o *pluginPackageOptions) passphraseFileFetcher() (provenance.PassphraseFetcher, error) {
 | |
| 	file, err := openPassphraseFile(o.passphraseFile, os.Stdin)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	defer file.Close()
 | |
| 
 | |
| 	// Read the entire passphrase
 | |
| 	passphrase, err := io.ReadAll(file)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	// Trim any trailing newline characters (both \n and \r\n)
 | |
| 	passphrase = bytes.TrimRight(passphrase, "\r\n")
 | |
| 
 | |
| 	return func(_ string) ([]byte, error) {
 | |
| 		return passphrase, nil
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| // copied from action.openPassphraseFile
 | |
| // TODO: should we move this to pkg/action so we can reuse the func from there?
 | |
| func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) {
 | |
| 	if passphraseFile == "-" {
 | |
| 		stat, err := stdin.Stat()
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		if (stat.Mode() & os.ModeNamedPipe) == 0 {
 | |
| 			return nil, errors.New("specified reading passphrase from stdin, without input on stdin")
 | |
| 		}
 | |
| 		return stdin, nil
 | |
| 	}
 | |
| 	return os.Open(passphraseFile)
 | |
| }
 |