diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9f1d106..87fe776 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -180,6 +180,8 @@ jobs: provenance: false cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max + build-args: | + PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | ghcr.io/${{ github.repository_owner }}/pulse:staging-${{ needs.extract_version.outputs.tag }} @@ -194,13 +196,17 @@ jobs: provenance: false cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max + build-args: | + PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} tags: | ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:staging-${{ needs.extract_version.outputs.tag }} - name: Build Docker images for integration tests run: | docker build -t pulse-mock-github:test tests/integration/mock-github-server - docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 . + docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" . + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Run update integration smoke tests working-directory: tests/integration @@ -305,6 +311,8 @@ jobs: run: | echo "Building release ${{ needs.extract_version.outputs.tag }}..." ./scripts/build-release.sh ${{ needs.extract_version.outputs.version }} + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Post-build health check run: | @@ -536,4 +544,3 @@ jobs: draft: false target_commitish: ${{ needs.create_release.outputs.target_commitish }} - diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 50d0066..fca625f 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -77,7 +77,9 @@ jobs: - name: Build Docker images for integration tests run: | docker build -t pulse-mock-github:test tests/integration/mock-github-server - docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 . + docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" . + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Run integration diagnostics working-directory: tests/integration diff --git a/.gitignore b/.gitignore index 842a4f7..c60546a 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ test-*.html PMG_BACKUP_DETECTION.md SAFE_TESTING.md tmp/ +.secrets/ # Master plan documents (local only) PULSE_V4_ISSUES_MASTER_PLAN.md diff --git a/Dockerfile b/Dockerfile index e85a77c..cf8a516 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1.7-labs ARG BUILD_AGENT=1 +ARG PULSE_LICENSE_PUBLIC_KEY # Build stage for frontend (must be built first for embedding) FROM node:20-alpine AS frontend-builder @@ -22,6 +23,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \ FROM golang:1.24-alpine AS backend-builder ARG BUILD_AGENT +ARG PULSE_LICENSE_PUBLIC_KEY WORKDIR /app # Install build dependencies @@ -49,8 +51,12 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ VERSION="v$(cat VERSION | tr -d '\n')" && \ BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \ GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \ + LICENSE_LDFLAGS="" && \ + if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then \ + LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"; \ + fi && \ CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION}" \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION} ${LICENSE_LDFLAGS}" \ -trimpath \ -o pulse ./cmd/pulse diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index c4c3c83..601c7c4 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -25,6 +25,12 @@ export interface ClearLicenseResponse { message?: string; } +export interface LicenseFeatureStatus { + license_status: string; + features: Record; + upgrade_url: string; +} + export class LicenseAPI { private static baseUrl = '/api/license'; @@ -32,6 +38,10 @@ export class LicenseAPI { return apiFetchJSON(`${this.baseUrl}/status`) as Promise; } + static async getFeatures(): Promise { + return apiFetchJSON(`${this.baseUrl}/features`) as Promise; + } + static async activateLicense(licenseKey: string): Promise { return apiFetchJSON(`${this.baseUrl}/activate`, { method: 'POST', diff --git a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx index 96a73b8..9c24838 100644 --- a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx +++ b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx @@ -2,6 +2,7 @@ import { Show, createSignal } from 'solid-js'; import { aiChatStore } from '@/stores/aiChat'; import type { Alert } from '@/types/api'; import { formatAlertValue } from '@/utils/alertFormatters'; +import { showWarning } from '@/utils/toast'; interface InvestigateAlertButtonProps { alert: Alert; @@ -10,6 +11,7 @@ interface InvestigateAlertButtonProps { size?: 'sm' | 'md'; variant?: 'icon' | 'text' | 'full'; class?: string; + licenseLocked?: boolean; } /** @@ -18,10 +20,15 @@ interface InvestigateAlertButtonProps { */ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) { const [isHovered, setIsHovered] = createSignal(false); + const isLocked = () => props.licenseLocked === true; const handleClick = (e: MouseEvent) => { e.stopPropagation(); e.preventDefault(); + if (isLocked()) { + showWarning('Pulse Pro required to investigate alerts with AI.'); + return; + } // Calculate how long the alert has been active const startTime = new Date(props.alert.startTime); @@ -102,8 +109,10 @@ Please: hover:text-purple-700 dark:hover:text-purple-300 border border-purple-200/50 dark:border-purple-700/50 hover:border-purple-300 dark:hover:border-purple-600 + ${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500/10 hover:to-blue-500/10' : ''} ${props.class || ''}`} - title="Ask AI to investigate this alert" + title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'} + aria-disabled={isLocked()} > { // Per-provider test state const [testingProvider, setTestingProvider] = createSignal(null); const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null); + const [licenseStatus, setLicenseStatus] = createSignal(null); + const hasAlertAnalysisFeature = createMemo(() => { + const status = licenseStatus(); + if (!status) return true; + return Boolean(status.valid && status.features?.includes('ai_alerts')); + }); + const hasAutoFixFeature = createMemo(() => { + const status = licenseStatus(); + if (!status) return true; + return Boolean(status.valid && status.features?.includes('ai_autofix')); + }); + const alertAnalysisLocked = createMemo(() => !hasAlertAnalysisFeature()); + const autoFixLocked = createMemo(() => !hasAutoFixFeature()); // Auto-fix acknowledgement state (not persisted - must acknowledge each session) const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false); @@ -239,6 +253,15 @@ export const AISettings: Component = () => { onMount(() => { loadSettings(); + void (async () => { + try { + const status = await LicenseAPI.getStatus(); + setLicenseStatus(status); + } catch (err) { + logger.debug('Failed to load license status for AI gating', err); + setLicenseStatus(null); + } + })(); // Check for OAuth callback parameters in URL const params = new URLSearchParams(window.location.search); @@ -1220,13 +1243,29 @@ export const AISettings: Component = () => { Efficient + + Pro + setForm('alertTriggeredAnalysis', event.currentTarget.checked)} - disabled={saving()} + disabled={saving() || alertAnalysisLocked()} /> + +

