aks-node-controller/app.go (157 lines of code) (raw):

package main import ( "bytes" "context" "errors" "flag" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "github.com/Azure/agentbaker/aks-node-controller/parser" "github.com/Azure/agentbaker/aks-node-controller/pkg/nodeconfigutils" "github.com/fsnotify/fsnotify" ) type App struct { // cmdRunner is a function that runs the given command. // the goal of this field is to make it easier to test the app by mocking the command runner. cmdRunner func(cmd *exec.Cmd) error } func cmdRunner(cmd *exec.Cmd) error { return cmd.Run() } func cmdRunnerDryRun(cmd *exec.Cmd) error { slog.Info("dry-run", "cmd", cmd.String()) return nil } type ProvisionFlags struct { ProvisionConfig string } type ProvisionStatusFiles struct { ProvisionJSONFile string ProvisionCompleteFile string } func (a *App) Run(ctx context.Context, args []string) int { slog.Info("aks-node-controller started") err := a.run(ctx, args) exitCode := errToExitCode(err) if exitCode == 0 { slog.Info("aks-node-controller finished successfully") } else { slog.Error("aks-node-controller failed", "error", err) } return exitCode } func (a *App) run(ctx context.Context, args []string) error { if len(args) < 2 { return errors.New("missing command argument") } switch args[1] { case "provision": fs := flag.NewFlagSet("provision", flag.ContinueOnError) provisionConfig := fs.String("provision-config", "", "path to the provision config file") dryRun := fs.Bool("dry-run", false, "print the command that would be run without executing it") err := fs.Parse(args[2:]) if err != nil { return fmt.Errorf("parse args: %w", err) } if provisionConfig == nil || *provisionConfig == "" { return errors.New("--provision-config is required") } if dryRun != nil && *dryRun { a.cmdRunner = cmdRunnerDryRun } return a.Provision(ctx, ProvisionFlags{ProvisionConfig: *provisionConfig}) case "provision-wait": provisionStatusFiles := ProvisionStatusFiles{ProvisionJSONFile: provisionJSONFilePath, ProvisionCompleteFile: provisionCompleteFilePath} provisionOutput, err := a.ProvisionWait(ctx, provisionStatusFiles) //nolint:forbidigo // stdout is part of the interface fmt.Println(provisionOutput) slog.Info("provision-wait finished", "provisionOutput", provisionOutput) return err default: return fmt.Errorf("unknown command: %s", args[1]) } } func (a *App) Provision(ctx context.Context, flags ProvisionFlags) error { inputJSON, err := os.ReadFile(flags.ProvisionConfig) if err != nil { return fmt.Errorf("open provision file %s: %w", flags.ProvisionConfig, err) } config, err := nodeconfigutils.UnmarshalConfigurationV1(inputJSON) if err != nil { return fmt.Errorf("unmarshal provision config: %w", err) } // TODO: "v0" were a mistake. We are not going to have different logic maintaining both v0 and v1 // Disallow "v0" after some time (allow some time to update consumers) if config.Version != "v0" && config.Version != "v1" { return fmt.Errorf("unsupported version: %s", config.Version) } if config.Version == "v0" { slog.Error("v0 version is deprecated, please use v1 instead") } cmd, err := parser.BuildCSECmd(ctx, config) if err != nil { return fmt.Errorf("build CSE command: %w", err) } var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf) cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) err = a.cmdRunner(cmd) exitCode := -1 if cmd.ProcessState != nil { exitCode = cmd.ProcessState.ExitCode() } // Is it ok to log a single line? Is it too much? slog.Info("CSE finished", "exitCode", exitCode, "stdout", stdoutBuf.String(), "stderr", stderrBuf.String(), "error", err) return err } func (a *App) ProvisionWait(ctx context.Context, filepaths ProvisionStatusFiles) (string, error) { if _, err := os.Stat(filepaths.ProvisionCompleteFile); err == nil { data, err := os.ReadFile(filepaths.ProvisionJSONFile) if err != nil { return "", fmt.Errorf("failed to read provision.json: %w", err) } return string(data), nil } watcher, err := fsnotify.NewWatcher() if err != nil { return "", fmt.Errorf("failed to create watcher: %w", err) } defer watcher.Close() // Watch the directory containing the provision complete file dir := filepath.Dir(filepaths.ProvisionCompleteFile) err = os.MkdirAll(dir, 0755) // create the directory if it doesn't exist if err != nil { return "", fmt.Errorf("failed to create directory %s: %w", dir, err) } if err = watcher.Add(dir); err != nil { return "", fmt.Errorf("failed to watch directory: %w", err) } for { select { case event := <-watcher.Events: if event.Op&fsnotify.Create == fsnotify.Create && event.Name == filepaths.ProvisionCompleteFile { data, err := os.ReadFile(filepaths.ProvisionJSONFile) if err != nil { return "", fmt.Errorf("failed to read provision.json: %w", err) } return string(data), nil } case err := <-watcher.Errors: return "", fmt.Errorf("error watching file: %w", err) case <-ctx.Done(): return "", fmt.Errorf("context deadline exceeded waiting for provision complete: %w", ctx.Err()) } } } var _ ExitCoder = &exec.ExitError{} type ExitCoder interface { error ExitCode() int } func errToExitCode(err error) int { if err == nil { return 0 } var exitErr ExitCoder if errors.As(err, &exitErr) { return exitErr.ExitCode() } return 1 }