github.com/argoproj/argo-cd/v3@v3.2.1/cmpserver/plugin/plugin.go (about) 1 package plugin 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/rand" 7 "encoding/hex" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "os" 12 "os/exec" 13 "path/filepath" 14 "strings" 15 "time" 16 17 "github.com/golang/protobuf/ptypes/empty" 18 19 "github.com/argoproj/argo-cd/v3/cmpserver/apiclient" 20 "github.com/argoproj/argo-cd/v3/common" 21 repoclient "github.com/argoproj/argo-cd/v3/reposerver/apiclient" 22 "github.com/argoproj/argo-cd/v3/util/buffered_context" 23 "github.com/argoproj/argo-cd/v3/util/cmp" 24 argoexec "github.com/argoproj/argo-cd/v3/util/exec" 25 "github.com/argoproj/argo-cd/v3/util/io/files" 26 27 "github.com/argoproj/gitops-engine/pkg/utils/kube" 28 securejoin "github.com/cyphar/filepath-securejoin" 29 "github.com/mattn/go-zglob" 30 log "github.com/sirupsen/logrus" 31 ) 32 33 // cmpTimeoutBuffer is the amount of time before the request deadline to timeout server-side work. It makes sure there's 34 // enough time before the client times out to send a meaningful error message. 35 const cmpTimeoutBuffer = 100 * time.Millisecond 36 37 // Service implements ConfigManagementPluginService interface 38 type Service struct { 39 initConstants CMPServerInitConstants 40 } 41 42 type CMPServerInitConstants struct { 43 PluginConfig PluginConfig 44 } 45 46 // NewService returns a new instance of the ConfigManagementPluginService 47 func NewService(initConstants CMPServerInitConstants) *Service { 48 return &Service{ 49 initConstants: initConstants, 50 } 51 } 52 53 func (s *Service) Init(workDir string) error { 54 err := os.RemoveAll(workDir) 55 if err != nil { 56 return fmt.Errorf("error removing workdir %q: %w", workDir, err) 57 } 58 err = os.MkdirAll(workDir, 0o700) 59 if err != nil { 60 return fmt.Errorf("error creating workdir %q: %w", workDir, err) 61 } 62 return nil 63 } 64 65 const execIDLen = 5 66 67 func randExecID() (string, error) { 68 execIDBytes := make([]byte, execIDLen/2+1) // we need one extra letter to discard 69 if _, err := rand.Read(execIDBytes); err != nil { 70 return "", err 71 } 72 return hex.EncodeToString(execIDBytes)[0:execIDLen], nil 73 } 74 75 func runCommand(ctx context.Context, command Command, path string, env []string) (string, error) { 76 if len(command.Command) == 0 { 77 return "", errors.New("Command is empty") 78 } 79 cmd := exec.CommandContext(ctx, command.Command[0], append(command.Command[1:], command.Args...)...) 80 81 cmd.Env = env 82 cmd.Dir = path 83 84 execId, err := randExecID() 85 if err != nil { 86 return "", err 87 } 88 logCtx := log.WithFields(log.Fields{"execID": execId}) 89 90 argsToLog := argoexec.GetCommandArgsToLog(cmd) 91 logCtx.WithFields(log.Fields{"dir": cmd.Dir}).Info(argsToLog) 92 93 var stdout bytes.Buffer 94 var stderr bytes.Buffer 95 cmd.Stdout = &stdout 96 cmd.Stderr = &stderr 97 98 // Make sure the command is killed immediately on timeout. https://stackoverflow.com/a/38133948/684776 99 cmd.SysProcAttr = newSysProcAttr(true) 100 101 start := time.Now() 102 err = cmd.Start() 103 if err != nil { 104 return "", err 105 } 106 107 go func() { 108 <-ctx.Done() 109 // Kill by group ID to make sure child processes are killed. The - tells `kill` that it's a group ID. 110 // Since we didn't set Pgid in SysProcAttr, the group ID is the same as the process ID. https://pkg.go.dev/syscall#SysProcAttr 111 112 // Sending a TERM signal first to allow any potential cleanup if needed, and then sending a KILL signal 113 _ = sysCallTerm(-cmd.Process.Pid) 114 115 // modify cleanup timeout to allow process to cleanup 116 cleanupTimeout := 5 * time.Second 117 time.Sleep(cleanupTimeout) 118 119 _ = sysCallKill(-cmd.Process.Pid) 120 }() 121 122 err = cmd.Wait() 123 124 duration := time.Since(start) 125 output := stdout.String() 126 127 logCtx.WithFields(log.Fields{"duration": duration}).Debug(output) 128 129 if err != nil { 130 err := newCmdError(argsToLog, errors.New(err.Error()), strings.TrimSpace(stderr.String())) 131 logCtx.Error(err.Error()) 132 return strings.TrimSuffix(output, "\n"), err 133 } 134 135 logCtx = logCtx.WithFields(log.Fields{ 136 "stderr": stderr.String(), 137 "command": command, 138 }) 139 if output == "" { 140 logCtx.Warn("Plugin command returned zero output") 141 } else { 142 // Log stderr even on successful commands to help develop plugins 143 logCtx.Info("Plugin command successful") 144 } 145 146 return strings.TrimSuffix(output, "\n"), nil 147 } 148 149 type CmdError struct { 150 Args string 151 Stderr string 152 Cause error 153 } 154 155 func (ce *CmdError) Error() string { 156 res := fmt.Sprintf("`%v` failed %v", ce.Args, ce.Cause) 157 if ce.Stderr != "" { 158 res = fmt.Sprintf("%s: %s", res, ce.Stderr) 159 } 160 return res 161 } 162 163 func newCmdError(args string, cause error, stderr string) *CmdError { 164 return &CmdError{Args: args, Stderr: stderr, Cause: cause} 165 } 166 167 // Environ returns a list of environment variables in name=value format from a list of variables 168 func environ(envVars []*apiclient.EnvEntry) []string { 169 var environ []string 170 for _, item := range envVars { 171 if item != nil && item.Name != "" { 172 environ = append(environ, fmt.Sprintf("%s=%s", item.Name, item.Value)) 173 } 174 } 175 return environ 176 } 177 178 // getTempDirMustCleanup creates a temporary directory and returns a cleanup function. 179 func getTempDirMustCleanup(baseDir string) (workDir string, cleanup func(), err error) { 180 workDir, err = files.CreateTempDir(baseDir) 181 if err != nil { 182 return "", nil, fmt.Errorf("error creating temp dir: %w", err) 183 } 184 cleanup = func() { 185 if err := os.RemoveAll(workDir); err != nil { 186 log.WithFields(map[string]any{ 187 common.SecurityField: common.SecurityHigh, 188 common.SecurityCWEField: common.SecurityCWEIncompleteCleanup, 189 }).Errorf("Failed to clean up temp directory: %s", err) 190 } 191 } 192 return workDir, cleanup, nil 193 } 194 195 type Stream interface { 196 Recv() (*apiclient.AppStreamRequest, error) 197 Context() context.Context 198 } 199 200 type GenerateManifestStream interface { 201 Stream 202 SendAndClose(response *apiclient.ManifestResponse) error 203 } 204 205 // GenerateManifest runs generate command from plugin config file and returns generated manifest files 206 func (s *Service) GenerateManifest(stream apiclient.ConfigManagementPluginService_GenerateManifestServer) error { 207 return s.generateManifestGeneric(stream) 208 } 209 210 func (s *Service) generateManifestGeneric(stream GenerateManifestStream) error { 211 ctx, cancel := buffered_context.WithEarlierDeadline(stream.Context(), cmpTimeoutBuffer) 212 defer cancel() 213 workDir, cleanup, err := getTempDirMustCleanup(common.GetCMPWorkDir()) 214 if err != nil { 215 return fmt.Errorf("error creating workdir for manifest generation: %w", err) 216 } 217 defer cleanup() 218 219 metadata, err := cmp.ReceiveRepoStream(ctx, stream, workDir, s.initConstants.PluginConfig.Spec.PreserveFileMode) 220 if err != nil { 221 return fmt.Errorf("generate manifest error receiving stream: %w", err) 222 } 223 224 appPath := filepath.Clean(filepath.Join(workDir, metadata.AppRelPath)) 225 if !strings.HasPrefix(appPath, workDir) { 226 return errors.New("illegal appPath: out of workDir bound") 227 } 228 response, err := s.generateManifest(ctx, appPath, metadata.GetEnv()) 229 if err != nil { 230 return fmt.Errorf("error generating manifests: %w", err) 231 } 232 233 log.Tracef("Generated manifests result: %s", response.Manifests) 234 235 err = stream.SendAndClose(response) 236 if err != nil { 237 return fmt.Errorf("error sending manifest response: %w", err) 238 } 239 return nil 240 } 241 242 // generateManifest runs generate command from plugin config file and returns generated manifest files 243 func (s *Service) generateManifest(ctx context.Context, appDir string, envEntries []*apiclient.EnvEntry) (*apiclient.ManifestResponse, error) { 244 if deadline, ok := ctx.Deadline(); ok { 245 log.Infof("Generating manifests with deadline %v from now", time.Until(deadline)) 246 } else { 247 log.Info("Generating manifests with no request-level timeout") 248 } 249 250 config := s.initConstants.PluginConfig 251 252 env := append(os.Environ(), environ(envEntries)...) 253 if len(config.Spec.Init.Command) > 0 { 254 _, err := runCommand(ctx, config.Spec.Init, appDir, env) 255 if err != nil { 256 return &apiclient.ManifestResponse{}, err 257 } 258 } 259 260 out, err := runCommand(ctx, config.Spec.Generate, appDir, env) 261 if err != nil { 262 return &apiclient.ManifestResponse{}, err 263 } 264 265 manifests, err := kube.SplitYAMLToString([]byte(out)) 266 if err != nil { 267 sanitizedManifests := manifests 268 if len(sanitizedManifests) > 1000 { 269 sanitizedManifests = manifests[:1000] 270 } 271 log.Debugf("Failed to split generated manifests. Beginning of generated manifests: %q", sanitizedManifests) 272 return &apiclient.ManifestResponse{}, err 273 } 274 275 return &apiclient.ManifestResponse{ 276 Manifests: manifests, 277 }, err 278 } 279 280 type MatchRepositoryStream interface { 281 Stream 282 SendAndClose(response *apiclient.RepositoryResponse) error 283 } 284 285 // MatchRepository receives the application stream and checks whether 286 // its repository type is supported by the config management plugin 287 // server. 288 // The checks are implemented in the following order: 289 // 1. If spec.Discover.FileName is provided it finds for a name match in Applications files 290 // 2. If spec.Discover.Find.Glob is provided if finds for a glob match in Applications files 291 // 3. Otherwise it runs the spec.Discover.Find.Command 292 func (s *Service) MatchRepository(stream apiclient.ConfigManagementPluginService_MatchRepositoryServer) error { 293 return s.matchRepositoryGeneric(stream) 294 } 295 296 func (s *Service) matchRepositoryGeneric(stream MatchRepositoryStream) error { 297 bufferedCtx, cancel := buffered_context.WithEarlierDeadline(stream.Context(), cmpTimeoutBuffer) 298 defer cancel() 299 300 workDir, cleanup, err := getTempDirMustCleanup(common.GetCMPWorkDir()) 301 if err != nil { 302 return fmt.Errorf("error creating workdir for repository matching: %w", err) 303 } 304 defer cleanup() 305 306 metadata, err := cmp.ReceiveRepoStream(bufferedCtx, stream, workDir, s.initConstants.PluginConfig.Spec.PreserveFileMode) 307 if err != nil { 308 return fmt.Errorf("match repository error receiving stream: %w", err) 309 } 310 311 isSupported, isDiscoveryEnabled, err := s.matchRepository(bufferedCtx, workDir, metadata.GetEnv(), metadata.GetAppRelPath()) 312 if err != nil { 313 return fmt.Errorf("match repository error: %w", err) 314 } 315 repoResponse := &apiclient.RepositoryResponse{IsSupported: isSupported, IsDiscoveryEnabled: isDiscoveryEnabled} 316 317 err = stream.SendAndClose(repoResponse) 318 if err != nil { 319 return fmt.Errorf("error sending match repository response: %w", err) 320 } 321 return nil 322 } 323 324 func (s *Service) matchRepository(ctx context.Context, workdir string, envEntries []*apiclient.EnvEntry, appRelPath string) (isSupported bool, isDiscoveryEnabled bool, err error) { 325 config := s.initConstants.PluginConfig 326 327 appPath, err := securejoin.SecureJoin(workdir, appRelPath) 328 if err != nil { 329 log.WithFields(map[string]any{ 330 common.SecurityField: common.SecurityHigh, 331 common.SecurityCWEField: common.SecurityCWEIncompleteCleanup, 332 }).Errorf("error joining workdir %q and appRelPath %q: %v", workdir, appRelPath, err) 333 } 334 335 if config.Spec.Discover.FileName != "" { 336 log.Debugf("config.Spec.Discover.FileName is provided") 337 pattern := filepath.Join(appPath, config.Spec.Discover.FileName) 338 matches, err := filepath.Glob(pattern) 339 if err != nil { 340 e := fmt.Errorf("error finding filename match for pattern %q: %w", pattern, err) 341 log.Debug(e) 342 return false, true, e 343 } 344 return len(matches) > 0, true, nil 345 } 346 347 if config.Spec.Discover.Find.Glob != "" { 348 log.Debugf("config.Spec.Discover.Find.Glob is provided") 349 pattern := filepath.Join(appPath, config.Spec.Discover.Find.Glob) 350 // filepath.Glob doesn't have '**' support hence selecting third-party lib 351 // https://github.com/golang/go/issues/11862 352 matches, err := zglob.Glob(pattern) 353 if err != nil { 354 e := fmt.Errorf("error finding glob match for pattern %q: %w", pattern, err) 355 log.Debug(e) 356 return false, true, e 357 } 358 359 return len(matches) > 0, true, nil 360 } 361 362 if len(config.Spec.Discover.Find.Command.Command) > 0 { 363 log.Debugf("Going to try runCommand.") 364 env := append(os.Environ(), environ(envEntries)...) 365 find, err := runCommand(ctx, config.Spec.Discover.Find.Command, appPath, env) 366 if err != nil { 367 return false, true, fmt.Errorf("error running find command: %w", err) 368 } 369 return find != "", true, nil 370 } 371 372 return false, false, nil 373 } 374 375 // ParametersAnnouncementStream defines an interface able to send/receive a stream of parameter announcements. 376 type ParametersAnnouncementStream interface { 377 Stream 378 SendAndClose(response *apiclient.ParametersAnnouncementResponse) error 379 } 380 381 // GetParametersAnnouncement gets parameter announcements for a given Application and repo contents. 382 func (s *Service) GetParametersAnnouncement(stream apiclient.ConfigManagementPluginService_GetParametersAnnouncementServer) error { 383 bufferedCtx, cancel := buffered_context.WithEarlierDeadline(stream.Context(), cmpTimeoutBuffer) 384 defer cancel() 385 386 workDir, cleanup, err := getTempDirMustCleanup(common.GetCMPWorkDir()) 387 if err != nil { 388 return fmt.Errorf("error creating workdir for generating parameter announcements: %w", err) 389 } 390 defer cleanup() 391 392 metadata, err := cmp.ReceiveRepoStream(bufferedCtx, stream, workDir, s.initConstants.PluginConfig.Spec.PreserveFileMode) 393 if err != nil { 394 return fmt.Errorf("parameters announcement error receiving stream: %w", err) 395 } 396 appPath := filepath.Clean(filepath.Join(workDir, metadata.AppRelPath)) 397 if !strings.HasPrefix(appPath, workDir) { 398 return errors.New("illegal appPath: out of workDir bound") 399 } 400 401 repoResponse, err := getParametersAnnouncement(bufferedCtx, appPath, s.initConstants.PluginConfig.Spec.Parameters.Static, s.initConstants.PluginConfig.Spec.Parameters.Dynamic, metadata.GetEnv()) 402 if err != nil { 403 return fmt.Errorf("get parameters announcement error: %w", err) 404 } 405 406 err = stream.SendAndClose(repoResponse) 407 if err != nil { 408 return fmt.Errorf("error sending parameters announcement response: %w", err) 409 } 410 return nil 411 } 412 413 func getParametersAnnouncement(ctx context.Context, appDir string, announcements []*repoclient.ParameterAnnouncement, command Command, envEntries []*apiclient.EnvEntry) (*apiclient.ParametersAnnouncementResponse, error) { 414 augmentedAnnouncements := announcements 415 416 if len(command.Command) > 0 { 417 env := append(os.Environ(), environ(envEntries)...) 418 stdout, err := runCommand(ctx, command, appDir, env) 419 if err != nil { 420 return nil, fmt.Errorf("error executing dynamic parameter output command: %w", err) 421 } 422 423 var dynamicParamAnnouncements []*repoclient.ParameterAnnouncement 424 err = json.Unmarshal([]byte(stdout), &dynamicParamAnnouncements) 425 if err != nil { 426 return nil, fmt.Errorf("error unmarshaling dynamic parameter output into ParametersAnnouncementResponse: %w", err) 427 } 428 429 // dynamic goes first, because static should take precedence by being later. 430 augmentedAnnouncements = append(dynamicParamAnnouncements, announcements...) 431 } 432 433 repoResponse := &apiclient.ParametersAnnouncementResponse{ 434 ParameterAnnouncements: augmentedAnnouncements, 435 } 436 return repoResponse, nil 437 } 438 439 func (s *Service) CheckPluginConfiguration(_ context.Context, _ *empty.Empty) (*apiclient.CheckPluginConfigurationResponse, error) { 440 isDiscoveryConfigured := s.isDiscoveryConfigured() 441 response := &apiclient.CheckPluginConfigurationResponse{IsDiscoveryConfigured: isDiscoveryConfigured, ProvideGitCreds: s.initConstants.PluginConfig.Spec.ProvideGitCreds} 442 443 return response, nil 444 } 445 446 func (s *Service) isDiscoveryConfigured() (isDiscoveryConfigured bool) { 447 config := s.initConstants.PluginConfig 448 return config.Spec.Discover.FileName != "" || config.Spec.Discover.Find.Glob != "" || len(config.Spec.Discover.Find.Command.Command) > 0 449 }