| package reflection |
| |
| import ( |
| "context" |
| "database/sql" |
| "fmt" |
| "strings" |
| |
| "github.com/iancoleman/strcase" |
| "google.golang.org/protobuf/proto" |
| "google.golang.org/protobuf/reflect/protoreflect" |
| "k8s.io/klog/v2" |
| |
| "source.monogon.dev/cloud/bmaas/server/api" |
| ) |
| |
| // Schema contains information about the tag types in a BMDB. It also contains an |
| // active connection to the BMDB, allowing retrieval of data based on the |
| // detected schema. |
| // |
| // It also contains an embedded connection to the CockroachDB database backing |
| // this BMDB which is then used to retrieve data described by this schema. |
| type Schema struct { |
| // TagTypes is the list of tag types extracted from the BMDB. |
| TagTypes []TagType |
| // Version is the go-migrate schema version of the BMDB this schema was extracted |
| // from. By convention, it is a stringified base-10 number representing the number |
| // of seconds since UNIX epoch of when the migration version was created, but |
| // this is not guaranteed. |
| Version string |
| |
| db *sql.DB |
| } |
| |
| // TagType describes the type of a BMDB Tag. Each tag in turn corresponds to a |
| // CockroachDB database. |
| type TagType struct { |
| // NativeName is the name of the table that holds tags of this type. |
| NativeName string |
| // Fields are the types of fields contained in this tag type. |
| Fields []TagFieldType |
| } |
| |
| // Name returns the canonical name of this tag type. For example, a table named |
| // machine_agent_started will have a canonical name AgentStarted. |
| func (r *TagType) Name() string { |
| tableSuffix := strings.TrimPrefix(r.NativeName, "machine_") |
| parts := strings.Split(tableSuffix, "_") |
| // Capitalize some known acronyms. |
| for i, p := range parts { |
| parts[i] = strings.ReplaceAll(p, "os", "OS") |
| } |
| return strcase.ToCamel(strings.Join(parts, "_")) |
| } |
| |
| // TagFieldType is the type of a field within a BMDB Tag. Each tag field in turn |
| // corresponds to a column inside its Tag table. |
| type TagFieldType struct { |
| // NativeName is the name of the column that holds this field type. It is also |
| // the canonical name of the field type. |
| NativeName string |
| // NativeType is the CockroachDB type name of this field. |
| NativeType string |
| // NativeUDTName is the CockroachDB user-defined-type name of this field. This is |
| // only valid if NativeType is 'USER-DEFINED'. |
| NativeUDTName string |
| // ProtoType is set non-nil if the field is a serialized protobuf of the same |
| // type as the given protoreflect.Message. |
| ProtoType protoreflect.Message |
| } |
| |
| // knownProtoFields is a mapping from column name of a field containing a |
| // serialized protobuf to an instance of a proto.Message that will be used to |
| // parse that column's data. |
| // |
| // Just mapping from column name is fine enough for now as we have mostly unique |
| // column names, and these column names uniquely map to a single type. |
| var knownProtoFields = map[string]proto.Message{ |
| "hardware_report_raw": &api.AgentHardwareReport{}, |
| "os_installation_request_raw": &api.OSInstallationRequest{}, |
| "os_installation_report_raw": &api.OSInstallationReport{}, |
| } |
| |
| // HumanType returns a human-readable representation of the field's type. This is |
| // not well-defined, and should be used only informatively. |
| func (r *TagFieldType) HumanType() string { |
| if r.ProtoType != nil { |
| return fmt.Sprintf("%s", r.ProtoType.Descriptor().FullName()) |
| } |
| switch r.NativeType { |
| case "USER-DEFINED": |
| return r.NativeUDTName |
| case "timestamp with time zone": |
| return "timestamp" |
| case "bytea": |
| return "bytes" |
| case "bigint": |
| return "int" |
| default: |
| return r.NativeType |
| } |
| } |
| |
| // Reflect builds a runtime BMDB schema from a raw SQL connection to the BMDB |
| // database. You're probably looking for bmdb.Connection.Reflect. |
| func Reflect(ctx context.Context, db *sql.DB) (*Schema, error) { |
| // Get all tables in the currently connected to database. |
| rows, err := db.QueryContext(ctx, ` |
| SELECT table_name |
| FROM information_schema.tables |
| WHERE table_catalog = current_database() |
| AND table_schema = 'public' |
| AND table_name LIKE 'machine\_%' |
| `) |
| if err != nil { |
| return nil, fmt.Errorf("could not query table names: %w", err) |
| } |
| defer rows.Close() |
| |
| // Collect all table names for further processing. |
| var tableNames []string |
| for rows.Next() { |
| var name string |
| if err := rows.Scan(&name); err != nil { |
| return nil, fmt.Errorf("table name scan failed: %w", err) |
| } |
| tableNames = append(tableNames, name) |
| } |
| |
| // Start processing each table into a TagType. |
| tags := make([]TagType, 0, len(tableNames)) |
| for _, tagName := range tableNames { |
| // Get all columns of the table. |
| rows, err := db.QueryContext(ctx, ` |
| SELECT column_name, data_type, udt_name |
| FROM information_schema.columns |
| WHERE table_catalog = current_database() |
| AND table_schema = 'public' |
| AND table_name = $1 |
| `, tagName) |
| if err != nil { |
| return nil, fmt.Errorf("could not query columns: %w", err) |
| } |
| |
| tag := TagType{ |
| NativeName: tagName, |
| } |
| |
| // Build field types from columns. |
| foundMachineID := false |
| for rows.Next() { |
| var column_name, data_type, udt_name string |
| if err := rows.Scan(&column_name, &data_type, &udt_name); err != nil { |
| rows.Close() |
| return nil, fmt.Errorf("column scan failed: %w", err) |
| } |
| if column_name == "machine_id" { |
| foundMachineID = true |
| continue |
| } |
| field := TagFieldType{ |
| NativeName: column_name, |
| NativeType: data_type, |
| NativeUDTName: udt_name, |
| } |
| if t, ok := knownProtoFields[column_name]; ok { |
| field.ProtoType = t.ProtoReflect() |
| } |
| tag.Fields = append(tag.Fields, field) |
| } |
| |
| // Make sure there's a machine_id key in the table, then remove it. |
| if !foundMachineID { |
| klog.Warningf("Table %q has no machine_id column, skipping", tag.NativeName) |
| continue |
| } |
| |
| tags = append(tags, tag) |
| } |
| |
| // Retrieve version information from go-migrate's schema_migrations table. |
| var version string |
| var dirty bool |
| if err := db.QueryRowContext(ctx, "SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty); err != nil { |
| return nil, fmt.Errorf("could not select schema version: %w", err) |
| } |
| if dirty { |
| version += " DIRTY!!!" |
| } |
| |
| return &Schema{ |
| TagTypes: tags, |
| Version: version, |
| |
| db: db, |
| }, nil |
| } |