hgctl/pkg/plugin/build/build.go (607 lines of code) (raw):
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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 build
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/user"
"strings"
"github.com/alibaba/higress/hgctl/pkg/plugin/option"
ptypes "github.com/alibaba/higress/hgctl/pkg/plugin/types"
"github.com/alibaba/higress/hgctl/pkg/plugin/utils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
const (
DefaultBuilderRepository = "higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder"
DefaultBuilderGo = "1.19"
DefaultBuilderTinyGo = "0.28.1"
DefaultBuilderOras = "1.0.0"
MediaTypeSpec = "application/vnd.module.wasm.spec.v1+yaml"
MediaTypeREADME = "application/vnd.module.wasm.doc.v1+markdown"
MediaTypeREADME_ZH = "application/vnd.module.wasm.doc.v1.zh+markdown"
MediaTypeREADME_EN = "application/vnd.module.wasm.doc.v1.en+markdown"
MediaTypeIcon = "application/vnd.module.wasm.icon.v1+png"
MediaTypePlugin = "application/vnd.oci.image.layer.v1.tar+gzip"
HostTempDirPattern = "higress-wasm-go-build-*"
HostDockerEntryPattern = "higress-wasm-go-build-docker-entrypoint-*.sh"
ContainerWorkDir = "/workspace"
ContainerTempDir = "/higress_temp" // the directory to temporarily store the build products
ContainerOutDir = "/output"
ContainerDockerAuth = "/root/.docker/config.json"
ContainerEntryFile = "docker-entrypoint.sh"
ContainerEntryFilePath = "/" + ContainerEntryFile
)
type Builder struct {
OptionFile string
option.BuildOptions
Username, Password string
repository string
tempDir string
dockerEntrypoint string
uid, gid string
manualClean bool
containerID string
containerConf types.ContainerCreateConfig
dockerCli *client.Client
w io.Writer
sig chan os.Signal // watch interrupt
stop chan struct{} // stop the build process when an interruption occurs
done chan struct{} // signal that the build process is finished
utils.Debugger
*utils.YesOrNoPrinter
}
func NewBuilder(f ConfigFunc) (*Builder, error) {
b := new(Builder)
if err := b.config(f); err != nil {
return nil, err
}
return b, nil
}
func NewCommand() *cobra.Command {
var bld Builder
v := viper.New()
buildCmd := &cobra.Command{
Use: "build",
Aliases: []string{"bld", "b"},
Short: "Build Golang WASM plugin",
Example: ` # If the option.yaml file exists in the current path, do the following:
hgctl plugin build
# Using "--model(-s)" to specify the WASM plugin configuration structure name, e.g. "HelloWorldConfig"
hgctl plugin build --model HelloWorldConfig
# Using "--output-type(-t)" and "--output-dest(-d)" to push the build products as an OCI image to the specified repository
docker login
hgctl plugin build -s BasicAuthConfig -t image -d docker.io/<your_username>/<your_image>
`,
PreRun: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(bld.config(func(b *Builder) error {
return b.parseOptions(v, cmd)
}))
},
Run: func(cmd *cobra.Command, args []string) {
cmdutil.CheckErr(bld.Build())
},
}
bld.bindFlags(v, buildCmd.PersistentFlags())
return buildCmd
}
func (b *Builder) bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
option.AddOptionFileFlag(&b.OptionFile, flags)
flags.StringVarP(&b.Username, "username", "u", "", "Username for pushing image to the docker repository")
flags.StringVarP(&b.Password, "password", "p", "", "Password for pushing image to the docker repository")
v.BindPFlags(flags)
// this binding ensures that flags explicitly set on the command line have the
// highest priority, and if they are not set, they are read from the configuration file.
flags.StringP("builder-go", "g", DefaultBuilderGo, "Golang version in the official builder image")
v.BindPFlag("build.builder.go", flags.Lookup("builder-go"))
v.SetDefault("build.builder.go", DefaultBuilderGo)
flags.StringP("builder-tinygo", "n", DefaultBuilderTinyGo, "TinyGo version in the official builder image")
v.BindPFlag("build.builder.tinygo", flags.Lookup("builder-tinygo"))
v.SetDefault("build.builder.tinygo", DefaultBuilderTinyGo)
flags.StringP("builder-oras", "r", DefaultBuilderOras, "ORAS version in official the builder image")
v.BindPFlag("build.builder.oras", flags.Lookup("builder-oras"))
v.SetDefault("build.builder.oras", DefaultBuilderOras)
flags.StringP("input", "i", "./", "Directory of the WASM plugin project to be built")
v.BindPFlag("build.input", flags.Lookup("input"))
v.SetDefault("build.input", "./")
flags.StringP("output-type", "t", "files", "Output type of the build products. [files, image]")
v.BindPFlag("build.output.type", flags.Lookup("output-type"))
v.SetDefault("build.output.type", "files")
flags.StringP("output-dest", "d", "./out", "Output destination of the build products")
v.BindPFlag("build.output.dest", flags.Lookup("output-dest"))
v.SetDefault("build.output.dest", "./out")
flags.StringP("docker-auth", "a", "~/.docker/config.json", "Authentication configuration for pushing image to the docker repository")
v.BindPFlag("build.docker-auth", flags.Lookup("docker-auth"))
v.SetDefault("build.docker-auth", "~/.docker/config.json")
flags.StringP("model-dir", "m", "./", "Directory of the WASM plugin configuration structure")
v.BindPFlag("build.model-dir", flags.Lookup("model-dir"))
v.SetDefault("build.model-dir", "./")
flags.StringP("model", "s", "", "Structure name of the WASM plugin configuration")
v.BindPFlag("build.model", flags.Lookup("model"))
v.SetDefault("build.model", "PluginConfig")
flags.BoolP("debug", "", false, "Enable debug mode")
v.BindPFlag("build.debug", flags.Lookup("debug"))
v.SetDefault("build.debug", false)
}
func (b *Builder) Build() (err error) {
b.Debugf("build options: \n%s\n", b.String())
go func() {
err = b.doBuild()
}()
// wait for an interruption to occur or finishing the build
select {
case <-b.sig:
b.interrupt()
b.Nof("\nInterrupt ...\n")
// wait for the doBuild process to exit, otherwise there will be unexpected bugs
b.waitForFinished()
// if the build process is interrupted, then we ignore the flag `manualClean` and clean up
// TODO(WeixinX): How do we clean up uploaded image when an interruption occurs?
b.Debugln("clean up for interrupting ...")
b.CleanupForError()
os.Exit(0)
case <-b.done:
if err != nil {
if !b.manualClean {
b.Debugln("clean up for error ...")
b.CleanupForError()
}
return
}
if !b.manualClean {
b.Debugln("clean up for normal ...")
b.Cleanup()
}
}
return
}
var (
waitIcon = "[-]"
successfulIcon = "[√]"
)
func (b *Builder) doBuild() (err error) {
// finish here does not mean that the build was successful,
// but that the doBuild process is complete
defer b.finish()
if err = b.generateMetadata(); err != nil {
return errors.Wrap(err, "failed to generate wasm plugin metadata files")
}
b.Printf("%s pull the builder image ...\n", waitIcon)
ctx := context.TODO()
if err = b.imagePull(ctx); err != nil {
return errors.Wrapf(err, "failed to pull the builder image %s", b.builderImageRef())
}
b.Yesf("%s pull the builder image: %s\n", successfulIcon, b.builderImageRef())
if err = b.addContainerConfByOutType(); err != nil {
return errors.Wrapf(err, "failed to add the additional container configuration for output type %q", b.Output.Type)
}
b.Printf("%s create the builder container ...\n", waitIcon)
if err = b.containerCreate(ctx); err != nil {
return errors.Wrap(err, "failed to create the builder container")
}
b.Yesf("%s create the builder container: %s\n", successfulIcon, b.containerID)
b.Printf("%s start the builder container ...\n", waitIcon)
if err = b.containerStart(ctx); err != nil {
return errors.Wrap(err, "failed to start the builder container")
}
if b.Output.Type == "files" {
b.Yesf("%s finish building!\n", successfulIcon)
} else if b.Output.Type == "image" {
b.Yesf("%s finish building and pushing!\n", successfulIcon)
}
return nil
}
var errBuildAbort = errors.New("build aborted")
func (b *Builder) generateMetadata() error {
// spec.yaml
if b.isInterrupted() {
return errBuildAbort
}
spec, err := os.Create(b.SpecYAMLPath())
if err != nil {
return err
}
defer spec.Close()
meta, err := ptypes.ParseGoSrc(b.ModelDir, b.Model)
if err != nil {
return err
}
if err = utils.MarshalYamlWithIndentTo(spec, meta, 2); err != nil {
return err
}
// TODO(WeixinX): More languages need to be supported
// README.md is required, README_{lang}.md is optional
if b.isInterrupted() {
return errBuildAbort
}
usages, err := meta.GetUsages()
if err != nil {
return errors.Wrap(err, "failed to get wasm usage")
}
for i, u := range usages {
// since `usages` are ordered by `I18nType` and currently only `en-US` and
// `zh-CN` are available, en-US is the default README.md language when en-US is
// present (because after sorting it is in the first place)
suffix := true
if i == 0 {
suffix = false
}
if err = genMarkdownUsage(&u, b.tempDir, suffix); err != nil {
return err
}
}
return nil
}
func (b *Builder) imagePull(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
r, err := b.dockerCli.ImagePull(ctx, b.builderImageRef(), types.ImagePullOptions{})
if err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
io.Copy(b.w, r)
return nil
}
func (b *Builder) addContainerConfByOutType() error {
if b.isInterrupted() {
return errBuildAbort
}
var err error
switch b.Output.Type {
case "files":
err = b.filesHandler()
case "image":
err = b.imageHandler()
default:
return errors.New("invalid output option, output type is unknown")
}
if err != nil {
return err
}
return nil
}
func (b *Builder) containerCreate(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
resp, err := b.dockerCli.ContainerCreate(ctx, b.containerConf.Config, b.containerConf.HostConfig,
b.containerConf.NetworkingConfig, b.containerConf.Platform, b.containerConf.Name)
if err != nil {
return err
}
b.containerID = resp.ID
return nil
}
func (b *Builder) containerStart(ctx context.Context) error {
if b.isInterrupted() {
return errBuildAbort
}
if err := b.dockerCli.ContainerStart(ctx, b.containerID, types.ContainerStartOptions{}); err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
statusCh, errCh := b.dockerCli.ContainerWait(ctx, b.containerID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
return err
}
case <-statusCh:
}
if b.isInterrupted() {
return errBuildAbort
}
logs, err := b.dockerCli.ContainerLogs(ctx, b.containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
return err
}
if b.isInterrupted() {
return errBuildAbort
}
_, err = stdcopy.StdCopy(b.w, b.w, logs)
if err != nil {
return err
}
return nil
}
var errWriteDockerEntrypoint = errors.New("failed to write docker entrypoint")
func (b *Builder) filesHandler() error {
b.containerConf.HostConfig.Mounts = append(b.containerConf.HostConfig.Mounts, mount.Mount{
// output dir for the build products
Type: mount.TypeBind,
Source: b.Output.Dest,
Target: ContainerOutDir,
})
ft := &FilesTmplFields{
BuildSrcDir: ContainerWorkDir,
BuildDestDir: ContainerTempDir,
Output: ContainerOutDir,
UID: b.uid,
GID: b.uid,
Debug: b.Debug,
}
if err := genFilesDockerEntrypoint(ft, b.dockerEntrypoint); err != nil {
return errors.Wrap(err, errWriteDockerEntrypoint.Error())
}
return nil
}
var (
optionalProducts = [][2]string{
{"README_ZH.md", MediaTypeREADME_ZH},
{"README_EN.md", MediaTypeREADME_EN},
{"icon.png", MediaTypeIcon},
}
)
// TODO(WeixinX): If the image exists, no push is performed
func (b *Builder) imageHandler() error {
products := ""
for i, p := range optionalProducts {
fileName := p[0]
mediaType := p[1]
if i == 0 {
products = fmt.Sprintf("%s %s", fileName, mediaType)
} else {
products = fmt.Sprintf("%s %s %s", products, fileName, mediaType)
}
}
// spec.yaml, README.md and plugin.tar.gz are required
basicCmd := fmt.Sprintf("oras push %s -u %s -p %s ./spec.yaml:%s ./README.md:%s",
b.Output.Dest, b.Username, b.Password, MediaTypeSpec, MediaTypeREADME)
if b.Username == "" || b.Password == "" {
basicCmd = fmt.Sprintf("oras push %s ./spec.yaml:%s ./README.md:%s",
b.Output.Dest, MediaTypeSpec, MediaTypeREADME)
b.containerConf.HostConfig.Mounts = append(b.containerConf.HostConfig.Mounts, mount.Mount{
// docker auth
Type: mount.TypeBind,
Source: b.DockerAuth,
Target: ContainerDockerAuth,
})
}
it := &ImageTmplFields{
BuildSrcDir: ContainerWorkDir,
BuildDestDir: ContainerTempDir,
Output: ContainerOutDir,
Username: b.Username,
Password: b.Password,
BasicCmd: basicCmd,
Products: products,
MediaTypePlugin: MediaTypePlugin,
Debug: b.Debug,
}
if err := genImageDockerEntrypoint(it, b.dockerEntrypoint); err != nil {
return errors.Wrap(err, errWriteDockerEntrypoint.Error())
}
return nil
}
// ConfigFunc is customized to set the fields of Builder
type ConfigFunc func(b *Builder) error
func (b *Builder) config(f ConfigFunc) (err error) {
if err = f(b); err != nil {
return err
}
// builder-go
b.Builder.Go = strings.TrimSpace(b.Builder.Go)
if b.Builder.Go == "" {
b.Builder.Go = DefaultBuilderGo
}
// builder-tinygo
b.Builder.TinyGo = strings.TrimSpace(b.Builder.TinyGo)
if b.Builder.TinyGo == "" {
b.Builder.TinyGo = DefaultBuilderTinyGo
}
// builder-oras
b.Builder.Oras = strings.TrimSpace(b.Builder.Oras)
if b.Builder.Oras == "" {
b.Builder.Oras = DefaultBuilderOras
}
// input
b.Input = strings.TrimSpace(b.Input)
if b.Input == "" {
b.Input = "./"
}
inp, err := utils.GetAbsolutePath(b.Input)
if err != nil {
return errors.Wrapf(err, "failed to parse input option %q", b.Input)
}
b.Input = inp
// output-type
b.Output.Type = strings.ToLower(strings.TrimSpace(b.Output.Type))
if b.Output.Type == "" {
b.Output.Type = "files"
}
if b.Output.Type != "files" && b.Output.Type != "image" {
return errors.Errorf("invalid output type: %q, must be `files` or `image`", b.Output.Type)
}
// output-dest
b.Output.Dest = strings.TrimSpace(b.Output.Dest)
if b.Output.Dest == "" {
b.Output.Dest = "./out"
}
out := b.Output.Dest
if b.Output.Type == "files" {
out, err = utils.GetAbsolutePath(b.Output.Dest)
if err != nil {
return errors.Wrapf(err, "failed to parse output destination %q", b.Output.Dest)
}
err = os.MkdirAll(b.Output.Dest, 0755)
if err != nil && !os.IsExist(err) {
return errors.Wrapf(err, "failed to create output destination %q", b.Output.Dest)
}
}
b.Output.Dest = out
// docker-auth
b.DockerAuth = strings.TrimSpace(b.DockerAuth)
if b.DockerAuth == "" {
b.DockerAuth = "~/.docker/config.json"
}
auth, err := utils.GetAbsolutePath(b.DockerAuth)
if err != nil {
return errors.Wrapf(err, "failed to parse docker authentication %q", b.DockerAuth)
}
b.DockerAuth = auth
// model-dir
b.ModelDir = strings.TrimSpace(b.ModelDir)
if b.ModelDir == "" {
b.ModelDir = "./"
}
// option-file/username/password/model/debug: nothing to deal with
// the unexported fields that users do not need to care about are as follows:
b.repository = DefaultBuilderRepository
b.tempDir, err = os.MkdirTemp("", HostTempDirPattern)
if err != nil && !os.IsExist(err) {
return errors.Wrap(err, "failed to create the host temporary dir")
}
dockerEp, err := os.CreateTemp("", HostDockerEntryPattern)
if err != nil && !os.IsExist(err) {
return errors.Wrap(err, "failed to create the docker entrypoint file")
}
err = dockerEp.Chmod(0777)
if err != nil {
return err
}
b.dockerEntrypoint = dockerEp.Name()
dockerEp.Close()
u, err := user.Current()
if err != nil {
return errors.Wrap(err, "failed to get the current user information")
}
b.uid, b.gid = u.Uid, u.Gid
b.containerConf = types.ContainerCreateConfig{
Name: "higress-wasm-go-builder",
Config: &container.Config{
Image: b.builderImageRef(),
Env: []string{
"GO111MODULE=on",
"GOPROXY=https://goproxy.cn,direct",
},
WorkingDir: ContainerWorkDir,
Entrypoint: []string{ContainerEntryFilePath},
},
HostConfig: &container.HostConfig{
NetworkMode: "host",
Mounts: []mount.Mount{
{ // input dir that includes the wasm plugin source: main.go ...
Type: mount.TypeBind,
Source: b.Input,
Target: ContainerWorkDir,
},
{ // temp dir that includes the wasm plugin metadata: spec.yaml and README.md ...
Type: mount.TypeBind,
Source: b.tempDir,
Target: ContainerTempDir,
},
{ // entrypoint
Type: mount.TypeBind,
Source: b.dockerEntrypoint,
Target: ContainerEntryFilePath,
},
},
},
}
if b.dockerCli == nil {
b.dockerCli, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return errors.Wrap(err, "failed to initialize the docker client")
}
}
if b.w == nil {
b.w = os.Stdout
}
signalNotify(b)
if b.Debugger == nil {
b.Debugger = utils.NewDefaultDebugger(b.Debug, b.w)
}
if b.YesOrNoPrinter == nil {
b.YesOrNoPrinter = utils.NewPrinter(b.w, utils.DefaultIdent, utils.DefaultYes, utils.DefaultNo)
}
return nil
}
func (b *Builder) parseOptions(v *viper.Viper, cmd *cobra.Command) error {
allOpt, err := option.ParseOptions(b.OptionFile, v, cmd.PersistentFlags())
if err != nil {
return err
}
b.BuildOptions = allOpt.Build
b.w = cmd.OutOrStdout()
return nil
}
func (b *Builder) finish() {
select {
case <-b.done:
default:
close(b.done)
}
}
func (b *Builder) waitForFinished() {
<-b.done
}
func (b *Builder) interrupt() {
select {
case <-b.stop:
default:
close(b.stop)
}
}
func (b *Builder) isInterrupted() bool {
if b.stop == nil {
return true
}
select {
case <-b.stop:
return true
default:
return false
}
}
// WithManualClean if set this option, then the temporary files and the container
// will not be cleaned up automatically, and you need to clean up manually
func (b *Builder) WithManualClean() {
b.manualClean = true
}
func (b *Builder) WithWriter(w io.Writer) {
b.w = w
}
// CleanupForError cleans up the temporary files and the container when an error occurs
func (b *Builder) CleanupForError() {
b.Cleanup()
b.removeOutputDest()
}
// Cleanup cleans up the temporary files and the container
func (b *Builder) Cleanup() {
b.removeTempDir()
b.removeDockerEntrypoint()
b.removeBuilderContainer()
b.closeDockerCli()
}
func (b *Builder) removeOutputDest() {
if b.BuildOptions.Output.Type == "files" {
b.Debugf("remove output destination %q\n", b.BuildOptions.Output.Dest)
os.RemoveAll(b.BuildOptions.Output.Dest)
}
}
func (b *Builder) removeTempDir() {
if b.tempDir != "" {
b.Debugf("remove temporary directory %q\n", b.tempDir)
os.RemoveAll(b.tempDir)
}
}
func (b *Builder) removeDockerEntrypoint() {
if b.dockerEntrypoint != "" {
b.Debugf("delete docker entrypoint %q\n", b.dockerEntrypoint)
os.Remove(b.dockerEntrypoint)
}
}
func (b *Builder) removeBuilderContainer() {
if b.containerID != "" {
err := b.dockerCli.ContainerRemove(context.TODO(), b.containerID, types.ContainerRemoveOptions{Force: true})
if err != nil {
b.Debugf("failed to remove container (%s): %s\n", b.containerConf.Name, b.containerID)
} else {
b.Debugf("remove container (%s): %s\n", b.containerConf.Name, b.containerID)
}
}
}
func (b *Builder) closeDockerCli() {
if b.dockerCli != nil {
b.Debugln("close the docker client")
b.dockerCli.Close()
}
}
func (b *Builder) builderImageRef() string {
return fmt.Sprintf("%s:go%s-tinygo%s-oras%s", b.repository, b.Builder.Go, b.Builder.TinyGo, b.Builder.Oras)
}
func (b *Builder) SpecYAMLPath() string {
return fmt.Sprintf("%s/spec.yaml", b.tempDir)
}
func (b *Builder) TempDir() string {
return b.tempDir
}
func (b *Builder) String() string {
by, err := json.MarshalIndent(b, "", " ")
if err != nil {
return ""
}
return string(by)
}