Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/config/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"gopkg.in/validator.v2"

"github.com/kimdre/doco-cd/internal/config"
"github.com/kimdre/doco-cd/internal/hook"

secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types"

Expand Down Expand Up @@ -66,6 +67,7 @@ type Config struct {
AutoDiscovery AutoDiscoveryConfig `yaml:"auto_discovery" json:"auto_discovery"` // AutoDiscovery configures autodiscovery of services to deploy in the working directory
Reconciliation ReconciliationConfig `yaml:"reconciliation" json:"reconciliation" doco:"allowOverride"` // Reconciliation is the configuration for the reconciliation feature
Oci config.OciTrustPolicyOverride `yaml:"oci" json:"oci" doco:"allowOverride"` // Oci allows per-target overrides for OCI signature verification policy
Hooks hook.Config `yaml:"hooks" json:"hooks" doco:"allowOverride"` // Hooks configures HTTP webhook hooks fired on deployment success/failure
Internal struct {
File string `yaml:"-"` // File is the path to the deployment configuration file
Environment map[string]string // Environment stores environment variables for variable interpolation in the compose project
Expand Down Expand Up @@ -184,6 +186,10 @@ func (c *Config) Validate() error {
return err
}

if err := c.Hooks.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}

return nil
}

Expand Down
67 changes: 67 additions & 0 deletions internal/config/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/kimdre/doco-cd/internal/config"

"github.com/kimdre/doco-cd/internal/filesystem"
"github.com/kimdre/doco-cd/internal/hook"
)

func createTestFile(t *testing.T, fileName string, content string) error {
Expand Down Expand Up @@ -287,6 +288,72 @@ func TestConfig_Validate_OciVersionField(t *testing.T) {
})
}

func TestConfig_Hooks(t *testing.T) {
t.Parallel()

t.Run("parses success and failure hooks from yaml", func(t *testing.T) {
t.Parallel()

dir := t.TempDir()
file := filepath.Join(dir, ".doco-cd.yaml")
content := `name: app
reference: main
hooks:
on_success:
- url: https://example.com/ok
headers:
X-Token: secret
on_failure:
- url: http://example.com/fail
method: PUT
`

if err := createTestFile(t, file, content); err != nil {
t.Fatalf("write file: %v", err)
}

configs, err := GetConfigFromYAML(file, true)
if err != nil {
t.Fatalf("parse: %v", err)
}

dc := configs[0]
if err := dc.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}

if len(dc.Hooks.OnSuccess) != 1 || dc.Hooks.OnSuccess[0].URL != "https://example.com/ok" {
t.Fatalf("unexpected on_success hooks: %+v", dc.Hooks.OnSuccess)
}

if dc.Hooks.OnSuccess[0].Headers["X-Token"] != "secret" {
t.Fatalf("expected header X-Token=secret, got %+v", dc.Hooks.OnSuccess[0].Headers)
}

if len(dc.Hooks.OnFailure) != 1 || dc.Hooks.OnFailure[0].Method != "PUT" {
t.Fatalf("unexpected on_failure hooks: %+v", dc.Hooks.OnFailure)
}
})

