feat(installer): build worker source over SSH
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Этот коммит содержится в:
@@ -3,6 +3,7 @@ package sshinstall
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,9 +16,25 @@ const DefaultGoVersion = "1.26.0"
|
||||
// source credential and is the plan's default clone URL.
|
||||
const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git"
|
||||
|
||||
// DefaultBranch is the branch the source installer checks out and
|
||||
// builds.
|
||||
const DefaultBranch = "main"
|
||||
var (
|
||||
// goVersionPattern bounds Go toolchain version strings that are
|
||||
// interpolated into remote shell commands and download URLs.
|
||||
goVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z._-]*$`)
|
||||
// goArchPattern bounds Go download archive suffixes (e.g. "amd64")
|
||||
// that are interpolated into remote shell commands and URLs.
|
||||
goArchPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z_-]*$`)
|
||||
)
|
||||
|
||||
// ValidGoVersion reports whether a Go toolchain version uses only safe
|
||||
// characters (digits, letters, dots, dashes, underscores) and starts
|
||||
// with a digit. Versions are embedded in remote shell commands and
|
||||
// download URLs, so the charset is the injection boundary.
|
||||
func ValidGoVersion(v string) bool { return goVersionPattern.MatchString(v) }
|
||||
|
||||
// ValidGoArch reports whether a Go download archive suffix uses only
|
||||
// safe characters. Suffixes are embedded in remote shell commands and
|
||||
// download URLs, so the charset is the injection boundary.
|
||||
func ValidGoArch(a string) bool { return goArchPattern.MatchString(a) }
|
||||
|
||||
// Toolchain is a pinned, checksum-verified Go toolchain download for a
|
||||
// remote Linux architecture. The SHA-256 is baked for the default
|
||||
@@ -117,7 +134,7 @@ func ToolchainFor(goarch, version string) (Toolchain, error) {
|
||||
// back to the pinned defaults.
|
||||
type SourceOptions struct {
|
||||
Repo string // clone URL; default DefaultRepo
|
||||
Branch string // default DefaultBranch
|
||||
Branch string // branch to build; empty means "the remote default branch"
|
||||
GoVersion string // default DefaultGoVersion
|
||||
GoArch string // go archive suffix; when empty, derived from UnameM
|
||||
UnameM string // remote `uname -m` output; required unless GoArch set
|
||||
@@ -147,6 +164,9 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
|
||||
return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID)
|
||||
}
|
||||
goarch := strings.TrimSpace(opts.GoArch)
|
||||
if goarch != "" && !ValidGoArch(goarch) {
|
||||
return SourcePlan{}, fmt.Errorf("invalid Go architecture %q: only letters, digits, dashes, and underscores are allowed", goarch)
|
||||
}
|
||||
if goarch == "" {
|
||||
var err error
|
||||
goarch, err = GoArch(opts.UnameM)
|
||||
@@ -154,6 +174,9 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
|
||||
return SourcePlan{}, err
|
||||
}
|
||||
}
|
||||
if version := strings.TrimSpace(opts.GoVersion); version != "" && !ValidGoVersion(version) {
|
||||
return SourcePlan{}, fmt.Errorf("invalid Go version %q: only digits, letters, dots, dashes, and underscores are allowed", version)
|
||||
}
|
||||
toolchain, err := ToolchainFor(goarch, opts.GoVersion)
|
||||
if err != nil {
|
||||
return SourcePlan{}, err
|
||||
@@ -166,9 +189,10 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
|
||||
return SourcePlan{}, err
|
||||
}
|
||||
branch := strings.TrimSpace(opts.Branch)
|
||||
if branch == "" {
|
||||
branch = DefaultBranch
|
||||
}
|
||||
// An empty branch means "build the remote's default branch" (the
|
||||
// public repo currently publishes master). The executor resolves and
|
||||
// records the remote default; a non-empty branch is pinned and must
|
||||
// exist on the remote.
|
||||
buildDir := strings.TrimSpace(opts.BuildDir)
|
||||
if buildDir == "" {
|
||||
buildDir = "/opt/rsmon-worker-src"
|
||||
@@ -206,9 +230,19 @@ func packagePrereqs(pkg PackageManager) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// validateRepoURL rejects repository references that could smuggle a
|
||||
// command or a non-remote scheme into the clone step. Only http(s) and
|
||||
// the git protocol are accepted; the default repository is https.
|
||||
// ValidateRepoURL rejects repository references that could smuggle a
|
||||
// command or a non-remote scheme into the clone step. Only https is
|
||||
// accepted (the default repository is https), and userinfo such as
|
||||
// `user:pass@host` is rejected so credentials can never reach the remote
|
||||
// clone command or the clone's config. An empty value is accepted here
|
||||
// (it falls back to the default repository when planning).
|
||||
func ValidateRepoURL(repo string) error {
|
||||
if repo == "" {
|
||||
return nil
|
||||
}
|
||||
return validateRepoURL(repo)
|
||||
}
|
||||
|
||||
func validateRepoURL(repo string) error {
|
||||
if strings.ContainsAny(repo, "\r\n\t ") {
|
||||
return fmt.Errorf("repository URL %q contains whitespace", repo)
|
||||
@@ -217,12 +251,13 @@ func validateRepoURL(repo string) error {
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("repository URL %q is not an absolute clone URL", repo)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https", "http", "git":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("repository URL %q uses unsupported scheme %q", repo, u.Scheme)
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("repository URL %q must not contain userinfo", repo)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return fmt.Errorf("repository URL %q must use the https scheme", repo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StepKind identifies one ordered remote step the executor will run.
|
||||
@@ -248,11 +283,15 @@ type Step struct {
|
||||
// Steps returns the ordered source-install plan as stable, reviewable
|
||||
// steps. It is the contract the executor work package implements.
|
||||
func (p SourcePlan) Steps() []Step {
|
||||
checkoutDetail := "check out branch " + p.Branch + " and record the resolved commit"
|
||||
if p.Branch == "" {
|
||||
checkoutDetail = "check out the remote default branch and record the resolved commit"
|
||||
}
|
||||
return []Step{
|
||||
{Kind: StepInstallPackages, Detail: "install minimal build prerequisites", Packages: p.Packages},
|
||||
{Kind: StepInstallToolchain, Detail: "install pinned Go " + p.Toolchain.Version + " (" + p.Toolchain.Arch + ") and verify SHA-256"},
|
||||
{Kind: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir},
|
||||
{Kind: StepCheckoutBranch, Detail: "check out branch " + p.Branch + " and record the resolved commit"},
|
||||
{Kind: StepCheckoutBranch, Detail: checkoutDetail},
|
||||
{Kind: StepBuildWorker, Detail: "build the worker binary with CGO_ENABLED=0 and trimpath"},
|
||||
{Kind: StepInstallService, Detail: "atomically install the binary, env, data dir, and " + string(p.InitSystem) + " service definition"},
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestPlanSourceAlpine(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Repo != DefaultRepo || p.Branch != DefaultBranch {
|
||||
if p.Repo != DefaultRepo || p.Branch != "" {
|
||||
t.Fatalf("plan defaults wrong: %+v", p)
|
||||
}
|
||||
if p.Toolchain.Arch != "linux-amd64" {
|
||||
@@ -230,14 +230,45 @@ func TestPackagePrereqsNeverIncludeCompiler(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRepoURLSchemes(t *testing.T) {
|
||||
for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "http://x/y", "git://example.test/r"} {
|
||||
for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "https://example.test/r"} {
|
||||
if err := validateRepoURL(ok); err != nil {
|
||||
t.Fatalf("validateRepoURL(%q): %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"ssh://h@x/r", "s3://bucket/key", "x y", ""} {
|
||||
for _, bad := range []string{
|
||||
"ssh://h@x/r", "s3://bucket/key", "x y", "", "http://x/y", "git://example.test/r",
|
||||
"https://user:pass@example.test/r", "https://token@example.test/r", "file:///tmp/r",
|
||||
} {
|
||||
if err := validateRepoURL(bad); err == nil {
|
||||
t.Fatalf("validateRepoURL(%q) succeeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceRejectsUnsafeCharset(t *testing.T) {
|
||||
ubuntu := Detect("ID=ubuntu\n", nil)
|
||||
for _, goarch := range []string{"amd64;rm", "x;rm -rf", "$(id)", "..", "a b"} {
|
||||
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", GoArch: goarch}); err == nil {
|
||||
t.Fatalf("unsafe GoArch %q planned", goarch)
|
||||
}
|
||||
}
|
||||
for _, version := range []string{"1.26;rm", "$(id)", "1.26.0 x", "a/b"} {
|
||||
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", GoVersion: version}); err == nil {
|
||||
t.Fatalf("unsafe GoVersion %q planned", version)
|
||||
}
|
||||
}
|
||||
if !ValidGoVersion("1.26.0") || !ValidGoArch("amd64") {
|
||||
t.Fatal("valid version/arch rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceEmptyBranchSteps(t *testing.T) {
|
||||
ubuntu := Detect("ID=ubuntu\n", nil)
|
||||
p, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(p.Steps()[3].Detail, "remote default branch") {
|
||||
t.Fatalf("checkout step detail for empty branch = %q", p.Steps()[3].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user