Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ type OperatorConfig struct {

// command for running the operator, just for local operator, default is "make deploy"
Command string `yaml:"command,omitempty"`

// Violet configures how the operatorhub flow invokes the external `violet`
// binary to create Artifact / ArtifactVersion CRs. When nil, the legacy
// in-process path is used (kept for the local operator and for transitional
// builds; will be removed once violet path is the only one).
Violet *VioletConfig `yaml:"violet,omitempty"`
}

// VioletConfig captures everything upgrade CLI needs to invoke `violet push`
// for the operatorhub onboarding flow.
type VioletConfig struct {
// Bin is an optional absolute path to the violet binary. Empty falls back
// to looking up `violet` in $PATH.
Bin string `yaml:"bin,omitempty"`

// PackagePrefix is the MinIO (or HTTP) root from which the per-version
// .tgz is downloaded. Default: "http://package-minio.alauda.cn:9199/packages/".
PackagePrefix string `yaml:"packagePrefix,omitempty"`

// SkipPush controls whether `--skip-push` is passed to `violet push`.
// Pointer so we can distinguish "unset" (treated as true) from "explicit
// false" (private-registry scenario that wants violet to also push images).
SkipPush *bool `yaml:"skipPush,omitempty"`

// PushArgs are extra arguments appended verbatim to `violet push`, used to
// inject options such as --dest-repo, --plain, --image-pull-secret, --force
// in private-registry scenarios. Credentials must come from environment
// variables (VIOLET_REGISTRY_USERNAME / VIOLET_REGISTRY_PASSWORD), not here.
PushArgs []string `yaml:"pushArgs,omitempty"`
}

// UpgradePath represents a single upgrade path
Expand All @@ -71,8 +100,16 @@ type Version struct {
TestSubPath string `yaml:"testSubPath,omitempty"`
// revision is the revision to use for the version
Channel string `yaml:"channel,omitempty"`
// ExpectedSha256, when non-empty, is the lowercase hex SHA-256 of the
// downloaded .tgz. The upgrade CLI verifies the digest after download and
// fails fast on mismatch. Optional; leave empty to skip verification.
ExpectedSha256 string `yaml:"expectedSha256,omitempty"`
}

// DefaultVioletPackagePrefix is the MinIO root used when OperatorConfig.Violet
// is configured but its PackagePrefix is left blank.
const DefaultVioletPackagePrefix = "http://package-minio.alauda.cn:9199/packages/"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不要硬编码在代码里


// LoadConfig loads the configuration from a YAML file
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
Expand Down Expand Up @@ -108,5 +145,15 @@ func defaultConfig(config *Config) *Config {
config.OperatorConfig.Timeout = 10 * time.Minute
}

if v := config.OperatorConfig.Violet; v != nil {
if v.PackagePrefix == "" {
v.PackagePrefix = DefaultVioletPackagePrefix
}
if v.SkipPush == nil {
t := true
v.SkipPush = &t
}
}

return config
}
91 changes: 80 additions & 11 deletions pkg/exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@ package exec
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
)

type Command struct {
Name string
Args []string
Dir string
Env []string

// EnvAllowlist limits which host environment variables are passed to the child
// process. Entries support an exact name ("KUBECONFIG") or a "PREFIX_*" pattern
// ("VIOLET_*"). When EnvAllowlist is empty, the child inherits os.Environ() in
// full (backward-compatible default). Env is always appended after the filtered
// host set.
EnvAllowlist []string
}

// CommandResult represents the result of a command execution
Expand All @@ -37,17 +46,19 @@ func (c *Command) WithEnv(env []string) CommandOption {
}
}

// stderrTailLines bounds how many trailing stderr lines are wrapped into the error.
const stderrTailLines = 20

// RunCommand executes a command and returns its stdout, stderr and error
// If the command fails, it will return the error along with the captured output
// The command's output will be printed to console in real-time while also being captured
func RunCommand(ctx context.Context, cmd Command) CommandResult {
runCmd := exec.CommandContext(ctx, cmd.Name, cmd.Args...)
runCmd.Dir = cmd.Dir

// Inherit current process environment variables
runCmd.Env = os.Environ()

// Add custom environment variables if specified
// Build child environment: allowlist-filtered host env (or full passthrough when empty),
// then append custom Env entries.
runCmd.Env = filterHostEnv(cmd.EnvAllowlist)
if len(cmd.Env) > 0 {
runCmd.Env = append(runCmd.Env, cmd.Env...)
}
Expand All @@ -65,16 +76,74 @@ func RunCommand(ctx context.Context, cmd Command) CommandResult {
// Run the command
err := runCmd.Run()
if err != nil {
return CommandResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
Err: err,
}
err = wrapWithStderrTail(err, stderrBuf.String(), stderrTailLines)
}

return CommandResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
Err: nil,
Err: err,
}
}

// filterHostEnv returns os.Environ() filtered by allowlist. An empty allowlist
// returns the full os.Environ() (existing behaviour preserved).
func filterHostEnv(allowlist []string) []string {
host := os.Environ()
if len(allowlist) == 0 {
return host
}
out := make([]string, 0, len(host))
for _, entry := range host {
eq := strings.IndexByte(entry, '=')
if eq < 0 {
continue
}
name := entry[:eq]
if matchAllowlist(name, allowlist) {
out = append(out, entry)
}
}
return out
}

