You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helm/pkg/cmd/plugin_package.go

217 lines
6.1 KiB

/*
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 {
// Read the tarball data
tarballData, err := os.ReadFile(tarballPath)
if err != nil {
os.Remove(tarballPath)
return fmt.Errorf("failed to read tarball for signing: %w", err)
}
// Sign the plugin tarball data
sig, err := plugin.SignPlugin(tarballData, filepath.Base(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)
}