Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 1 | package reflection |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/iancoleman/strcase" |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 10 | "google.golang.org/protobuf/proto" |
| 11 | "google.golang.org/protobuf/reflect/protoreflect" |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 12 | "k8s.io/klog/v2" |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 13 | |
| 14 | "source.monogon.dev/cloud/bmaas/server/api" |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 15 | ) |
| 16 | |
| 17 | // Schema contains information about the tag types in a BMDB. It also contains an |
| 18 | // active connection to the BMDB, allowing retrieval of data based on the |
| 19 | // detected schema. |
| 20 | // |
| 21 | // It also contains an embedded connection to the CockroachDB database backing |
| 22 | // this BMDB which is then used to retrieve data described by this schema. |
| 23 | type Schema struct { |
| 24 | // TagTypes is the list of tag types extracted from the BMDB. |
| 25 | TagTypes []TagType |
| 26 | // Version is the go-migrate schema version of the BMDB this schema was extracted |
| 27 | // from. By convention, it is a stringified base-10 number representing the number |
| 28 | // of seconds since UNIX epoch of when the migration version was created, but |
| 29 | // this is not guaranteed. |
| 30 | Version string |
| 31 | |
| 32 | db *sql.DB |
| 33 | } |
| 34 | |
| 35 | // TagType describes the type of a BMDB Tag. Each tag in turn corresponds to a |
| 36 | // CockroachDB database. |
| 37 | type TagType struct { |
| 38 | // NativeName is the name of the table that holds tags of this type. |
| 39 | NativeName string |
| 40 | // Fields are the types of fields contained in this tag type. |
| 41 | Fields []TagFieldType |
| 42 | } |
| 43 | |
| 44 | // Name returns the canonical name of this tag type. For example, a table named |
| 45 | // machine_agent_started will have a canonical name AgentStarted. |
| 46 | func (r *TagType) Name() string { |
| 47 | tableSuffix := strings.TrimPrefix(r.NativeName, "machine_") |
| 48 | parts := strings.Split(tableSuffix, "_") |
| 49 | // Capitalize some known acronyms. |
| 50 | for i, p := range parts { |
| 51 | parts[i] = strings.ReplaceAll(p, "os", "OS") |
| 52 | } |
| 53 | return strcase.ToCamel(strings.Join(parts, "_")) |
| 54 | } |
| 55 | |
| 56 | // TagFieldType is the type of a field within a BMDB Tag. Each tag field in turn |
| 57 | // corresponds to a column inside its Tag table. |
| 58 | type TagFieldType struct { |
| 59 | // NativeName is the name of the column that holds this field type. It is also |
| 60 | // the canonical name of the field type. |
| 61 | NativeName string |
| 62 | // NativeType is the CockroachDB type name of this field. |
| 63 | NativeType string |
| 64 | // NativeUDTName is the CockroachDB user-defined-type name of this field. This is |
| 65 | // only valid if NativeType is 'USER-DEFINED'. |
| 66 | NativeUDTName string |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 67 | // ProtoType is set non-nil if the field is a serialized protobuf of the same |
| 68 | // type as the given protoreflect.Message. |
| 69 | ProtoType protoreflect.Message |
| 70 | } |
| 71 | |
| 72 | // knownProtoFields is a mapping from column name of a field containing a |
| 73 | // serialized protobuf to an instance of a proto.Message that will be used to |
| 74 | // parse that column's data. |
| 75 | // |
| 76 | // Just mapping from column name is fine enough for now as we have mostly unique |
| 77 | // column names, and these column names uniquely map to a single type. |
| 78 | var knownProtoFields = map[string]proto.Message{ |
Tim Windelschmidt | 2bffb6f | 2023-04-24 19:06:10 +0200 | [diff] [blame] | 79 | "hardware_report_raw": &api.AgentHardwareReport{}, |
| 80 | "os_installation_request_raw": &api.OSInstallationRequest{}, |
Tim Windelschmidt | 5308730 | 2023-06-27 16:36:31 +0200 | [diff] [blame^] | 81 | "os_installation_report_raw": &api.OSInstallationReport{}, |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 82 | } |
| 83 | |
| 84 | // HumanType returns a human-readable representation of the field's type. This is |
| 85 | // not well-defined, and should be used only informatively. |
| 86 | func (r *TagFieldType) HumanType() string { |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 87 | if r.ProtoType != nil { |
| 88 | return fmt.Sprintf("%s", r.ProtoType.Descriptor().FullName()) |
| 89 | } |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 90 | switch r.NativeType { |
| 91 | case "USER-DEFINED": |
| 92 | return r.NativeUDTName |
| 93 | case "timestamp with time zone": |
| 94 | return "timestamp" |
| 95 | case "bytea": |
| 96 | return "bytes" |
Tim Windelschmidt | 2bffb6f | 2023-04-24 19:06:10 +0200 | [diff] [blame] | 97 | case "bigint": |
| 98 | return "int" |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 99 | default: |
| 100 | return r.NativeType |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // Reflect builds a runtime BMDB schema from a raw SQL connection to the BMDB |
| 105 | // database. You're probably looking for bmdb.Connection.Reflect. |
| 106 | func Reflect(ctx context.Context, db *sql.DB) (*Schema, error) { |
| 107 | // Get all tables in the currently connected to database. |
| 108 | rows, err := db.QueryContext(ctx, ` |
| 109 | SELECT table_name |
| 110 | FROM information_schema.tables |
| 111 | WHERE table_catalog = current_database() |
| 112 | AND table_schema = 'public' |
| 113 | AND table_name LIKE 'machine\_%' |
| 114 | `) |
| 115 | if err != nil { |
| 116 | return nil, fmt.Errorf("could not query table names: %w", err) |
| 117 | } |
| 118 | defer rows.Close() |
| 119 | |
| 120 | // Collect all table names for further processing. |
| 121 | var tableNames []string |
| 122 | for rows.Next() { |
| 123 | var name string |
| 124 | if err := rows.Scan(&name); err != nil { |
| 125 | return nil, fmt.Errorf("table name scan failed: %w", err) |
| 126 | } |
| 127 | tableNames = append(tableNames, name) |
| 128 | } |
| 129 | |
| 130 | // Start processing each table into a TagType. |
| 131 | tags := make([]TagType, 0, len(tableNames)) |
| 132 | for _, tagName := range tableNames { |
| 133 | // Get all columns of the table. |
| 134 | rows, err := db.QueryContext(ctx, ` |
| 135 | SELECT column_name, data_type, udt_name |
| 136 | FROM information_schema.columns |
| 137 | WHERE table_catalog = current_database() |
| 138 | AND table_schema = 'public' |
| 139 | AND table_name = $1 |
| 140 | `, tagName) |
| 141 | if err != nil { |
| 142 | return nil, fmt.Errorf("could not query columns: %w", err) |
| 143 | } |
| 144 | |
| 145 | tag := TagType{ |
| 146 | NativeName: tagName, |
| 147 | } |
| 148 | |
| 149 | // Build field types from columns. |
| 150 | foundMachineID := false |
| 151 | for rows.Next() { |
| 152 | var column_name, data_type, udt_name string |
| 153 | if err := rows.Scan(&column_name, &data_type, &udt_name); err != nil { |
| 154 | rows.Close() |
| 155 | return nil, fmt.Errorf("column scan failed: %w", err) |
| 156 | } |
| 157 | if column_name == "machine_id" { |
| 158 | foundMachineID = true |
| 159 | continue |
| 160 | } |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 161 | field := TagFieldType{ |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 162 | NativeName: column_name, |
| 163 | NativeType: data_type, |
| 164 | NativeUDTName: udt_name, |
Serge Bazanski | 10b2154 | 2023-04-13 12:12:05 +0200 | [diff] [blame] | 165 | } |
| 166 | if t, ok := knownProtoFields[column_name]; ok { |
| 167 | field.ProtoType = t.ProtoReflect() |
| 168 | } |
| 169 | tag.Fields = append(tag.Fields, field) |
Serge Bazanski | 424e201 | 2023-02-15 23:31:49 +0100 | [diff] [blame] | 170 | } |
| 171 | |
| 172 | // Make sure there's a machine_id key in the table, then remove it. |
| 173 | if !foundMachineID { |
| 174 | klog.Warningf("Table %q has no machine_id column, skipping", tag.NativeName) |
| 175 | continue |
| 176 | } |
| 177 | |
| 178 | tags = append(tags, tag) |
| 179 | } |
| 180 | |
| 181 | // Retrieve version information from go-migrate's schema_migrations table. |
| 182 | var version string |
| 183 | var dirty bool |
| 184 | if err := db.QueryRowContext(ctx, "SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty); err != nil { |
| 185 | return nil, fmt.Errorf("could not select schema version: %w", err) |
| 186 | } |
| 187 | if dirty { |
| 188 | version += " DIRTY!!!" |
| 189 | } |
| 190 | |
| 191 | return &Schema{ |
| 192 | TagTypes: tags, |
| 193 | Version: version, |
| 194 | |
| 195 | db: db, |
| 196 | }, nil |
| 197 | } |