├── assets ├── corsarr-logo.png └── corsarr-logo-transparent.png ├── main.go ├── profiles └── .gitkeep ├── internal ├── validator │ ├── path_unix.go │ ├── path_windows.go │ ├── docker_test.go │ ├── validator_test.go │ ├── path.go │ ├── validator.go │ ├── port.go │ ├── dependency.go │ ├── dependency_test.go │ └── docker.go ├── services │ ├── templates │ │ └── services │ │ │ ├── flaresolverr.yaml │ │ │ ├── jellyseerr.yaml │ │ │ ├── bazarr.yaml │ │ │ ├── prowlarr.yaml │ │ │ ├── lidarr.yaml │ │ │ ├── radarr.yaml │ │ │ ├── sonarr.yaml │ │ │ ├── lazylibrarian.yaml │ │ │ ├── jellyfin.yaml │ │ │ ├── qbittorrent.yaml │ │ │ ├── fileflows.yaml │ │ │ └── gluetun.yaml │ ├── categories.go │ ├── categories_test.go │ ├── services.go │ ├── services_test.go │ ├── registry.go │ └── registry_test.go ├── generator │ ├── templates │ │ ├── env.tmpl │ │ └── docker-compose │ │ │ ├── bridge-mode.tmpl │ │ │ ├── base.tmpl │ │ │ └── vpn-mode.tmpl │ ├── network.go │ ├── strategy.go │ ├── strategy_test.go │ ├── env.go │ ├── compose.go │ ├── compose_test.go │ ├── env_test.go │ └── network_test.go ├── i18n │ ├── language.go │ ├── i18n.go │ └── locales │ │ ├── en.yaml │ │ ├── pt-br.yaml │ │ └── es.yaml ├── prompts │ ├── config.go │ └── interactive.go └── profile │ └── profile.go ├── config.example.yaml ├── .github └── workflows │ ├── release.yml │ └── build.yml ├── .gitignore ├── cmd ├── preview.go ├── root.go ├── profile.go ├── check_ports.go └── health.go ├── LICENSE ├── .goreleaser.yml ├── go.mod ├── docs └── TROUBLESHOOTING.md └── go.sum /assets/corsarr-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woliveiras/corsarr/HEAD/assets/corsarr-logo.png -------------------------------------------------------------------------------- /assets/corsarr-logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woliveiras/corsarr/HEAD/assets/corsarr-logo-transparent.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/woliveiras/corsarr/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /profiles/.gitkeep: -------------------------------------------------------------------------------- 1 | # This directory stores user-saved configuration profiles 2 | # Profiles are YAML files containing service selections and environment configurations 3 | -------------------------------------------------------------------------------- /internal/validator/path_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix || darwin || linux 2 | 3 | package validator 4 | 5 | import "syscall" 6 | 7 | // getAvailableDiskSpaceGB returns available disk space in GB (Unix/Linux/macOS) 8 | func getAvailableDiskSpaceGB(path string) float64 { 9 | var stat syscall.Statfs_t 10 | err := syscall.Statfs(path, &stat) 11 | if err != nil { 12 | return 0 13 | } 14 | 15 | // Available blocks * block size / GB 16 | availableBytes := stat.Bavail * uint64(stat.Bsize) 17 | availableGB := float64(availableBytes) / (1024 * 1024 * 1024) 18 | 19 | return availableGB 20 | } 21 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Corsarr CLI Configuration Example 2 | # Use this file with: corsarr generate --config config.yaml 3 | 4 | name: my-setup 5 | description: "My media stack configuration" 6 | 7 | vpn: 8 | enabled: true 9 | provider: protonvpn 10 | password: "your-wireguard-private-key-here" 11 | 12 | services: 13 | - prowlarr 14 | - radarr 15 | - sonarr 16 | - jellyfin 17 | - qbittorrent 18 | 19 | environment: 20 | COMPOSE_PROJECT_NAME: corsarr 21 | ARRPATH: /home/user/media 22 | TZ: America/Sao_Paulo 23 | PUID: "1000" 24 | PGID: "1000" 25 | UMASK: "002" 26 | 27 | outputDir: . 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v6 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v6 21 | with: 22 | go-version-file: go.mod 23 | 24 | - name: Unshallow 25 | run: git fetch --prune --unshallow 26 | 27 | - name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@v6 29 | with: 30 | version: "~> v2" 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /internal/services/templates/services/flaresolverr.yaml: -------------------------------------------------------------------------------- 1 | id: flaresolverr 2 | name: FlareSolverr 3 | category: indexer 4 | description: Proxy server to bypass Cloudflare protection 5 | image: ghcr.io/flaresolverr/flaresolverr:latest 6 | container_name: flaresolverr 7 | 8 | ports: 9 | - host: "8191" 10 | container: "8191" 11 | protocol: tcp 12 | 13 | environment: 14 | - "TZ=${TZ}" 15 | - "LOG_LEVEL=${LOG_LEVEL:-info}" 16 | - "LOG_HTML=${LOG_HTML:-false}" 17 | - "CAPTCHA_SOLVER=${CAPTCHA_SOLVER:-none}" 18 | 19 | network: 20 | vpn_mode: 21 | network_mode: "service:gluetun" 22 | bridge_mode: 23 | hostname: flaresolverr 24 | networks: 25 | - media 26 | 27 | restart: unless-stopped 28 | supports_vpn: true 29 | requires_vpn: false 30 | dependencies: 31 | - prowlarr 32 | optional: true 33 | -------------------------------------------------------------------------------- /internal/services/templates/services/jellyseerr.yaml: -------------------------------------------------------------------------------- 1 | id: jellyseerr 2 | name: Jellyseerr 3 | category: request 4 | description: Request management for movies and TV shows 5 | image: fallenbagel/jellyseerr:latest 6 | container_name: jellyseerr 7 | 8 | ports: 9 | - host: "5055" 10 | container: "5055" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/jellyseerr" 15 | container: "/app/config" 16 | 17 | environment: 18 | - "TZ=${TZ}" 19 | - "PUID=${PUID}" 20 | - "PGID=${PGID}" 21 | - "UMASK=${UMASK}" 22 | 23 | network: 24 | vpn_mode: 25 | network_mode: "service:gluetun" 26 | bridge_mode: 27 | hostname: jellyseerr 28 | networks: 29 | - media 30 | 31 | restart: unless-stopped 32 | supports_vpn: true 33 | requires_vpn: false 34 | dependencies: 35 | - jellyfin 36 | optional: true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore compiled binaries 2 | corsarr 3 | corsarr-test 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Generated files 20 | docker-compose.yml 21 | .env 22 | *.backup 23 | 24 | # IDE 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | *.swo 29 | *~ 30 | 31 | # OS 32 | .DS_Store 33 | Thumbs.db 34 | 35 | # Saved profiles (user data) 36 | configs/profiles/*.yaml 37 | !configs/profiles/.gitkeep 38 | 39 | # Ignore media folders 40 | blackhole 41 | downloads 42 | config 43 | jackett 44 | jellyfin 45 | movies 46 | plex 47 | prowlarr 48 | qbittorrent 49 | radarr 50 | sonarr 51 | transmission 52 | lidarr 53 | overseerr 54 | bazarr 55 | readarr 56 | books 57 | download-client-downloads 58 | music 59 | tv 60 | jellyseerr -------------------------------------------------------------------------------- /internal/services/templates/services/bazarr.yaml: -------------------------------------------------------------------------------- 1 | id: bazarr 2 | name: Bazarr 3 | category: subtitles 4 | description: Subtitle downloader for movies and TV shows 5 | image: ghcr.io/hotio/bazarr:latest 6 | container_name: bazarr 7 | 8 | ports: 9 | - host: "6767" 10 | container: "6767" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/bazarr" 15 | container: "/config" 16 | - host: "${ARRPATH}data/movies" 17 | container: "/data/movies" 18 | - host: "${ARRPATH}data/tvshows" 19 | container: "/data/tvshows" 20 | 21 | environment: 22 | - "TZ=${TZ}" 23 | - "PUID=${PUID}" 24 | - "PGID=${PGID}" 25 | - "UMASK=${UMASK}" 26 | 27 | network: 28 | vpn_mode: 29 | network_mode: "service:gluetun" 30 | bridge_mode: 31 | hostname: bazarr 32 | networks: 33 | - media 34 | 35 | restart: unless-stopped 36 | supports_vpn: true 37 | requires_vpn: false 38 | dependencies: [] 39 | optional: true 40 | -------------------------------------------------------------------------------- /internal/services/templates/services/prowlarr.yaml: -------------------------------------------------------------------------------- 1 | id: prowlarr 2 | name: Prowlarr 3 | category: indexer 4 | description: Indexer manager for *arr apps 5 | image: lscr.io/linuxserver/prowlarr:latest 6 | container_name: prowlarr 7 | 8 | ports: 9 | - host: "9696" 10 | container: "9696" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/prowlarr" 15 | container: "/config" 16 | - host: "${ARRPATH}backup/prowlarr" 17 | container: "/data/backup" 18 | - host: "${ARRPATH}data/downloads" 19 | container: "/downloads" 20 | 21 | environment: 22 | - "TZ=${TZ}" 23 | - "PUID=${PUID}" 24 | - "PGID=${PGID}" 25 | - "UMASK=${UMASK}" 26 | 27 | network: 28 | vpn_mode: 29 | network_mode: "service:gluetun" 30 | bridge_mode: 31 | hostname: prowlarr 32 | networks: 33 | - media 34 | 35 | restart: unless-stopped 36 | supports_vpn: true 37 | requires_vpn: false 38 | dependencies: [] 39 | optional: false 40 | -------------------------------------------------------------------------------- /internal/services/templates/services/lidarr.yaml: -------------------------------------------------------------------------------- 1 | id: lidarr 2 | name: Lidarr 3 | category: media 4 | description: Music collection manager 5 | image: ghcr.io/hotio/lidarr:latest 6 | container_name: lidarr 7 | 8 | ports: 9 | - host: "8686" 10 | container: "8686" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/lidarr" 15 | container: "/config" 16 | - host: "${ARRPATH}data/music" 17 | container: "/data/music" 18 | - host: "${ARRPATH}data/downloads" 19 | container: "/downloads" 20 | 21 | environment: 22 | - "TZ=${TZ}" 23 | - "PUID=${PUID}" 24 | - "PGID=${PGID}" 25 | - "UMASK=${UMASK}" 26 | 27 | network: 28 | vpn_mode: 29 | network_mode: "service:gluetun" 30 | bridge_mode: 31 | hostname: lidarr 32 | networks: 33 | - media 34 | 35 | restart: unless-stopped 36 | supports_vpn: true 37 | requires_vpn: false 38 | dependencies: 39 | - qbittorrent 40 | - prowlarr 41 | optional: true 42 | -------------------------------------------------------------------------------- /cmd/preview.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // previewCmd represents the preview command 10 | var previewCmd = &cobra.Command{ 11 | Use: "preview", 12 | Short: "Preview configuration without generating files", 13 | Long: `Show a preview of the docker-compose.yml and .env files that would be generated 14 | based on your current configuration or a saved profile. 15 | 16 | This is useful to verify your configuration before actually generating the files.`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | t := GetTranslator() 19 | 20 | fmt.Println(t.T("commands.preview.long")) 21 | fmt.Println() 22 | 23 | // TODO: Implement preview logic 24 | fmt.Println("⚠️ Preview logic not yet implemented") 25 | }, 26 | } 27 | 28 | func init() { 29 | rootCmd.AddCommand(previewCmd) 30 | 31 | // Flags for preview command 32 | previewCmd.Flags().StringVarP(&profileName, "profile", "p", "", "Preview using a saved profile") 33 | } 34 | -------------------------------------------------------------------------------- /internal/validator/path_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package validator 4 | 5 | import ( 6 | "syscall" 7 | "unsafe" 8 | ) 9 | 10 | // getAvailableDiskSpaceGB returns available disk space in GB (Windows) 11 | func getAvailableDiskSpaceGB(path string) float64 { 12 | kernel32 := syscall.NewLazyDLL("kernel32.dll") 13 | getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW") 14 | 15 | var freeBytesAvailable uint64 16 | var totalBytes uint64 17 | var totalFreeBytes uint64 18 | 19 | pathPtr, err := syscall.UTF16PtrFromString(path) 20 | if err != nil { 21 | return 0 22 | } 23 | 24 | ret, _, _ := getDiskFreeSpaceEx.Call( 25 | uintptr(unsafe.Pointer(pathPtr)), 26 | uintptr(unsafe.Pointer(&freeBytesAvailable)), 27 | uintptr(unsafe.Pointer(&totalBytes)), 28 | uintptr(unsafe.Pointer(&totalFreeBytes)), 29 | ) 30 | 31 | if ret == 0 { 32 | return 0 33 | } 34 | 35 | // Convert bytes to GB 36 | availableGB := float64(freeBytesAvailable) / (1024 * 1024 * 1024) 37 | return availableGB 38 | } 39 | -------------------------------------------------------------------------------- /internal/services/templates/services/radarr.yaml: -------------------------------------------------------------------------------- 1 | id: radarr 2 | name: Radarr 3 | category: media 4 | description: Movie collection manager 5 | image: lscr.io/linuxserver/radarr:latest 6 | container_name: radarr 7 | 8 | ports: 9 | - host: "7878" 10 | container: "7878" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/radarr" 15 | container: "/config" 16 | - host: "${ARRPATH}backup/radarr" 17 | container: "/data/backup" 18 | - host: "${ARRPATH}data/movies" 19 | container: "/data/movies" 20 | - host: "${ARRPATH}data/downloads" 21 | container: "/downloads" 22 | 23 | environment: 24 | - "TZ=${TZ}" 25 | - "PUID=${PUID}" 26 | - "PGID=${PGID}" 27 | - "UMASK=${UMASK}" 28 | 29 | network: 30 | vpn_mode: 31 | network_mode: "service:gluetun" 32 | bridge_mode: 33 | hostname: radarr 34 | networks: 35 | - media 36 | 37 | restart: unless-stopped 38 | supports_vpn: true 39 | requires_vpn: false 40 | dependencies: 41 | - qbittorrent 42 | - prowlarr 43 | optional: false 44 | -------------------------------------------------------------------------------- /internal/services/templates/services/sonarr.yaml: -------------------------------------------------------------------------------- 1 | id: sonarr 2 | name: Sonarr 3 | category: media 4 | description: TV show collection manager 5 | image: lscr.io/linuxserver/sonarr:latest 6 | container_name: sonarr 7 | 8 | ports: 9 | - host: "8989" 10 | container: "8989" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/sonarr" 15 | container: "/config" 16 | - host: "${ARRPATH}backup/sonarr" 17 | container: "/data/backup" 18 | - host: "${ARRPATH}data/tvshows" 19 | container: "/data/tvshows" 20 | - host: "${ARRPATH}data/downloads" 21 | container: "/downloads" 22 | 23 | environment: 24 | - "TZ=${TZ}" 25 | - "PUID=${PUID}" 26 | - "PGID=${PGID}" 27 | - "UMASK=${UMASK}" 28 | 29 | network: 30 | vpn_mode: 31 | network_mode: "service:gluetun" 32 | bridge_mode: 33 | hostname: sonarr 34 | networks: 35 | - media 36 | 37 | restart: unless-stopped 38 | supports_vpn: true 39 | requires_vpn: false 40 | dependencies: 41 | - qbittorrent 42 | - prowlarr 43 | optional: false 44 | -------------------------------------------------------------------------------- /internal/services/templates/services/lazylibrarian.yaml: -------------------------------------------------------------------------------- 1 | id: lazylibrarian 2 | name: LazyLibrarian 3 | category: media 4 | description: Book collection manager 5 | image: lscr.io/linuxserver/lazylibrarian:latest 6 | container_name: lazylibrarian 7 | 8 | ports: 9 | - host: "5299" 10 | container: "5299" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "${ARRPATH}config/lazylibrarian/data" 15 | container: "/config" 16 | - host: "${ARRPATH}data/downloads" 17 | container: "/downloads" 18 | - host: "${ARRPATH}data/books" 19 | container: "/books" 20 | 21 | environment: 22 | - "TZ=${TZ}" 23 | - "PUID=${PUID}" 24 | - "PGID=${PGID}" 25 | - "UMASK=${UMASK}" 26 | - "DOCKER_MODS=linuxserver/mods:universal-calibre|linuxserver/mods:lazylibrarian-ffmpeg" 27 | 28 | network: 29 | vpn_mode: 30 | network_mode: "service:gluetun" 31 | bridge_mode: 32 | hostname: lazylibrarian 33 | networks: 34 | - media 35 | 36 | restart: unless-stopped 37 | supports_vpn: true 38 | requires_vpn: false 39 | dependencies: 40 | - qbittorrent 41 | - prowlarr 42 | optional: true 43 | -------------------------------------------------------------------------------- /internal/services/categories.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | // ServiceCategory represents the category of a service 4 | type ServiceCategory string 5 | 6 | const ( 7 | CategoryDownload ServiceCategory = "download" 8 | CategoryIndexer ServiceCategory = "indexer" 9 | CategoryMedia ServiceCategory = "media" 10 | CategorySubtitles ServiceCategory = "subtitles" 11 | CategoryStreaming ServiceCategory = "streaming" 12 | CategoryRequest ServiceCategory = "request" 13 | CategoryTranscode ServiceCategory = "transcode" 14 | CategoryVPN ServiceCategory = "vpn" 15 | ) 16 | 17 | // GetCategoryTranslationKey returns the i18n key for a category 18 | func (c ServiceCategory) GetCategoryTranslationKey() string { 19 | return "categories." + string(c) 20 | } 21 | 22 | // AllCategories returns all available categories in order 23 | func AllCategories() []ServiceCategory { 24 | return []ServiceCategory{ 25 | CategoryDownload, 26 | CategoryIndexer, 27 | CategoryMedia, 28 | CategorySubtitles, 29 | CategoryStreaming, 30 | CategoryRequest, 31 | CategoryTranscode, 32 | CategoryVPN, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 - Today William Oliveira 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 | -------------------------------------------------------------------------------- /internal/services/templates/services/jellyfin.yaml: -------------------------------------------------------------------------------- 1 | id: jellyfin 2 | name: Jellyfin 3 | category: streaming 4 | description: Media streaming server 5 | image: lscr.io/linuxserver/jellyfin:latest 6 | container_name: jellyfin 7 | 8 | ports: 9 | - host: "8096" 10 | container: "8096" 11 | protocol: tcp 12 | - host: "7359" 13 | container: "7359" 14 | protocol: udp 15 | - host: "1900" 16 | container: "1900" 17 | protocol: udp 18 | 19 | volumes: 20 | - host: "${ARRPATH}config/jellyfin" 21 | container: "/config" 22 | - host: "${ARRPATH}data/movies" 23 | container: "/data/movies" 24 | - host: "${ARRPATH}data/tvshows" 25 | container: "/data/tvshows" 26 | - host: "${ARRPATH}data/downloads" 27 | container: "/downloads" 28 | 29 | environment: 30 | - "TZ=${TZ}" 31 | - "PUID=${PUID}" 32 | - "PGID=${PGID}" 33 | - "UMASK=${UMASK}" 34 | 35 | network: 36 | vpn_mode: 37 | network_mode: "service:gluetun" 38 | bridge_mode: 39 | hostname: jellyfin 40 | networks: 41 | - media 42 | 43 | restart: unless-stopped 44 | supports_vpn: true 45 | requires_vpn: false 46 | dependencies: [] 47 | optional: false 48 | -------------------------------------------------------------------------------- /internal/services/templates/services/qbittorrent.yaml: -------------------------------------------------------------------------------- 1 | id: qbittorrent 2 | name: qBittorrent 3 | category: download 4 | description: BitTorrent client for downloading content 5 | image: lscr.io/linuxserver/qbittorrent:latest 6 | container_name: qbittorrent 7 | 8 | ports: 9 | - host: "8081" 10 | container: "8081" 11 | protocol: tcp 12 | - host: "6881" 13 | container: "6881" 14 | protocol: tcp 15 | - host: "6881" 16 | container: "6881" 17 | protocol: udp 18 | 19 | volumes: 20 | - host: "${ARRPATH}config/qbittorrent" 21 | container: "/config" 22 | - host: "${ARRPATH}data/downloads" 23 | container: "/downloads" 24 | - host: "${ARRPATH}data/movies" 25 | container: "/data/movies" 26 | 27 | environment: 28 | - "TZ=${TZ}" 29 | - "PUID=${PUID}" 30 | - "PGID=${PGID}" 31 | - "UMASK=${UMASK}" 32 | - "WEBUI_PORT=8081" 33 | - "TORRENTING_PORT=6881" 34 | 35 | network: 36 | vpn_mode: 37 | network_mode: "service:gluetun" 38 | bridge_mode: 39 | hostname: qbittorrent 40 | networks: 41 | - media 42 | 43 | restart: unless-stopped 44 | supports_vpn: true 45 | requires_vpn: false 46 | dependencies: [] 47 | optional: false 48 | -------------------------------------------------------------------------------- /internal/generator/templates/env.tmpl: -------------------------------------------------------------------------------- 1 | # Docker Compose Project Name 2 | COMPOSE_PROJECT_NAME={{ .ComposeProjectName }} 3 | 4 | # Base path for all configurations and data 5 | ARRPATH={{ .ARRPath }} 6 | 7 | # Timezone configuration 8 | TZ={{ .Timezone }} 9 | 10 | # User and Group IDs 11 | PUID={{ .PUID }} 12 | PGID={{ .PGID }} 13 | UMASK={{ .UMASK }} 14 | {{- if .VPNConfig }} 15 | 16 | # VPN Configuration (Gluetun) 17 | VPN_SERVICE_PROVIDER={{ .VPNConfig.ServiceProvider }} 18 | VPN_TYPE={{ .VPNConfig.Type }} 19 | {{- if .VPNConfig.WireguardPrivateKey }} 20 | WIREGUARD_PRIVATE_KEY={{ .VPNConfig.WireguardPrivateKey }} 21 | {{- end }} 22 | {{- if .VPNConfig.WireguardPublicKey }} 23 | WIREGUARD_PUBLIC_KEY={{ .VPNConfig.WireguardPublicKey }} 24 | {{- end }} 25 | {{- if .VPNConfig.WireguardAddresses }} 26 | WIREGUARD_ADDRESSES={{ .VPNConfig.WireguardAddresses }} 27 | {{- end }} 28 | {{- if .VPNConfig.ServerCountries }} 29 | SERVER_COUNTRIES={{ .VPNConfig.ServerCountries }} 30 | {{- end }} 31 | {{- if .VPNConfig.PortForwarding }} 32 | VPN_PORT_FORWARDING={{ .VPNConfig.PortForwarding }} 33 | {{- end }} 34 | {{- if .VPNConfig.DNSAddress }} 35 | VPN_DNS_ADDRESS={{ .VPNConfig.DNSAddress }} 36 | {{- end }} 37 | {{- end }} 38 | {{- if .CustomEnv }} 39 | 40 | # Custom Environment Variables 41 | {{- range $key, $value := .CustomEnv }} 42 | {{ $key }}={{ $value }} 43 | {{- end }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /internal/generator/templates/docker-compose/bridge-mode.tmpl: -------------------------------------------------------------------------------- 1 | services: 2 | {{- range .Services }} 3 | {{ .ContainerName }}: 4 | image: {{ .Image }} 5 | container_name: {{ .ContainerName }} 6 | {{- if .Network.BridgeMode.Hostname }} 7 | hostname: {{ .Network.BridgeMode.Hostname }} 8 | {{- end }} 9 | {{- if .Network.BridgeMode.Networks }} 10 | networks: 11 | {{- range .Network.BridgeMode.Networks }} 12 | - {{ . }} 13 | {{- end }} 14 | {{- end }} 15 | {{- if .Ports }} 16 | ports: 17 | {{- range .Ports }} 18 | - "{{ .Host }}:{{ .Container }}{{ if ne .Protocol "tcp" }}/{{ .Protocol }}{{ end }}" 19 | {{- end }} 20 | {{- end }} 21 | {{- if .Volumes }} 22 | volumes: 23 | {{- range .Volumes }} 24 | - {{ .Host }}:{{ .Container }}{{ if .ReadOnly }}:ro{{ end }} 25 | {{- end }} 26 | {{- end }} 27 | {{- if .Environment }} 28 | environment: 29 | {{- range .Environment }} 30 | - {{ . }} 31 | {{- end }} 32 | {{- end }} 33 | {{- if .Devices }} 34 | devices: 35 | {{- range .Devices }} 36 | - {{ . }} 37 | {{- end }} 38 | {{- end }} 39 | {{- if .CapAdd }} 40 | cap_add: 41 | {{- range .CapAdd }} 42 | - {{ . }} 43 | {{- end }} 44 | {{- end }} 45 | restart: {{ .Restart }} 46 | {{- end }} 47 | 48 | networks: 49 | media: 50 | driver: bridge 51 | -------------------------------------------------------------------------------- /internal/services/templates/services/fileflows.yaml: -------------------------------------------------------------------------------- 1 | id: fileflows 2 | name: FileFlows 3 | category: transcode 4 | description: Media transcoding and optimization 5 | image: revenz/fileflows:latest 6 | container_name: fileflows 7 | 8 | ports: 9 | - host: "19200" 10 | container: "5000" 11 | protocol: tcp 12 | 13 | volumes: 14 | - host: "/var/run/docker.sock" 15 | container: "/var/run/docker.sock" 16 | read_only: true 17 | - host: "${ARRPATH}config/fileflows" 18 | container: "/app/Data" 19 | - host: "${ARRPATH}config/fileflows/logs" 20 | container: "/app/Logs" 21 | - host: "${ARRPATH}data/movies" 22 | container: "/media/movies" 23 | - host: "${ARRPATH}data/tvshows" 24 | container: "/media/tvshows" 25 | - host: "${ARRPATH}data/downloads" 26 | container: "/media/downloads" 27 | - host: "/tmp/fileflows_temp" 28 | container: "/temp" 29 | 30 | devices: 31 | - "/dev/dri:/dev/dri" 32 | - "/dev/dri/renderD128:/dev/dri/renderD128" 33 | 34 | environment: 35 | - "TZ=${TZ}" 36 | - "PUID=${PUID}" 37 | - "PGID=${PGID}" 38 | - "UMASK=${UMASK}" 39 | - "BROWSER_START_DIRECTORY=/media" 40 | 41 | network: 42 | vpn_mode: 43 | network_mode: "service:gluetun" 44 | bridge_mode: 45 | hostname: fileflows 46 | networks: 47 | - media 48 | 49 | restart: unless-stopped 50 | supports_vpn: true 51 | requires_vpn: false 52 | dependencies: 53 | - jellyfin 54 | optional: true 55 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go mod verify 7 | 8 | builds: 9 | - id: corsarr 10 | main: . 11 | binary: corsarr 12 | goos: 13 | - linux 14 | - darwin 15 | - windows 16 | goarch: 17 | - amd64 18 | - arm64 19 | ldflags: 20 | - -s -w 21 | - -X main.version={{.Version}} 22 | - -X main.commit={{.Commit}} 23 | - -X main.date={{.Date}} 24 | env: 25 | - CGO_ENABLED=0 26 | 27 | archives: 28 | - id: corsarr 29 | format: tar.gz 30 | name_template: "corsarr_{{ .Os }}_{{ .Arch }}" 31 | format_overrides: 32 | - goos: windows 33 | format: zip 34 | files: 35 | - LICENSE 36 | - README.md 37 | 38 | checksum: 39 | name_template: 'checksums.txt' 40 | algorithm: sha256 41 | 42 | changelog: 43 | sort: asc 44 | use: github 45 | filters: 46 | exclude: 47 | - '^docs:' 48 | - '^test:' 49 | - '^ci:' 50 | - '^chore:' 51 | - Merge pull request 52 | - Merge branch 53 | groups: 54 | - title: 'New Features' 55 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 56 | order: 0 57 | - title: 'Bug Fixes' 58 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 59 | order: 1 60 | - title: 'Performance Improvements' 61 | regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' 62 | order: 2 63 | - title: 'Other Changes' 64 | order: 999 65 | 66 | release: 67 | github: 68 | owner: woliveiras 69 | name: corsarr 70 | name_template: "{{.Tag}}" 71 | prerelease: auto 72 | -------------------------------------------------------------------------------- /internal/services/categories_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestServiceCategory_GetCategoryTranslationKey(t *testing.T) { 8 | tests := []struct { 9 | category ServiceCategory 10 | expected string 11 | }{ 12 | {CategoryDownload, "categories.download"}, 13 | {CategoryIndexer, "categories.indexer"}, 14 | {CategoryMedia, "categories.media"}, 15 | {CategoryVPN, "categories.vpn"}, 16 | } 17 | 18 | for _, tt := range tests { 19 | t.Run(string(tt.category), func(t *testing.T) { 20 | result := tt.category.GetCategoryTranslationKey() 21 | if result != tt.expected { 22 | t.Errorf("Expected %s, got %s", tt.expected, result) 23 | } 24 | }) 25 | } 26 | } 27 | 28 | func TestAllCategories(t *testing.T) { 29 | categories := AllCategories() 30 | 31 | if len(categories) == 0 { 32 | t.Fatal("Expected categories, got 0") 33 | } 34 | 35 | expectedCount := 8 // download, indexer, media, subtitles, streaming, request, transcode, vpn 36 | if len(categories) != expectedCount { 37 | t.Errorf("Expected %d categories, got %d", expectedCount, len(categories)) 38 | } 39 | 40 | // Check that all expected categories are present 41 | expectedCategories := map[ServiceCategory]bool{ 42 | CategoryDownload: false, 43 | CategoryIndexer: false, 44 | CategoryMedia: false, 45 | CategorySubtitles: false, 46 | CategoryStreaming: false, 47 | CategoryRequest: false, 48 | CategoryTranscode: false, 49 | CategoryVPN: false, 50 | } 51 | 52 | for _, cat := range categories { 53 | expectedCategories[cat] = true 54 | } 55 | 56 | for cat, found := range expectedCategories { 57 | if !found { 58 | t.Errorf("Category %s not found in AllCategories()", cat) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/generator/templates/docker-compose/base.tmpl: -------------------------------------------------------------------------------- 1 | services: 2 | {{- range .Services }} 3 | {{ .ContainerName }}: 4 | image: {{ .Image }} 5 | container_name: {{ .ContainerName }} 6 | {{- if eq $.VPNMode true }} 7 | {{- if eq .Category "vpn" }} 8 | {{- if .Ports }} 9 | ports: 10 | {{- range .Ports }} 11 | - "{{ .Host }}:{{ .Container }}{{ if ne .Protocol "tcp" }}/{{ .Protocol }}{{ end }}" 12 | {{- end }} 13 | {{- end }} 14 | {{- else }} 15 | network_mode: "{{ .Network.VPNMode.NetworkMode }}" 16 | depends_on: 17 | - gluetun 18 | {{- end }} 19 | {{- else }} 20 | {{- if .Network.BridgeMode.Hostname }} 21 | hostname: {{ .Network.BridgeMode.Hostname }} 22 | {{- end }} 23 | {{- if .Network.BridgeMode.Networks }} 24 | networks: 25 | {{- range .Network.BridgeMode.Networks }} 26 | - {{ . }} 27 | {{- end }} 28 | {{- end }} 29 | {{- if .Ports }} 30 | ports: 31 | {{- range .Ports }} 32 | - "{{ .Host }}:{{ .Container }}{{ if ne .Protocol "tcp" }}/{{ .Protocol }}{{ end }}" 33 | {{- end }} 34 | {{- end }} 35 | {{- end }} 36 | {{- if .Volumes }} 37 | volumes: 38 | {{- range .Volumes }} 39 | - {{ .Host }}:{{ .Container }}{{ if .ReadOnly }}:ro{{ end }} 40 | {{- end }} 41 | {{- end }} 42 | {{- if .Environment }} 43 | environment: 44 | {{- range .Environment }} 45 | - {{ . }} 46 | {{- end }} 47 | {{- end }} 48 | {{- if .Devices }} 49 | devices: 50 | {{- range .Devices }} 51 | - {{ . }} 52 | {{- end }} 53 | {{- end }} 54 | restart: {{ .Restart }} 55 | {{- end }} 56 | 57 | {{- if eq .VPNMode false }} 58 | networks: 59 | media: 60 | driver: bridge 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/woliveiras/corsarr/internal/i18n" 9 | ) 10 | 11 | var ( 12 | translator *i18n.I18n 13 | language string 14 | ) 15 | 16 | // rootCmd represents the base command 17 | var rootCmd = &cobra.Command{ 18 | Use: "corsarr", 19 | Short: "🏴‍☠️ Corsarr - Navigate the high seas of media automation", 20 | Long: `Corsarr is a CLI tool to easily configure and deploy your *arr stack 21 | (Radarr, Sonarr, Prowlarr, etc.) with Docker Compose. 22 | 23 | Select the services you want, configure your environment, 24 | and Corsarr will generate the docker-compose.yml and .env files for you.`, 25 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 26 | // Initialize i18n if not already done 27 | if translator == nil { 28 | var err error 29 | 30 | // If language not set, prompt user to select 31 | if language == "" { 32 | language, err = i18n.SelectLanguage() 33 | if err != nil { 34 | fmt.Fprintf(os.Stderr, "Error selecting language: %v\n", err) 35 | language = "en" // Fallback to English 36 | } 37 | } 38 | 39 | translator, err = i18n.New(language) 40 | if err != nil { 41 | fmt.Fprintf(os.Stderr, "Error initializing translator: %v\n", err) 42 | os.Exit(1) 43 | } 44 | 45 | // Print welcome message 46 | fmt.Println(translator.T("messages.welcome")) 47 | fmt.Println() 48 | } 49 | }, 50 | } 51 | 52 | // Execute adds all child commands to the root command and sets flags appropriately. 53 | func Execute() { 54 | if err := rootCmd.Execute(); err != nil { 55 | fmt.Fprintln(os.Stderr, err) 56 | os.Exit(1) 57 | } 58 | } 59 | 60 | func init() { 61 | // Global flags 62 | rootCmd.PersistentFlags().StringVarP(&language, "language", "l", "", "Language (en, pt-br, es)") 63 | } 64 | 65 | // GetTranslator returns the current translator instance 66 | func GetTranslator() *i18n.I18n { 67 | return translator 68 | } 69 | -------------------------------------------------------------------------------- /internal/generator/templates/docker-compose/vpn-mode.tmpl: -------------------------------------------------------------------------------- 1 | services: 2 | gluetun: 3 | image: {{ .Gluetun.Image }} 4 | container_name: {{ .Gluetun.ContainerName }} 5 | {{- if .Gluetun.Ports }} 6 | ports: 7 | {{- range .Gluetun.Ports }} 8 | - "{{ .Host }}:{{ .Container }}{{ if ne .Protocol "tcp" }}/{{ .Protocol }}{{ end }}" 9 | {{- end }} 10 | {{- range .ExposedPorts }} 11 | - "{{ .Host }}:{{ .Container }}{{ if ne .Protocol "tcp" }}/{{ .Protocol }}{{ end }}" 12 | {{- end }} 13 | {{- end }} 14 | {{- if .Gluetun.Volumes }} 15 | volumes: 16 | {{- range .Gluetun.Volumes }} 17 | - {{ .Host }}:{{ .Container }}{{ if .ReadOnly }}:ro{{ end }} 18 | {{- end }} 19 | {{- end }} 20 | {{- if .Gluetun.Environment }} 21 | environment: 22 | {{- range .Gluetun.Environment }} 23 | - {{ . }} 24 | {{- end }} 25 | {{- end }} 26 | {{- if .Gluetun.Devices }} 27 | devices: 28 | {{- range .Gluetun.Devices }} 29 | - {{ . }} 30 | {{- end }} 31 | {{- end }} 32 | {{- if .Gluetun.CapAdd }} 33 | cap_add: 34 | {{- range .Gluetun.CapAdd }} 35 | - {{ . }} 36 | {{- end }} 37 | {{- end }} 38 | restart: {{ .Gluetun.Restart }} 39 | {{- range .Services }} 40 | {{ .ContainerName }}: 41 | image: {{ .Image }} 42 | container_name: {{ .ContainerName }} 43 | network_mode: "{{ .Network.VPNMode.NetworkMode }}" 44 | depends_on: 45 | - gluetun 46 | {{- if .Volumes }} 47 | volumes: 48 | {{- range .Volumes }} 49 | - {{ .Host }}:{{ .Container }}{{ if .ReadOnly }}:ro{{ end }} 50 | {{- end }} 51 | {{- end }} 52 | {{- if .Environment }} 53 | environment: 54 | {{- range .Environment }} 55 | - {{ . }} 56 | {{- end }} 57 | {{- end }} 58 | {{- if .Devices }} 59 | devices: 60 | {{- range .Devices }} 61 | - {{ . }} 62 | {{- end }} 63 | {{- end }} 64 | restart: {{ .Restart }} 65 | {{- end }} 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/woliveiras/corsarr 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/charmbracelet/huh v0.8.0 7 | github.com/nicksnyder/go-i18n/v2 v2.4.0 8 | github.com/spf13/cobra v1.8.0 9 | golang.org/x/text v0.31.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/catppuccin/go v0.3.0 // indirect 17 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect 18 | github.com/charmbracelet/bubbletea v1.3.10 // indirect 19 | github.com/charmbracelet/colorprofile v0.3.3 // indirect 20 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 21 | github.com/charmbracelet/x/ansi v0.11.2 // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect 23 | github.com/charmbracelet/x/exp/strings v0.0.0-20251201173703-9f73bfd934ff // indirect 24 | github.com/charmbracelet/x/term v0.2.2 // indirect 25 | github.com/clipperhouse/displaywidth v0.6.1 // indirect 26 | github.com/clipperhouse/stringish v0.1.1 // indirect 27 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 28 | github.com/dustin/go-humanize v1.0.1 // indirect 29 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 30 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 31 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mattn/go-localereader v0.0.1 // indirect 34 | github.com/mattn/go-runewidth v0.0.19 // indirect 35 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 37 | github.com/muesli/cancelreader v0.2.2 // indirect 38 | github.com/muesli/termenv v0.16.0 // indirect 39 | github.com/rivo/uniseg v0.4.7 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 42 | golang.org/x/sys v0.38.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /internal/i18n/language.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | ) 9 | 10 | // Language represents a supported language 11 | type Language struct { 12 | Name string 13 | Code string 14 | Flag string 15 | } 16 | 17 | // SupportedLanguages lists all available languages 18 | var SupportedLanguages = []Language{ 19 | {Name: "English", Code: "en", Flag: "🇺🇸"}, 20 | {Name: "Português Brasileiro", Code: "pt-br", Flag: "🇧🇷"}, 21 | {Name: "Español", Code: "es", Flag: "🇪🇸"}, 22 | } 23 | 24 | // SelectLanguage prompts the user to select a language 25 | func SelectLanguage() (string, error) { 26 | // Build options with flags 27 | options := make([]huh.Option[string], len(SupportedLanguages)) 28 | for i, lang := range SupportedLanguages { 29 | displayName := fmt.Sprintf("%s %s", lang.Flag, lang.Name) 30 | options[i] = huh.NewOption(displayName, lang.Code) 31 | } 32 | 33 | var selected string 34 | form := huh.NewForm( 35 | huh.NewGroup( 36 | huh.NewSelect[string](). 37 | Title("Select your language / Selecione seu idioma / Seleccione su idioma:"). 38 | Options(options...). 39 | Value(&selected), 40 | ), 41 | ) 42 | 43 | if err := form.Run(); err != nil { 44 | return "", err 45 | } 46 | 47 | return selected, nil 48 | } 49 | 50 | // DetectSystemLanguage attempts to detect the system language 51 | func DetectSystemLanguage() string { 52 | // Check LANG environment variable 53 | lang := os.Getenv("LANG") 54 | if lang == "" { 55 | lang = os.Getenv("LANGUAGE") 56 | } 57 | 58 | // Map common system locales to our supported languages 59 | if len(lang) >= 2 { 60 | langCode := lang[:2] 61 | switch langCode { 62 | case "pt": 63 | return "pt-br" 64 | case "es": 65 | return "es" 66 | case "en": 67 | return "en" 68 | } 69 | } 70 | 71 | return "en" // Default to English 72 | } 73 | 74 | // GetLanguageByCode returns the Language struct for a given code 75 | func GetLanguageByCode(code string) (Language, error) { 76 | for _, lang := range SupportedLanguages { 77 | if lang.Code == code { 78 | return lang, nil 79 | } 80 | } 81 | return Language{}, fmt.Errorf("unsupported language code: %s", code) 82 | } 83 | -------------------------------------------------------------------------------- /internal/services/templates/services/gluetun.yaml: -------------------------------------------------------------------------------- 1 | id: gluetun 2 | name: Gluetun 3 | category: vpn 4 | description: VPN client with support for multiple providers 5 | image: qmcgaw/gluetun:latest 6 | container_name: gluetun 7 | 8 | ports: 9 | # Common VPN Ports 10 | - host: "8888" 11 | container: "8888" 12 | protocol: tcp 13 | - host: "8388" 14 | container: "8388" 15 | protocol: tcp 16 | - host: "8388" 17 | container: "8388" 18 | protocol: udp 19 | # qBittorrent 20 | - host: "8081" 21 | container: "8081" 22 | protocol: tcp 23 | - host: "6881" 24 | container: "6881" 25 | protocol: tcp 26 | - host: "6881" 27 | container: "6881" 28 | protocol: udp 29 | # Prowlarr 30 | - host: "9696" 31 | container: "9696" 32 | protocol: tcp 33 | # FlareSolverr 34 | - host: "8191" 35 | container: "8191" 36 | protocol: tcp 37 | # Sonarr 38 | - host: "8989" 39 | container: "8989" 40 | protocol: tcp 41 | # Radarr 42 | - host: "7878" 43 | container: "7878" 44 | protocol: tcp 45 | # Bazarr 46 | - host: "6767" 47 | container: "6767" 48 | protocol: tcp 49 | # Jellyseerr 50 | - host: "5055" 51 | container: "5055" 52 | protocol: tcp 53 | # Jellyfin 54 | - host: "8096" 55 | container: "8096" 56 | protocol: tcp 57 | - host: "7359" 58 | container: "7359" 59 | protocol: udp 60 | 61 | volumes: 62 | - host: "${ARRPATH}config/gluetun" 63 | container: "/gluetun" 64 | 65 | devices: 66 | - "/dev/net/tun:/dev/net/tun" 67 | 68 | cap_add: 69 | - "NET_ADMIN" 70 | 71 | environment: 72 | - "TZ=${TZ}" 73 | - "VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER}" 74 | - "VPN_TYPE=${VPN_TYPE}" 75 | - "WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY}" 76 | - "WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}" 77 | - "VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING}" 78 | - "WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}" 79 | - "VPN_DNS_ADDRESS=${VPN_DNS_ADDRESS}" 80 | 81 | network: 82 | vpn_mode: 83 | network_mode: "bridge" 84 | bridge_mode: 85 | hostname: gluetun 86 | networks: 87 | - media 88 | 89 | restart: unless-stopped 90 | supports_vpn: false 91 | requires_vpn: false 92 | dependencies: [] 93 | optional: false 94 | -------------------------------------------------------------------------------- /internal/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | "golang.org/x/text/language" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | //go:embed locales/*.yaml 13 | var LocaleFS embed.FS 14 | 15 | // I18n handles internationalization 16 | type I18n struct { 17 | bundle *i18n.Bundle 18 | localizer *i18n.Localizer 19 | language string 20 | } 21 | 22 | // New creates a new I18n instance 23 | func New(lang string) (*I18n, error) { 24 | bundle := i18n.NewBundle(language.English) 25 | bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) 26 | 27 | // Load all supported languages 28 | supportedLanguages := []string{"en", "pt-br", "es"} 29 | for _, locale := range supportedLanguages { 30 | data, err := LocaleFS.ReadFile(fmt.Sprintf("locales/%s.yaml", locale)) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to read locale file %s: %w", locale, err) 33 | } 34 | 35 | if _, err := bundle.ParseMessageFileBytes(data, fmt.Sprintf("%s.yaml", locale)); err != nil { 36 | return nil, fmt.Errorf("failed to parse locale file %s: %w", locale, err) 37 | } 38 | } 39 | 40 | localizer := i18n.NewLocalizer(bundle, lang) 41 | 42 | return &I18n{ 43 | bundle: bundle, 44 | localizer: localizer, 45 | language: lang, 46 | }, nil 47 | } 48 | 49 | // T translates a message key 50 | func (i *I18n) T(key string, data ...map[string]interface{}) string { 51 | config := &i18n.LocalizeConfig{ 52 | MessageID: key, 53 | } 54 | 55 | if len(data) > 0 { 56 | config.TemplateData = data[0] 57 | } 58 | 59 | msg, err := i.localizer.Localize(config) 60 | if err != nil { 61 | // Fallback to key if translation doesn't exist 62 | return key 63 | } 64 | return msg 65 | } 66 | 67 | // Tf translates a message key with formatting (printf style) 68 | func (i *I18n) Tf(key string, args ...interface{}) string { 69 | msg := i.T(key) 70 | if len(args) > 0 { 71 | return fmt.Sprintf(msg, args...) 72 | } 73 | return msg 74 | } 75 | 76 | // GetLanguage returns the current language code 77 | func (i *I18n) GetLanguage() string { 78 | return i.language 79 | } 80 | 81 | // GetLanguageName returns the full language name 82 | func (i *I18n) GetLanguageName() string { 83 | for _, lang := range SupportedLanguages { 84 | if lang.Code == i.language { 85 | return lang.Name 86 | } 87 | } 88 | return i.language 89 | } 90 | -------------------------------------------------------------------------------- /internal/generator/network.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | // NetworkConfig holds network configuration for docker-compose 10 | type NetworkConfig struct { 11 | Name string 12 | Driver string 13 | } 14 | 15 | // GetNetworkConfig returns the network configuration based on VPN mode 16 | func GetNetworkConfig(vpnMode bool) *NetworkConfig { 17 | if vpnMode { 18 | // VPN mode doesn't need custom network (uses service:gluetun) 19 | return nil 20 | } 21 | 22 | return &NetworkConfig{ 23 | Name: "media", 24 | Driver: "bridge", 25 | } 26 | } 27 | 28 | // ConfigureServiceNetworking adjusts service networking based on VPN mode 29 | func ConfigureServiceNetworking(service *services.Service, vpnMode bool) error { 30 | // If service requires VPN but VPN is not enabled, error 31 | if service.RequiresVPN && !vpnMode { 32 | return fmt.Errorf("service %s requires VPN but VPN mode is disabled", service.ID) 33 | } 34 | 35 | if vpnMode { 36 | // For VPN mode, services (except Gluetun) use network_mode: "service:gluetun" 37 | // All services can work with VPN except those that require NOT having VPN 38 | if service.Category != services.CategoryVPN { 39 | // Service is compatible if it's not marked as incompatible 40 | if !service.IsCompatibleWithVPN(true) { 41 | return fmt.Errorf("service %s is not compatible with VPN mode", service.ID) 42 | } 43 | } 44 | } else { 45 | // For bridge mode, ensure service has proper network configuration 46 | if len(service.Network.BridgeMode.Networks) == 0 { 47 | return fmt.Errorf("service %s has no bridge network configuration", service.ID) 48 | } 49 | } 50 | 51 | return nil 52 | } 53 | 54 | // ValidateNetworkConfiguration validates network configuration for all services 55 | func ValidateNetworkConfiguration(selectedServices []*services.Service, vpnMode bool) error { 56 | for _, service := range selectedServices { 57 | if err := ConfigureServiceNetworking(service, vpnMode); err != nil { 58 | return err 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | // GetExposedPorts returns all exposed ports for VPN mode 65 | // In VPN mode, all service ports must be exposed through Gluetun 66 | func GetExposedPorts(selectedServices []*services.Service, vpnMode bool) []services.PortMapping { 67 | if !vpnMode { 68 | return nil // In bridge mode, each service exposes its own ports 69 | } 70 | 71 | var ports []services.PortMapping 72 | for _, service := range selectedServices { 73 | // Skip Gluetun itself 74 | if service.Category == services.CategoryVPN { 75 | continue 76 | } 77 | 78 | // Add all service ports 79 | if service.Ports != nil { 80 | ports = append(ports, service.Ports...) 81 | } 82 | } 83 | 84 | return ports 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v6 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version: '1.24.2' 22 | cache-dependency-path: go.sum 23 | 24 | - name: Verify dependencies 25 | run: go mod verify 26 | 27 | - name: Run go vet 28 | run: go vet ./... 29 | 30 | - name: Run tests 31 | run: go test -v -race -coverprofile=coverage.out ./... 32 | 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v5 35 | with: 36 | files: ./coverage.out 37 | flags: unittests 38 | name: codecov-umbrella 39 | fail_ci_if_error: false 40 | 41 | build: 42 | name: Build 43 | runs-on: ubuntu-latest 44 | needs: test 45 | 46 | strategy: 47 | matrix: 48 | goos: [linux, darwin, windows] 49 | goarch: [amd64, arm64] 50 | exclude: 51 | # Exclude windows/arm64 (not commonly used) 52 | - goos: windows 53 | goarch: arm64 54 | 55 | steps: 56 | - name: Checkout code 57 | uses: actions/checkout@v6 58 | 59 | - name: Set up Go 60 | uses: actions/setup-go@v6 61 | with: 62 | go-version: '1.24.2' 63 | cache-dependency-path: go.sum 64 | 65 | - name: Build binary 66 | env: 67 | GOOS: ${{ matrix.goos }} 68 | GOARCH: ${{ matrix.goarch }} 69 | run: | 70 | BINARY_NAME="corsarr-${{ matrix.goos }}-${{ matrix.goarch }}" 71 | if [ "${{ matrix.goos }}" = "windows" ]; then 72 | BINARY_NAME="${BINARY_NAME}.exe" 73 | fi 74 | go build -v -ldflags="-s -w" -o "$BINARY_NAME" . 75 | ls -lh "$BINARY_NAME" 76 | 77 | - name: Upload artifact 78 | uses: actions/upload-artifact@v5 79 | with: 80 | name: corsarr-${{ matrix.goos }}-${{ matrix.goarch }} 81 | path: corsarr-* 82 | retention-days: 7 83 | 84 | lint: 85 | name: Lint 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - name: Checkout code 90 | uses: actions/checkout@v6 91 | 92 | - name: Set up Go 93 | uses: actions/setup-go@v6 94 | with: 95 | go-version: '1.24.2' 96 | cache-dependency-path: go.sum 97 | 98 | - name: Run golangci-lint 99 | uses: golangci/golangci-lint-action@v4 100 | with: 101 | version: latest 102 | working-directory: ./ 103 | args: --timeout=5m 104 | -------------------------------------------------------------------------------- /internal/services/services.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | // PortMapping represents a port mapping between host and container 4 | type PortMapping struct { 5 | Host string `yaml:"host"` 6 | Container string `yaml:"container"` 7 | Protocol string `yaml:"protocol"` // tcp, udp 8 | } 9 | 10 | // VolumeMapping represents a volume mapping between host and container 11 | type VolumeMapping struct { 12 | Host string `yaml:"host"` 13 | Container string `yaml:"container"` 14 | ReadOnly bool `yaml:"read_only,omitempty"` 15 | } 16 | 17 | // NetworkConfig represents network configuration for different modes 18 | type NetworkConfig struct { 19 | VPNMode VPNModeConfig `yaml:"vpn_mode"` 20 | BridgeMode BridgeModeConfig `yaml:"bridge_mode"` 21 | } 22 | 23 | // VPNModeConfig represents network configuration for VPN mode 24 | type VPNModeConfig struct { 25 | NetworkMode string `yaml:"network_mode"` 26 | } 27 | 28 | // BridgeModeConfig represents network configuration for bridge mode 29 | type BridgeModeConfig struct { 30 | Hostname string `yaml:"hostname"` 31 | Networks []string `yaml:"networks"` 32 | } 33 | 34 | // Service represents a Docker service configuration 35 | type Service struct { 36 | ID string `yaml:"id"` 37 | Name string `yaml:"name"` 38 | Category ServiceCategory `yaml:"category"` 39 | Description string `yaml:"description"` 40 | Image string `yaml:"image"` 41 | ContainerName string `yaml:"container_name"` 42 | Ports []PortMapping `yaml:"ports,omitempty"` 43 | Volumes []VolumeMapping `yaml:"volumes"` 44 | Environment []string `yaml:"environment,omitempty"` 45 | Devices []string `yaml:"devices,omitempty"` 46 | CapAdd []string `yaml:"cap_add,omitempty"` 47 | Network NetworkConfig `yaml:"network"` 48 | Restart string `yaml:"restart"` 49 | SupportsVPN bool `yaml:"supports_vpn"` 50 | RequiresVPN bool `yaml:"requires_vpn"` 51 | Dependencies []string `yaml:"dependencies,omitempty"` 52 | Optional bool `yaml:"optional"` 53 | } 54 | 55 | // GetTranslationKey returns the i18n key for the service 56 | func (s *Service) GetTranslationKey() string { 57 | return "services." + s.ID 58 | } 59 | 60 | // GetNameKey returns the i18n key for the service name 61 | func (s *Service) GetNameKey() string { 62 | return s.GetTranslationKey() + ".name" 63 | } 64 | 65 | // GetDescriptionKey returns the i18n key for the service description 66 | func (s *Service) GetDescriptionKey() string { 67 | return "services_" + s.ID + "_description" 68 | } 69 | 70 | // IsCompatibleWithVPN checks if service can run with VPN 71 | func (s *Service) IsCompatibleWithVPN(vpnEnabled bool) bool { 72 | if s.RequiresVPN { 73 | return vpnEnabled 74 | } 75 | return true 76 | } 77 | 78 | // HasDependencies checks if service has dependencies 79 | func (s *Service) HasDependencies() bool { 80 | return len(s.Dependencies) > 0 81 | } 82 | -------------------------------------------------------------------------------- /internal/validator/docker_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCompareVersions(t *testing.T) { 8 | tests := []struct { 9 | v1 string 10 | v2 string 11 | expected int 12 | }{ 13 | {"1.0.0", "1.0.0", 0}, 14 | {"1.0.0", "1.0.1", -1}, 15 | {"1.0.1", "1.0.0", 1}, 16 | {"2.0.0", "1.9.9", 1}, 17 | {"1.9.9", "2.0.0", -1}, 18 | {"20.10.0", "19.03.0", 1}, 19 | {"2.23.0", "2.0.0", 1}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.v1+"_vs_"+tt.v2, func(t *testing.T) { 24 | result := compareVersions(tt.v1, tt.v2) 25 | if result != tt.expected { 26 | t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, result, tt.expected) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestParseVersion(t *testing.T) { 33 | tests := []struct { 34 | version string 35 | expected [3]int 36 | }{ 37 | {"1.0.0", [3]int{1, 0, 0}}, 38 | {"20.10.5", [3]int{20, 10, 5}}, 39 | {"2.23.0-rc1", [3]int{2, 23, 0}}, 40 | {"1.2", [3]int{1, 2, 0}}, 41 | {"5", [3]int{5, 0, 0}}, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.version, func(t *testing.T) { 46 | result := parseVersion(tt.version) 47 | if result != tt.expected { 48 | t.Errorf("parseVersion(%q) = %v, want %v", tt.version, result, tt.expected) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestDockerValidator_isDockerVersionValid(t *testing.T) { 55 | dv := NewDockerValidator() 56 | 57 | tests := []struct { 58 | version string 59 | valid bool 60 | }{ 61 | {"20.10.0", true}, 62 | {"20.10.5", true}, 63 | {"24.0.7", true}, 64 | {"19.03.0", false}, 65 | {"18.09.0", false}, 66 | {"unknown", true}, // Assume valid if unknown 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.version, func(t *testing.T) { 71 | result := dv.isDockerVersionValid(tt.version) 72 | if result != tt.valid { 73 | t.Errorf("isDockerVersionValid(%q) = %v, want %v", tt.version, result, tt.valid) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestDockerValidator_isComposeVersionValid(t *testing.T) { 80 | dv := NewDockerValidator() 81 | 82 | tests := []struct { 83 | version string 84 | valid bool 85 | }{ 86 | {"2.0.0", true}, 87 | {"2.23.0", true}, 88 | {"1.29.2", false}, 89 | {"1.27.0", false}, 90 | {"unknown", true}, // Assume valid if unknown 91 | } 92 | 93 | for _, tt := range tests { 94 | t.Run(tt.version, func(t *testing.T) { 95 | result := dv.isComposeVersionValid(tt.version) 96 | if result != tt.valid { 97 | t.Errorf("isComposeVersionValid(%q) = %v, want %v", tt.version, result, tt.valid) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestGetDockerInfo(t *testing.T) { 104 | info := GetDockerInfo() 105 | 106 | requiredKeys := []string{ 107 | "docker_installed", 108 | "docker_version", 109 | "docker_running", 110 | "compose_installed", 111 | "compose_version", 112 | } 113 | 114 | for _, key := range requiredKeys { 115 | if _, ok := info[key]; !ok { 116 | t.Errorf("Missing key in Docker info: %s", key) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/services/services_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestService_GetTranslationKey(t *testing.T) { 8 | service := &Service{ 9 | ID: "radarr", 10 | Name: "Radarr", 11 | } 12 | 13 | expected := "services.radarr" 14 | result := service.GetTranslationKey() 15 | 16 | if result != expected { 17 | t.Errorf("Expected %s, got %s", expected, result) 18 | } 19 | } 20 | 21 | func TestService_GetNameKey(t *testing.T) { 22 | service := &Service{ 23 | ID: "sonarr", 24 | Name: "Sonarr", 25 | } 26 | 27 | expected := "services.sonarr.name" 28 | result := service.GetNameKey() 29 | 30 | if result != expected { 31 | t.Errorf("Expected %s, got %s", expected, result) 32 | } 33 | } 34 | 35 | func TestService_GetDescriptionKey(t *testing.T) { 36 | service := &Service{ 37 | ID: "prowlarr", 38 | Name: "Prowlarr", 39 | } 40 | 41 | expected := "services_prowlarr_description" 42 | result := service.GetDescriptionKey() 43 | 44 | if result != expected { 45 | t.Errorf("Expected %s, got %s", expected, result) 46 | } 47 | } 48 | 49 | func TestService_IsCompatibleWithVPN(t *testing.T) { 50 | tests := []struct { 51 | name string 52 | service *Service 53 | vpnEnabled bool 54 | expected bool 55 | }{ 56 | { 57 | name: "Service requires VPN and VPN is enabled", 58 | service: &Service{ 59 | ID: "test1", 60 | RequiresVPN: true, 61 | }, 62 | vpnEnabled: true, 63 | expected: true, 64 | }, 65 | { 66 | name: "Service requires VPN but VPN is disabled", 67 | service: &Service{ 68 | ID: "test2", 69 | RequiresVPN: true, 70 | }, 71 | vpnEnabled: false, 72 | expected: false, 73 | }, 74 | { 75 | name: "Service supports VPN and VPN is enabled", 76 | service: &Service{ 77 | ID: "test3", 78 | RequiresVPN: false, 79 | SupportsVPN: true, 80 | }, 81 | vpnEnabled: true, 82 | expected: true, 83 | }, 84 | { 85 | name: "Service supports VPN but VPN is disabled", 86 | service: &Service{ 87 | ID: "test4", 88 | RequiresVPN: false, 89 | SupportsVPN: true, 90 | }, 91 | vpnEnabled: false, 92 | expected: true, 93 | }, 94 | } 95 | 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | result := tt.service.IsCompatibleWithVPN(tt.vpnEnabled) 99 | if result != tt.expected { 100 | t.Errorf("Expected %v, got %v", tt.expected, result) 101 | } 102 | }) 103 | } 104 | } 105 | 106 | func TestService_HasDependencies(t *testing.T) { 107 | tests := []struct { 108 | name string 109 | service *Service 110 | expected bool 111 | }{ 112 | { 113 | name: "Service with dependencies", 114 | service: &Service{ 115 | ID: "sonarr", 116 | Dependencies: []string{"qbittorrent", "prowlarr"}, 117 | }, 118 | expected: true, 119 | }, 120 | { 121 | name: "Service without dependencies", 122 | service: &Service{ 123 | ID: "jellyfin", 124 | Dependencies: []string{}, 125 | }, 126 | expected: false, 127 | }, 128 | { 129 | name: "Service with nil dependencies", 130 | service: &Service{ 131 | ID: "bazarr", 132 | Dependencies: nil, 133 | }, 134 | expected: false, 135 | }, 136 | } 137 | 138 | for _, tt := range tests { 139 | t.Run(tt.name, func(t *testing.T) { 140 | result := tt.service.HasDependencies() 141 | if result != tt.expected { 142 | t.Errorf("Expected %v, got %v", tt.expected, result) 143 | } 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/validator/validator_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | func TestValidationError(t *testing.T) { 10 | err := ValidationError{ 11 | Field: "test_field", 12 | Message: "test message", 13 | Severity: SeverityError, 14 | } 15 | 16 | expected := "[ERROR] test_field: test message" 17 | if err.Error() != expected { 18 | t.Errorf("Expected %q, got %q", expected, err.Error()) 19 | } 20 | } 21 | 22 | func TestValidationResult_AddError(t *testing.T) { 23 | result := &ValidationResult{Valid: true} 24 | 25 | // Add a warning - should not invalidate 26 | result.AddError("field1", "warning message", SeverityWarning) 27 | if !result.Valid { 28 | t.Error("Adding warning should not invalidate result") 29 | } 30 | if len(result.Warnings) != 1 { 31 | t.Errorf("Expected 1 warning, got %d", len(result.Warnings)) 32 | } 33 | 34 | // Add an error - should invalidate 35 | result.AddError("field2", "error message", SeverityError) 36 | if result.Valid { 37 | t.Error("Adding error should invalidate result") 38 | } 39 | if len(result.Errors) != 1 { 40 | t.Errorf("Expected 1 error, got %d", len(result.Errors)) 41 | } 42 | } 43 | 44 | func TestValidationResult_HasErrors(t *testing.T) { 45 | result := &ValidationResult{Valid: true} 46 | 47 | if result.HasErrors() { 48 | t.Error("New result should not have errors") 49 | } 50 | 51 | result.AddError("field", "message", SeverityWarning) 52 | if result.HasErrors() { 53 | t.Error("Warnings should not count as errors") 54 | } 55 | 56 | result.AddError("field", "message", SeverityError) 57 | if !result.HasErrors() { 58 | t.Error("Result should have errors") 59 | } 60 | } 61 | 62 | func TestNewConfig(t *testing.T) { 63 | registry, err := services.NewRegistry() 64 | if err != nil { 65 | t.Fatalf("Failed to create registry: %v", err) 66 | } 67 | 68 | tests := []struct { 69 | name string 70 | serviceIDs []string 71 | basePath string 72 | outputDir string 73 | vpnEnabled bool 74 | expectError bool 75 | }{ 76 | { 77 | name: "Valid config", 78 | serviceIDs: []string{"sonarr", "radarr"}, 79 | basePath: "/data", 80 | outputDir: "/output", 81 | vpnEnabled: false, 82 | expectError: false, 83 | }, 84 | { 85 | name: "Invalid service ID", 86 | serviceIDs: []string{"nonexistent"}, 87 | basePath: "/data", 88 | outputDir: "/output", 89 | vpnEnabled: false, 90 | expectError: true, 91 | }, 92 | } 93 | 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | config, err := NewConfig(registry, tt.serviceIDs, tt.basePath, tt.outputDir, tt.vpnEnabled) 97 | 98 | if tt.expectError { 99 | if err == nil { 100 | t.Error("Expected error but got none") 101 | } 102 | } else { 103 | if err != nil { 104 | t.Errorf("Unexpected error: %v", err) 105 | } 106 | if config == nil { 107 | t.Error("Config should not be nil") 108 | } 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func TestSeverityString(t *testing.T) { 115 | tests := []struct { 116 | severity Severity 117 | expected string 118 | }{ 119 | {SeverityWarning, "WARNING"}, 120 | {SeverityError, "ERROR"}, 121 | {SeverityCritical, "CRITICAL"}, 122 | } 123 | 124 | for _, tt := range tests { 125 | t.Run(tt.expected, func(t *testing.T) { 126 | if tt.severity.String() != tt.expected { 127 | t.Errorf("Expected %q, got %q", tt.expected, tt.severity.String()) 128 | } 129 | }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /docs/TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # 🆘 Troubleshooting Guide 2 | 3 | Use this guide to diagnose common issues when running Corsarr-generated stacks. 4 | 5 | ## Service Can't Access Files 6 | 7 | **Problem**: Permission denied errors 8 | 9 | ```bash 10 | # Fix ownership 11 | sudo chown -R 1000:1000 /path/to/media 12 | 13 | # Verify PUID/PGID match 14 | id $(whoami) 15 | ``` 16 | 17 | ## Port Already in Use 18 | 19 | **Problem**: Port conflict errors (e.g., "address already in use") 20 | 21 | ```bash 22 | # Check which ports are in use 23 | corsarr check-ports --suggest 24 | 25 | # Check specific port 26 | sudo lsof -i :8080 27 | 28 | # Or check all Docker ports 29 | docker ps --format "table {{.Names}}\t{{.Ports}}" 30 | ``` 31 | 32 | **Common conflicts**: 33 | - **Port 1900** - DLNA/UPnP service (minidlna, Jellyfin host) 34 | - Solution: Stop the conflicting service or remove from docker-compose.yml 35 | - **Port 8080/8081** - Web services (qBittorrent, other apps) 36 | - Solution: Change port in docker-compose.yml 37 | 38 | ## VPN Not Working 39 | 40 | **Problem**: Gluetun keeps restarting 41 | 42 | ```bash 43 | # Check Gluetun logs 44 | docker compose logs gluetun 45 | 46 | # Test VPN connection 47 | docker exec gluetun curl ifconfig.me 48 | ``` 49 | 50 | **Common fixes**: 51 | - Verify VPN credentials in `.env` 52 | - Regenerate WireGuard keys from provider 53 | - Check provider is supported by Gluetun 54 | - **Verify NET_ADMIN capability**: Ensure `cap_add: - NET_ADMIN` is present under the Gluetun service 55 | 56 | ## Container Won't Start 57 | 58 | **Problem**: Service keeps restarting or "EOF" / "can't get final child's PID" errors 59 | 60 | **Common causes**: 61 | 1. **Missing NET_ADMIN for Gluetun**: VPN won't work without this capability. 62 | 2. **Volume permission issues**: Run `sudo chown -R $USER:$USER /path/to/media`. 63 | 3. **Service dependency failed**: Check if a dependent container (e.g., Gluetun) is healthy. 64 | 65 | ```bash 66 | # Check health status 67 | corsarr health --detailed 68 | 69 | # View service logs 70 | docker compose logs [service_name] 71 | 72 | # Check Gluetun specifically (if using VPN) 73 | docker compose logs gluetun | grep -i error 74 | 75 | # Check for errors 76 | docker compose ps 77 | ``` 78 | 79 | ## Can't Connect to Other Services 80 | 81 | **Problem**: Radarr can't reach Prowlarr 82 | 83 | **Solution**: Use container names, not localhost. 84 | - ✅ `http://prowlarr:9696` 85 | - ❌ `http://localhost:9696` 86 | 87 | ## High CPU Usage 88 | 89 | **Problem**: Container using too much CPU 90 | 91 | ```bash 92 | # Check resource usage 93 | docker stats 94 | corsarr health --detailed 95 | ``` 96 | 97 | **Common causes**: 98 | - Jellyfin transcoding (normal during playback) 99 | - Radarr/Sonarr scanning library (temporary) 100 | - qBittorrent seeding (limit in settings) 101 | 102 | ## Database Locked 103 | 104 | **Problem**: "Database is locked" errors 105 | 106 | ```bash 107 | # Stop the affected service 108 | docker compose stop sonarr 109 | 110 | # Backup database 111 | cp config/sonarr/*.db config/sonarr/backup/ 112 | 113 | # Restart service 114 | docker compose start sonarr 115 | ``` 116 | 117 | ## Need More Help? 118 | 119 | **Collect diagnostic information**: 120 | 121 | ```bash 122 | # System information 123 | uname -a 124 | docker --version 125 | docker compose version 126 | 127 | # Health report 128 | corsarr health --detailed > health-report.txt 129 | 130 | # Service logs 131 | docker compose logs --tail=100 > logs.txt 132 | ``` 133 | 134 | **Get help**: 135 | - Check [GitHub Issues](https://github.com/woliveiras/corsarr/issues) 136 | -------------------------------------------------------------------------------- /internal/validator/path.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // PathValidator checks file system paths 10 | type PathValidator struct { 11 | config *Config 12 | } 13 | 14 | // NewPathValidator creates a new path validator 15 | func NewPathValidator(config *Config) *PathValidator { 16 | return &PathValidator{config: config} 17 | } 18 | 19 | // Validate checks paths are valid and accessible 20 | func (pv *PathValidator) Validate() *ValidationResult { 21 | result := &ValidationResult{Valid: true} 22 | 23 | // Check base path (ARRPATH) 24 | if pv.config.BasePath == "" { 25 | result.AddError( 26 | "base_path", 27 | "Base path (ARRPATH) is required", 28 | SeverityError, 29 | ) 30 | } else { 31 | // Check if path exists 32 | if !pathExists(pv.config.BasePath) { 33 | result.AddError( 34 | "base_path", 35 | fmt.Sprintf("Base path does not exist: %s", pv.config.BasePath), 36 | SeverityWarning, 37 | ) 38 | } else { 39 | // Check if path is writable 40 | if !isWritable(pv.config.BasePath) { 41 | result.AddError( 42 | "base_path", 43 | fmt.Sprintf("Base path is not writable: %s", pv.config.BasePath), 44 | SeverityError, 45 | ) 46 | } 47 | 48 | // Check disk space 49 | availableGB := getAvailableDiskSpaceGB(pv.config.BasePath) 50 | if availableGB < 10 { 51 | result.AddError( 52 | "disk_space", 53 | fmt.Sprintf("Low disk space: %.1f GB available (recommended: at least 10 GB)", availableGB), 54 | SeverityWarning, 55 | ) 56 | } 57 | } 58 | } 59 | 60 | // Check output directory 61 | if pv.config.OutputDir == "" { 62 | result.AddError( 63 | "output_dir", 64 | "Output directory is required", 65 | SeverityError, 66 | ) 67 | } else { 68 | // Create output directory if it doesn't exist 69 | if !pathExists(pv.config.OutputDir) { 70 | if err := os.MkdirAll(pv.config.OutputDir, 0755); err != nil { 71 | result.AddError( 72 | "output_dir", 73 | fmt.Sprintf("Cannot create output directory: %s", err), 74 | SeverityError, 75 | ) 76 | } 77 | } else { 78 | // Check if output directory is writable 79 | if !isWritable(pv.config.OutputDir) { 80 | result.AddError( 81 | "output_dir", 82 | fmt.Sprintf("Output directory is not writable: %s", pv.config.OutputDir), 83 | SeverityError, 84 | ) 85 | } 86 | } 87 | } 88 | 89 | return result 90 | } 91 | 92 | // pathExists checks if a path exists 93 | func pathExists(path string) bool { 94 | _, err := os.Stat(path) 95 | return err == nil 96 | } 97 | 98 | // isWritable checks if a path is writable 99 | func isWritable(path string) bool { 100 | // Try to create a temporary file 101 | testFile := filepath.Join(path, ".write_test") 102 | file, err := os.Create(testFile) 103 | if err != nil { 104 | return false 105 | } 106 | _ = file.Close() 107 | _ = os.Remove(testFile) 108 | return true 109 | } 110 | 111 | // ValidatePath performs a quick validation of a single path 112 | func ValidatePath(path string) error { 113 | if path == "" { 114 | return fmt.Errorf("path is empty") 115 | } 116 | 117 | if !pathExists(path) { 118 | return fmt.Errorf("path does not exist: %s", path) 119 | } 120 | 121 | if !isWritable(path) { 122 | return fmt.Errorf("path is not writable: %s", path) 123 | } 124 | 125 | return nil 126 | } 127 | 128 | // EnsurePathExists creates a path if it doesn't exist 129 | func EnsurePathExists(path string) error { 130 | if pathExists(path) { 131 | return nil 132 | } 133 | 134 | return os.MkdirAll(path, 0755) 135 | } 136 | -------------------------------------------------------------------------------- /internal/generator/strategy.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "text/template" 8 | 9 | "github.com/woliveiras/corsarr/internal/services" 10 | ) 11 | 12 | //go:embed templates/docker-compose/*.tmpl 13 | var templatesFS embed.FS 14 | 15 | // ComposeStrategy defines the interface for compose generation strategies 16 | type ComposeStrategy interface { 17 | GenerateCompose(selectedServices []*services.Service) (string, error) 18 | GetTemplatePath() string 19 | } 20 | 21 | // VPNModeStrategy generates compose for VPN mode 22 | type VPNModeStrategy struct{} 23 | 24 | // BridgeModeStrategy generates compose for bridge mode 25 | type BridgeModeStrategy struct{} 26 | 27 | // VPNComposeData holds data for VPN mode template 28 | type VPNComposeData struct { 29 | Services []*services.Service 30 | Gluetun *services.Service 31 | ExposedPorts []services.PortMapping 32 | } 33 | 34 | // BridgeComposeData holds data for bridge mode template 35 | type BridgeComposeData struct { 36 | Services []*services.Service 37 | } 38 | 39 | // NewComposeStrategy creates the appropriate strategy based on VPN mode 40 | func NewComposeStrategy(vpnMode bool) ComposeStrategy { 41 | if vpnMode { 42 | return &VPNModeStrategy{} 43 | } 44 | return &BridgeModeStrategy{} 45 | } 46 | 47 | // GenerateCompose implements ComposeStrategy for VPN mode 48 | func (s *VPNModeStrategy) GenerateCompose(selectedServices []*services.Service) (string, error) { 49 | // Separate Gluetun from other services 50 | var gluetun *services.Service 51 | var otherServices []*services.Service 52 | 53 | for _, svc := range selectedServices { 54 | if svc.Category == services.CategoryVPN { 55 | gluetun = svc 56 | } else { 57 | otherServices = append(otherServices, svc) 58 | } 59 | } 60 | 61 | if gluetun == nil { 62 | return "", fmt.Errorf("gluetun service not found in VPN mode") 63 | } 64 | 65 | // Get exposed ports for Gluetun 66 | exposedPorts := GetExposedPorts(selectedServices, true) 67 | 68 | data := VPNComposeData{ 69 | Services: otherServices, 70 | Gluetun: gluetun, 71 | ExposedPorts: exposedPorts, 72 | } 73 | 74 | return renderTemplate(s.GetTemplatePath(), "compose-vpn", data) 75 | } 76 | 77 | // GetTemplatePath returns the template path for VPN mode 78 | func (s *VPNModeStrategy) GetTemplatePath() string { 79 | return "templates/docker-compose/vpn-mode.tmpl" 80 | } 81 | 82 | // GenerateCompose implements ComposeStrategy for bridge mode 83 | func (s *BridgeModeStrategy) GenerateCompose(selectedServices []*services.Service) (string, error) { 84 | data := BridgeComposeData{ 85 | Services: selectedServices, 86 | } 87 | 88 | return renderTemplate(s.GetTemplatePath(), "compose-bridge", data) 89 | } 90 | 91 | // GetTemplatePath returns the template path for bridge mode 92 | func (s *BridgeModeStrategy) GetTemplatePath() string { 93 | return "templates/docker-compose/bridge-mode.tmpl" 94 | } 95 | 96 | // renderTemplate is a helper function to render templates 97 | func renderTemplate(templatePath, templateName string, data interface{}) (string, error) { 98 | tmplContent, err := templatesFS.ReadFile(templatePath) 99 | if err != nil { 100 | return "", fmt.Errorf("failed to read template %s: %w", templatePath, err) 101 | } 102 | 103 | tmpl, err := template.New(templateName).Parse(string(tmplContent)) 104 | if err != nil { 105 | return "", fmt.Errorf("failed to parse template: %w", err) 106 | } 107 | 108 | var buf bytes.Buffer 109 | if err := tmpl.Execute(&buf, data); err != nil { 110 | return "", fmt.Errorf("failed to execute template: %w", err) 111 | } 112 | 113 | return buf.String(), nil 114 | } 115 | -------------------------------------------------------------------------------- /internal/generator/strategy_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | func TestNewComposeStrategy(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | vpnMode bool 13 | expectType string 14 | }{ 15 | { 16 | name: "VPN mode creates VPNModeStrategy", 17 | vpnMode: true, 18 | expectType: "*generator.VPNModeStrategy", 19 | }, 20 | { 21 | name: "Bridge mode creates BridgeModeStrategy", 22 | vpnMode: false, 23 | expectType: "*generator.BridgeModeStrategy", 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | strategy := NewComposeStrategy(tt.vpnMode) 30 | if strategy == nil { 31 | t.Fatal("Strategy is nil") 32 | } 33 | 34 | // Check template path to verify strategy type 35 | path := strategy.GetTemplatePath() 36 | if tt.vpnMode && path != "templates/docker-compose/vpn-mode.tmpl" { 37 | t.Errorf("Expected VPN template path, got %s", path) 38 | } 39 | if !tt.vpnMode && path != "templates/docker-compose/bridge-mode.tmpl" { 40 | t.Errorf("Expected bridge template path, got %s", path) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestVPNModeStrategy_GenerateCompose(t *testing.T) { 47 | strategy := &VPNModeStrategy{} 48 | 49 | t.Run("Success with Gluetun", func(t *testing.T) { 50 | services := []*services.Service{ 51 | { 52 | ID: "gluetun", 53 | Name: "Gluetun", 54 | Category: services.CategoryVPN, 55 | Image: "qmcgaw/gluetun:latest", 56 | ContainerName: "gluetun", 57 | Restart: "unless-stopped", 58 | }, 59 | { 60 | ID: "radarr", 61 | Name: "Radarr", 62 | Category: services.CategoryMedia, 63 | Image: "lscr.io/linuxserver/radarr:latest", 64 | ContainerName: "radarr", 65 | Restart: "unless-stopped", 66 | Network: services.NetworkConfig{ 67 | VPNMode: services.VPNModeConfig{ 68 | NetworkMode: "service:gluetun", 69 | }, 70 | }, 71 | }, 72 | } 73 | 74 | content, err := strategy.GenerateCompose(services) 75 | if err != nil { 76 | t.Fatalf("Failed to generate: %v", err) 77 | } 78 | 79 | if content == "" { 80 | t.Error("Generated content is empty") 81 | } 82 | }) 83 | 84 | t.Run("Error without Gluetun", func(t *testing.T) { 85 | services := []*services.Service{ 86 | { 87 | ID: "radarr", 88 | Category: services.CategoryMedia, 89 | }, 90 | } 91 | 92 | _, err := strategy.GenerateCompose(services) 93 | if err == nil { 94 | t.Error("Expected error when Gluetun is missing") 95 | } 96 | }) 97 | } 98 | 99 | func TestBridgeModeStrategy_GenerateCompose(t *testing.T) { 100 | strategy := &BridgeModeStrategy{} 101 | 102 | services := []*services.Service{ 103 | { 104 | ID: "radarr", 105 | Name: "Radarr", 106 | Category: services.CategoryMedia, 107 | Image: "lscr.io/linuxserver/radarr:latest", 108 | ContainerName: "radarr", 109 | Restart: "unless-stopped", 110 | Network: services.NetworkConfig{ 111 | BridgeMode: services.BridgeModeConfig{ 112 | Hostname: "radarr", 113 | Networks: []string{"media"}, 114 | }, 115 | }, 116 | Ports: []services.PortMapping{ 117 | {Host: "7878", Container: "7878", Protocol: "tcp"}, 118 | }, 119 | }, 120 | } 121 | 122 | content, err := strategy.GenerateCompose(services) 123 | if err != nil { 124 | t.Fatalf("Failed to generate: %v", err) 125 | } 126 | 127 | if content == "" { 128 | t.Error("Generated content is empty") 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/validator/validator.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | // ValidationError represents a validation failure 10 | type ValidationError struct { 11 | Field string 12 | Message string 13 | Severity Severity 14 | } 15 | 16 | // Severity indicates how critical a validation error is 17 | type Severity int 18 | 19 | const ( 20 | SeverityWarning Severity = iota 21 | SeverityError 22 | SeverityCritical 23 | ) 24 | 25 | func (s Severity) String() string { 26 | switch s { 27 | case SeverityWarning: 28 | return "WARNING" 29 | case SeverityError: 30 | return "ERROR" 31 | case SeverityCritical: 32 | return "CRITICAL" 33 | default: 34 | return "UNKNOWN" 35 | } 36 | } 37 | 38 | func (ve ValidationError) Error() string { 39 | return fmt.Sprintf("[%s] %s: %s", ve.Severity, ve.Field, ve.Message) 40 | } 41 | 42 | // ValidationResult holds the results of validation 43 | type ValidationResult struct { 44 | Valid bool 45 | Errors []ValidationError 46 | Warnings []ValidationError 47 | } 48 | 49 | // AddError adds an error to the validation result 50 | func (vr *ValidationResult) AddError(field, message string, severity Severity) { 51 | err := ValidationError{ 52 | Field: field, 53 | Message: message, 54 | Severity: severity, 55 | } 56 | 57 | if severity == SeverityWarning { 58 | vr.Warnings = append(vr.Warnings, err) 59 | } else { 60 | vr.Errors = append(vr.Errors, err) 61 | vr.Valid = false 62 | } 63 | } 64 | 65 | // HasErrors returns true if there are any errors (not warnings) 66 | func (vr *ValidationResult) HasErrors() bool { 67 | return len(vr.Errors) > 0 68 | } 69 | 70 | // HasWarnings returns true if there are any warnings 71 | func (vr *ValidationResult) HasWarnings() bool { 72 | return len(vr.Warnings) > 0 73 | } 74 | 75 | // Validator interface for all validators 76 | type Validator interface { 77 | Validate() *ValidationResult 78 | } 79 | 80 | // Config holds all validation configuration 81 | type Config struct { 82 | Services []*services.Service 83 | Registry *services.Registry 84 | BasePath string 85 | OutputDir string 86 | VPNEnabled bool 87 | SkipDockerCheck bool 88 | } 89 | 90 | // NewConfig creates a new validation config 91 | func NewConfig(registry *services.Registry, serviceIDs []string, basePath, outputDir string, vpnEnabled bool) (*Config, error) { 92 | selectedServices := make([]*services.Service, 0, len(serviceIDs)) 93 | 94 | for _, id := range serviceIDs { 95 | service, err := registry.GetService(id) 96 | if err != nil { 97 | return nil, fmt.Errorf("service %s not found: %w", id, err) 98 | } 99 | selectedServices = append(selectedServices, service) 100 | } 101 | 102 | return &Config{ 103 | Services: selectedServices, 104 | Registry: registry, 105 | BasePath: basePath, 106 | OutputDir: outputDir, 107 | VPNEnabled: vpnEnabled, 108 | }, nil 109 | } 110 | 111 | // ValidateAll runs all validators and returns combined results 112 | func ValidateAll(config *Config) *ValidationResult { 113 | result := &ValidationResult{Valid: true} 114 | 115 | // Run all validators 116 | validators := []Validator{ 117 | NewPortValidator(config), 118 | NewDependencyValidator(config), 119 | NewPathValidator(config), 120 | } 121 | 122 | // Add Docker validator if not skipped 123 | if !config.SkipDockerCheck { 124 | validators = append(validators, NewDockerValidator()) 125 | } 126 | 127 | // Run each validator and merge results 128 | for _, validator := range validators { 129 | vResult := validator.Validate() 130 | result.Errors = append(result.Errors, vResult.Errors...) 131 | result.Warnings = append(result.Warnings, vResult.Warnings...) 132 | if vResult.HasErrors() { 133 | result.Valid = false 134 | } 135 | } 136 | 137 | return result 138 | } 139 | -------------------------------------------------------------------------------- /internal/validator/port.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | // PortValidator checks for port conflicts 10 | type PortValidator struct { 11 | config *Config 12 | } 13 | 14 | // NewPortValidator creates a new port validator 15 | func NewPortValidator(config *Config) *PortValidator { 16 | return &PortValidator{config: config} 17 | } 18 | 19 | // Validate checks for port conflicts 20 | func (pv *PortValidator) Validate() *ValidationResult { 21 | result := &ValidationResult{Valid: true} 22 | 23 | // Collect all ports from selected services 24 | portMap := make(map[string][]string) // "port/protocol" -> service names 25 | 26 | for _, service := range pv.config.Services { 27 | // Get ports based on VPN mode 28 | var portsWithOwner []struct { 29 | port string 30 | protocol string 31 | owner string 32 | } 33 | 34 | if pv.config.VPNEnabled { 35 | // In VPN mode, only Gluetun exposes ports 36 | if service.ID == "gluetun" { 37 | // Gluetun will expose all other services' ports 38 | for _, s := range pv.config.Services { 39 | if s.ID != "gluetun" && len(s.Ports) > 0 { 40 | for _, portMapping := range s.Ports { 41 | portsWithOwner = append(portsWithOwner, struct { 42 | port string 43 | protocol string 44 | owner string 45 | }{portMapping.Host, portMapping.Protocol, s.Name}) 46 | } 47 | } 48 | } 49 | } 50 | } else { 51 | // Bridge mode - each service exposes its own ports 52 | for _, portMapping := range service.Ports { 53 | portsWithOwner = append(portsWithOwner, struct { 54 | port string 55 | protocol string 56 | owner string 57 | }{portMapping.Host, portMapping.Protocol, service.Name}) 58 | } 59 | } 60 | 61 | // Check each port 62 | for _, item := range portsWithOwner { 63 | // Use "port/protocol" as key to differentiate TCP and UDP on same port 64 | key := fmt.Sprintf("%s/%s", item.port, item.protocol) 65 | portMap[key] = append(portMap[key], item.owner) 66 | } 67 | } 68 | 69 | // Check for conflicts (same port+protocol used by multiple services) 70 | for portKey, serviceNames := range portMap { 71 | if len(serviceNames) > 1 { 72 | result.AddError( 73 | "ports", 74 | fmt.Sprintf("Port %s is used by multiple services: %v", portKey, serviceNames), 75 | SeverityError, 76 | ) 77 | } 78 | 79 | // Extract port number from "port/protocol" key for system check 80 | parts := strings.Split(portKey, "/") 81 | portNum := parts[0] 82 | 83 | // Check if port is already in use on the system 84 | if pv.isPortInUse(portNum) { 85 | result.AddError( 86 | "ports", 87 | fmt.Sprintf("Port %s is already in use on the system", portNum), 88 | SeverityWarning, 89 | ) 90 | } 91 | } 92 | 93 | return result 94 | } 95 | 96 | // isPortInUse checks if a port is currently in use on localhost 97 | func (pv *PortValidator) isPortInUse(port string) bool { 98 | address := fmt.Sprintf("localhost:%s", port) 99 | listener, err := net.Listen("tcp", address) 100 | if err != nil { 101 | // Port is in use 102 | return true 103 | } 104 | defer listener.Close() 105 | return false 106 | } 107 | 108 | // GetPortConflicts returns a map of ports to conflicting services 109 | func GetPortConflicts(config *Config) map[string][]string { 110 | conflicts := make(map[string][]string) 111 | portMap := make(map[string][]string) 112 | 113 | for _, service := range config.Services { 114 | var ports []string 115 | if config.VPNEnabled && service.ID != "gluetun" { 116 | // In VPN mode, services don't expose ports directly 117 | continue 118 | } 119 | 120 | for _, portMapping := range service.Ports { 121 | ports = append(ports, portMapping.Host) 122 | } 123 | 124 | for _, port := range ports { 125 | portMap[port] = append(portMap[port], service.Name) 126 | } 127 | } 128 | 129 | // Find conflicts 130 | for port, serviceNames := range portMap { 131 | if len(serviceNames) > 1 { 132 | conflicts[port] = serviceNames 133 | } 134 | } 135 | 136 | return conflicts 137 | } 138 | -------------------------------------------------------------------------------- /internal/validator/dependency.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // DependencyValidator checks service dependencies 9 | type DependencyValidator struct { 10 | config *Config 11 | } 12 | 13 | // NewDependencyValidator creates a new dependency validator 14 | func NewDependencyValidator(config *Config) *DependencyValidator { 15 | return &DependencyValidator{config: config} 16 | } 17 | 18 | // Validate checks if all service dependencies are satisfied 19 | func (dv *DependencyValidator) Validate() *ValidationResult { 20 | result := &ValidationResult{Valid: true} 21 | 22 | // Build a set of selected service IDs for quick lookup 23 | selectedIDs := make(map[string]bool) 24 | for _, service := range dv.config.Services { 25 | selectedIDs[service.ID] = true 26 | } 27 | 28 | // Check each service's dependencies 29 | for _, service := range dv.config.Services { 30 | for _, depID := range service.Dependencies { 31 | if !selectedIDs[depID] { 32 | // Dependency not selected - get the dependency service name 33 | depService, err := dv.config.Registry.GetService(depID) 34 | depName := depID 35 | if err == nil { 36 | depName = depService.Name 37 | } 38 | 39 | result.AddError( 40 | "dependencies", 41 | fmt.Sprintf("Service '%s' requires '%s' but it is not selected", service.Name, depName), 42 | SeverityError, 43 | ) 44 | } 45 | } 46 | 47 | // Check VPN requirements 48 | if service.RequiresVPN && !dv.config.VPNEnabled { 49 | result.AddError( 50 | "vpn", 51 | fmt.Sprintf("Service '%s' requires VPN but VPN is not enabled", service.Name), 52 | SeverityError, 53 | ) 54 | } 55 | } 56 | 57 | // If VPN is enabled, ensure Gluetun is included 58 | if dv.config.VPNEnabled && !selectedIDs["gluetun"] { 59 | result.AddError( 60 | "vpn", 61 | "VPN mode is enabled but Gluetun service is not included", 62 | SeverityCritical, 63 | ) 64 | } 65 | 66 | return result 67 | } 68 | 69 | // GetMissingDependencies returns a map of services to their missing dependencies 70 | func GetMissingDependencies(config *Config) map[string][]string { 71 | missing := make(map[string][]string) 72 | 73 | selectedIDs := make(map[string]bool) 74 | for _, service := range config.Services { 75 | selectedIDs[service.ID] = true 76 | } 77 | 78 | for _, service := range config.Services { 79 | missingDeps := []string{} 80 | 81 | for _, depID := range service.Dependencies { 82 | if !selectedIDs[depID] { 83 | depService, err := config.Registry.GetService(depID) 84 | depName := depID 85 | if err == nil { 86 | depName = depService.Name 87 | } 88 | missingDeps = append(missingDeps, depName) 89 | } 90 | } 91 | 92 | if len(missingDeps) > 0 { 93 | missing[service.Name] = missingDeps 94 | } 95 | } 96 | 97 | return missing 98 | } 99 | 100 | // SuggestDependencies returns services that should be added to satisfy dependencies 101 | func SuggestDependencies(config *Config) []string { 102 | suggestions := make(map[string]bool) 103 | 104 | selectedIDs := make(map[string]bool) 105 | for _, service := range config.Services { 106 | selectedIDs[service.ID] = true 107 | } 108 | 109 | for _, service := range config.Services { 110 | for _, depID := range service.Dependencies { 111 | if !selectedIDs[depID] { 112 | suggestions[depID] = true 113 | } 114 | } 115 | } 116 | 117 | // Convert map to slice 118 | result := make([]string, 0, len(suggestions)) 119 | for id := range suggestions { 120 | service, err := config.Registry.GetService(id) 121 | if err == nil { 122 | result = append(result, service.Name) 123 | } 124 | } 125 | 126 | return result 127 | } 128 | 129 | // FormatDependencyError creates a user-friendly error message for dependency issues 130 | func FormatDependencyError(serviceName string, missingDeps []string) string { 131 | if len(missingDeps) == 0 { 132 | return "" 133 | } 134 | 135 | if len(missingDeps) == 1 { 136 | return fmt.Sprintf("Service '%s' requires '%s'", serviceName, missingDeps[0]) 137 | } 138 | 139 | return fmt.Sprintf("Service '%s' requires: %s", serviceName, strings.Join(missingDeps, ", ")) 140 | } 141 | -------------------------------------------------------------------------------- /internal/validator/dependency_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | func TestDependencyValidator(t *testing.T) { 10 | registry, err := services.NewRegistry() 11 | if err != nil { 12 | t.Fatalf("Failed to create registry: %v", err) 13 | } 14 | 15 | tests := []struct { 16 | name string 17 | serviceIDs []string 18 | vpnEnabled bool 19 | expectErrors bool 20 | errorCount int 21 | }{ 22 | { 23 | name: "No dependencies", 24 | serviceIDs: []string{"jellyfin"}, 25 | vpnEnabled: false, 26 | expectErrors: false, 27 | errorCount: 0, 28 | }, 29 | { 30 | name: "Missing dependency - Radarr without qBittorrent", 31 | serviceIDs: []string{"radarr"}, // Depends on qbittorrent and prowlarr 32 | vpnEnabled: false, 33 | expectErrors: true, 34 | errorCount: 2, // Missing qbittorrent and prowlarr 35 | }, 36 | { 37 | name: "Missing dependency - Jellyseerr without Jellyfin", 38 | serviceIDs: []string{"jellyseerr"}, // Depends on jellyfin 39 | vpnEnabled: false, 40 | expectErrors: true, 41 | errorCount: 1, // Missing jellyfin 42 | }, 43 | { 44 | name: "VPN enabled without Gluetun", 45 | serviceIDs: []string{"sonarr"}, 46 | vpnEnabled: true, 47 | expectErrors: true, 48 | errorCount: 3, // Missing Gluetun + missing dependencies (qbittorrent, prowlarr) 49 | }, 50 | { 51 | name: "Valid with VPN and all dependencies", 52 | serviceIDs: []string{"gluetun", "qbittorrent", "prowlarr", "radarr"}, 53 | vpnEnabled: true, 54 | expectErrors: false, 55 | errorCount: 0, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | config, err := NewConfig(registry, tt.serviceIDs, "/data", "/output", tt.vpnEnabled) 62 | if err != nil { 63 | t.Fatalf("Failed to create config: %v", err) 64 | } 65 | 66 | validator := NewDependencyValidator(config) 67 | result := validator.Validate() 68 | 69 | if tt.expectErrors && !result.HasErrors() { 70 | t.Error("Expected errors but got none") 71 | } 72 | 73 | if !tt.expectErrors && result.HasErrors() { 74 | t.Errorf("Expected no errors but got %d: %v", len(result.Errors), result.Errors) 75 | } 76 | 77 | if len(result.Errors) != tt.errorCount { 78 | t.Errorf("Expected %d errors, got %d", tt.errorCount, len(result.Errors)) 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestGetMissingDependencies(t *testing.T) { 85 | registry, err := services.NewRegistry() 86 | if err != nil { 87 | t.Fatalf("Failed to create registry: %v", err) 88 | } 89 | 90 | // Radarr depends on qbittorrent and prowlarr 91 | config, err := NewConfig(registry, []string{"radarr"}, "/data", "/output", false) 92 | if err != nil { 93 | t.Fatalf("Failed to create config: %v", err) 94 | } 95 | 96 | missing := GetMissingDependencies(config) 97 | 98 | if len(missing) == 0 { 99 | t.Error("Expected missing dependencies for Radarr") 100 | } 101 | 102 | if deps, ok := missing["Radarr"]; ok { 103 | if len(deps) != 2 { 104 | t.Errorf("Radarr should have 2 missing dependencies (qbittorrent, prowlarr), got %d: %v", len(deps), deps) 105 | } 106 | } else { 107 | t.Error("Radarr not found in missing dependencies map") 108 | } 109 | } 110 | 111 | func TestSuggestDependencies(t *testing.T) { 112 | registry, err := services.NewRegistry() 113 | if err != nil { 114 | t.Fatalf("Failed to create registry: %v", err) 115 | } 116 | 117 | // Jellyseerr depends on jellyfin 118 | config, err := NewConfig(registry, []string{"jellyseerr"}, "/data", "/output", false) 119 | if err != nil { 120 | t.Fatalf("Failed to create config: %v", err) 121 | } 122 | 123 | suggestions := SuggestDependencies(config) 124 | 125 | if len(suggestions) == 0 { 126 | t.Error("Expected dependency suggestions for Jellyseerr") 127 | } 128 | 129 | // Should suggest Jellyfin 130 | found := false 131 | for _, suggestion := range suggestions { 132 | if suggestion == "Jellyfin" { 133 | found = true 134 | break 135 | } 136 | } 137 | 138 | if !found { 139 | t.Errorf("Expected Jellyfin in suggestions, got: %v", suggestions) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /internal/generator/env.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "text/template" 10 | "time" 11 | ) 12 | 13 | //go:embed templates/env.tmpl 14 | var envTemplateFS embed.FS 15 | 16 | // EnvGenerator handles .env file generation 17 | type EnvGenerator struct { 18 | outputDir string 19 | } 20 | 21 | // EnvConfig holds all environment variables 22 | type EnvConfig struct { 23 | ComposeProjectName string 24 | ARRPath string 25 | Timezone string 26 | PUID string 27 | PGID string 28 | UMASK string 29 | VPNConfig *VPNConfig 30 | CustomEnv map[string]string 31 | } 32 | 33 | // VPNConfig holds VPN-specific configuration 34 | type VPNConfig struct { 35 | ServiceProvider string 36 | Type string 37 | WireguardPrivateKey string 38 | WireguardPublicKey string 39 | WireguardAddresses string 40 | ServerCountries string 41 | PortForwarding string 42 | DNSAddress string 43 | } 44 | 45 | // NewEnvGenerator creates a new env generator 46 | func NewEnvGenerator(outputDir string) *EnvGenerator { 47 | return &EnvGenerator{ 48 | outputDir: outputDir, 49 | } 50 | } 51 | 52 | // Generate creates a .env file based on the configuration 53 | func (g *EnvGenerator) Generate(config *EnvConfig, backup bool) error { 54 | // Backup existing file if requested 55 | if backup { 56 | if err := g.backupExistingFile(); err != nil { 57 | return fmt.Errorf("failed to backup existing file: %w", err) 58 | } 59 | } 60 | 61 | // Generate env file 62 | content, err := g.renderTemplate(config) 63 | if err != nil { 64 | return fmt.Errorf("failed to render template: %w", err) 65 | } 66 | 67 | // Write file with secure permissions (0600 - owner read/write only) 68 | outputPath := filepath.Join(g.outputDir, ".env") 69 | if err := os.WriteFile(outputPath, []byte(content), 0600); err != nil { 70 | return fmt.Errorf("failed to write file: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // renderTemplate processes the template with the given data 77 | func (g *EnvGenerator) renderTemplate(config *EnvConfig) (string, error) { 78 | // Read template file 79 | tmplContent, err := envTemplateFS.ReadFile("templates/env.tmpl") 80 | if err != nil { 81 | return "", fmt.Errorf("failed to read template: %w", err) 82 | } 83 | 84 | // Parse template 85 | tmpl, err := template.New("env").Parse(string(tmplContent)) 86 | if err != nil { 87 | return "", fmt.Errorf("failed to parse template: %w", err) 88 | } 89 | 90 | // Execute template 91 | var buf bytes.Buffer 92 | if err := tmpl.Execute(&buf, config); err != nil { 93 | return "", fmt.Errorf("failed to execute template: %w", err) 94 | } 95 | 96 | return buf.String(), nil 97 | } 98 | 99 | // backupExistingFile creates a backup of the existing .env 100 | func (g *EnvGenerator) backupExistingFile() error { 101 | sourcePath := filepath.Join(g.outputDir, ".env") 102 | 103 | // Check if file exists 104 | if _, err := os.Stat(sourcePath); os.IsNotExist(err) { 105 | return nil // No file to backup 106 | } 107 | 108 | // Create backup filename with timestamp 109 | timestamp := time.Now().Format("20060102_150405") 110 | backupPath := filepath.Join(g.outputDir, fmt.Sprintf(".env.backup.%s", timestamp)) 111 | 112 | // Read original file 113 | content, err := os.ReadFile(sourcePath) 114 | if err != nil { 115 | return fmt.Errorf("failed to read original file: %w", err) 116 | } 117 | 118 | // Write backup with secure permissions (0600 - owner read/write only) 119 | if err := os.WriteFile(backupPath, content, 0600); err != nil { 120 | return fmt.Errorf("failed to write backup: %w", err) 121 | } 122 | 123 | return nil 124 | } 125 | 126 | // Preview generates the env content without writing to file 127 | func (g *EnvGenerator) Preview(config *EnvConfig) (string, error) { 128 | return g.renderTemplate(config) 129 | } 130 | 131 | // NewDefaultEnvConfig creates a default environment configuration 132 | func NewDefaultEnvConfig() *EnvConfig { 133 | return &EnvConfig{ 134 | ComposeProjectName: "corsarr", 135 | ARRPath: "/opt/corsarr/", 136 | Timezone: "UTC", 137 | PUID: "1000", 138 | PGID: "1000", 139 | UMASK: "002", 140 | CustomEnv: make(map[string]string), 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/prompts/config.go: -------------------------------------------------------------------------------- 1 | package prompts 2 | 3 | import ( 4 | "github.com/charmbracelet/huh" 5 | "github.com/woliveiras/corsarr/internal/generator" 6 | "github.com/woliveiras/corsarr/internal/i18n" 7 | ) 8 | 9 | // ConfigureVPN prompts for VPN configuration if VPN is enabled 10 | func ConfigureVPN(t *i18n.I18n) (*generator.VPNConfig, error) { 11 | config := &generator.VPNConfig{ 12 | ServiceProvider: "custom", 13 | Type: "wireguard", 14 | PortForwarding: "off", 15 | DNSAddress: "1.1.1.1", 16 | } 17 | 18 | // VPN type selection 19 | form1 := huh.NewForm( 20 | huh.NewGroup( 21 | huh.NewSelect[string](). 22 | Title(t.T("prompts.vpn_type")). 23 | Options( 24 | huh.NewOption("WireGuard", "wireguard"), 25 | huh.NewOption("OpenVPN", "openvpn"), 26 | ). 27 | Value(&config.Type), 28 | ), 29 | ) 30 | 31 | if err := form1.Run(); err != nil { 32 | return nil, err 33 | } 34 | 35 | // Provider and WireGuard config 36 | if config.Type == "wireguard" { 37 | form2 := huh.NewForm( 38 | huh.NewGroup( 39 | huh.NewInput(). 40 | Title(t.T("prompts.vpn_provider")). 41 | Value(&config.ServiceProvider). 42 | Placeholder("custom"), 43 | huh.NewInput(). 44 | Title(t.T("prompts.vpn_wireguard_private_key")). 45 | Value(&config.WireguardPrivateKey). 46 | EchoMode(huh.EchoModePassword), 47 | huh.NewInput(). 48 | Title(t.T("prompts.vpn_wireguard_addresses")). 49 | Value(&config.WireguardAddresses). 50 | Placeholder("10.0.0.2/32"), 51 | huh.NewInput(). 52 | Title(t.T("prompts.vpn_wireguard_public_key")). 53 | Value(&config.WireguardPublicKey). 54 | EchoMode(huh.EchoModePassword). 55 | Placeholder("server public key"), 56 | ), 57 | ) 58 | 59 | if err := form2.Run(); err != nil { 60 | return nil, err 61 | } 62 | } else { 63 | // OpenVPN provider only 64 | form2 := huh.NewForm( 65 | huh.NewGroup( 66 | huh.NewInput(). 67 | Title(t.T("prompts.vpn_provider")). 68 | Value(&config.ServiceProvider). 69 | Placeholder("custom"), 70 | ), 71 | ) 72 | 73 | if err := form2.Run(); err != nil { 74 | return nil, err 75 | } 76 | } 77 | 78 | // Port forwarding and DNS 79 | var enablePortForwarding bool 80 | form3 := huh.NewForm( 81 | huh.NewGroup( 82 | huh.NewConfirm(). 83 | Title(t.T("prompts.vpn_port_forwarding")). 84 | Value(&enablePortForwarding), 85 | huh.NewInput(). 86 | Title(t.T("prompts.vpn_dns")). 87 | Value(&config.DNSAddress). 88 | Placeholder("1.1.1.1"), 89 | ), 90 | ) 91 | 92 | if err := form3.Run(); err != nil { 93 | return nil, err 94 | } 95 | 96 | if enablePortForwarding { 97 | config.PortForwarding = "on" 98 | } else { 99 | config.PortForwarding = "off" 100 | } 101 | 102 | return config, nil 103 | } 104 | 105 | // ConfigureEnvironment prompts for all environment variables 106 | func ConfigureEnvironment(translator *i18n.I18n, vpnEnabled bool) (*generator.EnvConfig, error) { 107 | config := generator.NewDefaultEnvConfig() 108 | 109 | // Project name 110 | projectName, err := AskProjectName(translator, config.ComposeProjectName) 111 | if err != nil { 112 | return nil, err 113 | } 114 | config.ComposeProjectName = projectName 115 | 116 | // Base path 117 | basePath, err := AskBasePath(translator, config.ARRPath) 118 | if err != nil { 119 | return nil, err 120 | } 121 | config.ARRPath = basePath 122 | 123 | // Timezone 124 | tz, err := AskTimezone(translator, config.Timezone) 125 | if err != nil { 126 | return nil, err 127 | } 128 | config.Timezone = tz 129 | 130 | // User IDs 131 | puid, pgid, umask, err := AskUserIDs(translator) 132 | if err != nil { 133 | return nil, err 134 | } 135 | config.PUID = puid 136 | config.PGID = pgid 137 | config.UMASK = umask 138 | 139 | // VPN configuration 140 | if vpnEnabled { 141 | vpnConfig, err := ConfigureVPN(translator) 142 | if err != nil { 143 | return nil, err 144 | } 145 | config.VPNConfig = vpnConfig 146 | } 147 | 148 | return config, nil 149 | } 150 | 151 | // AskProjectName prompts for the compose project name 152 | func AskProjectName(t *i18n.I18n, defaultName string) (string, error) { 153 | var projectName string 154 | 155 | form := huh.NewForm( 156 | huh.NewGroup( 157 | huh.NewInput(). 158 | Title(t.T("prompts.project_name")). 159 | Value(&projectName). 160 | Placeholder(defaultName). 161 | Validate(func(s string) error { 162 | if s == "" { 163 | projectName = defaultName 164 | } 165 | return nil 166 | }), 167 | ), 168 | ) 169 | 170 | if err := form.Run(); err != nil { 171 | return "", err 172 | } 173 | 174 | if projectName == "" { 175 | projectName = defaultName 176 | } 177 | 178 | return projectName, nil 179 | } 180 | -------------------------------------------------------------------------------- /internal/services/registry.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "sort" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | //go:embed templates/services/*.yaml 12 | var servicesFS embed.FS 13 | 14 | // Registry manages all available services 15 | type Registry struct { 16 | services map[string]*Service 17 | byCategory map[ServiceCategory][]*Service 18 | } 19 | 20 | // NewRegistry creates a new service registry 21 | func NewRegistry() (*Registry, error) { 22 | registry := &Registry{ 23 | services: make(map[string]*Service), 24 | byCategory: make(map[ServiceCategory][]*Service), 25 | } 26 | 27 | if err := registry.loadServices(); err != nil { 28 | return nil, err 29 | } 30 | 31 | return registry, nil 32 | } 33 | 34 | // loadServices loads all service definitions from embedded YAML files 35 | func (r *Registry) loadServices() error { 36 | // List of service definition files 37 | serviceFiles := []string{ 38 | "qbittorrent.yaml", 39 | "prowlarr.yaml", 40 | "flaresolverr.yaml", 41 | "sonarr.yaml", 42 | "radarr.yaml", 43 | "lidarr.yaml", 44 | "lazylibrarian.yaml", 45 | "bazarr.yaml", 46 | "jellyfin.yaml", 47 | "jellyseerr.yaml", 48 | "fileflows.yaml", 49 | "gluetun.yaml", 50 | } 51 | 52 | for _, filename := range serviceFiles { 53 | data, err := servicesFS.ReadFile(fmt.Sprintf("templates/services/%s", filename)) 54 | if err != nil { 55 | // Service file might not exist yet, skip 56 | continue 57 | } 58 | 59 | var service Service 60 | if err := yaml.Unmarshal(data, &service); err != nil { 61 | return fmt.Errorf("failed to parse service file %s: %w", filename, err) 62 | } 63 | 64 | r.services[service.ID] = &service 65 | r.byCategory[service.Category] = append(r.byCategory[service.Category], &service) 66 | } 67 | 68 | // Sort services by name within each category 69 | for category := range r.byCategory { 70 | sort.Slice(r.byCategory[category], func(i, j int) bool { 71 | return r.byCategory[category][i].Name < r.byCategory[category][j].Name 72 | }) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // GetService returns a service by ID 79 | func (r *Registry) GetService(id string) (*Service, error) { 80 | service, exists := r.services[id] 81 | if !exists { 82 | return nil, fmt.Errorf("service not found: %s", id) 83 | } 84 | return service, nil 85 | } 86 | 87 | // GetAllServices returns all available services 88 | func (r *Registry) GetAllServices() []*Service { 89 | services := make([]*Service, 0, len(r.services)) 90 | for _, service := range r.services { 91 | services = append(services, service) 92 | } 93 | 94 | // Sort by category and name 95 | sort.Slice(services, func(i, j int) bool { 96 | if services[i].Category == services[j].Category { 97 | return services[i].Name < services[j].Name 98 | } 99 | return services[i].Category < services[j].Category 100 | }) 101 | 102 | return services 103 | } 104 | 105 | // GetServicesByCategory returns all services in a specific category 106 | func (r *Registry) GetServicesByCategory(category ServiceCategory) []*Service { 107 | return r.byCategory[category] 108 | } 109 | 110 | // GetServicesByIDs returns services matching the provided IDs 111 | func (r *Registry) GetServicesByIDs(ids []string) ([]*Service, error) { 112 | services := make([]*Service, 0, len(ids)) 113 | 114 | for _, id := range ids { 115 | service, err := r.GetService(id) 116 | if err != nil { 117 | return nil, err 118 | } 119 | services = append(services, service) 120 | } 121 | 122 | return services, nil 123 | } 124 | 125 | // ValidateDependencies checks if all dependencies are satisfied 126 | func (r *Registry) ValidateDependencies(selectedIDs []string) error { 127 | selectedMap := make(map[string]bool) 128 | for _, id := range selectedIDs { 129 | selectedMap[id] = true 130 | } 131 | 132 | for _, id := range selectedIDs { 133 | service, err := r.GetService(id) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | for _, depID := range service.Dependencies { 139 | if !selectedMap[depID] { 140 | depService, _ := r.GetService(depID) 141 | depName := depID 142 | if depService != nil { 143 | depName = depService.Name 144 | } 145 | return fmt.Errorf("service '%s' requires '%s' but it is not selected", service.Name, depName) 146 | } 147 | } 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // FilterByVPNCompatibility filters services based on VPN mode 154 | func (r *Registry) FilterByVPNCompatibility(vpnEnabled bool) []*Service { 155 | filtered := make([]*Service, 0) 156 | 157 | for _, service := range r.services { 158 | // Skip VPN service itself from the list 159 | if service.Category == CategoryVPN { 160 | continue 161 | } 162 | 163 | if service.IsCompatibleWithVPN(vpnEnabled) { 164 | filtered = append(filtered, service) 165 | } 166 | } 167 | 168 | return filtered 169 | } 170 | 171 | // GetServiceCount returns the total number of services 172 | func (r *Registry) GetServiceCount() int { 173 | return len(r.services) 174 | } 175 | -------------------------------------------------------------------------------- /internal/generator/compose.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/woliveiras/corsarr/internal/services" 10 | ) 11 | 12 | // ComposeGenerator handles docker-compose.yml generation 13 | type ComposeGenerator struct { 14 | registry *services.Registry 15 | strategy ComposeStrategy 16 | outputDir string 17 | } 18 | 19 | // NewComposeGenerator creates a new compose generator 20 | func NewComposeGenerator(registry *services.Registry, outputDir string) *ComposeGenerator { 21 | return &ComposeGenerator{ 22 | registry: registry, 23 | outputDir: outputDir, 24 | } 25 | } 26 | 27 | // SetStrategy sets the compose generation strategy 28 | func (g *ComposeGenerator) SetStrategy(vpnMode bool) { 29 | g.strategy = NewComposeStrategy(vpnMode) 30 | } 31 | 32 | // Generate creates a docker-compose.yml file based on selected services 33 | func (g *ComposeGenerator) Generate(serviceIDs []string, vpnMode bool, backup bool) error { 34 | // Set strategy based on VPN mode 35 | g.SetStrategy(vpnMode) 36 | 37 | // Load and prepare services 38 | selectedServices, err := g.prepareServices(serviceIDs, vpnMode) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // Validate dependencies 44 | if err := g.validateServices(selectedServices); err != nil { 45 | return err 46 | } 47 | 48 | // Backup existing file if requested 49 | if backup { 50 | if err := g.backupExistingFile(); err != nil { 51 | return fmt.Errorf("failed to backup existing file: %w", err) 52 | } 53 | } 54 | 55 | // Generate compose using strategy 56 | content, err := g.strategy.GenerateCompose(selectedServices) 57 | if err != nil { 58 | return fmt.Errorf("failed to generate compose: %w", err) 59 | } 60 | 61 | // Write file 62 | outputPath := filepath.Join(g.outputDir, "docker-compose.yml") 63 | if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil { 64 | return fmt.Errorf("failed to write file: %w", err) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // prepareServices loads services and adds Gluetun if needed 71 | func (g *ComposeGenerator) prepareServices(serviceIDs []string, vpnMode bool) ([]*services.Service, error) { 72 | selectedServices := make([]*services.Service, 0, len(serviceIDs)) 73 | 74 | for _, id := range serviceIDs { 75 | service, err := g.registry.GetService(id) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to get service %s: %w", id, err) 78 | } 79 | selectedServices = append(selectedServices, service) 80 | } 81 | 82 | // Add Gluetun if VPN mode is enabled and not already in the list 83 | if vpnMode { 84 | hasGluetun := false 85 | for _, s := range selectedServices { 86 | if s.ID == "gluetun" { 87 | hasGluetun = true 88 | break 89 | } 90 | } 91 | if !hasGluetun { 92 | gluetun, err := g.registry.GetService("gluetun") 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to get gluetun service: %w", err) 95 | } 96 | // Prepend Gluetun to the list 97 | selectedServices = append([]*services.Service{gluetun}, selectedServices...) 98 | } 99 | } 100 | 101 | return selectedServices, nil 102 | } 103 | 104 | // validateServices validates service dependencies 105 | func (g *ComposeGenerator) validateServices(selectedServices []*services.Service) error { 106 | serviceIDs := make([]string, len(selectedServices)) 107 | for i, s := range selectedServices { 108 | serviceIDs[i] = s.ID 109 | } 110 | 111 | if err := g.registry.ValidateDependencies(serviceIDs); err != nil { 112 | return fmt.Errorf("dependency validation failed: %w", err) 113 | } 114 | 115 | return nil 116 | } 117 | 118 | 119 | 120 | // backupExistingFile creates a backup of the existing docker-compose.yml 121 | func (g *ComposeGenerator) backupExistingFile() error { 122 | sourcePath := filepath.Join(g.outputDir, "docker-compose.yml") 123 | 124 | // Check if file exists 125 | if _, err := os.Stat(sourcePath); os.IsNotExist(err) { 126 | return nil // No file to backup 127 | } 128 | 129 | // Create backup filename with timestamp 130 | timestamp := time.Now().Format("20060102_150405") 131 | backupPath := filepath.Join(g.outputDir, fmt.Sprintf("docker-compose.yml.backup.%s", timestamp)) 132 | 133 | // Read original file 134 | content, err := os.ReadFile(sourcePath) 135 | if err != nil { 136 | return fmt.Errorf("failed to read original file: %w", err) 137 | } 138 | 139 | // Write backup 140 | if err := os.WriteFile(backupPath, content, 0644); err != nil { 141 | return fmt.Errorf("failed to write backup: %w", err) 142 | } 143 | 144 | return nil 145 | } 146 | 147 | // Preview generates the compose content without writing to file 148 | func (g *ComposeGenerator) Preview(serviceIDs []string, vpnMode bool) (string, error) { 149 | // Set strategy based on VPN mode 150 | g.SetStrategy(vpnMode) 151 | 152 | // Load and prepare services 153 | selectedServices, err := g.prepareServices(serviceIDs, vpnMode) 154 | if err != nil { 155 | return "", err 156 | } 157 | 158 | // Generate using strategy 159 | return g.strategy.GenerateCompose(selectedServices) 160 | } 161 | -------------------------------------------------------------------------------- /internal/generator/compose_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/woliveiras/corsarr/internal/services" 10 | ) 11 | 12 | func TestNewComposeGenerator(t *testing.T) { 13 | registry, err := services.NewRegistry() 14 | if err != nil { 15 | t.Fatalf("Failed to create registry: %v", err) 16 | } 17 | 18 | tmpDir := t.TempDir() 19 | generator := NewComposeGenerator(registry, tmpDir) 20 | 21 | if generator == nil { 22 | t.Fatal("Generator is nil") 23 | } 24 | if generator.registry == nil { 25 | t.Fatal("Generator registry is nil") 26 | } 27 | if generator.outputDir != tmpDir { 28 | t.Errorf("Expected outputDir %s, got %s", tmpDir, generator.outputDir) 29 | } 30 | } 31 | 32 | func TestComposeGenerator_Preview(t *testing.T) { 33 | registry, err := services.NewRegistry() 34 | if err != nil { 35 | t.Fatalf("Failed to create registry: %v", err) 36 | } 37 | 38 | tmpDir := t.TempDir() 39 | generator := NewComposeGenerator(registry, tmpDir) 40 | 41 | tests := []struct { 42 | name string 43 | serviceIDs []string 44 | vpnMode bool 45 | expectError bool 46 | checkFor []string 47 | }{ 48 | { 49 | name: "Bridge mode - basic services", 50 | serviceIDs: []string{"qbittorrent", "prowlarr", "radarr"}, 51 | vpnMode: false, 52 | expectError: false, 53 | checkFor: []string{"services:", "qbittorrent:", "prowlarr:", "radarr:", "networks:", "media:"}, 54 | }, 55 | { 56 | name: "VPN mode - includes Gluetun", 57 | serviceIDs: []string{"qbittorrent", "prowlarr"}, 58 | vpnMode: true, 59 | expectError: false, 60 | checkFor: []string{"services:", "gluetun:", "qbittorrent:", "network_mode:"}, 61 | }, 62 | { 63 | name: "Invalid service", 64 | serviceIDs: []string{"nonexistent"}, 65 | vpnMode: false, 66 | expectError: true, 67 | checkFor: nil, 68 | }, 69 | } 70 | 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | content, err := generator.Preview(tt.serviceIDs, tt.vpnMode) 74 | 75 | if tt.expectError { 76 | if err == nil { 77 | t.Error("Expected error but got none") 78 | } 79 | return 80 | } 81 | 82 | if err != nil { 83 | t.Errorf("Unexpected error: %v", err) 84 | return 85 | } 86 | 87 | if content == "" { 88 | t.Error("Generated content is empty") 89 | } 90 | 91 | for _, check := range tt.checkFor { 92 | if !strings.Contains(content, check) { 93 | t.Errorf("Expected content to contain '%s'", check) 94 | } 95 | } 96 | 97 | t.Logf("Generated compose length: %d bytes", len(content)) 98 | }) 99 | } 100 | } 101 | 102 | func TestComposeGenerator_Generate(t *testing.T) { 103 | registry, err := services.NewRegistry() 104 | if err != nil { 105 | t.Fatalf("Failed to create registry: %v", err) 106 | } 107 | 108 | tmpDir := t.TempDir() 109 | generator := NewComposeGenerator(registry, tmpDir) 110 | 111 | t.Run("Generate without backup", func(t *testing.T) { 112 | err := generator.Generate([]string{"jellyfin"}, false, false) 113 | if err != nil { 114 | t.Fatalf("Failed to generate: %v", err) 115 | } 116 | 117 | // Check file exists 118 | outputPath := filepath.Join(tmpDir, "docker-compose.yml") 119 | if _, err := os.Stat(outputPath); os.IsNotExist(err) { 120 | t.Error("docker-compose.yml was not created") 121 | } 122 | 123 | // Read and verify content 124 | content, err := os.ReadFile(outputPath) 125 | if err != nil { 126 | t.Fatalf("Failed to read generated file: %v", err) 127 | } 128 | 129 | if !strings.Contains(string(content), "jellyfin:") { 130 | t.Error("Generated file doesn't contain jellyfin service") 131 | } 132 | }) 133 | 134 | t.Run("Generate with backup", func(t *testing.T) { 135 | // First create an existing file 136 | existingPath := filepath.Join(tmpDir, "docker-compose.yml") 137 | err := os.WriteFile(existingPath, []byte("existing content"), 0644) 138 | if err != nil { 139 | t.Fatalf("Failed to create existing file: %v", err) 140 | } 141 | 142 | // Generate with backup - include all dependencies 143 | err = generator.Generate([]string{"qbittorrent", "prowlarr", "radarr", "sonarr"}, false, true) 144 | if err != nil { 145 | t.Fatalf("Failed to generate with backup: %v", err) 146 | } 147 | 148 | // Check backup was created 149 | files, err := filepath.Glob(filepath.Join(tmpDir, "docker-compose.yml.backup.*")) 150 | if err != nil { 151 | t.Fatalf("Failed to list backup files: %v", err) 152 | } 153 | if len(files) == 0 { 154 | t.Error("No backup file was created") 155 | } 156 | }) 157 | 158 | t.Run("Generate VPN mode adds Gluetun", func(t *testing.T) { 159 | tmpDir2 := t.TempDir() 160 | generator2 := NewComposeGenerator(registry, tmpDir2) 161 | 162 | err := generator2.Generate([]string{"qbittorrent", "prowlarr", "radarr"}, true, false) 163 | if err != nil { 164 | t.Fatalf("Failed to generate: %v", err) 165 | } 166 | 167 | content, err := os.ReadFile(filepath.Join(tmpDir2, "docker-compose.yml")) 168 | if err != nil { 169 | t.Fatalf("Failed to read file: %v", err) 170 | } 171 | 172 | if !strings.Contains(string(content), "gluetun:") { 173 | t.Error("VPN mode should include Gluetun service") 174 | } 175 | }) 176 | } 177 | -------------------------------------------------------------------------------- /internal/prompts/interactive.go: -------------------------------------------------------------------------------- 1 | package prompts 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/huh" 8 | "github.com/woliveiras/corsarr/internal/i18n" 9 | "github.com/woliveiras/corsarr/internal/services" 10 | ) 11 | 12 | // AskVPN prompts the user if they want to use VPN 13 | func AskVPN(t *i18n.I18n) (bool, error) { 14 | var useVPN bool 15 | 16 | form := huh.NewForm( 17 | huh.NewGroup( 18 | huh.NewConfirm(). 19 | Title(t.T("prompts.vpn_question")). 20 | Value(&useVPN), 21 | ), 22 | ) 23 | 24 | if err := form.Run(); err != nil { 25 | return false, err 26 | } 27 | 28 | return useVPN, nil 29 | } 30 | 31 | // SelectServices prompts the user to select which services to use 32 | func SelectServices(t *i18n.I18n, registry *services.Registry, vpnEnabled bool) ([]string, error) { 33 | // Filter services by VPN compatibility 34 | availableServices := registry.FilterByVPNCompatibility(vpnEnabled) 35 | 36 | // Build options 37 | var options []huh.Option[string] 38 | 39 | // Group services by category for better organization 40 | servicesByCategory := make(map[services.ServiceCategory][]*services.Service) 41 | for _, service := range availableServices { 42 | servicesByCategory[service.Category] = append(servicesByCategory[service.Category], service) 43 | } 44 | 45 | // Add services organized by category 46 | for _, category := range services.AllCategories() { 47 | servicesInCategory := servicesByCategory[category] 48 | if len(servicesInCategory) == 0 { 49 | continue 50 | } 51 | 52 | // Add services in this category 53 | for _, service := range servicesInCategory { 54 | // Get translated description 55 | description := t.T(service.GetDescriptionKey()) 56 | displayName := fmt.Sprintf("%s (%s)", service.Name, description) 57 | 58 | if service.RequiresVPN { 59 | displayName += t.T("prompts.requires_vpn_suffix") 60 | } 61 | if len(service.Dependencies) > 0 { 62 | displayName += t.T("prompts.has_dependencies_suffix") 63 | } 64 | 65 | options = append(options, huh.NewOption(displayName, service.ID)) 66 | } 67 | } 68 | 69 | var selectedIDs []string 70 | 71 | form := huh.NewForm( 72 | huh.NewGroup( 73 | huh.NewMultiSelect[string](). 74 | Title(t.T("prompts.service_selection")). 75 | Options(options...). 76 | Value(&selectedIDs). 77 | Height(15), 78 | ), 79 | ) 80 | 81 | if err := form.Run(); err != nil { 82 | return nil, err 83 | } 84 | 85 | if len(selectedIDs) == 0 { 86 | return nil, fmt.Errorf("%s", t.T("errors.no_services_selected")) 87 | } 88 | 89 | return selectedIDs, nil 90 | } 91 | 92 | // AskBasePath prompts for the base path (ARRPATH) 93 | func AskBasePath(t *i18n.I18n, defaultPath string) (string, error) { 94 | var path string 95 | 96 | form := huh.NewForm( 97 | huh.NewGroup( 98 | huh.NewInput(). 99 | Title(t.T("prompts.base_path")). 100 | Value(&path). 101 | Placeholder(defaultPath). 102 | Validate(func(s string) error { 103 | if s == "" { 104 | return fmt.Errorf("path is required") 105 | } 106 | return nil 107 | }), 108 | ), 109 | ) 110 | 111 | if err := form.Run(); err != nil { 112 | return "", err 113 | } 114 | 115 | if path == "" { 116 | path = defaultPath 117 | } 118 | 119 | return path, nil 120 | } 121 | 122 | // AskOutputDirectory prompts for an output directory and optional reuse for volumes. 123 | func AskOutputDirectory(t *i18n.I18n, defaultDir string) (string, bool, bool, error) { 124 | var dir string 125 | useSame := true 126 | 127 | form := huh.NewForm( 128 | huh.NewGroup( 129 | huh.NewInput(). 130 | Title(fmt.Sprintf("%s (default: %s)", t.T("prompts.output_directory"), defaultDir)). 131 | Value(&dir). 132 | Placeholder(defaultDir), 133 | huh.NewConfirm(). 134 | Title(t.T("prompts.use_same_directory")). 135 | Value(&useSame), 136 | ), 137 | ) 138 | 139 | if err := form.Run(); err != nil { 140 | return "", false, false, err 141 | } 142 | 143 | cleaned := strings.TrimSpace(strings.ReplaceAll(dir, "\r", "")) 144 | provided := cleaned != "" 145 | if !provided { 146 | cleaned = defaultDir 147 | } 148 | 149 | return cleaned, useSame, provided, nil 150 | } 151 | 152 | // AskTimezone prompts for timezone 153 | func AskTimezone(t *i18n.I18n, defaultTZ string) (string, error) { 154 | var tz string 155 | 156 | form2 := huh.NewForm( 157 | huh.NewGroup( 158 | huh.NewInput(). 159 | Title(t.T("prompts.timezone")). 160 | Value(&tz). 161 | Placeholder(defaultTZ). 162 | Validate(func(s string) error { 163 | if s == "" { 164 | return fmt.Errorf("timezone is required") 165 | } 166 | return nil 167 | }), 168 | ), 169 | ) 170 | 171 | if err := form2.Run(); err != nil { 172 | return "", err 173 | } 174 | 175 | if tz == "" { 176 | tz = defaultTZ 177 | } 178 | 179 | return tz, nil 180 | } 181 | 182 | // AskUserIDs prompts for PUID, PGID, and UMASK 183 | func AskUserIDs(t *i18n.I18n) (puid, pgid, umask string, err error) { 184 | puid = "1000" 185 | pgid = "1000" 186 | umask = "002" 187 | 188 | form := huh.NewForm( 189 | huh.NewGroup( 190 | huh.NewInput(). 191 | Title(t.T("prompts.puid")). 192 | Value(&puid). 193 | Placeholder("1000"), 194 | huh.NewInput(). 195 | Title(t.T("prompts.pgid")). 196 | Value(&pgid). 197 | Placeholder("1000"), 198 | huh.NewInput(). 199 | Title(t.T("prompts.umask")). 200 | Value(&umask). 201 | Placeholder("002"), 202 | ), 203 | ) 204 | 205 | if err := form.Run(); err != nil { 206 | return "", "", "", err 207 | } 208 | 209 | return puid, pgid, umask, nil 210 | } 211 | 212 | // ConfirmGeneration asks for final confirmation before generating files 213 | func ConfirmGeneration(t *i18n.I18n) (bool, error) { 214 | var confirm bool 215 | 216 | form := huh.NewForm( 217 | huh.NewGroup( 218 | huh.NewConfirm(). 219 | Title(t.T("prompts.confirm_generation")). 220 | Value(&confirm). 221 | Affirmative("Yes"). 222 | Negative("No"), 223 | ), 224 | ) 225 | 226 | if err := form.Run(); err != nil { 227 | return false, err 228 | } 229 | 230 | return confirm, nil 231 | } 232 | -------------------------------------------------------------------------------- /internal/validator/docker.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | // DockerValidator checks Docker and Docker Compose installation 12 | type DockerValidator struct{} 13 | 14 | // NewDockerValidator creates a new Docker validator 15 | func NewDockerValidator() *DockerValidator { 16 | return &DockerValidator{} 17 | } 18 | 19 | // Validate checks Docker installation and version 20 | func (dv *DockerValidator) Validate() *ValidationResult { 21 | result := &ValidationResult{Valid: true} 22 | 23 | // Check if Docker is installed 24 | dockerInstalled, dockerVersion := dv.checkDocker() 25 | if !dockerInstalled { 26 | result.AddError( 27 | "docker", 28 | "Docker is not installed or not in PATH", 29 | SeverityCritical, 30 | ) 31 | } else { 32 | // Check Docker version (require at least 20.10) 33 | if !dv.isDockerVersionValid(dockerVersion) { 34 | result.AddError( 35 | "docker", 36 | fmt.Sprintf("Docker version %s is too old (require 20.10+)", dockerVersion), 37 | SeverityWarning, 38 | ) 39 | } 40 | 41 | // Check if Docker daemon is running 42 | if !dv.isDockerRunning() { 43 | result.AddError( 44 | "docker", 45 | "Docker daemon is not running", 46 | SeverityError, 47 | ) 48 | } 49 | } 50 | 51 | // Check if Docker Compose is installed 52 | composeInstalled, composeVersion := dv.checkDockerCompose() 53 | if !composeInstalled { 54 | result.AddError( 55 | "docker_compose", 56 | "Docker Compose is not installed or not in PATH", 57 | SeverityCritical, 58 | ) 59 | } else { 60 | // Check Compose version (require at least 2.0) 61 | if !dv.isComposeVersionValid(composeVersion) { 62 | result.AddError( 63 | "docker_compose", 64 | fmt.Sprintf("Docker Compose version %s is too old (require 2.0+)", composeVersion), 65 | SeverityWarning, 66 | ) 67 | } 68 | } 69 | 70 | return result 71 | } 72 | 73 | // checkDocker checks if Docker is installed and returns version 74 | func (dv *DockerValidator) checkDocker() (bool, string) { 75 | cmd := exec.Command("docker", "--version") 76 | output, err := cmd.CombinedOutput() 77 | if err != nil { 78 | return false, "" 79 | } 80 | 81 | // Parse version from output: "Docker version 24.0.7, build afdd53b" 82 | versionRegex := regexp.MustCompile(`Docker version ([\d.]+)`) 83 | matches := versionRegex.FindStringSubmatch(string(output)) 84 | if len(matches) > 1 { 85 | return true, matches[1] 86 | } 87 | 88 | return true, "unknown" 89 | } 90 | 91 | // checkDockerCompose checks if Docker Compose is installed and returns version 92 | func (dv *DockerValidator) checkDockerCompose() (bool, string) { 93 | // Try "docker compose" first (modern) 94 | cmd := exec.Command("docker", "compose", "version") 95 | output, err := cmd.CombinedOutput() 96 | if err == nil { 97 | // Parse version: "Docker Compose version v2.23.0" 98 | versionRegex := regexp.MustCompile(`version v?([\d.]+)`) 99 | matches := versionRegex.FindStringSubmatch(string(output)) 100 | if len(matches) > 1 { 101 | return true, matches[1] 102 | } 103 | return true, "unknown" 104 | } 105 | 106 | // Try "docker-compose" (legacy) 107 | cmd = exec.Command("docker-compose", "--version") 108 | output, err = cmd.CombinedOutput() 109 | if err != nil { 110 | return false, "" 111 | } 112 | 113 | // Parse version: "docker-compose version 1.29.2" 114 | versionRegex := regexp.MustCompile(`version ([\d.]+)`) 115 | matches := versionRegex.FindStringSubmatch(string(output)) 116 | if len(matches) > 1 { 117 | return true, matches[1] 118 | } 119 | 120 | return true, "unknown" 121 | } 122 | 123 | // isDockerRunning checks if Docker daemon is running 124 | func (dv *DockerValidator) isDockerRunning() bool { 125 | cmd := exec.Command("docker", "ps") 126 | err := cmd.Run() 127 | return err == nil 128 | } 129 | 130 | // isDockerVersionValid checks if Docker version meets minimum requirements 131 | func (dv *DockerValidator) isDockerVersionValid(version string) bool { 132 | if version == "unknown" { 133 | return true // Assume valid if we can't parse 134 | } 135 | 136 | return compareVersions(version, "20.10.0") >= 0 137 | } 138 | 139 | // isComposeVersionValid checks if Docker Compose version meets minimum requirements 140 | func (dv *DockerValidator) isComposeVersionValid(version string) bool { 141 | if version == "unknown" { 142 | return true // Assume valid if we can't parse 143 | } 144 | 145 | return compareVersions(version, "2.0.0") >= 0 146 | } 147 | 148 | // compareVersions compares two semantic versions 149 | // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 150 | func compareVersions(v1, v2 string) int { 151 | parts1 := parseVersion(v1) 152 | parts2 := parseVersion(v2) 153 | 154 | for i := 0; i < 3; i++ { 155 | if parts1[i] < parts2[i] { 156 | return -1 157 | } 158 | if parts1[i] > parts2[i] { 159 | return 1 160 | } 161 | } 162 | 163 | return 0 164 | } 165 | 166 | // parseVersion parses a version string into [major, minor, patch] 167 | func parseVersion(version string) [3]int { 168 | parts := [3]int{0, 0, 0} 169 | components := strings.Split(version, ".") 170 | 171 | for i := 0; i < len(components) && i < 3; i++ { 172 | // Remove any non-numeric suffix (e.g., "2.23.0-rc1" -> "2.23.0") 173 | numPart := regexp.MustCompile(`^\d+`).FindString(components[i]) 174 | if num, err := strconv.Atoi(numPart); err == nil { 175 | parts[i] = num 176 | } 177 | } 178 | 179 | return parts 180 | } 181 | 182 | // GetDockerInfo returns information about Docker installation 183 | func GetDockerInfo() map[string]string { 184 | info := make(map[string]string) 185 | 186 | dv := NewDockerValidator() 187 | 188 | dockerInstalled, dockerVersion := dv.checkDocker() 189 | info["docker_installed"] = fmt.Sprintf("%v", dockerInstalled) 190 | info["docker_version"] = dockerVersion 191 | info["docker_running"] = fmt.Sprintf("%v", dv.isDockerRunning()) 192 | 193 | composeInstalled, composeVersion := dv.checkDockerCompose() 194 | info["compose_installed"] = fmt.Sprintf("%v", composeInstalled) 195 | info["compose_version"] = composeVersion 196 | 197 | return info 198 | } 199 | -------------------------------------------------------------------------------- /internal/services/registry_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewRegistry(t *testing.T) { 8 | registry, err := NewRegistry() 9 | if err != nil { 10 | t.Fatalf("Failed to create registry: %v", err) 11 | } 12 | 13 | if registry == nil { 14 | t.Fatal("Registry is nil") 15 | } 16 | 17 | count := registry.GetServiceCount() 18 | if count == 0 { 19 | t.Error("Expected services to be loaded, got 0") 20 | } 21 | 22 | t.Logf("✅ Loaded %d services", count) 23 | } 24 | 25 | func TestGetService(t *testing.T) { 26 | registry, err := NewRegistry() 27 | if err != nil { 28 | t.Fatalf("Failed to create registry: %v", err) 29 | } 30 | 31 | tests := []struct { 32 | name string 33 | serviceID string 34 | wantErr bool 35 | }{ 36 | {"Valid service - qbittorrent", "qbittorrent", false}, 37 | {"Valid service - radarr", "radarr", false}, 38 | {"Valid service - sonarr", "sonarr", false}, 39 | {"Invalid service", "nonexistent", true}, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | service, err := registry.GetService(tt.serviceID) 45 | if tt.wantErr { 46 | if err == nil { 47 | t.Errorf("Expected error for service %s, got nil", tt.serviceID) 48 | } 49 | } else { 50 | if err != nil { 51 | t.Errorf("Unexpected error for service %s: %v", tt.serviceID, err) 52 | } 53 | if service == nil { 54 | t.Errorf("Expected service %s to be found", tt.serviceID) 55 | } 56 | if service != nil && service.ID != tt.serviceID { 57 | t.Errorf("Expected service ID %s, got %s", tt.serviceID, service.ID) 58 | } 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestGetServicesByCategory(t *testing.T) { 65 | registry, err := NewRegistry() 66 | if err != nil { 67 | t.Fatalf("Failed to create registry: %v", err) 68 | } 69 | 70 | tests := []struct { 71 | category ServiceCategory 72 | minExpected int 73 | }{ 74 | {CategoryDownload, 1}, // at least qbittorrent 75 | {CategoryIndexer, 1}, // at least prowlarr 76 | {CategoryMedia, 2}, // at least radarr and sonarr 77 | {CategoryStreaming, 1}, // at least jellyfin 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(string(tt.category), func(t *testing.T) { 82 | services := registry.GetServicesByCategory(tt.category) 83 | if len(services) < tt.minExpected { 84 | t.Errorf("Expected at least %d services in category %s, got %d", 85 | tt.minExpected, tt.category, len(services)) 86 | } 87 | t.Logf("Category %s has %d services", tt.category, len(services)) 88 | }) 89 | } 90 | } 91 | 92 | func TestValidateDependencies(t *testing.T) { 93 | registry, err := NewRegistry() 94 | if err != nil { 95 | t.Fatalf("Failed to create registry: %v", err) 96 | } 97 | 98 | tests := []struct { 99 | name string 100 | services []string 101 | shouldPass bool 102 | }{ 103 | { 104 | name: "Valid: Basic stack", 105 | services: []string{"qbittorrent", "prowlarr", "radarr", "sonarr"}, 106 | shouldPass: true, 107 | }, 108 | { 109 | name: "Invalid: Sonarr without dependencies", 110 | services: []string{"sonarr"}, 111 | shouldPass: false, 112 | }, 113 | { 114 | name: "Invalid: Jellyseerr without Jellyfin", 115 | services: []string{"qbittorrent", "prowlarr", "jellyseerr"}, 116 | shouldPass: false, 117 | }, 118 | { 119 | name: "Valid: Complete stack with Jellyfin", 120 | services: []string{"qbittorrent", "prowlarr", "radarr", "sonarr", "jellyfin", "jellyseerr"}, 121 | shouldPass: true, 122 | }, 123 | { 124 | name: "Valid: Just Jellyfin (no dependencies)", 125 | services: []string{"jellyfin"}, 126 | shouldPass: true, 127 | }, 128 | } 129 | 130 | for _, tt := range tests { 131 | t.Run(tt.name, func(t *testing.T) { 132 | err := registry.ValidateDependencies(tt.services) 133 | if tt.shouldPass && err != nil { 134 | t.Errorf("Expected to pass but failed: %v", err) 135 | } else if !tt.shouldPass && err == nil { 136 | t.Errorf("Expected to fail but passed") 137 | } 138 | }) 139 | } 140 | } 141 | 142 | func TestFilterByVPNCompatibility(t *testing.T) { 143 | registry, err := NewRegistry() 144 | if err != nil { 145 | t.Fatalf("Failed to create registry: %v", err) 146 | } 147 | 148 | // Test with VPN enabled 149 | t.Run("VPN enabled", func(t *testing.T) { 150 | services := registry.FilterByVPNCompatibility(true) 151 | if len(services) == 0 { 152 | t.Error("Expected services when VPN is enabled, got 0") 153 | } 154 | 155 | // Check that gluetun is not in the list 156 | for _, service := range services { 157 | if service.Category == CategoryVPN { 158 | t.Error("VPN service should not be in filtered list") 159 | } 160 | } 161 | 162 | t.Logf("VPN enabled: %d compatible services", len(services)) 163 | }) 164 | 165 | // Test with VPN disabled 166 | t.Run("VPN disabled", func(t *testing.T) { 167 | services := registry.FilterByVPNCompatibility(false) 168 | if len(services) == 0 { 169 | t.Error("Expected services when VPN is disabled, got 0") 170 | } 171 | 172 | // Check that no service requires VPN 173 | for _, service := range services { 174 | if service.RequiresVPN { 175 | t.Errorf("Service %s requires VPN but VPN is disabled", service.Name) 176 | } 177 | } 178 | 179 | t.Logf("VPN disabled: %d compatible services", len(services)) 180 | }) 181 | } 182 | 183 | func TestGetAllServices(t *testing.T) { 184 | registry, err := NewRegistry() 185 | if err != nil { 186 | t.Fatalf("Failed to create registry: %v", err) 187 | } 188 | 189 | services := registry.GetAllServices() 190 | if len(services) == 0 { 191 | t.Error("Expected services, got 0") 192 | } 193 | 194 | // Verify all services have required fields 195 | for _, service := range services { 196 | if service.ID == "" { 197 | t.Error("Service has empty ID") 198 | } 199 | if service.Name == "" { 200 | t.Errorf("Service %s has empty Name", service.ID) 201 | } 202 | if service.Image == "" { 203 | t.Errorf("Service %s has empty Image", service.ID) 204 | } 205 | if service.ContainerName == "" { 206 | t.Errorf("Service %s has empty ContainerName", service.ID) 207 | } 208 | } 209 | 210 | t.Logf("✅ All %d services are valid", len(services)) 211 | } 212 | -------------------------------------------------------------------------------- /internal/generator/env_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestNewEnvGenerator(t *testing.T) { 11 | tmpDir := t.TempDir() 12 | generator := NewEnvGenerator(tmpDir) 13 | 14 | if generator == nil { 15 | t.Fatal("Generator is nil") 16 | } 17 | if generator.outputDir != tmpDir { 18 | t.Errorf("Expected outputDir %s, got %s", tmpDir, generator.outputDir) 19 | } 20 | } 21 | 22 | func TestNewDefaultEnvConfig(t *testing.T) { 23 | config := NewDefaultEnvConfig() 24 | 25 | if config == nil { 26 | t.Fatal("Config is nil") 27 | } 28 | 29 | tests := []struct { 30 | name string 31 | value string 32 | expected string 33 | }{ 34 | {"ComposeProjectName", config.ComposeProjectName, "corsarr"}, 35 | {"ARRPath", config.ARRPath, "/opt/corsarr/"}, 36 | {"Timezone", config.Timezone, "UTC"}, 37 | {"PUID", config.PUID, "1000"}, 38 | {"PGID", config.PGID, "1000"}, 39 | {"UMASK", config.UMASK, "002"}, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | if tt.value != tt.expected { 45 | t.Errorf("Expected %s to be %s, got %s", tt.name, tt.expected, tt.value) 46 | } 47 | }) 48 | } 49 | 50 | if config.CustomEnv == nil { 51 | t.Error("CustomEnv should be initialized") 52 | } 53 | } 54 | 55 | func TestEnvGenerator_Preview(t *testing.T) { 56 | tmpDir := t.TempDir() 57 | generator := NewEnvGenerator(tmpDir) 58 | 59 | tests := []struct { 60 | name string 61 | config *EnvConfig 62 | checkFor []string 63 | }{ 64 | { 65 | name: "Basic config without VPN", 66 | config: NewDefaultEnvConfig(), 67 | checkFor: []string{"COMPOSE_PROJECT_NAME=", "ARRPATH=", "TZ=", "PUID=", "PGID=", "UMASK="}, 68 | }, 69 | { 70 | name: "Config with VPN", 71 | config: &EnvConfig{ 72 | ComposeProjectName: "test", 73 | ARRPath: "/test/", 74 | Timezone: "America/New_York", 75 | PUID: "1001", 76 | PGID: "1001", 77 | UMASK: "022", 78 | VPNConfig: &VPNConfig{ 79 | ServiceProvider: "mullvad", 80 | Type: "wireguard", 81 | DNSAddress: "1.1.1.1", 82 | }, 83 | }, 84 | checkFor: []string{ 85 | "COMPOSE_PROJECT_NAME=test", 86 | "TZ=America/New_York", 87 | "VPN_SERVICE_PROVIDER=mullvad", 88 | "VPN_TYPE=wireguard", 89 | "VPN_DNS_ADDRESS=1.1.1.1", 90 | }, 91 | }, 92 | { 93 | name: "Config with custom env vars", 94 | config: &EnvConfig{ 95 | ComposeProjectName: "custom", 96 | ARRPath: "/custom/", 97 | Timezone: "UTC", 98 | PUID: "1000", 99 | PGID: "1000", 100 | UMASK: "002", 101 | CustomEnv: map[string]string{ 102 | "CUSTOM_VAR1": "value1", 103 | "CUSTOM_VAR2": "value2", 104 | }, 105 | }, 106 | checkFor: []string{ 107 | "COMPOSE_PROJECT_NAME=custom", 108 | "CUSTOM_VAR1=value1", 109 | "CUSTOM_VAR2=value2", 110 | }, 111 | }, 112 | } 113 | 114 | for _, tt := range tests { 115 | t.Run(tt.name, func(t *testing.T) { 116 | content, err := generator.Preview(tt.config) 117 | if err != nil { 118 | t.Fatalf("Preview failed: %v", err) 119 | } 120 | 121 | if content == "" { 122 | t.Error("Generated content is empty") 123 | } 124 | 125 | for _, check := range tt.checkFor { 126 | if !strings.Contains(content, check) { 127 | t.Errorf("Expected content to contain '%s'", check) 128 | } 129 | } 130 | 131 | t.Logf("Generated env length: %d bytes", len(content)) 132 | }) 133 | } 134 | } 135 | 136 | func TestEnvGenerator_Generate(t *testing.T) { 137 | tmpDir := t.TempDir() 138 | generator := NewEnvGenerator(tmpDir) 139 | 140 | t.Run("Generate without backup", func(t *testing.T) { 141 | config := NewDefaultEnvConfig() 142 | err := generator.Generate(config, false) 143 | if err != nil { 144 | t.Fatalf("Failed to generate: %v", err) 145 | } 146 | 147 | // Check file exists 148 | outputPath := filepath.Join(tmpDir, ".env") 149 | if _, err := os.Stat(outputPath); os.IsNotExist(err) { 150 | t.Error(".env was not created") 151 | } 152 | 153 | // Read and verify content 154 | content, err := os.ReadFile(outputPath) 155 | if err != nil { 156 | t.Fatalf("Failed to read generated file: %v", err) 157 | } 158 | 159 | if !strings.Contains(string(content), "COMPOSE_PROJECT_NAME=corsarr") { 160 | t.Error("Generated file doesn't contain expected content") 161 | } 162 | }) 163 | 164 | t.Run("Generate with backup", func(t *testing.T) { 165 | // First create an existing file 166 | existingPath := filepath.Join(tmpDir, ".env") 167 | err := os.WriteFile(existingPath, []byte("EXISTING=value"), 0644) 168 | if err != nil { 169 | t.Fatalf("Failed to create existing file: %v", err) 170 | } 171 | 172 | // Generate with backup 173 | config := NewDefaultEnvConfig() 174 | err = generator.Generate(config, true) 175 | if err != nil { 176 | t.Fatalf("Failed to generate with backup: %v", err) 177 | } 178 | 179 | // Check backup was created 180 | files, err := filepath.Glob(filepath.Join(tmpDir, ".env.backup.*")) 181 | if err != nil { 182 | t.Fatalf("Failed to list backup files: %v", err) 183 | } 184 | if len(files) == 0 { 185 | t.Error("No backup file was created") 186 | } 187 | 188 | // Verify backup contains old content 189 | backupContent, err := os.ReadFile(files[0]) 190 | if err != nil { 191 | t.Fatalf("Failed to read backup: %v", err) 192 | } 193 | if !strings.Contains(string(backupContent), "EXISTING=value") { 194 | t.Error("Backup doesn't contain original content") 195 | } 196 | }) 197 | 198 | t.Run("File permissions are secure (0600)", func(t *testing.T) { 199 | config := NewDefaultEnvConfig() 200 | err := generator.Generate(config, false) 201 | if err != nil { 202 | t.Fatalf("Failed to generate: %v", err) 203 | } 204 | 205 | // Check file permissions 206 | outputPath := filepath.Join(tmpDir, ".env") 207 | info, err := os.Stat(outputPath) 208 | if err != nil { 209 | t.Fatalf("Failed to stat file: %v", err) 210 | } 211 | 212 | // On Unix systems, check for 0600 permissions (owner read/write only) 213 | mode := info.Mode().Perm() 214 | expected := os.FileMode(0600) 215 | if mode != expected { 216 | t.Errorf("Expected file permissions %o, got %o", expected, mode) 217 | } 218 | }) 219 | } 220 | -------------------------------------------------------------------------------- /internal/generator/network_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/woliveiras/corsarr/internal/services" 7 | ) 8 | 9 | func TestGetNetworkConfig(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | vpnMode bool 13 | wantNil bool 14 | }{ 15 | { 16 | name: "VPN mode - no custom network", 17 | vpnMode: true, 18 | wantNil: true, 19 | }, 20 | { 21 | name: "Bridge mode - has custom network", 22 | vpnMode: false, 23 | wantNil: false, 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | config := GetNetworkConfig(tt.vpnMode) 30 | 31 | if tt.wantNil { 32 | if config != nil { 33 | t.Error("Expected nil config for VPN mode") 34 | } 35 | } else { 36 | if config == nil { 37 | t.Fatal("Expected network config, got nil") 38 | } 39 | if config.Name != "media" { 40 | t.Errorf("Expected network name 'media', got '%s'", config.Name) 41 | } 42 | if config.Driver != "bridge" { 43 | t.Errorf("Expected driver 'bridge', got '%s'", config.Driver) 44 | } 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestConfigureServiceNetworking(t *testing.T) { 51 | tests := []struct { 52 | name string 53 | service *services.Service 54 | vpnMode bool 55 | expectError bool 56 | }{ 57 | { 58 | name: "VPN mode - compatible service", 59 | service: &services.Service{ 60 | ID: "radarr", 61 | SupportsVPN: true, 62 | Category: services.CategoryMedia, 63 | }, 64 | vpnMode: true, 65 | expectError: false, 66 | }, 67 | { 68 | name: "VPN mode - Gluetun service", 69 | service: &services.Service{ 70 | ID: "gluetun", 71 | Category: services.CategoryVPN, 72 | }, 73 | vpnMode: true, 74 | expectError: false, 75 | }, 76 | { 77 | name: "VPN mode - service without explicit VPN support", 78 | service: &services.Service{ 79 | ID: "test", 80 | RequiresVPN: false, 81 | SupportsVPN: false, 82 | Category: services.CategoryMedia, 83 | }, 84 | vpnMode: true, 85 | expectError: false, // Should pass because IsCompatibleWithVPN returns true for non-required services 86 | }, 87 | { 88 | name: "Bridge mode - service with network config", 89 | service: &services.Service{ 90 | ID: "radarr", 91 | Network: services.NetworkConfig{ 92 | BridgeMode: services.BridgeModeConfig{ 93 | Hostname: "radarr", 94 | Networks: []string{"media"}, 95 | }, 96 | }, 97 | }, 98 | vpnMode: false, 99 | expectError: false, 100 | }, 101 | { 102 | name: "Bridge mode - service without network config", 103 | service: &services.Service{ 104 | ID: "test", 105 | Network: services.NetworkConfig{ 106 | BridgeMode: services.BridgeModeConfig{ 107 | Networks: []string{}, 108 | }, 109 | }, 110 | }, 111 | vpnMode: false, 112 | expectError: true, 113 | }, 114 | } 115 | 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | err := ConfigureServiceNetworking(tt.service, tt.vpnMode) 119 | 120 | if tt.expectError { 121 | if err == nil { 122 | t.Error("Expected error but got none") 123 | } 124 | } else { 125 | if err != nil { 126 | t.Errorf("Unexpected error: %v", err) 127 | } 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestValidateNetworkConfiguration(t *testing.T) { 134 | tests := []struct { 135 | name string 136 | services []*services.Service 137 | vpnMode bool 138 | expectError bool 139 | }{ 140 | { 141 | name: "Valid VPN configuration", 142 | services: []*services.Service{ 143 | { 144 | ID: "radarr", 145 | SupportsVPN: true, 146 | Category: services.CategoryMedia, 147 | }, 148 | { 149 | ID: "sonarr", 150 | SupportsVPN: true, 151 | Category: services.CategoryMedia, 152 | }, 153 | }, 154 | vpnMode: true, 155 | expectError: false, 156 | }, 157 | { 158 | name: "Valid bridge configuration", 159 | services: []*services.Service{ 160 | { 161 | ID: "radarr", 162 | Network: services.NetworkConfig{ 163 | BridgeMode: services.BridgeModeConfig{ 164 | Networks: []string{"media"}, 165 | }, 166 | }, 167 | }, 168 | }, 169 | vpnMode: false, 170 | expectError: false, 171 | }, 172 | { 173 | name: "Invalid VPN configuration - service requires VPN but VPN disabled", 174 | services: []*services.Service{ 175 | { 176 | ID: "flaresolverr", 177 | RequiresVPN: true, 178 | SupportsVPN: true, 179 | Category: services.CategoryIndexer, 180 | }, 181 | }, 182 | vpnMode: false, 183 | expectError: true, 184 | }, 185 | } 186 | 187 | for _, tt := range tests { 188 | t.Run(tt.name, func(t *testing.T) { 189 | err := ValidateNetworkConfiguration(tt.services, tt.vpnMode) 190 | 191 | if tt.expectError { 192 | if err == nil { 193 | t.Error("Expected error but got none") 194 | } 195 | } else { 196 | if err != nil { 197 | t.Errorf("Unexpected error: %v", err) 198 | } 199 | } 200 | }) 201 | } 202 | } 203 | 204 | func TestGetExposedPorts(t *testing.T) { 205 | tests := []struct { 206 | name string 207 | services []*services.Service 208 | vpnMode bool 209 | expectedLen int 210 | }{ 211 | { 212 | name: "VPN mode - collects all ports", 213 | services: []*services.Service{ 214 | { 215 | ID: "gluetun", 216 | Category: services.CategoryVPN, 217 | Ports: []services.PortMapping{ 218 | {Host: "8000", Container: "8000"}, 219 | }, 220 | }, 221 | { 222 | ID: "radarr", 223 | Ports: []services.PortMapping{ 224 | {Host: "7878", Container: "7878"}, 225 | }, 226 | }, 227 | { 228 | ID: "sonarr", 229 | Ports: []services.PortMapping{ 230 | {Host: "8989", Container: "8989"}, 231 | }, 232 | }, 233 | }, 234 | vpnMode: true, 235 | expectedLen: 2, // Excludes Gluetun ports 236 | }, 237 | { 238 | name: "Bridge mode - returns nil", 239 | services: []*services.Service{ 240 | { 241 | ID: "radarr", 242 | Ports: []services.PortMapping{ 243 | {Host: "7878", Container: "7878"}, 244 | }, 245 | }, 246 | }, 247 | vpnMode: false, 248 | expectedLen: 0, 249 | }, 250 | } 251 | 252 | for _, tt := range tests { 253 | t.Run(tt.name, func(t *testing.T) { 254 | ports := GetExposedPorts(tt.services, tt.vpnMode) 255 | 256 | if len(ports) != tt.expectedLen { 257 | t.Errorf("Expected %d ports, got %d", tt.expectedLen, len(ports)) 258 | } 259 | }) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /cmd/profile.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/woliveiras/corsarr/internal/profile" 13 | ) 14 | 15 | // profileCmd represents the profile command 16 | var profileCmd = &cobra.Command{ 17 | Use: "profile", 18 | Short: "Manage configuration profiles", 19 | Long: "Save, load, list, delete, export and import configuration profiles", 20 | } 21 | 22 | var profileSaveCmd = &cobra.Command{ 23 | Use: "save [name]", 24 | Short: "Save the current configuration as a profile", 25 | Long: "Save the current configuration as a named profile for later reuse", 26 | Args: cobra.ExactArgs(1), 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | t := GetTranslator() 29 | name := args[0] 30 | 31 | description, _ := cmd.Flags().GetString("description") 32 | 33 | // Check if profile already exists 34 | if profile.ProfileExists(name) { 35 | force, _ := cmd.Flags().GetBool("force") 36 | if !force { 37 | return fmt.Errorf("%s", t.T("profile.already_exists")) 38 | } 39 | } 40 | 41 | // Create a new profile 42 | // Note: In real usage, this would be populated from interactive prompts or flags 43 | // For now, we create an example profile 44 | p := profile.NewProfile(name) 45 | p.Description = description 46 | 47 | if err := profile.SaveProfile(p); err != nil { 48 | return fmt.Errorf("%s: %w", t.T("profile.save_failed"), err) 49 | } 50 | 51 | fmt.Printf("✅ %s: %s\n", t.T("profile.saved_successfully"), name) 52 | return nil 53 | }, 54 | } 55 | 56 | var profileLoadCmd = &cobra.Command{ 57 | Use: "load [name]", 58 | Short: "Load a configuration profile", 59 | Long: "Load a previously saved configuration profile", 60 | Args: cobra.ExactArgs(1), 61 | RunE: func(cmd *cobra.Command, args []string) error { 62 | t := GetTranslator() 63 | name := args[0] 64 | 65 | p, err := profile.LoadProfile(name) 66 | if err != nil { 67 | return fmt.Errorf("%s: %w", t.T("profile.load_failed"), err) 68 | } 69 | 70 | fmt.Printf("✅ %s: %s\n", t.T("profile.loaded_successfully"), name) 71 | fmt.Printf("\n📋 %s:\n", t.T("profile.details")) 72 | fmt.Printf(" %s: %s\n", t.T("profile.name"), p.Name) 73 | if p.Description != "" { 74 | fmt.Printf(" %s: %s\n", t.T("profile.description"), p.Description) 75 | } 76 | fmt.Printf(" %s: %s\n", t.T("profile.created_at"), p.CreatedAt.Format(time.RFC3339)) 77 | fmt.Printf(" %s: %s\n", t.T("profile.updated_at"), p.UpdatedAt.Format(time.RFC3339)) 78 | fmt.Printf(" %s: %s\n", t.T("profile.version"), p.Version) 79 | fmt.Printf(" %s: %v\n", t.T("profile.vpn_enabled"), p.VPN.Enabled) 80 | if len(p.Services) > 0 { 81 | fmt.Printf(" %s: %s\n", t.T("profile.services"), strings.Join(p.Services, ", ")) 82 | } 83 | 84 | return nil 85 | }, 86 | } 87 | 88 | var profileListCmd = &cobra.Command{ 89 | Use: "list", 90 | Short: "List all saved profiles", 91 | Long: "Display a list of all saved configuration profiles", 92 | RunE: func(cmd *cobra.Command, args []string) error { 93 | t := GetTranslator() 94 | 95 | profiles, err := profile.ListProfiles() 96 | if err != nil { 97 | return fmt.Errorf("%s: %w", t.T("profile.list_failed"), err) 98 | } 99 | 100 | if len(profiles) == 0 { 101 | fmt.Printf("ℹ️ %s\n", t.T("profile.no_profiles")) 102 | return nil 103 | } 104 | 105 | fmt.Printf("📋 %s (%d):\n\n", t.T("profile.saved_profiles"), len(profiles)) 106 | 107 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 108 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", 109 | t.T("profile.name"), 110 | t.T("profile.services_count"), 111 | t.T("profile.updated_at"), 112 | t.T("profile.description")) 113 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", 114 | strings.Repeat("-", 20), 115 | strings.Repeat("-", 15), 116 | strings.Repeat("-", 20), 117 | strings.Repeat("-", 30)) 118 | 119 | for _, p := range profiles { 120 | updatedAt := p.UpdatedAt.Format("2006-01-02 15:04") 121 | servicesCount := fmt.Sprintf("%d", len(p.Services)) 122 | description := p.Description 123 | if len(description) > 30 { 124 | description = description[:27] + "..." 125 | } 126 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", 127 | p.Name, servicesCount, updatedAt, description) 128 | } 129 | 130 | _ = w.Flush() 131 | return nil 132 | }, 133 | } 134 | 135 | var profileDeleteCmd = &cobra.Command{ 136 | Use: "delete [name]", 137 | Short: "Delete a configuration profile", 138 | Long: "Remove a saved configuration profile", 139 | Args: cobra.ExactArgs(1), 140 | RunE: func(cmd *cobra.Command, args []string) error { 141 | t := GetTranslator() 142 | name := args[0] 143 | 144 | force, _ := cmd.Flags().GetBool("force") 145 | if !force { 146 | fmt.Printf("⚠️ %s '%s'? (y/N): ", t.T("profile.confirm_delete"), name) 147 | var response string 148 | _, _ = fmt.Scanln(&response) 149 | response = strings.ToLower(strings.TrimSpace(response)) 150 | if response != "y" && response != "yes" && response != "s" && response != "sim" { 151 | fmt.Printf("ℹ️ %s\n", t.T("profile.delete_cancelled")) 152 | return nil 153 | } 154 | } 155 | 156 | if err := profile.DeleteProfile(name); err != nil { 157 | return fmt.Errorf("%s: %w", t.T("profile.delete_failed"), err) 158 | } 159 | 160 | fmt.Printf("✅ %s: %s\n", t.T("profile.deleted_successfully"), name) 161 | return nil 162 | }, 163 | } 164 | 165 | var profileExportCmd = &cobra.Command{ 166 | Use: "export [name] [output-file]", 167 | Short: "Export a profile to a file", 168 | Long: "Export a configuration profile to a JSON file for sharing or backup", 169 | Args: cobra.ExactArgs(2), 170 | RunE: func(cmd *cobra.Command, args []string) error { 171 | t := GetTranslator() 172 | name := args[0] 173 | outputPath := args[1] 174 | 175 | // Ensure .json extension 176 | if filepath.Ext(outputPath) != ".json" { 177 | outputPath += ".json" 178 | } 179 | 180 | if err := profile.ExportProfile(name, outputPath); err != nil { 181 | return fmt.Errorf("%s: %w", t.T("profile.export_failed"), err) 182 | } 183 | 184 | absPath, _ := filepath.Abs(outputPath) 185 | fmt.Printf("✅ %s: %s\n", t.T("profile.exported_successfully"), absPath) 186 | return nil 187 | }, 188 | } 189 | 190 | var profileImportCmd = &cobra.Command{ 191 | Use: "import [file]", 192 | Short: "Import a profile from a file", 193 | Long: "Import a configuration profile from a JSON or YAML file", 194 | Args: cobra.ExactArgs(1), 195 | RunE: func(cmd *cobra.Command, args []string) error { 196 | t := GetTranslator() 197 | inputPath := args[0] 198 | 199 | p, err := profile.ImportProfile(inputPath) 200 | if err != nil { 201 | return fmt.Errorf("%s: %w", t.T("profile.import_failed"), err) 202 | } 203 | 204 | name, _ := cmd.Flags().GetString("name") 205 | if name != "" { 206 | p.Name = name 207 | } 208 | 209 | // Check if profile already exists 210 | if profile.ProfileExists(p.Name) { 211 | force, _ := cmd.Flags().GetBool("force") 212 | if !force { 213 | return fmt.Errorf("%s", t.T("profile.already_exists")) 214 | } 215 | } 216 | 217 | if err := profile.SaveProfile(p); err != nil { 218 | return fmt.Errorf("%s: %w", t.T("profile.save_failed"), err) 219 | } 220 | 221 | fmt.Printf("✅ %s: %s\n", t.T("profile.imported_successfully"), p.Name) 222 | return nil 223 | }, 224 | } 225 | 226 | func init() { 227 | rootCmd.AddCommand(profileCmd) 228 | 229 | // Add subcommands 230 | profileCmd.AddCommand(profileSaveCmd) 231 | profileCmd.AddCommand(profileLoadCmd) 232 | profileCmd.AddCommand(profileListCmd) 233 | profileCmd.AddCommand(profileDeleteCmd) 234 | profileCmd.AddCommand(profileExportCmd) 235 | profileCmd.AddCommand(profileImportCmd) 236 | 237 | // Flags for save command 238 | profileSaveCmd.Flags().StringP("description", "d", "", "Profile description") 239 | profileSaveCmd.Flags().BoolP("force", "f", false, "Overwrite existing profile") 240 | 241 | // Flags for delete command 242 | profileDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") 243 | 244 | // Flags for import command 245 | profileImportCmd.Flags().StringP("name", "n", "", "Override profile name") 246 | profileImportCmd.Flags().BoolP("force", "f", false, "Overwrite existing profile") 247 | } 248 | -------------------------------------------------------------------------------- /internal/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // Profile represents a saved configuration 14 | type Profile struct { 15 | Name string `json:"name" yaml:"name"` 16 | Description string `json:"description,omitempty" yaml:"description,omitempty"` 17 | CreatedAt time.Time `json:"created_at" yaml:"created_at"` 18 | UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` 19 | Version string `json:"version" yaml:"version"` 20 | VPN VPNConfig `json:"vpn" yaml:"vpn"` 21 | Services []string `json:"services" yaml:"services"` 22 | Environment map[string]string `json:"environment" yaml:"environment"` 23 | OutputDir string `json:"output_dir" yaml:"output_dir"` 24 | } 25 | 26 | // VPNConfig holds VPN-related configuration 27 | type VPNConfig struct { 28 | Enabled bool `json:"enabled" yaml:"enabled"` 29 | Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` 30 | Username string `json:"username,omitempty" yaml:"username,omitempty"` 31 | Password string `json:"password,omitempty" yaml:"password,omitempty"` 32 | Country string `json:"country,omitempty" yaml:"country,omitempty"` 33 | City string `json:"city,omitempty" yaml:"city,omitempty"` 34 | } 35 | 36 | // Metadata contains profile summary information 37 | type Metadata struct { 38 | Name string `json:"name" yaml:"name"` 39 | Description string `json:"description,omitempty" yaml:"description,omitempty"` 40 | CreatedAt time.Time `json:"created_at" yaml:"created_at"` 41 | UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` 42 | Version string `json:"version" yaml:"version"` 43 | Services []string `json:"services" yaml:"services"` 44 | } 45 | 46 | const ( 47 | // ProfileVersion is the current profile format version 48 | ProfileVersion = "1.0.0" 49 | // DefaultProfileDir is the default directory for storing profiles 50 | DefaultProfileDir = ".corsarr/profiles" 51 | ) 52 | 53 | // GetProfileDir returns the profile directory path 54 | func GetProfileDir() (string, error) { 55 | homeDir, err := os.UserHomeDir() 56 | if err != nil { 57 | return "", fmt.Errorf("failed to get home directory: %w", err) 58 | } 59 | return filepath.Join(homeDir, DefaultProfileDir), nil 60 | } 61 | 62 | // ensureProfileDir creates the profile directory if it doesn't exist 63 | func ensureProfileDir() error { 64 | profileDir, err := GetProfileDir() 65 | if err != nil { 66 | return err 67 | } 68 | return os.MkdirAll(profileDir, 0755) 69 | } 70 | 71 | // getProfilePath returns the full path for a profile file 72 | func getProfilePath(name string) (string, error) { 73 | profileDir, err := GetProfileDir() 74 | if err != nil { 75 | return "", err 76 | } 77 | return filepath.Join(profileDir, name+".yaml"), nil 78 | } 79 | 80 | // NewProfile creates a new profile with default values 81 | func NewProfile(name string) *Profile { 82 | now := time.Now() 83 | return &Profile{ 84 | Name: name, 85 | CreatedAt: now, 86 | UpdatedAt: now, 87 | Version: ProfileVersion, 88 | Services: []string{}, 89 | Environment: make(map[string]string), 90 | VPN: VPNConfig{ 91 | Enabled: false, 92 | }, 93 | } 94 | } 95 | 96 | // SaveProfile saves a profile to disk 97 | func SaveProfile(profile *Profile) error { 98 | if err := ensureProfileDir(); err != nil { 99 | return fmt.Errorf("failed to create profile directory: %w", err) 100 | } 101 | 102 | profilePath, err := getProfilePath(profile.Name) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // Update timestamp 108 | profile.UpdatedAt = time.Now() 109 | profile.Version = ProfileVersion 110 | 111 | data, err := yaml.Marshal(profile) 112 | if err != nil { 113 | return fmt.Errorf("failed to marshal profile: %w", err) 114 | } 115 | 116 | if err := os.WriteFile(profilePath, data, 0644); err != nil { 117 | return fmt.Errorf("failed to write profile file: %w", err) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | // LoadProfile loads a profile from disk 124 | func LoadProfile(name string) (*Profile, error) { 125 | profilePath, err := getProfilePath(name) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | data, err := os.ReadFile(profilePath) 131 | if err != nil { 132 | if os.IsNotExist(err) { 133 | return nil, fmt.Errorf("profile '%s' not found", name) 134 | } 135 | return nil, fmt.Errorf("failed to read profile file: %w", err) 136 | } 137 | 138 | var profile Profile 139 | if err := yaml.Unmarshal(data, &profile); err != nil { 140 | return nil, fmt.Errorf("failed to unmarshal profile: %w", err) 141 | } 142 | 143 | return &profile, nil 144 | } 145 | 146 | // ListProfiles returns a list of all saved profiles 147 | func ListProfiles() ([]*Metadata, error) { 148 | profileDir, err := GetProfileDir() 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | // Check if directory exists 154 | if _, err := os.Stat(profileDir); os.IsNotExist(err) { 155 | return []*Metadata{}, nil 156 | } 157 | 158 | entries, err := os.ReadDir(profileDir) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to read profile directory: %w", err) 161 | } 162 | 163 | var profiles []*Metadata 164 | for _, entry := range entries { 165 | if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { 166 | continue 167 | } 168 | 169 | name := entry.Name()[:len(entry.Name())-5] // Remove .yaml extension 170 | profile, err := LoadProfile(name) 171 | if err != nil { 172 | continue // Skip invalid profiles 173 | } 174 | 175 | profiles = append(profiles, &Metadata{ 176 | Name: profile.Name, 177 | Description: profile.Description, 178 | CreatedAt: profile.CreatedAt, 179 | UpdatedAt: profile.UpdatedAt, 180 | Version: profile.Version, 181 | Services: profile.Services, 182 | }) 183 | } 184 | 185 | return profiles, nil 186 | } 187 | 188 | // DeleteProfile removes a profile from disk 189 | func DeleteProfile(name string) error { 190 | profilePath, err := getProfilePath(name) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | if err := os.Remove(profilePath); err != nil { 196 | if os.IsNotExist(err) { 197 | return fmt.Errorf("profile '%s' not found", name) 198 | } 199 | return fmt.Errorf("failed to delete profile: %w", err) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | // ExportProfile exports a profile to a specific path in JSON format 206 | func ExportProfile(name, outputPath string) error { 207 | profile, err := LoadProfile(name) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | data, err := json.MarshalIndent(profile, "", " ") 213 | if err != nil { 214 | return fmt.Errorf("failed to marshal profile: %w", err) 215 | } 216 | 217 | if err := os.WriteFile(outputPath, data, 0644); err != nil { 218 | return fmt.Errorf("failed to write export file: %w", err) 219 | } 220 | 221 | return nil 222 | } 223 | 224 | // ImportProfile imports a profile from a JSON or YAML file 225 | func ImportProfile(inputPath string) (*Profile, error) { 226 | data, err := os.ReadFile(inputPath) 227 | if err != nil { 228 | return nil, fmt.Errorf("failed to read import file: %w", err) 229 | } 230 | 231 | var profile Profile 232 | 233 | // Try YAML first 234 | if err := yaml.Unmarshal(data, &profile); err != nil { 235 | // Try JSON 236 | if jsonErr := json.Unmarshal(data, &profile); jsonErr != nil { 237 | return nil, fmt.Errorf("failed to parse profile (tried YAML and JSON): %w", err) 238 | } 239 | } 240 | 241 | // Update metadata 242 | profile.UpdatedAt = time.Now() 243 | if profile.Version == "" { 244 | profile.Version = ProfileVersion 245 | } 246 | 247 | return &profile, nil 248 | } 249 | 250 | // ProfileExists checks if a profile exists 251 | func ProfileExists(name string) bool { 252 | profilePath, err := getProfilePath(name) 253 | if err != nil { 254 | return false 255 | } 256 | _, err = os.Stat(profilePath) 257 | return err == nil 258 | } 259 | 260 | // GetMetadata returns metadata for a profile without loading the full profile 261 | func GetMetadata(name string) (*Metadata, error) { 262 | profile, err := LoadProfile(name) 263 | if err != nil { 264 | return nil, err 265 | } 266 | 267 | return &Metadata{ 268 | Name: profile.Name, 269 | Description: profile.Description, 270 | CreatedAt: profile.CreatedAt, 271 | UpdatedAt: profile.UpdatedAt, 272 | Version: profile.Version, 273 | Services: profile.Services, 274 | }, nil 275 | } 276 | -------------------------------------------------------------------------------- /cmd/check_ports.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "os/exec" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "text/tabwriter" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/woliveiras/corsarr/internal/i18n" 15 | "github.com/woliveiras/corsarr/internal/services" 16 | ) 17 | 18 | var ( 19 | checkPortsOutputDir string 20 | checkPortsSuggest bool 21 | ) 22 | 23 | // checkPortsCmd represents the check-ports command 24 | var checkPortsCmd = &cobra.Command{ 25 | Use: "check-ports", 26 | Short: "Check for port conflicts", 27 | Long: `Check which ports are in use and detect potential conflicts. 28 | 29 | This command will: 30 | - List all ports used by configured services 31 | - Check if ports are available on the system 32 | - Suggest alternative ports if conflicts are detected 33 | - Show which process is using conflicting ports 34 | 35 | Example: 36 | corsarr check-ports 37 | corsarr check-ports --output /path/to/compose 38 | corsarr check-ports --suggest`, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | t := GetTranslator() 41 | 42 | if err := runPortCheck(t); err != nil { 43 | fmt.Fprintf(os.Stderr, "❌ %s: %v\n", t.T("errors.port_check_failed"), err) 44 | os.Exit(1) 45 | } 46 | }, 47 | } 48 | 49 | func init() { 50 | rootCmd.AddCommand(checkPortsCmd) 51 | 52 | checkPortsCmd.Flags().StringVarP(&checkPortsOutputDir, "output", "o", ".", "Directory with docker-compose.yml") 53 | checkPortsCmd.Flags().BoolVarP(&checkPortsSuggest, "suggest", "s", false, "Suggest alternative ports for conflicts") 54 | } 55 | 56 | type PortInfo struct { 57 | Service string 58 | Port int 59 | Protocol string 60 | InUse bool 61 | Available bool 62 | UsedBy string 63 | } 64 | 65 | func runPortCheck(t *i18n.I18n) error { 66 | fmt.Printf("🔍 %s\n", t.T("ports.checking_ports")) 67 | fmt.Printf("📂 %s: %s\n\n", t.T("ports.directory"), checkPortsOutputDir) 68 | 69 | // Load service registry 70 | registry, err := services.NewRegistry() 71 | if err != nil { 72 | return fmt.Errorf("%s: %w", t.T("errors.failed_to_load_services"), err) 73 | } 74 | 75 | // Get configured services from docker-compose.yml 76 | composePath := checkPortsOutputDir + "/docker-compose.yml" 77 | if _, err := os.Stat(composePath); os.IsNotExist(err) { 78 | return fmt.Errorf("%s: %s", t.T("errors.compose_not_found"), composePath) 79 | } 80 | 81 | configuredServices, err := getConfiguredServices(composePath) 82 | if err != nil { 83 | return fmt.Errorf("%s: %w", t.T("errors.failed_to_parse_compose"), err) 84 | } 85 | 86 | // Collect all ports from configured services 87 | var ports []PortInfo 88 | 89 | for _, serviceName := range configuredServices { 90 | service, err := registry.GetService(serviceName) 91 | if err != nil || service == nil { 92 | continue 93 | } 94 | 95 | for _, portMapping := range service.Ports { 96 | hostPort, err := strconv.Atoi(portMapping.Host) 97 | if err != nil { 98 | continue 99 | } 100 | 101 | info := PortInfo{ 102 | Service: service.Name, 103 | Port: hostPort, 104 | Protocol: portMapping.Protocol, 105 | } 106 | 107 | // Check if port is available 108 | info.Available = isPortAvailable(hostPort, portMapping.Protocol) 109 | info.InUse = !info.Available 110 | 111 | if info.InUse { 112 | info.UsedBy = getProcessUsingPort(hostPort) 113 | } 114 | 115 | ports = append(ports, info) 116 | } 117 | } 118 | 119 | if len(ports) == 0 { 120 | fmt.Printf("ℹ️ %s\n", t.T("ports.no_ports_configured")) 121 | return nil 122 | } 123 | 124 | // Sort by port number 125 | sort.Slice(ports, func(i, j int) bool { 126 | return ports[i].Port < ports[j].Port 127 | }) 128 | 129 | // Display results 130 | displayPortStatus(t, ports) 131 | 132 | // Count conflicts 133 | conflicts := 0 134 | for _, p := range ports { 135 | if p.InUse { 136 | conflicts++ 137 | } 138 | } 139 | 140 | // Summary 141 | fmt.Println() 142 | fmt.Println("═════════════════════════════════════════") 143 | fmt.Printf("📊 %s\n", t.T("ports.summary")) 144 | fmt.Println("═════════════════════════════════════════") 145 | fmt.Printf("📝 %s: %d\n", t.T("ports.total_ports"), len(ports)) 146 | fmt.Printf("✅ %s: %d\n", t.T("ports.available"), len(ports)-conflicts) 147 | 148 | if conflicts > 0 { 149 | fmt.Printf("❌ %s: %d\n", t.T("ports.in_use"), conflicts) 150 | 151 | if checkPortsSuggest { 152 | fmt.Println() 153 | fmt.Printf("💡 %s:\n", t.T("ports.suggestions")) 154 | suggestAlternativePorts(t, ports) 155 | } else { 156 | fmt.Println() 157 | fmt.Printf("💡 %s: corsarr check-ports --suggest\n", t.T("ports.suggest_hint")) 158 | } 159 | } else { 160 | fmt.Printf("✅ %s\n", t.T("ports.no_conflicts")) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func getConfiguredServices(composePath string) ([]string, error) { 167 | data, err := os.ReadFile(composePath) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | var services []string 173 | lines := strings.Split(string(data), "\n") 174 | inServices := false 175 | 176 | for _, line := range lines { 177 | trimmed := strings.TrimSpace(line) 178 | 179 | if trimmed == "services:" { 180 | inServices = true 181 | continue 182 | } 183 | 184 | if inServices { 185 | // Service names are at indentation level 1 186 | if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { 187 | serviceName := strings.TrimSpace(strings.Split(trimmed, ":")[0]) 188 | if serviceName != "" && !strings.HasPrefix(serviceName, "#") { 189 | services = append(services, serviceName) 190 | } 191 | } 192 | 193 | // Stop when we reach another top-level key 194 | if len(line) > 0 && !strings.HasPrefix(line, " ") && trimmed != "services:" { 195 | break 196 | } 197 | } 198 | } 199 | 200 | return services, nil 201 | } 202 | 203 | func isPortAvailable(port int, protocol string) bool { 204 | address := fmt.Sprintf(":%d", port) 205 | 206 | if protocol == "udp" { 207 | conn, err := net.ListenPacket("udp", address) 208 | if err != nil { 209 | return false 210 | } 211 | _ = conn.Close() 212 | return true 213 | } 214 | 215 | // TCP (default) 216 | listener, err := net.Listen("tcp", address) 217 | if err != nil { 218 | return false 219 | } 220 | _ = listener.Close() 221 | return true 222 | } 223 | 224 | func getProcessUsingPort(port int) string { 225 | // Try to get process info using lsof (Linux/Mac) 226 | cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-t") 227 | output, err := cmd.Output() 228 | if err == nil && len(output) > 0 { 229 | pid := strings.TrimSpace(string(output)) 230 | return fmt.Sprintf("PID %s", pid) 231 | } 232 | 233 | // Try netstat on Windows 234 | cmd = exec.Command("netstat", "-ano") 235 | output, err = cmd.Output() 236 | if err == nil { 237 | lines := strings.Split(string(output), "\n") 238 | portStr := fmt.Sprintf(":%d", port) 239 | for _, line := range lines { 240 | if strings.Contains(line, portStr) && strings.Contains(line, "LISTENING") { 241 | fields := strings.Fields(line) 242 | if len(fields) > 4 { 243 | return fmt.Sprintf("PID %s", fields[len(fields)-1]) 244 | } 245 | } 246 | } 247 | } 248 | 249 | return "Unknown" 250 | } 251 | 252 | func displayPortStatus(t *i18n.I18n, ports []PortInfo) { 253 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 254 | 255 | // Header 256 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", 257 | t.T("ports.service"), 258 | t.T("ports.port"), 259 | t.T("ports.protocol"), 260 | t.T("ports.status"), 261 | t.T("ports.used_by")) 262 | 263 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", 264 | strings.Repeat("-", 20), 265 | strings.Repeat("-", 8), 266 | strings.Repeat("-", 10), 267 | strings.Repeat("-", 12), 268 | strings.Repeat("-", 15)) 269 | 270 | // Rows 271 | for _, p := range ports { 272 | statusIcon := "✅" 273 | statusText := t.T("ports.available") 274 | usedBy := "-" 275 | 276 | if p.InUse { 277 | statusIcon = "❌" 278 | statusText = t.T("ports.in_use") 279 | usedBy = p.UsedBy 280 | } 281 | 282 | fmt.Fprintf(w, "%s\t%d\t%s\t%s %s\t%s\n", 283 | p.Service, 284 | p.Port, 285 | strings.ToUpper(p.Protocol), 286 | statusIcon, 287 | statusText, 288 | usedBy) 289 | } 290 | 291 | _ = w.Flush() 292 | } 293 | 294 | func suggestAlternativePorts(t *i18n.I18n, ports []PortInfo) { 295 | for _, p := range ports { 296 | if !p.InUse { 297 | continue 298 | } 299 | 300 | // Find next available port 301 | alternativePort := findNextAvailablePort(p.Port, p.Protocol) 302 | if alternativePort > 0 { 303 | fmt.Printf(" • %s (%d) → %s %d\n", 304 | p.Service, 305 | p.Port, 306 | t.T("ports.use_port"), 307 | alternativePort) 308 | } 309 | } 310 | } 311 | 312 | func findNextAvailablePort(startPort int, protocol string) int { 313 | // Try ports in range [startPort+1, startPort+100] 314 | for port := startPort + 1; port <= startPort+100; port++ { 315 | if isPortAvailable(port, protocol) { 316 | return port 317 | } 318 | } 319 | return 0 320 | } 321 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 10 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 11 | github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 12 | github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 13 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 14 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 15 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 16 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 17 | github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 18 | github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 19 | github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= 20 | github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 21 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 22 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 23 | github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= 24 | github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= 25 | github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= 26 | github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= 27 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 28 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 29 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 30 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 31 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 32 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 33 | github.com/charmbracelet/x/exp/strings v0.0.0-20251201173703-9f73bfd934ff h1:FzCajq562MNVMy/oN4ytwRd5GTl0VSLFVL2zfB6YVrU= 34 | github.com/charmbracelet/x/exp/strings v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= 35 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 36 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 37 | github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 38 | github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 39 | github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 40 | github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 41 | github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= 42 | github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 43 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 44 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 45 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 46 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 47 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 48 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 49 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 50 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 51 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 52 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 53 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 54 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 55 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 56 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 57 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 58 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 59 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 60 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 61 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 62 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 63 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 64 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 65 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 66 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 67 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 68 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 69 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 70 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 71 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 72 | github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= 73 | github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= 74 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 75 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 76 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 77 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 78 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 79 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 80 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 81 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 82 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 83 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 84 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 85 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 88 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 89 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 90 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 92 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 94 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 95 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 96 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 97 | -------------------------------------------------------------------------------- /cmd/health.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "text/tabwriter" 12 | "time" 13 | 14 | "github.com/spf13/cobra" 15 | "github.com/woliveiras/corsarr/internal/i18n" 16 | ) 17 | 18 | var ( 19 | healthOutputDir string 20 | healthDetailed bool 21 | ) 22 | 23 | // healthCmd represents the health command 24 | var healthCmd = &cobra.Command{ 25 | Use: "health", 26 | Short: "Check health of running containers", 27 | Long: `Check the health status of all configured services. 28 | 29 | This command will: 30 | - Verify if containers are running 31 | - Check their health status 32 | - Show uptime and resource usage 33 | - Detect any issues 34 | 35 | Example: 36 | corsarr health 37 | corsarr health --output /path/to/compose 38 | corsarr health --detailed`, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | t := GetTranslator() 41 | 42 | if err := runHealthCheck(t); err != nil { 43 | fmt.Fprintf(os.Stderr, "❌ %s: %v\n", t.T("errors.health_check_failed"), err) 44 | os.Exit(1) 45 | } 46 | }, 47 | } 48 | 49 | func init() { 50 | rootCmd.AddCommand(healthCmd) 51 | 52 | healthCmd.Flags().StringVarP(&healthOutputDir, "output", "o", ".", "Directory with docker-compose.yml") 53 | healthCmd.Flags().BoolVarP(&healthDetailed, "detailed", "d", false, "Show detailed container information") 54 | } 55 | 56 | func runHealthCheck(t *i18n.I18n) error { 57 | // Check if Docker is available 58 | if !isDockerAvailable() { 59 | return fmt.Errorf("%s", t.T("errors.docker_not_found")) 60 | } 61 | 62 | // Check if docker-compose.yml exists 63 | composePath := healthOutputDir + "/docker-compose.yml" 64 | if _, err := os.Stat(composePath); os.IsNotExist(err) { 65 | return fmt.Errorf("%s: %s", t.T("errors.compose_not_found"), composePath) 66 | } 67 | 68 | fmt.Printf("🏥 %s\n", t.T("health.checking_services")) 69 | fmt.Printf("📂 %s: %s\n\n", t.T("health.directory"), healthOutputDir) 70 | 71 | // Get container information 72 | containers, err := getContainerStatus(healthOutputDir) 73 | if err != nil { 74 | return fmt.Errorf("%s: %w", t.T("errors.failed_to_get_status"), err) 75 | } 76 | 77 | if len(containers) == 0 { 78 | fmt.Printf("ℹ️ %s\n", t.T("health.no_containers")) 79 | fmt.Printf("💡 %s: docker compose up -d\n", t.T("health.start_hint")) 80 | return nil 81 | } 82 | 83 | // Display results 84 | displayHealthStatus(t, containers) 85 | 86 | // Summary 87 | running := 0 88 | unhealthy := 0 89 | stopped := 0 90 | 91 | for _, c := range containers { 92 | switch c.Status { 93 | case "running": 94 | running++ 95 | case "exited", "dead": 96 | stopped++ 97 | } 98 | if c.Health == "unhealthy" { 99 | unhealthy++ 100 | } 101 | } 102 | 103 | fmt.Println() 104 | fmt.Println("═════════════════════════════════════════") 105 | fmt.Printf("📊 %s\n", t.T("health.summary")) 106 | fmt.Println("═════════════════════════════════════════") 107 | fmt.Printf("✅ %s: %d\n", t.T("health.running"), running) 108 | if stopped > 0 { 109 | fmt.Printf("⏹️ %s: %d\n", t.T("health.stopped"), stopped) 110 | } 111 | if unhealthy > 0 { 112 | fmt.Printf("❌ %s: %d\n", t.T("health.unhealthy"), unhealthy) 113 | } 114 | fmt.Printf("📦 %s: %d\n", t.T("health.total"), len(containers)) 115 | 116 | // Show commands for stopped/unhealthy containers 117 | if stopped > 0 || unhealthy > 0 { 118 | fmt.Println() 119 | fmt.Printf("💡 %s:\n", t.T("health.suggested_actions")) 120 | if stopped > 0 { 121 | fmt.Printf(" • %s: cd %s && docker compose up -d\n", t.T("health.start_containers"), healthOutputDir) 122 | } 123 | if unhealthy > 0 { 124 | fmt.Printf(" • %s: docker compose logs -f\n", t.T("health.check_logs")) 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | 131 | type ContainerInfo struct { 132 | Name string 133 | Status string 134 | Health string 135 | Uptime string 136 | CPU string 137 | Memory string 138 | Ports []string 139 | } 140 | 141 | type composePSOutput struct { 142 | Name string `json:"Name"` 143 | State string `json:"State"` 144 | Status string `json:"Status"` 145 | Health string `json:"Health"` 146 | } 147 | 148 | func isDockerAvailable() bool { 149 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 150 | defer cancel() 151 | 152 | cmd := exec.CommandContext(ctx, "docker", "info") 153 | return cmd.Run() == nil 154 | } 155 | 156 | func getContainerStatus(dir string) ([]ContainerInfo, error) { 157 | ctx := context.Background() 158 | 159 | cmd := exec.CommandContext(ctx, "docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "--format", "json") 160 | output, err := cmd.Output() 161 | if err != nil { 162 | return getContainerStatusFallback(dir) 163 | } 164 | 165 | var entries []composePSOutput 166 | if err := json.Unmarshal(output, &entries); err != nil { 167 | return getContainerStatusFallback(dir) 168 | } 169 | 170 | containers := make([]ContainerInfo, 0, len(entries)) 171 | for _, entry := range entries { 172 | status := entry.State 173 | if status == "" { 174 | status = entry.Status 175 | } 176 | 177 | containers = append(containers, ContainerInfo{ 178 | Name: entry.Name, 179 | Status: status, 180 | Health: entry.Health, 181 | }) 182 | } 183 | 184 | if healthDetailed { 185 | for i := range containers { 186 | enrichContainerInfo(&containers[i]) 187 | } 188 | } 189 | 190 | return containers, nil 191 | } 192 | 193 | func getContainerStatusFallback(dir string) ([]ContainerInfo, error) { 194 | ctx := context.Background() 195 | 196 | cmd := exec.CommandContext(ctx, "docker", "compose", "-f", dir+"/docker-compose.yml", "ps") 197 | output, err := cmd.Output() 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | var containers []ContainerInfo 203 | lines := strings.Split(string(output), "\n") 204 | 205 | // Skip header 206 | for i, line := range lines { 207 | if i < 1 || line == "" { 208 | continue 209 | } 210 | 211 | fields := strings.Fields(line) 212 | if len(fields) >= 2 { 213 | containers = append(containers, ContainerInfo{ 214 | Name: fields[0], 215 | Status: fields[1], 216 | }) 217 | } 218 | } 219 | 220 | return containers, nil 221 | } 222 | 223 | func enrichContainerInfo(info *ContainerInfo) { 224 | ctx := context.Background() 225 | 226 | // Get container stats 227 | cmd := exec.CommandContext(ctx, "docker", "stats", "--no-stream", "--format", "{{.CPUPerc}}\t{{.MemUsage}}", info.Name) 228 | output, err := cmd.Output() 229 | if err == nil { 230 | parts := strings.Split(strings.TrimSpace(string(output)), "\t") 231 | if len(parts) >= 2 { 232 | info.CPU = parts[0] 233 | info.Memory = parts[1] 234 | } 235 | } 236 | 237 | // Get container inspect for more details 238 | cmd = exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.Status}}\t{{.State.Health.Status}}", info.Name) 239 | output, err = cmd.Output() 240 | if err == nil { 241 | parts := strings.Split(strings.TrimSpace(string(output)), "\t") 242 | if len(parts) >= 1 { 243 | info.Status = parts[0] 244 | } 245 | if len(parts) >= 2 && parts[1] != "" { 246 | info.Health = parts[1] 247 | } 248 | } 249 | } 250 | 251 | func displayHealthStatus(t *i18n.I18n, containers []ContainerInfo) { 252 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 253 | 254 | // Header 255 | if healthDetailed { 256 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", 257 | t.T("health.container"), 258 | t.T("health.status"), 259 | t.T("health.health"), 260 | t.T("health.cpu"), 261 | t.T("health.memory")) 262 | } else { 263 | fmt.Fprintf(w, "%s\t%s\t%s\n", 264 | t.T("health.container"), 265 | t.T("health.status"), 266 | t.T("health.health")) 267 | } 268 | 269 | fmt.Fprintf(w, "%s\t%s\t%s", 270 | strings.Repeat("-", 20), 271 | strings.Repeat("-", 15), 272 | strings.Repeat("-", 15)) 273 | 274 | if healthDetailed { 275 | fmt.Fprintf(w, "\t%s\t%s", strings.Repeat("-", 10), strings.Repeat("-", 15)) 276 | } 277 | fmt.Fprintf(w, "\n") 278 | 279 | // Rows 280 | for _, c := range containers { 281 | statusIcon := getStatusIcon(c.Status) 282 | healthIcon := getHealthIcon(c.Health) 283 | 284 | if healthDetailed { 285 | cpu := c.CPU 286 | if cpu == "" { 287 | cpu = "N/A" 288 | } 289 | mem := c.Memory 290 | if mem == "" { 291 | mem = "N/A" 292 | } 293 | 294 | fmt.Fprintf(w, "%s\t%s %s\t%s %s\t%s\t%s\n", 295 | c.Name, 296 | statusIcon, c.Status, 297 | healthIcon, getHealthText(c.Health), 298 | cpu, 299 | mem) 300 | } else { 301 | fmt.Fprintf(w, "%s\t%s %s\t%s %s\n", 302 | c.Name, 303 | statusIcon, c.Status, 304 | healthIcon, getHealthText(c.Health)) 305 | } 306 | } 307 | 308 | _ = w.Flush() 309 | } 310 | 311 | func getStatusIcon(status string) string { 312 | switch status { 313 | case "running": 314 | return "✅" 315 | case "exited", "dead": 316 | return "❌" 317 | case "paused": 318 | return "⏸️" 319 | case "restarting": 320 | return "🔄" 321 | default: 322 | return "❓" 323 | } 324 | } 325 | 326 | func getHealthIcon(health string) string { 327 | switch health { 328 | case "healthy": 329 | return "💚" 330 | case "unhealthy": 331 | return "❤️" 332 | case "starting": 333 | return "🟡" 334 | default: 335 | return "⚪" 336 | } 337 | } 338 | 339 | func getHealthText(health string) string { 340 | if health == "" { 341 | return "N/A" 342 | } 343 | return health 344 | } 345 | -------------------------------------------------------------------------------- /internal/i18n/locales/en.yaml: -------------------------------------------------------------------------------- 1 | language: 2 | name: "English" 3 | code: "en" 4 | 5 | prompts: 6 | language_select: "Select your language / Selecione seu idioma / Seleccione su idioma" 7 | vpn_question: "Do you want to use VPN (Gluetun)?" 8 | service_selection: "Select the services you want to use:" 9 | requires_vpn_suffix: " (requires VPN)" 10 | has_dependencies_suffix: " *" 11 | base_path: "Base path (ARRPATH):" 12 | base_path_help: "Path to your media library (keep the final slash)" 13 | timezone: "Timezone (TZ):" 14 | timezone_help: "Set your timezone (e.g., Europe/Madrid, America/New_York)" 15 | puid: "User ID (PUID):" 16 | pgid: "Group ID (PGID):" 17 | umask: "UMASK:" 18 | project_name: "Project name:" 19 | confirm_generation: "Confirm file generation?" 20 | save_profile: "Do you want to save this configuration as a profile?" 21 | profile_name: "Profile name:" 22 | output_directory: "Output directory:" 23 | use_same_directory: "Use the same directory for service volumes?" 24 | vpn_provider: "VPN service provider:" 25 | vpn_provider_help: "e.g., mullvad, nordvpn, protonvpn, surfshark" 26 | vpn_type: "VPN type:" 27 | vpn_wireguard_private_key: "Wireguard private key:" 28 | vpn_wireguard_addresses: "Wireguard addresses:" 29 | vpn_wireguard_addresses_help: "e.g., 10.64.0.1/32" 30 | vpn_wireguard_public_key: "Wireguard public key (server):" 31 | vpn_wireguard_endpoint: "Wireguard endpoint:" 32 | vpn_port_forwarding: "Enable port forwarding?" 33 | vpn_dns: "Custom DNS server:" 34 | vpn_dns_help: "Leave default or specify custom DNS (e.g., 1.1.1.1)" 35 | 36 | categories: 37 | download: "Download Managers" 38 | indexer: "Indexers" 39 | media: "Media Management" 40 | subtitles: "Subtitles" 41 | streaming: "Streaming" 42 | request: "Request Management" 43 | transcode: "Transcoding" 44 | vpn: "VPN" 45 | 46 | services_qbittorrent_name: "qBittorrent" 47 | services_qbittorrent_description: "torrent downloader for your files" 48 | services_prowlarr_name: "Prowlarr" 49 | services_prowlarr_description: "indexer aggregator to search for your movies, music, etc. on the internet" 50 | services_flaresolverr_name: "FlareSolverr" 51 | services_flaresolverr_description: "bypasses Cloudflare protection on indexer sites" 52 | services_sonarr_name: "Sonarr" 53 | services_sonarr_description: "TV show finder and manager" 54 | services_radarr_name: "Radarr" 55 | services_radarr_description: "movie finder and manager" 56 | services_lidarr_name: "Lidarr" 57 | services_lidarr_description: "music finder and manager" 58 | services_lazylibrarian_name: "LazyLibrarian" 59 | services_lazylibrarian_description: "book and audiobook finder and manager" 60 | services_bazarr_name: "Bazarr" 61 | services_bazarr_description: "automatically downloads subtitles for your movies and TV shows" 62 | services_jellyfin_name: "Jellyfin" 63 | services_jellyfin_description: "streaming server to watch your media (like your personal Netflix)" 64 | services_jellyseerr_name: "Jellyseerr" 65 | services_jellyseerr_description: "interface for you and your family to request movies and TV shows" 66 | services_fileflows_name: "FileFlows" 67 | services_fileflows_description: "processes and optimizes your media files automatically" 68 | services_gluetun_name: "Gluetun" 69 | services_gluetun_description: "VPN client to protect your downloads" 70 | 71 | messages: 72 | welcome: "🏴‍☠️ Welcome to Corsarr - Navigate the high seas of media automation" 73 | validation_success: "✓ Configuration validated successfully!" 74 | services_configured: "✓ %d services will be configured" 75 | services_selected: "services selected" 76 | generation_cancelled: "Generation cancelled" 77 | generation_complete: "Files generated successfully!" 78 | mode_vpn: "✓ Mode: WITH VPN" 79 | mode_no_vpn: "✓ Mode: WITHOUT VPN" 80 | no_port_conflicts: "✓ No port conflicts detected" 81 | backup_created: "✓ Backup created: %s" 82 | file_created: "✓ %s created successfully" 83 | profile_saved: "✓ Profile '%s' saved" 84 | files_created_in: "Files created in: %s" 85 | start_services: "To start the services, run:" 86 | view_logs: "To view the logs:" 87 | preview_header: "Preview of files to be created:" 88 | preview_compose: "📄 docker-compose.yml (%d lines)" 89 | preview_env: "📄 .env (%d variables)" 90 | 91 | logs: 92 | loading_configuration: "📋 Loading configuration from: {{.source}}" 93 | output_directory_from_config: "📂 Output directory: {{.directory}} (from config)" 94 | configuration_loaded: "✅ Configuration loaded" 95 | loading_profile: "📋 Loading profile: {{.profile}}" 96 | profile_loaded: "✅ Profile loaded: {{.profile}}" 97 | output_directory_from_profile: "📂 Output directory: {{.directory}} (from profile)" 98 | vpn_from_profile: "🔒 VPN: {{.enabled}} (from profile)" 99 | services_from_profile: "📦 Services: {{.services}} (from profile)" 100 | services_from_flags: "📦 Services: {{.services}} (from flags)" 101 | environment_from_profile: "⚙️ Using environment from profile" 102 | environment_from_flags: "⚙️ Using environment from flags" 103 | vpn_gluetun_added: "🔒 VPN enabled: Gluetun added automatically" 104 | validating_configuration: "🔍 Validating configuration..." 105 | validation_warnings: "⚠️ Warnings:" 106 | validation_failed: "❌ Validation failed:" 107 | configuration_validated: "✅ Configuration validated" 108 | generation_cancelled: "❌ Generation cancelled" 109 | generating_files: "🚀 Generating files..." 110 | vpn_mode_status: "📡 VPN Mode: Services will use Gluetun network" 111 | bridge_mode_status: "🌉 Bridge Mode: Each service on media network" 112 | output_directory: "📂 Output directory: {{.directory}}" 113 | next_steps: "📝 Next steps:" 114 | next_step_review: " 1. Review the generated files" 115 | next_step_adjust: " 2. Adjust environment variables in .env if needed" 116 | next_step_run: " 3. Run: cd {{.directory}} && docker compose up -d" 117 | profile_name_prompt: "💾 Profile name: " 118 | profile_name_required: "⚠️ Profile name is required. Skipping profile save." 119 | profile_exists_overwrite: "⚠️ Profile '{{.name}}' already exists. Overwrite? (y/N): " 120 | profile_save_cancelled: "ℹ️ Profile save cancelled" 121 | profile_description_prompt: "📝 Description (optional): " 122 | directories_created: "📁 Created {{.count}} directories for service volumes" 123 | directories_found: "✓ Found {{.count}} existing directories" 124 | preview_dry_run_header: "📋 DRY RUN - Preview Mode" 125 | preview_compose_title: "📄 docker-compose.yml:" 126 | preview_env_title: "📄 .env:" 127 | preview_complete: "✅ Preview complete! Run without --dry-run to generate files." 128 | profile_use_instruction: " Use it with: corsarr generate --profile {{.name}}" 129 | 130 | errors: 131 | invalid_path: "✗ Invalid path: %s" 132 | path_not_writable: "✗ Path is not writable: %s" 133 | port_conflict: "✗ Port conflict detected: %d" 134 | missing_dependency: "✗ Service '%s' requires '%s'" 135 | vpn_credentials_missing: "✗ VPN credentials are missing" 136 | docker_not_found: "✗ Docker is not installed" 137 | docker_compose_not_found: "✗ Docker Compose is not installed" 138 | no_services_selected: "✗ No services selected" 139 | generation_failed: "✗ Failed to generate files: %s" 140 | validation_failed: "✗ Validation failed: %s" 141 | profile_not_found: "✗ Profile '%s' not found" 142 | profile_already_exists: "✗ Profile '%s' already exists" 143 | low_disk_space: "⚠ Warning: Less than %dGB of free disk space" 144 | health_check_failed: "Health check failed" 145 | compose_not_found: "docker-compose.yml file not found" 146 | failed_to_get_status: "Failed to get container status" 147 | port_check_failed: "Port check failed" 148 | failed_to_parse_compose: "Failed to parse docker-compose.yml" 149 | 150 | vpn: 151 | provider_question: "VPN Provider:" 152 | provider_help: "Supported providers: protonvpn, nordvpn, expressvpn, etc." 153 | type_question: "VPN Type:" 154 | wireguard_public_key: "Wireguard Public Key:" 155 | wireguard_private_key: "Wireguard Private Key:" 156 | wireguard_addresses: "Wireguard Addresses:" 157 | port_forwarding: "Enable port forwarding?" 158 | dns_address: "VPN DNS Address:" 159 | 160 | commands: 161 | generate: 162 | short: "Generate docker-compose.yml and .env files" 163 | long: "Generate docker-compose.yml and .env files based on your service selection" 164 | preview: 165 | short: "Preview configuration without generating files" 166 | long: "Show a preview of the docker-compose.yml and .env that would be generated" 167 | profile: 168 | short: "Manage configuration profiles" 169 | long: "Save, load, list, delete, export, and import configuration profiles" 170 | save: "Save current configuration as a profile" 171 | load: "Load a saved profile" 172 | list: "List all saved profiles" 173 | delete: "Delete a profile" 174 | export: "Export a profile to a file" 175 | import: "Import a profile from a file" 176 | health: 177 | short: "Check health of running containers" 178 | long: "Verify if all configured services are running and healthy" 179 | check_ports: 180 | short: "Check for port conflicts" 181 | long: "Verify which ports are in use and suggest alternatives if conflicts exist" 182 | 183 | profile: 184 | name: "Name" 185 | description: "Description" 186 | created_at: "Created at" 187 | updated_at: "Updated at" 188 | version: "Version" 189 | services: "Services" 190 | services_count: "Services" 191 | vpn_enabled: "VPN enabled" 192 | details: "Details" 193 | saved_profiles: "Saved profiles" 194 | no_profiles: "No profiles found" 195 | already_exists: "Profile already exists. Use --force to overwrite" 196 | save_failed: "Failed to save profile" 197 | load_failed: "Failed to load profile" 198 | -------------------------------------------------------------------------------- /internal/i18n/locales/pt-br.yaml: -------------------------------------------------------------------------------- 1 | language: 2 | name: "Português Brasileiro" 3 | code: "pt-br" 4 | 5 | prompts: 6 | language_select: "Select your language / Selecione seu idioma / Seleccione su idioma" 7 | vpn_question: "Deseja usar VPN (Gluetun)?" 8 | service_selection: "Selecione os serviços que deseja usar:" 9 | requires_vpn_suffix: " (requer VPN)" 10 | has_dependencies_suffix: " *" 11 | base_path: "Caminho base (ARRPATH):" 12 | base_path_help: "Caminho para sua biblioteca de mídia (mantenha a barra final)" 13 | timezone: "Fuso horário (TZ):" 14 | timezone_help: "Defina seu fuso horário (ex: America/Sao_Paulo, America/Recife)" 15 | puid: "ID do Usuário (PUID):" 16 | pgid: "ID do Grupo (PGID):" 17 | umask: "UMASK:" 18 | project_name: "Nome do projeto:" 19 | confirm_generation: "Confirmar geração dos arquivos?" 20 | save_profile: "Deseja salvar esta configuração como perfil?" 21 | profile_name: "Nome do perfil:" 22 | output_directory: "Diretório de saída:" 23 | use_same_directory: "Usar o mesmo diretório para volumes de serviço?" 24 | vpn_provider: "Provedor de VPN:" 25 | vpn_provider_help: "ex: mullvad, nordvpn, protonvpn, surfshark" 26 | vpn_type: "Tipo de VPN:" 27 | vpn_wireguard_private_key: "Chave privada Wireguard:" 28 | vpn_wireguard_addresses: "Endereços Wireguard:" 29 | vpn_wireguard_addresses_help: "ex: 10.64.0.1/32" 30 | vpn_wireguard_public_key: "Chave pública Wireguard (servidor):" 31 | vpn_wireguard_endpoint: "Endpoint Wireguard:" 32 | vpn_port_forwarding: "Habilitar encaminhamento de porta?" 33 | vpn_dns: "Servidor DNS customizado:" 34 | vpn_dns_help: "Mantenha o padrão ou especifique DNS customizado (ex: 1.1.1.1)" 35 | 36 | categories: 37 | download: "Gerenciadores de Download" 38 | indexer: "Indexadores" 39 | media: "Gerenciamento de Mídia" 40 | subtitles: "Legendas" 41 | streaming: "Streaming" 42 | request: "Gerenciamento de Requisições" 43 | transcode: "Transcodificação" 44 | vpn: "VPN" 45 | 46 | services_qbittorrent_name: "qBittorrent" 47 | services_qbittorrent_description: "downloads de arquivos via torrent" 48 | services_prowlarr_name: "Prowlarr" 49 | services_prowlarr_description: "agregador de indexadores para buscar seus filmes, músicas, etc na internet" 50 | services_flaresolverr_name: "FlareSolverr" 51 | services_flaresolverr_description: "contorna proteções Cloudflare em sites de indexadores" 52 | services_sonarr_name: "Sonarr" 53 | services_sonarr_description: "buscador e gerenciador de séries de TV" 54 | services_radarr_name: "Radarr" 55 | services_radarr_description: "buscador e gerenciador de filmes" 56 | services_lidarr_name: "Lidarr" 57 | services_lidarr_description: "buscador e gerenciador de músicas" 58 | services_lazylibrarian_name: "LazyLibrarian" 59 | services_lazylibrarian_description: "buscador e gerenciador de livros e audiobooks" 60 | services_bazarr_name: "Bazarr" 61 | services_bazarr_description: "baixa legendas automaticamente para seus filmes e séries" 62 | services_jellyfin_name: "Jellyfin" 63 | services_jellyfin_description: "servidor de streaming para assistir sua mídia (como Netflix pessoal)" 64 | services_jellyseerr_name: "Jellyseerr" 65 | services_jellyseerr_description: "interface para você e sua família solicitarem filmes e séries" 66 | services_fileflows_name: "FileFlows" 67 | services_fileflows_description: "processa e otimiza seus arquivos de mídia automaticamente" 68 | services_gluetun_name: "Gluetun" 69 | services_gluetun_description: "cliente VPN para proteger seus downloads" 70 | 71 | messages: 72 | welcome: "🏴‍☠️ Bem-vindo ao Corsarr - Navegue pelos altos mares da automação de mídia" 73 | validation_success: "✓ Configuração validada com sucesso!" 74 | services_configured: "✓ %d serviços serão configurados" 75 | services_selected: "serviços selecionados" 76 | generation_cancelled: "Geração cancelada" 77 | generation_complete: "Arquivos gerados com sucesso!" 78 | mode_vpn: "✓ Modo: COM VPN" 79 | mode_no_vpn: "✓ Modo: SEM VPN" 80 | no_port_conflicts: "✓ Nenhum conflito de portas detectado" 81 | backup_created: "✓ Backup criado: %s" 82 | file_created: "✓ %s criado com sucesso" 83 | profile_saved: "✓ Perfil '%s' salvo" 84 | files_created_in: "Arquivos criados em: %s" 85 | start_services: "Para iniciar os serviços, execute:" 86 | view_logs: "Para visualizar os logs:" 87 | preview_header: "Preview dos arquivos que serão criados:" 88 | preview_compose: "📄 docker-compose.yml (%d linhas)" 89 | preview_env: "📄 .env (%d variáveis)" 90 | 91 | logs: 92 | loading_configuration: "📋 Carregando configuração de: {{.source}}" 93 | output_directory_from_config: "📂 Diretório de saída: {{.directory}} (do arquivo de configuração)" 94 | configuration_loaded: "✅ Configuração carregada" 95 | loading_profile: "📋 Carregando perfil: {{.profile}}" 96 | profile_loaded: "✅ Perfil carregado: {{.profile}}" 97 | output_directory_from_profile: "📂 Diretório de saída: {{.directory}} (do perfil)" 98 | vpn_from_profile: "🔒 VPN: {{.enabled}} (do perfil)" 99 | services_from_profile: "📦 Serviços: {{.services}} (do perfil)" 100 | services_from_flags: "📦 Serviços: {{.services}} (das flags)" 101 | environment_from_profile: "⚙️ Usando ambiente do perfil" 102 | environment_from_flags: "⚙️ Usando ambiente das flags" 103 | vpn_gluetun_added: "🔒 VPN ativada: Gluetun adicionado automaticamente" 104 | validating_configuration: "🔍 Validando configuração..." 105 | validation_warnings: "⚠️ Avisos:" 106 | validation_failed: "❌ Validação falhou:" 107 | configuration_validated: "✅ Configuração validada" 108 | generation_cancelled: "❌ Geração cancelada" 109 | generating_files: "🚀 Gerando arquivos..." 110 | vpn_mode_status: "📡 Modo VPN: Serviços usarão a rede do Gluetun" 111 | bridge_mode_status: "🌉 Modo Bridge: Cada serviço na rede media" 112 | output_directory: "📂 Diretório de saída: {{.directory}}" 113 | next_steps: "📝 Próximos passos:" 114 | next_step_review: " 1. Revise os arquivos gerados" 115 | next_step_adjust: " 2. Ajuste as variáveis de ambiente em .env se necessário" 116 | next_step_run: " 3. Execute: cd {{.directory}} && docker compose up -d" 117 | profile_name_prompt: "💾 Nome do perfil: " 118 | profile_name_required: "⚠️ Nome do perfil é obrigatório. Salvando perfil cancelado." 119 | profile_exists_overwrite: "⚠️ O perfil '{{.name}}' já existe. Sobrescrever? (y/N): " 120 | profile_save_cancelled: "ℹ️ Salvamento do perfil cancelado" 121 | profile_description_prompt: "📝 Descrição (opcional): " 122 | directories_created: "📁 Criados {{.count}} diretórios para volumes de serviço" 123 | directories_found: "✓ Encontrados {{.count}} diretórios existentes" 124 | preview_dry_run_header: "📋 MODO DRY RUN - Pré-visualização" 125 | preview_compose_title: "📄 docker-compose.yml:" 126 | preview_env_title: "📄 .env:" 127 | preview_complete: "✅ Pré-visualização completa! Execute sem --dry-run para gerar os arquivos." 128 | profile_use_instruction: " Use com: corsarr generate --profile {{.name}}" 129 | 130 | errors: 131 | invalid_path: "✗ Caminho inválido: %s" 132 | path_not_writable: "✗ Caminho sem permissão de escrita: %s" 133 | port_conflict: "✗ Conflito de porta detectado: %d" 134 | missing_dependency: "✗ Serviço '%s' requer '%s'" 135 | vpn_credentials_missing: "✗ Credenciais VPN estão faltando" 136 | docker_not_found: "✗ Docker não está instalado" 137 | docker_compose_not_found: "✗ Docker Compose não está instalado" 138 | no_services_selected: "✗ Nenhum serviço selecionado" 139 | generation_failed: "✗ Falha ao gerar arquivos: %s" 140 | validation_failed: "✗ Validação falhou: %s" 141 | profile_not_found: "✗ Perfil '%s' não encontrado" 142 | profile_already_exists: "✗ Perfil '%s' já existe" 143 | low_disk_space: "⚠ Aviso: Menos de %dGB de espaço livre em disco" 144 | health_check_failed: "Falha na verificação de saúde" 145 | compose_not_found: "Arquivo docker-compose.yml não encontrado" 146 | failed_to_get_status: "Falha ao obter status dos containers" 147 | port_check_failed: "Falha na verificação de portas" 148 | failed_to_parse_compose: "Falha ao analisar docker-compose.yml" 149 | 150 | vpn: 151 | provider_question: "Provedor VPN:" 152 | provider_help: "Provedores suportados: protonvpn, nordvpn, expressvpn, etc." 153 | type_question: "Tipo de VPN:" 154 | wireguard_public_key: "Chave Pública Wireguard:" 155 | wireguard_private_key: "Chave Privada Wireguard:" 156 | wireguard_addresses: "Endereços Wireguard:" 157 | port_forwarding: "Habilitar encaminhamento de portas?" 158 | dns_address: "Endereço DNS da VPN:" 159 | 160 | commands: 161 | generate: 162 | short: "Gerar arquivos docker-compose.yml e .env" 163 | long: "Gerar arquivos docker-compose.yml e .env baseados na sua seleção de serviços" 164 | preview: 165 | short: "Visualizar configuração sem gerar arquivos" 166 | long: "Mostrar uma prévia do docker-compose.yml e .env que seriam gerados" 167 | profile: 168 | short: "Gerenciar perfis de configuração" 169 | long: "Salvar, carregar, listar, deletar, exportar e importar perfis de configuração" 170 | save: "Salvar configuração atual como perfil" 171 | load: "Carregar um perfil salvo" 172 | list: "Listar todos os perfis salvos" 173 | delete: "Deletar um perfil" 174 | export: "Exportar um perfil para um arquivo" 175 | import: "Importar um perfil de um arquivo" 176 | health: 177 | short: "Verificar saúde dos containers em execução" 178 | long: "Verificar se todos os serviços configurados estão rodando e saudáveis" 179 | check_ports: 180 | short: "Verificar conflitos de portas" 181 | long: "Verificar quais portas estão em uso e sugerir alternativas se houver conflitos" 182 | 183 | profile: 184 | name: "Nome" 185 | description: "Descrição" 186 | created_at: "Criado em" 187 | updated_at: "Atualizado em" 188 | version: "Versão" 189 | services: "Serviços" 190 | services_count: "Serviços" 191 | vpn_enabled: "VPN habilitada" 192 | details: "Detalhes" 193 | saved_profiles: "Perfis salvos" 194 | no_profiles: "Nenhum perfil encontrado" 195 | already_exists: "Perfil já existe. Use --force para sobrescrever" 196 | save_failed: "Falha ao salvar perfil" 197 | load_failed: "Falha ao carregar perfil" 198 | -------------------------------------------------------------------------------- /internal/i18n/locales/es.yaml: -------------------------------------------------------------------------------- 1 | language: 2 | name: "Español" 3 | code: "es" 4 | 5 | prompts: 6 | language_select: "Select your language / Selecione seu idioma / Seleccione su idioma" 7 | vpn_question: "¿Desea usar VPN (Gluetun)?" 8 | service_selection: "Seleccione los servicios que desea usar:" 9 | requires_vpn_suffix: " (requiere VPN)" 10 | has_dependencies_suffix: " *" 11 | base_path: "Ruta base (ARRPATH):" 12 | base_path_help: "Ruta a su biblioteca de medios (mantenga la barra final)" 13 | timezone: "Zona horaria (TZ):" 14 | timezone_help: "Defina su zona horaria (ej: Europe/Madrid, America/Mexico_City)" 15 | puid: "ID de Usuario (PUID):" 16 | pgid: "ID de Grupo (PGID):" 17 | umask: "UMASK:" 18 | project_name: "Nombre del proyecto:" 19 | confirm_generation: "¿Confirmar generación de archivos?" 20 | save_profile: "¿Desea guardar esta configuración como perfil?" 21 | profile_name: "Nombre del perfil:" 22 | output_directory: "Directorio de salida:" 23 | use_same_directory: "¿Usar el mismo directorio para los volúmenes del servicio?" 24 | vpn_provider: "Proveedor de VPN:" 25 | vpn_provider_help: "ej: mullvad, nordvpn, protonvpn, surfshark" 26 | vpn_type: "Tipo de VPN:" 27 | vpn_wireguard_private_key: "Clave privada Wireguard:" 28 | vpn_wireguard_addresses: "Direcciones Wireguard:" 29 | vpn_wireguard_addresses_help: "ej: 10.64.0.1/32" 30 | vpn_wireguard_public_key: "Clave pública Wireguard (servidor):" 31 | vpn_wireguard_endpoint: "Endpoint Wireguard:" 32 | vpn_port_forwarding: "¿Habilitar reenvío de puertos?" 33 | vpn_dns: "Servidor DNS personalizado:" 34 | vpn_dns_help: "Mantenga el predeterminado o especifique DNS personalizado (ej: 1.1.1.1)" 35 | 36 | categories: 37 | download: "Gestores de Descarga" 38 | indexer: "Indexadores" 39 | media: "Gestión de Medios" 40 | subtitles: "Subtítulos" 41 | streaming: "Streaming" 42 | request: "Gestión de Solicitudes" 43 | transcode: "Transcodificación" 44 | vpn: "VPN" 45 | 46 | services_qbittorrent_name: "qBittorrent" 47 | services_qbittorrent_description: "descarga de archivos vía torrent" 48 | services_prowlarr_name: "Prowlarr" 49 | services_prowlarr_description: "agregador de indexadores para buscar tus películas, música, etc. en internet" 50 | services_flaresolverr_name: "FlareSolverr" 51 | services_flaresolverr_description: "evita protecciones Cloudflare en sitios de indexadores" 52 | services_sonarr_name: "Sonarr" 53 | services_sonarr_description: "buscador y gestor de series de TV" 54 | services_radarr_name: "Radarr" 55 | services_radarr_description: "buscador y gestor de películas" 56 | services_lidarr_name: "Lidarr" 57 | services_lidarr_description: "buscador y gestor de música" 58 | services_lazylibrarian_name: "LazyLibrarian" 59 | services_lazylibrarian_description: "buscador y gestor de libros y audiolibros" 60 | services_bazarr_name: "Bazarr" 61 | services_bazarr_description: "descarga subtítulos automáticamente para tus películas y series" 62 | services_jellyfin_name: "Jellyfin" 63 | services_jellyfin_description: "servidor de streaming para ver tu media (como tu Netflix personal)" 64 | services_jellyseerr_name: "Jellyseerr" 65 | services_jellyseerr_description: "interfaz para que tú y tu familia soliciten películas y series" 66 | services_fileflows_name: "FileFlows" 67 | services_fileflows_description: "procesa y optimiza tus archivos multimedia automáticamente" 68 | services_gluetun_name: "Gluetun" 69 | services_gluetun_description: "cliente VPN para proteger tus descargas" 70 | 71 | messages: 72 | welcome: "🏴‍☠️ Bienvenido a Corsarr - Navegue por los altos mares de la automatización de medios" 73 | validation_success: "✓ ¡Configuración validada con éxito!" 74 | services_configured: "✓ %d servicios serán configurados" 75 | services_selected: "servicios seleccionados" 76 | generation_cancelled: "Generación cancelada" 77 | generation_complete: "¡Archivos generados con éxito!" 78 | mode_vpn: "✓ Modo: CON VPN" 79 | mode_no_vpn: "✓ Modo: SIN VPN" 80 | no_port_conflicts: "✓ No se detectaron conflictos de puertos" 81 | backup_created: "✓ Copia de seguridad creada: %s" 82 | file_created: "✓ %s creado con éxito" 83 | profile_saved: "✓ Perfil '%s' guardado" 84 | files_created_in: "Archivos creados en: %s" 85 | start_services: "Para iniciar los servicios, ejecute:" 86 | view_logs: "Para ver los registros:" 87 | preview_header: "Vista previa de los archivos a crear:" 88 | preview_compose: "📄 docker-compose.yml (%d líneas)" 89 | preview_env: "📄 .env (%d variables)" 90 | 91 | logs: 92 | loading_configuration: "📋 Cargando configuración desde: {{.source}}" 93 | output_directory_from_config: "📂 Directorio de salida: {{.directory}} (desde configuración)" 94 | configuration_loaded: "✅ Configuración cargada" 95 | loading_profile: "📋 Cargando perfil: {{.profile}}" 96 | profile_loaded: "✅ Perfil cargado: {{.profile}}" 97 | output_directory_from_profile: "📂 Directorio de salida: {{.directory}} (desde perfil)" 98 | vpn_from_profile: "🔒 VPN: {{.enabled}} (desde perfil)" 99 | services_from_profile: "📦 Servicios: {{.services}} (desde perfil)" 100 | services_from_flags: "📦 Servicios: {{.services}} (desde flags)" 101 | environment_from_profile: "⚙️ Usando entorno del perfil" 102 | environment_from_flags: "⚙️ Usando entorno desde flags" 103 | vpn_gluetun_added: "🔒 VPN activada: Gluetun agregado automáticamente" 104 | validating_configuration: "🔍 Validando configuración..." 105 | validation_warnings: "⚠️ Advertencias:" 106 | validation_failed: "❌ Validación fallida:" 107 | configuration_validated: "✅ Configuración validada" 108 | generation_cancelled: "❌ Generación cancelada" 109 | generating_files: "🚀 Generando archivos..." 110 | vpn_mode_status: "📡 Modo VPN: Los servicios usarán la red de Gluetun" 111 | bridge_mode_status: "🌉 Modo Bridge: Cada servicio en la red media" 112 | output_directory: "📂 Directorio de salida: {{.directory}}" 113 | next_steps: "📝 Próximos pasos:" 114 | next_step_review: " 1. Revisa los archivos generados" 115 | next_step_adjust: " 2. Ajusta las variables de entorno en .env si es necesario" 116 | next_step_run: " 3. Ejecuta: cd {{.directory}} && docker compose up -d" 117 | profile_name_prompt: "💾 Nombre del perfil: " 118 | profile_name_required: "⚠️ El nombre del perfil es obligatorio. Se omite el guardado." 119 | profile_exists_overwrite: "⚠️ El perfil '{{.name}}' ya existe. ¿Sobrescribir? (y/N): " 120 | profile_save_cancelled: "ℹ️ Guardado de perfil cancelado" 121 | profile_description_prompt: "📝 Descripción (opcional): " 122 | directories_created: "📁 Se crearon {{.count}} directorios para los volúmenes" 123 | directories_found: "✓ Se encontraron {{.count}} directorios existentes" 124 | preview_dry_run_header: "📋 EJECUCIÓN EN VACÍO - Vista previa" 125 | preview_compose_title: "📄 docker-compose.yml:" 126 | preview_env_title: "📄 .env:" 127 | preview_complete: "✅ Vista previa completa. Ejecute sin --dry-run para generar los archivos." 128 | profile_use_instruction: " Úsalo con: corsarr generate --profile {{.name}}" 129 | 130 | errors: 131 | invalid_path: "✗ Ruta inválida: %s" 132 | path_not_writable: "✗ Ruta sin permisos de escritura: %s" 133 | port_conflict: "✗ Conflicto de puerto detectado: %d" 134 | missing_dependency: "✗ El servicio '%s' requiere '%s'" 135 | vpn_credentials_missing: "✗ Faltan las credenciales de VPN" 136 | docker_not_found: "✗ Docker no está instalado" 137 | docker_compose_not_found: "✗ Docker Compose no está instalado" 138 | no_services_selected: "✗ No se seleccionaron servicios" 139 | generation_failed: "✗ Error al generar archivos: %s" 140 | validation_failed: "✗ Validación fallida: %s" 141 | profile_not_found: "✗ Perfil '%s' no encontrado" 142 | profile_already_exists: "✗ El perfil '%s' ya existe" 143 | low_disk_space: "⚠ Advertencia: Menos de %dGB de espacio libre en disco" 144 | health_check_failed: "Error en verificación de salud" 145 | compose_not_found: "Archivo docker-compose.yml no encontrado" 146 | failed_to_get_status: "Error al obtener estado de contenedores" 147 | port_check_failed: "Error en verificación de puertos" 148 | failed_to_parse_compose: "Error al analizar docker-compose.yml" 149 | 150 | vpn: 151 | provider_question: "Proveedor VPN:" 152 | provider_help: "Proveedores soportados: protonvpn, nordvpn, expressvpn, etc." 153 | type_question: "Tipo de VPN:" 154 | wireguard_public_key: "Clave Pública Wireguard:" 155 | wireguard_private_key: "Clave Privada Wireguard:" 156 | wireguard_addresses: "Direcciones Wireguard:" 157 | port_forwarding: "¿Habilitar reenvío de puertos?" 158 | dns_address: "Dirección DNS de VPN:" 159 | 160 | commands: 161 | generate: 162 | short: "Generar archivos docker-compose.yml y .env" 163 | long: "Generar archivos docker-compose.yml y .env basados en su selección de servicios" 164 | preview: 165 | short: "Vista previa de configuración sin generar archivos" 166 | long: "Mostrar una vista previa del docker-compose.yml y .env que se generarían" 167 | profile: 168 | short: "Gestionar perfiles de configuración" 169 | long: "Guardar, cargar, listar, eliminar, exportar e importar perfiles de configuración" 170 | save: "Guardar configuración actual como perfil" 171 | load: "Cargar un perfil guardado" 172 | list: "Listar todos los perfiles guardados" 173 | delete: "Eliminar un perfil" 174 | export: "Exportar un perfil a un archivo" 175 | import: "Importar un perfil desde un archivo" 176 | health: 177 | short: "Verificar salud de contenedores en ejecución" 178 | long: "Verificar si todos los servicios configurados están ejecutándose y saludables" 179 | check_ports: 180 | short: "Verificar conflictos de puertos" 181 | long: "Verificar qué puertos están en uso y sugerir alternativas si existen conflictos" 182 | 183 | profile: 184 | name: "Nombre" 185 | description: "Descripción" 186 | created_at: "Creado el" 187 | updated_at: "Actualizado el" 188 | version: "Versión" 189 | services: "Servicios" 190 | services_count: "Servicios" 191 | vpn_enabled: "VPN habilitada" 192 | details: "Detalles" 193 | saved_profiles: "Perfiles guardados" 194 | no_profiles: "No se encontraron perfiles" 195 | already_exists: "El perfil ya existe. Use --force para sobrescribir" 196 | save_failed: "Error al guardar perfil" 197 | load_failed: "Error al cargar perfil" 198 | --------------------------------------------------------------------------------