diff --git a/tools/applyconfig/README.md b/tools/applyconfig/README.md new file mode 100644 index 0000000000000..9e1d3f4c29bd4 --- /dev/null +++ b/tools/applyconfig/README.md @@ -0,0 +1,33 @@ +# ApplyConfig + +ApplyConfig is a tool for checking and applying cluster and service +configuration to the cluster. It behaves similarly to `oc apply -f directory/ --recursive` +but knows some additional DPTP conventions: + +1. Knows the distinction between admin resources and other resources +2. Allows non-resources YAML files to be present +3. Ignores directories and files that are marked by a convention +4. Ignores JSON files + +## Usage + +In general, `applyconfig --config-dir DIRECTORY` searches for all resource +config files under `DIRECTORY` and applies it. Subdirectories are searched +recursively and directories with names starting with `_` are skipped. Files and +directories are searched and applied in lexicographical order. All YAML files +are considered to be a config to apply, except those with filenames starting +with `_`. Files starting with `admin_` are considered to be admin resources, all +others are considered standard resources. + +By default, `applyconfig` only runs in dry-mode, validating that eventual full +run would be successful. To issue a full run that actually commits the config +to the cluster, add a `--confirm=true` option. + +By default, `applyconfig` works with non-admin resources. To apply admin +resources, use a `--level=admin` option. It is also possible to use +`--level=all` to apply both admin and standard resources. In this case, **all** +admin resources are applied first. + +By default, standard resources are applied using user's standard credentials and +admin resources are attempted to apply as `system:admin`. It is possible to pass +the username to impersonate using the `--as=USER` option. diff --git a/tools/applyconfig/applyconfig.go b/tools/applyconfig/applyconfig.go new file mode 100644 index 0000000000000..e41abf75f1f83 --- /dev/null +++ b/tools/applyconfig/applyconfig.go @@ -0,0 +1,189 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +type level string + +type options struct { + confirm bool + level level + user string + directory string +} + +const ( + standardLevel level = "standard" + adminLevel level = "admin" + allLevel level = "all" +) + +const defaultAdminUser = "system:admin" + +func (l level) isValid() bool { + return l == standardLevel || l == adminLevel || l == allLevel +} + +func (l level) shouldApplyAdmin() bool { + return l == adminLevel || l == allLevel +} + +func (l level) shouldApplyStandard() bool { + return l == standardLevel || l == allLevel +} + +var adminConfig = regexp.MustCompile(`^admin_.+\.yaml$`) + +func gatherOptions() *options { + opt := &options{} + var lvl string + flag.BoolVar(&opt.confirm, "confirm", false, "Set to true to make applyconfig commit the config to the cluster") + flag.StringVar(&lvl, "level", "standard", "Select which config to apply (standard, admin, all)") + flag.StringVar(&opt.user, "as", "", "Username to impersonate while applying the config") + flag.StringVar(&opt.directory, "config-dir", "", "Directory with config to apply") + + opt.level = level(lvl) + + flag.Parse() + + if !opt.level.isValid() { + fmt.Fprintf(os.Stderr, "--level: must be one of [standard, admin, all]\n") + os.Exit(1) + } + + if opt.directory == "" { + fmt.Fprintf(os.Stderr, "--config-dir must be provided\n") + os.Exit(1) + } + + return opt +} + +func isAdminConfig(filename string) bool { + return adminConfig.MatchString(filename) +} + +func isStandardConfig(filename string) bool { + return filepath.Ext(filename) == ".yaml" && + !isAdminConfig(filename) +} + +func makeOcArgs(path, user string, dry bool) []string { + args := []string{"apply", "-f", path} + if dry { + args = append(args, "--dry-run") + } + + if user != "" { + args = append(args, "--as", user) + } + + return args +} + +func apply(path, user string, dry bool) error { + args := makeOcArgs(path, user, dry) + + cmd := exec.Command("oc", args...) + if output, err := cmd.CombinedOutput(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + fmt.Printf("[ERROR] oc %s: failed to apply\n%s\n", strings.Join(args, " "), string(output)) + } else { + fmt.Printf("[ERROR] oc %s: failed to execute: %v\n", strings.Join(args, " "), err) + } + return fmt.Errorf("failed to apply config") + } + + fmt.Printf("oc %s: OK\n", strings.Join(args, " ")) + return nil +} + +type processFn func(name, path string) error + +func applyConfig(rootDir, cfgType string, process processFn) error { + failures := false + if err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + if strings.HasPrefix(info.Name(), "_") { + fmt.Printf("Skipping directory: %s\n", path) + return filepath.SkipDir + } + fmt.Printf("Applying %s config in directory: %s\n", cfgType, path) + return nil + } + + if err := process(info.Name(), path); err != nil { + failures = true + } + + return nil + }); err != nil { + // should not happen + fmt.Fprintf(os.Stderr, "failed to walk directory '%s': %v\n", rootDir, err) + return err + } + + if failures { + return fmt.Errorf("failed to apply admin config") + } + + return nil +} + +func main() { + o := gatherOptions() + var adminErr, standardErr error + + if o.level.shouldApplyAdmin() { + if o.user == "" { + o.user = defaultAdminUser + } + + f := func(name, path string) error { + if !isAdminConfig(name) { + return nil + } + return apply(path, o.user, !o.confirm) + } + + adminErr = applyConfig(o.directory, "admin", f) + if adminErr != nil { + fmt.Printf("There were failures while applying admin config\n") + } + } + + if o.level.shouldApplyStandard() { + f := func(name, path string) error { + if !isStandardConfig(name) { + return nil + } + if strings.HasPrefix(name, "_") { + return nil + } + + return apply(path, o.user, !o.confirm) + } + + standardErr = applyConfig(o.directory, "standard", f) + if standardErr != nil { + fmt.Printf("There were failures while applying standard config\n") + } + } + + if standardErr != nil || adminErr != nil { + os.Exit(1) + } + + fmt.Printf("Success!\n") +} diff --git a/tools/applyconfig/applyconfig_test.go b/tools/applyconfig/applyconfig_test.go new file mode 100644 index 0000000000000..20e2651fc41d6 --- /dev/null +++ b/tools/applyconfig/applyconfig_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestIsAdminConfig(t *testing.T) { + testCases := []struct{ + filename string + expected bool + }{ + { + filename: "admin_01_something_rbac.yaml", + expected: true, + }, + { + filename: "admin_something_rbac.yaml", + expected: true, + }, + // Negative + { filename: "cfg_01_something" }, + { filename: "admin_01_something_rbac" }, + { filename: "admin_01_something_rbac.yml" }, + { filename: "admin.yaml" }, + } + + for _, tc := range testCases { + t.Run(tc.filename, func(t *testing.T){ + is := isAdminConfig(tc.filename) + if is != tc.expected { + t.Errorf("expected %t, got %t", tc.expected, is) + } + }) + } +} + +func TestIsStandardConfig(t *testing.T) { + testCases := []struct{ + filename string + expected bool + }{ + { + filename: "01_something_rbac.yaml", + expected: true, + }, + { + filename: "something_rbac.yaml", + expected: true, + }, + // Negative + { filename: "admin_01_something.yaml" }, + { filename: "cfg_01_something_rbac" }, + { filename: "cfg_01_something_rbac.yml" }, + } + + for _, tc := range testCases { + t.Run(tc.filename, func(t *testing.T){ + is := isStandardConfig(tc.filename) + if is != tc.expected { + t.Errorf("expected %t, got %t", tc.expected, is) + } + }) + } +} + +func TestMakeOcArgs(t *testing.T) { + testCases := []struct{ + name string + + path string + user string + dry bool + + expected []string + }{ + { + name: "no user, not dry", + path: "/path/to/file", + expected: []string{"apply", "-f", "/path/to/file"}, + }, + { + name: "no user, dry", + path: "/path/to/different/file", + dry: true, + expected: []string{"apply", "-f", "/path/to/different/file", "--dry-run"}, + }, + { + name: "user, dry", + path: "/path/to/file", + dry: true, + user: "joe", + expected: []string{"apply", "-f", "/path/to/file", "--dry-run", "--as", "joe"}, + }, + { + name: "user, not dry", + path: "/path/to/file", + user: "joe", + expected: []string{"apply", "-f", "/path/to/file", "--as", "joe"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T){ + args := makeOcArgs(tc.path, tc.user, tc.dry) + if !reflect.DeepEqual(args, tc.expected) { + t.Errorf("Expected '%v', got '%v'", tc.expected, args) + } + }) + } +} +