diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e65988f..2dae7a2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,86 +8,37 @@ on: jobs: test: - name: Build and Test + name: Build & Test All Modules runs-on: ubuntu-latest - services: - docker: - image: docker:latest - options: --privileged # Nécessaire pour exécuter Docker dans Docker steps: - uses: actions/checkout@v3 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.23' - - - name: Start Docker Daemon - run: | - sudo systemctl start docker - docker info - - - name: Install dependencies - run: go mod download - - - name: Verify dependencies - run: go mod verify - - - name: Run vet - run: go vet ./... - - - name: Run tests with Docker - run: go test -v -race -cover ./... - env: - DOCKER_HOST: unix:///var/run/docker.sock # Permet d’accéder à Docker dans l'environnement CI/CD - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - version: latest - - build: - needs: test - name: Build - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.21' - - - name: Build - run: go build -v ./... - - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: app - path: ./anexis # Remplacez par le nom de votre binaire + - name: Find Go Modules + id: find-modules + run: | + echo "modules=$(find . -name 'go.mod' -not -path './go.work' -exec dirname {} \; | tr '\n' ' ')" >> $GITHUB_OUTPUT - # Décommentez cette section si vous voulez déployer sur un serveur - # deploy: - # needs: build - # name: Deploy - # runs-on: ubuntu-latest - # if: github.ref == 'refs/heads/main' - # - # steps: - # - name: Download artifact - # uses: actions/download-artifact@v3 - # with: - # name: app - # - # - name: Deploy to server - # uses: appleboy/scp-action@master - # with: - # host: ${{ secrets.HOST }} - # username: ${{ secrets.USERNAME }} - # key: ${{ secrets.SSH_PRIVATE_KEY }} - # source: "app" - # target: "/path/to/destination" \ No newline at end of file + - name: Run go test in each module + run: | + for module in ${{ steps.find-modules.outputs.modules }}; do + echo "📦 Testing $module" + cd $module + go mod tidy + go test -v -race -cover ./... + cd - + done + + - name: Run golangci-lint in each module + run: | + for module in ${{ steps.find-modules.outputs.modules }}; do + echo "🔍 Linting $module" + cd $module + golangci-lint run --timeout=2m + cd - + done diff --git a/.golangci.yml b/.golangci.yml index 225fefd..be7b299 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,27 +1,30 @@ -# See the dedicated "version" documentation section. -version: "2" +version: 2 + +run: + timeout: 2m + tests: true + linters: - # See the dedicated "linters" documentation section. - default: all -formatters: - # See the dedicated "formatters" documentation section. + disable-all: true enable: - - gci + - govet + - errcheck + - gosimple + - staticcheck + - unused - gofmt - - gofumpt - goimports - - golines - exclusions: - warn-unused: true + - gofumpt + issues: - # See the dedicated "issues" documentation section. - new-from-merge-base: "develop" -run: - # See the dedicated "run" documentation section. - timeout: 30s -severity: - default: error - rules: - - linters: - - dupl - severity: info \ No newline at end of file + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + severity: + default: error + +# Optionnel : pour exclure des fichiers ou répertoires +# exclude-rules: +# - path: _gen/ +# linters: +# - gofmt diff --git a/bx/build/builder.go b/bx/build/builder.go index 2a95b24..c0340c1 100644 --- a/bx/build/builder.go +++ b/bx/build/builder.go @@ -35,179 +35,6 @@ import ( "github.com/Backblaze/blazer/b2" ) -// --- Struct Definitions --- - -// BuildSpec is the specification structure parsing from the spec file -// This is the extended config for the build process -type BuildSpec struct { - Name string `json:"name" yaml:"name"` // The Name used for the service - Version string `json:"version" yaml:"version"` // The version of the software can use a semver specification - Codebases []CodebaseConfig `json:"codebases" yaml:"codebases"` // The list of the different codebases. It can be provided by git or local or tar/zip archive - Resources []ResourceConfig `json:"resources,omitempty" yaml:"resources,omitempty"` // A list of the resources to include in build process - BuildSteps []BuildStep `json:"build_steps,omitempty" yaml:"build_steps,omitempty"` // Specify the different build step. Useful for including a binary dependency in any codebase build - BuildConfig BuildConfig `json:"build_config" yaml:"build_config"` // The build Build configuration struct - Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` // Specify the Environment variables - EnvFiles []string `json:"env_files,omitempty" yaml:"env_files,omitempty"` // Used to load the Envs from the provided file path - Secrets []SecretSpec `json:"secrets,omitempty" yaml:"secrets,omitempty"` // Secrets specifications. Secrets is like env vars but it's provided by a specific service and encrypted/decrypted during the usage. Use this to pass very sensible information to your different services - RunConfigDef RunConfigDef `json:"run_config_def,omitempty" yaml:"run_config_def,omitempty"` // Configuration for the *.run.yml file. This file is used by the CLI to run your different services -} - -// Representation of any codebase in the services -type CodebaseConfig struct { - Name string `json:"name" yaml:"name"` // Specify the name of the codebase - SourceType string `json:"source_type" yaml:"source_type"` // git, local, archive, buffer - Source string `json:"source" yaml:"source"` // URL, local path - Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` // The git branch to build - Commit string `json:"commit,omitempty" yaml:"commit,omitempty"` // The specific commit to consider during the codebase pulling if the source is git - Path string `json:"path,omitempty" yaml:"path,omitempty"` // The path of the codebase in the local dir - Content []byte `json:"-" yaml:"-"` // The memory content if the source type is buffer - BuildOnly bool `json:"build_only,omitempty" yaml:"build_only,omitempty"` // If specified the codebase is only builded - TargetInHost string `json:"target_in_host,omitempty" yaml:"target_in_host,omitempty"` // Path to put the codebase in the host dir -} - -// ResourceConfig is resource representation to download during the build -type ResourceConfig struct { - URL string `json:"url" yaml:"url"` // The resource URL - TargetPath string `json:"target_path" yaml:"target_path"` // relative path destination in the build dir - Extract bool `json:"extract,omitempty" yaml:"extract,omitempty"` // Extract the archive (tar, tgz, zip) -} - -// BuildStep is a build sequenced step, potentially with dependencies -type BuildStep struct { - Name string `json:"name" yaml:"name"` // The step name - CodebaseName string `json:"codebase_name" yaml:"codebase_name"` // References a codebase name to use for this step - OutputsBinaryPath string `json:"outputs_binary_path,omitempty" yaml:"outputs_binary_path,omitempty"` // Path in the *container* of the binary to extract - UseBinaryFromStep string `json:"use_binary_from_step,omitempty" yaml:"use_binary_from_step,omitempty"` // The step in which the binary will be used - BinaryTargetPath string `json:"binary_target_path,omitempty" yaml:"binary_target_path,omitempty"` // The path to put the binary during the specific step -} - -// BuildConfig is a Docker build config spec extended -type BuildConfig struct { - BaseImage string `json:"base_image,omitempty" yaml:"base_image,omitempty"` // The base image to use - Dockerfile string `json:"dockerfile,omitempty" yaml:"dockerfile,omitempty"` // relative path of the Dockerfile or the inline content - ComposeFile string `json:"compose_file,omitempty" yaml:"compose_file,omitempty"` // the relative compose file path - Target string `json:"target,omitempty" yaml:"target,omitempty"` - Args map[string]string `json:"args,omitempty" yaml:"args,omitempty"` // Ens vars to inject in the build config - Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` // Tags for the finale docker image (or the principal image in case of compose) - Platforms []string `json:"platforms,omitempty" yaml:"platforms,omitempty"` // cross-platform support (experimental) - NoCache bool `json:"no_cache,omitempty" yaml:"no_cache,omitempty"` // Specify if the cache will be used between the build - OutputTarget string `json:"output_target" yaml:"output_target"` // The storage target "b2", "local", "docker" (by default) - LocalPath string `json:"local_path,omitempty" yaml:"local_path,omitempty"` // Output path if OutputTarget="local" - Pull bool `json:"pull,omitempty" yaml:"pull,omitempty"` // Trying to pull the based image - BuildKit bool `json:"buildkit,omitempty" yaml:"buildkit,omitempty"` // Use BuildKit (if available) -} - -// SecretSpec define the way to fetch the secrets -type SecretSpec struct { - Name string `json:"name" yaml:"name"` // The name of the env var that will receive the secret - Source string `json:"source" yaml:"source"` // The service ID for this secret - InjectMethod string `json:"inject_method" yaml:"inject_method"` // "env" (default), can be file later -} - -// RunConfigDef define the parameters for the *.run.yml generation -type RunConfigDef struct { - Generate bool `json:"generate" yaml:"generate"` // Is the file will be generated ? - ArtifactStorage string `json:"artifact_storage" yaml:"artifact_storage"` // "docker" (use the tags), "local" (referencing .tar) - Commands []string `json:"commands,omitempty" yaml:"commands,omitempty"` // The default commands (overriding if needed) - // Some other options can be added after... -} - -// RunService is any service representation in the *.run.yml -type RunService struct { - Image string `yaml:"image"` // The name of the tar local image - Command []string `yaml:"command,omitempty"` // The command to exec - Entrypoint []string `yaml:"entrypoint,omitempty"` // The entry point - Environment map[string]string `yaml:"environment,omitempty"` // Environment variables (include secrets) - Ports []string `yaml:"ports,omitempty"` // Format "host:container" - Volumes []string `yaml:"volumes,omitempty"` // Format "host:container" ou "named:container" - Restart string `yaml:"restart,omitempty"` // Reboot politic (e.g., "always", "on-failure") - DependsOn []string `yaml:"depends_on,omitempty"` // The depending services - // Some other fields can be added later... -} - -// RunYAML is the struct of the *.run.yml output file. This file is generated after a build and is used by the bx CLI to run your artifact -type RunYAML struct { - Version string `yaml:"version"` // The file version format - Services map[string]RunService `yaml:"services"` - // potentially other sections for volumes, networks, etc. -} - -// BuildResult is the struct representing a build result of each service -type BuildResult struct { - Success bool `json:"success"` - ImageID string `json:"image_id,omitempty"` // The docker image ID (if applicable) - ImageIDs map[string]string `json:"image_ids,omitempty"` // Each service IDS (if compose) - ImageSize int64 `json:"image_size,omitempty"` // The main docker image size - ImageSizes map[string]int64 `json:"image_sizes,omitempty"` // Image size by service - Artifacts map[string][]byte `json:"-"` // Memory artefact - BuildTime float64 `json:"build_time"` // Total Build time - ErrorMessage string `json:"error_message,omitempty"` // Build error message - Logs string `json:"logs"` // Build logs - B2ObjectNames []string `json:"b2_object_names,omitempty"` // For OutputTarget="b2" - LocalImagePaths map[string]string `json:"local_image_paths,omitempty"` // For OutputTarget="local" - RunConfigPath string `json:"run_config_path,omitempty"` // Path to the generated *.run.yml file - ServiceOutputs map[string]ServiceOutput `json:"service_outputs,omitempty"` // Specific information generated by service -} - -// ServiceOutput is the specific information for each builded service (e.g., image ID) -type ServiceOutput struct { - ImageID string `json:"image_id"` - ImageSize int64 `json:"image_size"` - Logs string `json:"logs"` -} - -// B2Config is the b2 storage information struct -type B2Config struct { - AccountID string `json:"account_id" yaml:"account_id"` - ApplicationKey string `json:"application_key" yaml:"application_key"` - BucketName string `json:"bucket_name" yaml:"bucket_name"` - BasePath string `json:"base_path" yaml:"base_path"` -} - -// The Main service to manage each build -type BuildService struct { - dockerClient *client.Client - workDir string - b2Config *B2Config - mutex sync.Mutex - inMemory bool // if true minimizing the system disk usage - secretFetcher SecretFetcher // Interface for secrets fetching -} - -type ComposeProject struct { - Version string `yaml:"version,omitempty"` - Services map[string]ComposeService `yaml:"services"` - Name string - Volumes map[string]interface{} `yaml:"volumes,omitempty"` - Networks map[string]interface{} `yaml:"networks,omitempty"` -} - -// A representation of a compose service (simplified) -type ComposeService struct { - Image string `yaml:"image,omitempty"` - Build *ComposeBuild `yaml:"build,omitempty"` - Command []string `yaml:"command,omitempty"` - Entrypoint []string `yaml:"entrypoint,omitempty"` - Environment map[string]*string `yaml:"environment,omitempty"` - Ports []string `yaml:"ports,omitempty"` - Volumes []string `yaml:"volumes,omitempty"` - DependsOn []string `yaml:"depends_on,omitempty"` - Restart string `yaml:"restart,omitempty"` - HealthCheck *HealthCheck `yaml:"healthcheck,omitempty"` - Labels map[string]string `yaml:"labels,omitempty"` - Expose []string `yaml:"expose,omitempty"` - StopGracePeriod string `yaml:"stop_grace_period,omitempty"` -} - -type ComposeBuild struct { - Context string - Dockerfile string - Args map[string]*string - Target string - CacheFrom []string `yaml:"cache_from,omitempty"` - Labels map[string]string `yaml:"labels,omitempty"` - Network string `yaml:"network,omitempty"` -} // UnmarshalYAML handle the case which `build: ./context` and `build: {context: ...}` func (cb *ComposeBuild) UnmarshalYAML(value *yaml.Node) error { diff --git a/bx/build/spec.go b/bx/build/spec.go new file mode 100644 index 0000000..fa93bf2 --- /dev/null +++ b/bx/build/spec.go @@ -0,0 +1,181 @@ +package build + +import ( + "sync" + + "github.com/docker/docker/client" +) + +// --- Struct Definitions --- + +// BuildSpec is the specification structure parsing from the spec file +// This is the extended config for the build process +type BuildSpec struct { + Name string `json:"name" yaml:"name"` // The Name used for the service + Version string `json:"version" yaml:"version"` // The version of the software can use a semver specification + Codebases []CodebaseConfig `json:"codebases" yaml:"codebases"` // The list of the different codebases. It can be provided by git or local or tar/zip archive + Resources []ResourceConfig `json:"resources,omitempty" yaml:"resources,omitempty"` // A list of the resources to include in build process + BuildSteps []BuildStep `json:"build_steps,omitempty" yaml:"build_steps,omitempty"` // Specify the different build step. Useful for including a binary dependency in any codebase build + BuildConfig BuildConfig `json:"build_config" yaml:"build_config"` // The build Build configuration struct + Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` // Specify the Environment variables + EnvFiles []string `json:"env_files,omitempty" yaml:"env_files,omitempty"` // Used to load the Envs from the provided file path + Secrets []SecretSpec `json:"secrets,omitempty" yaml:"secrets,omitempty"` // Secrets specifications. Secrets is like env vars but it's provided by a specific service and encrypted/decrypted during the usage. Use this to pass very sensible information to your different services + RunConfigDef RunConfigDef `json:"run_config_def,omitempty" yaml:"run_config_def,omitempty"` // Configuration for the *.run.yml file. This file is used by the CLI to run your different services +} + +// Representation of any codebase in the services +type CodebaseConfig struct { + Name string `json:"name" yaml:"name"` // Specify the name of the codebase + SourceType string `json:"source_type" yaml:"source_type"` // git, local, archive, buffer + Source string `json:"source" yaml:"source"` // URL, local path + Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` // The git branch to build + Commit string `json:"commit,omitempty" yaml:"commit,omitempty"` // The specific commit to consider during the codebase pulling if the source is git + Path string `json:"path,omitempty" yaml:"path,omitempty"` // The path of the codebase in the local dir + Content []byte `json:"-" yaml:"-"` // The memory content if the source type is buffer + BuildOnly bool `json:"build_only,omitempty" yaml:"build_only,omitempty"` // If specified the codebase is only builded + TargetInHost string `json:"target_in_host,omitempty" yaml:"target_in_host,omitempty"` // Path to put the codebase in the host dir +} + +// ResourceConfig is resource representation to download during the build +type ResourceConfig struct { + URL string `json:"url" yaml:"url"` // The resource URL + TargetPath string `json:"target_path" yaml:"target_path"` // relative path destination in the build dir + Extract bool `json:"extract,omitempty" yaml:"extract,omitempty"` // Extract the archive (tar, tgz, zip) +} + +// BuildStep is a build sequenced step, potentially with dependencies +type BuildStep struct { + Name string `json:"name" yaml:"name"` // The step name + CodebaseName string `json:"codebase_name" yaml:"codebase_name"` // References a codebase name to use for this step + OutputsBinaryPath string `json:"outputs_binary_path,omitempty" yaml:"outputs_binary_path,omitempty"` // Path in the *container* of the binary to extract + UseBinaryFromStep string `json:"use_binary_from_step,omitempty" yaml:"use_binary_from_step,omitempty"` // The step in which the binary will be used + BinaryTargetPath string `json:"binary_target_path,omitempty" yaml:"binary_target_path,omitempty"` // The path to put the binary during the specific step +} + +// BuildConfig is a Docker build config spec extended +type BuildConfig struct { + BaseImage string `json:"base_image,omitempty" yaml:"base_image,omitempty"` // The base image to use + Dockerfile string `json:"dockerfile,omitempty" yaml:"dockerfile,omitempty"` // relative path of the Dockerfile or the inline content + ComposeFile string `json:"compose_file,omitempty" yaml:"compose_file,omitempty"` // the relative compose file path + Target string `json:"target,omitempty" yaml:"target,omitempty"` + Args map[string]string `json:"args,omitempty" yaml:"args,omitempty"` // Ens vars to inject in the build config + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` // Tags for the finale docker image (or the principal image in case of compose) + Platforms []string `json:"platforms,omitempty" yaml:"platforms,omitempty"` // cross-platform support (experimental) + NoCache bool `json:"no_cache,omitempty" yaml:"no_cache,omitempty"` // Specify if the cache will be used between the build + OutputTarget string `json:"output_target" yaml:"output_target"` // The storage target "b2", "local", "docker" (by default) + LocalPath string `json:"local_path,omitempty" yaml:"local_path,omitempty"` // Output path if OutputTarget="local" + Pull bool `json:"pull,omitempty" yaml:"pull,omitempty"` // Trying to pull the based image + BuildKit bool `json:"buildkit,omitempty" yaml:"buildkit,omitempty"` // Use BuildKit (if available) +} + +// SecretSpec define the way to fetch the secrets +type SecretSpec struct { + Name string `json:"name" yaml:"name"` // The name of the env var that will receive the secret + Source string `json:"source" yaml:"source"` // The service ID for this secret + InjectMethod string `json:"inject_method" yaml:"inject_method"` // "env" (default), can be file later +} + +// RunConfigDef define the parameters for the *.run.yml generation +type RunConfigDef struct { + Generate bool `json:"generate" yaml:"generate"` // Is the file will be generated ? + ArtifactStorage string `json:"artifact_storage" yaml:"artifact_storage"` // "docker" (use the tags), "local" (referencing .tar) + Commands []string `json:"commands,omitempty" yaml:"commands,omitempty"` // The default commands (overriding if needed) + // Some other options can be added after... +} + +// RunService is any service representation in the *.run.yml +type RunService struct { + Image string `yaml:"image"` // The name of the tar local image + Command []string `yaml:"command,omitempty"` // The command to exec + Entrypoint []string `yaml:"entrypoint,omitempty"` // The entry point + Environment map[string]string `yaml:"environment,omitempty"` // Environment variables (include secrets) + Ports []string `yaml:"ports,omitempty"` // Format "host:container" + Volumes []string `yaml:"volumes,omitempty"` // Format "host:container" ou "named:container" + Restart string `yaml:"restart,omitempty"` // Reboot politic (e.g., "always", "on-failure") + DependsOn []string `yaml:"depends_on,omitempty"` // The depending services + // Some other fields can be added later... +} + +// RunYAML is the struct of the *.run.yml output file. This file is generated after a build and is used by the bx CLI to run your artifact +type RunYAML struct { + Version string `yaml:"version"` // The file version format + Services map[string]RunService `yaml:"services"` + // potentially other sections for volumes, networks, etc. +} + +// BuildResult is the struct representing a build result of each service +type BuildResult struct { + Success bool `json:"success"` + ImageID string `json:"image_id,omitempty"` // The docker image ID (if applicable) + ImageIDs map[string]string `json:"image_ids,omitempty"` // Each service IDS (if compose) + ImageSize int64 `json:"image_size,omitempty"` // The main docker image size + ImageSizes map[string]int64 `json:"image_sizes,omitempty"` // Image size by service + Artifacts map[string][]byte `json:"-"` // Memory artefact + BuildTime float64 `json:"build_time"` // Total Build time + ErrorMessage string `json:"error_message,omitempty"` // Build error message + Logs string `json:"logs"` // Build logs + B2ObjectNames []string `json:"b2_object_names,omitempty"` // For OutputTarget="b2" + LocalImagePaths map[string]string `json:"local_image_paths,omitempty"` // For OutputTarget="local" + RunConfigPath string `json:"run_config_path,omitempty"` // Path to the generated *.run.yml file + ServiceOutputs map[string]ServiceOutput `json:"service_outputs,omitempty"` // Specific information generated by service +} + +// ServiceOutput is the specific information for each builded service (e.g., image ID) +type ServiceOutput struct { + ImageID string `json:"image_id"` + ImageSize int64 `json:"image_size"` + Logs string `json:"logs"` +} + +// B2Config is the b2 storage information struct +type B2Config struct { + AccountID string `json:"account_id" yaml:"account_id"` + ApplicationKey string `json:"application_key" yaml:"application_key"` + BucketName string `json:"bucket_name" yaml:"bucket_name"` + BasePath string `json:"base_path" yaml:"base_path"` +} + +// The Main service to manage each build +type BuildService struct { + dockerClient *client.Client + workDir string + b2Config *B2Config + mutex sync.Mutex + inMemory bool // if true minimizing the system disk usage + secretFetcher SecretFetcher // Interface for secrets fetching +} + +type ComposeProject struct { + Version string `yaml:"version,omitempty"` + Services map[string]ComposeService `yaml:"services"` + Name string + Volumes map[string]interface{} `yaml:"volumes,omitempty"` + Networks map[string]interface{} `yaml:"networks,omitempty"` +} + +// A representation of a compose service (simplified) +type ComposeService struct { + Image string `yaml:"image,omitempty"` + Build *ComposeBuild `yaml:"build,omitempty"` + Command []string `yaml:"command,omitempty"` + Entrypoint []string `yaml:"entrypoint,omitempty"` + Environment map[string]*string `yaml:"environment,omitempty"` + Ports []string `yaml:"ports,omitempty"` + Volumes []string `yaml:"volumes,omitempty"` + DependsOn []string `yaml:"depends_on,omitempty"` + Restart string `yaml:"restart,omitempty"` + HealthCheck *HealthCheck `yaml:"healthcheck,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + Expose []string `yaml:"expose,omitempty"` + StopGracePeriod string `yaml:"stop_grace_period,omitempty"` +} + +type ComposeBuild struct { + Context string + Dockerfile string + Args map[string]*string + Target string + CacheFrom []string `yaml:"cache_from,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + Network string `yaml:"network,omitempty"` +} \ No newline at end of file diff --git a/socket/client.go b/socket/client.go index 0ee58de..a9f69a9 100644 --- a/socket/client.go +++ b/socket/client.go @@ -1,4 +1,3 @@ -// socket/client.go package socket import ( @@ -9,39 +8,40 @@ import ( "net/http" "sync" - "github.com/google/uuid" // Pour générer les RequestID + "github.com/google/uuid" "github.com/gorilla/websocket" ) -// Client gère la connexion WebSocket sortante. type Client struct { - conn *connection // Wrapper de connexion partagé + conn *connection // Shared wrapper for WebSocket connection - Incoming chan *Message // Canal public pour recevoir les messages du serveur - Done chan struct{} // Canal pour signaler l'arrêt du client + // Incoming messages are pushed here by the readPump. + // Users can read from this channel to process incoming messages. + Incoming chan *Message // Public channel for incoming messages - mu sync.Mutex + mu sync.Mutex isConnected bool - dialer *websocket.Dialer - connUrl string - headers http.Header // Pour l'authentification ou autres en-têtes + dialer *websocket.Dialer + connUrl string + headers http.Header // For authentication or other headers - // Pour corréler requêtes/réponses + // pendingRequests holds channels for requests that are waiting for a response. + // Keyed by RequestID, so we can correlate responses. + // This allows us to handle responses to specific requests. pendingRequests map[string]chan *Message pendingMu sync.RWMutex } -// NewClient crée une nouvelle instance client. +// Creating anew client for a websocket connection. func NewClient() *Client { return &Client{ - Incoming: make(chan *Message, 100), // Buffer pour les messages entrants - Done: make(chan struct{}), + Incoming: make(chan *Message, 100), // Buffer for incoming messages dialer: websocket.DefaultDialer, pendingRequests: make(map[string]chan *Message), } } -// Connect établit la connexion WebSocket avec le serveur. +// Connect to the given server url websocket with the provided headers. func (c *Client) Connect(serverUrl string, headers http.Header) error { c.mu.Lock() if c.isConnected { @@ -71,91 +71,73 @@ func (c *Client) Connect(serverUrl string, headers http.Header) error { c.mu.Lock() c.conn = newConnection(ws) c.isConnected = true - // Réinitialiser Done channel au cas où il aurait été fermé par une connexion précédente - // c.Done = make(chan struct{}) // Non, Done signale la fin définitive du client c.mu.Unlock() - // Démarrer les pompes go c.conn.writePump() go c.conn.readPump(c.handleIncomingMessage, c.handleDisconnect) return nil } -// IsConnected retourne l'état de la connexion. func (c *Client) IsConnected() bool { c.mu.Lock() defer c.mu.Unlock() return c.isConnected } -// handleIncomingMessage traite les messages reçus du serveur. func (c *Client) handleIncomingMessage(msg *Message, conn *connection) error { - // log.Printf("Client: Received message type %s (ReqID: %s)\n", msg.Type, msg.RequestID) // Debug + log.Printf("Client: Received message type %s (ReqID: %s)\n", msg.Type, msg.RequestID) // Debug - // Vérifier si c'est une réponse à une requête en attente - c.pendingMu.Lock() // Prendre le verrou en écriture car on va delete + // Check if it's a pending request + c.pendingMu.Lock() if msg.RequestID != "" { if respChan, ok := c.pendingRequests[msg.RequestID]; ok { - // C'est une réponse directe à une requête envoyée via SendRequest log.Printf("Client: Correlated response for RequestID %s\n", msg.RequestID) select { - case respChan <- msg: // Envoyer la réponse au demandeur + case respChan <- msg: default: - // Le demandeur n'écoute plus (timeout?), logguer mais continuer log.Printf("Warning: No listener for response channel of RequestID %s\n", msg.RequestID) } - // Supprimer la requête en attente après avoir envoyé la réponse delete(c.pendingRequests, msg.RequestID) - c.pendingMu.Unlock() // Libérer le verrou ici - return nil // Le message a été traité comme une réponse directe + c.pendingMu.Unlock() + return nil } } - c.pendingMu.Unlock() // Libérer le verrou si ce n'était pas une réponse corrélée + c.pendingMu.Unlock() - // Si ce n'est pas une réponse directe, le pousser sur le canal public Incoming select { case c.Incoming <- msg: - // Message transmis à l'application utilisatrice default: - // Le canal Incoming est plein, l'application ne lit pas assez vite log.Printf("Warning: Client Incoming channel full. Message type %s dropped.\n", msg.Type) } - return nil // Succès du traitement (mis en file d'attente) + return nil } -// handleDisconnect est appelé lorsque la connexion est perdue. func (c *Client) handleDisconnect(conn *connection) { c.mu.Lock() - // Vérifier si c'est bien la connexion actuelle qui est déconnectée if c.conn != conn { c.mu.Unlock() log.Printf("Client: Received disconnect signal for an old/stale connection (%p)\n", conn.ws) - return // Ne rien faire si ce n'est pas la connexion active + return } c.isConnected = false - c.conn = nil // Libérer la référence à la connexion + c.conn = nil log.Println("Client: Connection lost.") - // Ne pas fermer c.Done ici, l'utilisateur peut vouloir reconnecter. - // Fermer Done seulement quand Close() est appelé explicitement. c.mu.Unlock() - // Nettoyer les requêtes en attente pour cette connexion perdue + // Clean the pending request for this connection c.pendingMu.Lock() if len(c.pendingRequests) > 0 { - log.Printf("Client: Cleaning up %d pending requests due to disconnect.\n", len(c.pendingRequests)) - for reqID, respChan := range c.pendingRequests { - // Envoyer une erreur ou fermer le canal pour débloquer les appelants - // Fermer le canal est plus sûr pour signaler la fin. - close(respChan) - delete(c.pendingRequests, reqID) - } - } + log.Printf("Client: Cleaning up %d pending requests due to disconnect.\n", len(c.pendingRequests)) + for reqID, respChan := range c.pendingRequests { + close(respChan) + delete(c.pendingRequests, reqID) + } + } c.pendingMu.Unlock() } -// Send envoie un message au serveur de manière asynchrone. -// Ne garantit pas la réception et ne gère pas les réponses. +// sending message to the server asynchronously. func (c *Client) Send(msg *Message) error { c.mu.Lock() conn := c.conn @@ -165,13 +147,13 @@ func (c *Client) Send(msg *Message) error { if !isConnected || conn == nil { return fmt.Errorf("client not connected") } - // log.Printf("Client: Sending message type %s async\n", msg.Type) // Debug + log.Printf("Client: Sending message type %s async\n", msg.Type) // Debug conn.sendMsg(msg) return nil } -// SendRequest envoie un message et attend une réponse corrélée par RequestID. -func (c *Client) SendRequest(ctx context.Context, msgType EventType, payload interface{}) (*Message, error) { +// sending a request and waiting for the response based on the RequestID. +func (c *Client) SendRequest(ctx context.Context, msgType EventType, payload any) (*Message, error) { c.mu.Lock() conn := c.conn isConnected := c.isConnected @@ -181,7 +163,6 @@ func (c *Client) SendRequest(ctx context.Context, msgType EventType, payload int return nil, fmt.Errorf("client not connected") } - // Générer un RequestID unique requestID := uuid.NewString() msg := NewMessage(msgType, requestID) if payload != nil { @@ -190,73 +171,55 @@ func (c *Client) SendRequest(ctx context.Context, msgType EventType, payload int } } - // Créer un canal pour recevoir la réponse - respChan := make(chan *Message, 1) // Buffer 1 au cas où la réponse arrive avant qu'on écoute + respChan := make(chan *Message, 1) c.pendingMu.Lock() c.pendingRequests[requestID] = respChan c.pendingMu.Unlock() - // Nettoyer la requête en attente à la fin (succès, erreur, timeout) + // Cleaning the request before the response (success, error, timeout) defer func() { c.pendingMu.Lock() delete(c.pendingRequests, requestID) c.pendingMu.Unlock() - // Ne pas fermer respChan ici, car on peut le lire après la sortie }() - // Envoyer la requête + // Send the request log.Printf("Client: Sending request %s (Type: %s)\n", requestID, msg.Type) - conn.sendMsg(msg) // Envoi asynchrone + conn.sendMsg(msg) - // Attendre la réponse ou l'annulation/timeout du contexte + // Waiting for the response select { case resp := <-respChan: - // Réponse reçue ! log.Printf("Client: Received response for request %s (Type: %s, Error: '%s')\n", requestID, resp.Type, resp.Error) if resp.Error != "" || resp.Type == EvtError { - // Le serveur a renvoyé une erreur pour cette requête errMsg := resp.Error - if errMsg == "" { errMsg = "received error event" } // Cas où Error est vide mais Type est EvtError + if errMsg == "" { + errMsg = "received error event" + } var errPayload ErrorPayload if resp.DecodePayload(&errPayload) == nil && errPayload.Details != "" { - errMsg = fmt.Sprintf("%s: %s", errMsg, errPayload.Details) + errMsg = fmt.Sprintf("%s: %s", errMsg, errPayload.Details) } return nil, fmt.Errorf("server error response for request %s: %s", requestID, errMsg) } - // Réponse réussie return resp, nil case <-ctx.Done(): - // Le contexte a été annulé (timeout ou autre) log.Printf("Client: Context done while waiting for response to request %s: %v\n", requestID, ctx.Err()) return nil, fmt.Errorf("request %s timed out or was canceled: %w", requestID, ctx.Err()) - - // Gérer le cas où la connexion est perdue pendant l'attente. - // La lecture de respChan échouera si handleDisconnect ferme le canal. - // Il faut vérifier la valeur reçue. - // case resp, ok := <-respChan: - // if !ok { - // return nil, fmt.Errorf("connection closed while waiting for response to request %s", requestID) - // } - // // Traiter resp comme ci-dessus } } - -// Close ferme la connexion WebSocket et arrête le client. +// Close the websocket connection and stopping the client. func (c *Client) Close() { c.mu.Lock() defer c.mu.Unlock() log.Println("Client: Close called.") - // Signaler l'arrêt aux utilisateurs externes - close(c.Done) if c.conn != nil && c.isConnected { - // Fermer le canal d'envoi arrêtera la writePump, qui fermera le ws.Conn c.conn.closeSend() - // La readPump s'arrêtera aussi à cause de la fermeture par la writePump ou un read error. } - c.isConnected = false // Marquer comme déconnecté -} \ No newline at end of file + c.isConnected = false +} diff --git a/socket/conn.go b/socket/conn.go index 2a45530..2838e06 100644 --- a/socket/conn.go +++ b/socket/conn.go @@ -1,4 +1,3 @@ -// socket/conn.go package socket import ( @@ -10,43 +9,41 @@ import ( ) const ( - // Temps max pour écrire un message. + // Max time for any message writing writeWait = 10 * time.Second - // Temps max pour lire le prochain message pong du peer. + // Max time for the next peer message reading. pongWait = 60 * time.Second - // Envoyer des pings au peer avec cette période. Doit être inférieur à pongWait. + // Sending ping to the server after this period. Must be low than pongWait. pingPeriod = (pongWait * 9) / 10 - // Taille maximale des messages lus. - maxMessageSize = 8192 // Ajuster si de gros messages sont attendus (ex: build spec) + // Max message body. + maxMessageSize = 8192 // Adjust if the body size can be consequent (ex: build spec) ) -// connection est un wrapper autour de websocket.Conn gérant les pompes lecture/écriture. type connection struct { ws *websocket.Conn - send chan *Message // Channel pour envoyer des messages sortants + send chan *Message // Channel for writing the i/o message } -// newConnection crée une nouvelle structure de connexion. +// creating a new connection struct. func newConnection(ws *websocket.Conn) *connection { return &connection{ ws: ws, - send: make(chan *Message, 256), // Buffer pour éviter blocage sur écritures rapides + send: make(chan *Message, 256), } } -// write pompe les messages du channel 'send' vers la connexion WebSocket. +// fetching message from the channels 'send' to the WebSocket connection. func (c *connection) write(msgType int, payload []byte) error { c.ws.SetWriteDeadline(time.Now().Add(writeWait)) return c.ws.WriteMessage(msgType, payload) } -// writePump gère l'écriture des messages et les pings périodiques. -// Doit être lancée dans une goroutine séparée par connexion. +// Handling sorting and periodical ping messages to the server func (c *connection) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() - c.ws.Close() // Ferme la connexion WebSocket si la pompe d'écriture s'arrête + c.ws.Close() log.Println("writePump: Stopped and closed WebSocket connection") }() @@ -54,65 +51,61 @@ func (c *connection) writePump() { select { case message, ok := <-c.send: if !ok { - // Le channel 'send' a été fermé. Fermer la connexion. log.Println("writePump: Send channel closed, closing connection.") c.write(websocket.CloseMessage, []byte{}) return } c.ws.SetWriteDeadline(time.Now().Add(writeWait)) - w, err := c.ws.NextWriter(websocket.TextMessage) // Utiliser TextMessage pour JSON + w, err := c.ws.NextWriter(websocket.TextMessage) if err != nil { log.Printf("writePump: Error getting next writer: %v\n", err) - return // Erreur critique, arrêter la pompe + return } jsonBytes, err := json.Marshal(message) if err != nil { log.Printf("writePump: Error marshaling message type %s: %v\n", message.Type, err) - // Ne pas retourner, essayer d'envoyer le prochain message - w.Close() // Fermer le writer actuel + // Don't return try to send the next message + w.Close() // Close the actual writer continue } _, err = w.Write(jsonBytes) if err != nil { log.Printf("writePump: Error writing JSON: %v\n", err) - // Ne pas retourner ici, mais fermer le writer est important + w.Close() } - // Fermer le writer pour flusher le message sur le réseau. if err := w.Close(); err != nil { log.Printf("writePump: Error closing writer: %v\n", err) - return // Erreur critique, arrêter la pompe + return } - // log.Printf("writePump: Sent message type %s", message.Type) // Debug + log.Printf("writePump: Sent message type %s", message.Type) // Debug case <-ticker.C: - // Envoyer un ping périodique - // log.Println("writePump: Sending ping") // Debug + // Sending a periodical ping message + log.Println("writePump: Sending ping") // Debug if err := c.write(websocket.PingMessage, nil); err != nil { log.Printf("writePump: Error sending ping: %v\n", err) - return // Erreur critique, arrêter la pompe + return } } } } -// readPump lit les messages entrants et les passe au handler fourni. -// Le handler est responsable du traitement métier. -// Doit être lancée dans une goroutine séparée par connexion. +// Handling entering message func (c *connection) readPump(handler func(msg *Message, conn *connection) error, disconnect func(conn *connection)) { defer func() { - disconnect(c) // S'assurer que la déconnexion est gérée - c.ws.Close() // Ferme la connexion WebSocket si la pompe de lecture s'arrête + disconnect(c) + c.ws.Close() log.Println("readPump: Stopped and closed WebSocket connection") }() c.ws.SetReadLimit(maxMessageSize) - c.ws.SetReadDeadline(time.Now().Add(pongWait)) // Attendre le premier message ou pong + c.ws.SetReadDeadline(time.Now().Add(pongWait)) c.ws.SetPongHandler(func(string) error { - // log.Println("readPump: Received pong") // Debug - c.ws.SetReadDeadline(time.Now().Add(pongWait)) // Repousser l'échéance à la réception d'un pong + log.Println("readPump: Received pong") // Debug + c.ws.SetReadDeadline(time.Now().Add(pongWait)) return nil }) @@ -126,53 +119,45 @@ func (c *connection) readPump(handler func(msg *Message, conn *connection) error } else { log.Printf("readPump: Unhandled WebSocket read error: %v\n", err) } - break // Sortir de la boucle en cas d'erreur de lecture ou de fermeture + break } - // Ignorer les messages non-texte pour le moment + // Ignore non text message if msgType != websocket.TextMessage { log.Printf("readPump: Received non-text message type: %d\n", msgType) continue } - // log.Printf("readPump: Received raw message: %s", string(messageBytes)) // Debug + log.Printf("readPump: Received raw message: %s", string(messageBytes)) // Debug var msg Message if err := json.Unmarshal(messageBytes, &msg); err != nil { log.Printf("readPump: Error unmarshaling message: %v --- Raw: %s\n", err, string(messageBytes)) - // Envoyer un message d'erreur au client ? Ou juste ignorer ? Ignorons pour l'instant. - // errMsg := NewErrorMessage("", "Invalid message format", err.Error()) - // c.send <- errMsg + errMsg := NewErrorMessage("", "Invalid message format", err.Error()) + c.send <- errMsg continue } - // Appeler le handler métier avec le message décodé if err := handler(&msg, c); err != nil { log.Printf("readPump: Error handling message type %s: %v\n", msg.Type, err) - // Envoyer une réponse d'erreur liée à la requête si possible errMsg := NewErrorMessage(msg.RequestID, "Failed to handle request", err.Error()) - c.send <- errMsg // Mettre dans le canal d'envoi - // Faut-il fermer la connexion ici ? Probablement pas, laisser le client décider. + c.send <- errMsg } - // Réinitialiser le délai de lecture après avoir traité un message (pas strictement nécessaire si on reçoit des pongs) - // c.ws.SetReadDeadline(time.Now().Add(pongWait)) + c.ws.SetReadDeadline(time.Now().Add(pongWait)) } } -// sendMsg envoie un message de manière asynchrone via le channel. +// sending message asynchronously via the websocket. func (c *connection) sendMsg(msg *Message) { select { case c.send <- msg: - // Message envoyé avec succès (mis en file d'attente) default: - // Le buffer d'envoi est plein, le client est peut-être trop lent ou déconnecté log.Printf("Warning: Send channel full for connection %p. Message type %s dropped.\n", c.ws, msg.Type) - // Potentiellement fermer la connexion ici si c'est un problème récurrent. } } -// close ferme le canal d'envoi, ce qui arrêtera la writePump. +// closing the send channel and stopping the writePump function. func (c *connection) closeSend() { close(c.send) -} \ No newline at end of file +} diff --git a/socket/hub.go b/socket/hub.go index 422c71e..cc5e9db 100644 --- a/socket/hub.go +++ b/socket/hub.go @@ -1,4 +1,3 @@ -// socket/hub.go package socket import ( @@ -6,21 +5,19 @@ import ( "sync" ) -// Hub maintient l'ensemble des clients actifs et diffuse les messages. type Hub struct { - clients map[*connection]bool // Utiliser la connexion comme clé, booléen juste pour la présence - register chan *connection // Canal pour enregistrer de nouveaux clients - unregister chan *connection // Canal pour désenregistrer les clients - // broadcast chan *Message // Si vous avez besoin de diffuser à tous (moins utile pour ce cas d'usage) + clients map[*connection]bool // List of connection registered + register chan *connection // Channel for connection registration + unregister chan *connection // Channel for connection removing + broadcast chan *Message // Diffusing message for all registered instance - // Mutex pour protéger l'accès à la map clients mu sync.RWMutex - // Référence au handler métier pour traiter les messages entrants + // Handler for incoming message messageHandler func(msg *Message, client *connection) error } -// newHub crée un nouveau Hub. + func newHub(handler func(msg *Message, client *connection) error) *Hub { return &Hub{ clients: make(map[*connection]bool), @@ -31,8 +28,7 @@ func newHub(handler func(msg *Message, client *connection) error) *Hub { } } -// run démarre la boucle principale du Hub pour gérer les enregistrements/désenregistrements. -// Doit être lancée dans une goroutine. +// handling registration/removing clients connection asynchronously func (h *Hub) run() { log.Println("Hub: Starting run loop") for { @@ -47,7 +43,6 @@ func (h *Hub) run() { h.mu.Lock() if _, ok := h.clients[conn]; ok { delete(h.clients, conn) - // Important: Fermer le canal d'envoi de la connexion pour arrêter sa writePump conn.closeSend() log.Printf("Hub: Client unregistered (%p). Total clients: %d\n", conn.ws, len(h.clients)) } else { @@ -55,35 +50,33 @@ func (h *Hub) run() { } h.mu.Unlock() - /* // Logique de broadcast si nécessaire - case message := <-h.broadcast: - h.mu.RLock() - for conn := range h.clients { - select { - case conn.send <- message: - default: - log.Printf("Hub: Broadcast failed for client %p, closing its send channel.\n", conn.ws) - close(conn.send) // Ferme le canal pour ce client lent/mort - delete(h.clients, conn) // Supprimer immédiatement ? Attention à l'itération RLock - } + case message := <-h.broadcast: + h.mu.RLock() + for conn := range h.clients { + select { + case conn.send <- message: + default: + log.Printf("Hub: Broadcast failed for client %p, closing its send channel.\n", conn.ws) + close(conn.send) + delete(h.clients, conn) } - h.mu.RUnlock() - */ + } + h.mu.RUnlock() + } } } -// handleDisconnect est appelé par la readPump d'une connexion lorsqu'elle se termine. +// Calling this handler if a connection is disconnected func (h *Hub) handleDisconnect(conn *connection) { h.unregister <- conn } -// handleIncomingMessage est passé à la readPump pour traiter les messages reçus. +// handler passed to readPump for incoming message. func (h *Hub) handleIncomingMessage(msg *Message, conn *connection) error { - // Logique métier déléguée au handler fourni lors de la création du hub if h.messageHandler != nil { return h.messageHandler(msg, conn) } log.Printf("Hub: No message handler configured, dropping message type %s from %p\n", msg.Type, conn.ws) - return nil // Ne pas retourner d'erreur pour ne pas fermer la connexion -} \ No newline at end of file + return nil +} diff --git a/socket/message.go b/socket/message.go index 8c336b1..e53d686 100644 --- a/socket/message.go +++ b/socket/message.go @@ -1,4 +1,3 @@ -// socket/message.go package socket import ( @@ -6,96 +5,77 @@ import ( "fmt" ) -// EventType définit le type de message échangé. type EventType string -// Constantes pour les types d'événements possibles. const ( // Client -> Server - EvtBuildRequest EventType = "build_request" // Demande de lancement d'un build - EvtSecretRequest EventType = "secret_request" // Demande de récupération d'un secret + EvtBuildRequest EventType = "build_request" // Build request + EvtSecretRequest EventType = "secret_request" // Secret fetching request // Server -> Client - EvtBuildQueued EventType = "build_queued" // Accusé de réception, build mis en file d'attente - EvtLogChunk EventType = "log_chunk" // Un morceau de log du build - EvtBuildStatus EventType = "build_status" // Mise à jour du statut (running, success, failure) - EvtSecretResponse EventType = "secret_response" // Réponse à une demande de secret - EvtError EventType = "error" // Message d'erreur général ou spécifique à une requête - - // Bidirectionnel (peut-être moins utile ici, mais possible) - // EvtPing EventType = "ping" - // EvtPong EventType = "pong" + EvtBuildQueued EventType = "build_queued" // Queued build response message + EvtLogChunk EventType = "log_chunk" // A build part log result + EvtBuildStatus EventType = "build_status" // Updating the build status (running, success, failure) + EvtSecretResponse EventType = "secret_response" // Secret request response + EvtError EventType = "error" // A standard error message for any event + + EvtPing EventType = "ping" + EvtPong EventType = "pong" ) -// Message est la structure de base échangée via WebSocket. type Message struct { - Type EventType `json:"type"` // Type d'événement (obligatoire) - RequestID string `json:"request_id,omitempty"` // ID unique pour corréler req/resp (généré par le client) - Payload json.RawMessage `json:"payload,omitempty"` // Données spécifiques à l'événement (JSON brut) - Error string `json:"error,omitempty"` // Message d'erreur si Type=EvtError ou pour une réponse négative + Type EventType `json:"type"` // The event type (needed) + RequestID string `json:"request_id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` // Event specific data (raw JSON) + Error string `json:"error,omitempty"` // Event message if Type=EvtError or for negative error message } -// --- Payloads Spécifiques --- -// (Définir des structs pour les payloads complexes améliore la lisibilité et la robustesse) - -// BuildRequestPayload contient les informations pour démarrer un build. type BuildRequestPayload struct { - // Le BuildSpec peut être volumineux, l'envoyer en YAML est souvent pratique. BuildSpecYAML string `json:"build_spec_yaml"` - // Ou vous pourriez envoyer la struct BuildSpec directement si elle est sérialisable en JSON // BuildSpec build.BuildSpec `json:"build_spec"` } -// BuildQueuedPayload confirme la réception et fournit un ID de build. type BuildQueuedPayload struct { - BuildID string `json:"build_id"` // ID unique assigné par le serveur pour ce build + BuildID string `json:"build_id"` // UID for this build assigned by the server Message string `json:"message"` // e.g., "Build job accepted and queued" } -// LogChunkPayload contient un morceau de log. +// The log message chunk. type LogChunkPayload struct { - BuildID string `json:"build_id"` // ID du build concerné - Stream string `json:"stream"` // "stdout" ou "stderr" (ou "system") - Content string `json:"content"` // Le contenu du log - // Sequence int `json:"sequence,omitempty"` // Optionnel: pour garantir l'ordre si nécessaire + BuildID string `json:"build_id"` + Stream string `json:"stream"` // "stdout" or "stderr" (or "system") + Content string `json:"content"` + // Sequence int `json:"sequence,omitempty"` // The log sequence } -// BuildStatusPayload donne l'état actuel du build. +// The actual build status. type BuildStatusPayload struct { - BuildID string `json:"build_id"` - Status string `json:"status"` // e.g., "queued", "fetching", "building", "success", "failure" - Message string `json:"message,omitempty"` // Message additionnel (e.g., raison de l'échec) - ArtifactRef string `json:"artifact_ref,omitempty"` // Référence à l'artefact (URL, path B2, tag Docker, etc.) si succès - DurationSec *float64 `json:"duration_sec,omitempty"` // Durée du build si terminé - // On évite d'envoyer le BuildResult complet ici, trop lourd potentiellement. + BuildID string `json:"build_id"` + Status string `json:"status"` // e.g., "queued", "fetching", "building", "success", "failure" + Message string `json:"message,omitempty"` // additional Message (e.g., failure reason) + ArtifactRef string `json:"artifact_ref,omitempty"` // The ref of the actual completed build (URL, path B2, tag Docker, etc.) + DurationSec *float64 `json:"duration_sec,omitempty"` } -// SecretRequestPayload demande une valeur de secret. type SecretRequestPayload struct { - Source string `json:"source"` // L'identifiant du secret requis (correspond à SecretSpec.Source) + Source string `json:"source"` } -// SecretResponsePayload fournit la valeur d'un secret. type SecretResponsePayload struct { - Source string `json:"source"` // L'identifiant demandé - Value string `json:"value"` // La valeur du secret (ATTENTION: Sécurité !) + Source string `json:"source"` + Value string `json:"value"` } -// ErrorPayload (utilisé si Message.Type == EvtError) type ErrorPayload struct { - Code int `json:"code,omitempty"` // Code d'erreur interne (optionnel) - Details string `json:"details"` // Description de l'erreur + Code int `json:"code,omitempty"` + Details string `json:"details"` } -// --- Helpers pour créer des messages --- - -// NewMessage crée un message simple sans payload. func NewMessage(eventType EventType, requestID string) *Message { return &Message{Type: eventType, RequestID: requestID} } -// NewErrorMessage crée un message d'erreur. -func NewErrorMessage(requestID, errMsg string, details string) *Message { +func NewErrorMessage(requestID, errMsg, details string) *Message { payloadBytes, _ := json.Marshal(ErrorPayload{Details: details}) return &Message{ Type: EvtError, @@ -105,7 +85,6 @@ func NewErrorMessage(requestID, errMsg string, details string) *Message { } } -// AddPayload attache un payload structuré à un message existant. func (m *Message) AddPayload(payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { @@ -115,7 +94,6 @@ func (m *Message) AddPayload(payload interface{}) error { return nil } -// DecodePayload décode le Payload json.RawMessage dans la structure fournie. func (m *Message) DecodePayload(target interface{}) error { if len(m.Payload) == 0 { return fmt.Errorf("message payload is empty for type %s", m.Type) @@ -124,4 +102,4 @@ func (m *Message) DecodePayload(target interface{}) error { return fmt.Errorf("failed to unmarshal payload for type %s: %w", m.Type, err) } return nil -} \ No newline at end of file +} diff --git a/socket/server.go b/socket/server.go index ee3c3d5..845f9c0 100644 --- a/socket/server.go +++ b/socket/server.go @@ -1,4 +1,3 @@ -// socket/server.go package socket import ( @@ -7,44 +6,35 @@ import ( "log" "net/http" "sync" - "time" + "github.com/google/uuid" "github.com/gorilla/websocket" ) -// Server gère les connexions WebSocket entrantes. type Server struct { - hub *Hub - upgrader websocket.Upgrader - // Ajouter ici les dépendances nécessaires au handler métier - buildService BuildTriggerer // Interface pour découpler du package build - secretFetcher SecretFetcher // Interface pour découpler + hub *Hub + upgrader websocket.Upgrader + buildService BuildTriggerer // Interface implementing a build process + secretFetcher SecretFetcher // Interface implementing the secret service fetcher } -// BuildTriggerer définit l'interface pour démarrer un build et obtenir des notifications. -// Ceci permet de ne pas dépendre directement du package build concret. type BuildTriggerer interface { - // StartBuildAsync démarre un build et retourne immédiatement un buildID. - // Il prend un notificateur pour renvoyer les logs et le statut final. StartBuildAsync(ctx context.Context, buildID string, buildSpecYAML string, notifier BuildNotifier) error } -// SecretFetcher définit l'interface pour récupérer des secrets. type SecretFetcher interface { GetSecret(ctx context.Context, source string) (string, error) } -// BuildNotifier est utilisé par le service de build pour envoyer des mises à jour au serveur socket. type BuildNotifier interface { NotifyLog(buildID string, stream string, content string) NotifyStatus(buildID, status, artifactRef string, buildErr error, duration *float64) } -// serverBuildNotifier implémente BuildNotifier et envoie les notifications via le Hub. type serverBuildNotifier struct { - hub *Hub - buildToClient map[string]*connection // Map pour savoir à quel client envoyer les notifs - mu sync.RWMutex // Protéger la map + hub *Hub + buildToClient map[string]*connection + mu sync.RWMutex } func newServerBuildNotifier(hub *Hub) *serverBuildNotifier { @@ -54,7 +44,6 @@ func newServerBuildNotifier(hub *Hub) *serverBuildNotifier { } } -// Associer un build ID à une connexion client spécifique func (sbn *serverBuildNotifier) registerBuildClient(buildID string, clientConn *connection) { sbn.mu.Lock() defer sbn.mu.Unlock() @@ -62,7 +51,6 @@ func (sbn *serverBuildNotifier) registerBuildClient(buildID string, clientConn * log.Printf("Notifier: Registered client %p for build %s\n", clientConn.ws, buildID) } -// Désassocier un build ID (quand le build est fini ou le client déconnecté) func (sbn *serverBuildNotifier) unregisterBuild(buildID string) { sbn.mu.Lock() defer sbn.mu.Unlock() @@ -83,7 +71,7 @@ func (sbn *serverBuildNotifier) NotifyLog(buildID string, stream string, content return } - msg := NewMessage(EvtLogChunk, "") // Pas de requestID pour les logs streamés + msg := NewMessage(EvtLogChunk, "") payload := LogChunkPayload{ BuildID: buildID, Stream: stream, @@ -100,7 +88,6 @@ func (sbn *serverBuildNotifier) NotifyStatus(buildID string, status string, arti clientConn := sbn.getClientForBuild(buildID) if clientConn == nil { log.Printf("Notifier: No client found for build %s to send status update.\n", buildID) - // On doit quand même désenregistrer le build pour éviter les fuites sbn.unregisterBuild(buildID) return } @@ -113,7 +100,7 @@ func (sbn *serverBuildNotifier) NotifyStatus(buildID string, status string, arti DurationSec: duration, } if buildErr != nil { - payload.Message = buildErr.Error() // Ajouter le message d'erreur au statut + payload.Message = buildErr.Error() } if err := msg.AddPayload(payload); err == nil { @@ -122,67 +109,54 @@ func (sbn *serverBuildNotifier) NotifyStatus(buildID string, status string, arti log.Printf("Notifier: Error creating build status payload for build %s: %v\n", buildID, err) } - // Si le build est terminé (succès ou échec), désenregistrer if status == "success" || status == "failure" { sbn.unregisterBuild(buildID) } } - -// NewServer crée une nouvelle instance de serveur WebSocket. -func NewServer(buildSvc BuildTriggerer, secretF SecretFetcher) *Server { +// Creating a new Websocket server and upgrading connection +func NewServer(buildSvc BuildTriggerer, secretF SecretFetcher, originChecker func (r *http.Request) bool) *Server { server := &Server{ - // Configurer l'upgrader (vérifier l'origine, etc.) upgrader: websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - // CheckOrigin est important pour la sécurité en production ! CheckOrigin: func(r *http.Request) bool { - // Mettre ici la logique pour autoriser les origines (ex: localhost, votre domaine CLI/UI) - // Pour le développement, on peut autoriser tout : - log.Printf("CheckOrigin: Allowing origin %s\n", r.Header.Get("Origin")) - return true // ATTENTION: Trop permissif pour la prod + log.Printf("CheckOrigin: Checking origin %s\n", r.Header.Get("Origin")) + return originChecker(r) }, }, - buildService: buildSvc, + buildService: buildSvc, secretFetcher: secretF, } - // Le hub a besoin du handler de message défini dans le serveur server.hub = newHub(server.handleMessage) return server } -// Run démarre le hub dans une goroutine. +// Launching the Hub in a goroutine. func (s *Server) Run() { go s.hub.run() } -// ServeHTTP gère les requêtes HTTP et tente de les upgrader en WebSocket. +// Handling http request and trying to upgrade it to a websocket connection. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Upgrade la connexion HTTP en WebSocket ws, err := s.upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("ServeHTTP: Failed to upgrade connection: %v\n", err) - // L'upgrader envoie déjà une réponse HTTP d'erreur return } log.Printf("ServeHTTP: Client connected from %s\n", ws.RemoteAddr()) - // Crée une nouvelle connexion gérée conn := newConnection(ws) - // Enregistre le client auprès du hub s.hub.register <- conn - // Démarre les pompes de lecture et d'écriture pour cette connexion - // La readPump appellera hub.handleDisconnect quand elle se terminera. go conn.writePump() go conn.readPump(s.hub.handleIncomingMessage, s.hub.handleDisconnect) } -// handleMessage est le point central de traitement des messages entrants du serveur. +// The main entry point for all incoming Message. func (s *Server) handleMessage(msg *Message, client *connection) error { - ctx := context.Background() // Utiliser un contexte approprié + ctx := context.Background() log.Printf("Server: Handling message type '%s' from %p (ReqID: %s)\n", msg.Type, client.ws, msg.RequestID) switch msg.Type { @@ -195,37 +169,36 @@ func (s *Server) handleMessage(msg *Message, client *connection) error { return fmt.Errorf("build spec YAML cannot be empty") } - // Générer un ID de build unique - buildID := fmt.Sprintf("build-%d", time.Now().UnixNano()) // Ou utiliser UUID + uuid := uuid.NewString() + buildID := fmt.Sprintf("build-%s", uuid) - // Accusé de réception immédiat au client + // immediately acknowledge the build request ackPayload := BuildQueuedPayload{BuildID: buildID, Message: "Build job accepted"} ackMsg := NewMessage(EvtBuildQueued, msg.RequestID) // Utilise le RequestID original if err := ackMsg.AddPayload(ackPayload); err != nil { log.Printf("Server: Failed to create build queued payload: %v\n", err) - // Continuer quand même à lancer le build } - client.sendMsg(ackMsg) // Envoyer l'ack + client.sendMsg(ackMsg) - // Créer et enregistrer le notificateur pour ce build - notifier := newServerBuildNotifier(s.hub) // TODO: Avoir un seul notificateur partagé ? Ou un par build ? Un partagé semble mieux. - notifier.registerBuildClient(buildID, client) // Associer le client à ce build + // Create and register the notifier for this build + notifier := newServerBuildNotifier(s.hub) + notifier.registerBuildClient(buildID, client) - // Démarrer le build de manière asynchrone via l'interface + // Start the build asynchronously via the interface go func() { log.Printf("Server: Starting build %s asynchronously\n", buildID) - // Le contexte peut être utilisé pour l'annulation éventuelle + // The context can be used for eventual cancellation err := s.buildService.StartBuildAsync(context.Background(), buildID, payload.BuildSpecYAML, notifier) if err != nil { - // Si StartBuildAsync échoue immédiatement (rare), notifier l'échec + // If StartBuildAsync fails immediately (rare), notify the failure log.Printf("Server: Failed to start build %s: %v\n", buildID, err) notifier.NotifyStatus(buildID, "failure", "", err, nil) - // Le notifier va désenregistrer le build + // The notifier will unregister the build } - // Si StartBuildAsync réussit, le build s'exécute et le notifier gèrera les logs/status + // If StartBuildAsync succeeds, the build runs and the notifier will handle logs/status }() - return nil // Succès du traitement de la requête (le build est lancé en async) + return nil // Success in processing the request (the build is started asynchronously) case EvtSecretRequest: var payload SecretRequestPayload @@ -239,29 +212,26 @@ func (s *Server) handleMessage(msg *Message, client *connection) error { return fmt.Errorf("secret fetcher service is not configured on the server") } - // Récupérer le secret (synchrone ici, pourrait être async) + // Fetch the secret using the secret fetcher service secretValue, err := s.secretFetcher.GetSecret(ctx, payload.Source) if err != nil { - // Envoyer une réponse d'erreur spécifique au secret errMsg := NewErrorMessage(msg.RequestID, "Failed to fetch secret", err.Error()) client.sendMsg(errMsg) - return nil // L'erreur a été gérée en envoyant un message d'erreur + return nil } - // Envoyer la réponse avec le secret respPayload := SecretResponsePayload{Source: payload.Source, Value: secretValue} respMsg := NewMessage(EvtSecretResponse, msg.RequestID) if err := respMsg.AddPayload(respPayload); err != nil { - return fmt.Errorf("failed to create secret response payload: %w", err) // Erreur interne serveur + return fmt.Errorf("failed to create secret response payload: %w", err) } client.sendMsg(respMsg) return nil default: log.Printf("Server: Received unhandled message type '%s'\n", msg.Type) - // Envoyer une erreur au client pour indiquer que le type n'est pas géré ? errMsg := NewErrorMessage(msg.RequestID, "Unhandled message type", fmt.Sprintf("Type '%s' not supported by server", msg.Type)) client.sendMsg(errMsg) - return nil // Pas une erreur fatale pour la connexion + return nil } -} \ No newline at end of file +} diff --git a/socket/socket_test.go b/socket/socket_test.go index e892445..e2e7962 100644 --- a/socket/socket_test.go +++ b/socket/socket_test.go @@ -3,6 +3,7 @@ package socket import ( "context" "fmt" + "net/http" "net/http/httptest" "strings" "sync" @@ -13,7 +14,12 @@ import ( "github.com/stretchr/testify/require" ) -// --- Mocks pour BuildTriggerer et SecretFetcher --- +var ( + receivedBuildID string + receivedBuildSpec string + wg sync.WaitGroup + mu sync.Mutex +) type MockBuildTriggerer struct { StartBuildFunc func(ctx context.Context, buildID string, buildSpecYAML string, notifier BuildNotifier) error @@ -41,31 +47,28 @@ func (m *MockSecretFetcher) GetSecret(ctx context.Context, source string) (strin func TestSocket_ClientServerCommunication(t *testing.T) { // 1. Setup Mock Services - var wg sync.WaitGroup // Pour attendre les goroutines de notification - var receivedBuildID string - var receivedBuildSpec string mockBuildSvc := &MockBuildTriggerer{ StartBuildFunc: func(ctx context.Context, buildID string, buildSpecYAML string, notifier BuildNotifier) error { t.Logf("MockBuildTriggerer: StartBuildAsync called for BuildID: %s\n", buildID) - receivedBuildID = buildID // Capturer pour vérification + mu.Lock() + receivedBuildID = buildID // Catching this for verification receivedBuildSpec = buildSpecYAML + mu.Unlock() - // Simuler un build en arrière-plan qui envoie des logs et un statut wg.Add(1) go func() { defer wg.Done() - time.Sleep(50 * time.Millisecond) // Simuler le travail + time.Sleep(50 * time.Millisecond) notifier.NotifyLog(buildID, "stdout", "Fetching code...") time.Sleep(50 * time.Millisecond) notifier.NotifyLog(buildID, "stdout", "Building image...") time.Sleep(50 * time.Millisecond) - // Simuler un succès duration := 150.0 * time.Millisecond.Seconds() notifier.NotifyStatus(buildID, "success", "docker.io/library/test:latest", nil, &duration) t.Logf("MockBuildTriggerer: Sent final status for BuildID: %s\n", buildID) }() - return nil // Retourner nil pour indiquer que le lancement async a réussi + return nil }, } @@ -80,13 +83,13 @@ func TestSocket_ClientServerCommunication(t *testing.T) { } // 2. Start Test Server - server := NewServer(mockBuildSvc, mockSecretSvc) - server.Run() // Démarre le hub + server := NewServer(mockBuildSvc, mockSecretSvc, func(r *http.Request) bool { return true }) + server.Run() - httpServer := httptest.NewServer(server) // Utilise le ServeHTTP du serveur socket + httpServer := httptest.NewServer(server) defer httpServer.Close() - // Convertir l'URL HTTP en WS + // Convert the HTTP url into WS url prefixed wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") t.Logf("Test WebSocket Server running at: %s\n", wsURL) @@ -94,15 +97,15 @@ func TestSocket_ClientServerCommunication(t *testing.T) { client := NewClient() err := client.Connect(wsURL, nil) require.NoError(t, err, "Client failed to connect") - defer client.Close() // S'assurer que le client est fermé + defer client.Close() // Be sure that client is closed require.True(t, client.IsConnected(), "Client should be connected") // 4. Test Build Request / Response Flow - buildMessagesReceived := make(chan *Message, 10) // Buffer pour collecter les messages liés au build + buildMessagesReceived := make(chan *Message, 10) // Buffer to collect the build messages go func() { for msg := range client.Incoming { - // Filtrer les messages pour ce test + // Filter messages for this specific test if msg.Type == EvtBuildQueued || msg.Type == EvtLogChunk || msg.Type == EvtBuildStatus { buildMessagesReceived <- msg } else { @@ -110,36 +113,38 @@ func TestSocket_ClientServerCommunication(t *testing.T) { } } t.Log("Client Incoming Monitor: Exited.") - close(buildMessagesReceived) // Fermer quand client.Incoming est fermé + close(buildMessagesReceived) // Close if client.Incoming is closed }() - // Envoyer la requête de build - buildSpecContent := "name: test-build\nversion: '1.0'\n..." // Contenu YAML factice + // Send the build request + buildSpecContent := "name: test-build\nversion: '1.0'\n..." // Mock YAML content buildReqPayload := BuildRequestPayload{BuildSpecYAML: buildSpecContent} ctxReq, cancelReq := context.WithTimeout(context.Background(), 2*time.Second) defer cancelReq() - // Utiliser SendRequest pour obtenir l'accusé de réception (BuildQueued) + // Use SendRequest to get the acknowledgement of receipt (BuildQueued) respMsg, err := client.SendRequest(ctxReq, EvtBuildRequest, buildReqPayload) require.NoError(t, err, "SendRequest for build failed") require.NotNil(t, respMsg, "Response message should not be nil") require.Equal(t, EvtBuildQueued, respMsg.Type, "Response should be BuildQueued") - // Décoder le payload de la réponse pour obtenir le buildID + // Decode the response payload to get the buildID var queuedPayload BuildQueuedPayload err = respMsg.DecodePayload(&queuedPayload) require.NoError(t, err) require.NotEmpty(t, queuedPayload.BuildID, "BuildID should be in queued payload") - assert.Equal(t, queuedPayload.BuildID, receivedBuildID, "BuildID in response should match ID received by mock service") // Vérifier la cohérence + mu.Lock() + assert.Equal(t, queuedPayload.BuildID, receivedBuildID, "BuildID in response should match ID received by mock service") assert.Equal(t, buildSpecContent, receivedBuildSpec, "BuildSpec received by mock should match sent spec") + mu.Unlock() - // Attendre les messages streamés (logs, status final) + // Waiting for streaming messages (logs, status final) expectedLogs := []string{"Fetching code...", "Building image..."} receivedLogs := []string{} var finalStatusPayload BuildStatusPayload receivedFinalStatus := false - timeout := time.After(3 * time.Second) // Timeout pour attendre les messages streamés + timeout := time.After(3 * time.Second) // Timeout for waiting for the streaming messages logLoopDone := false for !logLoopDone { select { @@ -147,7 +152,7 @@ func TestSocket_ClientServerCommunication(t *testing.T) { if !ok { t.Log("Build message channel closed.") logLoopDone = true - break // Sortir si le canal est fermé + break } t.Logf("Client Received Async Message: Type=%s", msg.Type) switch msg.Type { @@ -162,14 +167,14 @@ func TestSocket_ClientServerCommunication(t *testing.T) { require.NoError(t, err) assert.Equal(t, receivedBuildID, finalStatusPayload.BuildID) receivedFinalStatus = true - logLoopDone = true // On a reçu le statut final + logLoopDone = true } case <-timeout: t.Fatal("Timeout waiting for streamed build messages (logs/status)") } } - // Vérifier les logs et le statut final reçus + // Check the logs and the final status assert.ElementsMatch(t, expectedLogs, receivedLogs, "Received logs do not match expected logs") require.True(t, receivedFinalStatus, "Should have received a final build status") assert.Equal(t, "success", finalStatusPayload.Status) @@ -193,22 +198,20 @@ func TestSocket_ClientServerCommunication(t *testing.T) { assert.Equal(t, "valid/secret", secretRespPayload.Source) assert.Equal(t, "secret_value_123", secretRespPayload.Value) - // Test secret non trouvé + // Test secret not found secretReqPayloadFail := SecretRequestPayload{Source: "invalid/secret"} ctxSecretFail, cancelSecretFail := context.WithTimeout(context.Background(), 1*time.Second) defer cancelSecretFail() _, err = client.SendRequest(ctxSecretFail, EvtSecretRequest, secretReqPayloadFail) require.Error(t, err, "SendRequest for invalid secret should fail") - assert.Contains(t, err.Error(), "secret 'invalid/secret' not found") // Vérifier l'erreur du serveur renvoyée + assert.Contains(t, err.Error(), "secret 'invalid/secret' not found") - // Attendre que la goroutine de notification du mock se termine wg.Wait() t.Log("Mock Build goroutine finished.") - // Fermer explicitement le client pour terminer le moniteur Incoming client.Close() - // Attendre un court instant pour que le canal `buildMessagesReceived` se ferme + // Waiting for the `buildMessagesReceived` chanel to be closed <-time.After(100 * time.Millisecond) -} \ No newline at end of file +}