Mohammed Naser | d4617bb | 2023-12-07 19:35:33 -0500 | [diff] [blame] | 1 | // Copyright (c) 2023 VEXXHOST, Inc. |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 4 | // not use this file except in compliance with the License. You may obtain |
| 5 | // 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, WITHOUT |
| 11 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 12 | // License for the specific language governing permissions and limitations |
| 13 | // under the License. |
| 14 | |
| 15 | package main |
| 16 | |
| 17 | import ( |
| 18 | "bytes" |
| 19 | "context" |
| 20 | "encoding/json" |
| 21 | "fmt" |
| 22 | "net/url" |
| 23 | "os" |
| 24 | "path/filepath" |
| 25 | "strings" |
| 26 | |
| 27 | "github.com/vexxhost/atmosphere/internal/portforwardutil" |
| 28 | |
| 29 | "github.com/erikgeiser/promptkit/confirmation" |
| 30 | "github.com/nsf/jsondiff" |
| 31 | log "github.com/sirupsen/logrus" |
| 32 | "github.com/spf13/cobra" |
| 33 | "gopkg.in/ini.v1" |
| 34 | "gorm.io/driver/mysql" |
| 35 | "gorm.io/gorm" |
| 36 | v1 "k8s.io/api/core/v1" |
| 37 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 38 | "k8s.io/client-go/kubernetes" |
| 39 | "k8s.io/client-go/rest" |
| 40 | "k8s.io/client-go/tools/clientcmd" |
| 41 | "k8s.io/client-go/tools/portforward" |
| 42 | "k8s.io/client-go/util/homedir" |
| 43 | ) |
| 44 | |
| 45 | var ( |
| 46 | kubeconfig string |
| 47 | |
| 48 | portForward *portforward.PortForwarder |
| 49 | db *gorm.DB |
| 50 | instanceExtra InstanceExtra |
| 51 | key string |
| 52 | |
| 53 | flavor *InstanceExtraFlavor |
| 54 | |
| 55 | rootCmd = &cobra.Command{ |
| 56 | Use: "extraspecfix", |
| 57 | Short: "Utility to fix extra_specs in the database", |
| 58 | PersistentPreRun: func(cmd *cobra.Command, args []string) { |
| 59 | instanceUUID := args[0] |
| 60 | key = args[1] |
| 61 | |
| 62 | config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) |
| 63 | if err != nil { |
| 64 | log.Fatal(err) |
| 65 | } |
| 66 | |
| 67 | databaseConnection, err := GetDatabaseConnection(config) |
| 68 | if err != nil { |
| 69 | log.Fatal(err) |
| 70 | } |
| 71 | |
| 72 | portForward, err = databaseConnection.PortForwarder() |
| 73 | if err != nil { |
| 74 | log.Fatal(err) |
| 75 | } |
| 76 | |
| 77 | go func() { |
| 78 | err := portForward.ForwardPorts() |
| 79 | if err != nil { |
| 80 | log.Fatal(err) |
| 81 | } |
| 82 | }() |
| 83 | |
| 84 | <-portForward.Ready |
| 85 | |
| 86 | dsn := fmt.Sprintf( |
| 87 | "%s:%s@tcp(%s:%d)/%s", |
| 88 | databaseConnection.Username, |
| 89 | databaseConnection.Password, |
| 90 | "localhost", |
| 91 | 3306, |
| 92 | databaseConnection.Database, |
| 93 | ) |
| 94 | // TODO: write a custom dialer? |
| 95 | db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) |
| 96 | if err != nil { |
| 97 | log.Fatal(err) |
| 98 | } |
| 99 | |
| 100 | tx := db.Where(&InstanceExtra{InstanceUUID: instanceUUID}).First(&instanceExtra) |
| 101 | if tx.Error != nil { |
| 102 | log.Fatal(err) |
| 103 | } |
| 104 | |
| 105 | err = StrictUnmarshal([]byte(instanceExtra.Flavor), &flavor) |
| 106 | if err != nil { |
| 107 | log.Fatal(err) |
| 108 | } |
| 109 | }, |
| 110 | PersistentPostRun: func(cmd *cobra.Command, args []string) { |
| 111 | serializedFlavor, err := json.Marshal(flavor) |
| 112 | if err != nil { |
| 113 | log.Fatal(err) |
| 114 | } |
| 115 | |
| 116 | diffOpts := jsondiff.DefaultConsoleOptions() |
| 117 | diff, output := jsondiff.Compare( |
| 118 | []byte(instanceExtra.Flavor), |
| 119 | []byte(serializedFlavor), |
| 120 | &diffOpts, |
| 121 | ) |
| 122 | |
| 123 | if diff == jsondiff.FullMatch { |
| 124 | log.Info("no changes") |
| 125 | os.Exit(0) |
| 126 | } |
| 127 | |
| 128 | fmt.Println(output) |
| 129 | |
| 130 | input := confirmation.New("Are you ready?", confirmation.Undecided) |
| 131 | ready, err := input.RunPrompt() |
| 132 | if err != nil || !ready { |
| 133 | log.Info("operation cancelled") |
| 134 | os.Exit(0) |
| 135 | } |
| 136 | |
| 137 | db.Model(&instanceExtra).Update("flavor", string(serializedFlavor)) |
| 138 | portForward.Close() |
| 139 | }, |
| 140 | } |
| 141 | setCmd = &cobra.Command{ |
| 142 | Use: "set [instance uuid] [key] [value]", |
| 143 | Short: "Set an extra spec on an instance", |
| 144 | Args: cobra.MatchAll(cobra.ExactArgs(3), cobra.OnlyValidArgs), |
| 145 | Run: func(cmd *cobra.Command, args []string) { |
| 146 | value := args[2] |
| 147 | flavor.Current.Data.ExtraSpecs[key] = value |
| 148 | }, |
| 149 | } |
| 150 | ) |
| 151 | |
| 152 | func init() { |
| 153 | homedir := homedir.HomeDir() |
| 154 | rootCmd.PersistentFlags().StringVar( |
| 155 | &kubeconfig, |
| 156 | "kubeconfig", |
| 157 | filepath.Join(homedir, ".kube", "config"), |
| 158 | "absolute path to the kubeconfig file", |
| 159 | ) |
| 160 | |
| 161 | rootCmd.AddCommand(setCmd) |
| 162 | } |
| 163 | |
| 164 | type InstanceExtra struct { |
| 165 | ID uint `gorm:"primaryKey"` |
| 166 | InstanceUUID string `gorm:"column:instance_uuid"` |
| 167 | Flavor string `gorm:"column:flavor"` |
| 168 | } |
| 169 | |
| 170 | type InstanceExtraFlavor struct { |
| 171 | Old *InstanceExtraFlavorObject `json:"old"` |
| 172 | Current *InstanceExtraFlavorObject `json:"cur"` |
| 173 | New *InstanceExtraFlavorObject `json:"new"` |
| 174 | } |
| 175 | |
| 176 | type InstanceExtraFlavorObject struct { |
| 177 | Name string `json:"nova_object.name"` |
| 178 | Namespace string `json:"nova_object.namespace"` |
| 179 | Version string `json:"nova_object.version"` |
| 180 | Changes []string `json:"nova_object.changes"` |
| 181 | Data InstanceExtraFlavorObjectData `json:"nova_object.data"` |
| 182 | } |
| 183 | |
| 184 | type InstanceExtraFlavorObjectData struct { |
| 185 | ID int64 `json:"id"` |
| 186 | FlavorID string `json:"flavorid"` |
| 187 | Name string `json:"name"` |
| 188 | Description *string `json:"description"` |
| 189 | VCPUs int64 `json:"vcpus"` |
| 190 | VCPUWeight int64 `json:"vcpu_weight"` |
| 191 | MemoryMB int64 `json:"memory_mb"` |
| 192 | RootGB int64 `json:"root_gb"` |
| 193 | EphemeralGB int64 `json:"ephemeral_gb"` |
| 194 | Swap int64 `json:"swap"` |
| 195 | RxTxFactor json.Number `json:"rxtx_factor"` |
| 196 | IsPublic bool `json:"is_public"` |
| 197 | Disabled bool `json:"disabled"` |
| 198 | ExtraSpecs map[string]string `json:"extra_specs"` |
| 199 | CreatedAt string `json:"created_at"` |
| 200 | UpdatedAt *string `json:"updated_at"` |
| 201 | DeletedAt *string `json:"deleted_at"` |
| 202 | Deleted bool `json:"deleted"` |
| 203 | } |
| 204 | |
| 205 | func (InstanceExtra) TableName() string { |
| 206 | return "instance_extra" |
| 207 | } |
| 208 | |
| 209 | func main() { |
| 210 | if err := rootCmd.Execute(); err != nil { |
| 211 | fmt.Fprintln(os.Stderr, err) |
| 212 | os.Exit(1) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | func StrictUnmarshal(data []byte, v interface{}) error { |
| 217 | dec := json.NewDecoder(bytes.NewReader(data)) |
| 218 | dec.DisallowUnknownFields() |
| 219 | return dec.Decode(v) |
| 220 | } |
| 221 | |
| 222 | type DatabaseConnection struct { |
| 223 | config *rest.Config |
| 224 | clientset *kubernetes.Clientset |
| 225 | Username string |
| 226 | Password string |
| 227 | Service *v1.Service |
| 228 | Database string |
| 229 | } |
| 230 | |
| 231 | func (db *DatabaseConnection) PortForwarder() (*portforward.PortForwarder, error) { |
| 232 | return portforwardutil.NewForService(db.config, db.Service, 3306) |
| 233 | } |
| 234 | |
| 235 | func GetDatabaseConnection(config *rest.Config) (*DatabaseConnection, error) { |
| 236 | clientset, err := kubernetes.NewForConfig(config) |
| 237 | if err != nil { |
| 238 | return nil, err |
| 239 | } |
| 240 | |
| 241 | secret, err := clientset.CoreV1().Secrets("openstack").Get( |
| 242 | context.TODO(), |
| 243 | "nova-etc", |
| 244 | metav1.GetOptions{}, |
| 245 | ) |
| 246 | if err != nil { |
| 247 | return nil, err |
| 248 | } |
| 249 | |
| 250 | cfg, err := ini.Load(secret.Data["nova.conf"]) |
| 251 | if err != nil { |
| 252 | return nil, err |
| 253 | } |
| 254 | |
| 255 | connection := cfg.Section("database").Key("connection").String() |
| 256 | |
| 257 | parsedConnection, err := url.Parse(connection) |
| 258 | if err != nil { |
| 259 | return nil, err |
| 260 | } |
| 261 | |
| 262 | username := parsedConnection.User.Username() |
| 263 | password, _ := parsedConnection.User.Password() |
| 264 | database := strings.TrimLeft(parsedConnection.Path, "/") |
| 265 | |
| 266 | hostname := parsedConnection.Hostname() |
| 267 | parts := strings.SplitN(hostname, ".", 3) |
| 268 | service := &v1.Service{ |
| 269 | ObjectMeta: metav1.ObjectMeta{ |
| 270 | Name: parts[0], |
| 271 | Namespace: parts[1], |
| 272 | }, |
| 273 | } |
| 274 | |
| 275 | return &DatabaseConnection{ |
| 276 | config: config, |
| 277 | clientset: clientset, |
| 278 | Username: username, |
| 279 | Password: password, |
| 280 | Service: service, |
| 281 | Database: database, |
| 282 | }, nil |
| 283 | } |