Skip to content

Commit 906c147

Browse files
qcserestipybupd
andauthored
configuration diff view for harbor config apply command with interactive confirmation prompt (#568)
* feat: start new function for null value filter Signed-off-by: Patrick Eschenbach <[email protected]> * feat: diff view for config apply and prompt -add templated function to extract config values from different models -add view for diff in color coded fashion -add user prompt for applied diff, if canceled not applied Signed-off-by: Patrick Eschenbach <[email protected]> * fix(lint): add missing headers Signed-off-by: Patrick Eschenbach <[email protected]> * feat: add flag for auto confirm Signed-off-by: Patrick Eschenbach <[email protected]> * fix(lint): add docs for yes flag Signed-off-by: Patrick Eschenbach <[email protected]> * feat(docs): add link to sdk in apply view Signed-off-by: Patrick Eschenbach <[email protected]> --------- Signed-off-by: Patrick Eschenbach <[email protected]> Co-authored-by: Prasanth Baskar <[email protected]>
1 parent d3264b7 commit 906c147

File tree

5 files changed

+270
-0
lines changed

5 files changed

+270
-0
lines changed

cmd/harbor/root/configurations/apply.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,26 @@
1414
package configurations
1515

1616
import (
17+
"bufio"
1718
"encoding/json"
1819
"fmt"
1920
"os"
2021
"path/filepath"
22+
"strings"
2123

24+
"github.com/charmbracelet/lipgloss"
2225
"github.com/goharbor/go-client/pkg/sdk/v2.0/models"
2326
"github.com/goharbor/harbor-cli/pkg/api"
27+
"github.com/goharbor/harbor-cli/pkg/utils"
28+
view "github.com/goharbor/harbor-cli/pkg/views/configurations/diff"
2429
"github.com/spf13/cobra"
2530
"gopkg.in/yaml.v2"
2631
)
2732

2833
func ApplyConfigCmd() *cobra.Command {
2934
var cfgFile string
35+
var skipConfirm bool
36+
3037
cmd := &cobra.Command{
3138
Use: "apply",
3239
Short: "Update system configurations from local config file",
@@ -61,6 +68,48 @@ Make sure to run 'harbor config get' first to populate the local config file wit
6168
return fmt.Errorf("no config file specified")
6269
}
6370

71+
response, err := api.GetConfigurations()
72+
if err != nil {
73+
return err
74+
}
75+
upstreamConfigs := utils.ExtractConfigValues(response.Payload) // *models.ConfigurationsResponse
76+
localConfigs := utils.ExtractConfigValues(configurations) // *models.Configurations
77+
78+
hasChanges := false
79+
for field, localVal := range localConfigs {
80+
upstreamVal, exists := upstreamConfigs[field]
81+
if !exists || fmt.Sprintf("%v", upstreamVal) != fmt.Sprintf("%v", localVal) {
82+
hasChanges = true
83+
break
84+
}
85+
}
86+
if !hasChanges {
87+
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
88+
fmt.Println(successStyle.Render("✓ No changes detected."))
89+
return nil
90+
}
91+
// Show diff
92+
view.DiffConfigurations(upstreamConfigs, localConfigs)
93+
94+
// Confirmation prompt
95+
if !skipConfirm {
96+
promptStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3"))
97+
fmt.Print(promptStyle.Render("Do you want to apply these changes? (y/N): "))
98+
99+
reader := bufio.NewReader(os.Stdin)
100+
userResponse, err := reader.ReadString('\n')
101+
if err != nil {
102+
return fmt.Errorf("failed to read user input: %v", err)
103+
}
104+
105+
userResponse = strings.TrimSpace(strings.ToLower(userResponse))
106+
if userResponse != "y" && userResponse != "yes" {
107+
cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
108+
fmt.Println(cancelStyle.Render("✗ Configuration update cancelled."))
109+
return nil
110+
}
111+
}
112+
64113
err = api.UpdateConfigurations(configurations)
65114
if err != nil {
66115
return fmt.Errorf("failed to update Harbor configurations: %v", err)
@@ -72,6 +121,7 @@ Make sure to run 'harbor config get' first to populate the local config file wit
72121
}
73122
flags := cmd.Flags()
74123
flags.StringVarP(&cfgFile, "configurations-file", "f", "", "Harbor configurations file to apply.")
124+
flags.BoolVarP(&skipConfirm, "yes", "y", false, "Skip confirmation prompt before applying changes.")
75125

76126
return cmd
77127
}

doc/cli-docs/harbor-config-apply.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ harbor config apply -f <config_file>
3030
```sh
3131
-f, --configurations-file string Harbor configurations file to apply.
3232
-h, --help help for apply
33+
-y, --yes Skip confirmation prompt before applying changes.
3334
```
3435

3536
### Options inherited from parent commands

doc/man-docs/man1/harbor-config-apply.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Make sure to run 'harbor config get' first to populate the local config file wit
2525
\fB-h\fP, \fB--help\fP[=false]
2626
help for apply
2727

28+
.PP
29+
\fB-y\fP, \fB--yes\fP[=false]
30+
Skip confirmation prompt before applying changes.
31+
2832

2933
.SH OPTIONS INHERITED FROM PARENT COMMANDS
3034
\fB-c\fP, \fB--config\fP=""

pkg/utils/reflect.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,51 @@ func isSecretConfigurationField(fieldName string) bool {
117117
}
118118
return secretFields[fieldName]
119119
}
120+
121+
type ConfigType interface {
122+
*models.Configurations | *models.ConfigurationsResponse
123+
}
124+
125+
func ExtractConfigValues[T ConfigType](cfg T) map[string]any {
126+
result := make(map[string]any)
127+
if cfg == nil {
128+
return result
129+
}
130+
v := reflect.ValueOf(cfg).Elem()
131+
t := v.Type()
132+
for i := 0; i < v.NumField(); i++ {
133+
field := v.Field(i)
134+
fieldName := t.Field(i).Name
135+
// Skip nil pointers
136+
if field.Kind() == reflect.Ptr && field.IsNil() {
137+
continue
138+
}
139+
configItem := field.Interface()
140+
// Use type switch to extract the correct Value
141+
switch v := configItem.(type) {
142+
case *models.StringConfigItem:
143+
if v.Value != "" {
144+
result[fieldName] = v.Value
145+
}
146+
case *models.BoolConfigItem:
147+
result[fieldName] = v.Value
148+
case *models.IntegerConfigItem:
149+
result[fieldName] = v.Value
150+
case *string:
151+
if v != nil && *v != "" {
152+
result[fieldName] = *v
153+
}
154+
default:
155+
// Handle generic pointer types using reflection
156+
val := reflect.ValueOf(configItem)
157+
if val.Kind() == reflect.Ptr && !val.IsNil() {
158+
deref := val.Elem()
159+
// Only include non-zero values
160+
if deref.IsValid() && !deref.IsZero() {
161+
result[fieldName] = deref.Interface()
162+
}
163+
}
164+
}
165+
}
166+
return result
167+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright Project Harbor Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package view
15+
16+
import (
17+
"fmt"
18+
"sort"
19+
"strings"
20+
21+
"github.com/charmbracelet/lipgloss"
22+
)
23+
24+
// DiffConfigurations displays configuration changes in AWS CDK style
25+
func DiffConfigurations(upstreamConfigs, localConfigs map[string]interface{}) {
26+
// Collect all unique field names and sort them
27+
allFields := make(map[string]bool)
28+
for field := range upstreamConfigs {
29+
allFields[field] = true
30+
}
31+
for field := range localConfigs {
32+
allFields[field] = true
33+
}
34+
35+
var sortedFields []string
36+
for field := range allFields {
37+
sortedFields = append(sortedFields, field)
38+
}
39+
sort.Strings(sortedFields)
40+
41+
// Track changes by type
42+
var additions, modifications, deletions []string
43+
changeDetails := make(map[string][2]string)
44+
45+
for _, field := range sortedFields {
46+
upstreamVal, hasUpstream := upstreamConfigs[field]
47+
localVal, hasLocal := localConfigs[field]
48+
49+
upstreamStr := formatValuePlain(upstreamVal, hasUpstream)
50+
localStr := formatValuePlain(localVal, hasLocal)
51+
52+
if !hasUpstream && hasLocal {
53+
additions = append(additions, field)
54+
changeDetails[field] = [2]string{"", localStr}
55+
} else if hasUpstream && !hasLocal {
56+
deletions = append(deletions, field)
57+
changeDetails[field] = [2]string{upstreamStr, ""}
58+
} else if upstreamStr != localStr {
59+
modifications = append(modifications, field)
60+
changeDetails[field] = [2]string{upstreamStr, localStr}
61+
}
62+
}
63+
64+
totalChanges := len(additions) + len(modifications) + len(deletions)
65+
if totalChanges == 0 {
66+
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
67+
fmt.Println(successStyle.Render("✓ No changes detected."))
68+
return
69+
}
70+
71+
// Define styles
72+
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
73+
addStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
74+
modifyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
75+
deleteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
76+
fieldStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
77+
oldValueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
78+
newValueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
79+
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
80+
infoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
81+
82+
// Header
83+
fmt.Println(headerStyle.Render("Configuration changes to be applied:"))
84+
fmt.Println(infoStyle.Render("(For available configuration fields, see: https://github.com/goharbor/go-client/blob/main/pkg/sdk/v2.0/models/configurations.go)"))
85+
86+
fmt.Println()
87+
88+
// Summary
89+
summary := []string{}
90+
if len(additions) > 0 {
91+
summary = append(summary, addStyle.Render(fmt.Sprintf("[+] %d to add", len(additions))))
92+
}
93+
if len(modifications) > 0 {
94+
summary = append(summary, modifyStyle.Render(fmt.Sprintf("[~] %d to modify", len(modifications))))
95+
}
96+
if len(deletions) > 0 {
97+
summary = append(summary, deleteStyle.Render(fmt.Sprintf("[-] %d to remove", len(deletions))))
98+
}
99+
fmt.Println(strings.Join(summary, " "))
100+
fmt.Println()
101+
102+
// Print additions
103+
if len(additions) > 0 {
104+
for _, field := range additions {
105+
values := changeDetails[field]
106+
fmt.Printf("%s %s\n", addStyle.Render("[+]"), fieldStyle.Render(field))
107+
fmt.Printf(" %s %s\n", grayStyle.Render("└─"), newValueStyle.Render(values[1]))
108+
fmt.Println()
109+
}
110+
}
111+
112+
// Print modifications
113+
if len(modifications) > 0 {
114+
for _, field := range modifications {
115+
values := changeDetails[field]
116+
fmt.Printf("%s %s\n", modifyStyle.Render("[~]"), fieldStyle.Render(field))
117+
fmt.Printf(" %s %s\n", oldValueStyle.Render("[-]"), oldValueStyle.Render(values[0]))
118+
fmt.Printf(" %s %s\n", newValueStyle.Render("[+]"), newValueStyle.Render(values[1]))
119+
fmt.Println()
120+
}
121+
}
122+
123+
// Print deletions
124+
if len(deletions) > 0 {
125+
for _, field := range deletions {
126+
values := changeDetails[field]
127+
fmt.Printf("%s %s\n", deleteStyle.Render("[-]"), fieldStyle.Render(field))
128+
fmt.Printf(" %s %s\n", grayStyle.Render("└─"), oldValueStyle.Render(values[0]))
129+
fmt.Println()
130+
}
131+
}
132+
}
133+
134+
// formatValuePlain converts a value to a plain string without styling
135+
func formatValuePlain(val interface{}, exists bool) string {
136+
if !exists {
137+
return "(not set)"
138+
}
139+
140+
if val == nil {
141+
return "(nil)"
142+
}
143+
144+
switch v := val.(type) {
145+
case string:
146+
if v == "" {
147+
return "(empty)"
148+
}
149+
// Quote strings to make them clear
150+
if len(v) > 60 {
151+
return fmt.Sprintf(`"%s..."`, v[:57])
152+
}
153+
return fmt.Sprintf(`"%s"`, v)
154+
case bool:
155+
return fmt.Sprintf("%t", v)
156+
case int, int64:
157+
return fmt.Sprintf("%d", v)
158+
case float64:
159+
return fmt.Sprintf("%.2f", v)
160+
default:
161+
str := fmt.Sprintf("%v", v)
162+
if len(str) > 60 {
163+
return str[:57] + "..."
164+
}
165+
return str
166+
}
167+
}

0 commit comments

Comments
 (0)