mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fix prerelease unified-agent release asset lookup
This commit is contained in:
@@ -7865,6 +7865,28 @@ func TestContract_InstallScriptReleaseAssetURLRejectsDevPrereleaseBuild(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_AgentBinaryReleaseAssetURL(t *testing.T) {
|
||||
router := &Router{serverVersion: "v6.0.0-rc.1"}
|
||||
|
||||
got, err := router.agentBinaryReleaseAssetURL("linux-arm64")
|
||||
if err != nil {
|
||||
t.Fatalf("agent binary release asset URL: %v", err)
|
||||
}
|
||||
|
||||
const want = "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-arm64"
|
||||
if got != want {
|
||||
t.Fatalf("agent binary release asset URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_AgentBinaryReleaseAssetURLRejectsDevPrereleaseBuild(t *testing.T) {
|
||||
router := &Router{serverVersion: "v6.0.0-dev"}
|
||||
|
||||
if _, err := router.agentBinaryReleaseAssetURL("linux-arm64"); err == nil {
|
||||
t.Fatalf("expected dev prerelease build to reject release asset lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T) {
|
||||
got := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
|
||||
BaseURL: "http://pulse.example.com:7655/",
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -40,18 +39,10 @@ func installScriptReleaseRepo() string {
|
||||
return repo
|
||||
}
|
||||
|
||||
func githubReleaseDownloadURL(assetName string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", installScriptReleaseRepo(), assetName)
|
||||
}
|
||||
|
||||
func githubReleaseAssetURL(tag, assetName string) string {
|
||||
return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", installScriptReleaseRepo(), strings.TrimSpace(tag), assetName)
|
||||
}
|
||||
|
||||
func githubLatestReleaseAPIURL() string {
|
||||
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", installScriptReleaseRepo())
|
||||
}
|
||||
|
||||
func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) {
|
||||
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript", r.proxyInstallScriptFromGitHub)
|
||||
}
|
||||
@@ -230,25 +221,25 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req
|
||||
log.Warn().Strs("paths", invalidCandidates).Msg("Ignoring stale local unified agent binaries")
|
||||
}
|
||||
|
||||
// In dev mode, never fall through to GitHub releases — the released binary
|
||||
// would lack current fixes. Return a clear 404 with build instructions.
|
||||
if r.serverVersion == "dev" {
|
||||
reason := fmt.Sprintf("Agent binary not found for %q in dev mode.", normalized)
|
||||
if len(invalidCandidates) > 0 {
|
||||
reason = fmt.Sprintf("Local agent binary for %q is stale or incompatible in dev mode:\n %s",
|
||||
normalized,
|
||||
strings.Join(invalidCandidates, "\n "),
|
||||
)
|
||||
}
|
||||
http.Error(w, reason+"\nBuild with:\n GOOS=linux GOARCH=amd64 go build -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: proxy from GitHub releases for the binary
|
||||
// This handles LXC/barebone installations that don't have agent binaries locally.
|
||||
// We proxy instead of redirecting because agents require the X-Checksum-Sha256 header,
|
||||
// which GitHub doesn't provide.
|
||||
if normalized != "" {
|
||||
// Outside published release builds, never fall through to GitHub releases —
|
||||
// that would silently fetch the wrong channel. Return a clear 404 with build
|
||||
// instructions instead.
|
||||
if !isPublishedReleaseAssetVersion(r.serverVersion) {
|
||||
reason := fmt.Sprintf("Agent binary not found for %q in dev mode.", normalized)
|
||||
if len(invalidCandidates) > 0 {
|
||||
reason = fmt.Sprintf("Local agent binary for %q is stale or incompatible in dev mode:\n %s",
|
||||
normalized,
|
||||
strings.Join(invalidCandidates, "\n "),
|
||||
)
|
||||
}
|
||||
http.Error(w, reason+"\nBuild with:\n GOOS=linux GOARCH=amd64 go build -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
r.proxyAgentBinaryFromGitHub(w, req, normalized)
|
||||
return
|
||||
}
|
||||
@@ -281,13 +272,14 @@ func validateUnifiedAgentBinary(path string) error {
|
||||
// We must proxy instead of redirecting because the agent requires the checksum header
|
||||
// for security verification, and GitHub doesn't provide it.
|
||||
func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
|
||||
binaryName := "pulse-agent-" + normalized
|
||||
if strings.HasPrefix(normalized, "windows-") {
|
||||
binaryName += ".exe"
|
||||
githubURL, err := r.agentBinaryReleaseAssetURL(normalized)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("server_version", strings.TrimSpace(r.serverVersion)).Str("arch", normalized).Msg("Agent binary fallback unavailable for current server build")
|
||||
http.Error(w, "Agent binary unavailable for current server build", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
githubURL := githubReleaseDownloadURL(binaryName)
|
||||
signatureURL := githubReleaseDownloadURL(binaryName + ".sig")
|
||||
sshSignatureURL := githubReleaseDownloadURL(binaryName + ".sshsig")
|
||||
signatureURL := githubURL + ".sig"
|
||||
sshSignatureURL := githubURL + ".sshsig"
|
||||
|
||||
log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases")
|
||||
|
||||
@@ -389,7 +381,7 @@ func serveProxiedAgentBinaryWithSignatures(w http.ResponseWriter, content []byte
|
||||
}
|
||||
|
||||
func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normalized string) ([]byte, string, error) {
|
||||
tag, err := fetchLatestReleaseTag(client)
|
||||
tag, err := r.releaseAssetTag()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -438,36 +430,6 @@ func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normali
|
||||
return binary, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func fetchLatestReleaseTag(client *http.Client) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, githubLatestReleaseAPIURL(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "pulse-agent-download-proxy")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("latest release lookup returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("failed decoding latest release payload: %w", err)
|
||||
}
|
||||
tag := strings.TrimSpace(payload.TagName)
|
||||
if tag == "" {
|
||||
return "", fmt.Errorf("latest release payload missing tag_name")
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func isPublishedReleaseAssetVersion(rawVersion string) bool {
|
||||
rawVersion = strings.TrimSpace(rawVersion)
|
||||
if rawVersion == "" || strings.EqualFold(rawVersion, "dev") {
|
||||
@@ -589,13 +551,13 @@ func extractFromZip(archive []byte, entryName string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("binary %q not found in zip", entryName)
|
||||
}
|
||||
|
||||
func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) {
|
||||
func (r *Router) releaseAssetTag() (string, error) {
|
||||
rawVersion := strings.TrimSpace(r.serverVersion)
|
||||
if rawVersion == "" {
|
||||
return "", fmt.Errorf("server version is unavailable")
|
||||
}
|
||||
if strings.EqualFold(rawVersion, "dev") {
|
||||
return "", fmt.Errorf("development builds must serve local install scripts")
|
||||
return "", fmt.Errorf("development builds must serve local assets")
|
||||
}
|
||||
|
||||
version, err := updates.ParseVersion(rawVersion)
|
||||
@@ -606,12 +568,27 @@ func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error)
|
||||
return "", fmt.Errorf("server version %q is not a published release asset version", rawVersion)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"https://github.com/%s/releases/download/v%s/%s",
|
||||
installScriptReleaseRepo(),
|
||||
version.String(),
|
||||
scriptName,
|
||||
), nil
|
||||
return "v" + version.String(), nil
|
||||
}
|
||||
|
||||
func (r *Router) releaseAssetURL(assetName string) (string, error) {
|
||||
tag, err := r.releaseAssetTag()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return githubReleaseAssetURL(tag, assetName), nil
|
||||
}
|
||||
|
||||
func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) {
|
||||
return r.releaseAssetURL(scriptName)
|
||||
}
|
||||
|
||||
func (r *Router) agentBinaryReleaseAssetURL(normalized string) (string, error) {
|
||||
binaryName := "pulse-agent-" + normalized
|
||||
if strings.HasPrefix(normalized, "windows-") {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
return r.releaseAssetURL(binaryName)
|
||||
}
|
||||
|
||||
// proxyInstallScriptFromGitHub fetches an install script from the exact GitHub
|
||||
|
||||
@@ -166,12 +166,13 @@ func TestDownloadUnifiedAgent_LocalReleaseBinaryIncludesSignatureHeader(t *testi
|
||||
|
||||
func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) {
|
||||
router, tempDir := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
stalePath := filepath.Join(tempDir, "bin", "pulse-agent-linux-amd64")
|
||||
require.NoError(t, os.WriteFile(stalePath, staleTestUnifiedAgentBinary("linux-amd64"), 0755))
|
||||
|
||||
binaryContent := "fresh github binary"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64"
|
||||
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
|
||||
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
|
||||
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
|
||||
@@ -210,11 +211,12 @@ func TestDownloadUnifiedAgent_DevModeRejectsStaleLocalBinary(t *testing.T) {
|
||||
|
||||
func TestDownloadUnifiedAgent_ProxyFromGitHub(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
// Ensure NO local files exist (temp dir is empty of binaries)
|
||||
// Set up a mock HTTP client to simulate GitHub response
|
||||
binaryContent := "fake binary content for proxy test"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64"
|
||||
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
|
||||
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
|
||||
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
|
||||
@@ -243,9 +245,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) {
|
||||
t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork")
|
||||
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
binaryContent := "fake binary content for proxy test"
|
||||
expectedURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-linux-amd64"
|
||||
expectedURL := "https://github.com/example/pulse-fork/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64"
|
||||
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
|
||||
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
|
||||
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
|
||||
@@ -263,9 +266,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) {
|
||||
|
||||
func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
binaryContent := "MZ fake windows binary"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-windows-amd64.exe"
|
||||
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-windows-amd64.exe"
|
||||
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
|
||||
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent},
|
||||
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"},
|
||||
@@ -286,13 +290,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) {
|
||||
|
||||
func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v9.9.9"
|
||||
|
||||
binaryContent := []byte("darwin arm64 binary payload")
|
||||
archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent)
|
||||
binaryURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-darwin-arm64"
|
||||
binaryURL := "https://github.com/rcourtman/Pulse/releases/download/v9.9.9/pulse-agent-darwin-arm64"
|
||||
signatureURL := binaryURL + ".sig"
|
||||
sshSignatureURL := binaryURL + ".sshsig"
|
||||
latestURL := "https://api.github.com/repos/rcourtman/Pulse/releases/latest"
|
||||
archiveURL := "https://github.com/rcourtman/Pulse/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz"
|
||||
|
||||
router.installScriptClient = &http.Client{
|
||||
@@ -305,13 +309,6 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
}, nil
|
||||
case latestURL:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)),
|
||||
}, nil
|
||||
case archiveURL:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
@@ -360,13 +357,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
|
||||
t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork")
|
||||
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v9.9.9"
|
||||
|
||||
binaryContent := []byte("darwin arm64 binary payload")
|
||||
archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent)
|
||||
binaryURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-darwin-arm64"
|
||||
binaryURL := "https://github.com/example/pulse-fork/releases/download/v9.9.9/pulse-agent-darwin-arm64"
|
||||
signatureURL := binaryURL + ".sig"
|
||||
sshSignatureURL := binaryURL + ".sshsig"
|
||||
latestURL := "https://api.github.com/repos/example/pulse-fork/releases/latest"
|
||||
archiveURL := "https://github.com/example/pulse-fork/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz"
|
||||
|
||||
router.installScriptClient = &http.Client{
|
||||
@@ -379,13 +376,6 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
}, nil
|
||||
case latestURL:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)),
|
||||
}, nil
|
||||
case archiveURL:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
@@ -428,10 +418,11 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo
|
||||
|
||||
func TestDownloadUnifiedAgent_ProxyFromGitHub_NotFound(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
|
||||
{Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", Status: http.StatusNotFound, Body: ""},
|
||||
{Method: http.MethodGet, URL: "https://api.github.com/repos/rcourtman/Pulse/releases/latest", Status: http.StatusNotFound, Body: ""},
|
||||
{Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64", Status: http.StatusNotFound, Body: ""},
|
||||
{Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-v6.0.0-rc.1-linux-amd64.tar.gz", Status: http.StatusNotFound, Body: ""},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
|
||||
@@ -444,9 +435,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_NotFound(t *testing.T) {
|
||||
|
||||
func TestDownloadUnifiedAgent_ProxyFromGitHub_Error(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-rc.1"
|
||||
|
||||
// GitHub is unreachable
|
||||
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", 0, "", errors.New("connection refused"))
|
||||
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64", 0, "", errors.New("connection refused"))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -456,6 +448,20 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Error(t *testing.T) {
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestDownloadUnifiedAgent_DevPrereleaseRejectsGitHubFallback(t *testing.T) {
|
||||
router, _ := setupUnifiedAgentRouter(t)
|
||||
router.serverVersion = "v6.0.0-dev"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.handleDownloadUnifiedAgent(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "dev mode")
|
||||
assert.Contains(t, w.Body.String(), "go build -o bin/pulse-agent-linux-amd64")
|
||||
}
|
||||
|
||||
func TestNormalizeUnifiedAgentArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
Reference in New Issue
Block a user