// matchAllowlist reports whether name matches any pattern in allowlist. A pattern
// ending in "*" matches by prefix; anything else is an exact match.
func matchAllowlist(name string, allowlist []string) bool {
for _, pat := range allowlist {
if strings.HasSuffix(pat, "*") {
if strings.HasPrefix(name, strings.TrimSuffix(pat, "*")) {
return true
}
continue
}
if name == pat {
return true
}
}
return false
}

// wrapWithStderrTail enriches err with the trailing stderr lines so callers see
// the actionable failure context without re-reading CommandResult.Stderr.
func wrapWithStderrTail(err error, stderr string, maxLines int) error {
tail := lastLines(stderr, maxLines)
if tail == "" {
return err
}
return fmt.Errorf("%w; stderr tail:\n%s", err, tail)
}

func lastLines(s string, n int) string {
if s == "" || n <= 0 {
return ""
}
trimmed := strings.TrimRight(s, "\n")
if trimmed == "" {
return ""
}
lines := strings.Split(trimmed, "\n")
if len(lines) <= n {
return trimmed
}
return strings.Join(lines[len(lines)-n:], "\n")
}
5 changes: 5 additions & 0 deletions pkg/operator/operatorhub/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type Operator struct {

const (
systemNamespace = "cpaas-system"
// targetCatalogSource is the OLM catalog source the operatorhub flow targets.
// It is referenced both in CR creation (via violet --target-catalog-source)
// and by the Subscription spec, so it lives next to systemNamespace to keep
// platform-coupled constants in one place.
targetCatalogSource = "platform"
)

var (
Expand Down
116 changes: 116 additions & 0 deletions pkg/operator/operatorhub/violet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package operatorhub

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)

// Environment variables consumed when assembling `violet push`. They are
// intentionally not part of OperatorConfig: pipelines inject them as secrets
// and the upgrade CLI must not persist them to disk or echo them back.
const (
EnvVioletRegistryUsername = "VIOLET_REGISTRY_USERNAME"
EnvVioletRegistryPassword = "VIOLET_REGISTRY_PASSWORD"
)

// violet CLI flag names. Kept private so the consuming code stays expressive
// (`flagSkipPush` rather than the raw string everywhere).
const (
flagSkipPush = "--skip-push"
flagTargetCatalogSource = "--target-catalog-source"
flagUsername = "--username"
flagPassword = "--password"
)

// BuildPackageURL composes the .tgz URL by the agreed MinIO convention:
//
// <prefix>/<name>/<channel>/<name>.latest.ALL.<bundleVersion>.tgz
//
// A trailing slash on prefix is tolerated. All four inputs are required; any
// empty value returns an error so callers fail loudly instead of producing a
// malformed URL that 404s later.
func BuildPackageURL(prefix, name, channel, bundleVersion string) (string, error) {
switch {
case prefix == "":
return "", fmt.Errorf("packagePrefix is empty")
case name == "":
return "", fmt.Errorf("operator name is empty")
case channel == "":
return "", fmt.Errorf("channel is empty (Version.Channel is required when using violet)")
case bundleVersion == "":
return "", fmt.Errorf("bundleVersion is empty")
}
p := strings.TrimRight(prefix, "/")
return fmt.Sprintf("%s/%s/%s/%s.latest.ALL.%s.tgz", p, name, channel, name, bundleVersion), nil
}

// BuildVioletPushArgs assembles the argv for `violet push <tgz>`. Credentials
// are read from EnvVioletRegistryUsername / EnvVioletRegistryPassword and
// injected as --username / --password when non-empty; pushArgs is appended
// verbatim. The function is a pure transform — it never touches the filesystem
// or starts a process — so callers can table-test the exact argv shape.
//
// The decision of skipPush belongs to the caller (it has already deferenced
// VioletConfig.SkipPush and applied the "nil == true" default in config), so
// this function takes a plain bool.
func BuildVioletPushArgs(tgzPath string, skipPush bool, pushArgs []string) []string {
args := []string{"push", tgzPath, flagTargetCatalogSource, targetCatalogSource}
if skipPush {
args = append(args, flagSkipPush)
}
if u := os.Getenv(EnvVioletRegistryUsername); u != "" {
args = append(args, flagUsername, u)
}
if p := os.Getenv(EnvVioletRegistryPassword); p != "" {
args = append(args, flagPassword, p)
}
args = append(args, pushArgs...)
return args
}

// MaskCommand renders the command for logging, replacing the token following
// --password with `***`. This only protects log output — the credential is
// still visible to OS-level inspection (e.g. `ps auxe`) once the child process
// runs. The README must document that risk for shared CI runners.
func MaskCommand(name string, args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, name)
for i := 0; i < len(args); i++ {
if args[i] == flagPassword && i+1 < len(args) {
parts = append(parts, args[i], "***")
i++
continue
}
parts = append(parts, args[i])
}
return strings.Join(parts, " ")
}

// VerifySha256 streams the file at filePath and compares its SHA-256 against
// expected (hex, case-insensitive). An empty expected disables verification —
// callers can pass Version.ExpectedSha256 directly without nil-checking. The
// returned error names the path so failures are actionable without extra
// wrapping at the call site.
func VerifySha256(filePath, expected string) error {
if expected == "" {
return nil
}
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open %s: %w", filePath, err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
actual := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(actual, expected) {
return fmt.Errorf("sha256 mismatch for %s: expected %s, got %s", filePath, expected, actual)
}
return nil
}
Loading
Loading