+ Pulse Pro required for alert-triggered analysis.{' '} + + Upgrade + +

+
{/* Auto-Fix Toggle - Compact with inline warning */}
@@ -1234,12 +1273,15 @@ export const AISettings: Component = () => { setForm('patrolAutoFix', event.currentTarget.checked)} - disabled={saving()} + disabled={saving() || autoFixLocked()} /> @@ -1249,19 +1291,32 @@ export const AISettings: Component = () => { setAutoFixAcknowledged(true); setForm('patrolAutoFix', true); }} - disabled={saving()} - class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800" + disabled={saving() || autoFixLocked()} + class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800 disabled:opacity-60 disabled:cursor-not-allowed" > Enable
- + +

+ Pulse Pro required for auto-fix.{' '} + + Upgrade + +

+
+

⚠️ AI will execute fixes without approval. Enable with caution.

- +

@@ -1272,7 +1327,7 @@ export const AISettings: Component = () => { {/* Auto-Fix Model - Only when enabled */} - +

0} fallback={ diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 308ddc5..2516176 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -17,6 +17,7 @@ import { showSuccess, showError } from '@/utils/toast'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import { AlertsAPI } from '@/api/alerts'; import { NotificationsAPI, Webhook } from '@/api/notifications'; +import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license'; import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { HysteresisThreshold } from '@/types/alerts'; import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api'; @@ -2077,6 +2078,19 @@ function OverviewTab(props: { const [newRuleResource, setNewRuleResource] = createSignal(''); const [newRuleCategory, setNewRuleCategory] = createSignal(''); const [newRuleDescription, setNewRuleDescription] = createSignal(''); + const [licenseFeatures, setLicenseFeatures] = createSignal(null); + const [licenseLoading, setLicenseLoading] = createSignal(false); + const hasAIAlertsFeature = createMemo(() => { + const status = licenseFeatures(); + if (!status) return true; + return Boolean(status.features?.['ai_alerts']); + }); + const showAIAlertsUpgrade = createMemo(() => { + const status = licenseFeatures(); + if (!status) return false; + return !status.features?.['ai_alerts']; + }); + const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro'); // Live streaming state for running patrol const [expandedLiveStream, setExpandedLiveStream] = createSignal(false); // Track streaming blocks for sequential display (like AI chat) @@ -2189,6 +2203,23 @@ function OverviewTab(props: { } }); + const loadLicenseStatus = async () => { + setLicenseLoading(true); + try { + const status = await LicenseAPI.getFeatures(); + setLicenseFeatures(status); + } catch (err) { + logger.debug('Failed to load license status for AI alerts gating', err); + setLicenseFeatures(null); + } finally { + setLicenseLoading(false); + } + }; + + onMount(() => { + void loadLicenseStatus(); + }); + // Fetch AI data - extracted for reuse const fetchAiData = async () => { try { @@ -3364,6 +3395,22 @@ function OverviewTab(props: { }>
+ +
+

AI alert investigation requires Pulse Pro

+

+ Upgrade to unlock one-click AI analysis for active alerts. +

+ + View Pulse Pro plans + +
+
0} fallback={ @@ -3602,6 +3649,7 @@ function OverviewTab(props: { alert={alert} variant="text" size="sm" + licenseLocked={!hasAIAlertsFeature() && !licenseLoading()} />
@@ -5105,6 +5153,13 @@ function ScheduleTab(props: ScheduleTabProps) { // History Tab - Comprehensive alert table function HistoryTab() { const { state, activeAlerts } = useWebSocket(); + const [licenseFeatures, setLicenseFeatures] = createSignal(null); + const [licenseLoading, setLicenseLoading] = createSignal(false); + const hasAIAlertsFeature = createMemo(() => { + const status = licenseFeatures(); + if (!status) return true; + return Boolean(status.features?.['ai_alerts']); + }); // Filter states with localStorage persistence const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>( @@ -5139,6 +5194,21 @@ function HistoryTab() { (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en-US'; + onMount(() => { + void (async () => { + setLicenseLoading(true); + try { + const status = await LicenseAPI.getFeatures(); + setLicenseFeatures(status); + } catch (err) { + logger.debug('Failed to load license status for AI alerts gating', err); + setLicenseFeatures(null); + } finally { + setLicenseLoading(false); + } + })(); + }); + const buildHistoryParams = (range: string) => { const params: { limit?: number; startTime?: string } = {}; const now = Date.now(); @@ -6313,6 +6383,7 @@ function HistoryTab() { }} variant="icon" size="sm" + licenseLocked={!hasAIAlertsFeature() && !licenseLoading()} />
diff --git a/internal/ai/metadata_provider.go b/internal/ai/metadata_provider.go index 1aeb355..4e6c685 100644 --- a/internal/ai/metadata_provider.go +++ b/internal/ai/metadata_provider.go @@ -42,22 +42,17 @@ func (s *Service) SetResourceURL(resourceType, resourceID, customURL string) err // Validate and normalize the URL if customURL != "" { + // If no scheme, default to http + if !strings.Contains(customURL, "://") { + customURL = "http://" + customURL + } + // Try to parse the URL parsedURL, err := url.Parse(customURL) if err != nil { return fmt.Errorf("invalid URL format: %w", err) } - // Ensure scheme is present - if parsedURL.Scheme == "" { - // Default to http if no scheme provided - customURL = "http://" + customURL - parsedURL, err = url.Parse(customURL) - if err != nil { - return fmt.Errorf("invalid URL format: %w", err) - } - } - // Validate scheme if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return fmt.Errorf("URL must use http:// or https:// scheme") diff --git a/internal/ai/service.go b/internal/ai/service.go index b219851..e66efde 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -8,6 +8,8 @@ import ( "net" "net/http" "net/url" + "os" + "reflect" "regexp" "strconv" "strings" @@ -30,6 +32,11 @@ type StateProvider interface { GetState() models.StateSnapshot } +// CommandPolicy defines the interface for command security policy +type CommandPolicy interface { + Evaluate(command string) agentexec.PolicyDecision +} + // LicenseState represents the current state of the license type LicenseState string @@ -48,14 +55,20 @@ type LicenseChecker interface { GetLicenseStateString() (string, bool) } +// AgentServer defines the interface for communicating with agents +type AgentServer interface { + GetConnectedAgents() []agentexec.ConnectedAgent + ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) +} + // Service orchestrates AI interactions type Service struct { mu sync.RWMutex persistence *config.ConfigPersistence provider providers.Provider cfg *config.AIConfig - agentServer *agentexec.Server - policy *agentexec.CommandPolicy + agentServer AgentServer + policy CommandPolicy stateProvider StateProvider alertProvider AlertProvider knowledgeStore *knowledge.Store @@ -89,7 +102,7 @@ type modelsCache struct { } // NewService creates a new AI service -func NewService(persistence *config.ConfigPersistence, agentServer *agentexec.Server) *Service { +func NewService(persistence *config.ConfigPersistence, agentServer AgentServer) *Service { // Initialize knowledge store var knowledgeStore *knowledge.Store costStore := cost.NewStore(cost.DefaultMaxDays) @@ -1693,9 +1706,17 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc // This uses the same routing logic as command execution to determine if the target // can be reached, including cluster peer routing for Proxmox clusters. func (s *Service) hasAgentForTarget(req ExecuteRequest) bool { + // Check for nil interface or nil underlying value + // Note: A typed nil pointer assigned to an interface is NOT nil according to Go's == operator + // We need to use reflection to check if the underlying value is nil if s.agentServer == nil { return false } + // Check if the interface contains a typed nil pointer + v := reflect.ValueOf(s.agentServer) + if v.Kind() == reflect.Ptr && v.IsNil() { + return false + } agents := s.agentServer.GetConnectedAgents() if len(agents) == 0 { @@ -2155,10 +2176,13 @@ func parseAndValidateFetchURL(ctx context.Context, urlStr string) (*url.URL, err return nil, fmt.Errorf("url is required") } - parsed, err := url.ParseRequestURI(clean) + parsed, err := url.Parse(clean) if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } + if !parsed.IsAbs() { + return nil, fmt.Errorf("URL must be absolute") + } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("only http/https URLs are allowed") } @@ -2212,6 +2236,9 @@ func isBlockedFetchIP(ip net.IP) bool { return true } if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + if ip.IsLoopback() && os.Getenv("PULSE_AI_ALLOW_LOOPBACK") == "true" { + return false + } return true } // Block multicast and other non-unicast targets. diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 0599ea8..9decb0f 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -67,6 +67,37 @@ func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Req json.NewEncoder(w).Encode(status) } +// LicenseFeaturesResponse provides a minimal, non-admin license view for feature gating. +type LicenseFeaturesResponse struct { + LicenseStatus string `json:"license_status"` + Features map[string]bool `json:"features"` + UpgradeURL string `json:"upgrade_url"` +} + +// HandleLicenseFeatures handles GET /api/license/features +// Returns license state and feature availability for authenticated users. +func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + state, _ := h.service.GetLicenseState() + response := LicenseFeaturesResponse{ + LicenseStatus: string(state), + Features: map[string]bool{ + license.FeatureAIPatrol: h.service.HasFeature(license.FeatureAIPatrol), + license.FeatureAIAlerts: h.service.HasFeature(license.FeatureAIAlerts), + license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix), + license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI), + }, + UpgradeURL: "https://pulsemonitor.app/pro", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // ActivateLicenseRequest is the request body for activating a license. type ActivateLicenseRequest struct { LicenseKey string `json:"license_key"` diff --git a/internal/api/router.go b/internal/api/router.go index eec2ae3..120bb87 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -424,6 +424,7 @@ func (r *Router) setupRoutes() { // License routes (Pulse Pro) r.mux.HandleFunc("/api/license/status", RequireAdmin(r.config, r.licenseHandlers.HandleLicenseStatus)) + r.mux.HandleFunc("/api/license/features", RequireAuth(r.config, r.licenseHandlers.HandleLicenseFeatures)) r.mux.HandleFunc("/api/license/activate", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleActivateLicense))) r.mux.HandleFunc("/api/license/clear", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleClearLicense))) diff --git a/scripts/build-release.sh b/scripts/build-release.sh index ef3b5c2..68c4fb8 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -19,6 +19,14 @@ RELEASE_DIR="release" echo "Building Pulse v${VERSION}..." +# Optional public key embedding for license validation +LICENSE_LDFLAGS="" +if [[ -n "${PULSE_LICENSE_PUBLIC_KEY:-}" ]]; then + LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}" +else + echo "Warning: PULSE_LICENSE_PUBLIC_KEY not set; Pulse Pro license activation will fail for release binaries." +fi + # Clean previous builds rm -rf $BUILD_DIR $RELEASE_DIR mkdir -p $BUILD_DIR $RELEASE_DIR @@ -100,7 +108,7 @@ for build_name in "${build_order[@]}"; do # Build backend binary with version info env $build_env go build \ - -ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION}" \ + -ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION} ${LICENSE_LDFLAGS}" \ -trimpath \ -o "$BUILD_DIR/pulse-$build_name" \ ./cmd/pulse