diff --git a/stovepipe/entity/BUILD.bazel b/stovepipe/entity/BUILD.bazel index 64862273..0884ce1a 100644 --- a/stovepipe/entity/BUILD.bazel +++ b/stovepipe/entity/BUILD.bazel @@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ + "build.go", "ingest.go", "queue.go", "queue_config.go", @@ -16,6 +17,7 @@ go_library( go_test( name = "go_default_test", srcs = [ + "build_test.go", "request_id_test.go", "request_test.go", ], diff --git a/stovepipe/entity/build.go b/stovepipe/entity/build.go new file mode 100644 index 00000000..4c194bc3 --- /dev/null +++ b/stovepipe/entity/build.go @@ -0,0 +1,118 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// 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 entity + +import "encoding/json" + +// BuildStatus defines the possible states of a build. Shaped the same as +// SubmitQueue's own BuildStatus (submitqueue/entity/build.go), but defined +// locally rather than shared — see build.md's "Alternatives considered for +// sharing the contract". +type BuildStatus string + +const ( + // BuildStatusUnknown is the unreachable zero value, set by default when + // the structure is initialized. It should never be seen in the system. + BuildStatusUnknown BuildStatus = "" + + // BuildStatusAccepted indicates the build has been accepted for + // execution by the runner via Trigger, but has not yet started. + BuildStatusAccepted BuildStatus = "accepted" + + // BuildStatusRunning indicates the build is currently executing. + BuildStatusRunning BuildStatus = "running" + + // BuildStatusSucceeded indicates the build completed successfully. + // This is a terminal state. + BuildStatusSucceeded BuildStatus = "succeeded" + + // BuildStatusFailed indicates the build did not complete successfully. + // This is a terminal state. + BuildStatusFailed BuildStatus = "failed" + + // BuildStatusCancelled indicates the build was cancelled. + // This is a terminal state. + BuildStatusCancelled BuildStatus = "cancelled" +) + +// IsTerminal returns true if the status represents a final state +// (Succeeded, Failed, or Cancelled). A terminal status is write-once: once +// buildsignal persists one, a later poll reporting a different terminal +// value must never overwrite it (see buildsignal.md's Algorithm, step 6). +func (s BuildStatus) IsTerminal() bool { + return s == BuildStatusSucceeded || s == BuildStatusFailed || s == BuildStatusCancelled +} + +// Build represents a single build triggered for a Request's commit. All +// fields except Status and Version are immutable after creation — build is +// the sole creator (via BuildStore.Create), and buildsignal is the sole +// writer of Status/Version afterward. +type Build struct { + // ID is the build's own key: the runner-assigned id returned by + // Trigger (e.g. a Buildkite build number). Opaque; never parsed or + // derived by stovepipe. + ID string `json:"id"` + // RequestID is the Request this build validates (Build->Request + // navigation; no reverse index from Request to its builds is needed). + RequestID string `json:"request_id"` + // URI is the head URI being built (== Request.URI). + URI string `json:"uri"` + // BaseURI is the incremental baseline; empty for full builds. + BaseURI string `json:"base_uri"` + // Status is the build's lifecycle state. + Status BuildStatus `json:"status"` + // Version is used for optimistic locking. Versioning starts at 1 and + // is incremented for each change to the object. + Version int32 `json:"version"` +} + +// ToBytes serializes the Build to JSON bytes for queue message payload. +func (b Build) ToBytes() ([]byte, error) { + return json.Marshal(b) +} + +// BuildFromBytes deserializes a Build from JSON bytes. +func BuildFromBytes(data []byte) (Build, error) { + var build Build + err := json.Unmarshal(data, &build) + return build, err +} + +// BuildID is a lightweight entity for publishing and consuming just the +// build identifier via the queue, and for the BuildRunner Status/Cancel +// parameter. It wraps the one runner-assigned id everywhere it appears. +type BuildID struct { + // ID is the runner-assigned identifier for the build. + ID string `json:"id"` +} + +// ToBytes serializes the BuildID to JSON bytes for queue message payload. +func (b BuildID) ToBytes() ([]byte, error) { + return json.Marshal(b) +} + +// BuildIDFromBytes deserializes a BuildID from JSON bytes. +func BuildIDFromBytes(data []byte) (BuildID, error) { + var bid BuildID + err := json.Unmarshal(data, &bid) + return bid, err +} + +// BuildMetadata carries caller-supplied, provider-echoed free-form metadata +// about a build. The runner must not depend on its contents. Empty today; +// expected to carry real data eventually (e.g. conflict-graph info, or other +// upstream decisions relevant to the build) once a concrete need lands in +// either domain — the shape is deferred until then, not decided here. +type BuildMetadata map[string]string diff --git a/stovepipe/entity/build_test.go b/stovepipe/entity/build_test.go new file mode 100644 index 00000000..4d94c3b1 --- /dev/null +++ b/stovepipe/entity/build_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// 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 entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildStatus_IsTerminal(t *testing.T) { + tests := []struct { + name string + status BuildStatus + expected bool + }{ + {name: "succeeded is terminal", status: BuildStatusSucceeded, expected: true}, + {name: "failed is terminal", status: BuildStatusFailed, expected: true}, + {name: "cancelled is terminal", status: BuildStatusCancelled, expected: true}, + {name: "accepted is not terminal", status: BuildStatusAccepted, expected: false}, + {name: "running is not terminal", status: BuildStatusRunning, expected: false}, + {name: "unknown is not terminal", status: BuildStatusUnknown, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.status.IsTerminal()) + }) + } +} + +func TestBuild_SerializationRoundTrip(t *testing.T) { + tests := []struct { + name string + build Build + }{ + { + name: "accepted incremental build", + build: Build{ + ID: "bk-1001", + RequestID: "request/monorepo/main/42", + URI: "git://remote/monorepo/main/deadbeef", + BaseURI: "git://remote/monorepo/main/cafef00d", + Status: BuildStatusAccepted, + Version: 1, + }, + }, + { + name: "succeeded full build with no baseline", + build: Build{ + ID: "bk-1002", + RequestID: "request/monorepo/main/43", + URI: "git://remote/monorepo/main/feedface", + Status: BuildStatusSucceeded, + Version: 3, + }, + }, + { + name: "failed build", + build: Build{ + ID: "bk-1003", + RequestID: "request/monorepo/main/44", + URI: "git://remote/monorepo/main/0ff1ce", + BaseURI: "git://remote/monorepo/main/cafef00d", + Status: BuildStatusFailed, + Version: 2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.build.ToBytes() + require.NoError(t, err) + + deserialized, err := BuildFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, tt.build, deserialized) + }) + } +} + +func TestBuildFromBytes_InvalidJSON(t *testing.T) { + _, err := BuildFromBytes([]byte(`{"invalid": json"}`)) + assert.Error(t, err) +} + +func TestBuildFromBytes_EmptyData(t *testing.T) { + build, err := BuildFromBytes([]byte(`{}`)) + require.NoError(t, err) + + assert.Empty(t, build.ID) + assert.Empty(t, build.RequestID) + assert.Equal(t, BuildStatusUnknown, build.Status) + assert.Equal(t, int32(0), build.Version) +} + +func TestBuildID_SerializationRoundTrip(t *testing.T) { + original := BuildID{ID: "bk-1001"} + + data, err := original.ToBytes() + require.NoError(t, err) + + deserialized, err := BuildIDFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, original, deserialized) +} + +func TestBuildIDFromBytes_InvalidJSON(t *testing.T) { + _, err := BuildIDFromBytes([]byte(`{"invalid": json"}`)) + assert.Error(t, err) +} diff --git a/stovepipe/entity/request.go b/stovepipe/entity/request.go index 69b49fb8..817b5250 100644 --- a/stovepipe/entity/request.go +++ b/stovepipe/entity/request.go @@ -36,8 +36,21 @@ const ( RequestStateProcessing RequestState = "processing" // RequestStateSuperseded means process skipped the request because a newer head exists. RequestStateSuperseded RequestState = "superseded" + // RequestStateRecordedGreen means record wrote whole-repo greenness as green for this URI. + RequestStateRecordedGreen RequestState = "recorded_green" + // RequestStateRecordedNotGreen means record wrote whole-repo greenness as not-green for this + // URI — either a genuine build failure, or a conservative verdict forced by the DLQ + // reconciler when the request could never complete (see workflow.md's fail-closed posture). + RequestStateRecordedNotGreen RequestState = "recorded_not_green" ) +// IsTerminal returns true if the state is one the pipeline never advances past: superseded (a +// newer head preempted this request) or either recorded outcome (record wrote greenness for +// this URI, whether via the normal path or the fail-closed reconciler). +func (s RequestState) IsTerminal() bool { + return s == RequestStateSuperseded || s == RequestStateRecordedGreen || s == RequestStateRecordedNotGreen +} + // BuildStrategy defines how build validates the request's commit. type BuildStrategy string diff --git a/stovepipe/entity/request_test.go b/stovepipe/entity/request_test.go index 10fa4bd5..e4120d6f 100644 --- a/stovepipe/entity/request_test.go +++ b/stovepipe/entity/request_test.go @@ -79,6 +79,27 @@ func TestRequestFromBytes_EmptyData(t *testing.T) { assert.Equal(t, int32(0), req.Version) } +func TestRequestState_IsTerminal(t *testing.T) { + tests := []struct { + name string + state RequestState + expected bool + }{ + {name: "superseded is terminal", state: RequestStateSuperseded, expected: true}, + {name: "recorded green is terminal", state: RequestStateRecordedGreen, expected: true}, + {name: "recorded not green is terminal", state: RequestStateRecordedNotGreen, expected: true}, + {name: "unknown is not terminal", state: RequestStateUnknown, expected: false}, + {name: "accepted is not terminal", state: RequestStateAccepted, expected: false}, + {name: "processing is not terminal", state: RequestStateProcessing, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.state.IsTerminal()) + }) + } +} + func TestRequestID_SerializationRoundTrip(t *testing.T) { original := RequestID{ID: "request/monorepo/main/100"}