diff --git a/AI_POLICY.md b/AI_POLICY.md index 395ee51fe..be42f8dd9 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -47,8 +47,13 @@ PR description. Add an `Assisted-by:` trailer to each affected commit: Assisted-by: GitHub Copilot Assisted-by: Claude Code Assisted-by: ChatGPT o3 +Assisted-by: OpenCode ``` +Use the name of the AI coding tool that actually produced the work (e.g. +`OpenCode`, `Claude Code`, `GitHub Copilot`), not the underlying model. Do not +blindly copy an example — pick the value that matches the tool you are running. + Disclosure is not a penalty — it is trust infrastructure. It preserves transparency, helps reviewers calibrate their attention, and keeps provenance clear for the project's long-term health. diff --git a/CLAUDE.md b/CLAUDE.md index 292b66887..77e0bec46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -267,8 +267,11 @@ Code reviews are required for all submissions via GitHub pull requests. Assisted-by: GitHub Copilot Assisted-by: Claude Code Assisted-by: ChatGPT o3 + Assisted-by: OpenCode ``` + Use the name of the AI coding tool that actually produced the work (e.g. `OpenCode`, `Claude Code`, `GitHub Copilot`), not the underlying model. Do not blindly copy an example — pick the value that matches the tool you are running. + 2. **The PR description** MUST disclose the AI assistance. Apply this on the *first* commit/PR you create — do not wait for review feedback to add it. If you amend or rebase a commit that previously lacked the trailer, add it as part of the amend. diff --git a/app/controlplane/internal/dispatcher/dispatch_timeout_test.go b/app/controlplane/internal/dispatcher/dispatch_timeout_test.go new file mode 100644 index 000000000..cc117ac86 --- /dev/null +++ b/app/controlplane/internal/dispatcher/dispatch_timeout_test.go @@ -0,0 +1,86 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dispatcher + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" + mockedSDK "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1/mocks" + "github.com/go-kratos/kratos/v2/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// setMaxDispatchElapsedTimeForTest temporarily shrinks the total retry +// budget so tests don't wait for the real 5m. The returned func restores +// the production value. +func setMaxDispatchElapsedTimeForTest(d time.Duration) func() { + prev := maxDispatchElapsedTime + maxDispatchElapsedTime = d + return func() { maxDispatchElapsedTime = prev } +} + +// TestDispatchSucceedsOnRetry verifies that a transient failure followed by +// success stops the retry loop and returns nil. +func TestDispatchSucceedsOnRetry(t *testing.T) { + // Safety net: if the mock setup is wrong, fail fast instead of waiting 5m. + restore := setMaxDispatchElapsedTimeForTest(2 * time.Second) + t.Cleanup(restore) + + plugin := mockedSDK.NewFanOut(t) + plugin.On("String", mock.Anything).Return("test-plugin").Maybe() + + // First call fails, second call succeeds. .Once() ensures each + // expectation is consumed exactly once, so the second call returns nil. + plugin.On("Execute", mock.Anything, mock.Anything).Return(errors.New("transient failure")).Once() + plugin.On("Execute", mock.Anything, mock.Anything).Return(nil).Once() + + logger := log.NewHelper(log.NewStdLogger(io.Discard)) + opts := &sdk.ExecutionRequest{ + Input: &sdk.ExecuteInput{ + Attestation: &sdk.ExecuteAttestation{}, + }, + } + + err := dispatch(context.Background(), plugin, opts, logger) + require.NoError(t, err) + + var execCalls int + for _, c := range plugin.Calls { + if c.Method == "Execute" { + execCalls++ + } + } + assert.Equal(t, 2, execCalls, "should have called Execute exactly twice") +} + +// TestDispatchNoInput verifies the fast-fail path that doesn't enter the retry loop. +func TestDispatchNoInput(t *testing.T) { + plugin := mockedSDK.NewFanOut(t) + logger := log.NewHelper(log.NewStdLogger(io.Discard)) + opts := &sdk.ExecutionRequest{Input: &sdk.ExecuteInput{}} + + err := dispatch(context.Background(), plugin, opts, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "no input provided") + plugin.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything) +} diff --git a/app/controlplane/internal/dispatcher/dispatcher.go b/app/controlplane/internal/dispatcher/dispatcher.go index 704bb6b2d..925af4f41 100644 --- a/app/controlplane/internal/dispatcher/dispatcher.go +++ b/app/controlplane/internal/dispatcher/dispatcher.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -48,6 +48,17 @@ type FanOutDispatcher struct { loaded sdk.AvailablePlugins } +// maxDispatchElapsedTime bounds the total time the dispatcher will keep +// retrying a single integration delivery. With the default exponential +// backoff (500ms initial, ×1.5 multiplier, 60s max interval) this yields +// roughly 12-15 attempts — enough to ride out a transient blip or a brief +// endpoint restart, while staying short enough not to leak goroutines in +// the current in-process, fire-and-forget dispatch model. +// +// It is a var (not a const) so tests can shrink it to avoid waiting for +// the real 5m budget. Production code must not mutate it. +var maxDispatchElapsedTime = 5 * time.Minute + func New(integrationUC *biz.IntegrationUseCase, wfUC *biz.WorkflowUseCase, wfRunUC *biz.WorkflowRunUseCase, creds credentials.ReaderWriter, c biz.CASClient, registered sdk.AvailablePlugins, l log.Logger) *FanOutDispatcher { return &FanOutDispatcher{integrationUC, wfUC, wfRunUC, creds, c, servicelogger.ScopedHelper(l, "fanout-dispatcher"), l, registered} } @@ -280,7 +291,7 @@ func (d *FanOutDispatcher) loadInputs(ctx context.Context, queue dispatchQueue, func dispatch(ctx context.Context, plugin sdk.FanOut, opts *sdk.ExecutionRequest, logger *log.Helper) error { b := backoff.NewExponentialBackOff() - b.MaxElapsedTime = 10 * time.Second + b.MaxElapsedTime = maxDispatchElapsedTime var inputType string switch { diff --git a/app/controlplane/plugins/core/webhook/v1/webhook.go b/app/controlplane/plugins/core/webhook/v1/webhook.go index afa83ac2e..f395afd69 100644 --- a/app/controlplane/plugins/core/webhook/v1/webhook.go +++ b/app/controlplane/plugins/core/webhook/v1/webhook.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -20,15 +20,20 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/url" + "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/go-kratos/kratos/v2/log" ) +// perAttemptTimeout caps how long a single webhook HTTP call may take, +// preventing a hung endpoint from blocking retries indefinitely. +const perAttemptTimeout = 10 * time.Second + // Integration implements a generic webhook integration type Integration struct { *sdk.FanOutIntegration @@ -69,7 +74,7 @@ func New(l log.Logger) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "webhook", - Version: "1.1", + Version: "1.2", Description: "Send Attestation and SBOMs to a generic POST webhook URL", Logger: l, InputSchema: &sdk.InputSchema{ @@ -87,7 +92,7 @@ func New(l log.Logger) (sdk.FanOut, error) { return &Integration{ FanOutIntegration: base, - client: &http.Client{}, + client: &http.Client{Timeout: perAttemptTimeout}, }, nil } @@ -259,7 +264,7 @@ func (i *Integration) sendWebhook(ctx context.Context, url, kind string, payload }() // Read response body for more detailed error messages - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { i.Logger.Warnw("failed to read response body", "error", err) }