t.Run("rejects invalid hook url", func(t *testing.T) {
t.Parallel()

dc := Config{
Name: "app",
Hooks: hook.Config{OnSuccess: []hook.Webhook{{URL: "ftp://example.com"}}},
}

if err := defaults.Set(&dc); err != nil {
t.Fatalf("defaults: %v", err)
}

err := dc.Validate()
if err == nil || !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}

func TestResolveConfigs_InlineOverride(t *testing.T) {
t.Parallel()

Expand Down
70 changes: 70 additions & 0 deletions internal/config/deploy/hooks_multidoc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package deploy

import (
"path/filepath"
"testing"
)

// Reproduces the server config shape: a multi-document file where only the
// first document carries a hooks block (like .doco-cd.qa-epic-workspace.yaml).
func TestGetConfigFromYAML_MultiDocHooks(t *testing.T) {
t.Parallel()

dir := t.TempDir()
file := filepath.Join(dir, ".doco-cd.qa-epic-workspace.yaml")
content := `name: qa-epic-chatbotix-backend
repository_url: https://github.com/Truevoice/accentix-doco-cd-config.git
reference: feat/multiple-hooks
working_dir: qa-epic-workspace/backend
compose_files:
- docker-compose-gcp.yaml
force_recreate: true
hooks:
on_success:
- url: https://idp-dev.accentix.dev/api/events
method: POST
on_failure:
- url: https://idp-dev.accentix.dev/api/events
method: POST
---
name: qa-epic-chatbotix-frontend
repository_url: https://github.com/Truevoice/accentix-doco-cd-config.git
reference: feat/multiple-hooks
working_dir: qa-epic-workspace/frontend
compose_files:
- docker-compose-gcp.yaml
`

if err := createTestFile(t, file, content); err != nil {
t.Fatalf("write file: %v", err)
}

configs, err := GetConfigFromYAML(file, true)
if err != nil {
t.Fatalf("parse: %v", err)
}

if len(configs) != 2 {
t.Fatalf("expected 2 docs, got %d", len(configs))
}

backend := configs[0]
t.Logf("backend.Hooks = %+v", backend.Hooks)

if len(backend.Hooks.OnSuccess) != 1 {
t.Fatalf("doc1 OnSuccess: expected 1, got %d", len(backend.Hooks.OnSuccess))
}

if len(backend.Hooks.OnFailure) != 1 {
t.Fatalf("doc1 OnFailure: expected 1, got %d", len(backend.Hooks.OnFailure))
}

if backend.Hooks.OnSuccess[0].URL != "https://idp-dev.accentix.dev/api/events" {
t.Fatalf("doc1 hook url wrong: %q", backend.Hooks.OnSuccess[0].URL)
}

// doc2 has no hooks
if len(configs[1].Hooks.OnSuccess) != 0 || len(configs[1].Hooks.OnFailure) != 0 {
t.Fatalf("doc2 should have no hooks, got %+v", configs[1].Hooks)
}
}
107 changes: 107 additions & 0 deletions internal/hook/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Package hook sends per-deployment HTTP webhook hooks on deployment success or failure.
package hook

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// requestTimeout bounds a single hook request.
const requestTimeout = 10 * time.Second

var (
ErrEmptyURL = errors.New("hook url must not be empty")
ErrInvalidURL = errors.New("hook url must be a valid http(s) url")
ErrHookFailed = errors.New("hook request failed")
httpHookClient = &http.Client{Timeout: requestTimeout}
)

// Webhook is a single HTTP hook target.
type Webhook struct {
URL string `yaml:"url" json:"url"` // URL is the endpoint to call
Method string `yaml:"method" json:"method"` // Method is the HTTP method (defaults to POST when empty)
Headers map[string]string `yaml:"headers" json:"headers"` // Headers are additional request headers
}

// Config holds the hooks fired at the success and failure lifecycle points of a deployment.
type Config struct {
OnSuccess []Webhook `yaml:"on_success" json:"on_success"` // OnSuccess hooks fire after a successful deployment
OnFailure []Webhook `yaml:"on_failure" json:"on_failure"` // OnFailure hooks fire when a deployment fails
}

// Payload is the JSON body sent to a hook endpoint.
type Payload struct {
Event string `json:"event"` // Event is "success" or "failure"
Repository string `json:"repository"` // Repository is the source repository/artifact name
Stack string `json:"stack"` // Stack is the deployment/stack name
Revision string `json:"revision"` // Revision is the deployed reference/commit
JobID string `json:"job_id"` // JobID is the unique job identifier
Images []string `json:"images,omitempty"` // Images are the resolved image references of the changed services
Error string `json:"error,omitempty"` // Error is the failure reason (failure event only)
}

// Validate checks that every configured hook has a valid http(s) URL.
func (c Config) Validate() error {
for _, w := range append(append([]Webhook{}, c.OnSuccess...), c.OnFailure...) {
if strings.TrimSpace(w.URL) == "" {
return ErrEmptyURL
}

u, err := url.Parse(w.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("%w: %s", ErrInvalidURL, w.URL)
}
}

return nil
}

// Send delivers the payload to a single hook target. Any non-2xx response is an error.
func Send(ctx context.Context, w Webhook, payload Payload) error {
method := strings.TrimSpace(w.Method)
if method == "" {
method = http.MethodPost
}

body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal hook payload: %w", err)
}

req, err := http.NewRequestWithContext(ctx, method, w.URL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create hook request: %w", err)
}

req.Header.Set("Content-Type", "application/json")

for k, v := range w.Headers {
req.Header.Set(k, v)
}

resp, err := httpHookClient.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrHookFailed, err)
}

defer func() {
_ = resp.Body.Close()
}()

// Drain the body so the underlying transport can safely reuse the connection.
_, _ = io.Copy(io.Discard, resp.Body)

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: status %s", ErrHookFailed, resp.Status)
}

return nil
}
Loading