mirror of https://github.com/helm/helm
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.
217 lines
6.1 KiB
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)
|
|
}
|