├── .github └── workflows │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore └── vcs.xml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── aws-finops │ └── main.go ├── go.mod ├── go.sum ├── img ├── aws-finops-dashboard-go-audit-report.png ├── aws-finops-dashboard-go-trend.png └── aws-finops-dashboard-go-v1.png ├── internal ├── adapter │ ├── driven │ │ ├── aws │ │ │ └── aws_repository.go │ │ ├── config │ │ │ └── config_repository.go │ │ └── export │ │ │ └── export_repository.go │ └── driving │ │ └── cli │ │ ├── app.go │ │ └── banner.go ├── application │ └── usecase │ │ └── dashboard_usecase.go ├── domain │ ├── entity │ │ ├── audit.go │ │ ├── budget.go │ │ ├── cost.go │ │ ├── ec2.go │ │ └── profile.go │ └── repository │ │ ├── aws_repository.go │ │ ├── config_repository.go │ │ └── export_repository.go └── shared │ └── types │ ├── cli_args.go │ ├── config.go │ ├── console.go │ └── errors.go └── pkg ├── console └── console.go └── version └── version.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.24 21 | 22 | - name: Build binaries 23 | run: | 24 | COMMIT=$(git rev-parse HEAD) 25 | BUILD_TIME=$(date -u +'%Y-%m-%d %H:%M:%S') 26 | LDFLAGS="-X 'github.com/diillson/aws-finops-dashboard-go/pkg/version.Commit=${COMMIT}' -X 'github.com/diillson/aws-finops-dashboard-go/pkg/version.BuildTime=${BUILD_TIME}'" 27 | 28 | # Build para várias plataformas 29 | GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o aws-finops-linux-amd64 ./cmd/aws-finops/main.go 30 | GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o aws-finops-darwin-amd64 ./cmd/aws-finops/main.go 31 | GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o aws-finops-windows-amd64.exe ./cmd/aws-finops/main.go 32 | 33 | # Verifica a versão do binário 34 | ./aws-finops-linux-amd64 --version 35 | 36 | - name: Create GitHub Release 37 | id: create_release 38 | uses: softprops/action-gh-release@v1 39 | with: 40 | files: | 41 | aws-finops-linux-amd64 42 | aws-finops-darwin-amd64 43 | aws-finops-windows-amd64.exe 44 | draft: false 45 | prerelease: false 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Edilson Freitas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Diretórios 4 | BIN_DIR := ./bin 5 | DIST_DIR := ./dist 6 | 7 | # Nome do executável 8 | BINARY_NAME := aws-finops 9 | 10 | # Informações da versão 11 | VERSION := $(shell grep -m1 '^const Version =' pkg/version/version.go | cut -d '"' -f2) 12 | COMMIT := $(shell git rev-parse --short HEAD) 13 | BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S') 14 | 15 | # Go flags 16 | GO_FLAGS := -ldflags "-X github.com/diillson/aws-finops-dashboard-go/pkg/version.Commit=$(COMMIT) -X github.com/diillson/aws-finops-dashboard-go/pkg/version.BuildTime=$(BUILD_TIME)" 17 | 18 | .PHONY: all build release clean test lint fmt help install uninstall 19 | 20 | all: clean lint test build 21 | 22 | # Compila o projeto 23 | build: 24 | @echo "Building $(BINARY_NAME) v$(VERSION)..." 25 | @mkdir -p $(BIN_DIR) 26 | @go build $(GO_FLAGS) -o $(BIN_DIR)/$(BINARY_NAME) cmd/aws-finops/main.go 27 | @echo "Build complete: $(BIN_DIR)/$(BINARY_NAME)" 28 | 29 | # Cria o release 30 | release: 31 | @echo "Releasing version $(VERSION)" 32 | git tag -a v$(VERSION) -m "Version $(VERSION)" 33 | git push origin v$(VERSION) 34 | 35 | # Limpa o diretório de build 36 | clean: 37 | @echo "Cleaning..." 38 | @rm -rf $(BIN_DIR) $(DIST_DIR) 39 | @go clean 40 | @echo "Clean complete" 41 | 42 | # Roda os testes 43 | test: 44 | @echo "Running tests..." 45 | @go test -v ./... 46 | 47 | # Roda o linter 48 | lint: 49 | @echo "Running golangci-lint..." 50 | @golangci-lint run 51 | 52 | # Formata o código 53 | fmt: 54 | @echo "Formatting code..." 55 | @gofmt -w -s . 56 | @echo "Format complete" 57 | 58 | # Instala o binário 59 | install: build 60 | @echo "Installing $(BINARY_NAME)..." 61 | @cp $(BIN_DIR)/$(BINARY_NAME) $(GOPATH)/bin/$(BINARY_NAME) 62 | @echo "Install complete: $(GOPATH)/bin/$(BINARY_NAME)" 63 | 64 | # Desinstala o binário 65 | uninstall: 66 | @echo "Uninstalling $(BINARY_NAME)..." 67 | @rm -f $(GOPATH)/bin/$(BINARY_NAME) 68 | @echo "Uninstall complete" 69 | 70 | # Ajuda 71 | help: 72 | @echo "Available targets:" 73 | @echo " all : Clean, lint, test and build" 74 | @echo " build : Build the binary" 75 | @echo " release : Create a new release" 76 | @echo " clean : Clean build artifacts" 77 | @echo " test : Run tests" 78 | @echo " lint : Run golangci-lint" 79 | @echo " fmt : Format code" 80 | @echo " install : Install binary to GOPATH/bin" 81 | @echo " uninstall : Remove binary from GOPATH/bin" 82 | @echo " help : Show this help" 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Funcionamento Detalhado da CLI do AWS FinOps Dashboard 2 | # AWS FinOps Dashboard CLI 3 | 4 | ### Prerequisitos 5 | - Go 1.24 ou superior 6 | - AWS CLI configurado com credenciais válidas 7 | - Acesso à AWS Cost Explorer API 8 | - Permissões adequadas para acessar os dados de custo e instâncias EC2 9 | 10 | 11 | ### AWS credentials com permissões: 12 | ```text 13 | ce:GetCostAndUsage 14 | budgets:DescribeBudgets 15 | ec2:DescribeInstances 16 | ec2:DescribeRegions 17 | sts:GetCallerIdentity 18 | ``` 19 | 20 | ### AWS credentials com permissões (para executar Audit report): 21 | ```text 22 | ec2:DescribeInstances 23 | ec2:DescribeVolumes 24 | ec2:DescribeAddresses 25 | budgets:DescribeBudgets 26 | resourcegroupstaggingapi:GetResources 27 | ec2:DescribeRegions 28 | ```` 29 | 30 | ### Instalação 31 | ```bash 32 | git clone https://github.com/diillson/aws-finops-dashboard-go.git 33 | 34 | cd aws-finops-dashboard-go 35 | 36 | make build 37 | ``` 38 | 39 | ### Executando a CLI 40 | Após o processo de build, o executável será gerado na pasta bin. 41 | ```path 42 | ./bin/aws-finops 43 | ``` 44 | 45 | ### AWS CLI Profile Setup 46 | Se você ainda não estiver configurado um perfil, configure seu nome de perfil usando AWS CLI será necessário para executar a CLI do AWS FinOps Dashboard.: 47 | ```text 48 | aws configure --profile profile1-name 49 | aws configure --profile profile2-name 50 | ... etc ... 51 | ``` 52 | Repetir para todos os perfil's que desejar usar na CLI. 53 | 54 | Após relizar o build execute o script usando aws-finops com options: 55 | ```bash 56 | aws-finops [options] 57 | ``` 58 | 59 | ## 1. Arquitetura Geral 60 | 61 | O AWS FinOps Dashboard segue uma arquitetura hexagonal (também conhecida como "portas e adaptadores") que separa claramente as responsabilidades: 62 | 63 | ### Principais Camadas: 64 | 65 | • Domain: Contém as entidades e contratos de repositórios (interfaces) 66 | • Application: Contém os casos de uso (lógica de negócio) 67 | • Adapters: Implementa os repositórios (driven) e interfaces de usuário (driving) 68 | 69 | ### Componentes Principais: 70 | 71 | - cmd/aws-finops/main.go : Ponto de entrada da aplicação 72 | - internal/adapter/driving/cli : Implementação da interface de linha de comando 73 | - internal/application/usecase : Lógica de negócios principal 74 | - internal/adapter/driven/aws : Implementação do repositório AWS 75 | - pkg/console : Utilitários para saída no console 76 | 77 | ## 2. Fluxo de Execução 78 | 79 | ### 2.1. Inicialização da Aplicação 80 | 81 | Quando o comando aws-finops é executado: 82 | 83 | 1. Bootstrap: Em main.go : 84 | ```go 85 | func main() { 86 | // Inicializa o aplicativo CLI 87 | app := cli.NewCLIApp(version.Version) 88 | 89 | // Inicializa os repositórios 90 | awsRepo := aws.NewAWSRepository() 91 | exportRepo := export.NewExportRepository() 92 | configRepo := config.NewConfigRepository() 93 | consoleImpl := console.NewConsole() 94 | 95 | // Inicializa o caso de uso 96 | dashboardUseCase := usecase.NewDashboardUseCase( 97 | awsRepo, 98 | exportRepo, 99 | configRepo, 100 | consoleImpl, 101 | ) 102 | 103 | // Define o caso de uso no aplicativo CLI 104 | app.SetDashboardUseCase(dashboardUseCase) 105 | 106 | // Executa o aplicativo 107 | if err := app.Execute(); err != nil { 108 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 109 | os.Exit(1) 110 | } 111 | } 112 | ``` 113 | 114 | 2. Criação do CLI App: O cli.NewCLIApp inicializa a aplicação CLI usando a biblioteca cobra : 115 | ```go 116 | func NewCLIApp(versionStr string) *CLIApp { 117 | app := &CLIApp{ 118 | version: versionStr, 119 | } 120 | 121 | // Configura o comando raiz 122 | rootCmd := &cobra.Command{ 123 | Use: "aws-finops", 124 | Short: "AWS FinOps Dashboard CLI", 125 | Version: formattedVersion, 126 | RunE: app.runCommand, 127 | } 128 | 129 | // Adiciona flags e opções de linha de comando 130 | rootCmd.PersistentFlags().StringP("config-file", "C", "", "...") 131 | rootCmd.PersistentFlags().StringSliceP("profiles", "p", nil, "...") 132 | // ... outras flags 133 | 134 | app.rootCmd = rootCmd 135 | return app 136 | } 137 | ``` 138 | 139 | ### 2.2. Execução do Comando 140 | 141 | Quando o comando é executado: 142 | 143 | 1. Exibição do Banner: O método displayWelcomeBanner exibe o banner ASCII colorido e a versão. 144 | 2. Verificação de Versão: A função checkLatestVersion é executada em uma goroutine para verificar se há atualizações sem bloquear a aplicação. 145 | 3. Análise de Argumentos: O método parseArgs converte os flags da linha de comando para um objeto CLIArgs . 146 | 4. Execução do Dashboard: O caso de uso principal dashboardUseCase.RunDashboard é invocado com os argumentos analisados. 147 | 148 | ### 2.3. Execução do Caso de Uso RunDashboard 149 | 150 | O método RunDashboard do DashboardUseCase é o coração da aplicação: 151 | 152 | 1. Inicialização dos Perfis AWS: 153 | profilesToUse, userRegions, timeRange, err := uc.InitializeProfiles(args) 154 | Esta etapa determina quais perfis AWS serão usados, com base nas flags --profiles , --all ou usando o perfil default. 155 | 2. Escolha do Tipo de Relatório: 156 | - Se --audit for especificado, executa o método RunAuditReport . 157 | - Se --trend for especificado, executa o método RunTrendAnalysis . 158 | - Caso contrário, executa o dashboard principal de custos. 159 | 3. Para o Dashboard Principal: 160 | - Cria uma tabela para exibição. 161 | - Gera os dados para cada perfil AWS. 162 | - Exibe a tabela formatada. 163 | - Exporta relatórios se a flag --report-name for especificada. 164 | 165 | 166 | ## 3. Processamento de Perfis AWS 167 | 168 | ### 3.1. Processamento Individual de Perfil 169 | 170 | Para cada perfil AWS especificado, o método ProcessSingleProfile ou ProcessSingleProfileWithProgress : 171 | 172 | 1. Obtenção de Dados AWS: 173 | - Obtém ID da conta AWS. 174 | - Obtém dados de custo do Cost Explorer. 175 | - Determina regiões acessíveis. 176 | - Obtém resumo das instâncias EC2. 177 | 2. Processamento dos Dados: 178 | - Processa custos por serviço. 179 | - Formata informações de orçamento. 180 | - Formata resumo das instâncias EC2. 181 | - Calcula alteração percentual no custo total. 182 | 3. Exibição dos Dados: 183 | - Adiciona os dados formatados e coloridos à tabela. 184 | 185 | 186 | ### 3.2. Processamento Combinado (para a flag --combine ) 187 | 188 | Quando a flag --combine é usada, os perfis que pertencem à mesma conta AWS são agrupados: 189 | 190 | 1. Agrupamento por Conta: 191 | for _, profile := range profilesToUse { 192 | accountID, err := uc.awsRepo.GetAccountID(ctx, profile) 193 | accountProfiles[accountID] = append(accountProfiles[accountID], profile) 194 | } 195 | 196 | 2. Processamento por Conta: 197 | Para cada conta, processa o perfil primário para obter dados de custo e EC2. 198 | 199 | ## 4. Tipos de Relatórios 200 | 201 | ### 4.1. Relatório de Custo (Padrão) 202 | 203 | Exibe uma tabela com: 204 | - Nome do perfil e ID da conta 205 | - Custo do mês anterior 206 | - Custo do mês atual com indicação de mudança percentual 207 | - Custos detalhados por serviço 208 | - Status do orçamento 209 | - Resumo das instâncias EC2 210 | 211 | 212 | ### 4.2. Relatório de Auditoria (flag --audit ) 213 | 214 | Exibe uma tabela com: 215 | - Nome do perfil e ID da conta 216 | - Recursos não marcados (untagged) 217 | - Instâncias EC2 paradas 218 | - Volumes EBS não utilizados 219 | - IPs Elásticos não utilizados 220 | - Alertas de orçamento 221 | 222 | 223 | ### 4.3. Análise de Tendência (flag --trend ) 224 | 225 | Exibe gráficos de barras mostrando: 226 | - Tendência de custos mensais por conta/perfil 227 | - Alteração percentual mês a mês 228 | - Visualização colorida para indicar aumento/diminuição 229 | 230 | 231 | ## 5. Exportação de Relatórios 232 | 233 | Quando a flag --report-name é fornecida, a aplicação pode exportar os relatórios em vários formatos: 234 | 235 | ### 5.1. Formatos Suportados (flag --report-type ): 236 | 237 | - CSV: Tabela em formato de valores separados por vírgulas 238 | - JSON: Representação estruturada em JSON 239 | - PDF: Documento PDF formatado com tabelas 240 | 241 | ### 5.2. Processo de Exportação: 242 | 243 | 1. Coleta e processa todos os dados. 244 | 2. Converte os dados para o formato especificado. 245 | 3. Salva no diretório especificado ou no diretório atual. 246 | 4. Exibe o caminho do arquivo salvo. 247 | 248 | ## 6. Interação com a AWS 249 | 250 | A interação com a AWS é gerenciada pela implementação do AWSRepository : 251 | 252 | ### 6.1. Principais Funcionalidades: 253 | 254 | - GetAWSProfiles: Obtém perfis do arquivo de credenciais AWS. 255 | - GetAccountID: Obtém o ID da conta AWS para um perfil. 256 | - GetCostData: Obtém dados de custo usando AWS Cost Explorer. 257 | - GetEC2Summary: Obtém resumo das instâncias EC2. 258 | - GetBudgets: Obtém informações de orçamento. 259 | - GetUntaggedResources: Identifica recursos sem tags. 260 | - GetStoppedInstances: Lista instâncias EC2 paradas. 261 | - GetUnusedVolumes: Lista volumes EBS não utilizados. 262 | - GetUnusedEIPs: Lista IPs Elásticos não utilizados. 263 | 264 | ## 7. Feedbacks Visuais 265 | 266 | A CLI fornece vários feedbacks visuais durante a execução: 267 | 268 | ### 7.1. Elementos Visuais: 269 | 270 | - Banner ASCII: Exibido no início. 271 | - Spinners: Mostrados durante operações de longa duração. 272 | - Barras de Progresso: Indicam o progresso do processamento. 273 | - Tabelas Coloridas: Exibem os dados formatados. 274 | - Códigos de Cores: 275 | - Verde: Valores positivos ou status bom 276 | - Amarelo: Avisos ou status neutro 277 | - Vermelho: Valores negativos ou alertas 278 | - Magenta: Identificadores de perfil 279 | - Ciano: Estados de instâncias alternativos 280 | 281 | 282 | ## 8. Personalização e Configuração 283 | 284 | A CLI pode ser personalizada através de: 285 | 286 | ### 8.1. Flags de Linha de Comando: 287 | 288 | - --profiles, -p: Especifica perfis AWS a serem usados 289 | - --regions, -r: Regiões AWS a verificar 290 | - --all, -a: Usa todos os perfis disponíveis 291 | - --combine, -c: Combina perfis da mesma conta 292 | - --report-name, -n: Nome do arquivo de relatório 293 | - --report-type, -y: Tipo de relatório (csv, json, pdf) 294 | - --dir, -d: Diretório para salvar o relatório 295 | - --time-range, -t: Intervalo de tempo para dados de custo 296 | - --tag, -g: Tag de alocação de custo para filtrar recursos 297 | - --trend: Exibe relatório de tendência 298 | - --audit: Exibe relatório de auditoria 299 | 300 | ### 8.2. Arquivo de Configuração: 301 | 302 | O AWS FinOps Dashboard suporta configuração via arquivos nos formatos TOML, YAML ou JSON, permitindo automatizar e padronizar as execuções sem precisar especificar os argumentos na linha de comando. Esta seção explica detalhadamente como utilizar esta funcionalidade. 303 | 304 | ### 8.2.1. Estrutura do Arquivo de Configuração 305 | type Config struct { 306 | Profiles []string `json:"profiles" yaml:"profiles" toml:"profiles"` 307 | Regions []string `json:"regions" yaml:"regions" toml:"regions"` 308 | Combine bool `json:"combine" yaml:"combine" toml:"combine"` 309 | ReportName string `json:"report_name" yaml:"report_name" toml:"report_name"` 310 | ReportType []string `json:"report_type" yaml:"report_type" toml:"report_type"` 311 | Dir string `json:"dir" yaml:"dir" toml:"dir"` 312 | TimeRange int `json:"time_range" yaml:"time_range" toml:"time_range"` 313 | Tag []string `json:"tag" yaml:"tag" toml:"tag"` 314 | Audit bool `json:"audit" yaml:"audit" toml:"audit"` 315 | Trend bool `json:"trend" yaml:"trend" toml:"trend"` 316 | } 317 | 318 | 319 | ### 8.2.2. Formatos Suportados 320 | O AWS FinOps Dashboard aceita os seguintes formatos de arquivo de configuração: 321 | 322 | - TOML ( .toml ): Tom's Obvious, Minimal Language - formato de configuração legível e minimalista 323 | - YAML ( .yaml ou .yml ): YAML Ain't Markup Language - formato muito utilizado para configurações 324 | - JSON ( .json ): JavaScript Object Notation - formato de dados compacto e independente de linguagem 325 | 326 | 327 | ## 8.2.3 Exemplos de Arquivos de Configuração 328 | 329 | ### Exemplo em TOML 330 | 331 | # config.toml - Configuração para AWS FinOps Dashboard 332 | ```toml 333 | # Perfis AWS a analisar 334 | profiles = ["production", "development", "data-warehouse"] 335 | 336 | # Regiões AWS a verificar (deixe vazio para auto-detecção) 337 | regions = ["us-east-1", "us-west-2", "eu-central-1"] 338 | 339 | # Combinar perfis da mesma conta 340 | combine = true 341 | 342 | # Configurações de relatório 343 | report_name = "aws-finops-monthly" 344 | report_type = ["csv", "pdf"] 345 | dir = "/home/user/reports/aws" 346 | 347 | # Período de tempo personalizado (em dias) 348 | time_range = 30 349 | 350 | # Tags para filtragem de custos 351 | tag = ["Environment=Production", "Department=IT"] 352 | 353 | # Tipos de relatório especiais 354 | audit = false 355 | trend = false 356 | ``` 357 | ### Exemplo em YAML 358 | 359 | ```yaml 360 | # config.yaml - Configuração para AWS FinOps Dashboard 361 | 362 | # Perfis AWS a analisar 363 | profiles: 364 | - production 365 | - development 366 | - data-warehouse 367 | 368 | # Regiões AWS a verificar (deixe vazio para auto-detecção) 369 | regions: 370 | - us-east-1 371 | - us-west-2 372 | - eu-central-1 373 | 374 | # Combinar perfis da mesma conta 375 | combine: true 376 | 377 | # Configurações de relatório 378 | report_name: aws-finops-monthly 379 | report_type: 380 | - csv 381 | - pdf 382 | dir: /home/user/reports/aws 383 | 384 | # Período de tempo personalizado (em dias) 385 | time_range: 30 386 | 387 | # Tags para filtragem de custos 388 | tag: 389 | - Environment=Production 390 | - Department=IT 391 | 392 | # Tipos de relatório especiais 393 | audit: false 394 | trend: false 395 | ``` 396 | 397 | ### Exemplo em JSON 398 | ```json 399 | { 400 | "profiles": ["production", "development", "data-warehouse"], 401 | "regions": ["us-east-1", "us-west-2", "eu-central-1"], 402 | "combine": true, 403 | "report_name": "aws-finops-monthly", 404 | "report_type": ["csv", "pdf"], 405 | "dir": "/home/user/reports/aws", 406 | "time_range": 30, 407 | "tag": ["Environment=Production", "Department=IT"], 408 | "audit": false, 409 | "trend": false 410 | } 411 | ``` 412 | 413 | ### 8.2.4. Exemplos de Casos de Uso 414 | ## Casos de Uso Específicos 415 | 416 | ### 1. Geração de Relatórios Mensais Automáticos 417 | 418 | # monthly-reporting.yaml 419 | profiles: 420 | - all-accounts # Perfil com acesso a todas as contas via IAM Role 421 | combine: true 422 | report_name: monthly-aws-costs 423 | report_type: 424 | - csv 425 | - pdf 426 | dir: /data/reports/aws/monthly 427 | 428 | ### 2. Auditoria de Recursos Não Utilizados 429 | 430 | # cost-optimization-audit.toml 431 | profiles = ["prod", "staging", "dev"] 432 | regions = ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] 433 | combine = false 434 | report_name = "resource-audit" 435 | report_type = ["csv", "pdf"] 436 | dir = "/data/audits" 437 | audit = true 438 | 439 | ### 3. Análise de Tendência por Departamento 440 | 441 | { 442 | "profiles": ["prod-main"], 443 | "combine": false, 444 | "tag": ["Department=Engineering", "Department=Marketing", "Department=Sales"], 445 | "trend": true, 446 | "time_range": 180 447 | } 448 | 449 | ### 4. Monitoramento Diário de Custos 450 | 451 | # daily-cost-check.yaml 452 | profiles: 453 | - production 454 | time_range: 7 # Últimos 7 dias 455 | regions: 456 | - us-east-1 457 | - us-west-2 458 | report_name: daily-cost-report 459 | report_type: 460 | - pdf 461 | dir: /var/reports/daily 462 | 463 | ## Como Utilizar o Arquivo de Configuração 464 | 465 | Para usar um arquivo de configuração, utilize a flag -C ou --config-file seguida do caminho para o arquivo: 466 | 467 | aws-finops --config-file /path/to/config.toml 468 | 469 | Você também pode sobrescrever qualquer configuração do arquivo via flags de linha de comando, que têm precedência: 470 | 471 | # Usa config.yaml mas substitui o nome do relatório e adiciona trend 472 | aws-finops --config-file config.yaml --report-name override-name --trend 473 | 474 | ## Carregamento Interno do Arquivo de Configuração 475 | 476 | O AWS FinOps Dashboard carrega o arquivo de configuração através da função LoadConfigFile no ConfigRepository : 477 | ```go 478 | func (r *ConfigRepositoryImpl) LoadConfigFile(filePath string) (*types.Config, error) { 479 | fileExtension := filepath.Ext(filePath) 480 | fileExtension = strings.ToLower(fileExtension) 481 | 482 | // Verifica se o arquivo existe 483 | fileInfo, err := os.Stat(filePath) 484 | if err != nil { 485 | return nil, fmt.Errorf("error accessing config file: %w", err) 486 | } 487 | 488 | // Lê o arquivo 489 | fileData, err := os.ReadFile(filePath) 490 | if err != nil { 491 | return nil, fmt.Errorf("error reading config file: %w", err) 492 | } 493 | 494 | var config types.Config 495 | 496 | switch fileExtension { 497 | case ".toml": 498 | if err := toml.Unmarshal(fileData, &config); err != nil { 499 | return nil, fmt.Errorf("error parsing TOML file: %w", err) 500 | } 501 | case ".yaml", ".yml": 502 | if err := yaml.Unmarshal(fileData, &config); err != nil { 503 | return nil, fmt.Errorf("error parsing YAML file: %w", err) 504 | } 505 | case ".json": 506 | if err := json.Unmarshal(fileData, &config); err != nil { 507 | return nil, fmt.Errorf("error parsing JSON file: %w", err) 508 | } 509 | default: 510 | return nil, fmt.Errorf("unsupported config file format: %s", fileExtension) 511 | } 512 | 513 | return &config, nil 514 | } 515 | ``` 516 | 517 | ## 9. Tratamento de Erros 518 | 519 | A CLI implementa um robusto tratamento de erros em vários níveis: 520 | 521 | ### 9.1. Principais Estratégias: 522 | 523 | - Erros de CLI: Exibidos no stderr com código de saída não-zero. 524 | - Erros de Acesso AWS: Registrados com mensagens informativas, mas a execução continua para outros perfis. 525 | - Erros de Exportação: Registrados, mas não impedem a exibição da tabela. 526 | - Perfis Inválidos: Avisos são exibidos, mas a execução continua com perfis válidos. 527 | 528 | ## 10. Concluindo 529 | 530 | A CLI do AWS FinOps Dashboard é uma ferramenta sofisticada que: 531 | 532 | 1. Facilita a Visibilidade: Apresenta dados de custos AWS de forma clara e segmentada. 533 | 2. Promove Otimização: Identifica recursos não utilizados e oportunidades de economia. 534 | 3. Suporta Compliance: Audita recursos sem tags e ajuda na governança. 535 | 4. Viabiliza Análise: Apresenta tendências de custo para informar decisões. 536 | 537 | Sua arquitetura modular e orientada a interfaces facilita a manutenção, extensão e teste, enquanto a experiência do usuário é enriquecida com feedback visual colorido e interativo. 538 | 539 | Esta CLI representa uma implementação prática e útil dos princípios de FinOps aplicados à infraestrutura AWS, permitindo que equipes visualizem, compreendam e otimizem seus gastos na nuvem. 540 | 541 | 542 | ## 11. Terminal Ouputs 543 | 544 | ![Dashboard](./img/aws-finops-dashboard-go-v1.png) 545 | 546 | ![Trend](./img/aws-finops-dashboard-go-trend.png) 547 | 548 | ![alt text](img/aws-finops-dashboard-go-audit-report.png) 549 | 550 | 551 | ## Licença 552 | 553 | This project is a Go implementation inspired by https://github.com/ravikiranvm/aws-finops-dashboard, originally developed in Python by https://github.com/ravikiranvm. 554 | 555 | Original license: MIT. See LICENSE file for more information. 556 | -------------------------------------------------------------------------------- /cmd/aws-finops/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/diillson/aws-finops-dashboard-go/internal/adapter/driven/aws" 8 | "github.com/diillson/aws-finops-dashboard-go/internal/adapter/driven/config" 9 | "github.com/diillson/aws-finops-dashboard-go/internal/adapter/driven/export" 10 | "github.com/diillson/aws-finops-dashboard-go/internal/adapter/driving/cli" 11 | "github.com/diillson/aws-finops-dashboard-go/internal/application/usecase" 12 | "github.com/diillson/aws-finops-dashboard-go/pkg/console" 13 | "github.com/diillson/aws-finops-dashboard-go/pkg/version" 14 | ) 15 | 16 | func main() { 17 | // Inicializa o aplicativo CLI 18 | app := cli.NewCLIApp(version.Version) 19 | 20 | // Inicializa os repositórios 21 | awsRepo := aws.NewAWSRepository() 22 | exportRepo := export.NewExportRepository() 23 | configRepo := config.NewConfigRepository() 24 | consoleImpl := console.NewConsole() 25 | 26 | // Inicializa o caso de uso 27 | dashboardUseCase := usecase.NewDashboardUseCase( 28 | awsRepo, 29 | exportRepo, 30 | configRepo, 31 | consoleImpl, 32 | ) 33 | 34 | // Define o caso de uso no aplicativo CLI 35 | app.SetDashboardUseCase(dashboardUseCase) 36 | 37 | // Executa o aplicativo 38 | if err := app.Execute(); err != nil { 39 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 40 | os.Exit(1) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/diillson/aws-finops-dashboard-go 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.36.3 7 | github.com/aws/aws-sdk-go-v2/config v1.29.14 8 | github.com/aws/aws-sdk-go-v2/service/budgets v1.31.0 9 | github.com/aws/aws-sdk-go-v2/service/costexplorer v1.49.0 10 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.218.0 11 | github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.45.2 12 | github.com/aws/aws-sdk-go-v2/service/lambda v1.71.2 13 | github.com/aws/aws-sdk-go-v2/service/rds v1.95.0 14 | github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.3 15 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 16 | github.com/fatih/color v1.18.0 17 | github.com/jung-kurt/gofpdf v1.16.2 18 | github.com/pelletier/go-toml v1.9.5 19 | github.com/pterm/pterm v0.12.80 20 | github.com/spf13/cobra v1.9.1 21 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 22 | gopkg.in/yaml.v3 v3.0.1 23 | ) 24 | 25 | require ( 26 | atomicgo.dev/cursor v0.2.0 // indirect 27 | atomicgo.dev/keyboard v0.2.9 // indirect 28 | atomicgo.dev/schedule v0.1.0 // indirect 29 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect 30 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 31 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 33 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 34 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 39 | github.com/aws/smithy-go v1.22.2 // indirect 40 | github.com/containerd/console v1.0.3 // indirect 41 | github.com/gookit/color v1.5.4 // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 44 | github.com/mattn/go-colorable v0.1.13 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/mattn/go-runewidth v0.0.16 // indirect 47 | github.com/rivo/uniseg v0.4.4 // indirect 48 | github.com/spf13/pflag v1.0.6 // indirect 49 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 50 | golang.org/x/sys v0.27.0 // indirect 51 | golang.org/x/term v0.26.0 // indirect 52 | golang.org/x/text v0.20.0 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= 2 | atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= 3 | atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= 4 | atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= 5 | atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= 6 | atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= 7 | atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= 8 | atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= 9 | github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= 10 | github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= 11 | github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= 12 | github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= 13 | github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= 14 | github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= 15 | github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= 16 | github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= 17 | github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= 18 | github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= 19 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 20 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 21 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= 22 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= 23 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 24 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 25 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 26 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 27 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 28 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 29 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 32 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 33 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 34 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 35 | github.com/aws/aws-sdk-go-v2/service/budgets v1.31.0 h1:mP7eNBOi2EeltVNHuOktwYpldEHV/t5zBHafmk5to0A= 36 | github.com/aws/aws-sdk-go-v2/service/budgets v1.31.0/go.mod h1:twa6cIACCvfTKjdl5209W8Gjr2igxlqgYPou4cYivGM= 37 | github.com/aws/aws-sdk-go-v2/service/costexplorer v1.49.0 h1:KaJZvF/hbq1Lhcd47boKZaN7cQQkB7ryNlUXOVfpCMc= 38 | github.com/aws/aws-sdk-go-v2/service/costexplorer v1.49.0/go.mod h1:zaYyuzR0Q8BI9yXtH5Jy9D7394t/96+cq/4qXZPUMxk= 39 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.218.0 h1:QPYsTfcPpPhkF+37pxLcl3xbQz2SRxsShQNB6VCkvLo= 40 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.218.0/go.mod h1:ouvGEfHbLaIlWwpDpOVWPWR+YwO0HDv3vm5tYLq8ImY= 41 | github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.45.2 h1:vX70Z4lNSr7XsioU0uJq5yvxgI50sB66MvD+V/3buS4= 42 | github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.45.2/go.mod h1:xnCC3vFBfOKpU6PcsCKL2ktgBTZfOwTGxj6V8/X3IS4= 43 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 44 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 45 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 46 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 47 | github.com/aws/aws-sdk-go-v2/service/lambda v1.71.2 h1:z926KZ1Ysi8Mbi4biJSAIRFdKemwQpO9M0QUTRLDaXA= 48 | github.com/aws/aws-sdk-go-v2/service/lambda v1.71.2/go.mod h1:c27kk10S36lBYgbG1jR3opn4OAS5Y/4wjJa1GiHK/X4= 49 | github.com/aws/aws-sdk-go-v2/service/rds v1.95.0 h1:7KmQEDuz6XWafMaeIahplfGSEakzX4RMSrNHyvhkEq8= 50 | github.com/aws/aws-sdk-go-v2/service/rds v1.95.0/go.mod h1:CXiHj5rVyQ5Q3zNSoYzwaJfWm8IGDweyyCGfO8ei5fQ= 51 | github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.3 h1:P87jejqS8WvQvRWyXlHUylt99VXt0y/WUIFuU6gBU7A= 52 | github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.3/go.mod h1:cgPfPTC/V3JqwCKed7Q6d0FrgarV7ltz4Bz6S4Q+Dqk= 53 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 54 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 55 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 56 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 57 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 58 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 59 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 60 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 61 | github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 62 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 63 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 64 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 65 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 66 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 67 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 68 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 69 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 70 | github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= 71 | github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= 72 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 73 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 74 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 75 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 76 | github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 77 | github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= 78 | github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= 79 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 80 | github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= 81 | github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= 82 | github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= 83 | github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 84 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 85 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 86 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 87 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 88 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 89 | github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= 90 | github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 91 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 92 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 93 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 94 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 95 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 96 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 97 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 98 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 99 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 100 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 101 | github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= 102 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 103 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 104 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 105 | github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= 106 | github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= 107 | github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= 108 | github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= 109 | github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= 110 | github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= 111 | github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= 112 | github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg= 113 | github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo= 114 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 115 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 116 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 117 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 118 | github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= 119 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 120 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 121 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 122 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 123 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 124 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 125 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 126 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 127 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 128 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 129 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 130 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 131 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 132 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= 133 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 134 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 135 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 136 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 137 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 138 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 139 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 140 | golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 141 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 142 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 143 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 144 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 145 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 146 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 147 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 148 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 149 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 150 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 151 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 155 | golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 156 | golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 159 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 160 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 163 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 164 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 165 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 166 | golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 167 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 168 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 169 | golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= 170 | golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= 171 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 172 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 173 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 174 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 175 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 176 | golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= 177 | golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= 178 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 179 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 180 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 181 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 182 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 183 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 184 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 185 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 186 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 187 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 188 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 189 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 190 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 191 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 192 | -------------------------------------------------------------------------------- /img/aws-finops-dashboard-go-audit-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diillson/aws-finops-dashboard-go/08f3d78ed4ae9b113facba20c2e1fe44cd3e4e38/img/aws-finops-dashboard-go-audit-report.png -------------------------------------------------------------------------------- /img/aws-finops-dashboard-go-trend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diillson/aws-finops-dashboard-go/08f3d78ed4ae9b113facba20c2e1fe44cd3e4e38/img/aws-finops-dashboard-go-trend.png -------------------------------------------------------------------------------- /img/aws-finops-dashboard-go-v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diillson/aws-finops-dashboard-go/08f3d78ed4ae9b113facba20c2e1fe44cd3e4e38/img/aws-finops-dashboard-go-v1.png -------------------------------------------------------------------------------- /internal/adapter/driven/aws/aws_repository.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | _ "math" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | _ "regexp" 11 | "sort" 12 | "strings" 13 | "time" 14 | 15 | "github.com/aws/aws-sdk-go-v2/aws" 16 | "github.com/aws/aws-sdk-go-v2/config" 17 | "github.com/aws/aws-sdk-go-v2/service/budgets" 18 | "github.com/aws/aws-sdk-go-v2/service/costexplorer" 19 | ce_types "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" 20 | "github.com/aws/aws-sdk-go-v2/service/ec2" 21 | ec2_types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 22 | "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" 23 | _ "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" 24 | "github.com/aws/aws-sdk-go-v2/service/lambda" 25 | "github.com/aws/aws-sdk-go-v2/service/rds" 26 | _ "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" 27 | "github.com/aws/aws-sdk-go-v2/service/sts" 28 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/entity" 29 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/repository" 30 | ) 31 | 32 | // AWSRepositoryImpl implementa o AWSRepository. 33 | type AWSRepositoryImpl struct{} 34 | 35 | // NewAWSRepository cria uma nova implementação do AWSRepository. 36 | func NewAWSRepository() repository.AWSRepository { 37 | return &AWSRepositoryImpl{} 38 | } 39 | 40 | // GetAWSProfiles retorna os perfis AWS disponíveis no arquivo de credenciais. 41 | func (r *AWSRepositoryImpl) GetAWSProfiles() []string { 42 | // Obtém o caminho para o arquivo de credenciais 43 | homeDir, err := os.UserHomeDir() 44 | if err != nil { 45 | return []string{"default"} 46 | } 47 | 48 | // Caminhos dos arquivos de configuração AWS 49 | credentialsPath := filepath.Join(homeDir, ".aws", "credentials") 50 | configPath := filepath.Join(homeDir, ".aws", "config") 51 | 52 | profiles := make(map[string]bool) 53 | 54 | // Lê perfis do arquivo credentials 55 | if fileContents, err := os.ReadFile(credentialsPath); err == nil { 56 | credProfiles := parseProfilesFromFile(string(fileContents)) 57 | for _, p := range credProfiles { 58 | profiles[p] = true 59 | } 60 | } 61 | 62 | // Lê perfis do arquivo config 63 | if fileContents, err := os.ReadFile(configPath); err == nil { 64 | // No arquivo config, os perfis são nomeados como "profile nome-do-perfil" 65 | configProfiles := parseProfilesFromConfigFile(string(fileContents)) 66 | for _, p := range configProfiles { 67 | profiles[p] = true 68 | } 69 | } 70 | 71 | // Converte o mapa em slice 72 | result := []string{} 73 | for profile := range profiles { 74 | result = append(result, profile) 75 | } 76 | 77 | // Garante que pelo menos "default" está incluído 78 | if len(result) == 0 { 79 | return []string{"default"} 80 | } 81 | 82 | // Ordena os perfis para facilitar a visualização 83 | sort.Strings(result) 84 | return result 85 | } 86 | 87 | // parseProfilesFromFile extrai nomes de perfil de um arquivo de credenciais AWS 88 | func parseProfilesFromFile(content string) []string { 89 | profiles := []string{} 90 | profileRegex := regexp.MustCompile(`\[(.*?)\]`) 91 | 92 | matches := profileRegex.FindAllStringSubmatch(content, -1) 93 | for _, match := range matches { 94 | if len(match) == 2 { 95 | profiles = append(profiles, match[1]) 96 | } 97 | } 98 | 99 | return profiles 100 | } 101 | 102 | // parseProfilesFromConfigFile extrai nomes de perfil de um arquivo config AWS 103 | func parseProfilesFromConfigFile(content string) []string { 104 | profiles := []string{} 105 | // No config, perfis são nomeados como "profile nome-do-perfil" exceto o default 106 | profileRegex := regexp.MustCompile(`\[profile (.*?)\]`) 107 | defaultRegex := regexp.MustCompile(`\[default\]`) 108 | 109 | // Verifica se o perfil default existe 110 | if defaultRegex.MatchString(content) { 111 | profiles = append(profiles, "default") 112 | } 113 | 114 | // Extrai os outros perfis 115 | matches := profileRegex.FindAllStringSubmatch(content, -1) 116 | for _, match := range matches { 117 | if len(match) == 2 { 118 | profiles = append(profiles, match[1]) 119 | } 120 | } 121 | 122 | return profiles 123 | } 124 | 125 | // GetSession retorna uma sessão AWS para o perfil especificado. 126 | func (r *AWSRepositoryImpl) GetSession(ctx context.Context, profile string) (string, error) { 127 | _, err := config.LoadDefaultConfig(ctx, 128 | config.WithSharedConfigProfile(profile), 129 | config.WithRegion("us-east-1"), // região padrão 130 | ) 131 | 132 | if err != nil { 133 | return "", fmt.Errorf("error loading AWS config for profile %s: %w", profile, err) 134 | } 135 | 136 | return profile, nil // retorna o perfil como identificador da sessão 137 | } 138 | 139 | // GetAccountID retorna o ID da conta AWS associada ao perfil especificado. 140 | func (r *AWSRepositoryImpl) GetAccountID(ctx context.Context, profile string) (string, error) { 141 | cfg, err := config.LoadDefaultConfig(ctx, 142 | config.WithSharedConfigProfile(profile), 143 | config.WithRegion("us-east-1"), 144 | ) 145 | 146 | if err != nil { 147 | return "", fmt.Errorf("error loading AWS config: %w", err) 148 | } 149 | 150 | stsClient := sts.NewFromConfig(cfg) 151 | result, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 152 | 153 | if err != nil { 154 | return "", fmt.Errorf("error getting account ID: %w", err) 155 | } 156 | 157 | return *result.Account, nil 158 | } 159 | 160 | // GetAllRegions retorna todas as regiões AWS disponíveis. 161 | func (r *AWSRepositoryImpl) GetAllRegions(ctx context.Context, profile string) ([]string, error) { 162 | cfg, err := config.LoadDefaultConfig(ctx, 163 | config.WithSharedConfigProfile(profile), 164 | config.WithRegion("us-east-1"), 165 | ) 166 | 167 | if err != nil { 168 | return nil, fmt.Errorf("error loading AWS config: %w", err) 169 | } 170 | 171 | ec2Client := ec2.NewFromConfig(cfg) 172 | result, err := ec2Client.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}) 173 | 174 | if err != nil { 175 | // Se falhar, retorna algumas regiões comuns 176 | return []string{ 177 | "us-east-1", "us-east-2", "us-west-1", "us-west-2", 178 | "ap-southeast-1", "ap-south-1", "eu-west-1", "eu-west-2", "eu-central-1", 179 | }, nil 180 | } 181 | 182 | regions := make([]string, 0, len(result.Regions)) 183 | for _, region := range result.Regions { 184 | regions = append(regions, *region.RegionName) 185 | } 186 | 187 | return regions, nil 188 | } 189 | 190 | // GetAccessibleRegions retorna regiões AWS acessíveis com as credenciais atuais. 191 | func (r *AWSRepositoryImpl) GetAccessibleRegions(ctx context.Context, profile string) ([]string, error) { 192 | allRegions, err := r.GetAllRegions(ctx, profile) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | accessibleRegions := []string{} 198 | cfg, err := config.LoadDefaultConfig(ctx, 199 | config.WithSharedConfigProfile(profile), 200 | ) 201 | 202 | if err != nil { 203 | return nil, fmt.Errorf("error loading AWS config: %w", err) 204 | } 205 | 206 | // Testa cada região para acessibilidade 207 | for _, region := range allRegions { 208 | regionCfg := cfg.Copy() 209 | regionCfg.Region = region 210 | 211 | ec2Client := ec2.NewFromConfig(regionCfg) 212 | _, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ 213 | MaxResults: aws.Int32(5), 214 | }) 215 | 216 | if err == nil { 217 | accessibleRegions = append(accessibleRegions, region) 218 | } 219 | } 220 | 221 | if len(accessibleRegions) == 0 { 222 | return []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2"}, nil 223 | } 224 | 225 | return accessibleRegions, nil 226 | } 227 | 228 | // GetEC2Summary retorna um resumo das instâncias EC2 nas regiões especificadas. 229 | func (r *AWSRepositoryImpl) GetEC2Summary(ctx context.Context, profile string, regions []string) (entity.EC2Summary, error) { 230 | summary := entity.EC2Summary{} 231 | 232 | cfg, err := config.LoadDefaultConfig(ctx, 233 | config.WithSharedConfigProfile(profile), 234 | ) 235 | 236 | if err != nil { 237 | return nil, fmt.Errorf("error loading AWS config: %w", err) 238 | } 239 | 240 | for _, region := range regions { 241 | regionCfg := cfg.Copy() 242 | regionCfg.Region = region 243 | 244 | ec2Client := ec2.NewFromConfig(regionCfg) 245 | instances, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}) 246 | 247 | if err != nil { 248 | continue // Pule esta região se houver um erro 249 | } 250 | 251 | for _, reservation := range instances.Reservations { 252 | for _, instance := range reservation.Instances { 253 | state := string(instance.State.Name) 254 | summary[state]++ 255 | } 256 | } 257 | } 258 | 259 | // Garante que estados padrão estão presentes mesmo se zero 260 | if _, ok := summary["running"]; !ok { 261 | summary["running"] = 0 262 | } 263 | if _, ok := summary["stopped"]; !ok { 264 | summary["stopped"] = 0 265 | } 266 | 267 | return summary, nil 268 | } 269 | 270 | // GetCostData recupera dados de custo da AWS Cost Explorer. 271 | func (r *AWSRepositoryImpl) GetCostData(ctx context.Context, profile string, timeRange *int, tags []string) (entity.CostData, error) { 272 | cfg, err := config.LoadDefaultConfig(ctx, 273 | config.WithSharedConfigProfile(profile), 274 | config.WithRegion("us-east-1"), // Cost Explorer é um serviço global 275 | ) 276 | 277 | if err != nil { 278 | return entity.CostData{}, fmt.Errorf("error loading AWS config: %w", err) 279 | } 280 | 281 | ceClient := costexplorer.NewFromConfig(cfg) 282 | accountID, _ := r.GetAccountID(ctx, profile) 283 | 284 | // Determina os períodos de tempo 285 | today := time.Now() 286 | var startDate, endDate, prevStartDate, prevEndDate time.Time 287 | currentPeriodName := "Current month's cost" 288 | previousPeriodName := "Last month's cost" 289 | 290 | if timeRange != nil && *timeRange > 0 { 291 | // Período personalizado baseado no timeRange 292 | endDate = today 293 | startDate = today.AddDate(0, 0, -(*timeRange)) 294 | prevEndDate = startDate.AddDate(0, 0, -1) 295 | prevStartDate = prevEndDate.AddDate(0, 0, -(*timeRange)) 296 | currentPeriodName = fmt.Sprintf("Current %d days cost", *timeRange) 297 | previousPeriodName = fmt.Sprintf("Previous %d days cost", *timeRange) 298 | } else { 299 | // Usa o mês atual 300 | startDate = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) 301 | endDate = today 302 | 303 | // Se estamos no primeiro dia do mês, avança um dia para garantir um período válido 304 | if startDate.Equal(endDate) { 305 | endDate = endDate.AddDate(0, 0, 1) 306 | } 307 | 308 | // Mês anterior 309 | prevEndDate = startDate.AddDate(0, 0, -1) 310 | prevStartDate = time.Date(prevEndDate.Year(), prevEndDate.Month(), 1, 0, 0, 0, 0, prevEndDate.Location()) 311 | } 312 | 313 | // Importante: verificações adicionais para garantir que as datas estejam em ordem correta 314 | if !startDate.Before(endDate) { 315 | return entity.CostData{}, fmt.Errorf("invalid date range: start date %s is not before end date %s", 316 | startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) 317 | } 318 | 319 | if !prevStartDate.Before(prevEndDate) { 320 | return entity.CostData{}, fmt.Errorf("invalid previous date range: start date %s is not before end date %s", 321 | prevStartDate.Format("2006-01-02"), prevEndDate.Format("2006-01-02")) 322 | } 323 | 324 | // Formata datas para a API 325 | startDateStr := startDate.Format("2006-01-02") 326 | endDateStr := endDate.Format("2006-01-02") 327 | prevStartDateStr := prevStartDate.Format("2006-01-02") 328 | prevEndDateStr := prevEndDate.Format("2006-01-02") 329 | 330 | // Prepara o filtro para tags 331 | // Nota: implementação simplificada para evitar erros de compilação 332 | var filterExpression *ce_types.Expression 333 | if len(tags) > 0 { 334 | // Implementação básica para evitar variáveis não utilizadas 335 | _ = tags 336 | // A implementação real precisaria parsear as tags e criar expressões 337 | } 338 | 339 | // Prepara o filtro para tags 340 | filterExpression, err = parseTagFilter(tags) 341 | if err != nil { 342 | return entity.CostData{}, fmt.Errorf("error parsing tag filter: %w", err) 343 | } 344 | 345 | // Obtém dados do período atual 346 | thisPeriodInput := &costexplorer.GetCostAndUsageInput{ 347 | TimePeriod: &ce_types.DateInterval{ 348 | Start: aws.String(startDateStr), 349 | End: aws.String(endDateStr), 350 | }, 351 | Granularity: ce_types.GranularityMonthly, 352 | Metrics: []string{"UnblendedCost"}, 353 | } 354 | 355 | // Adiciona filtro se necessário 356 | if filterExpression != nil { 357 | thisPeriodInput.Filter = filterExpression 358 | } 359 | 360 | thisPeriod, err := ceClient.GetCostAndUsage(ctx, thisPeriodInput) 361 | if err != nil { 362 | return entity.CostData{}, fmt.Errorf("error getting current period cost: %w", err) 363 | } 364 | 365 | // Obtém dados do período anterior 366 | previousPeriodInput := &costexplorer.GetCostAndUsageInput{ 367 | TimePeriod: &ce_types.DateInterval{ 368 | Start: aws.String(prevStartDateStr), 369 | End: aws.String(prevEndDateStr), 370 | }, 371 | Granularity: ce_types.GranularityMonthly, 372 | Metrics: []string{"UnblendedCost"}, 373 | } 374 | 375 | // Adiciona filtro se necessário 376 | if filterExpression != nil { 377 | previousPeriodInput.Filter = filterExpression 378 | } 379 | 380 | previousPeriod, err := ceClient.GetCostAndUsage(ctx, previousPeriodInput) 381 | if err != nil { 382 | return entity.CostData{}, fmt.Errorf("error getting previous period cost: %w", err) 383 | } 384 | 385 | // Obtém custos por serviço 386 | serviceInput := &costexplorer.GetCostAndUsageInput{ 387 | TimePeriod: &ce_types.DateInterval{ 388 | Start: aws.String(startDateStr), 389 | End: aws.String(endDateStr), 390 | }, 391 | Granularity: ce_types.GranularityMonthly, 392 | Metrics: []string{"UnblendedCost"}, 393 | GroupBy: []ce_types.GroupDefinition{ 394 | { 395 | Type: ce_types.GroupDefinitionTypeDimension, 396 | Key: aws.String("SERVICE"), 397 | }, 398 | }, 399 | } 400 | 401 | // Adiciona filtro se necessário 402 | if filterExpression != nil { 403 | serviceInput.Filter = filterExpression 404 | } 405 | 406 | serviceData, err := ceClient.GetCostAndUsage(ctx, serviceInput) 407 | if err != nil { 408 | return entity.CostData{}, fmt.Errorf("error getting service cost data: %w", err) 409 | } 410 | 411 | // Processa os resultados 412 | currentMonthCost := 0.0 413 | for _, result := range thisPeriod.ResultsByTime { 414 | if cost, ok := result.Total["UnblendedCost"]; ok { 415 | amount := cost.Amount 416 | if amount != nil { 417 | // Converte string para float64 418 | value := 0.0 419 | fmt.Sscanf(*amount, "%f", &value) 420 | currentMonthCost += value 421 | } 422 | } 423 | } 424 | 425 | lastMonthCost := 0.0 426 | for _, result := range previousPeriod.ResultsByTime { 427 | if cost, ok := result.Total["UnblendedCost"]; ok { 428 | amount := cost.Amount 429 | if amount != nil { 430 | // Converte string para float64 431 | value := 0.0 432 | fmt.Sscanf(*amount, "%f", &value) 433 | lastMonthCost += value 434 | } 435 | } 436 | } 437 | 438 | // Processa custos por serviço 439 | serviceCosts := []entity.ServiceCost{} 440 | for _, result := range serviceData.ResultsByTime { 441 | for _, group := range result.Groups { 442 | if len(group.Keys) > 0 { 443 | serviceName := group.Keys[0] 444 | if cost, ok := group.Metrics["UnblendedCost"]; ok && cost.Amount != nil { 445 | // Converte string para float64 446 | amount := 0.0 447 | fmt.Sscanf(*cost.Amount, "%f", &amount) 448 | 449 | if amount > 0.001 { 450 | serviceCosts = append(serviceCosts, entity.ServiceCost{ 451 | ServiceName: serviceName, 452 | Cost: amount, 453 | }) 454 | } 455 | } 456 | } 457 | } 458 | } 459 | 460 | // Ordena serviços por custo (maior para menor) 461 | sort.Slice(serviceCosts, func(i, j int) bool { 462 | return serviceCosts[i].Cost > serviceCosts[j].Cost 463 | }) 464 | 465 | // Obtém dados de orçamento 466 | budgets, _ := r.GetBudgets(ctx, profile) 467 | 468 | // Em Go, não existe operador ternário, então precisamos usar uma declaração if/else 469 | var timeRangeVal int 470 | if timeRange != nil { 471 | timeRangeVal = *timeRange 472 | } 473 | 474 | return entity.CostData{ 475 | AccountID: accountID, 476 | CurrentMonthCost: currentMonthCost, 477 | LastMonthCost: lastMonthCost, 478 | CurrentMonthCostByService: serviceCosts, 479 | Budgets: budgets, 480 | CurrentPeriodName: currentPeriodName, 481 | PreviousPeriodName: previousPeriodName, 482 | TimeRange: timeRangeVal, 483 | CurrentPeriodStart: startDate, 484 | CurrentPeriodEnd: endDate, 485 | PreviousPeriodStart: prevStartDate, 486 | PreviousPeriodEnd: prevEndDate, 487 | }, nil 488 | } 489 | 490 | // GetBudgets obtém os dados de orçamento da conta AWS. 491 | func (r *AWSRepositoryImpl) GetBudgets(ctx context.Context, profile string) ([]entity.BudgetInfo, error) { 492 | cfg, err := config.LoadDefaultConfig(ctx, 493 | config.WithSharedConfigProfile(profile), 494 | config.WithRegion("us-east-1"), // Budgets é um serviço global 495 | ) 496 | 497 | if err != nil { 498 | return nil, fmt.Errorf("error loading AWS config: %w", err) 499 | } 500 | 501 | budgetsClient := budgets.NewFromConfig(cfg) 502 | accountID, err := r.GetAccountID(ctx, profile) 503 | 504 | if err != nil { 505 | return nil, fmt.Errorf("error getting account ID: %w", err) 506 | } 507 | 508 | // Obtém os orçamentos 509 | result, err := budgetsClient.DescribeBudgets(ctx, &budgets.DescribeBudgetsInput{ 510 | AccountId: aws.String(accountID), 511 | }) 512 | 513 | if err != nil { 514 | return []entity.BudgetInfo{}, nil 515 | } 516 | 517 | budgetsData := []entity.BudgetInfo{} 518 | 519 | for _, budget := range result.Budgets { 520 | if budget.BudgetLimit == nil || budget.CalculatedSpend == nil || budget.CalculatedSpend.ActualSpend == nil { 521 | continue 522 | } 523 | 524 | // Converte string para float64 525 | limit := 0.0 526 | fmt.Sscanf(*budget.BudgetLimit.Amount, "%f", &limit) 527 | 528 | actual := 0.0 529 | fmt.Sscanf(*budget.CalculatedSpend.ActualSpend.Amount, "%f", &actual) 530 | 531 | var forecast float64 532 | if budget.CalculatedSpend.ForecastedSpend != nil && budget.CalculatedSpend.ForecastedSpend.Amount != nil { 533 | fmt.Sscanf(*budget.CalculatedSpend.ForecastedSpend.Amount, "%f", &forecast) 534 | } 535 | 536 | budgetsData = append(budgetsData, entity.BudgetInfo{ 537 | Name: *budget.BudgetName, 538 | Limit: limit, 539 | Actual: actual, 540 | Forecast: forecast, 541 | }) 542 | } 543 | 544 | return budgetsData, nil 545 | } 546 | 547 | // GetTrendData retorna dados de tendência de custo para análise. 548 | func (r *AWSRepositoryImpl) GetTrendData(ctx context.Context, profile string, tags []string) (map[string]interface{}, error) { 549 | cfg, err := config.LoadDefaultConfig(ctx, 550 | config.WithSharedConfigProfile(profile), 551 | config.WithRegion("us-east-1"), 552 | ) 553 | 554 | if err != nil { 555 | return nil, fmt.Errorf("error loading AWS config: %w", err) 556 | } 557 | 558 | ceClient := costexplorer.NewFromConfig(cfg) 559 | accountID, err := r.GetAccountID(ctx, profile) 560 | if err != nil { 561 | accountID = "Unknown" 562 | } 563 | 564 | // Determina o período de tempo para tendência (últimos 6 meses) 565 | today := time.Now() 566 | endDate := today 567 | startDate := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()).AddDate(0, -6, 0) 568 | 569 | // Prepara o filtro para tags 570 | var filterExpression *ce_types.Expression 571 | if len(tags) > 0 { 572 | // Implementação básica para evitar variáveis não utilizadas 573 | _ = tags 574 | // A implementação real precisaria parsear as tags e criar expressões 575 | } 576 | 577 | // Obtém dados de tendência mensal 578 | trendInput := &costexplorer.GetCostAndUsageInput{ 579 | TimePeriod: &ce_types.DateInterval{ 580 | Start: aws.String(startDate.Format("2006-01-02")), 581 | End: aws.String(endDate.Format("2006-01-02")), 582 | }, 583 | Granularity: ce_types.GranularityMonthly, 584 | Metrics: []string{"UnblendedCost"}, 585 | } 586 | 587 | // Adiciona filtro se necessário 588 | if filterExpression != nil { 589 | trendInput.Filter = filterExpression 590 | } 591 | 592 | trendData, err := ceClient.GetCostAndUsage(ctx, trendInput) 593 | if err != nil { 594 | return map[string]interface{}{ 595 | "monthly_costs": []entity.MonthlyCost{}, 596 | "account_id": accountID, 597 | }, nil 598 | } 599 | 600 | // Processa os resultados 601 | monthlyCosts := []entity.MonthlyCost{} 602 | for _, result := range trendData.ResultsByTime { 603 | if result.TimePeriod != nil && result.TimePeriod.Start != nil { 604 | // Parse da data de início para obter o mês/ano 605 | dateStr := *result.TimePeriod.Start 606 | t, err := time.Parse("2006-01-02", dateStr) 607 | if err != nil { 608 | continue 609 | } 610 | monthYear := t.Format("Jan 2006") 611 | 612 | // Obter o custo para o mês 613 | cost := 0.0 614 | if costMetric, ok := result.Total["UnblendedCost"]; ok && costMetric.Amount != nil { 615 | fmt.Sscanf(*costMetric.Amount, "%f", &cost) 616 | } 617 | 618 | monthlyCosts = append(monthlyCosts, entity.MonthlyCost{ 619 | Month: monthYear, 620 | Cost: cost, 621 | }) 622 | } 623 | } 624 | 625 | return map[string]interface{}{ 626 | "monthly_costs": monthlyCosts, 627 | "account_id": accountID, 628 | }, nil 629 | } 630 | 631 | // GetStoppedInstances retorna instâncias EC2 paradas agrupadas por região. 632 | func (r *AWSRepositoryImpl) GetStoppedInstances(ctx context.Context, profile string, regions []string) (entity.StoppedEC2Instances, error) { 633 | stopped := make(entity.StoppedEC2Instances) 634 | 635 | cfg, err := config.LoadDefaultConfig(ctx, 636 | config.WithSharedConfigProfile(profile), 637 | ) 638 | 639 | if err != nil { 640 | return nil, fmt.Errorf("error loading AWS config: %w", err) 641 | } 642 | 643 | for _, region := range regions { 644 | regionCfg := cfg.Copy() 645 | regionCfg.Region = region 646 | 647 | ec2Client := ec2.NewFromConfig(regionCfg) 648 | 649 | // Filtro para instâncias paradas 650 | result, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ 651 | Filters: []ec2_types.Filter{ 652 | { 653 | Name: aws.String("instance-state-name"), 654 | Values: []string{"stopped"}, 655 | }, 656 | }, 657 | }) 658 | 659 | if err != nil { 660 | continue // Pule esta região se houver um erro 661 | } 662 | 663 | instanceIDs := []string{} 664 | for _, reservation := range result.Reservations { 665 | for _, instance := range reservation.Instances { 666 | instanceIDs = append(instanceIDs, *instance.InstanceId) 667 | } 668 | } 669 | 670 | if len(instanceIDs) > 0 { 671 | stopped[region] = instanceIDs 672 | } 673 | } 674 | 675 | return stopped, nil 676 | } 677 | 678 | // GetUnusedVolumes retorna volumes EBS não anexados agrupados por região. 679 | func (r *AWSRepositoryImpl) GetUnusedVolumes(ctx context.Context, profile string, regions []string) (entity.UnusedVolumes, error) { 680 | unused := make(entity.UnusedVolumes) 681 | 682 | cfg, err := config.LoadDefaultConfig(ctx, 683 | config.WithSharedConfigProfile(profile), 684 | ) 685 | 686 | if err != nil { 687 | return nil, fmt.Errorf("error loading AWS config: %w", err) 688 | } 689 | 690 | for _, region := range regions { 691 | regionCfg := cfg.Copy() 692 | regionCfg.Region = region 693 | 694 | ec2Client := ec2.NewFromConfig(regionCfg) 695 | 696 | // Filtro para volumes disponíveis (não anexados) 697 | result, err := ec2Client.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{ 698 | Filters: []ec2_types.Filter{ 699 | { 700 | Name: aws.String("status"), 701 | Values: []string{"available"}, 702 | }, 703 | }, 704 | }) 705 | 706 | if err != nil { 707 | continue // Pule esta região se houver um erro 708 | } 709 | 710 | volumeIDs := []string{} 711 | for _, volume := range result.Volumes { 712 | volumeIDs = append(volumeIDs, *volume.VolumeId) 713 | } 714 | 715 | if len(volumeIDs) > 0 { 716 | unused[region] = volumeIDs 717 | } 718 | } 719 | 720 | return unused, nil 721 | } 722 | 723 | // GetUnusedEIPs retorna Elastic IPs não utilizados agrupados por região. 724 | func (r *AWSRepositoryImpl) GetUnusedEIPs(ctx context.Context, profile string, regions []string) (entity.UnusedEIPs, error) { 725 | eips := make(entity.UnusedEIPs) 726 | 727 | cfg, err := config.LoadDefaultConfig(ctx, 728 | config.WithSharedConfigProfile(profile), 729 | ) 730 | 731 | if err != nil { 732 | return nil, fmt.Errorf("error loading AWS config: %w", err) 733 | } 734 | 735 | for _, region := range regions { 736 | regionCfg := cfg.Copy() 737 | regionCfg.Region = region 738 | 739 | ec2Client := ec2.NewFromConfig(regionCfg) 740 | result, err := ec2Client.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}) 741 | 742 | if err != nil { 743 | continue // Pule esta região se houver um erro 744 | } 745 | 746 | freeIPs := []string{} 747 | for _, addr := range result.Addresses { 748 | // Se não tiver AssociationId, o EIP não está associado a um recurso 749 | if addr.AssociationId == nil && addr.PublicIp != nil { 750 | freeIPs = append(freeIPs, *addr.PublicIp) 751 | } 752 | } 753 | 754 | if len(freeIPs) > 0 { 755 | eips[region] = freeIPs 756 | } 757 | } 758 | 759 | return eips, nil 760 | } 761 | 762 | // GetUntaggedResources retorna recursos não marcados agrupados por serviço e região. 763 | func (r *AWSRepositoryImpl) GetUntaggedResources(ctx context.Context, profile string, regions []string) (entity.UntaggedResources, error) { 764 | untagged := make(entity.UntaggedResources) 765 | untagged["EC2"] = make(map[string][]string) 766 | untagged["RDS"] = make(map[string][]string) 767 | untagged["Lambda"] = make(map[string][]string) 768 | untagged["ELBv2"] = make(map[string][]string) 769 | 770 | cfg, err := config.LoadDefaultConfig(ctx, 771 | config.WithSharedConfigProfile(profile), 772 | ) 773 | 774 | if err != nil { 775 | return nil, fmt.Errorf("error loading AWS config: %w", err) 776 | } 777 | 778 | for _, region := range regions { 779 | regionCfg := cfg.Copy() 780 | regionCfg.Region = region 781 | 782 | // EC2 Instances 783 | ec2Client := ec2.NewFromConfig(regionCfg) 784 | instances, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}) 785 | if err == nil { 786 | untaggedEC2 := []string{} 787 | for _, reservation := range instances.Reservations { 788 | for _, instance := range reservation.Instances { 789 | if len(instance.Tags) == 0 { 790 | untaggedEC2 = append(untaggedEC2, *instance.InstanceId) 791 | } 792 | } 793 | } 794 | if len(untaggedEC2) > 0 { 795 | untagged["EC2"][region] = untaggedEC2 796 | } 797 | } 798 | 799 | // RDS Instances 800 | rdsClient := rds.NewFromConfig(regionCfg) 801 | rdsInstances, err := rdsClient.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{}) 802 | if err == nil { 803 | untaggedRDS := []string{} 804 | for _, db := range rdsInstances.DBInstances { 805 | // Obtém tags para a instância RDS 806 | tagsOutput, err := rdsClient.ListTagsForResource(ctx, &rds.ListTagsForResourceInput{ 807 | ResourceName: db.DBInstanceArn, 808 | }) 809 | if err == nil && len(tagsOutput.TagList) == 0 { 810 | untaggedRDS = append(untaggedRDS, *db.DBInstanceIdentifier) 811 | } 812 | } 813 | if len(untaggedRDS) > 0 { 814 | untagged["RDS"][region] = untaggedRDS 815 | } 816 | } 817 | 818 | // Lambda Functions 819 | lambdaClient := lambda.NewFromConfig(regionCfg) 820 | lambdaFunctions, err := lambdaClient.ListFunctions(ctx, &lambda.ListFunctionsInput{}) 821 | if err == nil { 822 | untaggedLambda := []string{} 823 | for _, function := range lambdaFunctions.Functions { 824 | // Obtém tags para a função Lambda 825 | tagsOutput, err := lambdaClient.ListTags(ctx, &lambda.ListTagsInput{ 826 | Resource: function.FunctionArn, 827 | }) 828 | if err == nil && len(tagsOutput.Tags) == 0 { 829 | untaggedLambda = append(untaggedLambda, *function.FunctionName) 830 | } 831 | } 832 | if len(untaggedLambda) > 0 { 833 | untagged["Lambda"][region] = untaggedLambda 834 | } 835 | } 836 | 837 | // ELBv2 Load Balancers 838 | elbv2Client := elasticloadbalancingv2.NewFromConfig(regionCfg) 839 | loadBalancers, err := elbv2Client.DescribeLoadBalancers(ctx, &elasticloadbalancingv2.DescribeLoadBalancersInput{}) 840 | if err == nil && len(loadBalancers.LoadBalancers) > 0 { 841 | // Cria um mapa ARN -> Nome 842 | arnToName := make(map[string]string) 843 | arns := []string{} 844 | 845 | for _, lb := range loadBalancers.LoadBalancers { 846 | arnToName[*lb.LoadBalancerArn] = *lb.LoadBalancerName 847 | arns = append(arns, *lb.LoadBalancerArn) 848 | } 849 | 850 | // Busca tags para todos os load balancers 851 | if len(arns) > 0 { 852 | tagsOutput, err := elbv2Client.DescribeTags(ctx, &elasticloadbalancingv2.DescribeTagsInput{ 853 | ResourceArns: arns, 854 | }) 855 | 856 | if err == nil { 857 | untaggedELB := []string{} 858 | 859 | for _, tagDesc := range tagsOutput.TagDescriptions { 860 | if len(tagDesc.Tags) == 0 { 861 | if name, ok := arnToName[*tagDesc.ResourceArn]; ok { 862 | untaggedELB = append(untaggedELB, name) 863 | } 864 | } 865 | } 866 | 867 | if len(untaggedELB) > 0 { 868 | untagged["ELBv2"][region] = untaggedELB 869 | } 870 | } 871 | } 872 | } 873 | } 874 | 875 | return untagged, nil 876 | } 877 | 878 | // parseTagFilter converte tags no formato Key=Value para filtros de Cost Explorer. 879 | func parseTagFilter(tags []string) (*ce_types.Expression, error) { 880 | if len(tags) == 0 { 881 | return nil, nil 882 | } 883 | 884 | tagExpressions := []ce_types.Expression{} 885 | 886 | for _, tag := range tags { 887 | parts := strings.SplitN(tag, "=", 2) 888 | if len(parts) != 2 { 889 | return nil, fmt.Errorf("invalid tag format: %s (should be Key=Value)", tag) 890 | } 891 | 892 | key, value := parts[0], parts[1] 893 | tagExpressions = append(tagExpressions, ce_types.Expression{ 894 | Tags: &ce_types.TagValues{ 895 | Key: aws.String(key), 896 | Values: []string{value}, 897 | MatchOptions: []ce_types.MatchOption{ce_types.MatchOptionEquals}, 898 | }, 899 | }) 900 | } 901 | 902 | // Se houver apenas uma tag, retorna a expressão direta 903 | if len(tagExpressions) == 1 { 904 | return &tagExpressions[0], nil 905 | } 906 | 907 | // Caso contrário, combina as expressões com AND 908 | return &ce_types.Expression{ 909 | And: tagExpressions, 910 | }, nil 911 | } 912 | -------------------------------------------------------------------------------- /internal/adapter/driven/config/config_repository.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/repository" 11 | "github.com/diillson/aws-finops-dashboard-go/internal/shared/types" 12 | "github.com/pelletier/go-toml" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // ConfigRepositoryImpl implementa o ConfigRepository. 17 | type ConfigRepositoryImpl struct{} 18 | 19 | // NewConfigRepository cria uma nova implementação do ConfigRepository. 20 | func NewConfigRepository() repository.ConfigRepository { 21 | return &ConfigRepositoryImpl{} 22 | } 23 | 24 | // LoadConfigFile carrega um arquivo de configuração TOML, YAML ou JSON. 25 | func (r *ConfigRepositoryImpl) LoadConfigFile(filePath string) (*types.Config, error) { 26 | fileExtension := filepath.Ext(filePath) 27 | fileExtension = strings.ToLower(fileExtension) 28 | 29 | // Verifica se o arquivo existe 30 | fileInfo, err := os.Stat(filePath) 31 | if err != nil { 32 | return nil, fmt.Errorf("error accessing config file: %w", err) 33 | } 34 | 35 | if fileInfo.IsDir() { 36 | return nil, fmt.Errorf("%s is a directory, not a file", filePath) 37 | } 38 | 39 | // Lê o arquivo 40 | fileData, err := os.ReadFile(filePath) 41 | if err != nil { 42 | return nil, fmt.Errorf("error reading config file: %w", err) 43 | } 44 | 45 | var config types.Config 46 | 47 | switch fileExtension { 48 | case ".toml": 49 | if err := toml.Unmarshal(fileData, &config); err != nil { 50 | return nil, fmt.Errorf("error parsing TOML file: %w", err) 51 | } 52 | case ".yaml", ".yml": 53 | if err := yaml.Unmarshal(fileData, &config); err != nil { 54 | return nil, fmt.Errorf("error parsing YAML file: %w", err) 55 | } 56 | case ".json": 57 | if err := json.Unmarshal(fileData, &config); err != nil { 58 | return nil, fmt.Errorf("error parsing JSON file: %w", err) 59 | } 60 | default: 61 | return nil, fmt.Errorf("unsupported config file format: %s", fileExtension) 62 | } 63 | 64 | return &config, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/adapter/driven/export/export_repository.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "encoding/csv" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/entity" 14 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/repository" 15 | "github.com/jung-kurt/gofpdf" 16 | ) 17 | 18 | // ExportRepositoryImpl implementa o ExportRepository. 19 | type ExportRepositoryImpl struct{} 20 | 21 | // NewExportRepository cria uma nova implementação do ExportRepository. 22 | func NewExportRepository() repository.ExportRepository { 23 | return &ExportRepositoryImpl{} 24 | } 25 | 26 | // ExportToCSV exporta dados para um arquivo CSV. 27 | func (r *ExportRepositoryImpl) ExportToCSV( 28 | data []entity.ProfileData, 29 | filename string, 30 | outputDir string, 31 | previousPeriodDates string, 32 | currentPeriodDates string, 33 | ) (string, error) { 34 | timestamp := time.Now().Format("20060102_1504") 35 | baseFilename := fmt.Sprintf("%s_%s.csv", filename, timestamp) 36 | 37 | // Garantir que o diretório de saída existe 38 | if err := os.MkdirAll(outputDir, 0755); err != nil { 39 | return "", fmt.Errorf("error creating output directory: %w", err) 40 | } 41 | 42 | outputFilename := filepath.Join(outputDir, baseFilename) 43 | 44 | file, err := os.Create(outputFilename) 45 | if err != nil { 46 | return "", fmt.Errorf("error creating CSV file: %w", err) 47 | } 48 | defer file.Close() 49 | 50 | writer := csv.NewWriter(file) 51 | defer writer.Flush() 52 | 53 | previousPeriodHeader := fmt.Sprintf("Cost for period\n(%s)", previousPeriodDates) 54 | currentPeriodHeader := fmt.Sprintf("Cost for period\n(%s)", currentPeriodDates) 55 | 56 | // Escreve o cabeçalho 57 | headers := []string{ 58 | "CLI Profile", 59 | "AWS Account ID", 60 | previousPeriodHeader, 61 | currentPeriodHeader, 62 | "Cost By Service", 63 | "Budget Status", 64 | "EC2 Instances", 65 | } 66 | if err := writer.Write(headers); err != nil { 67 | return "", fmt.Errorf("error writing CSV header: %w", err) 68 | } 69 | 70 | // Escreve os dados 71 | for _, row := range data { 72 | servicesData := "" 73 | for _, sc := range row.ServiceCosts { 74 | servicesData += fmt.Sprintf("%s: $%.2f\n", sc.ServiceName, sc.Cost) 75 | } 76 | 77 | budgetsData := strings.Join(row.BudgetInfo, "\n") 78 | if budgetsData == "" { 79 | budgetsData = "No budgets" 80 | } 81 | 82 | ec2Data := "" 83 | for state, count := range row.EC2Summary { 84 | if count > 0 { 85 | ec2Data += fmt.Sprintf("%s: %d\n", state, count) 86 | } 87 | } 88 | if ec2Data == "" { 89 | ec2Data = "No instances" 90 | } 91 | 92 | record := []string{ 93 | row.Profile, 94 | row.AccountID, 95 | fmt.Sprintf("$%.2f", row.LastMonth), 96 | fmt.Sprintf("$%.2f", row.CurrentMonth), 97 | servicesData, 98 | budgetsData, 99 | ec2Data, 100 | } 101 | 102 | if err := writer.Write(record); err != nil { 103 | return "", fmt.Errorf("error writing CSV row: %w", err) 104 | } 105 | } 106 | 107 | absPath, err := filepath.Abs(outputFilename) 108 | if err != nil { 109 | return outputFilename, nil 110 | } 111 | return absPath, nil 112 | } 113 | 114 | // ExportToJSON exporta dados para um arquivo JSON. 115 | func (r *ExportRepositoryImpl) ExportToJSON( 116 | data []entity.ProfileData, 117 | filename string, 118 | outputDir string, 119 | ) (string, error) { 120 | timestamp := time.Now().Format("20060102_1504") 121 | baseFilename := fmt.Sprintf("%s_%s.json", filename, timestamp) 122 | 123 | // Garantir que o diretório de saída existe 124 | if err := os.MkdirAll(outputDir, 0755); err != nil { 125 | return "", fmt.Errorf("error creating output directory: %w", err) 126 | } 127 | 128 | outputFilename := filepath.Join(outputDir, baseFilename) 129 | 130 | file, err := os.Create(outputFilename) 131 | if err != nil { 132 | return "", fmt.Errorf("error creating JSON file: %w", err) 133 | } 134 | defer file.Close() 135 | 136 | encoder := json.NewEncoder(file) 137 | encoder.SetIndent("", " ") 138 | if err := encoder.Encode(data); err != nil { 139 | return "", fmt.Errorf("error encoding JSON data: %w", err) 140 | } 141 | 142 | absPath, err := filepath.Abs(outputFilename) 143 | if err != nil { 144 | return outputFilename, nil 145 | } 146 | return absPath, nil 147 | } 148 | 149 | // ExportToPDF exporta dados para um arquivo PDF. 150 | func (r *ExportRepositoryImpl) ExportToPDF( 151 | data []entity.ProfileData, 152 | filename string, 153 | outputDir string, 154 | previousPeriodDates string, 155 | currentPeriodDates string, 156 | ) (string, error) { 157 | timestamp := time.Now().Format("20060102_1504") 158 | baseFilename := fmt.Sprintf("%s_%s.pdf", filename, timestamp) 159 | 160 | // Garantir que o diretório de saída existe 161 | if err := os.MkdirAll(outputDir, 0755); err != nil { 162 | return "", fmt.Errorf("error creating output directory: %w", err) 163 | } 164 | 165 | outputFilename := filepath.Join(outputDir, baseFilename) 166 | 167 | pdf := gofpdf.New("L", "mm", "Letter", "") 168 | pdf.AddPage() 169 | pdf.SetFont("Arial", "B", 16) 170 | pdf.Cell(40, 10, "AWS FinOps Dashboard (Cost Report)") 171 | pdf.Ln(15) 172 | 173 | // Define a tabela 174 | headers := []string{ 175 | "CLI Profile", 176 | "AWS Account ID", 177 | fmt.Sprintf("Cost for period\n(%s)", previousPeriodDates), 178 | fmt.Sprintf("Cost for period\n(%s)", currentPeriodDates), 179 | "Cost By Service", 180 | "Budget Status", 181 | "EC2 Instances", 182 | } 183 | 184 | // Calcula a largura de cada coluna 185 | tableWidth := 260.0 // ajuste conforme necessário 186 | colWidth := tableWidth / float64(len(headers)) 187 | 188 | // Cabeçalho 189 | pdf.SetFont("Arial", "B", 10) 190 | pdf.SetFillColor(0, 0, 0) 191 | pdf.SetTextColor(255, 255, 255) 192 | for _, header := range headers { 193 | pdf.CellFormat(colWidth, 10, header, "1", 0, "C", true, 0, "") 194 | } 195 | pdf.Ln(-1) 196 | 197 | // Dados 198 | pdf.SetFont("Arial", "", 8) 199 | pdf.SetTextColor(0, 0, 0) 200 | fill := false 201 | for _, row := range data { 202 | pdf.SetFillColor(240, 240, 240) 203 | fill = !fill 204 | 205 | // Perfil 206 | pdf.CellFormat(colWidth, 10, row.Profile, "1", 0, "L", fill, 0, "") 207 | 208 | // ID da conta 209 | pdf.CellFormat(colWidth, 10, row.AccountID, "1", 0, "L", fill, 0, "") 210 | 211 | // Custo mês anterior 212 | pdf.CellFormat(colWidth, 10, fmt.Sprintf("$%.2f", row.LastMonth), "1", 0, "R", fill, 0, "") 213 | 214 | // Custo mês atual 215 | pdf.CellFormat(colWidth, 10, fmt.Sprintf("$%.2f", row.CurrentMonth), "1", 0, "R", fill, 0, "") 216 | 217 | // Custos por serviço 218 | serviceCosts := "" 219 | for _, sc := range row.ServiceCosts { 220 | serviceCosts += fmt.Sprintf("%s: $%.2f\n", sc.ServiceName, sc.Cost) 221 | } 222 | pdf.CellFormat(colWidth, 10, serviceCosts, "1", 0, "L", fill, 0, "") 223 | 224 | // Status do orçamento 225 | budgetInfo := strings.Join(row.BudgetInfo, "\n") 226 | pdf.CellFormat(colWidth, 10, budgetInfo, "1", 0, "L", fill, 0, "") 227 | 228 | // Resumo EC2 229 | ec2Summary := "" 230 | for state, count := range row.EC2Summary { 231 | if count > 0 { 232 | ec2Summary += fmt.Sprintf("%s: %d\n", state, count) 233 | } 234 | } 235 | pdf.CellFormat(colWidth, 10, ec2Summary, "1", 0, "L", fill, 0, "") 236 | 237 | pdf.Ln(-1) 238 | } 239 | 240 | // Adiciona rodapé com timestamp 241 | currentTime := time.Now().Format("2006-01-02 15:04:05") 242 | footerText := fmt.Sprintf("This report is generated using AWS FinOps Dashboard (CLI) © 2023 on %s", currentTime) 243 | pdf.SetY(-15) 244 | pdf.SetFont("Arial", "I", 8) 245 | pdf.Cell(0, 10, footerText) 246 | 247 | // Salva o PDF 248 | err := pdf.OutputFileAndClose(outputFilename) 249 | if err != nil { 250 | return "", fmt.Errorf("error writing PDF file: %w", err) 251 | } 252 | 253 | absPath, err := filepath.Abs(outputFilename) 254 | if err != nil { 255 | return outputFilename, nil 256 | } 257 | return absPath, nil 258 | } 259 | 260 | // ExportAuditReportToPDF exporta um relatório de auditoria para um arquivo PDF. 261 | func (r *ExportRepositoryImpl) ExportAuditReportToPDF( 262 | auditData []entity.AuditData, 263 | filename string, 264 | outputDir string, 265 | ) (string, error) { 266 | timestamp := time.Now().Format("20060102_1504") 267 | baseFilename := fmt.Sprintf("%s_%s.pdf", filename, timestamp) 268 | 269 | // Garantir que o diretório de saída existe 270 | if err := os.MkdirAll(outputDir, 0755); err != nil { 271 | return "", fmt.Errorf("error creating output directory: %w", err) 272 | } 273 | 274 | outputFilename := filepath.Join(outputDir, baseFilename) 275 | 276 | pdf := gofpdf.New("L", "mm", "Letter", "") 277 | pdf.AddPage() 278 | pdf.SetFont("Arial", "B", 16) 279 | pdf.Cell(40, 10, "AWS FinOps Dashboard (Audit Report)") 280 | pdf.Ln(15) 281 | 282 | // Define a tabela 283 | headers := []string{ 284 | "Profile", 285 | "Account ID", 286 | "Untagged Resources", 287 | "Stopped EC2 Instances", 288 | "Unused Volumes", 289 | "Unused EIPs", 290 | "Budget Alerts", 291 | } 292 | 293 | // Calcula a largura de cada coluna 294 | tableWidth := 260.0 // ajuste conforme necessário 295 | colWidth := tableWidth / float64(len(headers)) 296 | 297 | // Cabeçalho 298 | pdf.SetFont("Arial", "B", 10) 299 | pdf.SetFillColor(0, 0, 0) 300 | pdf.SetTextColor(255, 255, 255) 301 | for _, header := range headers { 302 | pdf.CellFormat(colWidth, 10, header, "1", 0, "C", true, 0, "") 303 | } 304 | pdf.Ln(-1) 305 | 306 | // Dados 307 | pdf.SetFont("Arial", "", 8) 308 | pdf.SetTextColor(0, 0, 0) 309 | fill := false 310 | for _, row := range auditData { 311 | pdf.SetFillColor(240, 240, 240) 312 | fill = !fill 313 | 314 | // Remove as tags Rich da versão Python 315 | untaggedResources := cleanRichTags(row.UntaggedResources) 316 | stoppedInstances := cleanRichTags(row.StoppedInstances) 317 | unusedVolumes := cleanRichTags(row.UnusedVolumes) 318 | unusedEIPs := cleanRichTags(row.UnusedEIPs) 319 | budgetAlerts := cleanRichTags(row.BudgetAlerts) 320 | 321 | pdf.CellFormat(colWidth, 10, row.Profile, "1", 0, "L", fill, 0, "") 322 | pdf.CellFormat(colWidth, 10, row.AccountID, "1", 0, "L", fill, 0, "") 323 | pdf.CellFormat(colWidth, 10, untaggedResources, "1", 0, "L", fill, 0, "") 324 | pdf.CellFormat(colWidth, 10, stoppedInstances, "1", 0, "L", fill, 0, "") 325 | pdf.CellFormat(colWidth, 10, unusedVolumes, "1", 0, "L", fill, 0, "") 326 | pdf.CellFormat(colWidth, 10, unusedEIPs, "1", 0, "L", fill, 0, "") 327 | pdf.CellFormat(colWidth, 10, budgetAlerts, "1", 0, "L", fill, 0, "") 328 | 329 | pdf.Ln(-1) 330 | } 331 | 332 | // Adiciona rodapé com informações 333 | pdf.Ln(10) 334 | pdf.SetFont("Arial", "I", 8) 335 | pdf.Cell(0, 10, "Note: This table lists untagged EC2, RDS, Lambda, ELBv2 only.") 336 | 337 | // Adiciona rodapé com timestamp 338 | currentTime := time.Now().Format("2006-01-02 15:04:05") 339 | footerText := fmt.Sprintf("This audit report is generated using AWS FinOps Dashboard (CLI) © 2023 on %s", currentTime) 340 | pdf.SetY(-15) 341 | pdf.SetFont("Arial", "I", 8) 342 | pdf.Cell(0, 10, footerText) 343 | 344 | // Salva o PDF 345 | err := pdf.OutputFileAndClose(outputFilename) 346 | if err != nil { 347 | return "", fmt.Errorf("error writing PDF file: %w", err) 348 | } 349 | 350 | absPath, err := filepath.Abs(outputFilename) 351 | if err != nil { 352 | return outputFilename, nil 353 | } 354 | return absPath, nil 355 | } 356 | 357 | // ExportAuditReportToCSV exporta um relatório de auditoria para um arquivo CSV. 358 | func (r *ExportRepositoryImpl) ExportAuditReportToCSV( 359 | auditData []entity.AuditData, 360 | filename string, 361 | outputDir string, 362 | ) (string, error) { 363 | timestamp := time.Now().Format("20060102_1504") 364 | baseFilename := fmt.Sprintf("%s_%s.csv", filename, timestamp) 365 | 366 | // Garantir que o diretório de saída existe 367 | if err := os.MkdirAll(outputDir, 0755); err != nil { 368 | return "", fmt.Errorf("error creating output directory: %w", err) 369 | } 370 | 371 | outputFilename := filepath.Join(outputDir, baseFilename) 372 | 373 | file, err := os.Create(outputFilename) 374 | if err != nil { 375 | return "", fmt.Errorf("error creating CSV file: %w", err) 376 | } 377 | defer file.Close() 378 | 379 | writer := csv.NewWriter(file) 380 | defer writer.Flush() 381 | 382 | // Escreve o cabeçalho 383 | headers := []string{ 384 | "Profile", 385 | "Account ID", 386 | "Untagged Resources", 387 | "Stopped EC2 Instances", 388 | "Unused Volumes", 389 | "Unused EIPs", 390 | "Budget Alerts", 391 | } 392 | if err := writer.Write(headers); err != nil { 393 | return "", fmt.Errorf("error writing CSV header: %w", err) 394 | } 395 | 396 | // Escreve os dados 397 | for _, row := range auditData { 398 | // Remove as tags Rich para uma melhor visualização no CSV 399 | untaggedResources := cleanRichTags(row.UntaggedResources) 400 | stoppedInstances := cleanRichTags(row.StoppedInstances) 401 | unusedVolumes := cleanRichTags(row.UnusedVolumes) 402 | unusedEIPs := cleanRichTags(row.UnusedEIPs) 403 | budgetAlerts := cleanRichTags(row.BudgetAlerts) 404 | 405 | record := []string{ 406 | row.Profile, 407 | row.AccountID, 408 | untaggedResources, 409 | stoppedInstances, 410 | unusedVolumes, 411 | unusedEIPs, 412 | budgetAlerts, 413 | } 414 | 415 | if err := writer.Write(record); err != nil { 416 | return "", fmt.Errorf("error writing CSV row: %w", err) 417 | } 418 | } 419 | 420 | absPath, err := filepath.Abs(outputFilename) 421 | if err != nil { 422 | return outputFilename, nil 423 | } 424 | return absPath, nil 425 | } 426 | 427 | // ExportAuditReportToJSON exporta um relatório de auditoria para um arquivo JSON. 428 | func (r *ExportRepositoryImpl) ExportAuditReportToJSON( 429 | auditData []entity.AuditData, 430 | filename string, 431 | outputDir string, 432 | ) (string, error) { 433 | timestamp := time.Now().Format("20060102_1504") 434 | baseFilename := fmt.Sprintf("%s_%s.json", filename, timestamp) 435 | 436 | // Garantir que o diretório de saída existe 437 | if err := os.MkdirAll(outputDir, 0755); err != nil { 438 | return "", fmt.Errorf("error creating output directory: %w", err) 439 | } 440 | 441 | outputFilename := filepath.Join(outputDir, baseFilename) 442 | 443 | file, err := os.Create(outputFilename) 444 | if err != nil { 445 | return "", fmt.Errorf("error creating JSON file: %w", err) 446 | } 447 | defer file.Close() 448 | 449 | // Cria cópias limpas para JSON 450 | cleanData := make([]entity.AuditData, len(auditData)) 451 | for i, row := range auditData { 452 | cleanData[i] = entity.AuditData{ 453 | Profile: row.Profile, 454 | AccountID: row.AccountID, 455 | UntaggedResources: cleanRichTags(row.UntaggedResources), 456 | StoppedInstances: cleanRichTags(row.StoppedInstances), 457 | UnusedVolumes: cleanRichTags(row.UnusedVolumes), 458 | UnusedEIPs: cleanRichTags(row.UnusedEIPs), 459 | BudgetAlerts: cleanRichTags(row.BudgetAlerts), 460 | } 461 | } 462 | 463 | encoder := json.NewEncoder(file) 464 | encoder.SetIndent("", " ") 465 | if err := encoder.Encode(cleanData); err != nil { 466 | return "", fmt.Errorf("error encoding JSON data: %w", err) 467 | } 468 | 469 | absPath, err := filepath.Abs(outputFilename) 470 | if err != nil { 471 | return outputFilename, nil 472 | } 473 | return absPath, nil 474 | } 475 | 476 | // cleanRichTags remove as tags de estilo Rich texto para exportação. 477 | func cleanRichTags(text string) string { 478 | // Regex para remover padrões como [bold red], [/], etc. 479 | re := regexp.MustCompile(`\[\/?(bold|bright_red|bright_green|bright_yellow|bright_cyan|bright_magenta|dark_magenta|dark_orange|gold1|orange1|red1|yellow|)\]`) 480 | return re.ReplaceAllString(text, "") 481 | } 482 | -------------------------------------------------------------------------------- /internal/adapter/driving/cli/app.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "github.com/diillson/aws-finops-dashboard-go/pkg/version" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/diillson/aws-finops-dashboard-go/internal/application/usecase" 10 | "github.com/diillson/aws-finops-dashboard-go/internal/shared/types" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // CLIApp represents the command-line interface application. 15 | type CLIApp struct { 16 | rootCmd *cobra.Command 17 | dashboardUseCase *usecase.DashboardUseCase 18 | version string 19 | } 20 | 21 | // NewCLIApp cria uma nova aplicação CLI. 22 | func NewCLIApp(versionStr string) *CLIApp { 23 | app := &CLIApp{ 24 | version: versionStr, 25 | } 26 | 27 | // Obtem a versão formatada 28 | formattedVersion := version.FormatVersion() 29 | 30 | rootCmd := &cobra.Command{ 31 | Use: "aws-finops", 32 | Short: "AWS FinOps Dashboard CLI", 33 | Version: formattedVersion, // Use a versão formatada 34 | RunE: app.runCommand, 35 | } 36 | 37 | // Personaliza a template para incluir mais informações de versão 38 | rootCmd.SetVersionTemplate(`{{printf "AWS FinOps Dashboard version: %s\n" .Version}}`) 39 | 40 | // Adiciona flags de linha de comando 41 | rootCmd.PersistentFlags().StringP("config-file", "C", "", "Path to a TOML, YAML, or JSON configuration file") 42 | rootCmd.PersistentFlags().StringSliceP("profiles", "p", nil, "Specific AWS profiles to use (comma-separated)") 43 | rootCmd.PersistentFlags().StringSliceP("regions", "r", nil, "AWS regions to check for EC2 instances (comma-separated)") 44 | rootCmd.PersistentFlags().BoolP("all", "a", false, "Use all available AWS profiles") 45 | rootCmd.PersistentFlags().BoolP("combine", "c", false, "Combine profiles from the same AWS account") 46 | rootCmd.PersistentFlags().StringP("report-name", "n", "", "Specify the base name for the report file (without extension)") 47 | rootCmd.PersistentFlags().StringSliceP("report-type", "y", []string{"csv"}, "Specify report types: csv, json, pdf") 48 | rootCmd.PersistentFlags().StringP("dir", "d", "", "Directory to save the report files (default: current directory)") 49 | rootCmd.PersistentFlags().IntP("time-range", "t", 0, "Time range for cost data in days (default: current month)") 50 | rootCmd.PersistentFlags().StringSliceP("tag", "g", nil, "Cost allocation tag to filter resources, e.g., --tag Team=DevOps") 51 | rootCmd.PersistentFlags().Bool("trend", false, "Display a trend report as bars for the past 6 months time range") 52 | rootCmd.PersistentFlags().Bool("audit", false, "Display an audit report with cost anomalies, stopped EC2 instances, unused EBS volumes, budget alerts, and more") 53 | 54 | app.rootCmd = rootCmd 55 | return app 56 | } 57 | 58 | // Execute runs the CLI application. 59 | func (app *CLIApp) Execute() error { 60 | return app.rootCmd.Execute() 61 | } 62 | 63 | // parseArgs parses command-line arguments into a CLIArgs struct. 64 | func (app *CLIApp) parseArgs() (*types.CLIArgs, error) { 65 | configFile, _ := app.rootCmd.Flags().GetString("config-file") 66 | profiles, _ := app.rootCmd.Flags().GetStringSlice("profiles") 67 | regions, _ := app.rootCmd.Flags().GetStringSlice("regions") 68 | all, _ := app.rootCmd.Flags().GetBool("all") 69 | combine, _ := app.rootCmd.Flags().GetBool("combine") 70 | reportName, _ := app.rootCmd.Flags().GetString("report-name") 71 | reportType, _ := app.rootCmd.Flags().GetStringSlice("report-type") 72 | dir, _ := app.rootCmd.Flags().GetString("dir") 73 | timeRange, _ := app.rootCmd.Flags().GetInt("time-range") 74 | tag, _ := app.rootCmd.Flags().GetStringSlice("tag") 75 | trend, _ := app.rootCmd.Flags().GetBool("trend") 76 | audit, _ := app.rootCmd.Flags().GetBool("audit") 77 | 78 | // Set default directory to current working directory if not specified 79 | if dir == "" { 80 | cwd, err := os.Getwd() 81 | if err != nil { 82 | return nil, err 83 | } 84 | dir = cwd 85 | } else { 86 | // Convert to absolute path 87 | absDir, err := filepath.Abs(dir) 88 | if err != nil { 89 | return nil, err 90 | } 91 | dir = absDir 92 | } 93 | 94 | timeRangePtr := &timeRange 95 | if timeRange == 0 { 96 | timeRangePtr = nil 97 | } 98 | 99 | args := &types.CLIArgs{ 100 | ConfigFile: configFile, 101 | Profiles: profiles, 102 | Regions: regions, 103 | All: all, 104 | Combine: combine, 105 | ReportName: reportName, 106 | ReportType: reportType, 107 | Dir: dir, 108 | TimeRange: timeRangePtr, 109 | Tag: tag, 110 | Trend: trend, 111 | Audit: audit, 112 | } 113 | 114 | return args, nil 115 | } 116 | 117 | // runCommand é o ponto de entrada principal para o comando CLI. 118 | func (app *CLIApp) runCommand(cmd *cobra.Command, args []string) error { 119 | // Exibe o banner de boas-vindas 120 | displayWelcomeBanner(app.version) 121 | 122 | // Verifica a versão mais recente disponível 123 | go version.CheckLatestVersion(app.version) 124 | 125 | // Analisa os argumentos da linha de comando 126 | cliArgs, err := app.parseArgs() 127 | if err != nil { 128 | return err 129 | } 130 | 131 | // Lida com o arquivo de configuração, se especificado 132 | if cliArgs.ConfigFile != "" { 133 | // Carrega e mescla a configuração 134 | // Isso será implementado pelo repositório de configuração 135 | } 136 | 137 | // Executa o dashboard 138 | ctx := context.Background() 139 | return app.dashboardUseCase.RunDashboard(ctx, cliArgs) 140 | } 141 | 142 | // SetDashboardUseCase sets the dashboard use case for the CLI app. 143 | func (app *CLIApp) SetDashboardUseCase(useCase *usecase.DashboardUseCase) { 144 | app.dashboardUseCase = useCase 145 | } 146 | -------------------------------------------------------------------------------- /internal/adapter/driving/cli/banner.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diillson/aws-finops-dashboard-go/pkg/version" 7 | "github.com/fatih/color" 8 | ) 9 | 10 | // displayWelcomeBanner exibe o banner de boas-vindas com informações de versão. 11 | func displayWelcomeBanner(versionStr string) { 12 | banner := ` 13 | /$$$$$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$ /$$$$$$ 14 | /$$__ $$| $$ /$ | $$ /$$__ $$ | $$_____/|__/ /$$__ $$ 15 | | $$ \ $$| $$ /$$$| $$| $$ \__/ | $$ /$$ /$$$$$$$ | $$ \ $$ /$$$$$$ /$$$$$$$ 16 | | $$$$$$$$| $$/$$ $$ $$| $$$$$$ | $$$$$ | $$| $$__ $$| $$ | $$ /$$__ $$ /$$_____/ 17 | | $$__ $$| $$$$_ $$$$ \____ $$ | $$__/ | $$| $$ \ $$| $$ | $$| $$ \ $$| $$$$$$ 18 | | $$ | $$| $$$/ \ $$$ /$$ \ $$ | $$ | $$| $$ | $$| $$ | $$| $$ | $$ \____ $$ 19 | | $$ | $$| $$/ \ $$| $$$$$$/ | $$ | $$| $$ | $$| $$$$$$/| $$$$$$$/ /$$$$$$$/ 20 | |__/ |__/|__/ \__/ \______/ |__/ |__/|__/ |__/ \______/ | $$____/ |_______/ 21 | | $$ 22 | | $$ 23 | |__/ 24 | ` 25 | red := color.New(color.FgRed, color.Bold).SprintFunc() 26 | blue := color.New(color.FgBlue, color.Bold).SprintFunc() 27 | 28 | fmt.Println(red(banner)) 29 | 30 | // Obtem a string formatada da versão através do pacote version 31 | formattedVersion := version.FormatVersion() 32 | fmt.Println(blue(fmt.Sprintf("AWS FinOps Dashboard CLI (v%s)", formattedVersion))) 33 | } 34 | 35 | // checkLatestVersion verifica se uma versão mais recente está disponível. 36 | func checkLatestVersion(currentVersion string) { 37 | // Usa a função do pacote version para verificar por atualizações 38 | version.CheckLatestVersion(currentVersion) 39 | } 40 | -------------------------------------------------------------------------------- /internal/application/usecase/dashboard_usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/pterm/pterm" 7 | "math" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/entity" 12 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/repository" 13 | "github.com/diillson/aws-finops-dashboard-go/internal/shared/types" 14 | ) 15 | 16 | // DashboardUseCase handles the main dashboard functionality. 17 | type DashboardUseCase struct { 18 | awsRepo repository.AWSRepository 19 | exportRepo repository.ExportRepository 20 | configRepo repository.ConfigRepository 21 | console types.ConsoleInterface 22 | } 23 | 24 | // NewDashboardUseCase creates a new dashboard use case. 25 | func NewDashboardUseCase( 26 | awsRepo repository.AWSRepository, 27 | exportRepo repository.ExportRepository, 28 | configRepo repository.ConfigRepository, 29 | console types.ConsoleInterface, 30 | ) *DashboardUseCase { 31 | return &DashboardUseCase{ 32 | awsRepo: awsRepo, 33 | exportRepo: exportRepo, 34 | configRepo: configRepo, 35 | console: console, 36 | } 37 | } 38 | 39 | // InitializeProfiles determines which AWS profiles to use based on CLI args. 40 | func (uc *DashboardUseCase) InitializeProfiles(args *types.CLIArgs) ([]string, []string, int, error) { 41 | availableProfiles := uc.awsRepo.GetAWSProfiles() 42 | if len(availableProfiles) == 0 { 43 | return nil, nil, 0, types.ErrNoProfilesFound 44 | } 45 | 46 | profilesToUse := []string{} 47 | 48 | if len(args.Profiles) > 0 { 49 | for _, profile := range args.Profiles { 50 | found := false 51 | for _, availProfile := range availableProfiles { 52 | if profile == availProfile { 53 | profilesToUse = append(profilesToUse, profile) 54 | found = true 55 | break 56 | } 57 | } 58 | if !found { 59 | uc.console.LogWarning("Profile '%s' not found in AWS configuration", profile) 60 | } 61 | } 62 | if len(profilesToUse) == 0 { 63 | return nil, nil, 0, types.ErrNoValidProfilesFound 64 | } 65 | } else if args.All { 66 | profilesToUse = availableProfiles 67 | } else { 68 | // Check if default profile exists 69 | defaultExists := false 70 | for _, profile := range availableProfiles { 71 | if profile == "default" { 72 | profilesToUse = []string{"default"} 73 | defaultExists = true 74 | break 75 | } 76 | } 77 | 78 | if !defaultExists { 79 | profilesToUse = availableProfiles 80 | uc.console.LogWarning("No default profile found. Using all available profiles.") 81 | } 82 | } 83 | 84 | var timeRange int 85 | if args.TimeRange != nil { 86 | timeRange = *args.TimeRange 87 | } 88 | 89 | return profilesToUse, args.Regions, timeRange, nil 90 | } 91 | 92 | // ProcessSingleProfile implementa o processamento de um único perfil AWS. 93 | func (uc *DashboardUseCase) ProcessSingleProfile( 94 | ctx context.Context, 95 | profile string, 96 | userRegions []string, 97 | timeRange int, 98 | tags []string, 99 | ) entity.ProfileData { 100 | var profileData entity.ProfileData 101 | profileData.Profile = profile 102 | profileData.Success = false 103 | 104 | // Obtém dados de custo 105 | costData, err := uc.awsRepo.GetCostData(ctx, profile, &timeRange, tags) 106 | if err != nil { 107 | profileData.Error = err.Error() 108 | return profileData 109 | } 110 | 111 | // Define regiões a serem usadas 112 | regions := userRegions 113 | if len(regions) == 0 { 114 | regions, err = uc.awsRepo.GetAccessibleRegions(ctx, profile) 115 | if err != nil { 116 | profileData.Error = err.Error() 117 | return profileData 118 | } 119 | } 120 | 121 | // Obtém resumo das instâncias EC2 122 | ec2Summary, err := uc.awsRepo.GetEC2Summary(ctx, profile, regions) 123 | if err != nil { 124 | profileData.Error = err.Error() 125 | return profileData 126 | } 127 | 128 | // Processa custos por serviço 129 | serviceCosts, serviceCostsFormatted := uc.processServiceCosts(costData) 130 | 131 | // Formata informações do orçamento 132 | budgetInfo := uc.formatBudgetInfo(costData.Budgets) 133 | 134 | // Formata resumo do EC2 135 | ec2SummaryFormatted := uc.formatEC2Summary(ec2Summary) 136 | 137 | // Calcula alteração percentual no custo total 138 | var percentChange *float64 139 | if costData.LastMonthCost > 0.01 { 140 | change := ((costData.CurrentMonthCost - costData.LastMonthCost) / costData.LastMonthCost) * 100.0 141 | percentChange = &change 142 | } else if costData.CurrentMonthCost < 0.01 { 143 | change := 0.0 144 | percentChange = &change 145 | } 146 | 147 | // Preenche o dado do perfil 148 | profileData = entity.ProfileData{ 149 | Profile: profile, 150 | AccountID: costData.AccountID, 151 | LastMonth: costData.LastMonthCost, 152 | CurrentMonth: costData.CurrentMonthCost, 153 | ServiceCosts: serviceCosts, 154 | ServiceCostsFormatted: serviceCostsFormatted, 155 | BudgetInfo: budgetInfo, 156 | EC2Summary: ec2Summary, 157 | EC2SummaryFormatted: ec2SummaryFormatted, 158 | Success: true, 159 | CurrentPeriodName: costData.CurrentPeriodName, 160 | PreviousPeriodName: costData.PreviousPeriodName, 161 | PercentChangeInCost: percentChange, 162 | } 163 | 164 | return profileData 165 | } 166 | 167 | // RunDashboard executa a funcionalidade principal do dashboard. 168 | func (uc *DashboardUseCase) RunDashboard( 169 | ctx context.Context, 170 | args *types.CLIArgs, 171 | ) error { 172 | // Inicializa os perfis com base nos argumentos da CLI 173 | profilesToUse, userRegions, timeRange, err := uc.InitializeProfiles(args) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | // Executa relatório de auditoria se solicitado 179 | if args.Audit { 180 | auditData, err := uc.RunAuditReport(ctx, profilesToUse, args) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | // Exporta o relatório de auditoria se um nome de relatório for fornecido 186 | if args.ReportName != "" { 187 | for _, reportType := range args.ReportType { 188 | switch reportType { 189 | case "pdf": 190 | pdfPath, err := uc.exportRepo.ExportAuditReportToPDF(auditData, args.ReportName, args.Dir) 191 | if err != nil { 192 | uc.console.LogError("Failed to export audit report to PDF: %s", err) 193 | } else { 194 | uc.console.LogSuccess("Successfully exported audit report to PDF: %s", pdfPath) 195 | } 196 | case "csv": 197 | csvPath, err := uc.exportRepo.ExportAuditReportToCSV(auditData, args.ReportName, args.Dir) 198 | if err != nil { 199 | uc.console.LogError("Failed to export audit report to CSV: %s", err) 200 | } else { 201 | uc.console.LogSuccess("Successfully exported audit report to CSV: %s", csvPath) 202 | } 203 | case "json": 204 | jsonPath, err := uc.exportRepo.ExportAuditReportToJSON(auditData, args.ReportName, args.Dir) 205 | if err != nil { 206 | uc.console.LogError("Failed to export audit report to JSON: %s", err) 207 | } else { 208 | uc.console.LogSuccess("Successfully exported audit report to JSON: %s", jsonPath) 209 | } 210 | } 211 | } 212 | } 213 | return nil 214 | } 215 | 216 | // Executa análise de tendência se solicitada 217 | if args.Trend { 218 | err := uc.RunTrendAnalysis(ctx, profilesToUse, args) 219 | if err != nil { 220 | return err 221 | } 222 | return nil 223 | } 224 | 225 | // Inicializa o dashboard principal 226 | status := uc.console.Status("Initializing dashboard...") 227 | 228 | // Obtém informações do período para a tabela de exibição 229 | previousPeriodName, currentPeriodName, previousPeriodDates, currentPeriodDates := 230 | uc.getDisplayTablePeriodInfo(ctx, profilesToUse, timeRange) 231 | 232 | // Cria a tabela de exibição 233 | table := uc.createDisplayTable(previousPeriodDates, currentPeriodDates, previousPeriodName, currentPeriodName) 234 | 235 | //status.Stop() 236 | 237 | // Gera os dados do dashboard 238 | exportData, err := uc.generateDashboardData(ctx, profilesToUse, userRegions, timeRange, args, table, status) 239 | if err != nil { 240 | status.Stop() 241 | return err 242 | } 243 | 244 | status.Stop() 245 | 246 | // Exibe a tabela 247 | uc.console.Print(table.Render()) 248 | 249 | // Exporta os relatórios do dashboard 250 | if args.ReportName != "" && len(args.ReportType) > 0 { 251 | for _, reportType := range args.ReportType { 252 | switch reportType { 253 | case "csv": 254 | csvPath, err := uc.exportRepo.ExportToCSV(exportData, args.ReportName, args.Dir, previousPeriodDates, currentPeriodDates) 255 | if err != nil { 256 | uc.console.LogError("Failed to export to CSV: %s", err) 257 | } else { 258 | uc.console.LogSuccess("Successfully exported to CSV: %s", csvPath) 259 | } 260 | case "json": 261 | jsonPath, err := uc.exportRepo.ExportToJSON(exportData, args.ReportName, args.Dir) 262 | if err != nil { 263 | uc.console.LogError("Failed to export to JSON: %s", err) 264 | } else { 265 | uc.console.LogSuccess("Successfully exported to JSON: %s", jsonPath) 266 | } 267 | case "pdf": 268 | pdfPath, err := uc.exportRepo.ExportToPDF(exportData, args.ReportName, args.Dir, previousPeriodDates, currentPeriodDates) 269 | if err != nil { 270 | uc.console.LogError("Failed to export to PDF: %s", err) 271 | } else { 272 | uc.console.LogSuccess("\nSuccessfully exported to PDF: %s", pdfPath) 273 | } 274 | } 275 | } 276 | } 277 | 278 | return nil 279 | } 280 | 281 | // RunAuditReport executa um relatório de auditoria para os perfis especificados. 282 | func (uc *DashboardUseCase) RunAuditReport( 283 | ctx context.Context, 284 | profilesToUse []string, 285 | args *types.CLIArgs, 286 | ) ([]entity.AuditData, error) { 287 | uc.console.LogInfo("Preparing your audit report...") 288 | 289 | table := uc.console.CreateTable() 290 | table.AddColumn("Profile") 291 | table.AddColumn("Account ID") 292 | table.AddColumn("Untagged Resources") 293 | table.AddColumn("Stopped EC2 Instances") 294 | table.AddColumn("Unused Volumes") 295 | table.AddColumn("Unused EIPs") 296 | table.AddColumn("Budget Alerts") 297 | 298 | auditDataList := []entity.AuditData{} 299 | nl := "\n" 300 | 301 | for _, profile := range profilesToUse { 302 | _, err := uc.awsRepo.GetSession(ctx, profile) 303 | if err != nil { 304 | uc.console.LogError("Failed to create session for profile %s: %s", profile, err) 305 | continue 306 | } 307 | 308 | accountID, err := uc.awsRepo.GetAccountID(ctx, profile) 309 | if err != nil { 310 | accountID = "Unknown" 311 | } 312 | 313 | regions := args.Regions 314 | if len(regions) == 0 { 315 | regions, err = uc.awsRepo.GetAccessibleRegions(ctx, profile) 316 | if err != nil { 317 | uc.console.LogWarning("Could not get accessible regions for profile %s: %s", profile, err) 318 | regions = []string{"us-east-1", "us-west-2", "eu-west-1"} // defaults 319 | } 320 | } 321 | 322 | // Obtém recursos não marcados 323 | untagged, err := uc.awsRepo.GetUntaggedResources(ctx, profile, regions) 324 | var anomalies []string 325 | if err != nil { 326 | anomalies = []string{fmt.Sprintf("Error: %s", err)} 327 | } else { 328 | for service, regionMap := range untagged { 329 | if len(regionMap) > 0 { 330 | serviceBlock := fmt.Sprintf("%s:\n", pterm.FgYellow.Sprint(service)) 331 | for region, ids := range regionMap { 332 | if len(ids) > 0 { 333 | idsBlock := strings.Join(ids, "\n") 334 | serviceBlock += fmt.Sprintf("\n%s:\n%s\n", region, idsBlock) 335 | } 336 | } 337 | anomalies = append(anomalies, serviceBlock) 338 | } 339 | } 340 | if len(anomalies) == 0 { 341 | anomalies = []string{"None"} 342 | } 343 | } 344 | 345 | // Obtém instâncias EC2 paradas 346 | stopped, err := uc.awsRepo.GetStoppedInstances(ctx, profile, regions) 347 | stoppedList := []string{} 348 | if err != nil { 349 | stoppedList = []string{fmt.Sprintf("Error: %s", err)} 350 | } else { 351 | for region, ids := range stopped { 352 | if len(ids) > 0 { 353 | stoppedList = append(stoppedList, fmt.Sprintf("%s:\n%s", region, 354 | pterm.NewStyle(pterm.FgYellow).Sprint(strings.Join(ids, nl)))) 355 | } 356 | } 357 | if len(stoppedList) == 0 { 358 | stoppedList = []string{"None"} 359 | } 360 | } 361 | 362 | // Obtém volumes não utilizados 363 | unusedVols, err := uc.awsRepo.GetUnusedVolumes(ctx, profile, regions) 364 | volsList := []string{} 365 | if err != nil { 366 | volsList = []string{fmt.Sprintf("Error: %s", err)} 367 | } else { 368 | for region, ids := range unusedVols { 369 | if len(ids) > 0 { 370 | volsList = append(volsList, fmt.Sprintf("%s:\n%s", region, 371 | pterm.NewStyle(pterm.FgLightRed).Sprint(strings.Join(ids, nl)))) 372 | } 373 | } 374 | if len(volsList) == 0 { 375 | volsList = []string{"None"} 376 | } 377 | } 378 | 379 | // Obtém EIPs não utilizados 380 | unusedEIPs, err := uc.awsRepo.GetUnusedEIPs(ctx, profile, regions) 381 | eipsList := []string{} 382 | if err != nil { 383 | eipsList = []string{fmt.Sprintf("Error: %s", err)} 384 | } else { 385 | for region, ids := range unusedEIPs { 386 | if len(ids) > 0 { 387 | eipsList = append(eipsList, fmt.Sprintf("%s:\n%s", region, strings.Join(ids, ",\n"))) 388 | } 389 | } 390 | if len(eipsList) == 0 { 391 | eipsList = []string{"None"} 392 | } 393 | } 394 | 395 | // Obtém alertas de orçamento 396 | budgetData, err := uc.awsRepo.GetBudgets(ctx, profile) 397 | alerts := []string{} 398 | if err != nil { 399 | alerts = []string{fmt.Sprintf("Error: %s", err)} 400 | } else { 401 | for _, b := range budgetData { 402 | if b.Actual > b.Limit { 403 | alerts = append(alerts, 404 | fmt.Sprintf("%s: $%.2f > $%.2f", pterm.FgRed.Sprint(b.Name), b.Actual, b.Limit)) 405 | } 406 | } 407 | if len(alerts) == 0 { 408 | alerts = []string{"No budgets exceeded"} 409 | } 410 | } 411 | 412 | auditData := entity.AuditData{ 413 | Profile: profile, 414 | AccountID: accountID, 415 | UntaggedResources: strings.Join(anomalies, "\n"), 416 | StoppedInstances: strings.Join(stoppedList, "\n"), 417 | UnusedVolumes: strings.Join(volsList, "\n"), 418 | UnusedEIPs: strings.Join(eipsList, "\n"), 419 | BudgetAlerts: strings.Join(alerts, "\n"), 420 | } 421 | auditDataList = append(auditDataList, auditData) 422 | 423 | table.AddRow( 424 | pterm.FgMagenta.Sprintf("%s", profile), 425 | accountID, 426 | strings.Join(anomalies, "\n"), 427 | strings.Join(stoppedList, "\n"), 428 | strings.Join(volsList, "\n"), 429 | strings.Join(eipsList, "\n"), 430 | strings.Join(alerts, "\n"), 431 | ) 432 | } 433 | 434 | uc.console.Print(table.Render()) 435 | fmt.Println() 436 | uc.console.LogInfo("Note: The dashboard only lists untagged EC2, RDS, Lambda, ELBv2.\n") 437 | 438 | return auditDataList, nil 439 | } 440 | 441 | // RunTrendAnalysis executa uma análise de tendência de custo para os perfis especificados. 442 | func (uc *DashboardUseCase) RunTrendAnalysis( 443 | ctx context.Context, 444 | profilesToUse []string, 445 | args *types.CLIArgs, 446 | ) error { 447 | uc.console.LogInfo("Analysing cost trends...") 448 | 449 | if args.Combine { 450 | accountProfiles := make(map[string][]string) 451 | 452 | for _, profile := range profilesToUse { 453 | accountID, err := uc.awsRepo.GetAccountID(ctx, profile) 454 | if err != nil { 455 | uc.console.LogError("Error checking account ID for profile %s: %s", profile, err) 456 | continue 457 | } 458 | 459 | accountProfiles[accountID] = append(accountProfiles[accountID], profile) 460 | } 461 | 462 | for accountID, profiles := range accountProfiles { 463 | primaryProfile := profiles[0] 464 | trendData, err := uc.awsRepo.GetTrendData(ctx, primaryProfile, args.Tag) 465 | if err != nil { 466 | uc.console.LogError("Error getting trend for account %s: %s", accountID, err) 467 | continue 468 | } 469 | 470 | monthlyCosts, ok := trendData["monthly_costs"].([]entity.MonthlyCost) 471 | if !ok || len(monthlyCosts) == 0 { 472 | uc.console.LogWarning("No trend data available for account %s", accountID) 473 | continue 474 | } 475 | 476 | // Converte para o tipo correto 477 | uiMonthlyCosts := make([]types.MonthlyCost, len(monthlyCosts)) 478 | for i, mc := range monthlyCosts { 479 | uiMonthlyCosts[i] = types.MonthlyCost{ 480 | Month: mc.Month, 481 | Cost: mc.Cost, 482 | } 483 | } 484 | 485 | profileList := strings.Join(profiles, ", ") 486 | uc.console.Printf("\n%s\n", 487 | pterm.FgYellow.Sprintf("Account: %s (Profiles: %s)", accountID, profileList)) 488 | uc.console.DisplayTrendBars(uiMonthlyCosts) 489 | } 490 | } else { 491 | for _, profile := range profilesToUse { 492 | trendData, err := uc.awsRepo.GetTrendData(ctx, profile, args.Tag) 493 | if err != nil { 494 | uc.console.LogError("Error getting trend for profile %s: %s", profile, err) 495 | continue 496 | } 497 | 498 | monthlyCosts, ok := trendData["monthly_costs"].([]entity.MonthlyCost) 499 | if !ok || len(monthlyCosts) == 0 { 500 | uc.console.LogWarning("No trend data available for profile %s", profile) 501 | continue 502 | } 503 | 504 | accountID, _ := trendData["account_id"].(string) 505 | if accountID == "" { 506 | accountID = "Unknown" 507 | } 508 | 509 | // Converte para o tipo correto 510 | uiMonthlyCosts := make([]types.MonthlyCost, len(monthlyCosts)) 511 | for i, mc := range monthlyCosts { 512 | uiMonthlyCosts[i] = types.MonthlyCost{ 513 | Month: mc.Month, 514 | Cost: mc.Cost, 515 | } 516 | } 517 | 518 | uc.console.Printf("\n%s\n", 519 | pterm.FgYellow.Sprintf("Account: %s (Profile: %s)", accountID, profile)) 520 | uc.console.DisplayTrendBars(uiMonthlyCosts) 521 | } 522 | } 523 | 524 | return nil 525 | } 526 | 527 | // Funções auxiliares para o DashboardUseCase 528 | 529 | // processServiceCosts processa e formata os custos do serviço a partir dos dados de custo. 530 | func (uc *DashboardUseCase) processServiceCosts(costData entity.CostData) ([]entity.ServiceCost, []string) { 531 | serviceCosts := []entity.ServiceCost{} 532 | serviceCostsFormatted := []string{} 533 | 534 | // Considerando que CostData.CurrentMonthCostByService já tem um slice de ServiceCost 535 | for _, serviceCost := range costData.CurrentMonthCostByService { 536 | if serviceCost.Cost > 0.001 { 537 | serviceCosts = append(serviceCosts, serviceCost) 538 | } 539 | } 540 | 541 | // Ordena os serviços por custo (em ordem decrescente) 542 | sort.Slice(serviceCosts, func(i, j int) bool { 543 | return serviceCosts[i].Cost > serviceCosts[j].Cost 544 | }) 545 | 546 | if len(serviceCosts) == 0 { 547 | serviceCostsFormatted = append(serviceCostsFormatted, "No costs associated with this account") 548 | } else { 549 | for _, sc := range serviceCosts { 550 | serviceCostsFormatted = append(serviceCostsFormatted, fmt.Sprintf("%s: $%.2f", sc.ServiceName, sc.Cost)) 551 | } 552 | } 553 | 554 | return serviceCosts, serviceCostsFormatted 555 | } 556 | 557 | // formatBudgetInfo formata as informações do orçamento para exibição. 558 | func (uc *DashboardUseCase) formatBudgetInfo(budgets []entity.BudgetInfo) []string { 559 | budgetInfo := []string{} 560 | 561 | for _, budget := range budgets { 562 | budgetInfo = append(budgetInfo, fmt.Sprintf("%s limit: $%.2f", budget.Name, budget.Limit)) 563 | budgetInfo = append(budgetInfo, fmt.Sprintf("%s actual: $%.2f", budget.Name, budget.Actual)) 564 | if budget.Forecast > 0 { 565 | budgetInfo = append(budgetInfo, fmt.Sprintf("%s forecast: $%.2f", budget.Name, budget.Forecast)) 566 | } 567 | } 568 | 569 | if len(budgetInfo) == 0 { 570 | budgetInfo = append(budgetInfo, "No budgets found;\nCreate a budget for this account") 571 | } 572 | 573 | return budgetInfo 574 | } 575 | 576 | // formatEC2Summary formata o resumo da instância EC2 para exibição. 577 | func (uc *DashboardUseCase) formatEC2Summary(ec2Data entity.EC2Summary) []string { 578 | ec2SummaryText := []string{} 579 | 580 | for state, count := range ec2Data { 581 | if count > 0 { 582 | var stateText string 583 | if state == "running" { 584 | stateText = fmt.Sprintf("%s: %d", pterm.FgGreen.Sprint(state), count) 585 | } else if state == "stopped" { 586 | stateText = fmt.Sprintf("%s: %d", pterm.FgYellow.Sprint(state), count) 587 | } else { 588 | stateText = fmt.Sprintf("%s: %d", pterm.FgCyan.Sprint(state), count) 589 | } 590 | ec2SummaryText = append(ec2SummaryText, stateText) 591 | } 592 | } 593 | 594 | if len(ec2SummaryText) == 0 { 595 | ec2SummaryText = []string{"No instances found"} 596 | } 597 | 598 | return ec2SummaryText 599 | } 600 | 601 | // getDisplayTablePeriodInfo obtém informações do período para a tabela de exibição. 602 | func (uc *DashboardUseCase) getDisplayTablePeriodInfo( 603 | ctx context.Context, 604 | profilesToUse []string, 605 | timeRange int, 606 | ) (string, string, string, string) { 607 | if len(profilesToUse) > 0 { 608 | sampleProfile := profilesToUse[0] 609 | sampleCostData, err := uc.awsRepo.GetCostData(ctx, sampleProfile, &timeRange, nil) 610 | if err == nil { 611 | previousPeriodName := sampleCostData.PreviousPeriodName 612 | currentPeriodName := sampleCostData.CurrentPeriodName 613 | previousPeriodDates := fmt.Sprintf("%s to %s", 614 | sampleCostData.PreviousPeriodStart.Format("2006-01-02"), 615 | sampleCostData.PreviousPeriodEnd.Format("2006-01-02")) 616 | currentPeriodDates := fmt.Sprintf("%s to %s", 617 | sampleCostData.CurrentPeriodStart.Format("2006-01-02"), 618 | sampleCostData.CurrentPeriodEnd.Format("2006-01-02")) 619 | return previousPeriodName, currentPeriodName, previousPeriodDates, currentPeriodDates 620 | } 621 | } 622 | return "Last Month Due", "Current Month Cost", "N/A", "N/A" 623 | } 624 | 625 | // createDisplayTable cria e configura a tabela de exibição com nomes de colunas dinâmicos. 626 | func (uc *DashboardUseCase) createDisplayTable( 627 | previousPeriodDates string, 628 | currentPeriodDates string, 629 | previousPeriodName string, 630 | currentPeriodName string, 631 | ) types.TableInterface { 632 | table := uc.console.CreateTable() 633 | 634 | table.AddColumn("AWS Account Profile") 635 | table.AddColumn(fmt.Sprintf("%s\n(%s)", previousPeriodName, previousPeriodDates)) 636 | table.AddColumn(fmt.Sprintf("%s\n(%s)", currentPeriodName, currentPeriodDates)) 637 | table.AddColumn("Cost By Service") 638 | table.AddColumn("Budget Status") 639 | table.AddColumn("EC2 Instance Summary") 640 | 641 | return table 642 | } 643 | 644 | // generateDashboardData busca, processa e prepara os dados principais do dashboard. 645 | func (uc *DashboardUseCase) generateDashboardData( 646 | ctx context.Context, 647 | profilesToUse []string, 648 | userRegions []string, 649 | timeRange int, 650 | args *types.CLIArgs, 651 | table types.TableInterface, 652 | status types.StatusHandle, 653 | ) ([]entity.ProfileData, error) { 654 | exportData := []entity.ProfileData{} 655 | 656 | if args.Combine { 657 | accountProfiles := make(map[string][]string) 658 | 659 | for _, profile := range profilesToUse { 660 | accountID, err := uc.awsRepo.GetAccountID(ctx, profile) 661 | if err != nil { 662 | uc.console.LogError("Error checking account ID for profile %s: %s", profile, err) 663 | continue 664 | } 665 | 666 | accountProfiles[accountID] = append(accountProfiles[accountID], profile) 667 | } 668 | 669 | //progress := uc.console.Progress(maps.Keys(accountProfiles)) 670 | 671 | progressTotal := len(accountProfiles) * 5 // Multiplicamos por 5 para ter mais granularidade 672 | progress := uc.console.ProgressWithTotal(progressTotal) 673 | 674 | for accountID, profiles := range accountProfiles { 675 | // Atualize o status com informações sobre a conta atual 676 | status.Update(fmt.Sprintf("Processing account %s...", accountID)) 677 | 678 | var profileData entity.ProfileData 679 | 680 | if len(profiles) > 1 { 681 | // Divida o processamento em etapas com atualizações incrementais 682 | profileData = uc.processCombinedProfilesWithProgress(ctx, accountID, profiles, userRegions, timeRange, args.Tag, progress, status) 683 | } else { 684 | // Processe um único perfil com atualizações incrementais 685 | profileData = uc.ProcessSingleProfileWithProgress(ctx, profiles[0], userRegions, timeRange, args.Tag, progress, status) 686 | } 687 | 688 | exportData = append(exportData, profileData) 689 | uc.addProfileToTable(table, profileData) 690 | } 691 | 692 | progress.Stop() 693 | } else { 694 | // Crie uma barra de progresso com mais granularidade 695 | progressTotal := len(profilesToUse) * 5 // Multiplicamos por 5 para ter etapas por perfil 696 | progress := uc.console.ProgressWithTotal(progressTotal) 697 | 698 | for _, profile := range profilesToUse { 699 | // Atualize o status com informações sobre o perfil atual 700 | status.Update(fmt.Sprintf("Processing profile %s...", profile)) 701 | 702 | // Processe um único perfil com atualizações incrementais 703 | profileData := uc.ProcessSingleProfileWithProgress(ctx, profile, userRegions, timeRange, args.Tag, progress, status) 704 | exportData = append(exportData, profileData) 705 | uc.addProfileToTable(table, profileData) 706 | } 707 | 708 | progress.Stop() 709 | } 710 | 711 | return exportData, nil 712 | } 713 | 714 | // Função para processar um perfil com atualizações de progresso 715 | func (uc *DashboardUseCase) ProcessSingleProfileWithProgress( 716 | ctx context.Context, 717 | profile string, 718 | userRegions []string, 719 | timeRange int, 720 | tags []string, 721 | progress types.ProgressHandle, 722 | status types.StatusHandle, 723 | ) entity.ProfileData { 724 | var profileData entity.ProfileData 725 | profileData.Profile = profile 726 | profileData.Success = false 727 | 728 | // Etapa 1: Obter dados de conta 729 | status.Update(fmt.Sprintf("Getting account data for %s...", profile)) 730 | accountID, err := uc.awsRepo.GetAccountID(ctx, profile) 731 | if err == nil { 732 | profileData.AccountID = accountID 733 | } 734 | progress.Increment() // 1/5 735 | 736 | // Etapa 2: Obter dados de custo 737 | status.Update(fmt.Sprintf("Getting cost data for %s...", profile)) 738 | costData, err := uc.awsRepo.GetCostData(ctx, profile, &timeRange, tags) 739 | if err != nil { 740 | profileData.Error = err.Error() 741 | // Incrementar o progresso para o restante das etapas 742 | progress.Increment() // 2/5 743 | progress.Increment() // 3/5 744 | progress.Increment() // 4/5 745 | progress.Increment() // 5/5 746 | return profileData 747 | } 748 | progress.Increment() // 2/5 749 | 750 | // Etapa 3: Definir regiões e obter resumo de EC2 751 | status.Update(fmt.Sprintf("Getting EC2 data for %s...", profile)) 752 | regions := userRegions 753 | if len(regions) == 0 { 754 | regions, err = uc.awsRepo.GetAccessibleRegions(ctx, profile) 755 | if err != nil { 756 | profileData.Error = err.Error() 757 | progress.Increment() // 3/5 758 | progress.Increment() // 4/5 759 | progress.Increment() // 5/5 760 | return profileData 761 | } 762 | } 763 | progress.Increment() // 3/5 764 | 765 | // Obter resumo das instâncias EC2 766 | ec2Summary, err := uc.awsRepo.GetEC2Summary(ctx, profile, regions) 767 | if err != nil { 768 | profileData.Error = err.Error() 769 | progress.Increment() // 4/5 770 | progress.Increment() // 5/5 771 | return profileData 772 | } 773 | progress.Increment() // 4/5 774 | 775 | // Etapa 4: Processar e formatar os dados 776 | status.Update(fmt.Sprintf("Processing data for %s...", profile)) 777 | 778 | // Processa custos por serviço 779 | serviceCosts, serviceCostsFormatted := uc.processServiceCosts(costData) 780 | 781 | // Formata informações do orçamento 782 | budgetInfo := uc.formatBudgetInfo(costData.Budgets) 783 | 784 | // Formata resumo do EC2 785 | ec2SummaryFormatted := uc.formatEC2Summary(ec2Summary) 786 | 787 | // Calcula alteração percentual no custo total 788 | var percentChange *float64 789 | if costData.LastMonthCost > 0.01 { 790 | change := ((costData.CurrentMonthCost - costData.LastMonthCost) / costData.LastMonthCost) * 100.0 791 | percentChange = &change 792 | } else if costData.CurrentMonthCost < 0.01 { 793 | change := 0.0 794 | percentChange = &change 795 | } 796 | progress.Increment() // 5/5 797 | 798 | // Preenche o dado do perfil 799 | profileData = entity.ProfileData{ 800 | Profile: profile, 801 | AccountID: costData.AccountID, 802 | LastMonth: costData.LastMonthCost, 803 | CurrentMonth: costData.CurrentMonthCost, 804 | ServiceCosts: serviceCosts, 805 | ServiceCostsFormatted: serviceCostsFormatted, 806 | BudgetInfo: budgetInfo, 807 | EC2Summary: ec2Summary, 808 | EC2SummaryFormatted: ec2SummaryFormatted, 809 | Success: true, 810 | CurrentPeriodName: costData.CurrentPeriodName, 811 | PreviousPeriodName: costData.PreviousPeriodName, 812 | PercentChangeInCost: percentChange, 813 | } 814 | 815 | return profileData 816 | } 817 | 818 | // processCombinedProfilesWithProgress processa múltiplos perfis da mesma conta AWS, 819 | // atualizando o progresso e status conforme avança. 820 | func (uc *DashboardUseCase) processCombinedProfilesWithProgress( 821 | ctx context.Context, 822 | accountID string, 823 | profiles []string, 824 | userRegions []string, 825 | timeRange int, 826 | tags []string, 827 | progress types.ProgressHandle, 828 | status types.StatusHandle, 829 | ) entity.ProfileData { 830 | primaryProfile := profiles[0] 831 | profilesStr := strings.Join(profiles, ", ") 832 | 833 | // Inicializa os dados do perfil 834 | accountCostData := entity.CostData{ 835 | AccountID: accountID, 836 | CurrentMonthCost: 0.0, 837 | LastMonthCost: 0.0, 838 | CurrentMonthCostByService: []entity.ServiceCost{}, 839 | Budgets: []entity.BudgetInfo{}, 840 | CurrentPeriodName: "Current month", 841 | PreviousPeriodName: "Last month", 842 | TimeRange: timeRange, 843 | } 844 | 845 | profileData := entity.ProfileData{ 846 | Profile: profilesStr, 847 | AccountID: accountID, 848 | Success: false, 849 | EC2Summary: entity.EC2Summary{}, 850 | ServiceCostsFormatted: []string{}, 851 | BudgetInfo: []string{}, 852 | EC2SummaryFormatted: []string{}, 853 | } 854 | 855 | // Etapa 1: Verificar perfis e preparar-se para buscar dados 856 | status.Update(fmt.Sprintf("Initializing account %s with %d profiles...", accountID, len(profiles))) 857 | progress.Increment() // 1/5 858 | 859 | // Tenta obter dados de custo utilizando o primeiro perfil 860 | timeRangePtr := &timeRange 861 | if timeRange == 0 { 862 | timeRangePtr = nil 863 | } 864 | 865 | // Etapa 2: Buscar dados de custo 866 | status.Update(fmt.Sprintf("Getting cost data for account %s (via %s)...", accountID, primaryProfile)) 867 | costData, err := uc.awsRepo.GetCostData(ctx, primaryProfile, timeRangePtr, tags) 868 | if err != nil { 869 | uc.console.LogError("Error getting cost data for account %s: %s", accountID, err) 870 | profileData.Error = fmt.Sprintf("Failed to process account: %s", err) 871 | 872 | // Incrementar o restante do progresso para manter a contagem 873 | progress.Increment() // 2/5 874 | progress.Increment() // 3/5 875 | progress.Increment() // 4/5 876 | progress.Increment() // 5/5 877 | 878 | return profileData 879 | } 880 | progress.Increment() // 2/5 881 | 882 | // Usa os dados de custo do primeiro perfil 883 | accountCostData = costData 884 | 885 | // Etapa 3: Define as regiões a serem usadas 886 | status.Update(fmt.Sprintf("Determining accessible regions for account %s...", accountID)) 887 | regions := userRegions 888 | var regionErr error 889 | if len(regions) == 0 { 890 | regions, regionErr = uc.awsRepo.GetAccessibleRegions(ctx, primaryProfile) 891 | if regionErr != nil { 892 | uc.console.LogWarning("Error getting accessible regions: %s", regionErr) 893 | regions = []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"} 894 | } 895 | } 896 | progress.Increment() // 3/5 897 | 898 | // Etapa 4: Obter o resumo das instâncias EC2 de todos os perfis combinados 899 | status.Update(fmt.Sprintf("Getting EC2 data across all profiles for account %s...", accountID)) 900 | ec2Summary := entity.EC2Summary{} 901 | 902 | // Inicializa os contadores de instâncias EC2 903 | ec2Summary["running"] = 0 904 | ec2Summary["stopped"] = 0 905 | 906 | // Combina dados de EC2 de todos os perfis 907 | for _, profile := range profiles { 908 | // Atualiza o status para mostrar o perfil atual 909 | status.Update(fmt.Sprintf("Getting EC2 data for profile %s in account %s...", pterm.FgCyan.Sprint(profile), pterm.FgCyan.Sprint(accountID))) 910 | 911 | profileEC2Summary, err := uc.awsRepo.GetEC2Summary(ctx, profile, regions) 912 | if err != nil { 913 | uc.console.LogWarning("Error getting EC2 summary for profile %s: %s", profile, err) 914 | continue 915 | } 916 | 917 | // Combina os resumos de EC2 918 | for state, count := range profileEC2Summary { 919 | if _, exists := ec2Summary[state]; exists { 920 | ec2Summary[state] += count 921 | } else { 922 | ec2Summary[state] = count 923 | } 924 | } 925 | } 926 | progress.Increment() // 4/5 927 | 928 | // Etapa 5: Processar e formatar todos os dados 929 | status.Update(fmt.Sprintf("Processing combined data for account %s...", accountID)) 930 | 931 | // Processa custos por serviço 932 | serviceCosts, serviceCostsFormatted := uc.processServiceCosts(accountCostData) 933 | 934 | // Formata informações de orçamento 935 | budgetInfo := uc.formatBudgetInfo(accountCostData.Budgets) 936 | 937 | // Formata resumo de EC2 938 | ec2SummaryFormatted := uc.formatEC2Summary(ec2Summary) 939 | 940 | // Calcula a alteração percentual no custo total 941 | var percentChange *float64 942 | if accountCostData.LastMonthCost > 0.01 { 943 | change := ((accountCostData.CurrentMonthCost - accountCostData.LastMonthCost) / accountCostData.LastMonthCost) * 100.0 944 | percentChange = &change 945 | } else if accountCostData.CurrentMonthCost < 0.01 && accountCostData.LastMonthCost < 0.01 { 946 | change := 0.0 947 | percentChange = &change 948 | } 949 | progress.Increment() // 5/5 950 | 951 | // Preenche os dados do perfil combinado 952 | profileData.Success = true 953 | profileData.LastMonth = accountCostData.LastMonthCost 954 | profileData.CurrentMonth = accountCostData.CurrentMonthCost 955 | profileData.ServiceCosts = serviceCosts 956 | profileData.ServiceCostsFormatted = serviceCostsFormatted 957 | profileData.BudgetInfo = budgetInfo 958 | profileData.EC2Summary = ec2Summary 959 | profileData.EC2SummaryFormatted = ec2SummaryFormatted 960 | profileData.CurrentPeriodName = accountCostData.CurrentPeriodName 961 | profileData.PreviousPeriodName = accountCostData.PreviousPeriodName 962 | profileData.PercentChangeInCost = percentChange 963 | 964 | // Log de sucesso 965 | uc.console.LogSuccess("Successfully processed combined data for account %s with %d profiles", accountID, len(profiles)) 966 | 967 | return profileData 968 | } 969 | 970 | // addProfileToTable adiciona dados do perfil à tabela de exibição. 971 | func (uc *DashboardUseCase) addProfileToTable(table types.TableInterface, profileData entity.ProfileData) { 972 | if profileData.Success { 973 | percentageChange := profileData.PercentChangeInCost 974 | changeText := "" 975 | 976 | if percentageChange != nil { 977 | if *percentageChange > 0 { 978 | changeText = fmt.Sprintf("\n\n%s", pterm.FgRed.Sprintf("⬆ %.2f%%", *percentageChange)) 979 | } else if *percentageChange < 0 { 980 | changeText = fmt.Sprintf("\n\n%s", pterm.FgGreen.Sprintf("⬇ %.2f%%", math.Abs(*percentageChange))) 981 | } else { 982 | changeText = fmt.Sprintf("\n\n%s", pterm.FgYellow.Sprintf("➡ 0.00%%")) 983 | } 984 | } 985 | 986 | currentMonthWithChange := fmt.Sprintf("%s%s", 987 | pterm.NewStyle(pterm.FgRed, pterm.Bold).Sprintf("$%.2f", profileData.CurrentMonth), 988 | changeText) 989 | 990 | // Preparando textos formatados para cada coluna 991 | profileText := pterm.FgMagenta.Sprintf("Profile: %s\nAccount: %s", profileData.Profile, profileData.AccountID) 992 | lastMonthText := pterm.NewStyle(pterm.FgRed, pterm.Bold).Sprintf("$%.2f", profileData.LastMonth) 993 | servicesText := pterm.FgGreen.Sprintf("%s", strings.Join(profileData.ServiceCostsFormatted, "\n")) 994 | budgetText := pterm.FgYellow.Sprintf("%s", strings.Join(profileData.BudgetInfo, "\n\n")) 995 | 996 | // Adicionando a linha à tabela 997 | table.AddRow( 998 | profileText, 999 | lastMonthText, 1000 | currentMonthWithChange, 1001 | servicesText, 1002 | budgetText, 1003 | strings.Join(profileData.EC2SummaryFormatted, "\n"), 1004 | ) 1005 | } else { 1006 | table.AddRow( 1007 | pterm.FgMagenta.Sprintf("%s", profileData.Profile), 1008 | pterm.FgRed.Sprint("Error"), 1009 | pterm.FgRed.Sprint("Error"), 1010 | pterm.FgRed.Sprintf("Failed to process profile: %s", profileData.Error), 1011 | pterm.FgRed.Sprint("N/A"), 1012 | pterm.FgRed.Sprint("N/A"), 1013 | ) 1014 | } 1015 | } 1016 | 1017 | func (uc *DashboardUseCase) processCombinedProfiles( 1018 | ctx context.Context, 1019 | accountID string, 1020 | profiles []string, 1021 | userRegions []string, 1022 | timeRange int, 1023 | tags []string, 1024 | ) entity.ProfileData { 1025 | primaryProfile := profiles[0] 1026 | 1027 | // Inicializa os dados do perfil 1028 | accountCostData := entity.CostData{ 1029 | AccountID: accountID, 1030 | CurrentMonthCost: 0.0, 1031 | LastMonthCost: 0.0, 1032 | CurrentMonthCostByService: []entity.ServiceCost{}, 1033 | Budgets: []entity.BudgetInfo{}, 1034 | CurrentPeriodName: "Current month", 1035 | PreviousPeriodName: "Last month", 1036 | TimeRange: timeRange, 1037 | } 1038 | 1039 | profileData := entity.ProfileData{ 1040 | Profile: strings.Join(profiles, ", "), 1041 | AccountID: accountID, 1042 | Success: false, 1043 | EC2Summary: entity.EC2Summary{}, 1044 | ServiceCostsFormatted: []string{}, 1045 | BudgetInfo: []string{}, 1046 | EC2SummaryFormatted: []string{}, 1047 | } 1048 | 1049 | // Tenta obter dados de custo utilizando o primeiro perfil 1050 | timeRangePtr := &timeRange 1051 | if timeRange == 0 { 1052 | timeRangePtr = nil 1053 | } 1054 | 1055 | costData, err := uc.awsRepo.GetCostData(ctx, primaryProfile, timeRangePtr, tags) 1056 | if err != nil { 1057 | uc.console.LogError("Error getting cost data for account %s: %s", accountID, err) 1058 | profileData.Error = fmt.Sprintf("Failed to process account: %s", err) 1059 | return profileData 1060 | } 1061 | 1062 | // Usa os dados de custo do primeiro perfil 1063 | accountCostData = costData 1064 | 1065 | // Define as regiões a serem usadas 1066 | regions := userRegions 1067 | var regionErr error 1068 | if len(regions) == 0 { 1069 | regions, regionErr = uc.awsRepo.GetAccessibleRegions(ctx, primaryProfile) 1070 | if regionErr != nil { 1071 | uc.console.LogWarning("Error getting accessible regions: %s", regionErr) 1072 | regions = []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"} 1073 | } 1074 | } 1075 | 1076 | // Obtém o resumo das instâncias EC2 usando o primeiro perfil 1077 | ec2Summary, err := uc.awsRepo.GetEC2Summary(ctx, primaryProfile, regions) 1078 | if err != nil { 1079 | uc.console.LogWarning("Error getting EC2 summary: %s", err) 1080 | ec2Summary = entity.EC2Summary{"running": 0, "stopped": 0} 1081 | } 1082 | 1083 | // Processa custos por serviço 1084 | serviceCosts, serviceCostsFormatted := uc.processServiceCosts(accountCostData) 1085 | 1086 | // Formata informações de orçamento 1087 | budgetInfo := uc.formatBudgetInfo(accountCostData.Budgets) 1088 | 1089 | // Formata resumo de EC2 1090 | ec2SummaryFormatted := uc.formatEC2Summary(ec2Summary) 1091 | 1092 | // Calcula a alteração percentual no custo total 1093 | var percentChange *float64 1094 | if accountCostData.LastMonthCost > 0.01 { 1095 | change := ((accountCostData.CurrentMonthCost - accountCostData.LastMonthCost) / accountCostData.LastMonthCost) * 100.0 1096 | percentChange = &change 1097 | } else if accountCostData.CurrentMonthCost < 0.01 && accountCostData.LastMonthCost < 0.01 { 1098 | change := 0.0 1099 | percentChange = &change 1100 | } 1101 | 1102 | // Preenche os dados do perfil combinado 1103 | profileData.Success = true 1104 | profileData.LastMonth = accountCostData.LastMonthCost 1105 | profileData.CurrentMonth = accountCostData.CurrentMonthCost 1106 | profileData.ServiceCosts = serviceCosts 1107 | profileData.ServiceCostsFormatted = serviceCostsFormatted 1108 | profileData.BudgetInfo = budgetInfo 1109 | profileData.EC2Summary = ec2Summary 1110 | profileData.EC2SummaryFormatted = ec2SummaryFormatted 1111 | profileData.CurrentPeriodName = accountCostData.CurrentPeriodName 1112 | profileData.PreviousPeriodName = accountCostData.PreviousPeriodName 1113 | profileData.PercentChangeInCost = percentChange 1114 | 1115 | return profileData 1116 | } 1117 | -------------------------------------------------------------------------------- /internal/domain/entity/audit.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | // AuditData represents the audit information for a specific AWS profile. 4 | type AuditData struct { 5 | Profile string `json:"profile"` 6 | AccountID string `json:"account_id"` 7 | UntaggedResources string `json:"untagged_resources"` 8 | StoppedInstances string `json:"stopped_instances"` 9 | UnusedVolumes string `json:"unused_volumes"` 10 | UnusedEIPs string `json:"unused_eips"` 11 | BudgetAlerts string `json:"budget_alerts"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/domain/entity/budget.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | // BudgetInfo represents a budget with actual and forecasted spend. 4 | type BudgetInfo struct { 5 | Name string `json:"name"` 6 | Limit float64 `json:"limit"` 7 | Actual float64 `json:"actual"` 8 | Forecast float64 `json:"forecast,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /internal/domain/entity/cost.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | // ServiceCost represents a cost amount for a specific AWS service. 6 | type ServiceCost struct { 7 | ServiceName string `json:"service_name"` 8 | Cost float64 `json:"cost"` 9 | } 10 | 11 | // CostData contains all cost-related information for an AWS account. 12 | type CostData struct { 13 | AccountID string `json:"account_id,omitempty"` 14 | CurrentMonthCost float64 `json:"current_month"` 15 | LastMonthCost float64 `json:"last_month"` 16 | CurrentMonthCostByService []ServiceCost `json:"current_month_cost_by_service"` 17 | Budgets []BudgetInfo `json:"budgets"` 18 | CurrentPeriodName string `json:"current_period_name"` 19 | PreviousPeriodName string `json:"previous_period_name"` 20 | TimeRange int `json:"time_range,omitempty"` 21 | CurrentPeriodStart time.Time `json:"current_period_start"` 22 | CurrentPeriodEnd time.Time `json:"current_period_end"` 23 | PreviousPeriodStart time.Time `json:"previous_period_start"` 24 | PreviousPeriodEnd time.Time `json:"previous_period_end"` 25 | MonthlyCosts []MonthlyCost `json:"monthly_costs,omitempty"` 26 | } 27 | 28 | // MonthlyCost represents the cost for a specific month, used for trend analysis. 29 | type MonthlyCost struct { 30 | Month string `json:"month"` 31 | Cost float64 `json:"cost"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/domain/entity/ec2.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | // EC2Summary is a map of instance state names to instance counts. 4 | type EC2Summary map[string]int 5 | 6 | // StoppedEC2Instances represents stopped EC2 instances grouped by region. 7 | type StoppedEC2Instances map[string][]string 8 | 9 | // UnusedVolumes represents unused EBS volumes grouped by region. 10 | type UnusedVolumes map[string][]string 11 | 12 | // UnusedEIPs represents unused Elastic IPs grouped by region. 13 | type UnusedEIPs map[string][]string 14 | 15 | // UntaggedResources represents untagged resources grouped by service and region. 16 | type UntaggedResources map[string]map[string][]string 17 | -------------------------------------------------------------------------------- /internal/domain/entity/profile.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | // ProfileData represents all data collected for a specific AWS profile. 4 | type ProfileData struct { 5 | Profile string `json:"profile"` 6 | AccountID string `json:"account_id"` 7 | LastMonth float64 `json:"last_month"` 8 | CurrentMonth float64 `json:"current_month"` 9 | ServiceCosts []ServiceCost `json:"service_costs"` 10 | ServiceCostsFormatted []string `json:"service_costs_formatted"` 11 | BudgetInfo []string `json:"budget_info"` 12 | EC2Summary EC2Summary `json:"ec2_summary"` 13 | EC2SummaryFormatted []string `json:"ec2_summary_formatted"` 14 | Success bool `json:"success"` 15 | Error string `json:"error,omitempty"` 16 | CurrentPeriodName string `json:"current_period_name"` 17 | PreviousPeriodName string `json:"previous_period_name"` 18 | PercentChangeInCost *float64 `json:"percent_change_in_total_cost,omitempty"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/domain/repository/aws_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/entity" 7 | ) 8 | 9 | // AWSRepository defines the interface for AWS API interactions. 10 | type AWSRepository interface { 11 | // Profile Operations 12 | GetAWSProfiles() []string 13 | GetAccountID(ctx context.Context, profile string) (string, error) 14 | GetSession(ctx context.Context, profile string) (string, error) 15 | 16 | // Region Operations 17 | GetAllRegions(ctx context.Context, profile string) ([]string, error) 18 | GetAccessibleRegions(ctx context.Context, profile string) ([]string, error) 19 | 20 | // Cost Operations 21 | GetCostData(ctx context.Context, profile string, timeRange *int, tags []string) (entity.CostData, error) 22 | GetTrendData(ctx context.Context, profile string, tags []string) (map[string]interface{}, error) 23 | 24 | // Budget Operations 25 | GetBudgets(ctx context.Context, profile string) ([]entity.BudgetInfo, error) 26 | 27 | // EC2 Operations 28 | GetEC2Summary(ctx context.Context, profile string, regions []string) (entity.EC2Summary, error) 29 | GetStoppedInstances(ctx context.Context, profile string, regions []string) (entity.StoppedEC2Instances, error) 30 | GetUnusedVolumes(ctx context.Context, profile string, regions []string) (entity.UnusedVolumes, error) 31 | GetUnusedEIPs(ctx context.Context, profile string, regions []string) (entity.UnusedEIPs, error) 32 | 33 | // Resource Operations 34 | GetUntaggedResources(ctx context.Context, profile string, regions []string) (entity.UntaggedResources, error) 35 | } 36 | -------------------------------------------------------------------------------- /internal/domain/repository/config_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/diillson/aws-finops-dashboard-go/internal/shared/types" 5 | ) 6 | 7 | // ConfigRepository defines the interface for loading configuration files. 8 | type ConfigRepository interface { 9 | LoadConfigFile(filePath string) (*types.Config, error) 10 | } 11 | -------------------------------------------------------------------------------- /internal/domain/repository/export_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/diillson/aws-finops-dashboard-go/internal/domain/entity" 5 | ) 6 | 7 | // ExportRepository defines the interface for exporting data to different formats. 8 | type ExportRepository interface { 9 | ExportToCSV(data []entity.ProfileData, filename string, outputDir string, previousPeriodDates, currentPeriodDates string) (string, error) 10 | ExportToJSON(data []entity.ProfileData, filename string, outputDir string) (string, error) 11 | ExportToPDF(data []entity.ProfileData, filename string, outputDir string, previousPeriodDates, currentPeriodDates string) (string, error) 12 | 13 | ExportAuditReportToPDF(auditData []entity.AuditData, filename string, outputDir string) (string, error) 14 | ExportAuditReportToCSV(auditData []entity.AuditData, filename string, outputDir string) (string, error) 15 | ExportAuditReportToJSON(auditData []entity.AuditData, filename string, outputDir string) (string, error) 16 | } 17 | -------------------------------------------------------------------------------- /internal/shared/types/cli_args.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // CLIArgs represents the command-line arguments. 4 | type CLIArgs struct { 5 | ConfigFile string 6 | Profiles []string 7 | Regions []string 8 | All bool 9 | Combine bool 10 | ReportName string 11 | ReportType []string 12 | Dir string 13 | TimeRange *int 14 | Tag []string 15 | Trend bool 16 | Audit bool 17 | } 18 | -------------------------------------------------------------------------------- /internal/shared/types/config.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Config represents the application configuration that can be loaded from a file. 4 | type Config struct { 5 | Profiles []string `json:"profiles" yaml:"profiles" toml:"profiles"` 6 | Regions []string `json:"regions" yaml:"regions" toml:"regions"` 7 | Combine bool `json:"combine" yaml:"combine" toml:"combine"` 8 | ReportName string `json:"report_name" yaml:"report_name" toml:"report_name"` 9 | ReportType []string `json:"report_type" yaml:"report_type" toml:"report_type"` 10 | Dir string `json:"dir" yaml:"dir" toml:"dir"` 11 | TimeRange int `json:"time_range" yaml:"time_range" toml:"time_range"` 12 | Tag []string `json:"tag" yaml:"tag" toml:"tag"` 13 | Audit bool `json:"audit" yaml:"audit" toml:"audit"` 14 | Trend bool `json:"trend" yaml:"trend" toml:"trend"` 15 | } 16 | -------------------------------------------------------------------------------- /internal/shared/types/console.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // ConsoleInterface define a interface para saída no console. 4 | type ConsoleInterface interface { 5 | Print(a ...interface{}) 6 | Printf(format string, a ...interface{}) 7 | Println(a ...interface{}) 8 | 9 | LogInfo(format string, a ...interface{}) 10 | LogWarning(format string, a ...interface{}) 11 | LogError(format string, a ...interface{}) 12 | LogSuccess(format string, a ...interface{}) 13 | 14 | Status(message string) StatusHandle 15 | Progress(items []string) ProgressHandle 16 | 17 | CreateTable() TableInterface 18 | DisplayTrendBars(monthlyCosts []MonthlyCost) 19 | 20 | ProgressWithTotal(total int) ProgressHandle 21 | } 22 | 23 | // StatusHandle é uma interface para atualizar uma mensagem de status. 24 | type StatusHandle interface { 25 | Update(message string) 26 | Stop() 27 | } 28 | 29 | // ProgressHandle é uma interface para atualizar uma barra de progresso. 30 | type ProgressHandle interface { 31 | Increment() 32 | Stop() 33 | } 34 | 35 | // TableInterface define a interface para criar e manipular tabelas. 36 | type TableInterface interface { 37 | AddColumn(name string, options ...interface{}) 38 | AddRow(cells ...interface{}) 39 | Render() string 40 | } 41 | 42 | // MonthlyCost representa o custo para um mês específico, usado para gráficos de tendência. 43 | type MonthlyCost struct { 44 | Month string `json:"month"` 45 | Cost float64 `json:"cost"` 46 | } 47 | -------------------------------------------------------------------------------- /internal/shared/types/errors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoProfilesFound = errors.New("no AWS profiles found. Please configure AWS CLI first") 7 | ErrNoValidProfilesFound = errors.New("none of the specified profiles were found in AWS configuration") 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/console/console.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/color" 6 | "math" 7 | "strings" 8 | 9 | "github.com/diillson/aws-finops-dashboard-go/internal/shared/types" 10 | "github.com/pterm/pterm" 11 | ) 12 | 13 | // Console é uma implementação do ConsoleInterface. 14 | type Console struct{} 15 | 16 | // NewConsole cria um novo Console. 17 | func NewConsole() *Console { 18 | return &Console{} 19 | } 20 | 21 | // Print imprime no console. 22 | func (c *Console) Print(a ...interface{}) { 23 | fmt.Print(a...) 24 | } 25 | 26 | // Printf imprime uma string formatada no console. 27 | func (c *Console) Printf(format string, a ...interface{}) { 28 | fmt.Printf(format, a...) 29 | } 30 | 31 | // Println imprime no console com uma nova linha. 32 | func (c *Console) Println(a ...interface{}) { 33 | fmt.Println(a...) 34 | } 35 | 36 | // LogInfo registra uma mensagem de informação. 37 | func (c *Console) LogInfo(format string, a ...interface{}) { 38 | pterm.Info.Printfln(format, a...) 39 | } 40 | 41 | // LogWarning registra uma mensagem de aviso. 42 | func (c *Console) LogWarning(format string, a ...interface{}) { 43 | pterm.Warning.Printfln(format, a...) 44 | } 45 | 46 | // LogError registra uma mensagem de erro. 47 | func (c *Console) LogError(format string, a ...interface{}) { 48 | pterm.Error.Printfln(format, a...) 49 | } 50 | 51 | // LogSuccess registra uma mensagem de sucesso. 52 | func (c *Console) LogSuccess(format string, a ...interface{}) { 53 | pterm.Success.Printfln(format, a...) 54 | } 55 | 56 | // statusHandle é uma implementação do StatusHandle. 57 | type statusHandle struct { 58 | spinner *pterm.SpinnerPrinter 59 | } 60 | 61 | // Status cria um spinner de status com a mensagem especificada. 62 | func (c *Console) Status(message string) types.StatusHandle { 63 | spinner, _ := pterm.DefaultSpinner.Start(message) 64 | return &statusHandle{spinner: spinner} 65 | } 66 | 67 | // Cores predefinidas para uso consistente 68 | var ( 69 | BrightMagenta = color.New(color.FgMagenta, color.Bold).SprintFunc() 70 | BoldRed = color.New(color.FgRed, color.Bold).SprintFunc() 71 | BrightGreen = color.New(color.FgGreen, color.Bold).SprintFunc() 72 | BrightYellow = color.New(color.FgYellow, color.Bold).SprintFunc() 73 | BrightRed = color.New(color.FgRed, color.Bold).SprintFunc() 74 | BrightCyan = color.New(color.FgCyan, color.Bold).SprintFunc() 75 | ) 76 | 77 | // Update atualiza a mensagem de status. 78 | func (h *statusHandle) Update(message string) { 79 | if h.spinner != nil { 80 | h.spinner.UpdateText(message) 81 | } 82 | } 83 | 84 | // Stop pára o spinner de status. 85 | func (h *statusHandle) Stop() { 86 | if h.spinner != nil { 87 | h.spinner.Stop() 88 | } 89 | } 90 | 91 | // progressHandle é uma implementação do ProgressHandle. 92 | type progressHandle struct { 93 | bar *pterm.ProgressbarPrinter 94 | } 95 | 96 | // Progress cria uma barra de progresso para os itens especificados. 97 | func (c *Console) Progress(items []string) types.ProgressHandle { 98 | bar, _ := pterm.DefaultProgressbar.WithTotal(len(items)).Start() 99 | return &progressHandle{bar: bar} 100 | } 101 | 102 | func (c *Console) ProgressWithTotal(total int) types.ProgressHandle { 103 | bar, _ := pterm.DefaultProgressbar. 104 | WithTotal(total). 105 | WithTitle("Processing AWS data"). 106 | WithShowElapsedTime(true). 107 | WithShowCount(true). 108 | WithRemoveWhenDone(false). // Manter a barra após concluir 109 | Start() 110 | return &progressHandle{bar: bar} 111 | } 112 | 113 | // Increment incrementa a barra de progresso. 114 | func (h *progressHandle) Increment() { 115 | if h.bar != nil { 116 | h.bar.Increment() 117 | } 118 | } 119 | 120 | // Stop pára a barra de progresso. 121 | func (h *progressHandle) Stop() { 122 | if h.bar != nil { 123 | h.bar.Stop() 124 | } 125 | } 126 | 127 | // Table é uma implementação do TableInterface. 128 | type Table struct { 129 | columns []string 130 | rows [][]string 131 | } 132 | 133 | // CreateTable cria uma nova tabela. 134 | func (c *Console) CreateTable() types.TableInterface { 135 | return &Table{ 136 | columns: []string{}, 137 | rows: [][]string{}, 138 | } 139 | } 140 | 141 | // AddColumn adiciona uma coluna à tabela. 142 | func (t *Table) AddColumn(name string, options ...interface{}) { 143 | t.columns = append(t.columns, name) 144 | } 145 | 146 | // AddRow adiciona uma linha à tabela. 147 | func (t *Table) AddRow(cells ...interface{}) { 148 | // Convertemos cada célula para string 149 | processedCells := make([]string, len(cells)) 150 | for i, cell := range cells { 151 | processedCells[i] = fmt.Sprint(cell) 152 | } 153 | t.rows = append(t.rows, processedCells) 154 | } 155 | 156 | // Render renderiza a tabela como uma string. 157 | func (t *Table) Render() string { 158 | // Use o pterm para criar uma tabela visualmente agradável 159 | tableData := pterm.TableData{t.columns} 160 | for _, row := range t.rows { 161 | tableData = append(tableData, row) 162 | } 163 | 164 | table := pterm.DefaultTable. 165 | WithHasHeader(). 166 | WithBoxed(). 167 | WithHeaderStyle(pterm.NewStyle(pterm.FgLightCyan)). 168 | WithData(tableData) 169 | 170 | renderedTable, _ := table.Srender() 171 | return renderedTable 172 | } 173 | 174 | // DisplayTrendBars exibe gráficos de barras para análise de tendências. 175 | func (c *Console) DisplayTrendBars(monthlyCosts []types.MonthlyCost) { 176 | // Encontra o valor máximo para escala 177 | maxCost := 0.0 178 | for _, cost := range monthlyCosts { 179 | if cost.Cost > maxCost { 180 | maxCost = cost.Cost 181 | } 182 | } 183 | 184 | if maxCost == 0 { 185 | pterm.Warning.Println("All costs are $0.00 for this period") 186 | return 187 | } 188 | 189 | // Cria um panel com tabela de barras 190 | tableData := pterm.TableData{ 191 | {"Month", "Cost", "", "MoM Change"}, 192 | } 193 | 194 | var prevCost *float64 195 | 196 | for _, mc := range monthlyCosts { 197 | // Calcula tamanho da barra 198 | barLength := int((mc.Cost / maxCost) * 40) 199 | bar := strings.Repeat("█", barLength) 200 | 201 | // Cores e símbolos padrão 202 | barColor := pterm.FgBlue.Sprint(bar) 203 | change := "" 204 | 205 | if prevCost != nil { 206 | // Calcula mudança percentual mês a mês 207 | if *prevCost < 0.01 { 208 | if mc.Cost < 0.01 { 209 | change = pterm.FgYellow.Sprint("0%") 210 | barColor = pterm.FgYellow.Sprint(bar) 211 | } else { 212 | change = pterm.FgRed.Sprint("N/A") 213 | barColor = pterm.FgRed.Sprint(bar) 214 | } 215 | } else { 216 | changePercent := ((mc.Cost - *prevCost) / *prevCost) * 100.0 217 | 218 | if math.Abs(changePercent) < 0.01 { 219 | change = pterm.FgYellow.Sprintf("0%%") 220 | barColor = pterm.FgYellow.Sprint(bar) 221 | } else if math.Abs(changePercent) > 999 { 222 | if changePercent > 0 { 223 | change = pterm.FgRed.Sprint(">+999%") 224 | barColor = pterm.FgRed.Sprint(bar) 225 | } else { 226 | change = pterm.FgGreen.Sprint(">-999%") 227 | barColor = pterm.FgGreen.Sprint(bar) 228 | } 229 | } else { 230 | if changePercent > 0 { 231 | change = pterm.FgRed.Sprintf("+%.2f%%", changePercent) 232 | barColor = pterm.FgRed.Sprint(bar) 233 | } else { 234 | change = pterm.FgGreen.Sprintf("%.2f%%", changePercent) 235 | barColor = pterm.FgGreen.Sprint(bar) 236 | } 237 | } 238 | } 239 | } 240 | 241 | tableData = append(tableData, []string{ 242 | mc.Month, 243 | fmt.Sprintf("$%.2f", mc.Cost), 244 | barColor, 245 | change, 246 | }) 247 | 248 | currentCost := mc.Cost 249 | prevCost = ¤tCost 250 | } 251 | 252 | // Renderiza a tabela 253 | table := pterm.DefaultTable.WithHasHeader().WithData(tableData) 254 | renderedTable, _ := table.Srender() 255 | 256 | // Cria um panel azul em volta da tabela 257 | // Corrigindo o erro com WithTitle em vez de WithTitleTopCenter 258 | panel := pterm.DefaultBox.WithTitle("AWS Cost Trend Analysis").WithBoxStyle(pterm.NewStyle(pterm.FgCyan)).Sprint(renderedTable) 259 | 260 | fmt.Println("\n" + panel) 261 | } 262 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "encoding/json" 11 | "github.com/pterm/pterm" 12 | ) 13 | 14 | // Version é a versão atual do AWS FinOps Dashboard 15 | const Version = "1.0.0" 16 | 17 | // Commit é o commit do git que gerou este build (definido durante a compilação) 18 | var Commit = "development" 19 | 20 | // BuildTime é o momento em que a build foi gerada (definido durante a compilação) 21 | var BuildTime = "" 22 | 23 | // CheckLatestVersion verifica se uma versão mais recente está disponível. 24 | func CheckLatestVersion(currentVersion string) { 25 | // Versões de desenvolvimento não são verificadas 26 | if strings.HasSuffix(currentVersion, "-dev") { 27 | return 28 | } 29 | 30 | client := &http.Client{ 31 | Timeout: 3 * time.Second, 32 | } 33 | 34 | resp, err := client.Get("https://api.github.com/repos/diillson/aws-finops-dashboard-go/releases/latest") 35 | if err != nil { 36 | return 37 | } 38 | defer resp.Body.Close() 39 | 40 | if resp.StatusCode != http.StatusOK { 41 | return 42 | } 43 | 44 | body, err := io.ReadAll(resp.Body) 45 | if err != nil { 46 | return 47 | } 48 | 49 | var release struct { 50 | TagName string `json:"tag_name"` 51 | } 52 | 53 | if err := json.Unmarshal(body, &release); err != nil { 54 | return 55 | } 56 | 57 | latestVersion := strings.TrimPrefix(release.TagName, "v") 58 | 59 | // Compara versões (simples) 60 | if latestVersion > currentVersion { 61 | pterm.Warning.Println(fmt.Sprintf("A new version of AWS FinOps Dashboard is available: %s", latestVersion)) 62 | pterm.Info.Println("Please update using: go install github.com/diillson/aws-finops-dashboard-go@latest") 63 | } 64 | } 65 | 66 | // FormatVersion retorna a versão formatada com informações de commit e build time. 67 | func FormatVersion() string { 68 | if Commit == "development" { 69 | return fmt.Sprintf("%s (development)", Version) 70 | } 71 | 72 | if BuildTime != "" { 73 | return fmt.Sprintf("%s (commit: %s, built at: %s)", Version, Commit, BuildTime) 74 | } 75 | 76 | return fmt.Sprintf("%s (commit: %s)", Version, Commit) 77 | } 78 | --------------------------------------------------------------------------------