...

Source file src/edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/loaddata/loaddata.go

Documentation: edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/loaddata

     1  package loaddata
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"os"
    10  	"slices"
    11  	"strings"
    12  
    13  	"github.com/peterbourgon/ff/v3"
    14  	"github.com/shurcooL/graphql"
    15  
    16  	"edge-infra.dev/pkg/edge/api/client"
    17  	"edge-infra.dev/pkg/edge/api/graph/model"
    18  	"edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/edgeextension"
    19  	"edge-infra.dev/pkg/edge/edgeadmin/commands/operatorintervention/format"
    20  	"edge-infra.dev/pkg/edge/edgecli"
    21  	"edge-infra.dev/pkg/edge/edgecli/flagutil"
    22  	"edge-infra.dev/pkg/lib/cli/command"
    23  	"edge-infra.dev/pkg/lib/cli/rags"
    24  )
    25  
    26  // config represents the configuration structure for loading data.
    27  type config struct {
    28  	RoleMappings map[string][]string `json:"ROLE_MAPPING"`
    29  	Rules        map[string][]string `json:"RULES"`
    30  }
    31  
    32  func (cfg config) Validate() error {
    33  	var err error
    34  	if len(cfg.RoleMappings) == 0 && len(cfg.Rules) == 0 {
    35  		err = errors.Join(err, errors.New("empty config"))
    36  	}
    37  	for role, privileges := range cfg.RoleMappings {
    38  		// Every role must have at least one privilege
    39  		if len(privileges) == 0 {
    40  			err = errors.Join(err, fmt.Errorf("role %q has no privileges", role))
    41  		}
    42  		for _, privilege := range privileges {
    43  			// Privileges must be non-nil
    44  			if privilege == "" {
    45  				err = errors.Join(err, fmt.Errorf("role %q has empty privilege", role))
    46  			}
    47  		}
    48  	}
    49  	for privilege, commands := range cfg.Rules {
    50  		// Every privilege must have at least one command
    51  		if len(commands) == 0 {
    52  			err = errors.Join(err, fmt.Errorf("privilege %q has no commands", privilege))
    53  		}
    54  		for _, command := range commands {
    55  			// Commands must be non-nil
    56  			if command == "" {
    57  				err = errors.Join(err, fmt.Errorf("privilege %q has empty command", privilege))
    58  			}
    59  		}
    60  	}
    61  	return err
    62  }
    63  
    64  const (
    65  	longHelpString = `
    66  	This command loads Operator Intervention configuration from a file and stores it in the database. This allows for bulk configuration of role mappings and rules.
    67  	Any privileges or commands added through this operation will be added automatically to the database. Duplicate entries will be ignored.
    68  
    69  	The configuration file should contain role mappings and rules in JSON format as follows:
    70  	{
    71  	"ROLE_MAPPING": {
    72  		"EDGE_BANNER_ADMIN": [
    73  			"ea-read",
    74  			"ea-banner-admin"
    75  		]
    76  	},
    77  	"RULES": {
    78  		"ea-write": [
    79  			"ls",
    80  			"cat"
    81  		],
    82  		"ea-banner-admin": [
    83  			"kubectl"
    84  			]
    85  		}
    86  	}
    87  	Note that the top level keys are optional.
    88  
    89  	This file can then be saved to and passed to the binary using the --file flag as follows:
    90  
    91  	~/edge-infra$ edgeadmin operatorintervention loaddata --file /path/to/file.json
    92  	`
    93  )
    94  
    95  // NewCmd creates a new command for loading OperatorIntervention configuration data.
    96  func NewCmd(cfg *edgecli.Config) *command.Command {
    97  	var (
    98  		edge = &edgeextension.Ext{Cfg: cfg}
    99  	)
   100  
   101  	var cmd *command.Command
   102  	cmd = &command.Command{
   103  		ShortUsage: "edgeadmin operatorintervention loaddata",
   104  		ShortHelp:  "loads operator intervention configuration from a file to the database",
   105  		LongHelp:   longHelpString,
   106  		Options:    []ff.Option{},
   107  		Flags: []*rags.Rag{
   108  			{
   109  				Name:     flagutil.LoadData,
   110  				Usage:    "path to file with rolemappings and rules",
   111  				Value:    &rags.String{},
   112  				Required: true,
   113  			},
   114  		},
   115  
   116  		Extensions: []command.Extension{edge},
   117  
   118  		Exec: func(ctx context.Context, _ []string) error {
   119  			configPath := flagutil.GetStringFlag(cmd.Rags, flagutil.LoadData)
   120  
   121  			config, err := loadConfig(configPath)
   122  			if err != nil {
   123  				fmt.Printf("error loading config \n")
   124  				return err
   125  			}
   126  
   127  			err = loadData(ctx, edge.Client, config)
   128  			if err != nil {
   129  				fmt.Println("an error occurred while loading data")
   130  				return err
   131  			}
   132  
   133  			return nil
   134  		},
   135  	}
   136  	return cmd
   137  }
   138  
   139  // loadConfig loads the configuration from the specified file path.
   140  // returns an empty config and an error if the file is not found or the data is invalid.
   141  func loadConfig(configPath string) (config, error) {
   142  	data, err := os.ReadFile(configPath)
   143  	if err != nil {
   144  		return config{}, err
   145  	}
   146  	var conf config
   147  	decoder := json.NewDecoder(bytes.NewReader(data))
   148  	// Strict decoder that enforces the config file only having expected fields.
   149  	decoder.DisallowUnknownFields()
   150  	if err = decoder.Decode(&conf); err != nil {
   151  		return config{}, err
   152  	}
   153  	if decoder.More() {
   154  		return config{}, errors.New("error multiple objects in config file")
   155  	}
   156  	if err := conf.Validate(); err != nil {
   157  		return config{}, fmt.Errorf("error invalid config: %w", err)
   158  	}
   159  	return conf, nil
   160  }
   161  
   162  // createVariables is used to create a map which can be used as the variables
   163  // argument of a mutation call when applying all rules and role mappings.
   164  // We don't want to make the mutation call if the user has not supplied the
   165  // appropriate key in the configuration file, so include skip variables which
   166  // can be used to tell the server to not process specific mutations.
   167  func createVariables(conf config) map[string]interface{} {
   168  	// Build up the fulll list of all privileges and commands to be added by
   169  	// discovering each value referenced in the role mappings and rules
   170  	var allPrivs []string
   171  	var allCommands []string
   172  
   173  	// Build up the role mapping data type that the mutation expects
   174  	roleMappings := make([]*model.UpdateOperatorInterventionRoleMappingInput, 0, len(conf.RoleMappings))
   175  	for role, privileges := range conf.RoleMappings {
   176  		// Collect all referenced privileges
   177  		allPrivs = append(allPrivs, privileges...)
   178  
   179  		// Add the role mapping
   180  		roleMappings = append(roleMappings, &model.UpdateOperatorInterventionRoleMappingInput{
   181  			Role:       role,
   182  			Privileges: convertToPrivilegeInputs(privileges),
   183  		})
   184  	}
   185  
   186  	rules := make([]model.UpdateOperatorInterventionRuleInput, 0, len(conf.Rules))
   187  	for privilege, commands := range conf.Rules {
   188  		// Collect all referenced privileges and commands
   189  		allPrivs = append(allPrivs, privilege)
   190  		allCommands = append(allCommands, commands...)
   191  
   192  		// Add the rule
   193  		rules = append(rules, model.UpdateOperatorInterventionRuleInput{
   194  			Privilege: &model.OperatorInterventionPrivilegeInput{
   195  				Name: privilege,
   196  			},
   197  			Commands: convertToCommandInputs(commands),
   198  		})
   199  	}
   200  
   201  	// Ensure there are no duplicate privileges to be added
   202  	slices.Sort(allPrivs)
   203  	allPrivs = slices.Compact(allPrivs)
   204  	// From the collected privileges build up the mutation input type.
   205  	privileges := make([]model.OperatorInterventionPrivilegeInput, 0, len(allPrivs))
   206  	for _, priv := range allPrivs {
   207  		privileges = append(privileges, model.OperatorInterventionPrivilegeInput{
   208  			Name: priv,
   209  		})
   210  	}
   211  
   212  	// Ensure there are no duplicate commands to be added
   213  	slices.Sort(allCommands)
   214  	allCommands = slices.Compact(allCommands)
   215  	// From the collected commands build up the muatation input type.
   216  	commands := make([]model.OperatorInterventionCommandInput, 0, len(allCommands))
   217  	for _, com := range allCommands {
   218  		commands = append(commands, model.OperatorInterventionCommandInput{
   219  			Name: com,
   220  		})
   221  	}
   222  
   223  	return map[string]interface{}{
   224  		// Skip mutations if there is no data to add
   225  		"skipCommands":     graphql.Boolean(len(commands) == 0),
   226  		"skipPrivileges":   graphql.Boolean(len(privileges) == 0),
   227  		"skipRules":        graphql.Boolean(len(rules) == 0),
   228  		"skipRoleMappings": graphql.Boolean(len(roleMappings) == 0),
   229  
   230  		"roleMappings": roleMappings,
   231  		"privileges":   privileges,
   232  		"commands":     commands,
   233  		"rules":        rules,
   234  	}
   235  }
   236  
   237  // converts a slice of commands into a slice of the model.OperatorInterventionCommandInput
   238  // type
   239  func convertToCommandInputs(commands []string) []*model.OperatorInterventionCommandInput {
   240  	out := []*model.OperatorInterventionCommandInput{}
   241  	for _, command := range commands {
   242  		out = append(out, &model.OperatorInterventionCommandInput{
   243  			Name: command,
   244  		})
   245  	}
   246  	return out
   247  }
   248  
   249  // converts a slice of privileges into a slice of the model.OperatorInterventionPrivilegeInput
   250  // type
   251  func convertToPrivilegeInputs(privileges []string) []*model.OperatorInterventionPrivilegeInput {
   252  	out := []*model.OperatorInterventionPrivilegeInput{}
   253  	for _, privilege := range privileges {
   254  		out = append(out, &model.OperatorInterventionPrivilegeInput{
   255  			Name: privilege,
   256  		})
   257  	}
   258  	return out
   259  }
   260  
   261  // oiLoadDataMutation is a struct which can be used to call all mutations for
   262  // the loaddata command in a single request to the server. The order is
   263  // important here - we need to add all commands and privileges before we add the
   264  // roles or rules, otherwise there is a chance we run into a db conflict
   265  type oiLoadDataMutation struct {
   266  	CreateOperatorInterventionCommands struct {
   267  		model.CreateOperatorInterventionCommandResponse
   268  	} `graphql:"createOperatorInterventionCommands(commands: $commands) @skip(if: $skipCommands)"`
   269  	CreateOperatorInterventionPrivileges struct {
   270  		model.CreateOperatorInterventionPrivilegeResponse
   271  	} `graphql:"createOperatorInterventionPrivileges(privileges: $privileges) @skip(if: $skipPrivileges)"`
   272  	UpdateOperatorInterventionRules struct {
   273  		model.UpdateOperatorInterventionRuleResponse
   274  	} `graphql:"updateOperatorInterventionRules(rules: $rules) @skip(if: $skipRules)"`
   275  	UpdateOperatorInterventionRoleMappings struct {
   276  		model.UpdateOperatorInterventionRoleMappingResponse
   277  	} `graphql:"updateOperatorInterventionRoleMappings(roleMappings: $roleMappings) @skip(if: $skipRoleMappings)"`
   278  }
   279  
   280  func loadData(ctx context.Context, cl *client.EdgeClient, conf config) error {
   281  	variables := createVariables(conf)
   282  
   283  	var mutation oiLoadDataMutation
   284  	err := cl.Mutate(ctx, &mutation, variables)
   285  	if err != nil {
   286  		return err
   287  	}
   288  
   289  	output, err := generateAllOutput(variables, mutation)
   290  	// error from generateAllOutput is only used to indicate if we should exit
   291  	// the binary with a non-zero exit code, it doesn't include significant
   292  	// information that we would like to display to the user. Display the user
   293  	// facing response which includes lots of details first before returning err
   294  	fmt.Print(output)
   295  	return err
   296  }
   297  
   298  // generateAllOutput is used to build up the output string which includes all
   299  // details from all executed mutations. It uses the skipXXXX variables to
   300  // determin if the mutation would have been executed on the server. It then
   301  // generates an output string with all details of errors.
   302  func generateAllOutput(variables map[string]interface{}, mutation oiLoadDataMutation) (string, error) {
   303  	var failed bool
   304  	var output []string
   305  
   306  	skipComamands := variables["skipCommands"]
   307  	if skip, ok := skipComamands.(graphql.Boolean); ok && bool(!skip) {
   308  		if len(mutation.CreateOperatorInterventionCommands.Errors) != 0 {
   309  			failed = true
   310  		}
   311  		output = append(output, format.GenerateApplyOutput("Commands", mutation.CreateOperatorInterventionCommands.Errors))
   312  	}
   313  
   314  	skipPrivileges := variables["skipPrivileges"]
   315  	if skip, ok := skipPrivileges.(graphql.Boolean); ok && bool(!skip) {
   316  		if len(mutation.CreateOperatorInterventionPrivileges.Errors) != 0 {
   317  			failed = true
   318  		}
   319  		output = append(output, format.GenerateApplyOutput("Privileges", mutation.CreateOperatorInterventionPrivileges.Errors))
   320  	}
   321  
   322  	skipRules := variables["skipRules"]
   323  	if skip, ok := skipRules.(graphql.Boolean); ok && bool(!skip) {
   324  		if len(mutation.UpdateOperatorInterventionRules.Errors) != 0 {
   325  			failed = true
   326  		}
   327  		output = append(output, format.GenerateApplyOutput("Rules", mutation.UpdateOperatorInterventionRules.Errors))
   328  	}
   329  
   330  	skipRoleMappings := variables["skipRoleMappings"]
   331  	if skip, ok := skipRoleMappings.(graphql.Boolean); ok && bool(!skip) {
   332  		if len(mutation.UpdateOperatorInterventionRoleMappings.Errors) != 0 {
   333  			failed = true
   334  		}
   335  		output = append(output, format.GenerateApplyOutput("Role Mappings", mutation.UpdateOperatorInterventionRoleMappings.Errors))
   336  	}
   337  
   338  	var err error
   339  	if failed {
   340  		err = fmt.Errorf("error during mutation")
   341  	}
   342  	return strings.Join(output, "\n"), err
   343  }
   344  

View as plain text