feat: Add Cloud Endpoint Sync Commands (#216)

* feat: Add cloud sync command

* feat: Add pmg cloud commands

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-04-13 13:40:18 +05:30
committed by GitHub
parent e67735c1c3
commit 887984612c
14 changed files with 453 additions and 92 deletions
+18
View File
@@ -0,0 +1,18 @@
package cloud
import (
"github.com/spf13/cobra"
)
func NewCloudCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cloud",
Short: "SafeDep Cloud operations",
}
cmd.AddCommand(newSyncCommand())
cmd.AddCommand(newLoginCommand())
cmd.AddCommand(newLogoutCommand())
return cmd
}
+72
View File
@@ -0,0 +1,72 @@
package cloud
import (
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
func newLoginCommand() *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Store SafeDep Cloud credentials securely",
RunE: runLogin,
}
}
func runLogin(cmd *cobra.Command, args []string) error {
tenantID, err := ui.PromptInput("Tenant ID: ")
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to read Tenant ID"))
}
if tenantID == "" {
ui.ErrorExit(usefulerror.Useful().
WithCode(usefulerror.ErrCodeInvalidArgument).
WithHumanError("Tenant ID cannot be empty"))
}
apiKey, err := ui.PromptSecret("API Key: ")
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to read API Key"))
}
if apiKey == "" {
ui.ErrorExit(usefulerror.Useful().
WithCode(usefulerror.ErrCodeInvalidArgument).
WithHumanError("API Key cannot be empty"))
}
store, err := cloud.NewKeychainCredentialStore()
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to initialize credential store").
WithHelp("Your system may not support secure credential storage"))
}
defer func() {
if err := store.Close(); err != nil {
log.Warnf("failed to close credential store: %v", err)
}
}()
if err := store.SaveAPIKeyCredential(apiKey, tenantID); err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to save credentials").
WithHelp("Your system may not support secure credential storage"))
}
ui.Successf("Credentials saved securely")
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package cloud
import (
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
func newLogoutCommand() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Clear stored SafeDep Cloud credentials",
RunE: runLogout,
}
}
func runLogout(cmd *cobra.Command, args []string) error {
store, err := cloud.NewKeychainCredentialStore()
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to initialize credential store").
WithHelp("Your system may not support secure credential storage"))
}
defer func() {
if err := store.Close(); err != nil {
log.Warnf("failed to close credential store: %v", err)
}
}()
if err := store.Clear(); err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to clear credentials"))
}
ui.Successf("Credentials cleared from keychain")
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package cloud
import (
"context"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/audit"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/cobra"
)
var syncTimeout time.Duration
func newSyncCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sync",
Short: "Sync pending audit events to SafeDep Cloud",
RunE: runSync,
}
cmd.Flags().DurationVar(&syncTimeout, "timeout", 15*time.Minute, "Maximum time to spend syncing events")
return cmd
}
func runSync(cmd *cobra.Command, args []string) error {
cfg := config.Get()
if analytics.IsDisabled() {
ui.Infof("Cloud sync is disabled because telemetry is disabled (PMG_DISABLE_TELEMETRY)")
return nil
}
if !cfg.Config.Cloud.Enabled {
ui.ErrorExit(usefulerror.Useful().
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Cloud sync is not enabled").
WithHelp("Set 'cloud.enabled: true' in PMG config to enable cloud sync"))
}
ctx, cancel := context.WithTimeout(cmd.Context(), syncTimeout)
defer cancel()
bundle, err := audit.NewSyncClientBundle(cfg)
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Failed to initialize cloud sync client").
WithHelp("Run 'pmg cloud login' to store credentials, or set SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables"))
}
defer func() {
if err := bundle.Close(); err != nil {
log.Warnf("failed to close sync client: %v", err)
}
}()
synced, err := bundle.Sync(ctx)
if err != nil {
ui.ErrorExit(usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeNetwork).
WithHumanError("Failed to sync events to SafeDep Cloud").
WithHelp("Check your network connectivity and ensure SafeDep Cloud is reachable").
WithAdditionalHelp("Override the cloud endpoint with SAFEDEP_CLOUD_DATA_ADDR if needed"))
}
ui.Successf("Synced %d events to SafeDep Cloud", synced)
return nil
}
+7 -3
View File
@@ -12,7 +12,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/posthog/posthog-go v1.5.12
github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14
github.com/safedep/dry v0.0.0-20260411074023-b589e91de472
github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12
github.com/sony/gobreaker/v2 v2.4.0
github.com/spf13/cobra v1.9.1
@@ -20,18 +20,20 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
golang.org/x/term v0.40.0
golang.org/x/term v0.42.0
google.golang.org/grpc v1.80.0
gopkg.in/yaml.v3 v3.0.1
)
require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/caarlos0/env/v11 v11.3.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denisbrodbeck/machineid v1.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -46,6 +48,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-github/v74 v74.0.0 // indirect
@@ -70,13 +73,14 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/jsonc v0.3.2 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+14
View File
@@ -1,3 +1,5 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 h1:NXwdBG3BiC6xWH4iG3csbT+JHF9u1jl5ThDhumCnKnk=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/gen/go/safedep/api/grpc/go v1.6.1-20260409081445-73994c4e35a3.1 h1:OKKSMXf1k7NZ1qcp5gfpTw0F3QGVM/X6ghkRs9GtrIA=
@@ -29,6 +31,8 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -83,6 +87,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -162,6 +168,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14 h1:ajMAtCe1asSa4jz8Vl65CNtzTgWTAaHdeWRu0lDn8p4=
github.com/safedep/dry v0.0.0-20260409102613-51a5bb88eb14/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs=
github.com/safedep/dry v0.0.0-20260411074023-b589e91de472 h1:8CztWcu+C7alCjAS5AVDRhxr/LL/9y6lHwpZKmxZjvE=
github.com/safedep/dry v0.0.0-20260411074023-b589e91de472/go.mod h1:JOiCF9w4plbcMS6XnLNpDaSoUEabdscP+eFk+kROHrs=
github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 h1:NzARvPtncPbVI8a8Z0JKpJ7XJCSPpsRstV7wAgEtmOU=
github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12/go.mod h1:fyt+PACz6dtEoqsnE0BPPv/lHpuBG/8zkDqeIVcyRY4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
@@ -196,6 +204,8 @@ github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
@@ -261,8 +271,12 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+3 -8
View File
@@ -9,7 +9,6 @@ import (
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/usefulerror"
)
var global *auditor
@@ -31,14 +30,10 @@ func Initialize(cfg *config.RuntimeConfig) error {
if cfg.Config.Cloud.Enabled && !analytics.IsDisabled() {
cs, err := newCloudSink(cfg)
if err != nil {
return usefulerror.Useful().
Wrap(err).
WithCode(usefulerror.ErrCodeLifecycle).
WithHumanError("Cloud sync is enabled but failed to initialize").
WithHelp("Ensure SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables are set").
WithAdditionalHelp("Disable cloud sync with 'cloud.enabled: false' in config if not needed")
log.Warnf("Cloud sync initialization failed: %v", err)
} else {
sinks = append(sinks, cs)
}
sinks = append(sinks, cs)
}
if cfg.Config.Cloud.Enabled && analytics.IsDisabled() {
+131
View File
@@ -0,0 +1,131 @@
package audit
import (
"context"
"errors"
"fmt"
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/cloud/endpointsync"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
appVersion "github.com/safedep/pmg/internal/version"
)
// SyncClientBundle holds a SyncClient and its underlying cloud client.
// Callers must call Close() when done.
type SyncClientBundle struct {
syncClient *endpointsync.SyncClient
cloudClient *cloud.Client
keychainResolver cloud.CloseableCredentialResolver
}
// Sync delivers pending events from the WAL to SafeDep Cloud.
func (b *SyncClientBundle) Sync(ctx context.Context) (int, error) {
return b.syncClient.Sync(ctx)
}
func (b *SyncClientBundle) Close() error {
var errs []error
if b.syncClient != nil {
if err := b.syncClient.Close(); err != nil {
errs = append(errs, err)
}
}
if b.cloudClient != nil {
if err := b.cloudClient.Close(); err != nil {
errs = append(errs, err)
}
}
if b.keychainResolver != nil {
if err := b.keychainResolver.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// NewSyncClientBundle creates an authenticated SyncClient connected to SafeDep Cloud.
func NewSyncClientBundle(cfg *config.RuntimeConfig) (*SyncClientBundle, error) {
// Build credential resolver chain: keychain first, env fallback.
var resolvers []cloud.CredentialResolver
var keychainResolver cloud.CloseableCredentialResolver
keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey)
if err != nil {
log.Debugf("Keychain credential resolver not available, skipping: %v", err)
} else {
resolvers = append(resolvers, keychainResolver)
}
envResolver, err := cloud.NewEnvCredentialResolver()
if err != nil {
log.Debugf("Env credential resolver not available, skipping: %v", err)
} else {
resolvers = append(resolvers, envResolver)
}
if len(resolvers) == 0 {
if keychainResolver != nil {
if closeErr := keychainResolver.Close(); closeErr != nil {
log.Warnf("failed to close keychain resolver: %v", closeErr)
}
}
return nil, fmt.Errorf("no credential resolvers available")
}
chain := cloud.NewChainCredentialResolver(resolvers...)
creds, err := chain.Resolve()
if err != nil {
if keychainResolver != nil {
if closeErr := keychainResolver.Close(); closeErr != nil {
log.Warnf("failed to close keychain resolver: %v", closeErr)
}
}
return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err)
}
cloudClient, err := cloud.NewDataPlaneClient("pmg", creds)
if err != nil {
if keychainResolver != nil {
if closeErr := keychainResolver.Close(); closeErr != nil {
log.Warnf("failed to close keychain resolver: %v", closeErr)
}
}
return nil, fmt.Errorf("failed to create data plane client: %w", err)
}
transport := endpointsync.NewGrpcTransport(cloudClient.Connection())
var identityOpts []endpointsync.EndpointIdentityOption
if cfg.Config.Cloud.EndpointID != "" {
identityOpts = append(identityOpts, endpointsync.WithEndpointID(cfg.Config.Cloud.EndpointID))
}
identity := endpointsync.NewEndpointIdentityResolver(identityOpts...)
toolVersion := appVersion.Version
if toolVersion == "" {
toolVersion = "dev"
}
syncClient, err := endpointsync.NewSyncClient("pmg", toolVersion, transport, identity,
endpointsync.WithWALPath(cfg.CloudSyncDBPath()))
if err != nil {
if closeErr := cloudClient.Close(); closeErr != nil {
log.Warnf("failed to close cloud client after sync client init failure: %v", closeErr)
}
if keychainResolver != nil {
if closeErr := keychainResolver.Close(); closeErr != nil {
log.Warnf("failed to close keychain resolver: %v", closeErr)
}
}
return nil, fmt.Errorf("failed to create sync client: %w", err)
}
return &SyncClientBundle{
syncClient: syncClient,
cloudClient: cloudClient,
keychainResolver: keychainResolver,
}, nil
}
+8 -64
View File
@@ -6,79 +6,33 @@ import (
"fmt"
"github.com/google/uuid"
"github.com/safedep/dry/cloud"
"github.com/safedep/dry/cloud/endpointsync"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
appVersion "github.com/safedep/pmg/internal/version"
)
type cloudSink struct {
syncClient *endpointsync.SyncClient
cloudClient *cloud.Client
*SyncClientBundle
invocationID string
}
func newCloudSink(cfg *config.RuntimeConfig) (*cloudSink, error) {
resolver, err := cloud.NewEnvCredentialResolver()
bundle, err := NewSyncClientBundle(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create credential resolver: %w", err)
}
creds, err := resolver.Resolve()
if err != nil {
return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err)
}
cloudClient, err := cloud.NewDataPlaneClient("pmg", creds)
if err != nil {
return nil, fmt.Errorf("failed to create data plane client: %w", err)
}
transport := endpointsync.NewGrpcTransport(cloudClient.Connection())
sink, err := newCloudSinkWithTransport(transport, cfg.Config.Cloud.EndpointID, cfg.CloudSyncDBPath())
if err != nil {
if closeErr := cloudClient.Close(); closeErr != nil {
log.Warnf("failed to close cloud client after sink init failure: %v", closeErr)
}
return nil, err
}
sink.cloudClient = cloudClient
return sink, nil
}
func newCloudSinkWithTransport(transport endpointsync.EventTransport, endpointID, walPath string) (*cloudSink, error) {
var identityOpts []endpointsync.EndpointIdentityOption
if endpointID != "" {
identityOpts = append(identityOpts, endpointsync.WithEndpointID(endpointID))
}
identity := endpointsync.NewEndpointIdentityResolver(identityOpts...)
toolVersion := appVersion.Version
if toolVersion == "" {
toolVersion = "dev"
}
syncClient, err := endpointsync.NewSyncClient("pmg", toolVersion, transport, identity,
endpointsync.WithWALPath(walPath))
if err != nil {
return nil, fmt.Errorf("failed to create sync client: %w", err)
}
invocationID, err := uuid.NewRandom()
if err != nil {
if closeErr := syncClient.Close(); closeErr != nil {
log.Warnf("failed to close sync client after invocation ID generation failure: %v", closeErr)
if closeErr := bundle.Close(); closeErr != nil {
log.Warnf("failed to close sync client bundle after invocation ID failure: %v", closeErr)
}
return nil, fmt.Errorf("failed to generate invocation ID: %w", err)
}
return &cloudSink{
syncClient: syncClient,
invocationID: invocationID.String(),
SyncClientBundle: bundle,
invocationID: invocationID.String(),
}, nil
}
@@ -109,17 +63,7 @@ func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error {
return nil
}
// Close delegates to the embedded SyncClientBundle.Close().
func (s *cloudSink) Close() error {
var errs []error
if s.syncClient != nil {
if err := s.syncClient.Close(); err != nil {
errs = append(errs, err)
}
}
if s.cloudClient != nil {
if err := s.cloudClient.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
return s.SyncClientBundle.Close()
}
+20 -12
View File
@@ -6,6 +6,7 @@ import (
"time"
servicev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1"
"github.com/safedep/dry/cloud/endpointsync"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -29,17 +30,28 @@ func (m *mockTransport) Close() error {
return nil
}
func newTestCloudSink(t *testing.T, transport endpointsync.EventTransport) *cloudSink {
t.Helper()
walPath := t.TempDir() + "/test-sync.db"
identity := endpointsync.NewEndpointIdentityResolver()
syncClient, err := endpointsync.NewSyncClient("pmg", "test", transport, identity,
endpointsync.WithWALPath(walPath))
require.NoError(t, err)
return &cloudSink{
SyncClientBundle: &SyncClientBundle{syncClient: syncClient},
invocationID: "test-invocation",
}
}
func TestCloudSinkEmitsTranslatableEvents(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
sink := newTestCloudSink(t, transport)
defer func() {
require.NoError(t, sink.Close())
}()
err = sink.Handle(context.Background(), AuditEvent{
err := sink.Handle(context.Background(), AuditEvent{
Type: EventTypeMalwareBlocked,
Timestamp: time.Now(),
Message: "blocked malware package",
@@ -49,15 +61,13 @@ func TestCloudSinkEmitsTranslatableEvents(t *testing.T) {
func TestCloudSinkSkipsUntranslatableEvents(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
sink := newTestCloudSink(t, transport)
defer func() {
require.NoError(t, sink.Close())
}()
err = sink.Handle(context.Background(), AuditEvent{
err := sink.Handle(context.Background(), AuditEvent{
Type: EventTypeProxyHostObserved,
Timestamp: time.Now(),
Message: "observed proxy host",
@@ -68,16 +78,14 @@ func TestCloudSinkSkipsUntranslatableEvents(t *testing.T) {
func TestCloudSinkEmitAndSync(t *testing.T) {
transport := &mockTransport{}
walPath := t.TempDir() + "/test-sync.db"
sink, err := newCloudSinkWithTransport(transport, "", walPath)
require.NoError(t, err)
sink := newTestCloudSink(t, transport)
defer func() {
require.NoError(t, sink.Close())
}()
ctx := context.Background()
err = sink.Handle(ctx, AuditEvent{
err := sink.Handle(ctx, AuditEvent{
Type: EventTypeMalwareBlocked,
Timestamp: time.Now(),
Message: "blocked malware package",
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
// testCloudSink returns a cloudSink with no real SyncClient, suitable for testing translation.
var testSink = &cloudSink{invocationID: "test-invocation"}
var testSink = &cloudSink{SyncClientBundle: &SyncClientBundle{}, invocationID: "test-invocation"}
func TestTranslateMalwareBlocked(t *testing.T) {
event := AuditEvent{
+14 -4
View File
@@ -74,14 +74,19 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
startTime := time.Now()
audit.LogInstallStarted(f.pm.Name(), args)
sessionCompleted := false
defer func() {
if sessionCompleted {
return
}
// On early error returns (e.g. CA cert, analyzer init), reportData.Outcome
// is still the default (Success). Override to Error for these cases. Don't
// override when outcome was explicitly set (e.g. Blocked) — those paths may
// also return an error from the underlying package manager command.
// is still the default (Success). Override to Error for these cases.
if runErr != nil && reportData.Outcome == ui.OutcomeSuccess {
reportData.Outcome = ui.OutcomeError
}
audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy)
}()
@@ -169,6 +174,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
}()
ui.ClearStatus()
log.Infof("Proxy server started on %s", proxyAddr)
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
@@ -192,12 +198,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
reportData.BlockedCount = stats.BlockedCount
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks()
// Set outcome based on execution result using shared inference logic
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
// Emit session complete before report/exit — handleExecutionResultError may call
// os.Exit which skips defers, so we must emit the session summary here.
audit.LogSessionComplete(audit.Outcome(reportData.Outcome.String()), audit.FlowTypeProxy)
sessionCompleted = true
// Show the report
ui.Report(reportData)
+46
View File
@@ -9,6 +9,7 @@ import (
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/internal/models"
"golang.org/x/term"
)
// The UI is internal to PMG and opinionated for the CLI.
@@ -135,6 +136,51 @@ func ShowWarning(message string) {
fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message))
}
// Infof prints an informational message, suppressed in silent mode.
func Infof(msg string, args ...interface{}) {
if verbosityLevel == VerbosityLevelSilent {
return
}
fmt.Println(fmt.Sprintf(msg, args...))
}
// Successf prints a green success message, suppressed in silent mode.
func Successf(msg string, args ...interface{}) {
if verbosityLevel == VerbosityLevelSilent {
return
}
fmt.Printf("%s %s\n", Colors.Green("✓"), fmt.Sprintf(msg, args...))
}
// PromptInput prints a label and reads a line of visible input from stdin.
func PromptInput(label string) (string, error) {
fmt.Printf("%s %s", Colors.Cyan(""), Colors.Bold(label))
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text()), nil
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("no input received")
}
// PromptSecret prints a label and reads input from stdin with echo disabled.
// Returns an error if stdin is not a terminal (e.g. piped input).
func PromptSecret(label string) (string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return "", fmt.Errorf("interactive terminal required for secure input")
}
fmt.Printf("%s %s", Colors.Cyan("▪"), Colors.Bold(label))
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
func Fatalf(msg string, args ...interface{}) {
ClearStatus()
+2
View File
@@ -5,6 +5,7 @@ import (
"os"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/cmd/cloud"
"github.com/safedep/pmg/cmd/executors"
"github.com/safedep/pmg/cmd/npm"
"github.com/safedep/pmg/cmd/pypi"
@@ -127,6 +128,7 @@ func main() {
cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand())
cmd.AddCommand(cloud.NewCloudCommand())
// Print Banner on --help / -h
cmd.SetHelpFunc(func(command *cobra.Command, args []string) {