blob: d497f5d6dc13caa9c303c2b9c37046e85c651168 [file] [log] [blame]
Serge Bazanski424e2012023-02-15 23:31:49 +01001package reflection
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "strings"
8
9 "github.com/iancoleman/strcase"
Serge Bazanski10b21542023-04-13 12:12:05 +020010 "google.golang.org/protobuf/proto"
11 "google.golang.org/protobuf/reflect/protoreflect"
Serge Bazanski424e2012023-02-15 23:31:49 +010012 "k8s.io/klog/v2"
Serge Bazanski10b21542023-04-13 12:12:05 +020013
14 "source.monogon.dev/cloud/bmaas/server/api"
Serge Bazanski424e2012023-02-15 23:31:49 +010015)
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.
23type 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.
37type 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.
46func (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.
58type 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 Bazanski10b21542023-04-13 12:12:05 +020067 // 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.
78var knownProtoFields = map[string]proto.Message{
Tim Windelschmidt2bffb6f2023-04-24 19:06:10 +020079 "hardware_report_raw": &api.AgentHardwareReport{},
80 "os_installation_request_raw": &api.OSInstallationRequest{},
Tim Windelschmidt53087302023-06-27 16:36:31 +020081 "os_installation_report_raw": &api.OSInstallationReport{},
Serge Bazanski424e2012023-02-15 23:31:49 +010082}
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.
86func (r *TagFieldType) HumanType() string {
Serge Bazanski10b21542023-04-13 12:12:05 +020087 if r.ProtoType != nil {
88 return fmt.Sprintf("%s", r.ProtoType.Descriptor().FullName())
89 }
Serge Bazanski424e2012023-02-15 23:31:49 +010090 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 Windelschmidt2bffb6f2023-04-24 19:06:10 +020097 case "bigint":
98 return "int"
Serge Bazanski424e2012023-02-15 23:31:49 +010099 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.
106func 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 Bazanski10b21542023-04-13 12:12:05 +0200161 field := TagFieldType{
Serge Bazanski424e2012023-02-15 23:31:49 +0100162 NativeName: column_name,
163 NativeType: data_type,
164 NativeUDTName: udt_name,
Serge Bazanski10b21542023-04-13 12:12:05 +0200165 }
166 if t, ok := knownProtoFields[column_name]; ok {
167 field.ProtoType = t.ProtoReflect()
168 }
169 tag.Fields = append(tag.Fields, field)
Serge Bazanski424e2012023-02-15 23:31:49 +0100170 }
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}