diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..fdcccfb --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,74 @@ +name: Docker Image + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag' + required: true + release: + types: [created] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + VERSION: ${{ github.event.inputs.tag || github.event.release.tag_name || '' }} + +jobs: + build-and-push: + runs-on: k8s-infrastructure-dind + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if VERSION follows the x.x.x format + run: | + if [[ "${{ env.VERSION }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "LATEST_TAG_ENABLED=true" >> $GITHUB_ENV + else + echo "LATEST_TAG_ENABLED=false" >> $GITHUB_ENV + fi + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha + type=semver,pattern={{version}},value=${{ env.VERSION }} + type=raw,value=latest,enable=${{ env.LATEST_TAG_ENABLED == 'true' }} + type=raw,value=testnet + flavor: | + latest=false + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ env.VERSION }} + secrets: | + GIT_AUTH_TOKEN=${{ secrets.GH_PAT }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0584dfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/local.yaml +config/docker-compose.yaml +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5411676 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +# Install git and build dependencies +RUN apk add --no-cache git build-base + +WORKDIR /app + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Copy the source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o app . + +# Final stage +FROM alpine:3.17 + +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache ca-certificates curl + +# Copy the binary and goose from the builder stage +COPY --from=builder /app/app /app/app + +# Ensure the binary is executable +RUN chmod +x /app/app diff --git a/README.md b/README.md new file mode 100644 index 0000000..032b885 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Blockscout VC Sidecar + +## Overview + +The **Blockscout VC Sidecar** service monitors database changes in Supabase and automatically updates Docker container configurations and restarts services when necessary. This ensures that your Blockscout services are always running with the latest configuration. Intention is to make sure that changes made in Cloud Console are reflected in the running containers. + +## Configuration File + +To configure the service, use a YAML file with the following structure: + +```yaml +supabaseRealtimeUrl: "wss://your-project.supabase.co/realtime/v1/websocket" +supabaseAnonKey: "your-anon-key" +pathToDockerCompose: "./config/docker-compose.yaml" +frontendServiceName: "frontend" +backendServiceName: "backend" +statsServiceName: "stats" +table: "silos" +chainId: 10 +``` + +## Running the Service with Docker Compose + +The service can be deployed using Docker Compose. Below is an example configuration: + +```yaml +services: + blockscout-vc-sidecar: + image: ghcr.io/aurora-is-near/blockscout-vc:latest + container_name: blockscout-vc + pull_policy: always + command: ["--config", "/app/config/local.yaml"] + volumes: + - ./config:/app/config + - /var/run/docker.sock:/var/run/docker.sock + - ./docker-compose.yaml:/app/config/docker-compose.yaml:ro + restart: unless-stopped +``` + +### Important Notes +- Configuration files should be mounted in the `/app/config` directory + +### Basic Commands + +Start the service: +```bash +docker compose up -d +``` + +Restart the service: +```bash +docker compose up -d --force-recreate +``` + +Stop the service: +```bash +docker compose down +``` + +View logs: +```bash +docker logs -f blockscout-vc-sidecar +``` + +## Features + +- Monitors Supabase database changes in real-time +- Automatically updates Docker Compose environment variables +- Restarts affected services when configuration changes +- Handles multiple service updates efficiently +- Prevents duplicate container restarts +- Validates configuration changes before applying + +## Development + +### Prerequisites + +- Go 1.21 or later +- Docker +- Docker Compose + +### Building from Source + +1. Clone the repository: +```bash +git clone https://github.com/blockscout/blockscout-vc-sidecar.git +``` + +2. Build the binary: +```bash +go build -o blockscout-vc-sidecar +``` + +3. Run with configuration: +```bash +./blockscout-vc-sidecar --config config/local.yaml +``` + +### Project Structure + +``` +blockscout-vc/ +├── cmd/ +│ └── root.go +│ └── sidecar.go +├── internal/ +│ ├── client/ # WebSocket client implementation +│ ├── config/ # Configuration handling +│ ├── docker/ # Docker operations +│ ├── handlers/ # Event handlers +│ ├── heartbeat/ # Heartbeat logic +│ └── subscription/ # Supabase subscription logic +│ └── worker/ # Worker implementation +├── config/ +│ └── local.yaml # Configuration file +└── main.go +``` + +## Configuration Options + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `supabaseRealtimeUrl` | Supabase Realtime WebSocket URL | Yes | +| `supabaseAnonKey` | Supabase Anonymous Key | Yes | +| `pathToDockerCompose` | Path to the Docker Compose file | Yes | +| `frontendServiceName` | Name of the frontend service | Yes | +| `backendServiceName` | Name of the backend service | Yes | +| `statsServiceName` | Name of the stats service | Yes | +| `table` | Name of the table to listen to | Yes | +| `chainId` | Chain ID to listen to | Yes | + +## Debugging + +Enable debug logging by setting the environment variable: +```bash +export LOG_LEVEL=debug +``` + +## Deployment Guide + +### Release and Versioning + +Releases are managed via GitHub with canonical versioning (e.g., `0.1.2`). Ensure the versioning follows semantic versioning guidelines. + +To release a new version: +1. Create a release on GitHub, specifying the appropriate tag (following semantic versioning guidelines). +2. This will trigger the build and push workflows to create a new Docker image and store it in the GitHub registry. \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..62d81b4 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func RootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "blockscout-vc", + Short: "Blockscout Virtual Chain toolset", + Long: `Blockscout Virtual Chain toolset`, + // Default behavior is to show help + Run: func(cmd *cobra.Command, args []string) { + err := cmd.Help() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, + } + + return rootCmd +} diff --git a/cmd/sidecar.go b/cmd/sidecar.go new file mode 100644 index 0000000..9f619cf --- /dev/null +++ b/cmd/sidecar.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "blockscout-vc/internal/client" + "blockscout-vc/internal/config" + "blockscout-vc/internal/heartbeat" + "blockscout-vc/internal/subscription" + "blockscout-vc/internal/worker" + "context" + "fmt" + "os" + "os/signal" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// StartSidecarCmd creates and returns the sidecar command +func StartSidecarCmd() *cobra.Command { + startServer := &cobra.Command{ + Use: "sidecar", + Short: "Start sidecar", + Long: `Starts sidecar to listen for changes in the database and recreate the containers`, + // Initialize configuration before running + PreRun: func(cmd *cobra.Command, args []string) { + config.InitConfig() + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Create a cancellable context + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize WebSocket client + supabaseRealtimeUrl := viper.GetString("supabaseRealtimeUrl") + supabaseAnonKey := viper.GetString("supabaseAnonKey") + client := client.New(supabaseRealtimeUrl, supabaseAnonKey) + if err := client.Connect(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer client.Close() + + // Initialize and start the worker + worker := worker.New() + worker.Start(ctx) + + // Initialize and start heartbeat service + hb := heartbeat.New(client, 30*time.Second) + hb.Start() + defer hb.Stop() + + // Initialize and start subscription service + sub := subscription.New(client) + sub.Subscribe(worker) + defer sub.Stop() + + // Wait for interrupt signal + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + <-interrupt + fmt.Println("\nReceived interrupt signal, shutting down.") + return nil + }, + } + + return startServer +} diff --git a/config/example.yaml b/config/example.yaml new file mode 100644 index 0000000..f60c04b --- /dev/null +++ b/config/example.yaml @@ -0,0 +1,9 @@ +supabaseUrl: "postgresql://postgres:postgres@localhost:5432/postgres" +supabaseRealtimeUrl: "wss://localhost:5432/realtime/v1/websocket" +supabaseAnonKey: "replace-with-actual-anon-key" +pathToDockerCompose: "./config/docker-compose.yaml" +frontendServiceName: "frontend" +backendServiceName: "backend" +statsServiceName: "stats" +table: "silos" +chainId: "replace-with-actual-chain-id" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..753df76 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module blockscout-vc + +go 1.23.3 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.0 + github.com/lib/pq v1.10.9 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d8c72e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..f514de0 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,58 @@ +// Package client provides WebSocket client functionality for Supabase Realtime +package client + +import ( + "fmt" + "log" + "net/http" + + "github.com/gorilla/websocket" +) + +// Client represents a WebSocket client connection to Supabase Realtime +type Client struct { + conn *websocket.Conn + apiKey string + endpoint string + handlers map[string]func([]byte) + Conn *websocket.Conn // Public connection instance for external use +} + +// New creates a new WebSocket client with the specified endpoint and API key +func New(endpoint, apiKey string) *Client { + return &Client{ + endpoint: endpoint, + apiKey: apiKey, + handlers: make(map[string]func([]byte)), + Conn: nil, + } +} + +// Connect establishes a WebSocket connection to the Supabase Realtime server +// It configures the connection with the necessary headers and authentication +func (c *Client) Connect() error { + header := http.Header{} + header.Add("Authorization", "Bearer "+c.apiKey) + + dialer := websocket.Dialer{ + EnableCompression: true, + } + + conn, resp, err := dialer.Dial(c.endpoint+"?apikey="+c.apiKey, header) + if err != nil { + if resp != nil { + log.Printf("HTTP Response Status: %s", resp.Status) + log.Printf("HTTP Response Headers: %v", resp.Header) + } + log.Fatalf("Failed to connect to Realtime server: %v", err) + } + c.Conn = conn + + fmt.Println("Connected to Supabase Realtime!") + return nil +} + +// Close terminates the WebSocket connection +func (c *Client) Close() error { + return c.Conn.Close() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..28f9608 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,23 @@ +package config + +import ( + "log" + + "github.com/spf13/viper" +) + +// InitConfig initializes the application configuration using viper +// It looks for a configuration file named 'local.yaml' in the config directory +func InitConfig() { + viper.AddConfigPath("config") // path to look for the config file in + viper.SetConfigName("local") // name of the config file (without extension) + viper.SetConfigType("yaml") // type of the config file + + // Enable automatic environment variable binding + viper.AutomaticEnv() + + // Attempt to read the configuration file + if err := viper.ReadInConfig(); err != nil { + log.Printf("Can't read config: %s\n", err) + } +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..89dac5f --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,120 @@ +package docker + +import ( + "fmt" + "os" + "os/exec" + "sort" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +type Docker struct { + ContainerName string + PathToDockerCompose string +} + +func NewDocker() *Docker { + return &Docker{ + PathToDockerCompose: viper.GetString("pathToDockerCompose"), + } +} + +// RecreateContainers stops, removes and recreates specified containers +// It uses docker-compose to handle the container lifecycle +func (d *Docker) RecreateContainers(containerNames []string) error { + pathToDockerCompose := viper.GetString("pathToDockerCompose") + uniqueContainers := d.UniqueContainerNames(containerNames) + + args := []string{"compose", "-f", pathToDockerCompose, "up", "-d", "--force-recreate"} + for _, containerName := range uniqueContainers { + args = append(args, containerName) + } + + cmd := exec.Command("docker", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("Running docker-compose up -d --force-recreate %v\n", containerNames) + + if err := cmd.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + return err + } + + fmt.Println("Docker containers recreated successfully!") + return nil +} + +// ReadComposeFile reads and parses the Docker compose file +func (d *Docker) ReadComposeFile() (map[string]interface{}, error) { + data, err := os.ReadFile(d.PathToDockerCompose) + if err != nil { + return nil, fmt.Errorf("failed to read compose file: %w", err) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse compose file: %w", err) + } + + return config, nil +} + +// WriteComposeFile writes the updated compose configuration back to the file +func (d *Docker) WriteComposeFile(compose map[string]interface{}) error { + data, err := yaml.Marshal(compose) + if err != nil { + return fmt.Errorf("failed to marshal compose file: %w", err) + } + + if err := os.WriteFile(d.PathToDockerCompose, data, 0644); err != nil { + return fmt.Errorf("failed to write compose file: %w", err) + } + + return nil +} + +// UpdateServiceEnv updates environment variables for a specific service in the compose file +// Returns the updated compose configuration and whether any changes were made +func (d *Docker) UpdateServiceEnv(compose map[string]interface{}, serviceName string, env map[string]interface{}) (map[string]interface{}, bool, error) { + updated := false + services, ok := compose["services"].(map[string]interface{}) + if !ok { + return nil, updated, fmt.Errorf("services section not found") + } + + service, ok := services[serviceName].(map[string]interface{}) + if !ok { + return nil, updated, fmt.Errorf("service %s not found", serviceName) + } + + serviceEnv, ok := service["environment"].(map[string]interface{}) + if !ok { + return nil, updated, fmt.Errorf("environment section not found in service") + } + + for key, value := range env { + if serviceEnv[key] != value { + serviceEnv[key] = value + updated = true + } + } + + return compose, updated, nil +} + +// UniqueContainerNames returns a sorted list of unique container names +func (d *Docker) UniqueContainerNames(containerNames []string) []string { + unique := make(map[string]bool) + for _, name := range containerNames { + unique[name] = true + } + keys := make([]string, 0, len(unique)) + for k := range unique { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/handlers/coin.go b/internal/handlers/coin.go new file mode 100644 index 0000000..d0a4617 --- /dev/null +++ b/internal/handlers/coin.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "fmt" + + "github.com/spf13/viper" +) + +// MaxCoinLength defines the maximum allowed length for a coin symbol +const MaxCoinLength = 20 + +type CoinHandler struct { + BaseHandler +} + +func NewCoinHandler() *CoinHandler { + return &CoinHandler{ + BaseHandler: NewBaseHandler(), + } +} + +// Handle processes coin-related changes and updates service configurations +func (h *CoinHandler) Handle(record *Record) HandlerResult { + result := HandlerResult{} + + if err := h.validateCoin(record.Coin); err != nil { + result.Error = fmt.Errorf("invalid coin: %w", err) + return result + } + + compose, err := h.docker.ReadComposeFile() + if err != nil { + result.Error = fmt.Errorf("failed to read compose file: %w", err) + return result + } + frontendService := viper.GetString("frontendServiceName") + backendService := viper.GetString("backendServiceName") + statsService := viper.GetString("statsServiceName") + + // Define environment updates for each service + updates := map[string]map[string]interface{}{ + frontendService: { + "NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL": record.Coin, + }, + backendService: { + "COIN": record.Coin, + }, + statsService: { + "STATS_CHARTS__TEMPLATE_VALUES__NATIVE_COIN_SYMBOL": record.Coin, + }, + } + + // Apply updates to each service + for service, env := range updates { + var updated bool + compose, updated, err = h.docker.UpdateServiceEnv(compose, service, env) + if err != nil { + result.Error = fmt.Errorf("failed to update %s service environment: %w", service, err) + return result + } + if updated { + fmt.Printf("Updated %s service environment: %+v\n", service, env) + result.ContainersToRestart = append(result.ContainersToRestart, service) + } + } + + if err = h.docker.WriteComposeFile(compose); err != nil { + result.Error = fmt.Errorf("failed to write compose file: %w", err) + return result + } + + return result +} + +// validateCoin checks if the coin symbol meets the required criteria +func (h *CoinHandler) validateCoin(coin string) error { + if coin == "" { + return fmt.Errorf("coin symbol cannot be empty") + } + if len(coin) == 0 { + return fmt.Errorf("coin symbol cannot be empty") + } + if len(coin) > MaxCoinLength { + return fmt.Errorf("coin symbol length cannot exceed %d characters", MaxCoinLength) + } + return nil +} diff --git a/internal/handlers/image.go b/internal/handlers/image.go new file mode 100644 index 0000000..f00ab09 --- /dev/null +++ b/internal/handlers/image.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "fmt" + + "github.com/spf13/viper" +) + +// MaxImageLength defines the maximum allowed length for image URLs +const MaxImageLength = 2000 + +type ImageHandler struct { + BaseHandler +} + +func NewImageHandler() *ImageHandler { + return &ImageHandler{ + BaseHandler: NewBaseHandler(), + } +} + +// Handle processes image-related changes and updates service configurations +// It handles light logo, dark logo, and favicon URL updates +func (h *ImageHandler) Handle(record *Record) HandlerResult { + result := HandlerResult{} + + // Skip if no image URLs are provided + if record.LightLogoURL == "" && record.DarkLogoURL == "" && record.FaviconURL == "" { + return result + } + + compose, err := h.docker.ReadComposeFile() + if err != nil { + result.Error = fmt.Errorf("failed to read compose file: %w", err) + return result + } + + frontendService := viper.GetString("frontendServiceName") + + updates := map[string]map[string]interface{}{ + frontendService: {}, + } + + // Validate and update light logo URL + if err := h.validateImage(record.LightLogoURL); err != nil { + result.Error = fmt.Errorf("invalid light logo URL: %w", err) + return result + } else { + updates[frontendService]["NEXT_PUBLIC_NETWORK_LOGO"] = record.LightLogoURL + } + + // Validate and update dark logo URL + if err := h.validateImage(record.DarkLogoURL); err != nil { + result.Error = fmt.Errorf("invalid dark logo URL: %w", err) + return result + } else { + updates[frontendService]["NEXT_PUBLIC_NETWORK_LOGO_DARK"] = record.DarkLogoURL + } + + // Validate and update favicon URL + if err := h.validateImage(record.FaviconURL); err != nil { + result.Error = fmt.Errorf("invalid favicon URL: %w", err) + return result + } else { + updates[frontendService]["NEXT_PUBLIC_NETWORK_ICON"] = record.FaviconURL + } + + // Apply updates to services + for service, env := range updates { + var updated bool + compose, updated, err = h.docker.UpdateServiceEnv(compose, service, env) + if err != nil { + result.Error = fmt.Errorf("failed to update %s service environment: %w", service, err) + return result + } + if updated { + fmt.Printf("Updated %s service environment: %+v\n", service, env) + result.ContainersToRestart = append(result.ContainersToRestart, service) + } + } + + err = h.docker.WriteComposeFile(compose) + if err != nil { + result.Error = fmt.Errorf("failed to write compose file: %w", err) + return result + } + + return result +} + +// validateImage checks if the image URL meets the required criteria +func (h *ImageHandler) validateImage(image string) error { + if image == "" { + return fmt.Errorf("image cannot be empty") + } + if len(image) == 0 { + return fmt.Errorf("image cannot be empty") + } + if len(image) > MaxImageLength { + return fmt.Errorf("image length cannot exceed %d characters", MaxImageLength) + } + return nil +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go new file mode 100644 index 0000000..c4dcca8 --- /dev/null +++ b/internal/handlers/types.go @@ -0,0 +1,39 @@ +package handlers + +import "blockscout-vc/internal/docker" + +// Handler defines the interface for all update handlers +type Handler interface { + Handle(record *Record) HandlerResult +} + +// HandlerResult represents the outcome of a handler's processing +type HandlerResult struct { + Error error // Any error that occurred during handling + ContainersToRestart []string // List of container names that need to be restarted +} + +// Record represents the common data structure for all handlers +// containing the database record fields +type Record struct { + ID int `json:"id"` + Name string `json:"name"` + Coin string `json:"coin"` + ChainID int `json:"chain_id"` + LightLogoURL string `json:"light_logo_url"` + DarkLogoURL string `json:"dark_logo_url"` + FaviconURL string `json:"favicon_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// BaseHandler provides common functionality for handlers +type BaseHandler struct { + docker *docker.Docker +} + +func NewBaseHandler() BaseHandler { + return BaseHandler{ + docker: docker.NewDocker(), + } +} diff --git a/internal/heartbeat/heartbeat.go b/internal/heartbeat/heartbeat.go new file mode 100644 index 0000000..6a23f43 --- /dev/null +++ b/internal/heartbeat/heartbeat.go @@ -0,0 +1,63 @@ +// Package heartbeat provides functionality for maintaining WebSocket connection health +package heartbeat + +import ( + "blockscout-vc/internal/client" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type HeartbeatService struct { + client *client.Client + interval time.Duration + stopChan chan struct{} +} + +type HeartbeatPayload struct { + Event string `json:"event"` + Topic string `json:"topic"` + Payload map[string]interface{} `json:"payload"` + Ref string `json:"ref"` +} + +func New(client *client.Client, interval time.Duration) *HeartbeatService { + return &HeartbeatService{ + client: client, + interval: interval, + stopChan: make(chan struct{}), + } +} + +// sendHeartbeat sends a single heartbeat message through the WebSocket connection +func sendHeartbeat(conn *websocket.Conn) error { + heartbeat := HeartbeatPayload{ + Event: "heartbeat", + Topic: "phoenix", + Payload: map[string]interface{}{}, + Ref: uuid.New().String(), + } + return conn.WriteJSON(heartbeat) +} + +// Start begins sending periodic heartbeat messages +func (h *HeartbeatService) Start() { + ticker := time.NewTicker(h.interval) + go func() { + for { + select { + case <-ticker.C: + sendHeartbeat(h.client.Conn) + case <-h.stopChan: + ticker.Stop() + return + } + } + }() +} + +// Stop terminates the heartbeat service +func (h *HeartbeatService) Stop() { + close(h.stopChan) +} diff --git a/internal/subscription/subscription.go b/internal/subscription/subscription.go new file mode 100644 index 0000000..bd3f4a0 --- /dev/null +++ b/internal/subscription/subscription.go @@ -0,0 +1,231 @@ +package subscription + +import ( + "blockscout-vc/internal/client" + "blockscout-vc/internal/handlers" + "blockscout-vc/internal/worker" + "database/sql" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + + "github.com/google/uuid" + _ "github.com/lib/pq" + "github.com/spf13/viper" +) + +// Package subscription handles real-time database changes and container updates +type Subscription struct { + client *client.Client +} + +// PostgresChange represents a single database change subscription configuration +type PostgresChange struct { + Event string `json:"event"` + Schema string `json:"schema"` + Table string `json:"table"` + Filter string `json:"filter,omitempty"` +} + +// SubscriptionPayload is the message sent to establish a real-time connection +type SubscriptionPayload struct { + Event string `json:"event"` + Topic string `json:"topic"` + Payload struct { + Config struct { + Broadcast struct { + Self bool `json:"self"` + } `json:"broadcast"` + PostgresChanges []PostgresChange `json:"postgres_changes"` + } `json:"config"` + } `json:"payload"` + Ref string `json:"ref"` +} + +// PostgresChanges represents a database change event received from Supabase +type PostgresChanges struct { + Event string `json:"event"` + Payload struct { + Data struct { + Table string `json:"table"` + Type string `json:"type"` + Record handlers.Record `json:"record"` + } `json:"data"` + } `json:"payload"` + Worker *worker.Worker +} + +// New creates a new Subscription instance +func New(client *client.Client) *Subscription { + return &Subscription{ + client: client, + } +} + +// Subscribe starts listening for database changes and handles container updates +func (s *Subscription) Subscribe(worker *worker.Worker) error { + // Run initial check first to handle existing records + if err := s.InitialCheck(worker); err != nil { + return fmt.Errorf("failed initial check: %w", err) + } + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + // Start listening for WebSocket messages + go func() { + for { + _, message, err := s.client.Conn.ReadMessage() + if err != nil { + log.Printf("Read error: %v", err) + os.Exit(1) + } + record, err := NewPostgresChanges(message, worker) + if err != nil { + log.Printf("Failed to handle payload: %v", err) + continue + } + + fmt.Printf("Received event: %s\n", record.Event) + if record.Event == "postgres_changes" { + table := viper.GetString("table") + if record.Payload.Data.Table == table { + record.HandleMessage() + } else { + log.Printf("Unhandled table: %s", record.Payload.Data.Table) + } + } + } + }() + + table := viper.GetString("table") + // Create subscription payload + payload := SubscriptionPayload{ + Event: "phx_join", + Topic: fmt.Sprintf("realtime:public:%s", table), + Ref: uuid.New().String(), + } + payload.Payload.Config.Broadcast.Self = true + chainId := viper.GetInt("chainId") + payload.Payload.Config.PostgresChanges = []PostgresChange{ + { + Event: "*", // Listen to all events (INSERT, UPDATE, DELETE) + Schema: "public", // Database schema + Table: table, // Table name + Filter: fmt.Sprintf("chain_id=eq.%d", chainId), + }, + } + + // Send subscription request + if err := s.client.Conn.WriteJSON(payload); err != nil { + log.Fatalf("Failed to subscribe: %v", err) + } + fmt.Println("Subscribed to table changes.") + return nil +} + +// Stop closes the subscription connection +func (s *Subscription) Stop() error { + s.client.Close() + return nil +} + +// NewPostgresChanges creates a PostgresChanges instance from a raw message +func NewPostgresChanges(message []byte, worker *worker.Worker) (*PostgresChanges, error) { + var changes PostgresChanges + if err := json.Unmarshal(message, &changes); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload: %w", err) + } + changes.Worker = worker + return &changes, nil +} + +// HandleMessage processes a database change event and updates containers if needed +func (p *PostgresChanges) HandleMessage() error { + handlers := []handlers.Handler{ + handlers.NewCoinHandler(), + handlers.NewImageHandler(), + } + + containersToRestart := []string{} + for _, handler := range handlers { + result := handler.Handle(&p.Payload.Data.Record) + if result.Error != nil { + return fmt.Errorf("handler error: %w", result.Error) + } + containersToRestart = append(containersToRestart, result.ContainersToRestart...) + } + + if len(containersToRestart) > 0 { + added := p.Worker.AddJob(containersToRestart) + if !added { + log.Printf("Job for containers %v already in queue", containersToRestart) + } + } + + return nil +} + +// InitialCheck queries the database for existing record and processes it +// This ensures containers are properly configured on service startup +func (s *Subscription) InitialCheck(worker *worker.Worker) error { + dbURL := viper.GetString("supabaseUrl") + chainId := viper.GetInt("chainId") + table := viper.GetString("table") + + // Connect to the database + db, err := sql.Open("postgres", dbURL) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer db.Close() + + // Query the current state - limit 1 since there should be only one record + query := fmt.Sprintf("SELECT id, name, coin, chain_id, light_logo_url, dark_logo_url, favicon_url, created_at, updated_at FROM %s WHERE chain_id = $1 LIMIT 1", table) + rows, err := db.Query(query, chainId) + if err != nil { + return fmt.Errorf("failed to query database: %w", err) + } + defer rows.Close() + + // Process each record using the same handlers as real-time updates + for rows.Next() { + var record handlers.Record + err := rows.Scan( + &record.ID, + &record.Name, + &record.Coin, + &record.ChainID, + &record.LightLogoURL, + &record.DarkLogoURL, + &record.FaviconURL, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to scan row: %w", err) + } + + // Create a PostgresChanges instance to reuse existing handler logic + changes := &PostgresChanges{ + Event: "postgres_changes", + Worker: worker, + } + changes.Payload.Data.Record = record + changes.Payload.Data.Table = table + + // Handle the record + if err := changes.HandleMessage(); err != nil { + log.Printf("Failed to handle initial record %d: %v", record.ID, err) + continue + } + } + + if err = rows.Err(); err != nil { + return fmt.Errorf("error iterating rows: %w", err) + } + + return nil +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go new file mode 100644 index 0000000..a205179 --- /dev/null +++ b/internal/worker/worker.go @@ -0,0 +1,89 @@ +package worker + +import ( + "context" + "log" + "strings" + "sync" + + "blockscout-vc/internal/docker" +) + +// Job represents a container recreation task with one or more containers +type Job struct { + ContainerNames []string +} + +// Worker manages a queue of container recreation jobs, +// ensuring sequential processing and preventing duplicate jobs +type Worker struct { + docker *docker.Docker + jobs chan Job // Buffered channel for job queue + jobSet map[string]struct{} // Set of unique jobs currently in queue + jobSetMux sync.Mutex // Mutex to protect the job set +} + +// New creates a new Worker instance with a job buffer of 100 +func New() *Worker { + return &Worker{ + docker: docker.NewDocker(), + jobs: make(chan Job, 100), + jobSet: make(map[string]struct{}), + jobSetMux: sync.Mutex{}, + } +} + +// Start begins processing jobs in a separate goroutine +// The worker will continue until the context is cancelled +func (w *Worker) Start(ctx context.Context) { + go w.process(ctx) +} + +// AddJob adds a new container recreation job to the queue +// Returns false if the job is already in queue or if containerNames is empty +// Returns true if the job was successfully added +func (w *Worker) AddJob(containerNames []string) bool { + if len(containerNames) == 0 { + return false + } + + w.jobSetMux.Lock() + defer w.jobSetMux.Unlock() + + key := w.makeKey(containerNames) + if _, exists := w.jobSet[key]; exists { + return false + } + + w.jobSet[key] = struct{}{} + w.jobs <- Job{ContainerNames: containerNames} + return true +} + +// process is the main job processing loop +// It handles one job at a time and removes completed jobs from the set +func (w *Worker) process(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case job := <-w.jobs: + err := w.docker.RecreateContainers(job.ContainerNames) + if err != nil { + log.Printf("failed to recreate containers: %v", err) + continue + } + + w.jobSetMux.Lock() + delete(w.jobSet, w.makeKey(job.ContainerNames)) + w.jobSetMux.Unlock() + } + } +} + +// makeKey creates a unique string key for a set of container names +// Uses docker.UniqueContainerNames to handle container name normalization +func (w *Worker) makeKey(containerNames []string) string { + unique := w.docker.UniqueContainerNames(containerNames) + return strings.Join(unique, ",") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b659439 --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + + "blockscout-vc/cmd" +) + +func main() { + // Initialize the root command + c := cmd.RootCmd() + // Add the sidecar subcommand + c.AddCommand(cmd.StartSidecarCmd()) + + // Execute the command and handle any errors + if err := c.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "There was an error while executing Blockscout CLI '%s'", err) + os.Exit(1) + } +}