scripts/update_assets_md/main.go (305 lines of code) (raw):
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"maps"
"os"
"slices"
"strings"
"github.com/ettle/strcase"
"github.com/xuri/excelize/v2"
)
const (
GO_FILE = "../../internal/inventory/asset.go"
EXCEL_FILE = "../../internal/inventory/cloud_assets.xlsx"
SUMMARY_FILE = "../../internal/inventory/ASSETS.md"
CLASSIFICATION_TYPE = "AssetClassification"
// Provider prefixes
AWS_PREFIX = "Aws"
AZURE_PREFIX = "Azure"
GCP_PREFIX = "Gcp"
)
type Classification struct {
Category string
OldType string
Type string
}
func (item Classification) ID() string {
if item.Type != "" {
return fmt.Sprintf("%s_%s",
strcase.ToKebab(item.Category),
strcase.ToKebab(item.Type),
)
}
return fmt.Sprintf("%s_%s",
strcase.ToKebab(item.Category),
strcase.ToKebab(item.OldType),
)
}
type ByProvider struct {
AWS map[string]Classification
Azure map[string]Classification
GCP map[string]Classification
}
func (bp *ByProvider) Assign(provider string, c Classification) {
switch provider {
case AWS_PREFIX:
bp.AWS[c.ID()] = c
case AZURE_PREFIX:
bp.Azure[c.ID()] = c
case GCP_PREFIX:
bp.GCP[c.ID()] = c
default:
panic(fmt.Errorf("unsupported provider: %s", provider))
}
}
func (bp *ByProvider) Get(provider string) map[string]Classification {
switch provider {
case AWS_PREFIX:
return bp.AWS
case AZURE_PREFIX:
return bp.Azure
case GCP_PREFIX:
return bp.GCP
default:
panic(fmt.Errorf("unsupported provider: %s", provider))
}
}
func main() {
implementedByProvider, err := loadClassificationsFromGolang(GO_FILE)
if err != nil {
panic(err)
}
plannedByProvider, err := loadClassificationsFromExcel(EXCEL_FILE)
if err != nil {
panic(err)
}
err = writeSummary(plannedByProvider, implementedByProvider, SUMMARY_FILE)
if err != nil {
panic(err)
}
}
func loadClassificationsFromGolang(filepath string) (*ByProvider, error) {
output := &ByProvider{
AWS: map[string]Classification{},
Azure: map[string]Classification{},
GCP: map[string]Classification{},
}
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filepath, nil, parser.ParseComments)
if err != nil {
return output, fmt.Errorf("failed to parse Go file: %w", err)
}
ast.Inspect(node, func(n ast.Node) bool {
decl, ok := n.(*ast.GenDecl)
if !ok {
return true
}
for _, spec := range decl.Specs {
valSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
provider, ok := extractProvider(valSpec)
if !ok {
continue
}
for _, value := range valSpec.Values {
cl, err := extractClassification(value)
if err != nil {
continue
}
output.Assign(provider, cl)
}
}
return false
})
return output, nil
}
func loadClassificationsFromExcel(filepath string) (*ByProvider, error) {
output := &ByProvider{
AWS: map[string]Classification{},
Azure: map[string]Classification{},
GCP: map[string]Classification{},
}
f, err := excelize.OpenFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to open Excel file: %w", err)
}
sheets := map[int]string{3: AWS_PREFIX, 4: AZURE_PREFIX, 5: GCP_PREFIX}
for sheetNo, provider := range sheets {
sheetName := f.GetSheetName(sheetNo)
rows, err := f.GetRows(sheetName)
if err != nil {
return nil, fmt.Errorf("failed to get rows: %w", err)
}
headers := rows[0]
for _, row := range rows[1:] {
cl := Classification{
Category: row[getColumnIndex(headers, "Category")],
OldType: row[getColumnIndex(headers, "(current) Type")],
Type: row[getColumnIndex(headers, "Updated Type")],
}
output.Assign(provider, cl)
}
}
return output, nil
}
func writeSummary(plannedByProvider, implementedByProvider *ByProvider, filepath string) error {
file, err := os.Create(filepath)
if err != nil {
return err
}
defer file.Close()
for providerNo, provider := range []string{AWS_PREFIX, AZURE_PREFIX, GCP_PREFIX} {
planned := plannedByProvider.Get(provider)
implemented := implementedByProvider.Get(provider)
sortedKeys := slices.Sorted(maps.Keys(planned))
// stats
totalImplemented := 0
implementedByCategory := map[string]int{}
plannedByCategory := map[string]int{}
// table of assets
table := []string{
"<details> <summary>Full table</summary>\n",
"| Category | Old Type | Type | Implemented? |",
"|---|---|---|---|",
}
for _, key := range sortedKeys {
item := planned[key]
status := "No ❌"
plannedByCategory[item.Category] += 1
if _, ok := implemented[key]; ok {
status = "Yes ✅"
totalImplemented += 1
implementedByCategory[item.Category] += 1
}
table = append(table,
fmt.Sprintf(
"| %s | %s | %s | %s |",
item.Category, item.OldType, item.Type, status,
),
)
}
table = append(table, "\n</details>")
// write ASSETS.md
if providerNo > 0 {
writeToFile(file, "\n\n")
}
writeToFile(file, fmt.Sprintf("## %s Resources\n\n", strings.ToUpper(provider)))
percentage := totalImplemented * 100 / len(planned)
writeToFile(
file,
fmt.Sprintf("**Progress: %d%% (%d/%d)**\n", percentage, totalImplemented, len(planned)),
)
sortedCategories := slices.Sorted(maps.Keys(plannedByCategory))
for _, category := range sortedCategories {
plannedCount := plannedByCategory[category]
implementedCount := implementedByCategory[category]
percentage = implementedCount * 100 / plannedCount
writeToFile(
file,
fmt.Sprintf("%s: %d%% (%d/%d)\n", category, percentage, implementedCount, plannedCount),
)
}
writeToFile(file, "\n"+strings.Join(table, "\n"))
}
writeToFile(file, "\n") // required for valid Markdown :o
return nil
}
// Golang AST functions -------------------------------------------------
func extractProvider(valSpec *ast.ValueSpec) (string, bool) {
if len(valSpec.Names) == 0 {
return "", false
}
name := valSpec.Names[0].Name
if !strings.HasPrefix(name, CLASSIFICATION_TYPE) {
return "", false
}
name = name[len(CLASSIFICATION_TYPE):]
if strings.HasPrefix(name, AWS_PREFIX) {
return AWS_PREFIX, true
}
if strings.HasPrefix(name, AZURE_PREFIX) {
return AZURE_PREFIX, true
}
if strings.HasPrefix(name, GCP_PREFIX) {
return GCP_PREFIX, true
}
return "", false
}
func extractClassification(expr ast.Expr) (Classification, error) {
output := Classification{}
compLit, ok := expr.(*ast.CompositeLit)
if !ok {
return output, fmt.Errorf("value is not a composite literal, skipping")
}
if len(compLit.Elts) != 2 {
return output, fmt.Errorf("expected full, 2-field classification; got %d", len(compLit.Elts))
}
classification := []string{}
for _, elt := range compLit.Elts {
s, err := unpackElt(elt)
if err != nil {
return output, fmt.Errorf("could not unpack elements: %w", err)
}
classification = append(classification, s)
}
output.Category = classification[0]
output.Type = classification[1]
return output, nil
}
func unpackElt(expr ast.Expr) (string, error) {
switch element := expr.(type) {
case *ast.KeyValueExpr:
return unpackKeyValueExpr(element)
case *ast.BasicLit:
return unpackBasicLit(element)
case *ast.Ident:
return unpackIdent(element)
default:
return "", fmt.Errorf("unhandled expr type %T", element)
}
}
func unpackIdent(obj *ast.Ident) (string, error) {
o := obj.Obj
if o == nil {
return "", nil
}
valueSpec, ok := o.Decl.(*ast.ValueSpec)
if !ok {
return "", fmt.Errorf("cannot cast values to []Expr")
}
if len(valueSpec.Values) != 1 {
return "", fmt.Errorf("this should not happen - len(values) != 1")
}
basicLitVal, ok := valueSpec.Values[0].(*ast.BasicLit)
if !ok {
return "", fmt.Errorf("got a single value, but it's not a BasicLit")
}
return unpackBasicLit(basicLitVal)
}
func unpackBasicLit(obj *ast.BasicLit) (string, error) {
return strings.Trim(obj.Value, "\" "), nil
}
func unpackKeyValueExpr(obj *ast.KeyValueExpr) (string, error) {
switch v := obj.Value.(type) {
case *ast.Ident:
return unpackIdent(v)
case *ast.BasicLit:
return unpackBasicLit(v)
default:
return "", fmt.Errorf("cannot unpack KeyValue val")
}
}
// Helper functions -----------------------------------------------------
// Helper function to get the index of a header in the Excel file
func getColumnIndex(headers []string, headerName string) int {
for i, h := range headers {
if strings.TrimSpace(h) == headerName {
return i
}
}
return -1
}
func writeToFile(file *os.File, s string) {
_, err := file.WriteString(s)
if err != nil {
panic(err)
}
}