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