package analysis
import (
"fmt"
"strings"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/model"
)
// BuildGraph builds a directed graph of targets and analyzes it.
func BuildGraph(nodes model.BuildNodeMap) (*dag.DirectedTargetGraph, error) {
graph := dag.NewDirectedGraphFromMap(nodes)
// Add edges defined by dependencies
for _, node := range nodes {
for _, depLabel := range node.GetDependencies() {
dep := nodes[depLabel]
if dep == nil {
return &dag.DirectedTargetGraph{}, fmt.Errorf("dependency %s of node %s not found", depLabel, node.GetLabel())
}
err := graph.AddEdge(dep, node)
if err != nil {
return &dag.DirectedTargetGraph{}, err
}
}
}
if cycle, hasCycle := graph.FindCycle(); hasCycle {
var chain []string
for _, node := range cycle {
chain = append(chain, node.GetLabel().String())
}
return &dag.DirectedTargetGraph{}, fmt.Errorf("cycle detected: %s", strings.Join(chain, " -> "))
}
if err := detectOutputConflicts(graph); err != nil {
return &dag.DirectedTargetGraph{}, err
}
return graph, nil
}
package analysis
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output/handlers"
)
type outputRecord struct {
target *model.Target
output model.Output
path string
}
// detectOutputConflicts checks for conflicting output declarations between targets.
// A conflict happens when:
// - two targets write to the same file, directory, docker image (or overlap of dir and file)
// - two targets are not in each other's dependency ancestors
// This is an error, because there is no guarantee which target will execute first
// and overwrite the other's outputs.
func detectOutputConflicts(graph *dag.DirectedTargetGraph) error {
var fileOutputs []outputRecord
var dirOutputs []outputRecord
dockerOutputs := make(map[string][]outputRecord)
for _, node := range graph.GetNodes() {
target, ok := node.(*model.Target)
if !ok {
continue
}
for _, output := range target.AllOutputs() {
switch output.Type {
case string(handlers.OCIHandler):
dockerOutputs[output.Identifier] = append(dockerOutputs[output.Identifier], outputRecord{
target: target,
output: output,
})
case string(handlers.DirHandler):
dirOutputs = append(dirOutputs, outputRecord{
target: target,
output: output,
path: cleanOutputPath(target, output.Identifier),
})
default:
fileOutputs = append(fileOutputs, outputRecord{
target: target,
output: output,
path: cleanOutputPath(target, output.Identifier),
})
}
}
}
var conflicts []string
ancestorCache := make(map[label.TargetLabel]map[label.TargetLabel]struct{})
addConflict := func(message string) {
conflicts = append(conflicts, "- "+message)
}
fileOutputsByPath := make(map[string][]outputRecord)
for _, record := range fileOutputs {
fileOutputsByPath[record.path] = append(fileOutputsByPath[record.path], record)
}
conflicts = append(conflicts, sharedKeyConflicts(graph, dockerOutputs, "declare docker image", ancestorCache)...)
conflicts = append(conflicts, sharedKeyConflicts(graph, fileOutputsByPath, "write file output", ancestorCache)...)
for i := 0; i < len(dirOutputs); i++ {
for j := i + 1; j < len(dirOutputs); j++ {
if targetsAreOrdered(graph, dirOutputs[i].target, dirOutputs[j].target, ancestorCache) {
continue
}
if pathsOverlap(dirOutputs[i].path, dirOutputs[j].path) {
addConflict(fmt.Sprintf("%s and %s both write overlapping directories (%q and %q)", dirOutputs[i].target.Label, dirOutputs[j].target.Label, dirOutputs[i].path, dirOutputs[j].path))
}
}
}
for _, dirRecord := range dirOutputs {
for _, fileRecord := range fileOutputs {
if targetsAreOrdered(graph, dirRecord.target, fileRecord.target, ancestorCache) {
continue
}
if pathWithin(fileRecord.path, dirRecord.path) {
addConflict(fmt.Sprintf("%s writes directory %q which overlaps file output %q from %s", dirRecord.target.Label, dirRecord.path, fileRecord.path, fileRecord.target.Label))
}
}
}
if len(conflicts) == 0 {
return nil
}
sort.Strings(conflicts)
return fmt.Errorf("conflicting outputs detected between independent targets:\n%s\nnote: these overlapping outputs create a race condition that can lead to unexpected or inconsistent build results", strings.Join(conflicts, "\n"))
}
// sharedKeyConflicts reports every pair of unordered targets that declare the same output key.
func sharedKeyConflicts(graph *dag.DirectedTargetGraph, recordsByKey map[string][]outputRecord, action string, ancestorCache map[label.TargetLabel]map[label.TargetLabel]struct{}) []string {
var conflicts []string
for key, records := range recordsByKey {
for i := range records {
for j := i + 1; j < len(records); j++ {
if targetsAreOrdered(graph, records[i].target, records[j].target, ancestorCache) {
continue
}
conflicts = append(conflicts, fmt.Sprintf("- %s and %s both %s %q", records[i].target.Label, records[j].target.Label, action, key))
}
}
}
return conflicts
}
func cleanOutputPath(target *model.Target, output string) string {
return filepath.Clean(filepath.Join(target.Label.Package, output))
}
func pathWithin(path, dir string) bool {
if path == dir {
return true
}
dirWithSeparator := dir + string(filepath.Separator)
return strings.HasPrefix(path, dirWithSeparator)
}
func pathsOverlap(a, b string) bool {
return pathWithin(a, b) || pathWithin(b, a)
}
// targetsAreOrdered returns true if a and b are in the same dependency chain.
func targetsAreOrdered(graph *dag.DirectedTargetGraph, a, b model.BuildNode, ancestorCache map[label.TargetLabel]map[label.TargetLabel]struct{}) bool {
if ancestorCache == nil {
ancestorCache = make(map[label.TargetLabel]map[label.TargetLabel]struct{})
}
ancestorsOfA := getAncestorSet(graph, a, ancestorCache)
if _, ok := ancestorsOfA[b.GetLabel()]; ok {
return true
}
ancestorsOfB := getAncestorSet(graph, b, ancestorCache)
if _, ok := ancestorsOfB[a.GetLabel()]; ok {
return true
}
return false
}
// getAncestorSet returns a set of all ancestors of the given node, including the node itself.
func getAncestorSet(graph *dag.DirectedTargetGraph, node model.BuildNode, cache map[label.TargetLabel]map[label.TargetLabel]struct{}) map[label.TargetLabel]struct{} {
if ancestors, ok := cache[node.GetLabel()]; ok {
return ancestors
}
set := make(map[label.TargetLabel]struct{})
stack := append([]model.BuildNode{}, graph.GetDependencies(node)...)
for len(stack) > 0 {
ancestor := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if _, seen := set[ancestor.GetLabel()]; seen {
continue
}
set[ancestor.GetLabel()] = struct{}{}
if cached, ok := cache[ancestor.GetLabel()]; ok {
for cachedAncestor := range cached {
set[cachedAncestor] = struct{}{}
}
continue
}
stack = append(stack, graph.GetDependencies(ancestor)...)
}
cache[node.GetLabel()] = set
return set
}
package analysis
import (
"fmt"
"path"
"path/filepath"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/fatih/color"
)
/*
We need to check the following four constraints for the paths defined by each target:
1. all inputs must be relative to the package path
2. all outputs point to files within the repository
TODO 3. warn if a target's inputs intersect with another target's outputs without them explicitly depending on each other
TODO 4. error if a target's inputs intersect with its own outputs?
TODO: We don't yet check that a parent package does not include inputs from children. Should we?
*/
// CheckTargetConstraints checks that the paths defined by each target are valid
// logs any warnings on the way.
func CheckTargetConstraints(logger *console.Logger, nodeMap model.BuildNodeMap) (errs []error) {
// iterate over targets in alphabetical order for consistent logging
for _, node := range nodeMap.NodesAlphabetically() {
target, ok := node.(*model.Target)
if !ok {
continue
}
inputRelativeError := checkInputPathsRelative(target)
errs = append(errs, inputRelativeError...)
outputsError := checkOutputsAreWithinRepository(target)
errs = append(errs, outputsError...)
ociPushErrs := checkOciPushReferences(target)
errs = append(errs, ociPushErrs...)
if target.IsTest() && target.Command == "" {
errs = append(errs, fmt.Errorf("target %s is a test target but has no command", target.Label))
}
if len(target.Inputs) == 0 && len(target.Dependencies) == 0 && len(target.OutputChecks) == 0 && len(target.Fingerprint) == 0 {
logger.Warnf("target %s has no inputs, dependencies, output checks or fingerprint causing it to run only once", target.Label)
}
}
dependencyConstraintErrors := checkDependencyConstraints(nodeMap)
errs = append(errs, dependencyConstraintErrors...)
resourceConstraintErrors := checkResourceConstraints(nodeMap)
errs = append(errs, resourceConstraintErrors...)
return errs
}
// checkResourceConstraints validates resource nodes: a resource may depend on
// regular targets (e.g. an image build) or other resources, but never on test
// targets.
func checkResourceConstraints(nodeMap model.BuildNodeMap) (errs []error) {
for _, node := range nodeMap.NodesAlphabetically() {
resource, isResource := node.(*model.Resource)
if !isResource {
continue
}
for _, dependencyLabel := range resource.Dependencies {
dependencyTarget := resolveDependencyTarget(nodeMap, dependencyLabel)
if dependencyTarget == nil {
continue
}
if dependencyTarget.IsTest() {
errs = append(errs, fmt.Errorf("%s depends on %s which is a test target",
resource.Label,
dependencyTarget.Label,
))
}
}
}
return errs
}
func checkDependencyConstraints(nodeMap model.BuildNodeMap) (errs []error) {
for _, node := range nodeMap.NodesAlphabetically() {
target, isTarget := node.(*model.Target)
if !isTarget {
continue
}
for _, dependencyLabel := range target.Dependencies {
dependencyTarget := resolveDependencyTarget(nodeMap, dependencyLabel)
if dependencyTarget == nil {
continue
}
if dependencyTarget.IsTest() && !target.IsTest() {
errs = append(errs, fmt.Errorf("%s depends on %s which is a test target",
target.Label,
dependencyTarget.Label,
))
continue
}
if dependencyTarget.IsTestOnly() && !target.IsTestOnly() && !target.IsTest() {
errs = append(errs, fmt.Errorf("%s depends on %s which is tagged %q",
target.Label,
dependencyTarget.Label,
model.TagTestOnly,
))
}
}
}
return errs
}
func resolveDependencyTarget(
nodeMap model.BuildNodeMap,
dependencyLabel label.TargetLabel,
) *model.Target {
currentDependencyLabel := dependencyLabel
visitedDependencyLabels := make(map[label.TargetLabel]struct{})
for {
if _, alreadyVisited := visitedDependencyLabels[currentDependencyLabel]; alreadyVisited {
return nil
}
visitedDependencyLabels[currentDependencyLabel] = struct{}{}
dependencyNode, hasDependencyNode := nodeMap[currentDependencyLabel]
if !hasDependencyNode {
return nil
}
dependencyTarget, isTarget := dependencyNode.(*model.Target)
if isTarget {
return dependencyTarget
}
dependencyAlias, isAlias := dependencyNode.(*model.Alias)
if !isAlias {
return nil
}
currentDependencyLabel = dependencyAlias.Actual
}
}
// checkInputPathsRelative checks that all inputs are relative to the package path
// and do not point outside the package.
func checkInputPathsRelative(target *model.Target) (errs []error) {
for _, input := range target.Inputs {
if path.IsAbs(input) {
errs = append(errs, fmt.Errorf(
"input %s for target %s is not relative",
input,
target.Label))
continue
}
triesToEscapePackage := pathTriesToEscape(input)
if triesToEscapePackage {
errs = append(errs, fmt.Errorf(
"input %s for target %s points outside the package. Use %s to declare dependencies between targets",
input,
target.Label,
color.New(color.Bold).Sprintf("deps")))
}
}
return
}
// checkOciPushReferences verifies that every key in target.OciPush matches the
// identifier of an oci:: output produced by the same target — otherwise --push
// would have nothing to ship.
func checkOciPushReferences(target *model.Target) (errs []error) {
if len(target.OciPush) == 0 {
return nil
}
available := make(map[string]struct{}, len(target.Outputs))
for _, out := range target.AllOutputs() {
if out.Type == "oci" {
available[out.Identifier] = struct{}{}
}
}
for localName := range target.OciPush {
if _, ok := available[localName]; !ok {
errs = append(errs, fmt.Errorf(
"target %s: oci_push key %q does not match any oci:: output",
target.Label, localName,
))
}
}
return errs
}
const relativePrefix = ".." + string(filepath.Separator)
func pathTriesToEscape(relPath string) bool {
cleanedPath := filepath.Clean(relPath)
return strings.HasPrefix(cleanedPath, relativePrefix) || cleanedPath == ".."
}
func checkOutputsAreWithinRepository(target *model.Target) (errs []error) {
workspaceRoot := config.Global.WorkspaceRoot
for _, output := range target.FileOutputs() {
if path.IsAbs(output) {
errs = append(errs, fmt.Errorf(
"output %s for target %s is not relative",
output,
target.Label))
continue
}
check, err := isWithinWorkspace(workspaceRoot, target.Label.Package, output)
if err != nil {
errs = append(errs, fmt.Errorf(
"could not check output %s for target %s: %w",
output,
target.Label,
err,
))
continue
}
if !check {
errs = append(errs, fmt.Errorf(
"output %s for target %s points outside the repository",
output,
target.Label,
))
}
}
return errs
}
// isWithinWorkspace checks whether the resolved path (when joined with a starting directory)
// remains within the workspace root. The relative path may start with "..", but once resolved,
// it must still be inside the workspace.
func isWithinWorkspace(absWorkspace, packagePath, relPath string) (bool, error) {
absOutput, err := filepath.Abs(filepath.Join(absWorkspace, packagePath, relPath))
if err != nil {
return false, err
}
// Compute the relative path from the workspace to the target.
// If absTarget is within absWorkspace, the computed relative path will NOT start with ".."
// (even if the user-supplied relative path started with "..").
rel, err := filepath.Rel(absWorkspace, absOutput)
if err != nil {
return false, err
}
// Check if the path escapes the workspace.
if pathTriesToEscape(rel) {
return false, nil
}
return true, nil
}
package backends
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/google/uuid"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
// azureStagingPath is the path used for in-flight uploads. Anything under
// this prefix is staged data and must be invisible to Get/Exists/Size against
// any cache key.
const azureStagingPath = ".uploads"
// AzureBlobClient defines the interface for Azure Blob Storage operations.
// This interface allows for easy mocking in tests.
type AzureBlobClient interface {
// GetBlob retrieves a blob from Azure Blob Storage.
GetBlob(ctx context.Context, container, blob string) (io.ReadCloser, error)
// UploadBlob uploads a blob to Azure Blob Storage.
UploadBlob(ctx context.Context, container, blob string, body io.Reader) error
// DeleteBlob removes a blob from Azure Blob Storage.
DeleteBlob(ctx context.Context, container, blob string) error
// BlobExists checks if a blob exists in Azure Blob Storage.
BlobExists(ctx context.Context, container, blob string) (bool, error)
// BlobSize returns the size in bytes of a blob without downloading it.
BlobSize(ctx context.Context, container, blob string) (int64, error)
// CopyBlob performs a server-side copy from srcBlob to destBlob within
// the same container. Bytes do not transit the client. Used by AzureCache's
// staged-write commit step to promote a staging blob to its final
// content-addressed key without re-uploading the data.
CopyBlob(ctx context.Context, container, srcBlob, destBlob string) error
}
// AzureBlobAdapter adapts the Azure azblob client to the AzureBlobClient interface.
type AzureBlobAdapter struct {
client *azblob.Client
}
// NewAzureBlobAdapter creates a new adapter for the Azure Blob Storage client.
func NewAzureBlobAdapter(client *azblob.Client) *AzureBlobAdapter {
return &AzureBlobAdapter{
client: client,
}
}
// GetBlob retrieves a blob from Azure Blob Storage.
func (a *AzureBlobAdapter) GetBlob(ctx context.Context, container, blob string) (io.ReadCloser, error) {
response, err := a.client.DownloadStream(ctx, container, blob, nil)
if err != nil {
return nil, err
}
return response.Body, nil
}
// UploadBlob uploads a blob to Azure Blob Storage.
func (a *AzureBlobAdapter) UploadBlob(ctx context.Context, container, blob string, body io.Reader) error {
_, err := a.client.UploadStream(ctx, container, blob, body, nil)
return err
}
// DeleteBlob removes a blob from Azure Blob Storage.
func (a *AzureBlobAdapter) DeleteBlob(ctx context.Context, container, blob string) error {
_, err := a.client.DeleteBlob(ctx, container, blob, nil)
return err
}
// BlobExists checks if a blob exists in Azure Blob Storage using a lightweight properties request.
func (a *AzureBlobAdapter) BlobExists(ctx context.Context, container, blobName string) (bool, error) {
blobClient := a.client.ServiceClient().NewContainerClient(container).NewBlobClient(blobName)
_, err := blobClient.GetProperties(ctx, nil)
if err != nil {
// The Azure SDK returns a ResponseError for not-found blobs.
// Check for the BlobNotFound error code in the response.
if isBlobNotFoundError(err) {
return false, nil
}
return false, err
}
return true, nil
}
// BlobSize returns the size of a blob via a properties request (no body
// download).
func (a *AzureBlobAdapter) BlobSize(ctx context.Context, container, blobName string) (int64, error) {
blobClient := a.client.ServiceClient().NewContainerClient(container).NewBlobClient(blobName)
props, err := blobClient.GetProperties(ctx, nil)
if err != nil {
return 0, err
}
if props.ContentLength == nil {
return 0, fmt.Errorf("azure get properties: missing content length for %s/%s", container, blobName)
}
return *props.ContentLength, nil
}
// CopyBlob performs a server-side copy by initiating a StartCopyFromURL and
// polling until completion. The copy is asynchronous on the Azure side; for
// objects within the same storage account it usually finishes near-instantly,
// but we poll defensively to handle larger blobs.
func (a *AzureBlobAdapter) CopyBlob(ctx context.Context, container, srcBlob, destBlob string) error {
containerClient := a.client.ServiceClient().NewContainerClient(container)
srcURL := containerClient.NewBlobClient(srcBlob).URL()
dstClient := containerClient.NewBlobClient(destBlob)
if _, err := dstClient.StartCopyFromURL(ctx, srcURL, nil); err != nil {
return fmt.Errorf("azure start copy %s -> %s: %w", srcBlob, destBlob, err)
}
// Poll until the destination blob's CopyStatus reports success. Azure
// transitions are typically near-instant within a single account, so a
// short poll interval is fine.
const (
pollInterval = 100 * time.Millisecond
pollTimeout = 5 * time.Minute
)
deadline := time.Now().Add(pollTimeout)
for {
props, err := dstClient.GetProperties(ctx, nil)
if err != nil {
return fmt.Errorf("azure poll copy status: %w", err)
}
if props.CopyStatus == nil {
return fmt.Errorf("azure copy %s -> %s: missing copy status", srcBlob, destBlob)
}
switch *props.CopyStatus {
case blob.CopyStatusTypeSuccess:
return nil
case blob.CopyStatusTypePending:
// fall through to sleep
case blob.CopyStatusTypeAborted, blob.CopyStatusTypeFailed:
return fmt.Errorf("azure copy %s -> %s: %s", srcBlob, destBlob, *props.CopyStatus)
default:
return fmt.Errorf("azure copy %s -> %s: unexpected status %q", srcBlob, destBlob, *props.CopyStatus)
}
if time.Now().After(deadline) {
return fmt.Errorf("azure copy %s -> %s: timed out after %s", srcBlob, destBlob, pollTimeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(pollInterval):
}
}
}
// isBlobNotFoundError checks whether an error from the Azure SDK indicates that a blob was not found.
func isBlobNotFoundError(err error) bool {
// The Azure SDK wraps HTTP 404 responses as *azcore.ResponseError.
// We check the error string for the BlobNotFound code rather than importing
// azcore directly, keeping the dependency surface minimal.
return strings.Contains(err.Error(), "BlobNotFound") ||
strings.Contains(err.Error(), "RESPONSE 404")
}
// AzureCache implements the CacheBackend interface using Azure Blob Storage.
type AzureCache struct {
containerName string
prefix string
workspacePrefix string
client AzureBlobClient
logger *console.Logger
}
// TypeName returns the name of the cache backend.
func (a *AzureCache) TypeName() string {
return "azure"
}
// NewAzureCache creates a new Azure Blob Storage cache.
func NewAzureCache(
ctx context.Context,
cacheConfig config.AzureCacheConfig,
) (*AzureCache, error) {
if cacheConfig.Container == "" {
return nil, fmt.Errorf("azure container name is not set")
}
var client *azblob.Client
if cacheConfig.ConnectionString != "" {
// Authenticate using a connection string (account key based).
var err error
client, err = azblob.NewClientFromConnectionString(cacheConfig.ConnectionString, nil)
if err != nil {
return nil, fmt.Errorf("failed to create Azure client from connection string: %w", err)
}
} else if cacheConfig.AccountURL != "" {
// Authenticate using DefaultAzureCredential (Azure AD / managed identity / CLI).
credential, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, fmt.Errorf("failed to create Azure credential: %w", err)
}
client, err = azblob.NewClient(cacheConfig.AccountURL, credential, nil)
if err != nil {
return nil, fmt.Errorf("failed to create Azure Blob Storage client: %w", err)
}
} else {
return nil, fmt.Errorf("azure cache requires either account_url or connection_string to be set")
}
adapter := NewAzureBlobAdapter(client)
return NewAzureCacheWithClient(ctx, cacheConfig, adapter)
}
// NewAzureCacheWithClient creates a new Azure Blob Storage cache with a provided client.
// This is useful for testing with a mock client.
func NewAzureCacheWithClient(
ctx context.Context,
cacheConfig config.AzureCacheConfig,
client AzureBlobClient,
) (*AzureCache, error) {
if cacheConfig.Container == "" {
return nil, fmt.Errorf("azure container name is not set")
}
prefix := cacheConfig.Prefix
if prefix != "" {
prefix = strings.Trim(prefix, "/")
}
workspaceDir := config.Global.WorkspaceRoot
var workspacePrefix string
if cacheConfig.SharedCache {
// If shared cache is enabled treat prefix as the workspace root.
workspacePrefix = ""
} else {
// If shared cache is disabled, use the full path hash.
workspacePrefix = strings.Trim(config.GetWorkspaceCachePrefix(workspaceDir), "/")
}
logger := console.GetLogger(ctx)
logger.Tracef("Instantiated Azure cache at container %s with prefix %s and workspace dir %s",
cacheConfig.Container,
prefix,
workspacePrefix)
return &AzureCache{
logger: logger,
client: client,
containerName: cacheConfig.Container,
prefix: prefix,
workspacePrefix: workspacePrefix,
}, nil
}
func (a *AzureCache) fullPrefix() string {
if a.prefix == "" {
return a.workspacePrefix
}
if a.workspacePrefix == "" {
return a.prefix
}
return a.prefix + "/" + a.workspacePrefix
}
// buildPath constructs the full Azure blob path for a cached item.
func (a *AzureCache) buildPath(path, key string) string {
parts := []string{a.fullPrefix(), strings.Trim(path, "/"), strings.Trim(key, "/")}
return strings.Join(parts, "/")
}
// Get retrieves a cached file from Azure Blob Storage.
func (a *AzureCache) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
logger := console.GetLogger(ctx)
blobPath := a.buildPath(path, key)
logger.Tracef("Getting file from Azure for path: %s", blobPath)
return a.client.GetBlob(ctx, a.containerName, blobPath)
}
// Set stores a file in Azure Blob Storage.
func (a *AzureCache) Set(ctx context.Context, path, key string, content io.Reader) error {
logger := console.GetLogger(ctx)
blobPath := a.buildPath(path, key)
logger.Tracef("Setting file in Azure for path: %s", blobPath)
return a.client.UploadBlob(ctx, a.containerName, blobPath, content)
}
// Delete removes a cached file from Azure Blob Storage.
func (a *AzureCache) Delete(ctx context.Context, path string, key string) error {
logger := console.GetLogger(ctx)
blobPath := a.buildPath(path, key)
logger.Tracef("Deleting file from Azure for path: %s", blobPath)
return a.client.DeleteBlob(ctx, a.containerName, blobPath)
}
// Exists checks if a file exists in Azure Blob Storage.
func (a *AzureCache) Exists(ctx context.Context, path string, key string) (bool, error) {
logger := console.GetLogger(ctx)
blobPath := a.buildPath(path, key)
logger.Tracef("Checking existence of file in Azure for path: %s", blobPath)
return a.client.BlobExists(ctx, a.containerName, blobPath)
}
// BeginWrite opens a streaming upload to a staging blob under .uploads/<uuid>.
// On Commit, the staging blob is server-side copied to its final
// content-addressed name and the staging blob is deleted. Bytes never transit
// grog twice.
//
// The Azure SDK's UploadStream call wants the full reader handed to it (no
// per-write streaming Writer abstraction). We bridge this by spawning a
// background goroutine that calls UploadStream against an io.Pipe; the
// returned StagedWriter writes into the pipe.
func (a *AzureCache) BeginWrite(ctx context.Context) (StagedWriter, error) {
stagingPath := a.buildPath(azureStagingPath, uuid.NewString())
pr, pw := io.Pipe()
sw := &azureStagedWriter{
client: a.client,
container: a.containerName,
stagingPath: stagingPath,
pipeWriter: pw,
done: make(chan error, 1),
buildPath: a.buildPath,
}
go func() {
err := a.client.UploadBlob(ctx, a.containerName, stagingPath, pr)
_ = pr.CloseWithError(err)
sw.done <- err
}()
return sw, nil
}
// azureStagedWriter is the StagedWriter implementation for Azure Blob Storage.
type azureStagedWriter struct {
client AzureBlobClient
container string
stagingPath string
pipeWriter *io.PipeWriter
done chan error
buildPath func(path, key string) string
mu sync.Mutex
finished bool
}
func (w *azureStagedWriter) Write(p []byte) (int, error) {
return w.pipeWriter.Write(p)
}
func (w *azureStagedWriter) Commit(ctx context.Context, path, key string) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return errors.New("azure staged writer: commit after commit/cancel")
}
w.finished = true
w.mu.Unlock()
if err := w.pipeWriter.Close(); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("close staging pipe: %w", err)
}
if err := <-w.done; err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("upload to staging blob: %w", err)
}
finalPath := w.buildPath(path, key)
if err := w.client.CopyBlob(ctx, w.container, w.stagingPath, finalPath); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("copy staging -> final: %w", err)
}
_ = w.cleanupStaging(ctx)
return nil
}
func (w *azureStagedWriter) Cancel(ctx context.Context) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return nil
}
w.finished = true
w.mu.Unlock()
_ = w.pipeWriter.CloseWithError(errors.New("azure staged write cancelled"))
<-w.done
return w.cleanupStaging(ctx)
}
func (w *azureStagedWriter) cleanupStaging(ctx context.Context) error {
if err := w.client.DeleteBlob(ctx, w.container, w.stagingPath); err != nil {
return err
}
return nil
}
// Size returns the byte size of a blob in Azure Blob Storage via a properties
// request — no body is downloaded.
func (a *AzureCache) Size(ctx context.Context, path, key string) (int64, error) {
logger := console.GetLogger(ctx)
blobPath := a.buildPath(path, key)
logger.Tracef("Sizing blob in Azure for path: %s", blobPath)
return a.client.BlobSize(ctx, a.containerName, blobPath)
}
// ListKeys uses Azure Blob Storage list blobs to list keys under the given path.
func (a *AzureCache) ListKeys(ctx context.Context, path string, suffix string) ([]string, error) {
adapter, ok := a.client.(*AzureBlobAdapter)
if !ok {
return nil, nil
}
fullPath := a.buildPath(path, "")
if !strings.HasSuffix(fullPath, "/") {
fullPath += "/"
}
var keys []string
pager := adapter.client.NewListBlobsFlatPager(a.containerName, &azblob.ListBlobsFlatOptions{
Prefix: &fullPath,
})
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, err
}
for _, blob := range page.Segment.BlobItems {
name := *blob.Name
key := strings.TrimPrefix(name, fullPath)
if suffix == "" || strings.HasSuffix(key, suffix) {
keys = append(keys, key)
}
}
}
return keys, nil
}
package backends
import (
"context"
"io"
"runtime"
"sync"
"sync/atomic"
"golang.org/x/sync/semaphore"
)
// Default bounds for the global I/O concurrency limit.
//
// Lower bound (32): keeps an S3-style HTTP connection saturated under
// typical RTT (Little's law: ~50ms latency × ~600 req/s ≈ 30 in flight).
// Upper bound (256): stays under Go's 10k-thread ceiling and default FD
// limits even when CAS, target/taint cache, and the docker proxy share
// the budget.
//
// Between the bounds the default scales with NumCPU * 4, preserving PR
// #100's heuristic for mid-sized machines.
const (
defaultIOConcurrencyMin = 32
defaultIOConcurrencyMax = 256
)
// DefaultIOConcurrency returns NumCPU * 4 clamped to
// [defaultIOConcurrencyMin, defaultIOConcurrencyMax].
func DefaultIOConcurrency() int {
n := runtime.NumCPU() * 4
if n < defaultIOConcurrencyMin {
return defaultIOConcurrencyMin
}
if n > defaultIOConcurrencyMax {
return defaultIOConcurrencyMax
}
return n
}
// globalIOSem bounds concurrent backend I/O process-wide. Initialised via
// SetGlobalIOConcurrency before the backend is used; when nil (e.g. tests
// that bypass GetCacheBackend) BoundedBackend degrades to a passthrough.
// globalIOCap mirrors the configured capacity so callers that fan out their
// own goroutines (e.g. directory uploads) can size their concurrency to the
// same budget via GlobalIOConcurrency.
var (
globalIOSemMu sync.Mutex
globalIOSem atomic.Pointer[semaphore.Weighted]
globalIOCap atomic.Int64
)
// SetGlobalIOConcurrency installs (or replaces) the process-wide I/O
// semaphore. A non-positive capacity disables the limit.
func SetGlobalIOConcurrency(capacity int) {
globalIOSemMu.Lock()
defer globalIOSemMu.Unlock()
if capacity <= 0 {
globalIOSem.Store(nil)
globalIOCap.Store(0)
return
}
globalIOSem.Store(semaphore.NewWeighted(int64(capacity)))
globalIOCap.Store(int64(capacity))
}
// GlobalIOConcurrency reports the configured process-wide I/O cap, falling
// back to DefaultIOConcurrency when the limit is unset. Use it to bound a
// goroutine fan-out that performs backend I/O so the fan-out can't open more
// resources (e.g. file descriptors) than the backend will service at once.
func GlobalIOConcurrency() int {
if c := globalIOCap.Load(); c > 0 {
return int(c)
}
return DefaultIOConcurrency()
}
// acquireForBackend is the internal Acquire used by BoundedBackend ops.
func acquireForBackend(ctx context.Context) (func(), error) {
sem := globalIOSem.Load()
if sem == nil {
return func() {}, nil
}
if err := sem.Acquire(ctx, 1); err != nil {
return nil, err
}
var released atomic.Bool
return func() {
if released.CompareAndSwap(false, true) {
sem.Release(1)
}
}, nil
}
// BoundedBackend wraps a CacheBackend so every op acquires a slot on the
// process-wide I/O semaphore. Get holds the slot for the reader's lifetime,
// so the cap reflects in-flight bytes — not just call sites.
type BoundedBackend struct {
inner CacheBackend
}
func NewBoundedBackend(inner CacheBackend) *BoundedBackend {
return &BoundedBackend{inner: inner}
}
// Inner returns the wrapped backend (e.g. for tests).
func (b *BoundedBackend) Inner() CacheBackend {
return b.inner
}
func (b *BoundedBackend) TypeName() string {
return b.inner.TypeName()
}
func (b *BoundedBackend) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
release, err := acquireForBackend(ctx)
if err != nil {
return nil, err
}
rc, err := b.inner.Get(ctx, path, key)
if err != nil {
release()
return nil, err
}
return &releasingReadCloser{ReadCloser: rc, release: release}, nil
}
func (b *BoundedBackend) Set(ctx context.Context, path, key string, content io.Reader) error {
release, err := acquireForBackend(ctx)
if err != nil {
return err
}
defer release()
return b.inner.Set(ctx, path, key, content)
}
// BeginWrite is intentionally NOT bounded. The ociproxy registry holds
// staged writes across POST/PATCH/.../PUT, so a global slot held for the
// whole session would leak on interrupted uploads until concurrent docker
// pushes blocked the rest of the cache. Bytes through the staged writer
// reach the backend via a background pipe goroutine; the natural rate
// limit there is the caller and the pipe buffer, not this semaphore.
func (b *BoundedBackend) BeginWrite(ctx context.Context) (StagedWriter, error) {
return b.inner.BeginWrite(ctx)
}
func (b *BoundedBackend) Delete(ctx context.Context, path, key string) error {
release, err := acquireForBackend(ctx)
if err != nil {
return err
}
defer release()
return b.inner.Delete(ctx, path, key)
}
func (b *BoundedBackend) Exists(ctx context.Context, path, key string) (bool, error) {
release, err := acquireForBackend(ctx)
if err != nil {
return false, err
}
defer release()
return b.inner.Exists(ctx, path, key)
}
func (b *BoundedBackend) Size(ctx context.Context, path, key string) (int64, error) {
release, err := acquireForBackend(ctx)
if err != nil {
return 0, err
}
defer release()
return b.inner.Size(ctx, path, key)
}
func (b *BoundedBackend) ListKeys(ctx context.Context, path, suffix string) ([]string, error) {
release, err := acquireForBackend(ctx)
if err != nil {
return nil, err
}
defer release()
return b.inner.ListKeys(ctx, path, suffix)
}
// releasingReadCloser releases its semaphore slot on Close. Idempotent.
type releasingReadCloser struct {
io.ReadCloser
release func()
}
func (r *releasingReadCloser) Close() error {
err := r.ReadCloser.Close()
r.release()
return err
}
package backends
import (
"context"
"io"
"sync"
"github.com/chrismatix/grog/internal/config"
)
// CacheBackend represents an interface for a file system-based cache.
type CacheBackend interface {
// TypeName returns the name of the cache type.
TypeName() string
// Get retrieves a cached file as a byte slice by its key.
// It returns the file content and a boolean indicating whether the key exists in the cache.
Get(ctx context.Context, path, key string) (io.ReadCloser, error)
// Set stores a file in the cache with the given key and content.
// It overwrites the content if the key already exists.
Set(ctx context.Context, path, key string, content io.Reader) error
// BeginWrite opens a streaming writer whose final cache location is
// decided at Commit time. This is used by callers that compute the cache
// key from the streamed bytes themselves (e.g. the ociproxy registry,
// which only learns the digest from the daemon's PUT request after every
// chunk has already been received).
//
// Implementations MUST guarantee that data written to the returned
// StagedWriter is invisible to Get/Exists/Size against any cache key
// until Commit returns successfully. Backends that store data on disk
// should stage in a directory on the same filesystem as the final
// destination so the commit step can be a single rename.
//
// Either Commit or Cancel must be called on the returned writer to
// release backend resources. Cancel is idempotent and safe to call
// after a successful Commit.
BeginWrite(ctx context.Context) (StagedWriter, error)
// Delete removes a cached file by its key.
// It does nothing if the key does not exist.
Delete(ctx context.Context, path string, key string) error
// Exists checks if a file exists in the cache with the given key.
Exists(ctx context.Context, path string, key string) (bool, error)
// Size returns the byte size of the entry without reading its content.
// Used by the ociproxy registry to populate Content-Length headers on
// blob HEAD/GET responses (the Docker daemon refuses responses without one).
// Returns an error if the entry does not exist.
Size(ctx context.Context, path, key string) (int64, error)
// ListKeys returns all keys under the given path that match the given suffix.
// Keys are returned as relative paths (e.g. "2026-03-30/trace-id.parquet").
ListKeys(ctx context.Context, path string, suffix string) ([]string, error)
}
// StagedWriter accumulates bytes that will be promoted to a cache entry only
// once Commit is called with the final (path, key). It is the streaming
// counterpart to CacheBackend.Set: callers that don't know the key upfront
// (e.g. content-addressed writes that derive the key from a hash of the bytes)
// can stream straight into a StagedWriter and decide where it lands later.
//
// StagedWriter is not safe for concurrent Write calls; the caller must
// serialise writes if multiple goroutines need to push bytes. Commit and
// Cancel may be called from any goroutine.
type StagedWriter interface {
io.Writer
// Commit promotes the staged data to (path, key) and releases backend
// resources. After a successful Commit the data is visible to subsequent
// Get/Exists/Size against the same (path, key). Commit must not be called
// more than once. After a failed Commit the writer should be Cancel'd.
Commit(ctx context.Context, path, key string) error
// Cancel discards the staged data without making it visible at any cache
// key. Cancel is idempotent and safe to call after Commit (in which case
// it is a no-op). Callers should defer Cancel right after BeginWrite so
// any error path releases resources without further bookkeeping.
Cancel(ctx context.Context) error
}
// GetCacheBackend constructs the configured cache backend and wraps it with
// the global I/O concurrency limiter, so every Get/Set across the process
// shares the same semaphore configured via SetGlobalIOConcurrency.
func GetCacheBackend(
ctx context.Context,
cacheConfig config.CacheConfig,
) (CacheBackend, error) {
ensureGlobalIOInit()
inner, err := buildBackend(ctx, cacheConfig)
if err != nil {
return nil, err
}
return NewBoundedBackend(inner), nil
}
// ensureGlobalIOInit initialises the I/O semaphore from config.Global on
// first call; later calls are no-ops. A non-positive config value falls
// back to DefaultIOConcurrency.
var globalIOOnce sync.Once
func ensureGlobalIOInit() {
globalIOOnce.Do(func() {
capacity := config.Global.NumIOWorkers
if capacity < 1 {
capacity = DefaultIOConcurrency()
}
SetGlobalIOConcurrency(capacity)
})
}
func buildBackend(
ctx context.Context,
cacheConfig config.CacheConfig,
) (CacheBackend, error) {
fs, err := NewFileSystemCache(ctx)
if err != nil {
return nil, err
}
switch cacheConfig.Backend {
case config.GCSCacheBackend:
gcsCache, err := NewGCSCache(ctx, cacheConfig.GCS)
if err != nil {
return nil, err
}
return NewRemoteWrapper(fs, gcsCache), nil
case config.S3CacheBackend:
s3Cache, err := NewS3Cache(ctx, cacheConfig.S3)
if err != nil {
return nil, err
}
return NewRemoteWrapper(fs, s3Cache), nil
case config.AzureCacheBackend:
azureCache, err := NewAzureCache(ctx, cacheConfig.Azure)
if err != nil {
return nil, err
}
return NewRemoteWrapper(fs, azureCache), nil
default:
return fs, nil
}
}
package backends
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
// fsStagingDirName is the subdirectory under the shared CAS directory where
// in-flight uploads are buffered. Keeping it inside the CAS directory means
// the final commit can be a same-filesystem os.Rename rather than a
// cross-device copy.
const fsStagingDirName = ".staging"
// FileSystemCache implements the CacheBackend interface using the file system for storage.
type FileSystemCache struct {
workspaceCacheDir string
sharedCasDir string
}
func (fsc *FileSystemCache) TypeName() string {
return "fs"
}
// NewFileSystemCache creates a new cache using the configured cache directory.
func NewFileSystemCache(ctx context.Context) (*FileSystemCache, error) {
workspaceCacheDir := config.Global.GetWorkspaceCacheDirectory()
sharedCasDir := config.Global.GetCasDirectory()
// Ensure the root directory exists
if err := os.MkdirAll(workspaceCacheDir, 0755); err != nil {
return nil, err
}
if err := os.MkdirAll(sharedCasDir, 0755); err != nil {
return nil, err
}
console.GetLogger(ctx).Tracef("Instantiated fs cache at: %s", workspaceCacheDir)
return &FileSystemCache{
workspaceCacheDir: workspaceCacheDir,
sharedCasDir: sharedCasDir,
}, nil
}
// buildFilePath constructs the full file path for a cached item.
func (fsc *FileSystemCache) buildFilePath(path, key string) string {
dir := fsc.getDir(path)
return filepath.Join(dir, key)
}
func (fsc *FileSystemCache) getDir(path string) string {
if path == "cas" {
return fsc.sharedCasDir
}
return filepath.Join(fsc.workspaceCacheDir, path)
}
// Get retrieves a cached file by its key.
func (fsc *FileSystemCache) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
logger := console.GetLogger(ctx)
logger.Tracef("Getting file from cache for path: %s, key: %s", path, key)
filePath := fsc.buildFilePath(path, key)
file, err := os.Open(filePath)
if err != nil {
logger.Tracef("Failed to get file for path: %s, key: %s", path, key)
return nil, err
}
return file, err
}
// Set stores a file in the cache with the given key and content.
func (fsc *FileSystemCache) Set(ctx context.Context, path, key string, content io.Reader) error {
logger := console.GetLogger(ctx)
logger.Tracef("Setting file in cache for path: %s, key: %s", path, key)
filePath := fsc.buildFilePath(path, key)
destinationDirectory := filepath.Dir(filePath)
// Make sure the destination directory exists. Keys can include path separators.
if err := os.MkdirAll(destinationDirectory, 0755); err != nil {
return err
}
// Write to a temp file first to ensure atomicity
tmpFile, err := os.CreateTemp(destinationDirectory, "tmp-*")
if err != nil {
return err
}
defer func() { _ = os.Remove(tmpFile.Name()) }() // Cleanup temp file if rename fails
// Copy the content from the reader to the file
if _, err = io.Copy(tmpFile, content); err != nil {
return errors.Join(err, tmpFile.Close())
}
// Close explicitly before rename
if err := tmpFile.Close(); err != nil {
return err
}
return os.Rename(tmpFile.Name(), filePath)
}
// Delete removes a cached file by its key.
func (fsc *FileSystemCache) Delete(ctx context.Context, path, key string) error {
logger := console.GetLogger(ctx)
logger.Tracef("Deleting file from cache for path: %s, key: %s", path, key)
filePath := fsc.buildFilePath(path, key)
// Check if file exists before attempting to remove
if _, err := os.Stat(filePath); os.IsNotExist(err) {
logger.Tracef("File not found for deletion for path: %s, key: %s", path, key)
return nil
}
return os.Remove(filePath)
}
// Exists checks if a file exists in the cache with the given key.
func (fsc *FileSystemCache) Exists(ctx context.Context, path, key string) (bool, error) {
logger := console.GetLogger(ctx)
filePath := fsc.buildFilePath(path, key)
_, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
logger.Tracef("Cache-miss for path: %s, key: %s", path, key)
return false, nil
}
logger.Tracef("Cache failed for path: %s, key: %s %v", path, key, err)
return false, err
}
logger.Tracef("Cache-hit for path: %s, key: %s", path, key)
return true, nil
}
// Size returns the size in bytes of the cached file with the given key, or
// an error if the file is missing. Backends that don't implement SizeAware
// fall back to reading the entry; this implementation is just an os.Stat.
func (fsc *FileSystemCache) Size(_ context.Context, path, key string) (int64, error) {
filePath := fsc.buildFilePath(path, key)
info, err := os.Stat(filePath)
if err != nil {
return 0, err
}
return info.Size(), nil
}
// BeginWrite opens a temp file in a staging directory inside the shared CAS
// directory. Because the staging file lives on the same filesystem as the
// final destination, Commit can be a single os.Rename — no double copy.
func (fsc *FileSystemCache) BeginWrite(ctx context.Context) (StagedWriter, error) {
// We always stage inside the shared CAS directory so the final move is a
// same-filesystem rename. Workspace-cache writes use a sibling subdir to
// avoid mixing temp data with the namespaced workspace tree.
stagingDir := filepath.Join(fsc.sharedCasDir, fsStagingDirName)
if err := os.MkdirAll(stagingDir, 0755); err != nil {
return nil, fmt.Errorf("create staging dir: %w", err)
}
f, err := os.CreateTemp(stagingDir, "upload-*")
if err != nil {
return nil, fmt.Errorf("create staging file: %w", err)
}
console.GetLogger(ctx).Tracef("fs cache opened staging file %s", f.Name())
return &fsStagedWriter{file: f, fsc: fsc}, nil
}
// fsStagedWriter is the FileSystemCache StagedWriter. It writes incoming
// bytes directly to a same-filesystem temp file and promotes the file to its
// final CAS location at Commit time via a single rename.
type fsStagedWriter struct {
fsc *FileSystemCache
mu sync.Mutex
file *os.File // nil after Commit/Cancel
}
func (w *fsStagedWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return 0, errors.New("fs staged writer: write after commit/cancel")
}
return w.file.Write(p)
}
func (w *fsStagedWriter) Commit(_ context.Context, path, key string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return errors.New("fs staged writer: commit after commit/cancel")
}
stagedPath := w.file.Name()
// Close before rename so the file's contents are flushed to the OS.
if err := w.file.Close(); err != nil {
_ = os.Remove(stagedPath)
w.file = nil
return fmt.Errorf("close staging file: %w", err)
}
w.file = nil
finalPath := w.fsc.buildFilePath(path, key)
finalDir := filepath.Dir(finalPath)
if err := os.MkdirAll(finalDir, 0755); err != nil {
_ = os.Remove(stagedPath)
return fmt.Errorf("create destination dir: %w", err)
}
if err := os.Rename(stagedPath, finalPath); err != nil {
_ = os.Remove(stagedPath)
return fmt.Errorf("rename staging file: %w", err)
}
return nil
}
func (w *fsStagedWriter) Cancel(_ context.Context) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
// Already committed or cancelled — Cancel is idempotent.
return nil
}
stagedPath := w.file.Name()
_ = w.file.Close()
w.file = nil
if err := os.Remove(stagedPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove staging file: %w", err)
}
return nil
}
// ListKeys walks the directory tree and returns keys matching the suffix.
// In-flight uploads under the .staging subdirectory are skipped so they
// never leak into cache enumerations.
func (fsc *FileSystemCache) ListKeys(ctx context.Context, path string, suffix string) ([]string, error) {
dir := fsc.getDir(path)
var keys []string
err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
if info.Name() == fsStagingDirName && filePath != dir {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(dir, filePath)
if relErr != nil {
return relErr
}
if suffix == "" || strings.HasSuffix(rel, suffix) {
keys = append(keys, rel)
}
return nil
})
if err != nil && os.IsNotExist(err) {
return nil, nil
}
return keys, err
}
func (fsc *FileSystemCache) GetWorkspaceCacheSizeBytes() (int64, error) {
return getDirectorySizeBytes(fsc.workspaceCacheDir)
}
func (fsc *FileSystemCache) GetCacheSizeBytes() (int64, error) {
return getDirectorySizeBytes(config.Global.Root)
}
func getDirectorySizeBytes(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size, err
}
package backends
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"cloud.google.com/go/storage"
"github.com/google/uuid"
"google.golang.org/api/iterator"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
// gcsStagingPath is the path used for in-flight uploads. Anything under this
// prefix is staged data and must be invisible to Get/Exists/Size against any
// cache key.
const gcsStagingPath = ".uploads"
// GCSCache implements the CacheBackend interface using Google Cloud Storage.
type GCSCache struct {
bucketName string
prefix string
workspacePrefix string
client *storage.Client
logger *console.Logger
}
func (gcs *GCSCache) TypeName() string {
return "gcs"
}
// NewGCSCache creates a new GCS cache.
func NewGCSCache(
ctx context.Context,
cacheConfig config.GCSCacheConfig,
) (*GCSCache, error) {
if cacheConfig.Bucket == "" {
return nil, fmt.Errorf("GCS bucket name is not set")
}
client, err := storage.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}
prefix := cacheConfig.Prefix
if prefix == "" {
prefix = ""
} else {
prefix = strings.Trim(prefix, "/")
}
workspaceDir := config.Global.WorkspaceRoot
var workspacePrefix string
if cacheConfig.SharedCache {
// If shared cache is enabled treat prefix as the workspace root
workspacePrefix = ""
} else {
// If shared cache is disabled, use the full path hash
workspacePrefix = strings.Trim(config.GetWorkspaceCachePrefix(workspaceDir), "/")
}
logger := console.GetLogger(ctx)
logger.Tracef("Instantiated GCS cache at bucket %s with prefix %s and workspace dir %s",
cacheConfig.Bucket,
prefix,
workspacePrefix)
return &GCSCache{
logger: logger,
client: client,
bucketName: cacheConfig.Bucket,
prefix: prefix,
workspacePrefix: workspacePrefix,
}, nil
}
func (gcs *GCSCache) fullPrefix() string {
if gcs.prefix == "" {
return gcs.workspacePrefix
}
if gcs.workspacePrefix == "" {
return gcs.prefix
}
return gcs.prefix + "/" + gcs.workspacePrefix
}
// buildPath constructs the full GCS path for a cached item.
func (gcs *GCSCache) buildPath(path, key string) string {
parts := []string{gcs.fullPrefix(), strings.Trim(path, "/"), strings.Trim(key, "/")}
return strings.Join(parts, "/")
}
// Get retrieves a cached file from GCS.
func (gcs *GCSCache) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
logger := console.GetLogger(ctx)
gcsPath := gcs.buildPath(path, key)
logger.Tracef("Getting file from GCS for path: %s", gcsPath)
rc, err := gcs.client.Bucket(gcs.bucketName).Object(gcsPath).NewReader(ctx)
if err != nil {
logger.Tracef("Failed to get file from GCS for path: %s, key: %s: %v", path, key, err)
return nil, fmt.Errorf("failed to create reader: %w", err)
}
return rc, nil
}
// Set stores a file in GCS.
func (gcs *GCSCache) Set(ctx context.Context, path, key string, content io.Reader) error {
logger := console.GetLogger(ctx)
gcsPath := gcs.buildPath(path, key)
logger.Tracef("Setting file in GCS for path: %s", gcsPath)
wc := gcs.client.Bucket(gcs.bucketName).Object(gcsPath).NewWriter(ctx)
if _, err := io.Copy(wc, content); err != nil {
return fmt.Errorf("failed to copy data to GCS: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return nil
}
// Delete removes a cached file from GCS.
func (gcs *GCSCache) Delete(ctx context.Context, path string, key string) error {
logger := console.GetLogger(ctx)
gcsPath := gcs.buildPath(path, key)
logger.Tracef("Deleting file from GCS for path: %s", gcsPath)
err := gcs.client.Bucket(gcs.bucketName).Object(gcsPath).Delete(ctx)
if err != nil {
return fmt.Errorf("failed to delete object: %w", err)
}
return nil
}
// BeginWrite opens a streaming upload to a staging object under .uploads/<uuid>.
// On Commit, the staging object is server-side copied to its final
// content-addressed key via Object.CopierFrom and the staging object is
// deleted. No bytes pass through grog twice.
//
// The ctx passed here lives for the entire upload session — it's captured
// inside *storage.Writer which uses it to drive the resumable upload — so
// callers must pass a context that outlives all subsequent Write calls
// (e.g. the ociproxy registry's session ctx, not an HTTP request ctx).
func (gcs *GCSCache) BeginWrite(ctx context.Context) (StagedWriter, error) {
stagingKey := gcs.buildPath(gcsStagingPath, uuid.NewString())
wc := gcs.client.Bucket(gcs.bucketName).Object(stagingKey).NewWriter(ctx)
return &gcsStagedWriter{
client: gcs.client,
bucket: gcs.bucketName,
stagingKey: stagingKey,
writer: wc,
buildPath: gcs.buildPath,
}, nil
}
// gcsStagedWriter is the StagedWriter implementation for GCS. The streaming
// resumable upload is owned by *storage.Writer (which captures the ctx from
// BeginWrite internally); Commit promotes the staging object via a
// server-side rewrite using Object.CopierFrom.
type gcsStagedWriter struct {
client *storage.Client
bucket string
stagingKey string
writer *storage.Writer
buildPath func(path, key string) string
mu sync.Mutex
finished bool
}
func (w *gcsStagedWriter) Write(p []byte) (int, error) {
return w.writer.Write(p)
}
func (w *gcsStagedWriter) Commit(ctx context.Context, path, key string) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return errors.New("gcs staged writer: commit after commit/cancel")
}
w.finished = true
w.mu.Unlock()
// Closing the writer flushes and finalizes the resumable upload.
if err := w.writer.Close(); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("close staging writer: %w", err)
}
finalKey := w.buildPath(path, key)
src := w.client.Bucket(w.bucket).Object(w.stagingKey)
dst := w.client.Bucket(w.bucket).Object(finalKey)
if _, err := dst.CopierFrom(src).Run(ctx); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("copy staging -> final: %w", err)
}
_ = w.cleanupStaging(ctx)
return nil
}
func (w *gcsStagedWriter) Cancel(ctx context.Context) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return nil
}
w.finished = true
w.mu.Unlock()
// Aborting the resumable upload — *storage.Writer doesn't expose a
// dedicated abort, but Close on a writer that hasn't been fully written
// to ends the upload session. Any partial object is deleted below.
_ = w.writer.Close()
return w.cleanupStaging(ctx)
}
func (w *gcsStagedWriter) cleanupStaging(ctx context.Context) error {
if err := w.client.Bucket(w.bucket).Object(w.stagingKey).Delete(ctx); err != nil {
// Not fatal — orphan staging objects can be cleaned up by a GCS
// lifecycle rule on the .uploads/ prefix.
return err
}
return nil
}
// Size returns the byte size of an object in GCS via Object.Attrs (a metadata
// fetch — no body is downloaded).
func (gcs *GCSCache) Size(ctx context.Context, path, key string) (int64, error) {
logger := console.GetLogger(ctx)
gcsPath := gcs.buildPath(path, key)
logger.Tracef("Sizing object in GCS for path: %s", gcsPath)
attrs, err := gcs.client.Bucket(gcs.bucketName).Object(gcsPath).Attrs(ctx)
if err != nil {
return 0, fmt.Errorf("failed to get object attrs: %w", err)
}
return attrs.Size, nil //nolint:nilaway // Attrs only returns nil alongside a non-nil error.
}
// Exists checks if a file exists in GCS.
func (gcs *GCSCache) Exists(ctx context.Context, path string, key string) (bool, error) {
logger := console.GetLogger(ctx)
gcsPath := gcs.buildPath(path, key)
logger.Tracef("Checking existence of file in GCS for path: %s", gcsPath)
_, err := gcs.client.Bucket(gcs.bucketName).Object(gcsPath).Attrs(ctx)
if errors.Is(err, storage.ErrObjectNotExist) {
logger.Tracef("File does not exist: %s", gcsPath)
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// ListKeys uses GCS Objects.List to list keys under the given path.
func (gcs *GCSCache) ListKeys(ctx context.Context, path string, suffix string) ([]string, error) {
fullPath := gcs.buildPath(path, "")
if !strings.HasSuffix(fullPath, "/") {
fullPath += "/"
}
it := gcs.client.Bucket(gcs.bucketName).Objects(ctx, &storage.Query{
Prefix: fullPath,
})
var keys []string
for {
attrs, err := it.Next()
if errors.Is(err, iterator.Done) {
break
}
if err != nil {
return nil, err
}
key := strings.TrimPrefix(attrs.Name, fullPath)
if suffix == "" || strings.HasSuffix(key, suffix) {
keys = append(keys, key)
}
}
return keys, nil
}
package backends
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/chrismatix/grog/internal/console"
)
// RemoteWrapper is the default implementation when using a remote cache
// It implements the logic of using the local file system first and
// falling back to the remote cache if the file is not found locally
// while updating the remote cache with local changes.
type RemoteWrapper struct {
fs *FileSystemCache
remote CacheBackend
}
func NewRemoteWrapper(
fs *FileSystemCache,
remote CacheBackend,
) *RemoteWrapper {
return &RemoteWrapper{
fs: fs,
remote: remote,
}
}
func (rw *RemoteWrapper) GetFS() *FileSystemCache {
return rw.fs
}
func (rw *RemoteWrapper) TypeName() string {
return rw.remote.TypeName()
}
// Get retrieves a cached file. It first tries the local file system cache.
// If the file is not found locally, it retrieves it from the remote cache
// and stores it in the local file system cache for future access.
func (rw *RemoteWrapper) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
logger := console.GetLogger(ctx)
logger.Tracef("Remote wrapper fetching path: %s, key: %s", path, key)
// Try to get the file from the local file system cache
reader, err := rw.fs.Get(ctx, path, key)
if err == nil {
return reader, nil
}
logger.Tracef("Local cache miss for path: %s, key: %s; trying remote cache", path, key)
// File not found locally, try the remote cache
remoteReader, err := rw.remote.Get(ctx, path, key)
if err != nil {
return nil, err
}
defer remoteReader.Close()
// Write the remote content into the local filesystem cache
if err := rw.fs.Set(ctx, path, key, remoteReader); err != nil {
return nil, err
}
// Now return a fresh reader from the local cache
return rw.fs.Get(ctx, path, key)
}
// Set stores a file in both the local file system cache and the remote cache concurrently.
func (rw *RemoteWrapper) Set(ctx context.Context, path, key string, content io.Reader) error {
console.GetLogger(ctx).Tracef("Remote wrapper writing path: %s, key: %s", path, key)
// Create pipes for the two cache destinations
fsRead, fsWrite := io.Pipe()
remoteRead, remoteWrite := io.Pipe()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errChan := make(chan error, 2)
var wg sync.WaitGroup
wg.Add(3) // Track all three goroutines
// Goroutine for writing to the filesystem cache
go func() {
defer wg.Done()
defer func() { _ = fsRead.Close() }()
if err := rw.fs.Set(ctx, path, key, fsRead); err != nil {
select {
case errChan <- fmt.Errorf("filesystem cache error: %w", err):
default:
}
}
}()
// Goroutine for writing to the remote cache
go func() {
defer wg.Done()
defer func() { _ = remoteRead.Close() }()
if err := rw.remote.Set(ctx, path, key, remoteRead); err != nil {
select {
case errChan <- fmt.Errorf("remote cache error: %w", err):
default:
}
}
}()
// Goroutine for copying content to both destinations
go func() {
defer wg.Done()
mw := io.MultiWriter(fsWrite, remoteWrite)
_, err := io.Copy(mw, content)
_ = fsWrite.CloseWithError(err)
_ = remoteWrite.CloseWithError(err)
}()
wg.Wait()
close(errChan)
// Collect all errors (if any)
var errs []error
for err := range errChan {
if err != nil {
errs = append(errs, err)
}
}
// Return a combined error if we have any
if len(errs) > 0 {
if len(errs) == 1 {
return errs[0]
}
// Otherwise, combine all errors into one
var errMsg strings.Builder
errMsg.WriteString("multiple cache write errors occurred:")
for i, err := range errs {
fmt.Fprintf(&errMsg, " (%d) %v;", i+1, err)
}
return errors.New(errMsg.String())
}
return nil
}
// Delete removes a cached file from both the local file system cache and the remote cache.
func (rw *RemoteWrapper) Delete(ctx context.Context, path string, key string) error {
console.GetLogger(ctx).Tracef("Remote wrapper deleting path: %s, key: %s", path, key)
// Delete the file from the local file system cache
err := rw.fs.Delete(ctx, path, key)
if err != nil {
return err
}
// Delete the file from the remote cache
err = rw.remote.Delete(ctx, path, key)
return err
}
// Exists checks if a file exists in either the local file system cache or the remote cache.
func (rw *RemoteWrapper) Exists(ctx context.Context, path string, key string) (bool, error) {
logger := console.GetLogger(ctx)
// Check if the file exists in the local file system cache
localExists, err := rw.fs.Exists(ctx, path, key)
if err != nil {
return false, err
}
if localExists {
logger.Tracef("Remote wrapper local hit for path: %s, key: %s", path, key)
return true, nil
}
logger.Tracef("Remote wrapper checking remote existence for path: %s, key: %s", path, key)
// Check if the file exists in the remote cache
return rw.remote.Exists(ctx, path, key)
}
// ListKeys delegates to the remote backend for a complete picture of all
// keys, including those written by other machines.
func (rw *RemoteWrapper) ListKeys(ctx context.Context, path string, suffix string) ([]string, error) {
return rw.remote.ListKeys(ctx, path, suffix)
}
// Size returns the byte size of the entry. The fast path stats the local
// filesystem cache; if the file is not yet cached locally, the wrapper asks
// the remote backend (which uses a metadata-only call like S3 HeadObject).
func (rw *RemoteWrapper) Size(ctx context.Context, path, key string) (int64, error) {
if size, err := rw.fs.Size(ctx, path, key); err == nil {
return size, nil
}
return rw.remote.Size(ctx, path, key)
}
// BeginWrite fans the streaming write out to both the local filesystem
// cache and the remote backend simultaneously, using io.Pipe + io.MultiWriter
// to keep memory usage flat. Each side has its own staged writer; Commit
// promotes both, Cancel discards both.
func (rw *RemoteWrapper) BeginWrite(ctx context.Context) (StagedWriter, error) {
fsStaged, err := rw.fs.BeginWrite(ctx)
if err != nil {
return nil, fmt.Errorf("fs begin write: %w", err)
}
remoteStaged, err := rw.remote.BeginWrite(ctx)
if err != nil {
_ = fsStaged.Cancel(ctx)
return nil, fmt.Errorf("remote begin write: %w", err)
}
fsRead, fsWrite := io.Pipe()
remoteRead, remoteWrite := io.Pipe()
w := &fanoutStagedWriter{
fsStaged: fsStaged,
remoteStaged: remoteStaged,
fsWrite: fsWrite,
remoteWrite: remoteWrite,
mw: io.MultiWriter(fsWrite, remoteWrite),
fsErr: make(chan error, 1),
remoteErr: make(chan error, 1),
}
// Each goroutine drains its pipe into the corresponding staged writer.
// Both goroutines run for the lifetime of the upload session and exit
// when the pipe is closed (either via Commit's Close or Cancel's
// CloseWithError).
go func() {
_, copyErr := io.Copy(fsStaged, fsRead)
w.fsErr <- copyErr
_ = fsRead.Close()
}()
go func() {
_, copyErr := io.Copy(remoteStaged, remoteRead)
w.remoteErr <- copyErr
_ = remoteRead.Close()
}()
return w, nil
}
// fanoutStagedWriter mirrors a single byte stream into the local fs cache and
// the remote backend in parallel. Writes are non-blocking on either side as
// long as both readers keep up.
type fanoutStagedWriter struct {
fsStaged StagedWriter
remoteStaged StagedWriter
fsWrite *io.PipeWriter
remoteWrite *io.PipeWriter
mw io.Writer
fsErr chan error
remoteErr chan error
mu sync.Mutex
finished bool
}
func (w *fanoutStagedWriter) Write(p []byte) (int, error) {
return w.mw.Write(p)
}
func (w *fanoutStagedWriter) Commit(ctx context.Context, path, key string) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return errors.New("remote wrapper staged writer: commit after commit/cancel")
}
w.finished = true
w.mu.Unlock()
// Close pipe writers so the drain goroutines see EOF and finish.
_ = w.fsWrite.Close()
_ = w.remoteWrite.Close()
fsCopyErr := <-w.fsErr
remoteCopyErr := <-w.remoteErr
if fsCopyErr != nil {
_ = w.fsStaged.Cancel(ctx)
_ = w.remoteStaged.Cancel(ctx)
return fmt.Errorf("fs staging copy: %w", fsCopyErr)
}
if remoteCopyErr != nil {
_ = w.fsStaged.Cancel(ctx)
_ = w.remoteStaged.Cancel(ctx)
return fmt.Errorf("remote staging copy: %w", remoteCopyErr)
}
// Commit fs first because it's much cheaper to roll back (a single
// os.Remove) than the remote side. If fs fails, we never touch remote.
if err := w.fsStaged.Commit(ctx, path, key); err != nil {
_ = w.remoteStaged.Cancel(ctx)
return fmt.Errorf("fs commit: %w", err)
}
if err := w.remoteStaged.Commit(ctx, path, key); err != nil {
// fs has already been promoted; the inconsistency is bounded — the
// next read on the same machine will be served from fs and a future
// read on a different machine will simply re-trigger the build. We
// match RemoteWrapper.Set's existing best-effort semantics here.
return fmt.Errorf("remote commit: %w", err)
}
return nil
}
func (w *fanoutStagedWriter) Cancel(ctx context.Context) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return nil
}
w.finished = true
w.mu.Unlock()
// Close the pipes with an error so the drain goroutines unblock and
// abandon any in-flight writes to the staged writers.
cancelErr := errors.New("staged write cancelled")
_ = w.fsWrite.CloseWithError(cancelErr)
_ = w.remoteWrite.CloseWithError(cancelErr)
// Drain the goroutines (best effort — we don't care about the error
// values, only that they've finished using the staged writers).
<-w.fsErr
<-w.remoteErr
var firstErr error
if err := w.fsStaged.Cancel(ctx); err != nil && firstErr == nil {
firstErr = err
}
if err := w.remoteStaged.Cancel(ctx); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
package backends
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/google/uuid"
grogconfig "github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
// S3Client defines the interface for S3 operations.
// This interface allows for easy mocking in tests.
type S3Client interface {
// GetObject retrieves an object from S3.
GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error)
// PutObject uploads an object to S3.
PutObject(ctx context.Context, bucket, key string, body io.Reader) error
// DeleteObject removes an object from S3.
DeleteObject(ctx context.Context, bucket, key string) error
// ObjectExists checks if an object exists in S3.
ObjectExists(ctx context.Context, bucket, key string) (bool, error)
// ObjectSize returns the size in bytes of the object without downloading
// it. Implementations should use a HEAD-style call (e.g. S3 HeadObject).
ObjectSize(ctx context.Context, bucket, key string) (int64, error)
// CopyObject performs a server-side copy from srcKey to destKey within
// the same bucket. Bytes do not transit the client. Used by S3Cache's
// staged-write commit step to promote a staging object to its final
// content-addressed key without re-uploading the data.
CopyObject(ctx context.Context, bucket, srcKey, destKey string) error
}
// AWSS3Adapter adapts the AWS S3 client to the S3Client interface.
type AWSS3Adapter struct {
client *s3.Client
uploader *transfermanager.Client
}
// NewAWSS3Adapter creates a new adapter for the AWS S3 client.
func NewAWSS3Adapter(client *s3.Client) *AWSS3Adapter {
uploader := transfermanager.New(client, func(options *transfermanager.Options) {
// 16 MiB parts; AWS minimum is 5 MiB. Larger parts mean fewer requests
// for big layers but a bigger floor on per-upload memory usage.
options.PartSizeBytes = 16 * 1024 * 1024
// Concurrency per upload — keep this modest because grog already runs
// many uploads in parallel at the target/output level.
options.Concurrency = 4
})
return &AWSS3Adapter{
client: client,
uploader: uploader,
}
}
// GetObject retrieves an object from S3 using the AWS S3 client.
func (a *AWSS3Adapter) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
input := &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
result, err := a.client.GetObject(ctx, input)
if err != nil {
return nil, err
}
return result.Body, nil
}
// PutObject uploads an object to S3 by streaming the body through the
// transfer manager. The body is consumed lazily — at most PartSizeBytes *
// Concurrency bytes are buffered in memory at a time, regardless of total size.
func (a *AWSS3Adapter) PutObject(ctx context.Context, bucket, key string, body io.Reader) error {
_, err := a.uploader.UploadObject(ctx, &transfermanager.UploadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: body,
})
if err != nil {
return fmt.Errorf("s3 upload: %w", err)
}
return nil
}
// DeleteObject removes an object from S3 using the AWS S3 client.
func (a *AWSS3Adapter) DeleteObject(ctx context.Context, bucket, key string) error {
input := &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
_, err := a.client.DeleteObject(ctx, input)
return err
}
// CopyObject performs a server-side copy. Bytes never transit the client.
//
// Limitation: S3 single-call CopyObject is capped at 5 GiB. Docker layers
// larger than that are rare in practice but not impossible — when one is
// pushed through this backend the daemon will see a 4xx from the StagedWriter
// commit step and surface it as a push failure with a clear "InvalidRequest"
// error message from the AWS SDK. The fix is to fall back to multipart copy
// (CreateMultipartUpload + UploadPartCopy + CompleteMultipartUpload) when the
// source object exceeds the limit; tracked as a follow-up rather than added
// here to keep this PR focused on the streaming refactor that landed the
// staged-writer interface.
func (a *AWSS3Adapter) CopyObject(ctx context.Context, bucket, srcKey, destKey string) error {
_, err := a.client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(destKey),
CopySource: aws.String(bucket + "/" + srcKey),
})
if err != nil {
return fmt.Errorf("s3 copy %s -> %s: %w", srcKey, destKey, err)
}
return nil
}
// ObjectSize returns the size of an object in S3 via a HeadObject call —
// no body is downloaded.
func (a *AWSS3Adapter) ObjectSize(ctx context.Context, bucket, key string) (int64, error) {
out, err := a.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return 0, err
}
if out.ContentLength == nil {
return 0, fmt.Errorf("s3 head: missing content length for %s/%s", bucket, key)
}
return *out.ContentLength, nil
}
// ObjectExists checks if an object exists in S3 using the AWS S3 client.
func (a *AWSS3Adapter) ObjectExists(ctx context.Context, bucket, key string) (bool, error) {
input := &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
_, err := a.client.HeadObject(ctx, input)
if err != nil {
if _, isNoSuchKey := errors.AsType[*types.NoSuchKey](err); isNoSuchKey {
return false, nil
}
if _, isNotFound := errors.AsType[*types.NotFound](err); isNotFound {
return false, nil
}
return false, err
}
return true, nil
}
// S3Cache implements the CacheBackend interface using AWS S3.
type S3Cache struct {
bucketName string
prefix string
workspacePrefix string
client S3Client
logger *console.Logger
}
func (s *S3Cache) TypeName() string {
return "s3"
}
// NewS3Cache creates a new S3 cache.
func NewS3Cache(
ctx context.Context,
cacheConfig grogconfig.S3CacheConfig,
) (*S3Cache, error) {
if cacheConfig.Bucket == "" {
return nil, fmt.Errorf("s3 bucket name is not set")
}
// Load the AWS SDK configuration
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load AWS configuration: %w", err)
}
// Create an S3 client
s3Client := s3.NewFromConfig(cfg)
adapter := NewAWSS3Adapter(s3Client)
return NewS3CacheWithClient(ctx, cacheConfig, adapter)
}
// NewS3CacheWithClient creates a new S3 cache with a provided client.
// This is useful for testing with a mock client.
func NewS3CacheWithClient(
ctx context.Context,
cacheConfig grogconfig.S3CacheConfig,
client S3Client,
) (*S3Cache, error) {
if cacheConfig.Bucket == "" {
return nil, fmt.Errorf("s3 bucket name is not set")
}
prefix := cacheConfig.Prefix
if prefix == "" {
prefix = ""
} else {
prefix = strings.Trim(prefix, "/")
}
workspaceDir := grogconfig.Global.WorkspaceRoot
var workspacePrefix string
if cacheConfig.SharedCache {
// If shared cache is enabled treat prefix as the workspace root
workspacePrefix = ""
} else {
// If shared cache is disabled, use the full path hash
workspacePrefix = strings.Trim(grogconfig.GetWorkspaceCachePrefix(workspaceDir), "/")
}
logger := console.GetLogger(ctx)
logger.Tracef("Instantiated s3 cache at bucket %s with prefix %s and workspace dir %s",
cacheConfig.Bucket,
prefix,
workspacePrefix)
return &S3Cache{
logger: logger,
client: client,
bucketName: cacheConfig.Bucket,
prefix: prefix,
workspacePrefix: workspacePrefix,
}, nil
}
func (s *S3Cache) fullPrefix() string {
if s.prefix == "" {
return s.workspacePrefix
}
if s.workspacePrefix == "" {
return s.prefix
}
return s.prefix + "/" + s.workspacePrefix
}
// buildPath constructs the full S3 path for a cached item.
func (s *S3Cache) buildPath(path, key string) string {
parts := []string{s.fullPrefix(), strings.Trim(path, "/"), strings.Trim(key, "/")}
return strings.Join(parts, "/")
}
// Get retrieves a cached file from S3.
func (s *S3Cache) Get(ctx context.Context, path, key string) (io.ReadCloser, error) {
logger := console.GetLogger(ctx)
s3Path := s.buildPath(path, key)
logger.Tracef("Getting file from s3 for path: %s", s3Path)
return s.client.GetObject(ctx, s.bucketName, s3Path)
}
// Set stores a file in S3.
func (s *S3Cache) Set(ctx context.Context, path, key string, content io.Reader) error {
logger := console.GetLogger(ctx)
s3Path := s.buildPath(path, key)
logger.Tracef("Setting file in s3 for path: %s", s3Path)
return s.client.PutObject(ctx, s.bucketName, s3Path, content)
}
// Delete removes a cached file from S3.
func (s *S3Cache) Delete(ctx context.Context, path string, key string) error {
logger := console.GetLogger(ctx)
s3Path := s.buildPath(path, key)
logger.Tracef("Deleting file from s3 for path: %s", s3Path)
return s.client.DeleteObject(ctx, s.bucketName, s3Path)
}
// s3StagingPath is the path component used for staged uploads under the
// configured workspace prefix. Anything under this prefix is in-flight and
// must be invisible to Get/Exists/Size against any cache key.
const s3StagingPath = ".uploads"
// BeginWrite opens a streaming upload to a staging key under .uploads/<uuid>.
// On Commit, the staging object is server-side copied to its final
// content-addressed key and the staging object is deleted. No bytes pass
// through grog twice.
func (s *S3Cache) BeginWrite(ctx context.Context) (StagedWriter, error) {
stagingKey := s.buildPath(s3StagingPath, uuid.NewString())
pr, pw := io.Pipe()
sw := &s3StagedWriter{
client: s.client,
bucket: s.bucketName,
stagingKey: stagingKey,
pipeWriter: pw,
done: make(chan error, 1),
buildPath: s.buildPath,
}
go func() {
// PutObject streams from the pipe via s3manager.Uploader. The Upload
// call only returns once the pipe sees EOF (success) or an error.
err := s.client.PutObject(ctx, s.bucketName, stagingKey, pr)
_ = pr.CloseWithError(err) // unblock any pending writers if upload errored mid-stream
sw.done <- err
}()
return sw, nil
}
// s3StagedWriter is the StagedWriter implementation for S3. Writes go through
// an io.Pipe into a background s3manager.Uploader; Commit is a server-side
// CopyObject from the staging key to the final cache key.
type s3StagedWriter struct {
client S3Client
bucket string
stagingKey string
pipeWriter *io.PipeWriter
done chan error
buildPath func(path, key string) string
mu sync.Mutex
finished bool
}
func (w *s3StagedWriter) Write(p []byte) (int, error) {
return w.pipeWriter.Write(p)
}
func (w *s3StagedWriter) Commit(ctx context.Context, path, key string) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return errors.New("s3 staged writer: commit after commit/cancel")
}
w.finished = true
w.mu.Unlock()
// Closing the pipe writer with EOF lets the background upload finish
// successfully. Wait for the upload to drain before issuing the copy.
if err := w.pipeWriter.Close(); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("close staging pipe: %w", err)
}
if err := <-w.done; err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("upload to staging key: %w", err)
}
finalKey := w.buildPath(path, key)
if err := w.client.CopyObject(ctx, w.bucket, w.stagingKey, finalKey); err != nil {
_ = w.cleanupStaging(ctx)
return fmt.Errorf("copy staging -> final: %w", err)
}
// Best-effort cleanup of the staging object — the final object exists
// regardless of whether this delete succeeds.
_ = w.cleanupStaging(ctx)
return nil
}
func (w *s3StagedWriter) Cancel(ctx context.Context) error {
w.mu.Lock()
if w.finished {
w.mu.Unlock()
return nil
}
w.finished = true
w.mu.Unlock()
// Close the pipe with an error so the upload goroutine sees a failed
// read, the s3manager.Uploader aborts the multipart upload internally,
// and we can drain the done channel without deadlocking.
_ = w.pipeWriter.CloseWithError(errors.New("s3 staged write cancelled"))
<-w.done
return w.cleanupStaging(ctx)
}
func (w *s3StagedWriter) cleanupStaging(ctx context.Context) error {
if err := w.client.DeleteObject(ctx, w.bucket, w.stagingKey); err != nil {
// Not fatal — orphan staging objects can be cleaned up by an S3
// lifecycle rule on the .uploads/ prefix. We log via the caller.
return err
}
return nil
}
// Size returns the byte size of an object in S3 via the underlying client's
// HeadObject-equivalent call. No body is downloaded.
func (s *S3Cache) Size(ctx context.Context, path, key string) (int64, error) {
logger := console.GetLogger(ctx)
s3Path := s.buildPath(path, key)
logger.Tracef("Sizing object in s3 for path: %s", s3Path)
return s.client.ObjectSize(ctx, s.bucketName, s3Path)
}
// Exists checks if a file exists in S3.
func (s *S3Cache) Exists(ctx context.Context, path string, key string) (bool, error) {
logger := console.GetLogger(ctx)
s3Path := s.buildPath(path, key)
logger.Tracef("Checking existence of file in s3 for path: %s", s3Path)
return s.client.ObjectExists(ctx, s.bucketName, s3Path)
}
// ListKeys uses S3 ListObjectsV2 to list keys under the given path.
func (sc *S3Cache) ListKeys(ctx context.Context, path string, suffix string) ([]string, error) {
adapter, ok := sc.client.(*AWSS3Adapter)
if !ok {
return nil, nil
}
fullPath := sc.buildPath(path, "")
if !strings.HasSuffix(fullPath, "/") {
fullPath += "/"
}
var keys []string
paginator := s3.NewListObjectsV2Paginator(adapter.client, &s3.ListObjectsV2Input{
Bucket: aws.String(sc.bucketName),
Prefix: aws.String(fullPath),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
key := strings.TrimPrefix(*obj.Key, fullPath)
if suffix == "" || strings.HasSuffix(key, suffix) {
keys = append(keys, key)
}
}
}
return keys, nil
}
package backends
// NewFileSystemCacheForTest creates a FileSystemCache with custom directories.
// Intended for use in tests outside this package.
func NewFileSystemCacheForTest(workspaceCacheDir, sharedCasDir string) *FileSystemCache {
return &FileSystemCache{
workspaceCacheDir: workspaceCacheDir,
sharedCasDir: sharedCasDir,
}
}
package caching
import (
"bytes"
"context"
"io"
"sync"
"github.com/chrismatix/grog/internal/caching/backends"
)
// Cas is a content-addressable store.
// That is: Every record is identified by its digest.
type Cas struct {
backend backends.CacheBackend
// Cache for exists queries since we assume that during the runtime of a build
// the cache backend cannot lose a digest (grog does not delete during a build)
keyExistsCache sync.Map
}
func NewCas(
cache backends.CacheBackend,
) *Cas {
return &Cas{
backend: cache,
}
}
func (c *Cas) GetBackend() backends.CacheBackend {
return c.backend
}
// Write writes a digest for a given reader.
func (c *Cas) Write(ctx context.Context, digest string, reader io.Reader) error {
if exists, err := c.Exists(ctx, digest); exists && err == nil {
// If the digest already exists, we don't need to write it again
return nil
}
err := c.backend.Set(ctx, "cas", digest, reader)
if err == nil {
// Mark the digest as existing in case later targets create the same digest
c.keyExistsCache.Store(digest, true)
}
return err
}
// WriteBytes writes a digest for a given reader.
func (c *Cas) WriteBytes(ctx context.Context, digest string, content []byte) error {
return c.Write(ctx, digest, bytes.NewReader(content))
}
// StagedWriter is re-exported so callers don't have to import the backends
// package just to type the streaming writer the ociproxy registry uses.
type StagedWriter = backends.StagedWriter
// BeginWrite opens a streaming writer that can be Commit'ed to the CAS once
// the caller has determined the digest of the bytes it pushed in. Used by
// the ociproxy registry to stream PATCH bodies straight into the backend
// without buffering them through a temp file first.
//
// The caller MUST call Commit (with the verified digest) or Cancel on the
// returned writer; deferring Cancel right after BeginWrite is the safe pattern.
func (c *Cas) BeginWrite(ctx context.Context) (StagedWriter, error) {
return c.backend.BeginWrite(ctx)
}
// Load loads the content for a given digest.
func (c *Cas) Load(ctx context.Context, digest string) (io.ReadCloser, error) {
return c.backend.Get(ctx, "cas", digest)
}
// LoadBytes loads the content for a given digest directly into a byte slice.
func (c *Cas) LoadBytes(ctx context.Context, digest string) ([]byte, error) {
reader, err := c.backend.Get(ctx, "cas", digest)
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
// Size returns the byte size of the CAS entry with the given digest. The
// ociproxy registry uses this to populate Content-Length headers on blob
// HEAD/GET responses, which the Docker daemon insists on.
func (c *Cas) Size(ctx context.Context, digest string) (int64, error) {
return c.backend.Size(ctx, "cas", digest)
}
func (c *Cas) Exists(ctx context.Context, digest string) (bool, error) {
if cached, ok := c.keyExistsCache.Load(digest); ok && cached.(bool) {
return cached.(bool), nil
}
exists, err := c.backend.Exists(ctx, "cas", digest)
if err != nil {
return false, err
}
if exists {
// Only cache if the key exists
c.keyExistsCache.Store(digest, exists)
}
return exists, nil
}
package caching
import (
"context"
"errors"
"os"
"path/filepath"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/label"
)
// TaintStore tracks targets that the user has marked for forced rebuild.
// Taint is per-checkout control state — it never writes to remote caches
// and never crosses workspace boundaries, even when multiple workspaces
// share a GROG_ROOT. The on-disk layout mirrors the label under
// $GROG_ROOT/<workspace-prefix>/taint/.
type TaintStore struct {
dir string
}
// NewTaintStore returns a TaintStore rooted at the current workspace's
// per-checkout taint directory.
func NewTaintStore() *TaintStore {
return &TaintStore{
dir: filepath.Join(config.Global.GetWorkspaceRootDir(), "taint"),
}
}
func (ts *TaintStore) entryPath(targetLabel label.TargetLabel) string {
return filepath.Join(ts.dir, targetLabel.String())
}
// Taint marks a target as pending rebuild. Idempotent.
func (ts *TaintStore) Taint(_ context.Context, targetLabel label.TargetLabel) error {
entryPath := ts.entryPath(targetLabel)
if err := os.MkdirAll(filepath.Dir(entryPath), 0755); err != nil {
return err
}
f, err := os.OpenFile(entryPath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
return f.Close()
}
// IsTainted reports whether a target has been marked for rebuild.
func (ts *TaintStore) IsTainted(_ context.Context, targetLabel label.TargetLabel) (bool, error) {
_, err := os.Stat(ts.entryPath(targetLabel))
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
// Clear removes a target's taint mark. Clearing an untainted target is a
// no-op.
func (ts *TaintStore) Clear(_ context.Context, targetLabel label.TargetLabel) error {
if err := os.Remove(ts.entryPath(targetLabel)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
package caching
import (
"bytes"
"context"
"io"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/chrismatix/grog/internal/proto/gen"
"google.golang.org/protobuf/proto"
)
type TargetResultCache struct {
backend backends.CacheBackend
}
func NewTargetResultCache(
cache backends.CacheBackend,
) *TargetResultCache {
return &TargetResultCache{backend: cache}
}
func (tc *TargetResultCache) GetBackend() backends.CacheBackend {
return tc.backend
}
func (tc *TargetResultCache) Write(ctx context.Context, targetResult *gen.TargetResult) error {
marshalledBytes, err := proto.MarshalOptions{Deterministic: true}.Marshal(targetResult)
if err != nil {
return err
}
return tc.backend.Set(ctx, "target", targetResult.ChangeHash, bytes.NewReader(marshalledBytes))
}
func (tc *TargetResultCache) Load(ctx context.Context, changeHash string) (*gen.TargetResult, error) {
reader, err := tc.backend.Get(ctx, "target", changeHash)
if err != nil {
return nil, err
}
defer reader.Close()
targetResult := &gen.TargetResult{}
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if err := proto.Unmarshal(data, targetResult); err != nil {
return nil, err
}
return targetResult, nil
}
func (tc *TargetResultCache) Has(ctx context.Context, changeHash string) (bool, error) {
return tc.backend.Exists(ctx, "target", changeHash)
}
package cmds
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/execution"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/locking"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output"
"github.com/chrismatix/grog/internal/output/handlers"
"github.com/chrismatix/grog/internal/output/push"
"github.com/chrismatix/grog/internal/selection"
"github.com/chrismatix/grog/internal/tracing"
)
// GrogVersion is set by the root command during initialization.
var GrogVersion string
func dependencyInferrer() *loading.DependencyInferrer {
inferrer := &loading.DependencyInferrer{GrogVersion: GrogVersion}
if config.Global.EnableCache {
inferrer.CasProvider = func(ctx context.Context) (*caching.Cas, error) {
cache, err := backends.GetCacheBackend(ctx, config.Global.Cache)
if err != nil {
return nil, err
}
return caching.NewCas(cache), nil
}
}
return inferrer
}
var BuildCmd = &cobra.Command{
Use: "build",
Short: "Loads the user configuration and executes build targets.",
Long: `Loads the user configuration, checks which targets need to be rebuilt based on file hashes, builds the dependency graph, and executes targets.`,
Example: ` grog build # Build all targets in the current package and subpackages
grog build //path/to/package:target # Build a specific target
grog build //path/to/package/... # Build all targets in a package and subpackages`,
Args: cobra.ArbitraryArgs, // Optional argument for target pattern
ValidArgsFunction: completions.BuildTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetPatterns, err := label.ParsePatternsOrMatchCurrentPackageAndSubpackages(currentPackagePath, args)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
RunBuild(
ctx,
logger,
targetPatterns,
graph,
selection.NonTestOnly,
config.Global.StreamLogs,
config.Global.GetLoadOutputsMode(),
)
},
}
func AddBuildCmd(rootCmd *cobra.Command) {
rootCmd.AddCommand(BuildCmd)
}
// RunBuild runs the build/test command for the given target pattern.
// commandOverride optionally overrides the trace command name (e.g. "run").
// If empty, the command name is derived from the testFilter.
func RunBuild(
ctx context.Context,
logger *console.Logger,
targetPatterns []label.TargetPattern,
graph *dag.DirectedTargetGraph,
testFilter selection.TargetTypeSelection,
streamLogs bool,
loadOutputsMode config.LoadOutputsMode,
commandOverride ...string,
) {
RunBuildAndAfter(ctx, logger, targetPatterns, graph, testFilter, streamLogs, loadOutputsMode, nil, commandOverride...)
}
// RunBuildAndAfter runs the build, then — on build success — invokes
// afterBuildSuccess in parallel with any in-flight async cache writes.
// afterBuildSuccess=nil is equivalent to RunBuild.
func RunBuildAndAfter(
ctx context.Context,
logger *console.Logger,
targetPatterns []label.TargetPattern,
graph *dag.DirectedTargetGraph,
testFilter selection.TargetTypeSelection,
streamLogs bool,
loadOutputsMode config.LoadOutputsMode,
afterBuildSuccess func(*execution.Executor) error,
commandOverride ...string,
) {
startTime := time.Now()
commandName := buildCommandName(testFilter, commandOverride)
var traceCollector *tracing.TraceCollector
if config.Global.Traces.Enabled {
traceCollector = tracing.NewTraceCollector(commandName, targetPatterns, GrogVersion)
}
selectTargetsForBuild(logger, targetPatterns, graph, testFilter)
failFast := config.Global.FailFast
cache, err := backends.GetCacheBackend(ctx, config.Global.Cache)
if err != nil {
logger.Fatalf("could not instantiate cache: %v", err)
}
targetCache := caching.NewTargetResultCache(cache)
cas := caching.NewCas(cache)
taintCache := caching.NewTaintStore()
registry := output.NewRegistry(ctx, cas)
ociHandler, ok := registry.Handler(handlers.OCIHandler)
if !ok {
logger.Fatalf("oci output handler is not registered")
}
pusher, err := push.New(
ociHandler,
func() bool { return config.Global.Push },
func() bool { return config.Global.FailFast },
)
if err != nil {
logger.Fatalf("could not instantiate image pusher: %v", err)
}
releaseWorkspaceLock := acquireWorkspaceLock(ctx, logger)
defer releaseWorkspaceLock()
executor := execution.NewExecutor(
targetCache,
taintCache,
registry,
pusher,
graph,
failFast,
streamLogs,
config.Global.EnableCache,
loadOutputsMode,
)
if afterBuildSuccess != nil {
executor.DeferAsyncWait()
}
completionMap, executionErr := executor.Execute(ctx)
goal := "Build"
switch testFilter {
case selection.TestOnly:
goal = "Test"
case selection.AllTargets:
goal = "Build and test"
}
// Print the build summary once, up front — it doubles as the signal that
// the build finished before any callback output appears.
buildOK := buildSucceeded(executionErr, completionMap)
if buildOK {
successCount, cacheHits := completionMap.TargetSuccessCount()
logger.Infof("%s completed successfully. %s completed (%d cache hits).",
goal,
console.FCountTargets(successCount),
cacheHits)
}
// When --push is set AND we have a follow-on callback (e.g. `grog run`
// binaries), the callback may consume the pushed image, so make sure
// pushes have finished before invoking it. Otherwise we keep the
// original parallelism: callback overlaps with async cache writes.
var afterBuildErr error
pushBeforeAfter := config.Global.Push && afterBuildSuccess != nil
if pushBeforeAfter {
executor.WaitForAsyncWrites(ctx)
}
if afterBuildSuccess != nil && buildOK {
releaseWorkspaceLock()
if pushBeforeAfter && pusher.Reporter().HasFailures() {
logger.Errorf("Skipping post-build callback because one or more pushes failed.")
} else {
afterBuildErr = afterBuildSuccess(executor)
}
}
if !pushBeforeAfter {
executor.WaitForAsyncWrites(ctx)
}
pushHadFailures := pusher.Reporter().RenderSummary(logger)
// Close output handlers (notably the loopback docker registry) only after
// async writes have drained. Closing earlier would tear the proxy down
// while the docker daemon is still streaming layers to it.
if err := registry.Close(); err != nil {
logger.Warnf("failed to close output registry: %v", err)
}
// Write trace (synchronous — Parquet writes are fast for local FS,
// and we need to ensure the write completes before the process exits)
writeTrace(ctx, logger, traceCollector, completionMap, graph, executor, cache)
logElapsedTime(logger, graph, executor, startTime)
exitOnBuildFailure(logger, graph, goal, completionMap, executionErr, afterBuildErr, pushHadFailures)
}
// buildCommandName returns the command name recorded in the build trace.
func buildCommandName(testFilter selection.TargetTypeSelection, commandOverride []string) string {
commandName := "build"
if len(commandOverride) > 0 && commandOverride[0] != "" {
commandName = commandOverride[0]
} else if testFilter == selection.TestOnly {
commandName = "test"
} else if testFilter == selection.AllTargets {
commandName = "build_and_test"
}
return commandName
}
// selectTargetsForBuild validates target constraints and selects the targets to build, exiting on failure.
func selectTargetsForBuild(
logger *console.Logger,
targetPatterns []label.TargetPattern,
graph *dag.DirectedTargetGraph,
testFilter selection.TargetTypeSelection,
) {
errs := analysis.CheckTargetConstraints(logger, graph.GetNodes())
if len(errs) > 0 {
for _, err := range errs {
logger.Errorf(err.Error())
}
os.Exit(1)
}
selector := selection.New(targetPatterns, config.Global.Tags, config.Global.ExcludeTags, testFilter)
// Select targets based on the target pattern.
selectedCount, skippedCount, err := selector.SelectTargetsForBuild(graph)
if err != nil {
logger.Fatalf("target selection failed: %v", err)
}
if selectedCount == 0 {
// Fail if no targets were selected
errString := fmt.Sprintf("could not find any targets matching %s", label.PatternSetToString(targetPatterns))
if skippedCount > 0 {
errString += fmt.Sprintf(" (%s not matching %s host)",
console.FCountTargets(skippedCount), config.Global.GetPlatform())
}
logger.Fatalf(errString)
}
infoStr := fmt.Sprintf("Selected %s.",
console.FCountTargets(selectedCount))
if skippedCount > 0 {
infoStr = fmt.Sprintf("Selected %s (%s not matching %s host).",
console.FCountTargets(selectedCount),
console.FCountTargets(skippedCount),
config.Global.GetPlatform())
}
logger.Infof(infoStr)
}
// acquireWorkspaceLock locks the workspace unless disabled and returns an idempotent release function.
func acquireWorkspaceLock(ctx context.Context, logger *console.Logger) func() {
// Only lock the workspace once necessary, i.e., before we start building.
// releaseWorkspaceLock is invoked explicitly before afterBuildSuccess so a
// user binary that itself shells out to grog doesn't deadlock on the lock.
releaseWorkspaceLock := func() {}
if config.Global.SkipWorkspaceLock {
logger.Warn("Skipping workspace lock. Concurrent grog executions may corrupt the cache or workspace state.")
} else {
locker := locking.NewWorkspaceLocker()
if err := locker.Lock(ctx); err != nil {
logger.Fatalf("could not acquire workspace lock: %v", err)
}
var unlockOnce sync.Once
releaseWorkspaceLock = func() {
unlockOnce.Do(func() {
if err := locker.Unlock(); err != nil {
logger.Errorf("failed to release workspace lock: %v", err)
}
})
}
}
return releaseWorkspaceLock
}
// writeTrace finalizes the build trace and writes it to the traces backend, falling back to the cache.
func writeTrace(
ctx context.Context,
logger *console.Logger,
traceCollector *tracing.TraceCollector,
completionMap dag.CompletionMap,
graph *dag.DirectedTargetGraph,
executor *execution.Executor,
cache backends.CacheBackend,
) {
if traceCollector != nil && completionMap != nil {
buildTrace := traceCollector.Finalize(completionMap, graph, executor.AsyncWaitTime())
// Use the dedicated traces backend if configured, otherwise fall back to the main cache
traceBackend := cache
if config.Global.Traces.Backend != "" {
traceCacheConfig := config.CacheConfig{
Backend: config.Global.Traces.Backend,
GCS: config.Global.Traces.GCS,
S3: config.Global.Traces.S3,
}
if tb, err := backends.GetCacheBackend(ctx, traceCacheConfig); err == nil {
traceBackend = tb
} else {
logger.Warnf("failed to instantiate traces backend, falling back to cache: %v", err)
}
}
traceWriter := tracing.NewTraceWriter(traceBackend)
if err := traceWriter.Write(context.WithoutCancel(ctx), buildTrace); err != nil {
logger.Warnf("failed to write trace: %v", err)
}
}
}
// logElapsedTime logs the total build time and the critical path.
func logElapsedTime(logger *console.Logger, graph *dag.DirectedTargetGraph, executor *execution.Executor, startTime time.Time) {
elapsedTime := time.Since(startTime).Seconds()
// Mostly used to keep our test fixtures deterministic
if !config.Global.DisableNonDeterministicLogging {
if criticalPath, ok := graph.GetSelectedSubgraph().FindCriticalPath(); ok && len(criticalPath.Nodes) > 0 {
var criticalPathLabels []string
for _, node := range criticalPath.Nodes {
criticalPathLabels = append(criticalPathLabels, node.GetLabel().String())
}
asyncWait := executor.AsyncWaitTime().Seconds()
if asyncWait > 0 {
logger.Infof(
"Elapsed time: %.3fs (critical path: exec %.3fs, cache %.3fs, async cache %.3fs)",
elapsedTime,
criticalPath.ExecutionDuration.Seconds(),
criticalPath.CacheDuration.Seconds(),
asyncWait,
)
} else {
logger.Infof(
"Elapsed time: %.3fs (critical path: exec %.3fs, cache %.3fs)",
elapsedTime,
criticalPath.ExecutionDuration.Seconds(),
criticalPath.CacheDuration.Seconds(),
)
}
logger.Infof("Critical path: %s", strings.Join(criticalPathLabels, " -> "))
} else {
logger.Infof(
"Elapsed time: %.3fs",
elapsedTime,
)
}
}
}
// exitOnBuildFailure reports failed targets and exits non-zero if the build, callback or pushes failed.
func exitOnBuildFailure(
logger *console.Logger,
graph *dag.DirectedTargetGraph,
goal string,
completionMap dag.CompletionMap,
executionErr error,
afterBuildErr error,
pushHadFailures bool,
) {
if executionErr != nil {
// If this is a cancellation error continue printing out any collected errors
if !errors.Is(executionErr, context.Canceled) || completionMap == nil {
os.Exit(1)
return
}
logger.Errorf("execution failed: %s", executionErr)
}
executionErrors := completionMap.GetErrors()
successCount, cacheHits := completionMap.TargetSuccessCount()
if len(executionErrors) > 0 {
logger.Errorf("%s failed. %s completed (%d cache hits), %d failed:",
goal,
console.FCountTargets(successCount),
cacheHits,
len(executionErrors))
for completionLabel, completion := range completionMap {
target, ok := graph.GetNodes()[completionLabel].(*model.Target)
if !ok {
continue
}
if completion.IsSuccess {
continue
}
color.Red("---------------------------------")
if completion.Err == nil {
logger.Errorf("Target %s failed with no error", target.Label)
} else if executionError, isCommandError := errors.AsType[*execution.CommandError](completion.Err); isCommandError {
logger.Errorf("Target %s failed with exit code %d:\ncommand: \"%s\"\n%s",
target.Label,
executionError.ExitCode,
target.Command,
strings.TrimSpace(executionError.Output))
} else {
logger.Errorf("Target %s failed: %v", target.Label, completion.Err)
}
}
os.Exit(1)
}
if executionErr != nil {
os.Exit(1)
}
if afterBuildErr != nil {
logger.Fatalf("%v", afterBuildErr)
}
if pushHadFailures {
os.Exit(1)
}
}
// buildSucceeded reports whether it is safe to run a dependent post-build step.
func buildSucceeded(executionErr error, completionMap dag.CompletionMap) bool {
if executionErr != nil {
return false
}
if completionMap == nil {
return false
}
return len(completionMap.GetErrors()) == 0
}
package cmds
import (
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/selection"
)
var BuildAndTestCmd = &cobra.Command{
Use: "build-and-test",
Aliases: []string{"bat"},
Short: "Loads the user configuration and executes build and test targets.",
Long: `Loads the user configuration, checks which targets need to be rebuilt based on file hashes, builds the dependency graph, and executes both build and test targets.`,
Example: ` grog build-and-test # Build all targets and run all tests in the current package and subpackages
grog build-and-test //path/to/package:target # Build or test a specific target
grog build-and-test //path/to/package/... # Build all targets and run all tests in a package and subpackages`,
Args: cobra.ArbitraryArgs,
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetPatterns, err := label.ParsePatternsOrMatchCurrentPackageAndSubpackages(currentPackagePath, args)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
RunBuild(
ctx,
logger,
targetPatterns,
graph,
selection.AllTargets,
config.Global.StreamLogs,
config.Global.GetLoadOutputsMode(),
)
},
}
func AddBuildAndTestCmd(rootCmd *cobra.Command) {
rootCmd.AddCommand(BuildAndTestCmd)
}
package cmds
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var changesOptions = struct {
since string
dependents *flagtypes.Enum
targetType *flagtypes.Enum
}{
dependents: flagtypes.NewEnum("none", "transitive"),
targetType: flagtypes.NewEnum("all", "test", "no_test", "bin_output"),
}
var ChangesCmd = &cobra.Command{
Use: "changes",
Short: "Lists targets whose inputs have been modified since a given commit.",
Long: `Identifies targets that need to be rebuilt due to changes in their input files since a specified Git commit or Jujutsu revision.
Can optionally include transitive dependents of changed targets to find all affected targets.`,
Example: ` grog changes --since=HEAD~1 # Show targets changed in the last commit
grog changes --since=main --dependents=transitive # Show targets changed since main branch, including dependents
grog changes --since=v1.0.0 --target-type=test # Show only test targets changed since Git tag v1.0.0`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
// Get changed files using git or jj
changedFiles, err := getChangedFiles(changesOptions.since)
if err != nil {
logger.Fatalf("Failed to get changed files: %v", err)
}
if len(changedFiles) == 0 {
logger.Debug("No files changed")
return
}
logger.Debugf("Changed files: %v", changedFiles)
packages, err := loading.LoadAllPackages(ctx, dependencyInferrer())
if err != nil {
logger.Fatalf(
"could not load packages: %v",
err)
}
nodes, err := model.BuildNodeMapFromPackages(packages)
if err != nil {
logger.Fatalf("could not create target map: %v", err)
}
graph, err := analysis.BuildGraph(nodes)
if err != nil {
logger.Fatalf("could not build graph: %v", err)
}
// Find nodes that own the changed files
var matchingTargets []*model.Target
for _, target := range nodes.GetTargets() {
for _, inputFile := range target.Inputs {
// Get the absolute path of the input file
absInputPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(
target.Label.Package,
inputFile,
))
if containsFile(changedFiles, absInputPath) {
matchingTargets = append(matchingTargets, target)
break // Found a match, no need to check other inputs
}
}
}
// Check package definitions
for _, pkg := range packages {
for _, target := range pkg.Targets {
if containsFile(changedFiles, target.SourceFilePath) {
// Add this target if the package source file changed
matchingTargets = append(matchingTargets, target)
}
}
}
// A changed resolver input can change the inferred graph, so every
// target the resolver stands behind counts as changed.
for _, pkg := range packages {
for _, resolver := range pkg.DependencyResolvers {
if resolverInputChanged(resolver, changedFiles) {
matchingTargets = append(matchingTargets, resolverTargets(nodes, resolver.Label)...)
}
}
}
// Get dependents if requested
var resultTargets []*model.Target
if changesOptions.dependents.Value == "transitive" {
// Get all transitive dependents of the matching nodes
for _, target := range matchingTargets {
resultTargets = append(resultTargets, target)
for _, descendant := range graph.GetDescendants(target) {
if targetDescendant, ok := descendant.(*model.Target); ok {
resultTargets = append(resultTargets, targetDescendant)
}
}
}
} else {
resultTargets = matchingTargets
}
// Deduplicate nodes
uniqueLabels := make(map[label.TargetLabel]bool)
var deduplicatedTargets []model.BuildNode
for _, target := range resultTargets {
if !uniqueLabels[target.Label] {
uniqueLabels[target.Label] = true
deduplicatedTargets = append(deduplicatedTargets, target)
}
}
targetTypeFilter, err := selection.StringToTargetTypeSelection(changesOptions.targetType.Value)
if err != nil {
logger.Fatalf(err.Error())
}
selector := selection.New(nil, config.Global.Tags, config.Global.ExcludeTags, targetTypeFilter)
model.PrintSortedLabels(selector.FilterNodes(deduplicatedTargets))
},
}
// getChangedFiles returns a list of files that have changed since the given revision.
func getChangedFiles(revision string) ([]string, error) {
gitRoot, err := getGitRoot()
if err != nil {
return nil, err
}
outputs := [][]byte{}
if vcsIsJJ(gitRoot) {
output, err := getChangedFilesForJujutsuRevision(gitRoot, revision)
if err != nil {
return nil, err
}
outputs = append(outputs, output)
} else {
output, err := getChangedFilesForGitRevision(gitRoot, revision)
if err != nil {
return nil, err
}
outputs = append(outputs, output)
dirtyOutputs, err := getDirtyChangedFiles(gitRoot)
if err != nil {
return nil, err
}
outputs = append(outputs, dirtyOutputs...)
}
uniqueFiles := make(map[string]bool)
for _, output := range outputs {
for file := range strings.SplitSeq(string(output), "\n") {
if file != "" {
uniqueFiles[file] = true
}
}
}
var files []string
for file := range uniqueFiles {
// Get the absolute path of the file
absolutePath := filepath.Join(gitRoot, file)
files = append(files, absolutePath)
}
return files, nil
}
func getGitRoot() (string, error) {
gitRootCommand := exec.Command("git", "rev-parse", "--show-toplevel")
gitRootOutput, err := gitRootCommand.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(gitRootOutput)), nil
}
func getChangedFilesForGitRevision(gitRoot string, revision string) ([]byte, error) {
// Run a tree-only diff to get changed files. Disabling rename detection keeps
// this path-based query compatible with blobless partial clones.
gitDiffCommand := exec.Command(
"git",
"diff-tree",
"--name-only",
"-r",
"--no-commit-id",
"--no-renames",
revision,
"HEAD",
)
gitDiffCommand.Dir = gitRoot
return gitDiffCommand.Output()
}
func getChangedFilesForJujutsuRevision(gitRoot string, revision string) ([]byte, error) {
jujutsuDiffCommand := exec.Command(
"jj",
"--no-pager",
"--color",
"never",
"diff",
"--name-only",
"--from",
revision,
"--to",
"@",
)
jujutsuDiffCommand.Dir = gitRoot
return jujutsuDiffCommand.Output()
}
func getDirtyChangedFiles(gitRoot string) ([][]byte, error) {
commands := []*exec.Cmd{
exec.Command("git", "diff-index", "--cached", "--name-only", "--no-renames", "HEAD"),
exec.Command("git", "diff-files", "--name-only", "--no-renames"),
exec.Command("git", "ls-files", "--others", "--exclude-standard"),
}
var outputs [][]byte
for _, command := range commands {
command.Dir = gitRoot
output, err := command.Output()
if err != nil {
return nil, err
}
outputs = append(outputs, output)
}
return outputs, nil
}
func vcsIsJJ(gitRoot string) bool {
jujutsuDirectoryPath := filepath.Join(gitRoot, ".jj")
fileInfo, err := os.Stat(jujutsuDirectoryPath)
if err != nil {
return false
}
return fileInfo.IsDir()
}
// containsFile checks if the list of files contains the given file.
func containsFile(files []string, file string) bool {
return slices.Contains(files, file)
}
func resolverInputChanged(resolver *model.DependencyResolver, changedFiles []string) bool {
for _, inputFile := range resolver.Inputs {
if containsFile(changedFiles, config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(resolver.Label.Package, inputFile))) {
return true
}
}
return false
}
// resolverTargets lists the targets registered with, or synthesized by, a resolver.
func resolverTargets(nodes model.BuildNodeMap, resolverLabel label.TargetLabel) []*model.Target {
var targets []*model.Target
for _, target := range nodes.GetTargets() {
if slices.Contains(target.DependencyResolvers, resolverLabel) {
targets = append(targets, target)
}
}
return targets
}
func AddChangesCmd(rootCmd *cobra.Command) {
flags := ChangesCmd.Flags()
flags.StringVar(
&changesOptions.since,
"since",
"",
"Git ref or Jujutsu revision to compare against")
flags.Var(
changesOptions.dependents,
"dependents",
"Whether to include dependents of changed targets (none or transitive)")
flags.Var(
changesOptions.targetType,
"target-type",
"Filter targets by type (all, test, no_test, bin_output)")
if err := ChangesCmd.MarkFlagRequired("since"); err != nil {
panic(err)
}
rootCmd.AddCommand(ChangesCmd)
}
package cmds
import (
"os"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/loading"
"github.com/spf13/cobra"
)
var CheckCmd = &cobra.Command{
Use: "check",
Short: "Loads the build graph and runs basic consistency checks.",
Long: `Loads the build graph and performs the same consistency checks as 'grog build' without actually building anything.`,
Example: ` grog check # Validate the build graph for consistency issues`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
errs := analysis.CheckTargetConstraints(logger, graph.GetNodes())
if len(errs) > 0 {
for _, err := range errs {
logger.Errorf(err.Error())
}
os.Exit(1)
}
logger.Infof("Build graph is valid.")
},
}
package cmds
import (
"fmt"
"os"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/locking"
"github.com/spf13/cobra"
)
var (
expunge bool
cleanForce bool
)
var CleanCmd = &cobra.Command{
Use: "clean",
Short: "Removes all cached artifacts.",
Long: `Removes cached artifacts from the workspace or the entire grog cache.
By default, the target cache and the workspace's logs/lockfile are cleaned. Since the target cache is shared across checkouts of the same repo, running clean affects other workspaces pointing at the same GROG_ROOT. Use the --expunge flag to remove every grog cache directory (CAS included).
Because the cache is shared across checkouts, clean refuses to run while another grog build is in progress in any checkout that uses the same GROG_ROOT — deleting cache entries out from under a running build can make it fail when it reads an output it had already recorded as a cache hit. Pass --force to clean anyway.`,
Example: ` grog clean # Clean the target cache and this checkout's logs
grog clean --expunge # Clean the entire grog cache`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
_, logger := console.SetupCommand()
if !cleanForce {
activeLocks, err := locking.FindActiveLocks(config.Global.Root)
if err != nil {
logger.Fatalf("Clean failed: could not check for running builds: %v", err)
}
if len(activeLocks) > 0 {
var message strings.Builder
fmt.Fprintf(
&message,
"Refusing to clean: %d grog build(s) using this cache (%s) are still running:\n",
len(activeLocks),
config.Global.Root,
)
for _, lock := range activeLocks {
if lock.Command != "" {
fmt.Fprintf(&message, " PID %d: %s\n", lock.ProcessID, lock.Command)
} else {
fmt.Fprintf(&message, " PID %d\n", lock.ProcessID)
}
}
message.WriteString(
"Cleaning the shared cache now could corrupt those builds. " +
"Wait for them to finish, or pass --force to clean anyway.",
)
logger.Fatal(message.String())
}
}
var dirsToClear []string
if expunge {
dirsToClear = []string{config.Global.Root}
} else {
dirsToClear = []string{
config.Global.GetWorkspaceRootDir(),
config.Global.GetWorkspaceCacheDirectory(),
}
}
for _, dir := range dirsToClear {
if err := os.RemoveAll(dir); err != nil {
logger.Fatalf("Clean failed: %v", err)
}
if err := os.MkdirAll(dir, 0755); err != nil {
logger.Fatalf("Clean succeeded but failed to recreate the directory: %v", err)
}
}
if expunge {
logger.Info("Cache expunged successfully.")
} else {
logger.Info("Workspace cache cleaned successfully.")
}
},
}
func AddCleanCmd(rootCmd *cobra.Command) {
CleanCmd.Flags().BoolVarP(&expunge, "expunge", "e", false, "Expunge all cached artifacts")
CleanCmd.Flags().BoolVarP(&cleanForce, "force", "f", false, "Clean even if other grog builds are running")
rootCmd.AddCommand(CleanCmd)
}
package cmds
import (
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var depsOptions = struct {
transitive bool
targetType *flagtypes.Enum
}{
targetType: flagtypes.NewEnum("all", "test", "no_test", "bin_output"),
}
var DepsCmd = &cobra.Command{
Use: "deps",
Short: "Lists (transitive) dependencies of a target.",
Long: `Lists the direct or transitive dependencies of a specified target.
By default, only direct dependencies are shown. Use the --transitive flag to show all transitive dependencies.
Dependencies can be filtered by target type using the --target-type flag.`,
Example: ` grog deps //path/to/package:target # Show direct dependencies
grog deps -t //path/to/package:target # Show transitive dependencies
grog deps --target-type=test //path/to/package:target # Show only test dependencies`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
if len(args) == 0 {
logger.Fatalf("`%s` requires a target label", cmd.UseLine())
}
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetLabel, err := label.ParseTargetLabel(currentPackagePath, args[0])
if err != nil {
logger.Fatalf("could not parse target label: %v", err)
}
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
target, hasTarget := graph.GetNodes()[targetLabel]
if !hasTarget {
logger.Fatalf("could not find target %s", targetLabel)
}
var dependencies []model.BuildNode
if depsOptions.transitive {
dependencies = graph.GetAncestors(target)
} else {
dependencies = graph.GetDependencies(target)
}
// Filter by target type
targetTypeFilter, err := selection.StringToTargetTypeSelection(depsOptions.targetType.Value)
if err != nil {
logger.Fatalf(err.Error())
}
selector := selection.New(nil, config.Global.Tags, config.Global.ExcludeTags, targetTypeFilter)
filteredDeps := selector.FilterNodes(dependencies)
model.PrintSortedLabels(filteredDeps)
},
}
func AddDepsCmd(rootCmd *cobra.Command) {
DepsCmd.Flags().BoolVarP(
&depsOptions.transitive,
"transitive",
"t",
false,
"Include all transitive dependencies of the target")
DepsCmd.Flags().Var(
depsOptions.targetType,
"target-type",
"Filter targets by type (all, test, no_test, bin_output)")
rootCmd.AddCommand(DepsCmd)
}
package cmds
import (
"fmt"
"path/filepath"
"sort"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
)
var explainChangesOptions struct {
since string
showFiles bool
filesFirst bool
}
var ExplainChangesCmd = &cobra.Command{
Use: "explain-changes",
Short: "Renders the chain of targets affected by changes since a revision as a tree.",
Long: `Shows, as a tree, how file changes since a given Git ref or Jujutsu revision propagate through the dependency graph.
By default the tree is rooted on the leaf consumers of the change — i.e. the top-level targets
(binaries, tests, etc.) that ultimately depend on the changed code — and walks back through their
dependencies to the directly-affected targets, with the changed input files attached as leaves.
This surfaces the actionable answer (which top-level targets need to be rebuilt / retested) first
and uses the chain underneath as the evidence.
This is the human-readable counterpart to ` + "`grog changes`" + `: same query, tree view. Use
` + "`grog changes`" + ` when you want a flat, pipeable list of target labels.
Use --files-first to flip the tree the other way: root on the changed files and walk downstream
through the directly-affected targets to their transitive dependents.`,
Example: ` grog explain-changes --since=HEAD~1 # Explain impact of the last commit
grog explain-changes --since=main # Explain impact since the main branch
grog explain-changes --since=main --show-files=false # Drop the changed-file leaves
grog explain-changes --since=main --files-first # Flip to a file-rooted, downstream tree`,
Args: cobra.MaximumNArgs(0),
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
if explainChangesOptions.since == "" {
logger.Fatalf("--since flag is required")
}
changedFiles, err := getChangedFiles(explainChangesOptions.since)
if err != nil {
logger.Fatalf("Failed to get changed files: %v", err)
}
if len(changedFiles) == 0 {
logger.Debug("No files changed")
return
}
logger.Debugf("Changed files: %v", changedFiles)
packages, err := loading.LoadAllPackages(ctx, dependencyInferrer())
if err != nil {
logger.Fatalf("could not load packages: %v", err)
}
nodes, err := model.BuildNodeMapFromPackages(packages)
if err != nil {
logger.Fatalf("could not create target map: %v", err)
}
graph, err := analysis.BuildGraph(nodes)
if err != nil {
logger.Fatalf("could not build graph: %v", err)
}
// Build the file -> directly-affected targets map. Same matching logic as
// `grog changes` (input files + package source files), but we preserve the
// per-file association so the tree can root on files.
fileToTargets := make(map[string][]*model.Target)
seenPerFile := make(map[string]map[label.TargetLabel]bool)
add := func(file string, target *model.Target) {
if seenPerFile[file] == nil {
seenPerFile[file] = make(map[label.TargetLabel]bool)
}
if seenPerFile[file][target.Label] {
return
}
seenPerFile[file][target.Label] = true
fileToTargets[file] = append(fileToTargets[file], target)
}
for _, target := range nodes.GetTargets() {
for _, inputFile := range target.Inputs {
absInputPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(
target.Label.Package,
inputFile,
))
if containsFile(changedFiles, absInputPath) {
add(absInputPath, target)
}
}
}
for _, pkg := range packages {
for _, target := range pkg.Targets {
if containsFile(changedFiles, target.SourceFilePath) {
add(target.SourceFilePath, target)
}
}
}
for _, pkg := range packages {
for _, resolver := range pkg.DependencyResolvers {
for _, inputFile := range resolver.Inputs {
absInputPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(resolver.Label.Package, inputFile))
if !containsFile(changedFiles, absInputPath) {
continue
}
for _, target := range resolverTargets(nodes, resolver.Label) {
add(absInputPath, target)
}
}
}
}
if len(fileToTargets) == 0 {
logger.Debug("No targets affected by the changed files")
return
}
switch {
case explainChangesOptions.filesFirst && explainChangesOptions.showFiles:
printFileRootedTree(fileToTargets, graph)
case explainChangesOptions.filesFirst:
printTargetRootedTree(fileToTargets, graph)
default:
affected, targetToFiles := computeAffectedSet(fileToTargets, graph)
printConsumerRootedTree(affected, targetToFiles, graph, explainChangesOptions.showFiles)
}
},
}
// printFileRootedTree prints one tree per changed file: file -> affected
// targets -> transitive dependents.
func printFileRootedTree(fileToTargets map[string][]*model.Target, graph *dag.DirectedTargetGraph) {
files := make([]string, 0, len(fileToTargets))
for f := range fileToTargets {
files = append(files, f)
}
sort.Strings(files)
for _, f := range files {
root := tree.New().Root(displayPath(f))
applyRootStyle(root, downEnumerator)
targets := fileToTargets[f]
sort.Slice(targets, func(i, j int) bool {
return targets[i].Label.String() < targets[j].Label.String()
})
for _, t := range targets {
root.Child(buildDependentTree(t, graph))
}
fmt.Println(root)
}
}
// printTargetRootedTree collapses the file layer and prints one tree per
// directly-affected target with its transitive dependent chain.
func printTargetRootedTree(fileToTargets map[string][]*model.Target, graph *dag.DirectedTargetGraph) {
seen := make(map[label.TargetLabel]bool)
var roots []*model.Target
for _, targets := range fileToTargets {
for _, t := range targets {
if seen[t.Label] {
continue
}
seen[t.Label] = true
roots = append(roots, t)
}
}
sort.Slice(roots, func(i, j int) bool {
return roots[i].Label.String() < roots[j].Label.String()
})
for _, t := range roots {
subtree := buildDependentTree(t, graph)
applyRootStyle(subtree, downEnumerator)
fmt.Println(subtree)
}
}
// buildDependentTree builds a Lipgloss tree rooted at `node`, recursing
// downstream through `GetDependants`. Mirrors graph.go's buildTree but follows
// the dependency direction in reverse. No visited-set: the graph is acyclic
// (validated by BuildGraph), so we render diamond subtrees verbatim under each
// path that reaches them, matching the behavior of `grog graph`.
func buildDependentTree(node model.BuildNode, graph *dag.DirectedTargetGraph) *tree.Tree {
t := tree.New().Root(node.GetLabel().String())
deps := graph.GetDependants(node)
sort.Slice(deps, func(i, j int) bool {
return deps[i].GetLabel().String() < deps[j].GetLabel().String()
})
for _, dep := range deps {
if len(graph.GetDependants(dep)) > 0 {
t.Child(buildDependentTree(dep, graph))
} else {
t.Child(dep.GetLabel().String())
}
}
return t
}
// computeAffectedSet returns the full set of nodes touched by the change
// (directly-affected targets plus all their transitive dependents) along with a
// reverse target -> changed-files index. The returned set keys on label so root
// detection can ask "do any dependants of this node also live in the set?"
// while keeping the BuildNode handy for traversal.
func computeAffectedSet(
fileToTargets map[string][]*model.Target,
graph *dag.DirectedTargetGraph,
) (map[label.TargetLabel]model.BuildNode, map[label.TargetLabel][]string) {
affected := make(map[label.TargetLabel]model.BuildNode)
targetToFiles := make(map[label.TargetLabel][]string)
for file, targets := range fileToTargets {
for _, t := range targets {
affected[t.Label] = t
targetToFiles[t.Label] = append(targetToFiles[t.Label], file)
for _, d := range graph.GetDescendants(t) {
affected[d.GetLabel()] = d
}
}
}
// Deterministic file ordering per target.
for lbl := range targetToFiles {
sort.Strings(targetToFiles[lbl])
}
return affected, targetToFiles
}
// printConsumerRootedTree is the default view. It roots on the leaf consumers
// of the change (nodes in the affected set whose dependants are all outside the
// set) and walks back through dependencies, stopping naturally at the
// directly-affected targets. When showFiles is true, each directly-affected
// target has its changed input files attached as leaves under it.
func printConsumerRootedTree(
affected map[label.TargetLabel]model.BuildNode,
targetToFiles map[label.TargetLabel][]string,
graph *dag.DirectedTargetGraph,
showFiles bool,
) {
var roots []model.BuildNode
for _, node := range affected {
isRoot := true
for _, dep := range graph.GetDependants(node) {
if _, in := affected[dep.GetLabel()]; in {
isRoot = false
break
}
}
if isRoot {
roots = append(roots, node)
}
}
sort.Slice(roots, func(i, j int) bool {
return roots[i].GetLabel().String() < roots[j].GetLabel().String()
})
for _, r := range roots {
t := buildUpstreamTree(r, graph, affected, targetToFiles, showFiles)
applyRootStyle(t, upEnumerator)
fmt.Println(t)
}
}
// buildUpstreamTree builds a Lipgloss tree rooted at `node`, recursing upstream
// through `GetDependencies` but only following edges into nodes that are in the
// affected set. Optionally attaches changed input files as leaf children for
// nodes that are directly-affected. As with buildDependentTree, no visited-set
// is kept: diamond subtrees repeat under each path that reaches them.
func buildUpstreamTree(
node model.BuildNode,
graph *dag.DirectedTargetGraph,
affected map[label.TargetLabel]model.BuildNode,
targetToFiles map[label.TargetLabel][]string,
showFiles bool,
) *tree.Tree {
t := tree.New().Root(node.GetLabel().String())
if showFiles {
for _, f := range targetToFiles[node.GetLabel()] {
t.Child(displayPath(f))
}
}
deps := graph.GetDependencies(node)
sort.Slice(deps, func(i, j int) bool {
return deps[i].GetLabel().String() < deps[j].GetLabel().String()
})
for _, dep := range deps {
if _, in := affected[dep.GetLabel()]; !in {
continue
}
t.Child(buildUpstreamTree(dep, graph, affected, targetToFiles, showFiles))
}
return t
}
// upEnumerator and downEnumerator replace the dash in the standard rounded
// connector with an arrow pointing in the direction of change propagation:
//
// - up (╰─↑ / ├─↑): consumer-rooted view — change flows UP from each child
// line to its parent (cause is below, effect is above).
// - down (╰─↓ / ├─↓): files-first view — change flows DOWN from each parent
// to its children (cause is above, effect is below).
//
// Both keep the standard 3-char width so they align with lipgloss's default
// 3-char indenter without further configuration.
func upEnumerator(c tree.Children, i int) string {
if i == c.Length()-1 {
return "╰─↑"
}
return "├─↑"
}
func downEnumerator(c tree.Children, i int) string {
if i == c.Length()-1 {
return "╰─↓"
}
return "├─↓"
}
// applyRootStyle matches the coloring used by `grog graph` at its top level so
// the two tree views feel consistent, and installs the supplied enumerator —
// lipgloss propagates the top-level enumerator to nested sub-trees.
func applyRootStyle(t *tree.Tree, enum tree.Enumerator) {
enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).MarginRight(1)
rootStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
t.RootStyle(rootStyle).
ItemStyle(itemStyle).
EnumeratorStyle(enumeratorStyle).
Enumerator(enum)
}
// displayPath renders an absolute file path relative to the workspace root for
// readability. Falls back to the absolute path if a relative one can't be
// derived.
func displayPath(absPath string) string {
rel, err := filepath.Rel(config.Global.WorkspaceRoot, absPath)
if err != nil {
return absPath
}
return rel
}
func AddExplainChangesCmd(rootCmd *cobra.Command) {
ExplainChangesCmd.Flags().StringVar(
&explainChangesOptions.since,
"since",
"",
"Git ref or Jujutsu revision to compare against")
ExplainChangesCmd.Flags().BoolVar(
&explainChangesOptions.showFiles,
"show-files",
true,
"Include the changed input files in the tree. By default files are leaves under the directly-affected targets; with --files-first they are tree roots. Use --show-files=false to omit them.")
ExplainChangesCmd.Flags().BoolVar(
&explainChangesOptions.filesFirst,
"files-first",
false,
"Flip the tree to root on the changed files and walk downstream through the directly-affected targets to their transitive dependents.")
rootCmd.AddCommand(ExplainChangesCmd)
}
package cmds
import (
"fmt"
"sort"
"github.com/chrismatix/grog/internal/console"
"path/filepath"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/TyphonHill/go-mermaid/diagrams/flowchart"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"github.com/spf13/cobra"
)
var graphOptions = struct {
output *flagtypes.Enum
mermaidInputsAsNodes bool
transitive bool
}{
output: flagtypes.NewEnum("tree", "json", "mermaid"),
}
var GraphCmd = &cobra.Command{
Use: "graph",
Short: "Outputs the target dependency graph.",
Long: `Visualizes the dependency graph of targets in various formats.
Supports tree, JSON, and Mermaid diagram output formats. By default, only direct dependencies are shown.`,
Example: ` grog graph # Show dependency tree for all targets
grog graph //path/to/package:target # Show dependencies for a specific target
grog graph -o mermaid //path/to/package:target # Output as Mermaid diagram
grog graph -t //path/to/package:target # Include transitive dependencies`,
Args: cobra.ArbitraryArgs,
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
packages, err := loading.LoadAllPackages(ctx, dependencyInferrer())
if err != nil {
logger.Fatalf(
"could not load packages: %v",
err)
}
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetPatterns, err := label.ParsePatternsOrMatchAll(currentPackagePath, args)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
nodes, err := model.BuildNodeMapFromPackages(packages)
if err != nil {
logger.Fatalf("could not create target map: %v", err)
}
graph, err := analysis.BuildGraph(nodes)
if err != nil {
logger.Fatalf("could not build graph: %v", err)
}
// TODO make this explicitly configurable
// Graphing by default should ignore platform selectors as it is more about documentation
// and not execution.
config.Global.AllPlatforms = true
selector := selection.New(targetPatterns, config.Global.Tags, config.Global.ExcludeTags, selection.AllTargets)
selector.SelectTargets(graph)
if graphOptions.transitive {
// Select transitive dependencies of the selected nodes
for _, target := range graph.GetSelectedNodes() {
for _, ancestor := range graph.GetAncestors(target) {
ancestor.Select()
}
}
}
subgraph := graph.GetSelectedSubgraph()
switch graphOptions.output.Value {
case "mermaid":
printMermaidDiagram(subgraph)
case "tree":
printTree(subgraph)
case "json":
jsonData, err := subgraph.MarshalJSON()
if err != nil {
logger.Fatalf("could not marshal graph to json: %v", err)
}
fmt.Println(string(jsonData))
}
},
}
func AddGraphCmd(cmd *cobra.Command) {
GraphCmd.Flags().BoolVarP(
&graphOptions.transitive,
"transitive",
"t",
false,
"Include all transitive dependencies of the selected targets.")
GraphCmd.Flags().VarP(graphOptions.output, "output", "o", "Output format. One of: tree, json, mermaid.")
GraphCmd.Flags().BoolVarP(&graphOptions.mermaidInputsAsNodes, "mermaid-inputs-as-nodes", "m", false, "Render inputs as nodes in mermaid graphs.")
cmd.AddCommand(GraphCmd)
}
func printMermaidDiagram(graph *dag.DirectedTargetGraph) {
chart := flowchart.NewFlowchart()
chart.SetDirection(flowchart.FlowchartDirectionBottomUp)
workspaceDir := config.Global.WorkspaceRoot
chart.Title = fmt.Sprintf("%s dependency graph", filepath.Base(workspaceDir))
// Add Nodes:
// Deterministic ordering of nodes.
nodes := graph.GetNodes().SelectedNodesAlphabetically()
nodeMap := make(map[string]*flowchart.Node)
for _, node := range nodes {
chartNode := chart.AddNode(node.GetLabel().String())
chartNode.Style = &flowchart.NodeStyle{
Fill: "#E3F2FD", // light-blue-50
Stroke: "#1E88E5", // blue-600
StrokeWidth: 2,
}
nodeMap[node.GetLabel().String()] = chartNode
targetNode, isTarget := node.(*model.Target)
// Add inputs as separate nodes if requested.
if graphOptions.mermaidInputsAsNodes && isTarget {
// Keep input nodes in stable order as well.
inputs := make([]string, len(targetNode.UnresolvedInputs))
copy(inputs, targetNode.UnresolvedInputs)
sort.Strings(inputs)
for _, input := range inputs {
inputNode := chart.AddNode(input)
inputNode.Style = &flowchart.NodeStyle{
Fill: "#FFF8E1", // amber-50
Stroke: "#FB8C00", // orange-600
StrokeWidth: 2,
StrokeDash: "6 3", // dashed border emphasises "just an input"
}
link := chart.AddLink(inputNode, chartNode)
link.Shape = flowchart.LinkShapeDotted
}
}
}
// Add Edges:
out := graph.GetOutEdges()
// Sort the map keys.
var fromKeys []label.TargetLabel
for key := range out {
fromKeys = append(fromKeys, key)
}
sort.Slice(fromKeys, func(i, j int) bool {
return fromKeys[i].String() < fromKeys[j].String()
})
for _, from := range fromKeys {
toList := out[from]
// Sort each destination slice.
sort.Slice(toList, func(i, j int) bool {
return toList[i].GetLabel().String() < toList[j].GetLabel().String()
})
for _, to := range toList {
chart.AddLink(nodeMap[from.String()], nodeMap[to.GetLabel().String()])
}
}
fmt.Println(chart.String())
}
func printTree(graph *dag.DirectedTargetGraph) {
// Gather all nodes that have no dependants – they are the roots.
var roots []model.BuildNode
for _, v := range graph.GetNodes() {
if len(graph.GetDependants(v)) == 0 {
roots = append(roots, v)
}
}
// Deterministic order of roots.
sort.Slice(roots, func(i, j int) bool {
return roots[i].GetLabel().String() < roots[j].GetLabel().String()
})
for _, root := range roots {
rootTree := buildTree(root, graph, 0)
fmt.Println(rootTree)
}
}
// buildTree converts a node and all of its (transitive) dependencies into a
// Lipgloss tree.
func buildTree(
node model.BuildNode,
graph *dag.DirectedTargetGraph,
level int,
) *tree.Tree {
targetLabel := node.GetLabel().String()
t := tree.New().Root(targetLabel)
if level == 0 {
enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).MarginRight(1)
rootStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
t.RootStyle(rootStyle).ItemStyle(itemStyle).EnumeratorStyle(enumeratorStyle).Enumerator(tree.RoundedEnumerator)
}
// Deterministic output – sort the direct dependencies alphabetically.
deps := graph.GetDependencies(node)
sort.Slice(deps, func(i, j int) bool {
return deps[i].GetLabel().String() < deps[j].GetLabel().String()
})
for _, dep := range deps {
depLabel := dep.GetLabel().String()
if len(graph.GetDependencies(dep)) > 0 {
t.Child(buildTree(dep, graph, level+1))
} else {
t.Child(depLabel)
}
}
return t
}
package cmds
import (
"fmt"
"os"
"text/tabwriter"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var InfoCmd = &cobra.Command{
Use: "info",
Short: "Prints information about the grog cli and workspace.",
Long: `Displays detailed information about the grog CLI configuration, workspace settings, and cache statistics.`,
Example: ` grog info # Show all grog information
grog info --version # Show only the version information`,
Args: cobra.ArbitraryArgs, // Optional argument for target pattern
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
version := cmd.VersionTemplate()
platform := config.Global.GetPlatform()
writer := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
defer writer.Flush()
fsCache, err := backends.NewFileSystemCache(ctx)
if err != nil {
logger.Fatalf("could not instantiate cache: %v", err)
}
fmt.Fprintf(writer, "Version:\t%s\n", version)
fmt.Fprintf(writer, "Platform:\t%s\n", platform)
fmt.Fprintf(writer, "Workspace:\t%s\n", config.Global.WorkspaceRoot)
fmt.Fprintf(writer, "Workspace Cache:\t%s\n", config.Global.GetWorkspaceCacheDirectory())
fmt.Fprintf(writer, "Config:\t%s\n", viper.ConfigFileUsed())
fmt.Fprintf(writer, "Grog root:\t%s\n", config.Global.Root)
workspaceCacheSizeBytes, err := fsCache.GetWorkspaceCacheSizeBytes()
if err != nil {
logger.Fatalf("could not get workspace cache size: %v", err)
}
workspaceCacheSizeMB := float64(workspaceCacheSizeBytes) / (1024 * 1024)
fmt.Fprintf(writer, "Local workspace cache size:\t%.2f MB\n", workspaceCacheSizeMB)
totalCacheSizeBytes, err := fsCache.GetCacheSizeBytes()
if err != nil {
logger.Fatalf("could not get cache size:\t%v", err)
}
totalCacheSizeMB := float64(totalCacheSizeBytes) / (1024 * 1024)
fmt.Fprintf(writer, "Grog root size:\t%.2f MB\n", totalCacheSizeMB)
},
}
package cmds
import (
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var listOptions = struct {
targetType *flagtypes.Enum
}{
targetType: flagtypes.NewEnum("all", "test", "no_test", "bin_output"),
}
var ListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "Lists targets by pattern.",
Long: `Lists targets that match the specified pattern. If no pattern is specified only lists the targets in the current workspace. Can filter targets by type using the --target-type flag.`,
Example: ` grog list # List all targets in the current package
grog list //path/to/package:target # List a specific target
grog list //path/to/package/... # List all targets in a package and subpackages
grog list --target-type=test # List only test targets`,
Args: cobra.ArbitraryArgs, // Optional argument for target pattern
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
var targetPatterns []label.TargetPattern
if len(args) == 0 {
// Default to only showing the targets in the current package
currentPackagePattern, err := label.ParseTargetPattern(currentPackagePath, ":all")
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
targetPatterns = []label.TargetPattern{currentPackagePattern}
} else {
var err error
targetPatterns, err = label.ParsePatternsOrMatchAll(currentPackagePath, args)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
}
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
targetTypeFilter, err := selection.StringToTargetTypeSelection(listOptions.targetType.Value)
if err != nil {
logger.Fatalf(err.Error())
}
selector := selection.New(targetPatterns, config.Global.Tags, config.Global.ExcludeTags, targetTypeFilter)
selector.SelectTargets(graph)
graph.LogSelectedNodes()
},
}
var listCmdConfigured = configureListCmd()
func configureListCmd() bool {
ListCmd.Flags().Var(
listOptions.targetType,
"target-type",
"Filter targets by type (all, test, no_test, bin_output)")
return true
}
package cmds
import (
"fmt"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/logs"
"github.com/chrismatix/grog/internal/model"
"github.com/spf13/cobra"
)
var logsOptions struct {
pathOnly bool
}
var LogsCmd = &cobra.Command{
Use: "logs",
Short: "Print the latest log file for the given target.",
Long: `Displays the contents of the most recent log file for a specified target.
Use the --path-only flag to only print the path to the log file instead of its contents.`,
Example: ` grog logs //path/to/package:target # Show log contents
grog logs -p //path/to/package:target # Show only the log file path`,
Args: cobra.ExactArgs(1), // Requires exactly one target argument
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetLabel, err := label.ParseTargetLabel(currentPackagePath, args[0])
if err != nil {
logger.Fatalf("could not parse target label: %v", err)
}
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
node, hasTarget := graph.GetNodes()[targetLabel]
if !hasTarget {
logger.Fatalf("could not find target %s", targetLabel)
}
logTarget, isTarget := node.(*model.Target)
if !isTarget {
logger.Fatalf("%s is not a target", targetLabel)
}
targetLogFile := logs.NewTargetLogFile(*logTarget)
if !targetLogFile.Exists() {
logger.Fatalf("no log file found for target %s", targetLabel)
}
if logsOptions.pathOnly {
fmt.Println(targetLogFile.Path())
return
}
if err := targetLogFile.Print(); err != nil {
logger.Fatalf("could not print log file: %v", err)
}
},
}
func AddListCmd(cmd *cobra.Command) {
LogsCmd.Flags().BoolVarP(
&logsOptions.pathOnly,
"path-only",
"p",
false,
"Only print out the path of the target logs")
cmd.AddCommand(LogsCmd)
}
package cmds
import (
"slices"
"path/filepath"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/spf13/cobra"
)
var OwnersCmd = &cobra.Command{
Use: "owners",
Short: "Lists targets that own the specified files as inputs.",
Long: `Identifies and lists all targets that include the specified files as inputs.
This is useful for finding which targets will be affected by changes to specific files.`,
Example: ` grog owners path/to/file.txt # Find targets that use a specific file
grog owners path/to/file1.txt path/to/file2.txt # Find targets that use any of the specified files`,
Args: cobra.MinimumNArgs(1), // Require at least one file argument
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
nodes := graph.GetNodes()
// Resolve the input files relative to current directory
for i, arg := range args {
absPath, err := filepath.Abs(arg)
if err != nil {
logger.Fatalf("Failed to get absolute path for %s: %v", arg, err)
}
args[i] = absPath
}
inputFileMatches := func(absInputPath string) bool {
return slices.Contains(args, absInputPath)
}
// Find nodes that have any of the specified files in their inputs
var matchingTargets []*model.Target
for _, target := range nodes.GetTargets() {
for _, inputFile := range target.Inputs {
// Get the absolute path of the input file
absInputPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(
target.Label.Package,
inputFile,
))
if inputFileMatches(absInputPath) {
matchingTargets = append(matchingTargets, target)
break // Found a match, no need to check other args
}
}
}
var matchingLabels []label.TargetLabel
for _, target := range matchingTargets {
matchingLabels = append(matchingLabels, target.Label)
}
label.PrintSorted(matchingLabels)
},
}
func AddOwnersCmd(rootCmd *cobra.Command) {
rootCmd.AddCommand(OwnersCmd)
}
package cmds
import (
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var rDepsOptions = struct {
transitive bool
targetType *flagtypes.Enum
}{
targetType: flagtypes.NewEnum("all", "test", "no_test", "bin_output"),
}
var RDepsCmd = &cobra.Command{
Use: "rdeps",
Short: "Lists (transitive) dependants (reverse dependencies) of a target.",
Long: `Lists the direct or transitive dependants (reverse dependencies) of a specified target.
By default, only direct dependants are shown. Use the --transitive flag to show all transitive dependants.
Dependants can be filtered by target type using the --target-type flag.`,
Example: ` grog rdeps //path/to/package:target # Show direct dependants
grog rdeps -t //path/to/package:target # Show transitive dependants
grog rdeps --target-type=test //path/to/package:target # Show only test dependants`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
if len(args) == 0 {
logger.Fatalf("`%s` requires a target label", cmd.UseLine())
}
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetLabel, err := label.ParseTargetLabel(currentPackagePath, args[0])
if err != nil {
logger.Fatalf("could not parse target label: %v", err)
}
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
target, hasTarget := graph.GetNodes()[targetLabel]
if !hasTarget {
logger.Fatalf("could not find target %s", targetLabel)
}
var rDeps []model.BuildNode
if rDepsOptions.transitive {
rDeps = graph.GetDescendants(target)
} else {
rDeps = graph.GetDependants(target)
}
targetTypeFilter, err := selection.StringToTargetTypeSelection(rDepsOptions.targetType.Value)
if err != nil {
logger.Fatalf(err.Error())
}
selector := selection.New(nil, config.Global.Tags, config.Global.ExcludeTags, targetTypeFilter)
filteredRDeps := selector.FilterNodes(rDeps)
model.PrintSortedLabels(filteredRDeps)
},
}
func AddRDepsCmd(rootCmd *cobra.Command) {
RDepsCmd.Flags().BoolVarP(
&rDepsOptions.transitive,
"transitive",
"t",
false,
"Include all transitive dependants of the target")
RDepsCmd.Flags().Var(
rDepsOptions.targetType,
"target-type",
"Filter targets by type (all, test, no_test, bin_output)")
rootCmd.AddCommand(RDepsCmd)
}
package cmds
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/execution"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/chrismatix/grog/internal/worker"
)
var runOptions struct {
inPackage bool
}
var RunCmd = &cobra.Command{
Use: "run",
Short: "Builds and runs one or more targets' binary outputs.",
Long: `Builds targets that produce binary outputs and then executes them with the provided arguments.
Use "--" to separate the list of targets from the arguments passed to the binaries.`,
Example: ` grog run //path/to/package:target -- arg1 arg2 # Run with arguments
grog run //path/to/package:target //path:other -- # Run multiple targets
grog run -i //path/to/package:target -- arg1 arg2 # Run in the package directory`,
Args: cobra.ArbitraryArgs,
ValidArgsFunction: completions.BinaryTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
if len(args) == 0 {
logger.Fatalf("`%s` requires a target pattern", cmd.UseLine())
}
targetArgs, userCommandArgs, err := splitRunArgs(args, cmd.ArgsLenAtDash())
if err != nil {
logger.Fatalf("%v", err)
}
if len(targetArgs) == 0 {
logger.Fatalf("`%s` requires a target pattern", cmd.UseLine())
}
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
if len(targetArgs) > 1 {
targetLabels := parseMultipleTargetLabels(logger, currentPackagePath, targetArgs)
runTargetsByLabels(ctx, logger, targetLabels, userCommandArgs)
return
}
targetArg := targetArgs[0]
if targetLabel, parseErr := label.ParseTargetLabel(currentPackagePath, targetArg); parseErr == nil {
runTargetsByLabels(ctx, logger, []label.TargetLabel{targetLabel}, userCommandArgs)
return
}
if err := runScriptFile(ctx, logger, targetArg, userCommandArgs); err != nil {
logger.Fatalf("%v", err)
}
},
}
func AddRunCmd(cmd *cobra.Command) {
RunCmd.Flags().BoolVarP(&runOptions.inPackage, "in-package", "i", false, "Run the target in the package directory where it is defined.")
cmd.AddCommand(RunCmd)
}
func runScriptFile(ctx context.Context, logger *console.Logger, scriptArg string, userCommandArgs []string) error {
scriptPath, err := filepath.Abs(scriptArg)
if err != nil {
return fmt.Errorf("could not resolve script path %s: %w", scriptArg, err)
}
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("script %s: %w", scriptArg, err)
}
if _, err := config.GetPathRelativeToWorkspaceRoot(scriptPath); err != nil {
return fmt.Errorf("script %s is outside the workspace: %w", scriptArg, err)
}
target, err := loading.LoadScriptTarget(ctx, logger, scriptPath)
if err != nil {
return err
}
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
if existing := graph.GetNodes()[target.Label]; existing != nil {
return fmt.Errorf("target %s is already defined in the build graph; refer to it by label instead", target.Label)
}
graph.AddNode(target)
for _, dep := range target.Dependencies {
dependencyNode := graph.GetNodes()[dep]
if dependencyNode == nil {
return fmt.Errorf("dependency %s referenced by %s was not found", dep, target.Label)
}
if err := graph.AddEdge(dependencyNode, target); err != nil {
return err
}
}
buildAndRunTargets(ctx, logger, graph, []*model.Target{target}, userCommandArgs)
return nil
}
func buildAndRunTargets(ctx context.Context, logger *console.Logger, graph *dag.DirectedTargetGraph, runTargets []*model.Target, userCommandArgs []string) {
if len(runTargets) == 0 {
return
}
var targetPatterns []label.TargetPattern
patternSet := make(map[label.TargetPattern]struct{})
for _, runTarget := range runTargets {
pattern := label.TargetPatternFromLabel(runTarget.Label)
if _, exists := patternSet[pattern]; exists {
continue
}
patternSet[pattern] = struct{}{}
targetPatterns = append(targetPatterns, pattern)
}
afterBuildSuccess := func(executor *execution.Executor) error {
for _, runTarget := range runTargets {
loadDependencyOutputsIfNeeded(ctx, logger, executor, runTarget)
}
return runTargetBinaries(ctx, logger, runTargets, userCommandArgs)
}
RunBuildAndAfter(
ctx,
logger,
targetPatterns,
graph,
selection.NonTestOnly,
config.Global.StreamLogs,
config.Global.GetLoadOutputsMode(),
afterBuildSuccess,
"run",
)
}
func loadDependencyOutputsIfNeeded(ctx context.Context, logger *console.Logger, executor *execution.Executor, runTarget *model.Target) {
if config.Global.GetLoadOutputsMode() != config.LoadOutputsMinimal {
return
}
logger.Infof("Loading outputs of direct dependencies due to load_outputs=minimal")
if err := executor.LoadDependencyOutputs(ctx, runTarget, func(_ worker.StatusUpdate) {}); err != nil {
logger.Fatalf("could not load dependencies: %v", err)
}
}
func splitRunArgs(args []string, argsLenAtDash int) ([]string, []string, error) {
if argsLenAtDash == 0 {
return nil, nil, fmt.Errorf("expected a target pattern before '--'")
}
if argsLenAtDash > 0 {
return args[:argsLenAtDash], args[argsLenAtDash:], nil
}
// (== -1) This means that no '--' was found and we interpret all remaining args as targets
return args, nil, nil
}
func parseMultipleTargetLabels(logger *console.Logger, currentPackagePath string, targetArgs []string) []label.TargetLabel {
labels := make([]label.TargetLabel, 0, len(targetArgs))
seen := make(map[label.TargetLabel]struct{})
for _, targetArg := range targetArgs {
targetLabel, err := label.ParseTargetLabel(currentPackagePath, targetArg)
if err != nil {
logger.Fatalf("could not parse target label %s: %v. Use '--' to separate target labels from binary arguments.", targetArg, err)
}
if _, exists := seen[targetLabel]; exists {
continue
}
seen[targetLabel] = struct{}{}
labels = append(labels, targetLabel)
}
return labels
}
func runTargetsByLabels(ctx context.Context, logger *console.Logger, targetLabels []label.TargetLabel, userCommandArgs []string) {
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
runTargets := make([]*model.Target, 0, len(targetLabels))
seen := make(map[label.TargetLabel]struct{})
for _, targetLabel := range targetLabels {
runTarget := resolveRunTarget(logger, graph, targetLabel)
if _, exists := seen[runTarget.Label]; exists {
continue
}
seen[runTarget.Label] = struct{}{}
runTargets = append(runTargets, runTarget)
}
buildAndRunTargets(ctx, logger, graph, runTargets, userCommandArgs)
}
func resolveRunTarget(logger *console.Logger, graph *dag.DirectedTargetGraph, targetLabel label.TargetLabel) *model.Target {
node, hasNode := graph.GetNodes()[targetLabel]
if !hasNode {
logger.Fatalf("could not find target %s", targetLabel)
}
switch typed := node.(type) {
case *model.Target:
if !typed.HasBinOutput() {
logger.Fatalf("target %s does not have a binary output.", targetLabel)
}
checkBinaryRequiresPush(logger, typed)
return typed
case *model.Alias:
resolvedNode := graph.GetNodes()[typed.Actual]
resolvedTarget, ok := resolvedNode.(*model.Target)
if !ok {
logger.Fatalf("%s resolved from %s is not a target", targetLabel, typed.Actual)
}
if !resolvedTarget.HasBinOutput() {
logger.Fatalf("target %s does not have a binary output.", typed.Actual)
}
checkBinaryRequiresPush(logger, resolvedTarget)
return resolvedTarget
default:
logger.Fatalf("%s is not a target", targetLabel)
}
return nil
}
func checkBinaryRequiresPush(logger *console.Logger, target *model.Target) {
if target.BinaryRequiresPush && !config.Global.Push {
logger.Fatalf("target %s requires the --push flag to run", target.Label)
}
}
func runTargetBinaries(ctx context.Context, logger *console.Logger, runTargets []*model.Target, userCommandArgs []string) error {
if len(runTargets) == 0 {
return nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type binaryRun struct {
target *model.Target
cmd *exec.Cmd
}
// For each target, create a new Cmd and run it in a goroutine.
runs := make([]binaryRun, 0, len(runTargets))
for _, runTarget := range runTargets {
cmd := newBinaryRunCommand(ctx, runTarget, userCommandArgs)
if runOptions.inPackage {
logger.Infof("Running %s -> %s with args %s in package directory", runTarget.Label, runTarget.BinOutput.Identifier, userCommandArgs)
} else {
logger.Infof("Running %s -> %s with args %s", runTarget.Label, runTarget.BinOutput.Identifier, userCommandArgs)
}
runs = append(runs, binaryRun{target: runTarget, cmd: cmd})
}
errCh := make(chan error, len(runs))
var wg sync.WaitGroup
for _, run := range runs {
wg.Add(1)
go func(run binaryRun) {
defer wg.Done()
if err := run.cmd.Run(); err != nil {
errCh <- fmt.Errorf("failed to run %s: %w", run.target.Label, err)
cancel()
}
}(run)
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
if len(errs) == 0 {
return nil
}
if len(runTargets) == 1 {
return errs[0]
}
return fmt.Errorf("multiple binaries failed: %w", errors.Join(errs...))
}
func newBinaryRunCommand(ctx context.Context, runTarget *model.Target, userCommandArgs []string) *exec.Cmd {
binOutputPath := config.GetPathAbsoluteToWorkspaceRoot(
filepath.Join(runTarget.Label.Package, runTarget.BinOutput.Identifier),
)
runCommand := exec.CommandContext(ctx, binOutputPath, userCommandArgs...)
runCommand.Env = execution.GetExtendedTargetEnv(ctx, runTarget)
runCommand.Stdout = os.Stdout
runCommand.Stderr = os.Stderr
runCommand.Stdin = os.Stdin
if runOptions.inPackage {
packagePath := config.GetPathAbsoluteToWorkspaceRoot(runTarget.Label.Package)
runCommand.Dir = packagePath
}
return runCommand
}
package cmds
import (
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var TaintCmd = &cobra.Command{
Use: "taint",
Short: "Taints targets by pattern to force execution regardless of cache status.",
Long: `Marks specified targets as "tainted", which forces them to be rebuilt on the next build command,
regardless of whether they would normally be considered up-to-date according to the cache.
This is useful when you want to force a rebuild of specific targets.`,
Example: ` grog taint //path/to/package:target # Taint a specific target
grog taint //path/to/package/... # Taint all targets in a package and subpackages
grog taint //path/to/package:* # Taint all targets in a package`,
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: completions.AllTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetPatterns, err := label.ParsePatternsOrMatchAll(currentPackagePath, args)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
graph := loading.MustLoadGraphForQuery(ctx, logger, dependencyInferrer())
selector := selection.New(targetPatterns, config.Global.Tags, config.Global.ExcludeTags, selection.AllTargets)
selector.SelectTargets(graph)
selectedNodes := graph.GetSelectedNodes()
taintStore := caching.NewTaintStore()
taintedCount := 0
for _, node := range selectedNodes {
target, ok := node.(*model.Target)
if !ok {
continue
}
err := taintStore.Taint(ctx, target.Label)
if err != nil {
logger.Errorf("Failed to taint target %s: %v", target.Label, err)
continue
}
taintedCount++
logger.Debugf("Tainted target: %s", target.Label)
}
logger.Infof("Tainted %s", console.FCountTargets(taintedCount))
},
}
package cmds
import (
"github.com/chrismatix/grog/internal/completions"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/execution"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
var TestCmd = &cobra.Command{
Use: "test",
Short: "Loads the user configuration and executes test targets.",
Long: `Loads the user configuration, checks which targets need to be rebuilt based on file hashes, builds the dependency graph, and executes test targets.
Use "--" to separate the list of targets from additional arguments passed to the target commands.`,
Example: ` grog test # Run all tests in the current package and subpackages
grog test //path/to/package:test # Run a specific test
grog test //path/to/package/... # Run all tests in a package and subpackages
grog test //path/to/package:test -- -k test_foo # Pass extra arguments to the test command`,
Args: cobra.ArbitraryArgs, // Optional argument for target pattern
ValidArgsFunction: completions.TestTargetPatternCompletion,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
// Split target patterns from extra args at "--". Unlike grog run,
// grog test allows omitting the target pattern (defaults to current
// package), so "grog test -- -x" is valid.
var targetArgs, extraArgs []string
if dash := cmd.ArgsLenAtDash(); dash == 0 {
// "--" is the first argument: no targets, everything is extra args
extraArgs = args
} else if dash > 0 {
targetArgs = args[:dash]
extraArgs = args[dash:]
} else {
// No "--" found: all args are target patterns
targetArgs = args
}
currentPackagePath, err := config.Global.GetCurrentPackage()
if err != nil {
logger.Fatalf("could not get current package: %v", err)
}
targetPatterns, err := label.ParsePatternsOrMatchCurrentPackageAndSubpackages(currentPackagePath, targetArgs)
if err != nil {
logger.Fatalf("could not parse target pattern: %v", err)
}
if len(extraArgs) > 0 {
ctx = execution.WithExtraArgs(ctx, extraArgs)
}
graph := loading.MustLoadGraphForBuild(ctx, logger, dependencyInferrer())
RunBuild(
ctx,
logger,
targetPatterns,
graph,
selection.TestOnly,
config.Global.StreamLogs,
config.Global.GetLoadOutputsMode(),
)
},
}
func AddTestCmd(rootCmd *cobra.Command) {
rootCmd.AddCommand(TestCmd)
}
package traces
import (
"io"
"os"
"time"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var (
exportFormat = flagtypes.NewEnum("jsonl", "otel")
exportOutput string
exportLimit int
exportSince string
)
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export traces for dashboard integration.",
Example: ` grog traces export --format=jsonl
grog traces export --format=otel --output traces.json`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
opts := tracing.ListOptions{Limit: exportLimit}
if exportSince != "" {
sinceTime, err := time.Parse("2006-01-02", exportSince)
if err != nil {
logger.Fatalf("invalid --since date: %v", err)
}
opts.Since = &sinceTime
}
entries, err := store.List(ctx, opts)
if err != nil {
logger.Fatalf("failed to list traces: %v", err)
}
if len(entries) == 0 {
logger.Info("No traces to export.")
return
}
var w io.Writer = os.Stdout
var outputFile *os.File
if exportOutput != "" {
var openErr error
outputFile, openErr = os.Create(exportOutput)
if openErr != nil {
logger.Fatalf("failed to create output file: %v", openErr)
}
w = outputFile
}
switch exportFormat.Value {
case "jsonl":
if err := tracing.ExportJSONL(ctx, store, entries, w); err != nil {
logger.Fatalf("export failed: %v", err)
}
case "otel":
if err := tracing.ExportOTLP(ctx, store, entries, w); err != nil {
logger.Fatalf("export failed: %v", err)
}
}
if outputFile != nil {
if err := outputFile.Close(); err != nil {
logger.Fatalf("failed to close output file: %v", err)
}
}
},
}
func registerExportCmd() {
exportCmd.Flags().Var(exportFormat, "format", "Export format: jsonl or otel")
exportCmd.Flags().StringVar(&exportOutput, "output", "", "Output file (default: stdout)")
exportCmd.Flags().IntVar(&exportLimit, "limit", 0, "Maximum number of traces to export (0 = all)")
exportCmd.Flags().StringVar(&exportSince, "since", "", "Only export traces after this date (YYYY-MM-DD)")
Cmd.AddCommand(exportCmd)
}
package traces
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var (
listLimit int
listSince string
listCommand = flagtypes.NewEnum("", "build", "test", "run")
listFailuresOnly bool
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List recent build traces.",
Example: ` grog traces list
grog traces list --limit 50
grog traces list --since 2026-03-01 --command build
grog traces list --failures-only`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
command, err := normalizeCommand(listCommand.Value)
if err != nil {
logger.Fatalf("%v", err)
}
opts := tracing.ListOptions{
Limit: listLimit,
Command: command,
FailuresOnly: listFailuresOnly,
}
if listSince != "" {
sinceTime, err := time.Parse("2006-01-02", listSince)
if err != nil {
logger.Fatalf("invalid --since date: %v (use YYYY-MM-DD format)", err)
}
opts.Since = &sinceTime
}
entries, err := store.List(ctx, opts)
if err != nil {
logger.Fatalf("failed to list traces: %v", err)
}
if len(entries) == 0 {
logger.Info("No traces found.")
return
}
headers := []string{"TRACE ID", "DATE", "CMD", "TARGETS", "HITS", "FAILS", "DURATION", "COMMIT"}
var rows [][]string
for _, e := range entries {
t := time.UnixMilli(e.StartTimeUnixMillis)
shortID := e.TraceID
if len(shortID) > 8 {
shortID = shortID[:8]
}
commit := e.GitCommit
if len(commit) > 7 {
commit = commit[:7]
}
fails := fmt.Sprintf("%d", e.FailureCount)
if styled() && e.FailureCount > 0 {
fails = statsBadStyle.Render(fails)
}
rows = append(rows, []string{
shortID,
t.Format("2006-01-02"),
e.Command,
fmt.Sprintf("%d", e.TotalTargets),
fmt.Sprintf("%d", e.CacheHitCount),
fails,
formatDuration(time.Duration(e.TotalDurationMillis) * time.Millisecond),
renderDim(commit),
})
}
if styled() {
t := table.New().
Headers(headers...).
Rows(rows...).
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return lipgloss.NewStyle()
})
fmt.Println(t.Render())
} else {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
w.Flush()
}
},
}
func registerListCmd() {
listCmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum number of traces to display")
listCmd.Flags().StringVar(&listSince, "since", "", "Only show traces after this date (YYYY-MM-DD)")
listCmd.Flags().Var(listCommand, "command", "Filter by command type (build, test, run)")
listCmd.Flags().BoolVar(&listFailuresOnly, "failures-only", false, "Only show traces with failures")
Cmd.AddCommand(listCmd)
}
package traces
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/console"
)
var pruneOlderThan string
var pruneCmd = &cobra.Command{
Use: "prune",
Short: "Delete traces older than a specified duration.",
Example: ` grog traces prune --older-than 30d
grog traces prune --older-than 7d`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
duration, err := parseDuration(pruneOlderThan)
if err != nil {
logger.Fatalf("invalid --older-than value: %v", err)
}
cutoff := time.Now().Add(-duration)
pruned, pruneErr := store.Prune(ctx, cutoff)
if pruneErr != nil {
logger.Fatalf("prune failed: %v", pruneErr)
}
logger.Infof("Pruned %d traces older than %s.", pruned, pruneOlderThan)
},
}
func registerPruneCmd() {
pruneCmd.Flags().StringVar(&pruneOlderThan, "older-than", "30d", "Delete traces older than this duration (e.g. 30d, 72h)")
Cmd.AddCommand(pruneCmd)
}
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, fmt.Errorf("duration too short: %q", s)
}
unit := s[len(s)-1]
valueStr := s[:len(s)-1]
var value int
if _, err := fmt.Sscanf(valueStr, "%d", &value); err != nil {
return 0, fmt.Errorf("invalid duration value: %q", s)
}
switch unit {
case 'd':
return time.Duration(value) * 24 * time.Hour, nil
case 'h':
return time.Duration(value) * time.Hour, nil
default:
return 0, fmt.Errorf("unknown unit %q in %q (use d for days, h for hours)", string(unit), s)
}
}
package traces
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var pullCmd = &cobra.Command{
Use: "pull",
Short: "Download remote traces to local cache for querying.",
Example: ` grog traces pull`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
var onProgress tracing.PullProgress
var teardown func()
if console.UseTea() {
teaCtx, program, sendMsg := console.StartTaskUI(ctx)
ctx = teaCtx
startedAt := time.Now().Unix()
onProgress = func(current, total int) {
sendMsg(console.TaskStateMsg{
State: console.TaskStateMap{
0: console.TaskState{
Status: fmt.Sprintf("Pulling traces (%d/%d)", current, total),
StartedAtSec: startedAt,
Progress: &console.Progress{
StartedAtSec: startedAt,
Current: int64(current),
Total: int64(total),
Unit: console.ProgressUnitCount,
},
},
},
})
}
teardown = func() {
program.Quit()
_ = program.ReleaseTerminal()
}
}
pulled, err := store.Pull(ctx, onProgress)
if teardown != nil {
teardown()
}
if err != nil {
logger.Fatalf("pull failed: %v", err)
}
logger.Infof("Pulled %d remote trace files.", pulled)
},
}
func registerPullCmd() {
Cmd.AddCommand(pullCmd)
}
package traces
import (
"fmt"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/chrismatix/grog/internal/console"
)
// styled returns true if lipgloss rendering should be used.
func styled() bool {
return console.UseTea()
}
var (
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).MarginTop(1)
hintStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("241"))
impactHighStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // red
impactMedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // orange
impactLowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")) // yellow
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
statsTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
statsLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("248")).Width(16)
statsValueStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
statsGoodStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) // green
statsWarnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) // orange
statsBadStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")) // red
)
func renderImpact(impact float64) string {
bar := fmt.Sprintf("%.0fms", impact)
if !styled() {
return bar
}
if impact > 10000 {
return impactHighStyle.Render(bar)
} else if impact > 3000 {
return impactMedStyle.Render(bar)
}
return impactLowStyle.Render(bar)
}
func renderSection(title string) string {
if styled() {
return sectionStyle.Render(title)
}
return "\n" + title
}
func renderHint(text string) string {
if styled() {
return hintStyle.Render(" " + text)
}
return " " + text
}
func renderLabel(label string) string {
if styled() {
return labelStyle.Render(label)
}
return label
}
func renderDim(text string) string {
if styled() {
return dimStyle.Render(text)
}
return text
}
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}
func formatMillis(ms int64) string {
return formatDuration(time.Duration(ms) * time.Millisecond)
}
package traces
import (
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var (
showSortBy = flagtypes.NewEnum("total", "command", "queue", "hash")
showTop int
)
var showCmd = &cobra.Command{
Use: "show <trace-id>",
Short: "Show details of a specific trace.",
Example: ` grog traces show a1b2c3d4
grog traces show a1b2c3d4 --sort-by command --top 10`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
trace, err := store.FindAndLoad(ctx, args[0])
if err != nil {
logger.Fatalf("failed to load trace: %v", err)
}
sortSpans(trace.Spans, showSortBy.Value)
printBuildSummary(&trace.Build)
printSpanTable(trace.Spans)
},
}
func registerShowCmd() {
showCmd.Flags().Var(showSortBy, "sort-by", "Sort targets by: total, command, queue, hash")
showCmd.Flags().IntVar(&showTop, "top", 0, "Show only the N slowest targets (0 = all)")
Cmd.AddCommand(showCmd)
}
func sortSpans(spans []tracing.SpanRow, sortBy string) {
switch sortBy {
case "command":
sort.Slice(spans, func(i, j int) bool {
return spans[i].CommandDurationMillis > spans[j].CommandDurationMillis
})
case "queue":
sort.Slice(spans, func(i, j int) bool {
return spans[i].QueueWaitMillis > spans[j].QueueWaitMillis
})
case "hash":
sort.Slice(spans, func(i, j int) bool {
return spans[i].HashDurationMillis > spans[j].HashDurationMillis
})
default: // "total"
sort.Slice(spans, func(i, j int) bool {
return spans[i].TotalDurationMillis > spans[j].TotalDurationMillis
})
}
}
func printBuildSummary(b *tracing.BuildRow) {
t := time.UnixMilli(b.StartTimeUnixMillis)
duration := formatDuration(time.Duration(b.TotalDurationMillis) * time.Millisecond)
failures := fmt.Sprintf("%d", b.FailureCount)
if styled() {
label := statsLabelStyle.Width(12)
val := statsValueStyle
if b.FailureCount > 0 {
failures = statsBadStyle.Render(failures)
} else {
failures = statsGoodStyle.Render(failures)
}
fmt.Printf("%s %s\n", label.Render("Trace:"), renderDim(b.TraceID))
fmt.Printf("%s %s\n", label.Render("Date:"), val.Render(t.Format("2006-01-02 15:04:05")))
fmt.Printf("%s %s\n", label.Render("Command:"), val.Render(b.Command))
fmt.Printf("%s %s\n", label.Render("Version:"), renderDim(b.GrogVersion))
fmt.Printf("%s %s\n", label.Render("Platform:"), renderDim(b.Platform))
fmt.Printf("%s %s\n", label.Render("Commit:"), renderDim(b.GitCommit))
fmt.Printf("%s %s\n", label.Render("Branch:"), val.Render(b.GitBranch))
fmt.Printf("%s %s\n", label.Render("Duration:"), val.Render(duration))
fmt.Printf("%s %s (%s cache hits, %s failures)\n",
label.Render("Targets:"),
val.Render(fmt.Sprintf("%d", b.TotalTargets)),
statsGoodStyle.Render(fmt.Sprintf("%d", b.CacheHitCount)),
failures)
if b.CriticalPathExecMillis > 0 || b.CriticalPathCacheMillis > 0 {
fmt.Printf("%s exec %s, cache %s\n",
label.Render("Critical:"),
val.Render(formatDuration(time.Duration(b.CriticalPathExecMillis)*time.Millisecond)),
val.Render(formatDuration(time.Duration(b.CriticalPathCacheMillis)*time.Millisecond)))
}
if b.RequestedPatterns != "" {
fmt.Printf("%s %s\n", label.Render("Patterns:"), renderDim(strings.ReplaceAll(b.RequestedPatterns, ",", ", ")))
}
} else {
fmt.Printf("Trace: %s\n", b.TraceID)
fmt.Printf("Date: %s\n", t.Format("2006-01-02 15:04:05"))
fmt.Printf("Command: %s\n", b.Command)
fmt.Printf("Version: %s\n", b.GrogVersion)
fmt.Printf("Platform: %s\n", b.Platform)
fmt.Printf("Commit: %s\n", b.GitCommit)
fmt.Printf("Branch: %s\n", b.GitBranch)
fmt.Printf("Duration: %s\n", duration)
fmt.Printf("Targets: %d (%d cache hits, %s failures)\n",
b.TotalTargets, b.CacheHitCount, failures)
if b.CriticalPathExecMillis > 0 || b.CriticalPathCacheMillis > 0 {
fmt.Printf("Critical: exec %s, cache %s\n",
formatDuration(time.Duration(b.CriticalPathExecMillis)*time.Millisecond),
formatDuration(time.Duration(b.CriticalPathCacheMillis)*time.Millisecond))
}
if b.RequestedPatterns != "" {
fmt.Printf("Patterns: %s\n", strings.ReplaceAll(b.RequestedPatterns, ",", ", "))
}
}
fmt.Println()
}
func printSpanTable(spans []tracing.SpanRow) {
if len(spans) == 0 {
return
}
displaySpans := spans
if showTop > 0 && len(displaySpans) > showTop {
displaySpans = displaySpans[:showTop]
}
headers := []string{"TARGET", "STATUS", "CACHE", "TOTAL", "CMD", "HASH", "I/O", "QUEUE"}
var rows [][]string
for _, s := range displaySpans {
status := "ok"
switch s.Status {
case "FAILURE":
status = "FAIL"
case "CANCELLED":
status = "skip"
}
cache := "miss"
switch s.CacheResult {
case "CACHE_HIT":
cache = "hit"
case "CACHE_SKIP":
cache = "skip"
}
ioMillis := s.OutputWriteMillis + s.OutputLoadMillis + s.CacheWriteMillis + s.DepLoadMillis
row := []string{
s.Label, status, cache,
formatMillis(s.TotalDurationMillis),
formatMillis(s.CommandDurationMillis),
formatMillis(s.HashDurationMillis),
formatMillis(ioMillis),
formatMillis(s.QueueWaitMillis),
}
if styled() {
row[0] = renderLabel(s.Label)
if status == "FAIL" {
row[1] = impactHighStyle.Render(status)
}
if cache == "hit" {
row[2] = dimStyle.Render(cache)
}
}
rows = append(rows, row)
}
if styled() {
t := table.New().
Headers(headers...).
Rows(rows...).
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return lipgloss.NewStyle()
})
fmt.Println(t.Render())
} else {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
w.Flush()
}
}
package traces
import (
"fmt"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var (
statsLimit int
statsCommandType = flagtypes.NewEnum("all", "build", "test")
statsCI = flagtypes.NewEnum("all", "true", "false")
statsDetailed bool
)
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show aggregate statistics across recent traces.",
Example: ` grog traces stats
grog traces stats --command-type build
grog traces stats --ci true
grog traces stats --detailed --command-type test`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
ctx, logger := console.SetupCommand()
store := getStore(ctx, logger)
defer store.Close()
command, err := normalizeStatsCommandType(statsCommandType.Value)
if err != nil {
logger.Fatalf("%v", err)
}
isCI, err := normalizeStatsCI(statsCI.Value)
if err != nil {
logger.Fatalf("%v", err)
}
statsOptions := tracing.StatsOptions{
Limit: statsLimit,
Command: command,
IsCI: isCI,
}
stats, err := store.Stats(ctx, statsOptions)
if err != nil {
logger.Fatalf("failed to compute stats: %v", err)
}
if stats.TraceCount == 0 {
logger.Info("No traces found.")
return
}
printStatsSummary(stats)
if statsDetailed {
report, err := store.Bottlenecks(ctx, statsOptions)
if err != nil {
logger.Fatalf("failed to compute bottleneck analysis: %v", err)
}
printBottleneckReport(report)
}
},
}
func registerStatsCmd() {
statsCmd.Flags().IntVar(&statsLimit, "limit", 20, "Number of recent traces to aggregate")
statsCmd.Flags().Var(statsCommandType, "command-type", "Filter by build command type (build, test, all)")
statsCmd.Flags().Var(statsCI, "ci", "Filter by CI origin (true, false, all)")
statsCmd.Flags().BoolVar(&statsDetailed, "detailed", false, "Load full traces for per-target analysis")
Cmd.AddCommand(statsCmd)
}
func printStatsSummary(stats *tracing.TraceStats) {
duration := formatDuration(time.Duration(stats.AvgDuration) * time.Millisecond)
hitRate := fmt.Sprintf("%.1f%%", stats.CacheHitRate)
failures := fmt.Sprintf("%d", stats.TotalFails)
if styled() {
title := statsTitleStyle.Render(fmt.Sprintf("Stats over last %d traces:", stats.TraceCount))
var hitRateStyled string
if stats.CacheHitRate >= 70 {
hitRateStyled = statsGoodStyle.Render(hitRate)
} else if stats.CacheHitRate >= 40 {
hitRateStyled = statsWarnStyle.Render(hitRate)
} else {
hitRateStyled = statsBadStyle.Render(hitRate)
}
var failuresStyled string
if stats.TotalFails == 0 {
failuresStyled = statsGoodStyle.Render(failures)
} else {
failuresStyled = statsBadStyle.Render(failures)
}
fmt.Println(title)
fmt.Printf(" %s %s\n", statsLabelStyle.Render("Avg duration:"), statsValueStyle.Render(duration))
fmt.Printf(" %s %s\n", statsLabelStyle.Render("Cache hit rate:"), hitRateStyled)
fmt.Printf(" %s %s\n", statsLabelStyle.Render("Total failures:"), failuresStyled)
} else {
fmt.Printf("Stats over last %d traces:\n", stats.TraceCount)
fmt.Printf(" Avg duration: %s\n", duration)
fmt.Printf(" Cache hit rate: %s\n", hitRate)
fmt.Printf(" Total failures: %s\n", failures)
}
}
func printBottleneckReport(r *tracing.BottleneckReport) {
if len(r.SlowestTargets) > 0 {
fmt.Println(renderSection("Highest impact targets (avg duration x frequency):"))
if styled() {
printBottleneckTable(r.SlowestTargets, func(t tracing.TargetBottleneck) []string {
return []string{
renderImpact(t.Impact),
renderLabel(t.Label),
formatMillis(int64(t.AvgCmd)),
fmt.Sprintf("%.0f%%", t.Frequency*100),
fmt.Sprintf("%d", t.Count),
}
}, []string{"IMPACT", "TARGET", "AVG CMD", "FREQ", "N"})
} else {
for _, t := range r.SlowestTargets {
fmt.Printf(" %s %s (avg: %s, freq: %.0f%%, n=%d)\n",
renderImpact(t.Impact), t.Label, formatMillis(int64(t.AvgCmd)),
t.Frequency*100, t.Count)
}
}
}
if len(r.QueueSaturated) > 0 {
fmt.Println(renderSection("Worker pool saturation (avg queue wait > 500ms):"))
if styled() {
printBottleneckTable(r.QueueSaturated, func(t tracing.TargetBottleneck) []string {
return []string{
formatMillis(int64(t.AvgQueue)),
renderLabel(t.Label),
fmt.Sprintf("%d", t.Count),
}
}, []string{"AVG QUEUE", "TARGET", "N"})
} else {
for _, t := range r.QueueSaturated {
fmt.Printf(" %s %s (n=%d)\n", formatMillis(int64(t.AvgQueue)), t.Label, t.Count)
}
}
fmt.Println(renderHint("consider increasing num_workers"))
}
if len(r.IOBottlenecks) > 0 {
fmt.Println(renderSection("I/O bottlenecks (avg I/O time > 1s):"))
if styled() {
printBottleneckTable(r.IOBottlenecks, func(t tracing.TargetBottleneck) []string {
return []string{
formatMillis(int64(t.AvgIO)),
renderLabel(t.Label),
formatMillis(int64(t.AvgOutputWrite)),
formatMillis(int64(t.AvgOutputLoad)),
formatMillis(int64(t.AvgCacheWrite)),
fmt.Sprintf("%d", t.Count),
}
}, []string{"AVG I/O", "TARGET", "WRITE", "LOAD", "CACHE", "N"})
} else {
for _, t := range r.IOBottlenecks {
fmt.Printf(" %s %s (write: %s, load: %s, cache: %s, n=%d)\n",
formatMillis(int64(t.AvgIO)), t.Label,
formatMillis(int64(t.AvgOutputWrite)),
formatMillis(int64(t.AvgOutputLoad)),
formatMillis(int64(t.AvgCacheWrite)),
t.Count)
}
}
}
if len(r.SlowHashing) > 0 {
fmt.Println(renderSection("Slow hashing (avg hash time > 200ms):"))
if styled() {
printBottleneckTable(r.SlowHashing, func(t tracing.TargetBottleneck) []string {
return []string{
formatMillis(int64(t.AvgHash)),
renderLabel(t.Label),
fmt.Sprintf("%d", t.Count),
}
}, []string{"AVG HASH", "TARGET", "N"})
} else {
for _, t := range r.SlowHashing {
fmt.Printf(" %s %s (n=%d)\n", formatMillis(int64(t.AvgHash)), t.Label, t.Count)
}
}
fmt.Println(renderHint("reduce input glob scope or split into smaller targets"))
}
if len(r.FrequentMisses) > 0 {
fmt.Println(renderSection(fmt.Sprintf("Frequent cache misses (>%.0f%% miss rate, overall avg: %.0f%%):",
r.OverallCacheMissRate+30, r.OverallCacheMissRate)))
if styled() {
printBottleneckTable(r.FrequentMisses, func(t tracing.TargetBottleneck) []string {
return []string{
fmt.Sprintf("%.0f%%", t.MissRate),
renderLabel(t.Label),
fmt.Sprintf("%d", t.Count),
}
}, []string{"MISS RATE", "TARGET", "N"})
} else {
for _, t := range r.FrequentMisses {
fmt.Printf(" %.0f%% %s (n=%d)\n", t.MissRate, t.Label, t.Count)
}
}
}
if len(r.FlakyTargets) > 0 {
fmt.Println(renderSection("Frequently failing targets:"))
if styled() {
printBottleneckTable(r.FlakyTargets, func(t tracing.TargetBottleneck) []string {
return []string{
fmt.Sprintf("%d/%d", t.Failures, t.Count),
renderLabel(t.Label),
}
}, []string{"FAILS", "TARGET"})
} else {
for _, t := range r.FlakyTargets {
fmt.Printf(" %d/%d %s\n", t.Failures, t.Count, t.Label)
}
}
}
}
func printBottleneckTable(items []tracing.TargetBottleneck, rowFn func(tracing.TargetBottleneck) []string, headers []string) {
var rows [][]string
for _, item := range items {
rows = append(rows, rowFn(item))
}
t := table.New().
Headers(headers...).
Rows(rows...).
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return lipgloss.NewStyle()
})
fmt.Println(t.Render())
}
package traces
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/tracing"
)
var Cmd = &cobra.Command{
Use: "traces",
Short: "View and manage build execution traces.",
Long: `View, analyze, and export build execution traces for performance analysis and dashboard integration.`,
}
func AddCmd(rootCmd *cobra.Command) {
registerListCmd()
registerShowCmd()
registerStatsCmd()
registerPullCmd()
registerExportCmd()
registerPruneCmd()
rootCmd.AddCommand(Cmd)
}
func getStore(ctx context.Context, logger *console.Logger) *tracing.TraceStore {
cacheConfig := config.Global.Cache
if config.Global.Traces.Backend != "" {
cacheConfig = config.CacheConfig{
Backend: config.Global.Traces.Backend,
GCS: config.Global.Traces.GCS,
S3: config.Global.Traces.S3,
}
}
cache, err := backends.GetCacheBackend(ctx, cacheConfig)
if err != nil {
logger.Fatalf("could not instantiate cache backend for traces: %v", err)
}
resolver := tracing.NewPathResolver()
store, err := tracing.NewTraceStore(cache, resolver)
if err != nil {
logger.Fatalf("could not initialize trace store: %v", err)
}
return store
}
func normalizeCommand(command string) (string, error) {
switch command {
case "":
return "", nil
case "build", "test", "run":
return command, nil
default:
return "", fmt.Errorf("invalid command %q (use build, test, or run)", command)
}
}
func normalizeStatsCommandType(commandType string) (string, error) {
switch commandType {
case "", "all":
return "", nil
case "build", "test":
return commandType, nil
default:
return "", fmt.Errorf("invalid command type %q (use build, test, or all)", commandType)
}
}
func normalizeStatsCI(ciValue string) (*bool, error) {
switch ciValue {
case "", "all":
return nil, nil
case "true":
isCI := true
return &isCI, nil
case "false":
isCI := false
return &isCI, nil
default:
return nil, fmt.Errorf("invalid ci filter %q (use true, false, or all)", ciValue)
}
}
package cmds
import (
"fmt"
"github.com/spf13/cobra"
)
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Print the version info.",
Long: `Displays the current version of the grog CLI tool.`,
Example: ` grog version # Show the version information`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(cmd.VersionTemplate())
},
}
// Package flagtypes provides reusable pflag value types.
package flagtypes
import (
"fmt"
"slices"
"strings"
)
// Enum is a string flag value restricted to a fixed set of values.
type Enum struct {
Value string
Allowed []string
}
// NewEnum creates an enum whose default is also its first allowed value.
func NewEnum(defaultValue string, additionalAllowed ...string) *Enum {
allowed := append([]string{defaultValue}, additionalAllowed...)
return &Enum{Value: defaultValue, Allowed: allowed}
}
// String returns the current value.
func (enum *Enum) String() string {
return enum.Value
}
// Set validates and stores a value.
func (enum *Enum) Set(value string) error {
if !slices.Contains(enum.Allowed, value) {
return fmt.Errorf("must be one of: %s", strings.Join(enum.Allowed, ", "))
}
enum.Value = value
return nil
}
// Type returns the value type displayed by pflag.
func (enum *Enum) Type() string {
return "string"
}
package cmd
import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/chrismatix/grog/internal/cmd/cmds"
"github.com/chrismatix/grog/internal/cmd/cmds/traces"
"github.com/chrismatix/grog/internal/cmd/flagtypes"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/subosito/gotenv"
)
var Version string
var RootCmd = &cobra.Command{
Use: "grog",
// PersistentPreRunE runs before any subcommand's Run, after flags are parsed.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("help") || cmd.Flags().Changed("version") ||
cmd.Name() == "help" || isCompletionCmd(cmd) {
return nil
}
workspaceRoot := config.MustFindWorkspaceRoot()
viper.Set("workspace_root", workspaceRoot)
viper.AddConfigPath(workspaceRoot)
// Initialize config (read file, env, flags)
if err := initConfig(cmd); err != nil {
return err
}
if err := config.Global.Validate(); err != nil {
return err
}
if !console.UseTea() {
config.Global.DisableProgressTracker = true
}
if err := config.Global.ValidateGrogVersion(Version); err != nil {
console.InitLogger().Fatalf("Invalid grog version: %v", err)
}
return nil
},
}
// isCompletionCmd reports whether cmd is the `completion` command or one of
// its subcommands (bash, zsh, fish, powershell).
func isCompletionCmd(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Name() == "completion" {
return true
}
}
return false
}
// Stamp sets the data for the version command.
func Stamp(version string, commit string, buildDate string) {
RootCmd.Version = version
Version = version
cmds.GrogVersion = version
RootCmd.SetVersionTemplate(fmt.Sprintf(
"%s (%s) built on %s",
version,
commit,
buildDate,
))
}
var rootConfigured = configureRoot()
func configureRoot() bool {
cobra.OnInitialize()
RootCmd.InitDefaultCompletionCmd()
RootCmd.CompletionOptions.DisableDefaultCmd = false
// Set up Viper
viper.SetConfigType("toml")
viper.SetEnvPrefix("GROG")
viper.AddConfigPath("$HOME/.grog") // optionally look for config in the home directory
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) // allow FLAG-NAME to map to ENV VAR_NAME
viper.AutomaticEnv() // read in environment variables that match
// Set default global root directory
viper.SetDefault("root", filepath.Join(os.Getenv("HOME"), ".grog"))
// Options:
// color
RootCmd.PersistentFlags().Var(flagtypes.NewEnum("auto", "yes", "no"), "color", "Set color output (yes, no, or auto)")
_ = viper.BindPFlag("color", RootCmd.PersistentFlags().Lookup("color"))
viper.SetDefault("color", "auto")
// debug
RootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging")
_ = viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug"))
RootCmd.PersistentFlags().CountP("verbose", "v", "Set verbosity level (-v, -vv)")
_ = viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose"))
// log_level
RootCmd.PersistentFlags().Var(flagtypes.NewEnum("", "trace", "debug", "info", "warn", "error"), "log-level", "Set log level (trace, debug, info, warn, error)")
_ = viper.BindPFlag("log_level", RootCmd.PersistentFlags().Lookup("log-level"))
// fail_fast
RootCmd.PersistentFlags().Bool("fail-fast", false, "Fail fast on first error")
_ = viper.BindPFlag("fail_fast", RootCmd.PersistentFlags().Lookup("fail-fast"))
// push (used by grog build and grog run)
RootCmd.PersistentFlags().Bool("push", false, "Push oci:: outputs declared in target.oci_push to their remote destinations after a successful build")
_ = viper.BindPFlag("push", RootCmd.PersistentFlags().Lookup("push"))
// skip_workspace_lock
RootCmd.PersistentFlags().Bool("skip-workspace-lock", false, "Skip the workspace level lock (DANGEROUS: may corrupt the cache)")
_ = viper.BindPFlag("skip_workspace_lock", RootCmd.PersistentFlags().Lookup("skip-workspace-lock"))
// all_platforms
RootCmd.PersistentFlags().BoolP("all-platforms", "a", false, "Select all platforms (bypasses platform selectors)")
_ = viper.BindPFlag("all_platforms", RootCmd.PersistentFlags().Lookup("all-platforms"))
// platform
RootCmd.PersistentFlags().String("platform", "", "Force a specific platform in the form os/arch")
_ = viper.BindPFlag("platform", RootCmd.PersistentFlags().Lookup("platform"))
// platform_tag
RootCmd.PersistentFlags().StringSlice("platform-tag", []string{}, "Enable a custom platform tag for matching targets' platform selectors. Can be used multiple times.")
_ = viper.BindPFlag("platform_tag", RootCmd.PersistentFlags().Lookup("platform-tag"))
// stream_logs
RootCmd.PersistentFlags().Bool("stream-logs", false, "Forward all target build/test logs to stdout/-err")
_ = viper.BindPFlag("stream_logs", RootCmd.PersistentFlags().Lookup("stream-logs"))
// output_mode
RootCmd.PersistentFlags().Var(flagtypes.NewEnum("terse", "detailed"), "output-mode", "Build output style: terse (one line per target) or detailed (stream each target's lifecycle)")
_ = viper.BindPFlag("output_mode", RootCmd.PersistentFlags().Lookup("output-mode"))
viper.SetDefault("output_mode", "terse")
// disable_progress_tracker
RootCmd.PersistentFlags().Bool("disable-progress-tracker", false, "Disable progress tracking updates")
_ = viper.BindPFlag("disable_progress_tracker", RootCmd.PersistentFlags().Lookup("disable-progress-tracker"))
viper.SetDefault("disable_progress_tracker", false)
// disable_default_shell_flags
RootCmd.PersistentFlags().Bool("disable-default-shell-flags", false, "Do not prepend \"set -eu\" to target commands")
_ = viper.BindPFlag("disable_default_shell_flags", RootCmd.PersistentFlags().Lookup("disable-default-shell-flags"))
viper.SetDefault("disable_default_shell_flags", false)
// load_outputs
RootCmd.PersistentFlags().Var(flagtypes.NewEnum("all", "minimal"), "load-outputs", "Level of output loading for cached targets. One of: all, minimal.")
_ = viper.BindPFlag("load_outputs", RootCmd.PersistentFlags().Lookup("load-outputs"))
viper.SetDefault("load_outputs", "all")
// tags
RootCmd.PersistentFlags().StringSlice("tag", []string{}, "Filter targets by tag. Can be used multiple times. Example: --tag=foo --tag=bar")
_ = viper.BindPFlag("tag", RootCmd.PersistentFlags().Lookup("tag"))
RootCmd.PersistentFlags().StringSlice("exclude-tag", []string{}, "Exclude targets by tag. Can be used multiple times. Example: --exclude-tag=foo --exclude-tag=bar")
_ = viper.BindPFlag("exclude_tag", RootCmd.PersistentFlags().Lookup("exclude-tag"))
// trace_tag
RootCmd.PersistentFlags().StringSlice("trace-tag", []string{}, "Attach a key=value tag to this build's trace. Can be used multiple times. Example: --trace-tag=job=lint")
_ = viper.BindPFlag("trace_tag", RootCmd.PersistentFlags().Lookup("trace-tag"))
// enable_caching
RootCmd.PersistentFlags().Bool("enable-cache", true, "Enable cache")
_ = viper.BindPFlag("enable_cache", RootCmd.PersistentFlags().Lookup("enable-cache"))
viper.SetDefault("enable_cache", true)
// select profiles
RootCmd.PersistentFlags().String("profile", "", "Select a configuration profile to use")
_ = viper.BindPFlag("profile", RootCmd.PersistentFlags().Lookup("profile"))
viper.SetDefault("profile", "")
// async_cache_writes
RootCmd.PersistentFlags().Bool("async-cache-writes", true, "Defer cache writes to background I/O workers during the build")
_ = viper.BindPFlag("async_cache_writes", RootCmd.PersistentFlags().Lookup("async-cache-writes"))
viper.SetDefault("async_cache_writes", true)
// disable_tea
RootCmd.PersistentFlags().Bool("disable-tea", false, "Disable interactive TUI (Bubble Tea)")
_ = viper.BindPFlag("disable_tea", RootCmd.PersistentFlags().Lookup("disable-tea"))
viper.SetDefault("disable_tea", false)
// Register subcommands
RootCmd.AddCommand(cmds.VersionCmd)
RootCmd.AddCommand(cmds.ListCmd)
RootCmd.AddCommand(cmds.InfoCmd)
RootCmd.AddCommand(cmds.CheckCmd)
RootCmd.AddCommand(cmds.TaintCmd)
cmds.AddRunCmd(RootCmd)
cmds.AddGraphCmd(RootCmd)
cmds.AddCleanCmd(RootCmd)
cmds.AddBuildAndTestCmd(RootCmd)
cmds.AddTestCmd(RootCmd)
cmds.AddBuildCmd(RootCmd)
cmds.AddDepsCmd(RootCmd)
cmds.AddRDepsCmd(RootCmd)
cmds.AddOwnersCmd(RootCmd)
cmds.AddChangesCmd(RootCmd)
cmds.AddExplainChangesCmd(RootCmd)
cmds.AddListCmd(RootCmd)
traces.AddCmd(RootCmd)
return true
}
func initConfig(cmd *cobra.Command) error {
// Set defaults here
viper.SetDefault("log_level", "info")
viper.SetDefault("load_outputs", "all")
viper.SetDefault("disable_non_deterministic_logging", false)
viper.SetDefault("os", runtime.GOOS)
viper.SetDefault("arch", runtime.GOARCH)
viper.SetDefault("cache.gcs.shared_cache", true)
viper.SetDefault("cache.s3.shared_cache", true)
viper.SetDefault("cache.azure.shared_cache", true)
viper.SetDefault("hash_algorithm", config.HashAlgorithmXXH3)
viper.SetDefault("include_hidden", false)
viper.SetDefault("environment_variables", make(map[string]string))
viper.SetDefault("traces.enabled", false)
names := []string{"grog"}
if os.Getenv("CI") == "1" {
names = append([]string{"grog.ci"}, names...)
}
if viper.GetString("profile") != "" {
names = append([]string{"grog." + viper.GetString("profile")}, names...)
}
logger := console.InitLogger()
var found bool
for _, name := range names {
viper.SetConfigName(name)
if err := viper.ReadInConfig(); err != nil {
if _, isConfigFileNotFound := errors.AsType[viper.ConfigFileNotFoundError](err); isConfigFileNotFound {
continue
}
return err
}
found = true
logger.Debugf("Loaded config file: %s", viper.ConfigFileUsed())
break
}
if !found {
return fmt.Errorf("no grog config file found (tried: %v)", names)
}
// Determine effective log level precedence before unmarshalling into Global:
// 1) --log-level flag (if set)
// 2) --verbose/-v or --debug flags
// 3) workspace config (already read) or env or defaults
logLevelFlagSet := false
if cmd != nil {
if f := cmd.Flags().Lookup("log-level"); f != nil {
logLevelFlagSet = f.Changed
}
}
if !logLevelFlagSet {
switch viper.GetInt("verbose") {
case 1:
viper.Set("log_level", "debug")
case 2:
viper.Set("log_level", "trace")
}
if viper.GetBool("debug") {
viper.Set("log_level", "debug")
}
}
// Merge all config sources into the global
if err := viper.Unmarshal(&config.Global); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
config.Global.HashAlgorithm = strings.ToLower(config.Global.HashAlgorithm)
logger.Debugf("Using config file: %s", viper.ConfigFileUsed())
logger.Debugf("Running on %s", config.Global.GetPlatform())
platform := viper.GetString("platform")
if config.Global.AllPlatforms && platform != "" {
return fmt.Errorf("--platform cannot be used with --all-platforms")
}
if platform != "" {
parts := strings.SplitN(platform, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid platform %s, expected os/arch", platform)
}
config.Global.OS = parts[0]
config.Global.Arch = parts[1]
}
if err := readInEnvironmentVariablesConfig(); err != nil {
return err
}
return nil
}
// Viper always normalizes all configuration keys to be lower-case
// but users should be able to specify upper case environment_variables
// So as a workaround we load the section here a second time _if_ there are env vars.
//
// When environment_variables_file is set, variables are loaded from the file
// first, then inline environment_variables from grog.toml are merged on top
// (inline values take precedence over file values).
func readInEnvironmentVariablesConfig() error {
merged := make(map[string]string)
// Phase 1: Load variables from file if configured.
if config.Global.EnvironmentVariablesFile != "" {
envFilePath := config.Global.EnvironmentVariablesFile
if !filepath.IsAbs(envFilePath) {
envFilePath = filepath.Join(config.Global.WorkspaceRoot, envFilePath)
}
f, err := os.Open(envFilePath)
if err != nil {
return fmt.Errorf("failed to open environment_variables_file %q: %w", envFilePath, err)
}
defer func() { _ = f.Close() }()
fileEnv := gotenv.Parse(f)
maps.Copy(merged, fileEnv)
}
// Phase 2: Load inline environment_variables from grog.toml (preserving case).
if len(config.Global.EnvironmentVariables) > 0 {
raw, err := os.ReadFile(viper.ConfigFileUsed())
if err != nil {
return err
}
var helper EnvVarsHelper
err = toml.Unmarshal(raw, &helper)
if err != nil {
return err
}
// Inline values override file-loaded values.
maps.Copy(merged, helper.EnvironmentVariables)
}
config.Global.EnvironmentVariables = merged
return nil
}
type EnvVarsHelper struct {
EnvironmentVariables map[string]string `toml:"environment_variables"`
}
package completions
import (
"context"
"fmt"
os "os"
"slices"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/loading"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/selection"
"github.com/spf13/cobra"
)
func TargetPatternCompletion(command *cobra.Command, _ []string, toComplete string, targetType selection.TargetTypeSelection) ([]string, cobra.ShellCompDirective) {
context, _ := console.SetupCommand()
currentPackage, err := config.Global.GetCurrentPackage()
debugToFile(fmt.Sprintf("err: %s\n", err))
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveError
}
// Completion input states.
// Absolute: starts with "//", intended to resolve from workspace root.
// Relative target: starts with ":", intended to resolve within current package only.
// Partial prefix: a package path that is not fully qualified yet, e.g. "//pack".
isAbsolute := strings.HasPrefix(toComplete, "//")
isRelativeTarget := strings.HasPrefix(toComplete, ":")
pattern := label.ParsePartialTargetPattern(currentPackage, toComplete)
// Parsing state:
// - Prefix(): resolved package prefix (may be partial for absolute patterns).
// - Target(): target name fragment or directory fragment depending on input.
// - IsPrefixPartial(): true when the user has typed an incomplete package path like "//pack".
originalPrefix := pattern.Prefix()
targetPrefix := pattern.Target()
isPrefixPartial := pattern.IsPrefixPartial()
searchDirectory, directoryPrefix := resolveSearchDirectory(pattern, currentPackage, isAbsolute)
debugToFile(fmt.Sprintf("searchDir: %s\n", searchDirectory))
debugToFile(fmt.Sprintf("target: %s\n", pattern.Target()))
// Load packages from the search directory, which is either the current package,
// the workspace root, or the parent directory for partial prefixes.
packages, err := loading.LoadPackages(context, config.GetPathAbsoluteToWorkspaceRoot(searchDirectory))
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveError
}
// The package the user is (implicitly) referring to: the search directory, or the typed
// prefix itself when it is partial. An unresolved partial prefix has no packages in scope,
// so it only gets directory suggestions.
packagePath, packagesInScope, originalPrefixExists := searchDirectory, packages, false
if isPrefixPartial {
packagePath = originalPrefix
packagesInScope, originalPrefixExists = loadOriginalPrefixPackages(context, originalPrefix, searchDirectory, packages)
}
selector := selection.New(nil, config.Global.Tags, config.Global.ExcludeTags, targetType)
targets := collectTargets(packagesInScope, packagePath, targetPrefix, selector, isRelativeTarget)
// Directory suggestions are computed from two sources:
// 1) Siblings under the search directory (root or parent).
// 2) Child directories under the original prefix if it resolves to a package.
var completions []string
var directorySuggestions map[string]bool
if !isRelativeTarget {
directorySuggestions = collectSiblingDirectories(packages, searchDirectory, directoryPrefix, originalPrefixExists && directoryPrefix != "")
if originalPrefixExists {
mergeDirectorySuggestions(directorySuggestions, collectChildDirectories(packagesInScope, packagePath))
}
completions = directoryCompletions(directorySuggestions, isPrefixPartial)
if packageHasChildDirectories(packagesInScope, packagePath) {
if packagePath == "" {
completions = append(completions, "//...")
} else {
completions = append(completions, "//"+packagePath+"/...")
}
}
}
// If only targets remain (no directories), add a trailing ":" to make it clear that
// the next completion step is a target name rather than a package segment.
if isAbsolute && !strings.Contains(toComplete, ":") && len(directorySuggestions) == 0 && len(targets) > 0 {
completions = append(completions, "//"+packagePath+":")
}
if packageHasMatchingTargets(packagesInScope, packagePath, selector) {
if isRelativeTarget {
completions = append(completions, ":...")
} else {
completions = append(completions, "//"+packagePath+":...")
}
}
// If there is only a single target and no directory completions just offer that
if len(completions) == 0 && len(targets) == 1 {
return []string{targets[0]}, cobra.ShellCompDirectiveNoFileComp
}
completions = append(completions, targets...)
slices.Sort(completions)
completions = slices.Compact(completions)
debugToFile(fmt.Sprintf("completions: %s", completions))
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
// resolveSearchDirectory returns the directory to load packages from and the directory
// name fragment that suggestions must start with.
func resolveSearchDirectory(pattern label.TargetPattern, currentPackage string, isAbsolute bool) (string, string) {
searchDirectory := pattern.Prefix()
directoryPrefix := pattern.Target()
// When the prefix is partial, re-scope the search to the parent directory so we can
// suggest siblings that share the same leading segment(s). Example:
// - Input: "//pack"
// - Prefix(): "pack"
// - Search should happen at root, with "pack" used as the directory prefix filter.
if pattern.IsPrefixPartial() && isAbsolute {
searchDirectory, directoryPrefix = splitPartialPrefix(searchDirectory)
}
// Relative inputs without a package prefix should resolve from the current package.
if searchDirectory == "" && !isAbsolute {
searchDirectory = currentPackage
}
return searchDirectory, directoryPrefix
}
// loadOriginalPrefixPackages loads the packages beneath a partially typed prefix, or
// returns nil when the prefix is not itself a package.
func loadOriginalPrefixPackages(commandContext context.Context, originalPrefix string, searchDirectory string, packages []*model.Package) ([]*model.Package, bool) {
if originalPrefix == "" {
return nil, false
}
if originalPrefix != searchDirectory {
var err error
packages, err = loading.LoadPackages(commandContext, config.GetPathAbsoluteToWorkspaceRoot(originalPrefix))
if err != nil {
return nil, false
}
}
for _, packageEntry := range packages {
if packageEntry.Path == originalPrefix {
return packages, true
}
}
return nil, false
}
// directoryCompletions formats directory suggestions as "//dir" (with a trailing "/" for
// partial prefixes that have children) plus a "//dir/..." wildcard for each directory with children.
func directoryCompletions(directorySuggestions map[string]bool, isPrefixPartial bool) []string {
var completions []string
for fullPath, hasChildren := range directorySuggestions {
completion := "//" + fullPath
if isPrefixPartial && hasChildren {
completion += "/"
}
completions = append(completions, completion)
if hasChildren {
completions = append(completions, "//"+fullPath+"/...")
}
}
return completions
}
func TestTargetPatternCompletion(command *cobra.Command, arguments []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return TargetPatternCompletion(command, arguments, toComplete, selection.TestOnly)
}
func BuildTargetPatternCompletion(command *cobra.Command, arguments []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return TargetPatternCompletion(command, arguments, toComplete, selection.NonTestOnly)
}
func BinaryTargetPatternCompletion(command *cobra.Command, arguments []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return TargetPatternCompletion(command, arguments, toComplete, selection.BinOutput)
}
func AllTargetPatternCompletion(command *cobra.Command, arguments []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return TargetPatternCompletion(command, arguments, toComplete, selection.AllTargets)
}
// collectTargets returns target and alias completions for the exact package path.
// The returned values already include package qualification unless relative targets were requested.
func collectTargets(packages []*model.Package, packagePath string, targetPrefix string, selector *selection.Selector, isRelativeTarget bool) []string {
var targets []string
for _, packageEntry := range packages {
normalizedPath := normalizePackagePath(packageEntry.Path)
if normalizedPath != packagePath {
continue
}
for targetLabel, target := range packageEntry.Targets {
if targetPrefix != "" && !strings.HasPrefix(targetLabel.Name, targetPrefix) {
continue
}
if selector.Match(target) {
targets = append(targets, formatTargetCompletion(targetLabel.String(), targetLabel.Name, isRelativeTarget))
}
}
for aliasLabel := range packageEntry.Aliases {
if targetPrefix != "" && !strings.HasPrefix(aliasLabel.Name, targetPrefix) {
continue
}
targets = append(targets, formatTargetCompletion(aliasLabel.String(), aliasLabel.Name, isRelativeTarget))
}
}
return targets
}
func packageHasMatchingTargets(packages []*model.Package, packagePath string, selector *selection.Selector) bool {
for _, packageEntry := range packages {
normalizedPath := normalizePackagePath(packageEntry.Path)
if normalizedPath != packagePath {
continue
}
for _, target := range packageEntry.Targets {
if selector.Match(target) {
return true
}
}
}
return false
}
func packageHasChildDirectories(packages []*model.Package, packagePath string) bool {
normalizedPrefix := normalizePackagePath(packagePath)
prefixWithSeparator := normalizedPrefix
if prefixWithSeparator != "" {
prefixWithSeparator += "/"
}
for _, packageEntry := range packages {
normalizedPath := normalizePackagePath(packageEntry.Path)
if normalizedPrefix == "" {
if normalizedPath != "" {
return true
}
continue
}
if strings.HasPrefix(normalizedPath, prefixWithSeparator) {
return true
}
}
return false
}
// collectSiblingDirectories returns directories that are siblings of the search directory.
// For the workspace root, siblings are the first path segment. Otherwise they are the
// first segment under searchDirectory.
func collectSiblingDirectories(packages []*model.Package, searchDirectory string, directoryPrefix string, skipExactPrefixDirectory bool) map[string]bool {
directorySuggestions := make(map[string]bool)
for _, packageEntry := range packages {
packagePath := normalizePackagePath(packageEntry.Path)
if packagePath == searchDirectory {
continue
}
if searchDirectory == "" {
segment, _, _ := strings.Cut(packagePath, "/")
if segment == "" {
continue
}
if directoryPrefix == "" || strings.HasPrefix(segment, directoryPrefix) {
if skipExactPrefixDirectory && segment == directoryPrefix {
continue
}
addDirectorySuggestion(directorySuggestions, segment, strings.Contains(packagePath, "/"))
}
continue
}
if after, ok := strings.CutPrefix(packagePath, searchDirectory+"/"); ok {
rest := after
segment, _, _ := strings.Cut(rest, "/")
if directoryPrefix == "" || strings.HasPrefix(segment, directoryPrefix) {
if skipExactPrefixDirectory && segment == directoryPrefix {
continue
}
addDirectorySuggestion(directorySuggestions, searchDirectory+"/"+segment, strings.Contains(rest, "/"))
}
}
}
return directorySuggestions
}
// collectChildDirectories returns immediate child directories under the given prefix.
func collectChildDirectories(packages []*model.Package, prefix string) map[string]bool {
directorySuggestions := make(map[string]bool)
for _, packageEntry := range packages {
packagePath := normalizePackagePath(packageEntry.Path)
if !strings.HasPrefix(packagePath, prefix+"/") {
continue
}
rest := strings.TrimPrefix(packagePath, prefix+"/")
segment, _, _ := strings.Cut(rest, "/")
fullPath := prefix + "/" + segment
addDirectorySuggestion(directorySuggestions, fullPath, strings.Contains(rest, "/"))
}
return directorySuggestions
}
func mergeDirectorySuggestions(target map[string]bool, additions map[string]bool) {
for path, hasChildren := range additions {
if target[path] {
continue
}
target[path] = hasChildren
}
}
func addDirectorySuggestion(target map[string]bool, fullPath string, hasChildren bool) {
if existing, ok := target[fullPath]; ok && existing {
return
}
target[fullPath] = hasChildren
}
func normalizePackagePath(path string) string {
if path == "." {
return ""
}
return path
}
func formatTargetCompletion(absoluteLabel string, targetName string, isRelativeTarget bool) string {
if isRelativeTarget {
return ":" + targetName
}
return absoluteLabel
}
func splitPartialPrefix(prefix string) (string, string) {
if prefix == "" {
return "", ""
}
if strings.Contains(prefix, "/") {
baseDirectory := prefix[:strings.LastIndex(prefix, "/")]
if baseDirectory == "." {
baseDirectory = ""
}
return baseDirectory, prefix[strings.LastIndex(prefix, "/")+1:]
}
return "", prefix
}
func debugToFile(msg string) {
if config.Global.DebugCompletion {
_ = os.WriteFile("/tmp/grog-completion.log", []byte(msg+"\n"), 0o644)
}
}
package config
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
semver "github.com/blang/semver/v4"
)
const (
HashAlgorithmXXH3 = "xxh3"
HashAlgorithmSHA256 = "sha256"
)
type WorkspaceConfig struct {
Root string `mapstructure:"root"`
WorkspaceRoot string `mapstructure:"workspace_root"`
// CacheNamespace optionally namespaces the target cache under $GROG_ROOT.
// By default the target cache is flat (shared across all workspaces and
// all checkouts) because cache keys include a complete hash of their
// inputs, so collisions are not possible. Set this to isolate the cache
// — e.g. to keep different projects' caches from mingling on the same
// machine.
CacheNamespace string `mapstructure:"cache_namespace"`
// Execution
FailFast bool `mapstructure:"fail_fast"`
StreamLogs bool `mapstructure:"stream_logs"`
NumWorkers int `mapstructure:"num_workers"`
LoadOutputs string `mapstructure:"load_outputs"`
// DisableProgressTracker suppresses progress bar updates in task status output
// while still allowing status strings to be emitted.
DisableProgressTracker bool `mapstructure:"disable_progress_tracker"`
// DisableDefaultShellFlags prevents Grog from prepending "set -eu" to user commands.
DisableDefaultShellFlags bool `mapstructure:"disable_default_shell_flags"`
// HashAlgorithm selects the hash function used for cache keys and target
// change detection. Supported values: "xxh3" (default) or "sha256".
HashAlgorithm string `mapstructure:"hash_algorithm"`
// SkipWorkspaceLock disables the workspace-level lock. This should only be
// used in situations where the user can guarantee that no concurrent grog
// processes operate on the same workspace, otherwise cache corruption may
// occur.
SkipWorkspaceLock bool `mapstructure:"skip_workspace_lock"`
// AsyncCacheWrites defers cache writes to a dedicated I/O worker pool,
// freeing task workers to start downstream targets sooner. Output hashes
// are still computed synchronously so dependency chains stay correct.
// Pending I/O writes are drained before the build returns. Defaults to true.
AsyncCacheWrites bool `mapstructure:"async_cache_writes"`
// NumIOWorkers caps concurrent I/O against the cache backend (CAS,
// target/taint cache, tracing, docker proxy) via a process-wide
// semaphore in the BoundedBackend wrapper. Defaults to
// clamp(NumCPU * 4, 32, 256) — see backends.DefaultIOConcurrency.
NumIOWorkers int `mapstructure:"num_io_workers"`
// NumAsyncWriters sizes the async cache-writer goroutine pool that
// drains deferred writes when AsyncCacheWrites is true. Each task it
// dispatches still acquires a slot on the global I/O semaphore, so
// this knob only affects queueing — not the backend bound. Defaults
// to 3 * num_workers.
NumAsyncWriters int `mapstructure:"num_async_writers"`
// IncludeHidden controls whether the package loader descends into hidden
// files and directories (names starting with a dot, e.g. ".github"). By
// default these are skipped, matching the behavior of most code tools.
// Set to true to allow BUILD files and source inputs inside dot-directories
// to be discovered.
IncludeHidden bool `mapstructure:"include_hidden"`
// ConcurrencyGroups maps a concurrency group name to its capacity (the
// max number of group members that can run in parallel). Groups
// referenced by a target but missing from this map default to capacity
// 1 (fully serialized).
ConcurrencyGroups map[string]int `mapstructure:"concurrency_groups"`
// Logging
LogLevel string `mapstructure:"log_level"`
LogOutputPath string `mapstructure:"log_output_path"`
// OutputMode controls how per-target progress is rendered in
// non-interactive output: "terse" (default, one line per target) or
// "detailed" (stream each target's lifecycle).
OutputMode string `mapstructure:"output_mode"`
// Matching
AllPlatforms bool `mapstructure:"all_platforms"`
// PlatformTags are user-defined platform identifiers the host opts into.
// They behave like an additional host platform for target matching: a target
// whose `platforms` list contains a tag also enabled here will match,
// regardless of the host's auto-detected os/arch.
PlatformTags []string `mapstructure:"platform_tag"`
// Caching
EnableCache bool `mapstructure:"enable_cache"`
Cache CacheConfig `mapstructure:"cache"`
// Tracing
Traces TracesConfig `mapstructure:"traces"`
// OCI
OCI OCIConfig `mapstructure:"oci"`
Push bool `mapstructure:"push"`
// Environment Variables
EnvironmentVariables map[string]string `mapstructure:"environment_variables"`
EnvironmentVariablesFile string `mapstructure:"environment_variables_file"`
// Environment
// Not officially supported in grog.toml but exposed via env variables
OS string `mapstructure:"os"`
Arch string `mapstructure:"arch"`
Tags []string `mapstructure:"tag"`
ExcludeTags []string `mapstructure:"exclude_tag"`
TraceTags []string `mapstructure:"trace_tag"`
// Internal configs
// Used for integration testing:
// Due to the concurrent nature of grog's execution we don't want to include
// logs that don't have a guaranteed order
DisableNonDeterministicLogging bool `mapstructure:"disable_non_deterministic_logging"`
// Require that the running grog version matches this semver range
RequiredGrogVersion string `mapstructure:"requires_grog"`
DebugCompletion bool `mapstructure:"debug_completion"`
}
var Global WorkspaceConfig
// GetWorkspaceRootDir is the directory under $GROG_ROOT that hosts
// per-checkout ephemeral files (logs, workspace lock). The prefix is derived
// from the absolute workspace path so that concurrent grog invocations in
// different checkouts of the same repo do not conflict.
func (w WorkspaceConfig) GetWorkspaceRootDir() string {
workspaceDir := Global.WorkspaceRoot
return filepath.Join(w.Root, GetWorkspaceCachePrefix(workspaceDir))
}
// GetWorkspaceCacheDirectory returns the directory that stores the target
// cache. It is flat under $GROG_ROOT by default so that the same repo
// checked out at different absolute paths (e.g. ephemeral CI runners) shares
// cache hits. When CacheNamespace is set the cache is namespaced under that
// name.
func (w WorkspaceConfig) GetWorkspaceCacheDirectory() string {
if w.CacheNamespace != "" {
return filepath.Join(w.Root, w.CacheNamespace, "cache")
}
return filepath.Join(w.Root, "cache")
}
// GetCasDirectory returns the directory under $GROG_ROOT that hosts the shared CAS.
func (w WorkspaceConfig) GetCasDirectory() string {
return filepath.Join(w.Root, "cas")
}
func (w WorkspaceConfig) IsDebug() bool {
return w.LogLevel == "debug"
}
func (w WorkspaceConfig) GetCurrentPackage() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current working directory: %w", err)
}
rel, err := filepath.Rel(w.WorkspaceRoot, cwd)
if err != nil {
return "", fmt.Errorf("failed to get relative path: %w", err)
}
if rel == "." {
// Since we are returning the package we omit the dot prefix.
return "", nil
}
return rel, nil
}
func (w WorkspaceConfig) GetPlatform() string {
return fmt.Sprintf("%s/%s", w.OS, w.Arch)
}
func (w WorkspaceConfig) Validate() error {
switch strings.ToLower(w.HashAlgorithm) {
case "", HashAlgorithmXXH3, HashAlgorithmSHA256:
default:
return fmt.Errorf("invalid hash_algorithm: %s. Must be either %s or %s", w.HashAlgorithm, HashAlgorithmXXH3, HashAlgorithmSHA256)
}
if w.OCI.Backend != "" &&
(w.OCI.Backend != OCIBackendFS && w.OCI.Backend != OCIBackendRegistry) {
return fmt.Errorf("invalid oci backend: %s. Must be either %s or %s",
w.OCI.Backend, OCIBackendFS, OCIBackendRegistry)
}
// assert that tags and exclude tags do not overlap
for _, tag := range w.Tags {
if slices.Contains(w.ExcludeTags, tag) {
return fmt.Errorf("tag %s cannot both be selected and excluded", tag)
}
}
// Validate LoadOutputs
_, err := ParseLoadOutputsMode(w.LoadOutputs)
if err != nil {
return err
}
// Validate OutputMode
if _, err := ParseOutputMode(w.OutputMode); err != nil {
return err
}
return nil
}
// ValidateGrogVersion checks if the provided current version satisfies the
// RequiredGrogVersion constraint if it is set. The version string should
// follow semver conventions.
func (w WorkspaceConfig) ValidateGrogVersion(currentVersion string) error {
if w.RequiredGrogVersion == "" {
return nil
}
required, err := semver.ParseRange(w.RequiredGrogVersion)
if err != nil {
return fmt.Errorf("invalid requires_grog: %w", err)
}
actual, err := semver.ParseTolerant(currentVersion)
if err != nil {
return fmt.Errorf("invalid grog version %q: %w", currentVersion, err)
}
if !required(actual) {
return fmt.Errorf("grog version %s does not satisfy %s", currentVersion, w.RequiredGrogVersion)
}
return nil
}
func (w WorkspaceConfig) GetLoadOutputsMode() LoadOutputsMode {
mode, err := ParseLoadOutputsMode(w.LoadOutputs)
if err != nil {
// This should never happen because we validate the value in Validate()
// But just in case, return the default value
return LoadOutputsAll
}
return mode
}
func (w WorkspaceConfig) GetOutputMode() OutputMode {
mode, err := ParseOutputMode(w.OutputMode)
if err != nil {
// This should never happen because we validate the value in Validate()
// But just in case, return the default value
return OutputModeTerse
}
return mode
}
type CacheBackend string
const (
GCSCacheBackend CacheBackend = "gcs"
S3CacheBackend CacheBackend = "s3"
AzureCacheBackend CacheBackend = "azure"
)
type CacheConfig struct {
Backend CacheBackend `mapstructure:"backend"`
GCS GCSCacheConfig `mapstructure:"gcs"`
S3 S3CacheConfig `mapstructure:"s3"`
Azure AzureCacheConfig `mapstructure:"azure"`
}
type GCSCacheConfig struct {
Bucket string `mapstructure:"bucket"`
Prefix string `mapstructure:"prefix"`
CredentialsFile string `mapstructure:"credentials_file"`
SharedCache bool `mapstructure:"shared_cache"`
}
type S3CacheConfig struct {
Bucket string `mapstructure:"bucket"`
Prefix string `mapstructure:"prefix"`
CredentialsFile string `mapstructure:"credentials_file"`
SharedCache bool `mapstructure:"shared_cache"`
}
// AzureCacheConfig holds the configuration for the Azure Blob Storage cache backend.
type AzureCacheConfig struct {
AccountURL string `mapstructure:"account_url"`
ConnectionString string `mapstructure:"connection_string"`
Container string `mapstructure:"container"`
Prefix string `mapstructure:"prefix"`
SharedCache bool `mapstructure:"shared_cache"`
}
type TracesConfig struct {
Enabled bool `mapstructure:"enabled"`
Backend CacheBackend `mapstructure:"backend"`
GCS GCSCacheConfig `mapstructure:"gcs"`
S3 S3CacheConfig `mapstructure:"s3"`
}
const (
OCIBackendFS = "fs"
OCIBackendRegistry = "registry"
)
type OCIConfig struct {
Backend string `mapstructure:"backend"`
Registry string `mapstructure:"registry"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
// InsecureRegistries lists registry hosts grog should talk to over plain
// HTTP. Entries are matched against the destination's host portion: an
// entry without a port matches any port on that host ("localhost" matches
// "localhost:5000"); an entry with a port matches exactly. Mirrors the
// docker daemon's insecure-registries config so a user already familiar
// with that knob has no new mental model.
InsecureRegistries []string `mapstructure:"insecure_registries"`
}
package config
import (
"bytes"
"os/exec"
"strings"
)
// GetGitHash returns the current git hash.
func GetGitHash() (string, error) {
cmd := exec.Command("git", "rev-parse", "HEAD")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return "", err
}
return strings.TrimSpace(out.String()), nil
}
// GetGitBranch returns the current git branch name.
func GetGitBranch() (string, error) {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return "", err
}
return strings.TrimSpace(out.String()), nil
}
package config
import "fmt"
// LoadOutputsMode determines what outputs to load.
type LoadOutputsMode int
const (
// LoadOutputsAll loads all outputs.
LoadOutputsAll LoadOutputsMode = iota
// LoadOutputsMinimal only loads an output if a direct dependant needs to be re-built.
LoadOutputsMinimal
)
// ParseLoadOutputsMode converts a string to a LoadOutputsMode
// Returns an error if the string is not a valid mode.
func ParseLoadOutputsMode(s string) (LoadOutputsMode, error) {
switch s {
case "all":
return LoadOutputsAll, nil
case "minimal":
return LoadOutputsMinimal, nil
default:
return LoadOutputsAll, fmt.Errorf("invalid load_outputs mode: '%s'. Must be either 'all' or 'minimal'", s)
}
}
package config
import "fmt"
// OutputMode determines how per-target build/test progress is rendered in
// non-interactive (non-TUI) output.
type OutputMode int
const (
// OutputModeTerse prints a single aligned completion line per target
// (label, outcome, timing and cache indicator). This is the default.
OutputModeTerse OutputMode = iota
// OutputModeDetailed streams each target's lifecycle as it happens
// (checking cache, running, writing outputs, done).
OutputModeDetailed
)
// ParseOutputMode converts a string to an OutputMode. An empty string maps to
// the default (terse). Returns an error for any other unrecognized value.
func ParseOutputMode(s string) (OutputMode, error) {
switch s {
case "", "terse":
return OutputModeTerse, nil
case "detailed":
return OutputModeDetailed, nil
default:
return OutputModeTerse, fmt.Errorf("invalid output_mode: '%s'. Must be either 'terse' or 'detailed'", s)
}
}
package config
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fatih/color"
)
// GetWorkspaceCachePrefix returns the per-checkout prefix used for workspace
// ephemera (logs, workspace lock) and for remote cache paths when
// shared_cache is disabled. It combines a SHA-256 hash of the absolute
// workspace path with the directory basename so that concurrent grog
// invocations in different checkouts of the same repo cannot clobber each
// other's logs or deadlock on the same lock file. The target cache itself is
// not prefixed with this value — it lives directly under $GROG_ROOT and is
// shared across checkouts (see WorkspaceConfig.GetWorkspaceCacheDirectory).
func GetWorkspaceCachePrefix(workspaceDir string) string {
repoHash := fmt.Sprintf("%x", sha256.Sum256([]byte(workspaceDir)))[:16]
workspaceName := filepath.Base(workspaceDir)
return fmt.Sprintf("%s-%s", repoHash, workspaceName)
}
// MustFindWorkspaceRoot searches for the repository root by looking for "grog.toml"
// in the current working directory and its parents. It panics if it
// does not find the file.
func MustFindWorkspaceRoot() string {
cwd, err := os.Getwd()
if err != nil {
fmt.Printf("%s failed to get current working directory: %v\n", color.RedString("FATAL:"), err)
os.Exit(1)
}
for {
configPath := filepath.Join(cwd, "grog.toml")
if _, err := os.Stat(configPath); err == nil {
return cwd
} else if !os.IsNotExist(err) {
fmt.Printf("%s failed to check for grog.toml: %v\n", color.RedString("FATAL:"), err)
os.Exit(1)
}
parent := filepath.Dir(cwd)
if parent == cwd {
break
}
cwd = parent
}
fmt.Printf("%s grog.toml not found in any parent directory. Is this a grog workspace?\n", color.RedString("FATAL:"))
os.Exit(1)
return "" // unreachable but needed to satisfy compiler
}
func GetPathRelativeToWorkspaceRoot(path string) (string, error) {
workspaceRoot := Global.WorkspaceRoot
// error if path is not under workspace root
if !strings.HasPrefix(path, workspaceRoot) {
return "", fmt.Errorf("path %s is not under workspace root %s", path, workspaceRoot)
}
return path[len(workspaceRoot)+1:], nil
}
func GetPathAbsoluteToWorkspaceRoot(path string) string {
workspaceRoot := Global.WorkspaceRoot
return filepath.Join(workspaceRoot, path)
}
func GetPackagePath(path string) (string, error) {
relativePath, err := GetPathRelativeToWorkspaceRoot(path)
if err != nil {
return "", err
}
// get dir and remove the last slash
dirPath := filepath.Dir(relativePath)
return strings.TrimSuffix(dirPath, "/"), nil
}
package console
import (
"context"
"os"
"os/signal"
"syscall"
)
// SetupCommand universal helper for setting up the context and logger for each command.
func SetupCommand() (context.Context, *Logger) {
ctx, cancel := context.WithCancel(context.Background())
// Listen for SIGTERM or SIGINT to cancel the context
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
go func() {
select {
case sig := <-signalChan:
GetLogger(ctx).Infof("Received signal %v, exiting...", sig)
cancel()
case <-ctx.Done():
return
}
// After the first signal triggered a graceful shutdown, a second
// signal means the user wants to force-exit immediately.
<-signalChan
os.Exit(1)
}()
logger := GetLogger(ctx)
return WithLogger(ctx, logger), logger
}
package console
import (
"fmt"
"github.com/fatih/color"
"go.uber.org/zap/zapcore"
)
// Pl small pluralization helper.
func Pl(s string, count int) string {
if count == 1 {
return s
}
return s + "s"
}
// FCount Format a count.
func FCount(count int, s string) string {
return fmt.Sprintf("%d %s", count, Pl(s, count))
}
// FCountTargets Format a target count.
func FCountTargets(count int) string {
return FCount(count, "target")
}
// FCountOutputs Format a target count.
func FCountOutputs(count int) string {
return FCount(count, "output")
}
// FCountPkg Format a package count.
func FCountPkg(count int) string {
return FCount(count, "package")
}
func getMessagePrefix(level zapcore.Level) string {
var levelText string
cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
switch level {
case TraceLevel:
levelText = cyan("TRACE:")
case zapcore.DebugLevel:
levelText = cyan("DEBUG:") // Cyan
case zapcore.InfoLevel:
levelText = green("INFO:") // Green
case zapcore.WarnLevel:
levelText = yellow("WARN:") // Yellow
case zapcore.ErrorLevel:
levelText = red("ERROR:") // Red
case zapcore.FatalLevel:
levelText = red("FATAL:") // Red
default:
levelText = "UNKNOWN"
}
return levelText
}
package console
import (
"context"
"fmt"
"strings"
"github.com/chrismatix/grog/internal/config"
tea "github.com/charmbracelet/bubbletea"
"github.com/fatih/color"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// TraceLevel represents the most verbose logging level supported by grog.
const TraceLevel zapcore.Level = zapcore.DebugLevel - 1
// Logger wraps zap's SugaredLogger to add a trace level helper and preserve
// the sugared logging ergonomics throughout the codebase.
type Logger struct {
*zap.SugaredLogger
traceEnabled bool
}
func newLogger(logger *zap.SugaredLogger, level zapcore.Level) *Logger {
return &Logger{SugaredLogger: logger, traceEnabled: level <= TraceLevel}
}
// NewFromSugared wraps an existing zap.SugaredLogger into a console.Logger with the given level.
// Use this in tests to adapt observed/test loggers to the console logger type expected by production code.
func NewFromSugared(logger *zap.SugaredLogger, level zapcore.Level) *Logger {
return newLogger(logger, level)
}
// Tracef logs at the trace level when enabled.
func (l *Logger) Tracef(template string, args ...any) {
if l == nil || !l.traceEnabled {
return
}
if checkedEntry := l.Desugar().WithOptions(zap.AddCallerSkip(1)).Check(TraceLevel, fmt.Sprintf(template, args...)); checkedEntry != nil {
checkedEntry.Write()
}
}
// DebugEnabled reports whether debug level logs are currently enabled.
func (l *Logger) DebugEnabled() bool {
if l == nil {
return false
}
return l.Desugar().Core().Enabled(zap.DebugLevel)
}
func (l *Logger) With(args ...any) *Logger {
return &Logger{
SugaredLogger: l.SugaredLogger.With(args...),
traceEnabled: l.traceEnabled,
}
}
func (l *Logger) Named(name string) *Logger {
return &Logger{
SugaredLogger: l.SugaredLogger.Named(name),
traceEnabled: l.traceEnabled,
}
}
func (l *Logger) WithOptions(opts ...zap.Option) *Logger {
return &Logger{
SugaredLogger: l.SugaredLogger.WithOptions(opts...),
traceEnabled: l.traceEnabled,
}
}
// InitLogger returns a new logger that writes to stdout.
func InitLogger() *Logger {
return InitLoggerWithTea(nil)
}
// InitLoggerWithTea returns a new logger that writes to the given Program.
// Leave the Program empty to write to stdout.
func InitLoggerWithTea(program *tea.Program) *Logger {
logPath := config.Global.LogOutputPath
if logPath == "" {
// default to stdout
logPath = "stdout"
}
logLevel := config.Global.LogLevel
if logLevel == "" {
logLevel = "info"
}
cfg := zap.NewProductionConfig()
cfg.DisableStacktrace = true
cfg.DisableCaller = true
// Define a custom encoder config
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.TimeKey = "" // Disable time encoding
encoderConfig.CallerKey = "" // Disable caller encoding
encoderConfig.EncodeLevel = CustomLevelEncoder
encoderConfig.ConsoleSeparator = " "
var level zapcore.Level
switch strings.ToLower(logLevel) {
case "trace":
level = TraceLevel
// Trace mode is debug + caller
cfg.DisableStacktrace = false
cfg.DisableCaller = false
encoderConfig.CallerKey = "C"
encoderConfig.EncodeCaller = zapcore.FullCallerEncoder
case "debug":
level = zap.DebugLevel
case "info":
level = zap.InfoLevel
case "warn":
level = zap.WarnLevel
case "error":
level = zap.ErrorLevel
default:
level = zap.InfoLevel
}
// do not use structured logging by default
cfg.Encoding = "console"
cfg.OutputPaths = []string{
logPath,
}
cfg.Level = zap.NewAtomicLevelAt(level)
// Implement the color option
MustApplyColorSetting()
cfg.EncoderConfig = encoderConfig
if program != nil && UseTea() {
teaCore := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(NewTeaWriter(program)),
level,
)
return newLogger(zap.New(teaCore).Sugar(), level)
}
logger, err := cfg.Build()
if err != nil {
panic(err)
}
return newLogger(logger.Sugar(), level)
}
type ctxLoggerKey struct{}
func WithLogger(ctx context.Context, logger *Logger) context.Context {
if storedLogger, ok := ctx.Value(ctxLoggerKey{}).(*Logger); ok {
if storedLogger == logger {
return ctx
}
}
return context.WithValue(ctx, ctxLoggerKey{}, logger)
}
type programKey struct{}
// WithTeaLogger returns a new context with a tea logger and the tea Program.
func WithTeaLogger(ctx context.Context, program *tea.Program) context.Context {
loggerCtx := context.WithValue(ctx, ctxLoggerKey{}, InitLoggerWithTea(program))
return context.WithValue(loggerCtx, programKey{}, program)
}
func GetLogger(ctx context.Context) *Logger {
if logger, ok := ctx.Value(ctxLoggerKey{}).(*Logger); ok {
return logger
}
logger := InitLogger()
logger.Debugf("no logger found in context, using default logger. This is probably a bug.")
return logger
}
func GetTeaProgram(ctx context.Context) *tea.Program {
if program, ok := ctx.Value(programKey{}).(*tea.Program); ok {
return program
}
return nil
}
// WarnOnError is a helper for warning when some defer cleanup
// function returns an error.
func WarnOnError(ctx context.Context, fn func() error) {
logger := GetLogger(ctx)
if err := fn(); err != nil {
logger.Warnf("deferred error: %v", err)
}
}
func MustApplyColorSetting() {
colorSetting := viper.GetString("color")
switch colorSetting {
case "yes", "1":
color.NoColor = false
case "no", "0":
color.NoColor = true
// No need to explicitly handle "auto" as the color package will
// automatically detect if it is a TTY or not.
}
}
// CustomLevelEncoder matches the way bazel outputs its log levels.
func CustomLevelEncoder(level zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(getMessagePrefix(level))
}
package console
import (
"fmt"
"strings"
"time"
"github.com/lucasb-eyer/go-colorful"
"github.com/muesli/termenv"
)
// RenderAfterSeconds determines how long to wait before rendering progress bars.
const RenderAfterSeconds = 1
// ProgressUnit controls how Current/Total values are formatted in the bar.
type ProgressUnit int
const (
// ProgressUnitBytes formats values as human-readable byte counts (default).
ProgressUnitBytes ProgressUnit = iota
// ProgressUnitCount formats values as plain integers.
ProgressUnitCount
)
// Progress represents a unit of work with a current and total value.
// It is used to render progress bars in the task UI.
type Progress struct {
StartedAtSec int64
Current int64
Total int64
Unit ProgressUnit
}
// percent clamps the progress to the 0-100 range.
func (p Progress) percent() int {
if p.Total <= 0 {
return 0
}
if p.Current >= p.Total {
return 100
}
if p.Current <= 0 {
return 0
}
return int((p.Current * 100) / p.Total)
}
func (p Progress) hasTotal() bool {
return p.Total > 0
}
func (p Progress) isComplete() bool {
return p.Current >= p.Total
}
func (p Progress) shouldRender() bool {
return p.hasTotal() && time.Since(time.Unix(p.StartedAtSec, 0)).Seconds() > RenderAfterSeconds && !p.isComplete()
}
func formatProgressBar(p Progress, width int) string {
if !p.hasTotal() || width <= 0 {
return ""
}
percent := p.percent()
filled := (percent * width) / 100
// light green to dark green gradient
startColor, _ := colorful.Hex("#3CCF6D")
endColor, _ := colorful.Hex("#0A8F3A")
cp := termenv.ColorProfile()
var b strings.Builder
for i := range width {
if i < filled {
ratio := 0.0
if width > 1 {
ratio = float64(i) / float64(width-1)
}
c := startColor.BlendLuv(endColor, ratio).Hex()
b.WriteString(termenv.String("=").Foreground(cp.Color(c)).String())
} else if i == filled {
ratio := 0.0
if width > 1 {
ratio = float64(i) / float64(width-1)
}
c := startColor.BlendLuv(endColor, ratio).Hex()
b.WriteString(termenv.String(">").Foreground(cp.Color(c)).String())
} else {
b.WriteString(" ")
}
}
var suffix string
switch p.Unit {
case ProgressUnitCount:
suffix = fmt.Sprintf("%d/%d", p.Current, p.Total)
default:
suffix = fmt.Sprintf("%s/%s", formatBytes(p.Current), formatBytes(p.Total))
}
return fmt.Sprintf("[%s] %3d%% %s", b.String(), percent, suffix)
}
// formatBytes renders a human-readable byte count for progress bars.
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit && exp < len("KMGTPE")-1; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
package console
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chrismatix/grog/internal/config"
"github.com/fatih/color"
)
// ResultLoggerKey is the context key for the ResultLogger.
type ResultLoggerKey struct{}
// GetResultLogger retrieves the ResultLogger from the context.
func GetResultLogger(ctx context.Context) *ResultLogger {
if logger, ok := ctx.Value(ResultLoggerKey{}).(*ResultLogger); ok {
return logger
}
return nil
}
// resultKind is the outcome rendered for a target.
type resultKind int
const (
resultBuilt resultKind = iota
resultPassed
resultFailed
)
// ResultLogger renders one completion line per target. It has two styles,
// selected by config.OutputMode:
//
// - terse (default): an aligned table — the label sits in a left column
// padded to a common width, followed by the outcome (DONE/PASSED/FAILED),
// the timing and an optional cache indicator. The cache indicator is
// always appended last (after the timing) so the "<verb> in <t>s" column
// stays vertically aligned whether or not a target was served from cache.
//
// - detailed: an inline "<label>: <verb> in <t>s" line matching the lifecycle
// status lines streamed during the build, so each target's final result
// reads as the last step of its own stream.
//
// In deterministic logging mode (used by the integration fixtures) the order
// in which parallel workers finish is not stable, so completion lines (in
// either style) are buffered and emitted sorted by label on Flush. Otherwise
// lines are emitted as soon as each target completes so that long builds stream
// their progress.
type ResultLogger struct {
maxLabelWidth int
terminalWidth int
detailed bool
buffered bool
mu sync.Mutex
lines []bufferedLine
}
type bufferedLine struct {
label string
text string
}
// NewResultLogger creates a ResultLogger sized to the given target labels.
// If terminalWidth is 0 a default width is used.
func NewResultLogger(targetLabels []string, terminalWidth int) *ResultLogger {
if terminalWidth <= 0 {
terminalWidth = 80
}
maxLabelWidth := 0
for _, label := range targetLabels {
if w := utf8.RuneCountInString(label); w > maxLabelWidth {
maxLabelWidth = w
}
}
// Keep the label column to at most 70% of the terminal width.
if maxAllowed := int(float64(terminalWidth) * 0.7); maxLabelWidth > maxAllowed {
maxLabelWidth = maxAllowed
}
return &ResultLogger{
maxLabelWidth: maxLabelWidth,
terminalWidth: terminalWidth,
detailed: config.Global.GetOutputMode() == config.OutputModeDetailed,
// In deterministic logging mode, buffer and sort completion lines by
// label on Flush so concurrent completion order can't make output
// flaky. This applies to both styles: the detailed lifecycle stream is
// itself suppressed under deterministic logging, leaving only these
// completion lines, which would otherwise race.
buffered: config.Global.DisableNonDeterministicLogging,
}
}
// formatLabel pads the label to the column width, or truncates it from the
// left with an ellipsis when it is too long.
func (rl *ResultLogger) formatLabel(label string) string {
labelWidth := utf8.RuneCountInString(label)
if labelWidth <= rl.maxLabelWidth {
return fmt.Sprintf("%-*s", rl.maxLabelWidth, label)
}
ellipsis := "..."
truncatedWidth := rl.maxLabelWidth - utf8.RuneCountInString(ellipsis)
if truncatedWidth <= 0 {
return ellipsis
}
runes := []rune(label)
return ellipsis + string(runes[len(runes)-truncatedWidth:])
}
// verb returns the colored outcome word for the current output style.
func (rl *ResultLogger) verb(kind resultKind) string {
var word string
var c *color.Color
switch kind {
case resultPassed:
word, c = "PASSED", color.New(color.FgGreen)
case resultFailed:
word, c = "FAILED", color.New(color.FgRed)
default: // resultBuilt
word, c = "DONE", color.New(color.FgGreen)
}
if rl.detailed {
// Lowercase to read as the last step of the lifecycle stream.
word = strings.ToLower(word)
}
return c.Sprint(word)
}
// emit formats and either buffers or writes a single result line. The timing is
// omitted in deterministic logging mode to keep fixtures stable; the cache
// indicator is always appended last so the timing column stays aligned.
func (rl *ResultLogger) emit(logger *Logger, label string, kind resultKind, seconds float64, cached bool) {
timing := ""
if !config.Global.DisableNonDeterministicLogging {
timing = fmt.Sprintf(" in %.1fs", seconds)
}
cachedSuffix := ""
if cached {
cachedSuffix = " (cached)"
}
var line string
if rl.detailed {
// Inline, matching the "<label>: <action>" lifecycle status lines.
line = fmt.Sprintf("%s: %s%s%s", label, rl.verb(kind), timing, cachedSuffix)
} else {
line = fmt.Sprintf("%s %s%s%s", rl.formatLabel(label), rl.verb(kind), timing, cachedSuffix)
}
if rl.buffered {
rl.mu.Lock()
rl.lines = append(rl.lines, bufferedLine{label: label, text: line})
rl.mu.Unlock()
return
}
logger.Infof("%s", line)
}
// Flush emits any buffered lines sorted by label. It is a no-op when lines are
// streamed (the non-deterministic default). Safe to call more than once.
func (rl *ResultLogger) Flush(logger *Logger) {
if rl == nil || !rl.buffered {
return
}
rl.mu.Lock()
lines := rl.lines
rl.lines = nil
rl.mu.Unlock()
sort.Slice(lines, func(i, j int) bool { return lines[i].label < lines[j].label })
for _, l := range lines {
logger.Infof("%s", l.text)
}
}
// LogBuilt logs a freshly built (non-test) target.
func (rl *ResultLogger) LogBuilt(logger *Logger, label string, seconds float64) {
rl.emit(logger, label, resultBuilt, seconds, false)
}
// LogBuiltCached logs a (non-test) target served from cache.
func (rl *ResultLogger) LogBuiltCached(logger *Logger, label string, seconds float64) {
rl.emit(logger, label, resultBuilt, seconds, true)
}
// LogTestPassed logs a passing test target.
func (rl *ResultLogger) LogTestPassed(logger *Logger, label string, seconds float64) {
rl.emit(logger, label, resultPassed, seconds, false)
}
// LogTestPassedCached logs a passing test target served from cache.
func (rl *ResultLogger) LogTestPassedCached(logger *Logger, label string, seconds float64) {
rl.emit(logger, label, resultPassed, seconds, true)
}
// LogFailed logs a failed build or test target.
func (rl *ResultLogger) LogFailed(logger *Logger, label string, executionTime time.Duration) {
rl.emit(logger, label, resultFailed, executionTime.Seconds(), false)
}
package console
import (
"context"
"sync/atomic"
)
type streamLogsToggleKey struct{}
// StreamLogsToggle manages the state for streaming target logs to the console.
type StreamLogsToggle struct {
enabled atomic.Bool
}
// NewStreamLogsToggle returns a new toggle initialized with the provided value.
func NewStreamLogsToggle(initial bool) *StreamLogsToggle {
toggle := &StreamLogsToggle{}
toggle.enabled.Store(initial)
return toggle
}
// Enabled reports whether streaming is currently enabled.
func (t *StreamLogsToggle) Enabled() bool {
if t == nil {
return false
}
return t.enabled.Load()
}
// Toggle flips the current state and returns the new value.
func (t *StreamLogsToggle) Toggle() bool {
if t == nil {
return false
}
for {
current := t.enabled.Load()
if t.enabled.CompareAndSwap(current, !current) {
return !current
}
}
}
// WithStreamLogsToggle stores the toggle in the context for downstream use.
func WithStreamLogsToggle(ctx context.Context, toggle *StreamLogsToggle) context.Context {
if toggle == nil {
return ctx
}
return context.WithValue(ctx, streamLogsToggleKey{}, toggle)
}
// GetStreamLogsToggle retrieves the toggle stored in the context, if present.
func GetStreamLogsToggle(ctx context.Context) *StreamLogsToggle {
if toggle, ok := ctx.Value(streamLogsToggleKey{}).(*StreamLogsToggle); ok {
return toggle
}
return nil
}
package console
import (
"context"
"fmt"
"maps"
"os"
"sort"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/fatih/color"
"github.com/mattn/go-isatty"
"github.com/spf13/viper"
)
// HeaderMsg What to display in the header.
type HeaderMsg string
// TaskState reflects the current State of a task for display.
type TaskState struct {
Status string
SubStatus string // optional secondary detail line rendered below status
StartedAtSec int64
Progress *Progress
}
type TaskStateMap map[int]TaskState
type TaskStateMsg struct {
State TaskStateMap
}
func StartTaskUI(ctx context.Context) (context.Context, *tea.Program, func(tea.Msg)) {
msgCh := make(chan tea.Msg, 100)
// Closed when the tea program stops consuming msgCh, so producers can
// drop messages instead of blocking on a full channel after teardown.
uiDone := make(chan struct{})
// Because bubble tea manages the interrupt we overwrite
// the default cancellation mechanism in cmd_setup.go
wrappedCtx, cancel := context.WithCancel(ctx)
var opts []tea.ProgramOption
opts = append(opts, tea.WithContext(ctx))
useTea := UseTea()
if !useTea {
// If we're in daemon mode don't render the TUI
opts = append(opts, tea.WithInput(nil), tea.WithoutRenderer())
}
// Start the Bubbletea Program.
p := tea.NewProgram(initialModel(ctx, msgCh, cancel), opts...)
go func() {
defer close(uiDone)
errCh := make(chan error, 1)
go func() {
_, err := p.Run()
errCh <- err
}()
select {
case <-ctx.Done():
p.Quit()
return
case err := <-errCh:
if err != nil {
fmt.Println("Failed to start Bubble Tea Program:", err)
}
}
}()
// inspired by program.Send
sendFunc := func(msg tea.Msg) {
select {
case <-wrappedCtx.Done():
case <-uiDone:
case msgCh <- msg:
}
}
// Finally, also add the tea logger
return WithTeaLogger(wrappedCtx, p), p, sendFunc
}
func UseTea() bool {
if viper.GetBool("disable_tea") {
return false
}
return isatty.IsTerminal(os.Stdout.Fd())
}
// Bubbletea Model.
type model struct {
// the header message to keep updating
header string
// The tasks to keep updating
tasks map[int]TaskState
// The current tick (used for time display)
tick int
// Message channel for sending updates.
msgCh chan tea.Msg
// Mutex for the tasks map
tasksMutex sync.RWMutex
cancel context.CancelFunc
streamLogsToggle *StreamLogsToggle
}
func initialModel(ctx context.Context, msgCh chan tea.Msg, cancel context.CancelFunc) *model {
return &model{
header: "",
tasks: make(map[int]TaskState),
msgCh: msgCh,
tick: time.Now().Second(),
cancel: cancel,
streamLogsToggle: GetStreamLogsToggle(ctx),
}
}
func (m *model) Init() tea.Cmd {
// Start listening for messages and tick every second.
return tea.Batch(listenForMsg(m.msgCh), tickEvery())
}
func listenForMsg(msgCh chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-msgCh
}
}
type TickMsg time.Time
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch castMsg := msg.(type) {
case tea.KeyMsg:
switch castMsg.String() {
// React to ctrl+c
case "ctrl+c":
m.cancel()
green := color.New(color.FgGreen).SprintFunc()
printMessage := fmt.Sprintf("%s: Received interrupt signal, exiting...", green("INFO"))
return m, tea.Sequence(tea.Println(printMessage), tea.Quit)
case "s":
if m.streamLogsToggle != nil {
m.streamLogsToggle.Toggle()
return m, nil
}
}
case HeaderMsg:
m.header = string(castMsg)
case TaskStateMsg:
m.tasksMutex.Lock()
m.tasks = make(map[int]TaskState, len(castMsg.State))
maps.Copy(m.tasks, castMsg.State)
m.tasksMutex.Unlock()
case TickMsg:
m.tick++
return m, tickEvery()
}
return m, listenForMsg(m.msgCh)
}
func (m *model) View() string {
m.tasksMutex.RLock()
defer m.tasksMutex.RUnlock()
var s strings.Builder
label := m.streamLogsCommandLabel()
// Render header
if m.header != "" || label != "" {
if label == "" {
s.WriteString(m.header + "\n")
} else if m.header == "" {
s.WriteString(label + "\n")
} else {
s.WriteString(m.header + ": " + label + "\n")
}
}
// Render tasks in order:
// Tasks may be sparse so we need to sort the task ids and then loop
keys := make([]int, 0, len(m.tasks))
for k := range m.tasks {
keys = append(keys, k)
}
sort.Ints(keys)
indent := " "
progressIndent := " "
if m.header == "" {
indent = ""
progressIndent = " "
}
dim := color.New(color.Faint).SprintFunc()
for _, i := range keys {
if status, ok := m.tasks[i]; ok {
timePassed := int(time.Since(time.Unix(status.StartedAtSec, 0)).Seconds())
fmt.Fprintf(&s, "%s%s %ds\n", indent, status.Status, timePassed)
hasProgress := status.Progress != nil && status.Progress.shouldRender()
if status.SubStatus != "" {
connector := "╰─"
if hasProgress {
connector = "├─"
}
fmt.Fprintf(&s, "%s%s%s\n", progressIndent, connector, dim(status.SubStatus))
}
if hasProgress {
fmt.Fprintf(&s, "%s╰─%s\n", progressIndent, formatProgressBar(*status.Progress, 24))
}
}
}
return s.String()
}
func (m *model) streamLogsCommandLabel() string {
if m.streamLogsToggle == nil {
return ""
}
if m.streamLogsToggle.Enabled() {
return color.New(color.Bold).Sprint("(s)") + "top streaming logs"
}
return color.New(color.Bold).Sprint("(s)") + "tream logs"
}
package console
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
)
// TeaWriter bridges a subprocess (or zap) byte stream into a tea.Program.
//
// It line-buffers: bytes are accumulated and only emitted on complete newline
// boundaries, so a single logical line that the OS pipe splits across multiple
// Write calls renders as one line instead of several. Carriage returns within a
// line are collapsed to the final segment, so in-place progress animations
// (download bars, etc.) render as one line rather than one line per redraw.
type TeaWriter struct {
Program *tea.Program
mu sync.Mutex
buf []byte
isDone bool
}
func NewTeaWriter(program *tea.Program) *TeaWriter {
w := &TeaWriter{
Program: program,
}
go func() {
w.Program.Wait()
w.mu.Lock()
w.isDone = true
w.mu.Unlock()
}()
return w
}
func (w *TeaWriter) Write(b []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf = append(w.buf, b...)
for {
i := bytes.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := string(w.buf[:i])
w.buf = w.buf[i+1:]
w.emit(collapseCarriageReturns(line))
}
return len(b), nil
}
// Flush emits any buffered partial line (one without a trailing newline). Call
// after the producing stream has closed so trailing output is not lost.
func (w *TeaWriter) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.buf) == 0 {
return
}
line := string(w.buf)
w.buf = w.buf[:0]
w.emit(collapseCarriageReturns(line))
}
// emit writes a single newline-free line. Callers must hold w.mu.
func (w *TeaWriter) emit(line string) {
if UseTea() && !w.isDone {
w.Program.Println(line)
} else {
// Log directly to stdout in non-tty (e.g. CI) environments
fmt.Println(line)
}
}
func (w *TeaWriter) Sync() error {
w.Flush()
return nil
}
// collapseCarriageReturns reduces a line redrawn in place via carriage returns
// to the text that would remain visible: the segment after the last \r.
func collapseCarriageReturns(line string) string {
line = strings.TrimRight(line, "\r")
if i := strings.LastIndexByte(line, '\r'); i >= 0 {
line = line[i+1:]
}
return line
}
// StreamToggleWriter writes to the underlying writer only when the toggle is enabled.
type StreamToggleWriter struct {
writer io.Writer
toggle *StreamLogsToggle
}
// NewStreamToggleWriter wraps the provided writer with a toggle check.
func NewStreamToggleWriter(writer io.Writer, toggle *StreamLogsToggle) *StreamToggleWriter {
return &StreamToggleWriter{writer: writer, toggle: toggle}
}
func (w *StreamToggleWriter) Write(p []byte) (int, error) {
if w.toggle == nil || w.toggle.Enabled() {
return w.writer.Write(p)
}
return len(p), nil
}
package dag
import (
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
)
type CompletionMap map[label.TargetLabel]Completion
func (c CompletionMap) GetErrors() []error {
var errorList []error
for _, completion := range c {
if !completion.IsSuccess {
errorList = append(errorList, completion.Err)
}
}
return errorList
}
// TargetSuccessCount returns the number of successful targets and the number of cache hits.
func (c CompletionMap) TargetSuccessCount() (int, int) {
successCount := 0
cacheHits := 0
for _, completion := range c {
if completion.IsSuccess && completion.NodeType == model.TargetNode {
successCount++
if completion.CacheResult == CacheHit {
cacheHits++
}
}
}
return successCount, cacheHits
}
package dag
import (
"encoding/json"
"fmt"
"slices"
"sort"
"time"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
)
// DirectedTargetGraph represents a directed graph of build targets.
// It is used to represent the dependency graph of a project.
// In order to assert that it does not contain cycles, you can call hasCycle().
type DirectedTargetGraph struct {
nodes model.BuildNodeMap
// outEdges maps a node to its list of outgoing nodes,
// representing the directed outEdges in the graph.
outEdges map[label.TargetLabel][]model.BuildNode
// inEdges map a node to its list of incoming nodes,
// representing the directed inEdges in the graph.
inEdges map[label.TargetLabel][]model.BuildNode
}
// NewDirectedGraph creates and initializes a new directed graph.
func NewDirectedGraph() *DirectedTargetGraph {
return &DirectedTargetGraph{
nodes: make(model.BuildNodeMap),
outEdges: make(map[label.TargetLabel][]model.BuildNode),
inEdges: make(map[label.TargetLabel][]model.BuildNode),
}
}
func NewDirectedGraphFromMap(targetMap model.BuildNodeMap) *DirectedTargetGraph {
return &DirectedTargetGraph{
nodes: targetMap,
outEdges: make(map[label.TargetLabel][]model.BuildNode),
inEdges: make(map[label.TargetLabel][]model.BuildNode),
}
}
// NewDirectedGraphFromTargets Useful for testing.
func NewDirectedGraphFromTargets(nodes ...model.BuildNode) *DirectedTargetGraph {
return &DirectedTargetGraph{
nodes: model.BuildNodeMapFromNodes(nodes...),
outEdges: make(map[label.TargetLabel][]model.BuildNode),
inEdges: make(map[label.TargetLabel][]model.BuildNode),
}
}
func (g *DirectedTargetGraph) GetNodes() model.BuildNodeMap {
return g.nodes
}
func (g *DirectedTargetGraph) GetOutEdges() map[label.TargetLabel][]model.BuildNode {
return g.outEdges
}
func (g *DirectedTargetGraph) GetSelectedNodes() []model.BuildNode {
// Filter selected nodes and return them
var selectedNodes []model.BuildNode
for _, node := range g.nodes {
if node.GetIsSelected() {
selectedNodes = append(selectedNodes, node)
}
}
return selectedNodes
}
// GetSelectedSubgraph returns a new graph containing only selected nodes and edges between them.
// The returned graph preserves the edge relationships between selected nodes from the original graph.
func (g *DirectedTargetGraph) GetSelectedSubgraph() *DirectedTargetGraph {
subgraph := NewDirectedGraph()
// Add all selected nodes
for _, node := range g.nodes {
if node.GetIsSelected() {
subgraph.AddNode(node)
}
}
// Add edges between selected nodes
for fromLabel, toList := range g.outEdges {
from, exists := g.nodes[fromLabel]
if !exists || !from.GetIsSelected() {
continue
}
for _, to := range toList {
if to.GetIsSelected() {
_ = subgraph.AddEdge(from, to)
}
}
}
return subgraph
}
// AddNode idempotently adds a new node to the graph.
func (g *DirectedTargetGraph) AddNode(node model.BuildNode) {
if !g.hasNode(node) {
g.nodes[node.GetLabel()] = node
}
}
// AddEdge adds a directed edge between two nodes.
func (g *DirectedTargetGraph) AddEdge(from, to model.BuildNode) error {
if from == to {
return fmt.Errorf("cannot add self-loop for target %s", from.GetLabel())
}
if !g.hasNode(from) {
return fmt.Errorf("node %s does not exist in the graph", from.GetLabel())
}
if !g.hasNode(to) {
return fmt.Errorf("node %s does not exist in the graph", from.GetLabel())
}
g.outEdges[from.GetLabel()] = append(g.outEdges[from.GetLabel()], to)
g.inEdges[to.GetLabel()] = append(g.inEdges[to.GetLabel()], from)
return nil
}
func (g *DirectedTargetGraph) GetDependencies(target model.BuildNode) []model.BuildNode {
return g.inEdges[target.GetLabel()]
}
func (g *DirectedTargetGraph) GetTargetDependencies(node model.BuildNode) []*model.Target {
var targets []*model.Target
for _, dependency := range g.GetDependencies(node) {
if target, ok := dependency.(*model.Target); ok {
targets = append(targets, target)
}
}
return targets
}
func (g *DirectedTargetGraph) GetDependants(target model.BuildNode) []model.BuildNode {
return g.outEdges[target.GetLabel()]
}
// GetDescendants returns a list of nodes that are descendants (dependants) of the given node.
// Recurses via the outEdges of each node.
func (g *DirectedTargetGraph) GetDescendants(target model.BuildNode) []model.BuildNode {
var descendants []model.BuildNode
for _, descendant := range g.outEdges[target.GetLabel()] {
descendants = append(descendants, descendant)
// Recurse
recursiveDescendants := g.GetDescendants(descendant)
descendants = append(descendants, recursiveDescendants...)
}
return descendants
}
// GetAncestors returns a list of nodes that are ancestors (transitive dependencies) of the given node.
// Recurses via the inEdges of each node.
func (g *DirectedTargetGraph) GetAncestors(target model.BuildNode) []model.BuildNode {
var ancestors []model.BuildNode
for _, ancestor := range g.inEdges[target.GetLabel()] {
ancestors = append(ancestors, ancestor)
// Recurse
recursiveAncestors := g.GetAncestors(ancestor)
ancestors = append(ancestors, recursiveAncestors...)
}
return ancestors
}
// hasNode checks whether a node exists in the graph.
func (g *DirectedTargetGraph) hasNode(node model.BuildNode) bool {
if node == nil {
return false
}
return g.nodes[node.GetLabel()] != nil
}
// HasCycle detects if the directed graph has a cycle using Depth-First Search (DFS).
// It maintains three states for each node:
// - 0: unvisited
// - 1: visiting (currently in the recursion stack)
// - 2: visited (completely explored).
func (g *DirectedTargetGraph) HasCycle() bool {
_, hasCycle := g.FindCycle()
return hasCycle
}
// FindCycle returns the nodes that form a cycle in the graph, including the repeated
// starting node at the end of the slice to illustrate the full loop. The boolean return
// value indicates whether a cycle was found.
func (g *DirectedTargetGraph) FindCycle() ([]model.BuildNode, bool) {
visited := make(map[model.BuildNode]int) // 0: unvisited, 1: visiting, 2: visited
var stack []model.BuildNode
var cycle []model.BuildNode
var depthFirstSearch func(target model.BuildNode) bool
depthFirstSearch = func(target model.BuildNode) bool {
visited[target] = 1 // Mark as visiting
stack = append(stack, target)
for _, neighbor := range g.outEdges[target.GetLabel()] {
if visited[neighbor] == 0 {
if depthFirstSearch(neighbor) {
return true // Cycle detected in descendant
}
continue
}
if visited[neighbor] == 1 {
idx := -1
for i, v := range slices.Backward(stack) {
if v == neighbor {
idx = i
break
}
}
if idx == -1 {
cycle = []model.BuildNode{neighbor, neighbor}
} else {
cycle = append([]model.BuildNode{}, stack[idx:]...)
cycle = append(cycle, neighbor)
}
return true
}
}
stack = stack[:len(stack)-1]
visited[target] = 2 // Mark as visited
return false // No cycle detected in this branch
}
for _, node := range g.nodes.NodesAlphabetically() {
if visited[node] == 0 {
if depthFirstSearch(node) {
return cycle, true // Cycle detected starting from this node
}
}
}
return nil, false // No cycle detected in the entire graph
}
type CriticalPath struct {
Nodes []model.BuildNode
ExecutionDuration time.Duration
CacheDuration time.Duration
}
// FindCriticalPath returns the longest path in the graph based on execution + cache time.
// It also returns the total execution and cache durations for that path.
func (g *DirectedTargetGraph) FindCriticalPath() (CriticalPath, bool) {
if len(g.nodes) == 0 {
return CriticalPath{}, false
}
indegree := make(map[label.TargetLabel]int)
for lbl := range g.nodes {
indegree[lbl] = len(g.inEdges[lbl])
}
var queue []model.BuildNode
for lbl, node := range g.nodes {
if indegree[lbl] == 0 {
queue = append(queue, node)
}
}
var order []model.BuildNode
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
order = append(order, node)
for _, neighbor := range g.outEdges[node.GetLabel()] {
indegree[neighbor.GetLabel()]--
if indegree[neighbor.GetLabel()] == 0 {
queue = append(queue, neighbor)
}
}
}
if len(order) == 0 || len(order) != len(g.nodes) {
return CriticalPath{}, false
}
type pathInfo struct {
path []model.BuildNode
exec time.Duration
cache time.Duration
}
getDurations := func(node model.BuildNode) (time.Duration, time.Duration) {
if target, ok := node.(*model.Target); ok {
return target.ExecutionTime, target.CacheTime
}
return 0, 0
}
best := make(map[label.TargetLabel]pathInfo)
for _, node := range order {
current, ok := best[node.GetLabel()]
if !ok {
execDuration, cacheDuration := getDurations(node)
current = pathInfo{
path: []model.BuildNode{node},
exec: execDuration,
cache: cacheDuration,
}
best[node.GetLabel()] = current
}
for _, neighbor := range g.outEdges[node.GetLabel()] {
neighborExec, neighborCache := getDurations(neighbor)
candidateExec := current.exec + neighborExec
candidateCache := current.cache + neighborCache
candidateTotal := candidateExec + candidateCache
existing, exists := best[neighbor.GetLabel()]
existingTotal := existing.exec + existing.cache
if !exists || candidateTotal > existingTotal {
pathCopy := append([]model.BuildNode{}, current.path...)
pathCopy = append(pathCopy, neighbor)
best[neighbor.GetLabel()] = pathInfo{
path: pathCopy,
exec: candidateExec,
cache: candidateCache,
}
}
}
}
var bestPath pathInfo
var found bool
for _, info := range best {
total := info.exec + info.cache
bestTotal := bestPath.exec + bestPath.cache
if !found || total > bestTotal {
bestPath = info
found = true
}
}
if !found {
return CriticalPath{}, false
}
return CriticalPath{
Nodes: bestPath.path,
ExecutionDuration: bestPath.exec,
CacheDuration: bestPath.cache,
}, true
}
// GraphJSON is a helper struct for JSON serialization.
type GraphJSON struct {
Nodes []model.BuildNode `json:"nodes"`
Edges map[string][]string `json:"edges"` // from label to label
}
func (g *DirectedTargetGraph) LogSelectedNodes() {
for _, node := range g.nodes.SelectedNodesAlphabetically() {
fmt.Println(node.GetLabel())
}
}
// MarshalJSON serializes the graph to JSON.
func (g *DirectedTargetGraph) MarshalJSON() ([]byte, error) {
graphJSON := GraphJSON{
Nodes: g.nodes.NodesAlphabetically(),
Edges: map[string][]string{},
}
for from, toList := range g.outEdges {
fromLabel := from
var toLabels []string
for _, to := range toList {
toLabels = append(toLabels, to.GetLabel().String())
}
sort.Strings(toLabels)
graphJSON.Edges[fromLabel.String()] = toLabels
}
return json.Marshal(graphJSON)
}
package dag
import (
"context"
"errors"
"sync"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
)
type CacheResult int
const (
// CacheHit found the cache data and loaded it successfully.
CacheHit CacheResult = iota
// CacheSkip cache was intentionally skipped (does not invalidate downstream targets!)
CacheSkip
// CacheMiss either did not find the cache data or failed to load it or there was some other error.
CacheMiss
)
// WalkCallback is called for each target and should return true if the target was cached.
type WalkCallback func(ctx context.Context, node model.BuildNode) (CacheResult, error)
type Completion struct {
IsSuccess bool
NodeType model.NodeType
CacheResult CacheResult
Err error
}
type nodeInfo struct {
// node routine sends this when it's done
done chan Completion
// node routine receives this when it's ready
ready chan any
// node routine receives this when it is supposed to stop
cancel chan any
cancelOnce sync.Once
}
// Walker walks the graph in topological order.
// Not thread-safe.
type Walker struct {
graph *DirectedTargetGraph
walkCallback WalkCallback
nodeInfoMap map[label.TargetLabel]*nodeInfo
// Keep track of which targets have been completed
completions CompletionMap
// Options
failFast bool
// Set to true if failFast was triggered
failFastTriggered bool
allCancel context.CancelFunc
// Concurrency
// doneMutex protects completions
doneMutex sync.Mutex
// nodeMutex protects nodeInfoMap
nodeMutex sync.Mutex
wait sync.WaitGroup
}
func NewWalker(graph *DirectedTargetGraph, walkFunc WalkCallback, failFast bool) *Walker {
return &Walker{
graph: graph,
walkCallback: walkFunc,
nodeInfoMap: map[label.TargetLabel]*nodeInfo{},
completions: map[label.TargetLabel]Completion{},
failFast: failFast,
}
}
/*
Walk For each node generate an info payload containing
- a done channel
- a cancel channel
- a start channel
Procedure:
- Start all routines that do not have dependencies
- For each routine there is a fanout onComplete function
- When it receives a doneCh it marks the worker as completed
- The onComplete then checks for each *dependant* if all their dependencies are satisfied
- If that is the case we send a message to the dependant's readyCh to start them
- In case of failure we cancel them and in case of failFast we cancel all outstanding targets
Note: We do not start routines for targets that are not selected. Therefore,
we need to check if descendants exist before starting/cancelling them.
Walk will then wait for all the goroutines to finish.
*/
func (w *Walker) Walk(
ctx context.Context,
) (CompletionMap, error) {
logger := console.GetLogger(ctx)
if w.walkCallback == nil {
return nil, errors.New("walk callback is nil")
}
ctx, cancelFunc := context.WithCancel(ctx)
w.allCancel = cancelFunc
// Populate nodeInfoMap fully before starting any node: a node started mid-loop
// could finish and startNode a dependant not yet in the map, racing this
// unlocked write against startNode's locked read.
for _, node := range w.graph.nodes {
if !node.GetIsSelected() {
// skip unselected targets
continue
}
doneCh := make(chan Completion, 1)
readyCh := make(chan any, 1)
cancelCh := make(chan any, 1)
info := &nodeInfo{
done: doneCh,
ready: readyCh,
cancel: cancelCh,
}
w.nodeInfoMap[node.GetLabel()] = info
w.wait.Add(1)
// start all routines
go w.nodeRoutine(ctx, node, info)
}
// Map is fully populated; now start the no-dependency nodes.
for _, node := range w.graph.nodes {
if !node.GetIsSelected() {
continue
}
if len(w.graph.inEdges[node.GetLabel()]) == 0 {
w.startNode(node)
}
}
// Wait for all goroutines to complete
done := make(chan struct{})
go func() {
w.wait.Wait()
close(done)
}()
select {
case <-done:
return w.completions, nil
case <-ctx.Done():
logger.Debugf(
"context cancelled, cancelling all workers",
)
w.cancelAll()
if w.failFastTriggered {
return w.completions, nil
} else {
return w.completions, ctx.Err()
}
}
}
// cancelNode cancels a target if it is present in the graph (not idempotent!)
func (w *Walker) cancelNode(node model.BuildNode) {
w.nodeMutex.Lock()
defer w.nodeMutex.Unlock()
// cancel the node routine
if info, ok := w.nodeInfoMap[node.GetLabel()]; ok {
info.cancelOnce.Do(func() {
close(info.cancel)
})
}
}
// startNode sends a ready message to a target if it is present in the graph (not idempotent!)
func (w *Walker) startNode(node model.BuildNode) {
w.nodeMutex.Lock()
defer w.nodeMutex.Unlock()
if info, ok := w.nodeInfoMap[node.GetLabel()]; ok {
go func() {
info.ready <- true
}()
}
}
// onComplete called when a node is done
// - fans out ready messages to dependants (if their deps are satisfied)
// - in case of failure, cancels all dependants (or the entire walk if failFast=true).
func (w *Walker) onComplete(node model.BuildNode, completion Completion) {
w.doneMutex.Lock()
defer w.doneMutex.Unlock()
// Mark node as done
completion.NodeType = node.GetType()
w.completions[node.GetLabel()] = completion
if w.failFastTriggered {
// If failFast was triggered, we assume everything is being cancelled already
return
}
if !completion.IsSuccess {
// If failFast is true, cancel the entire walk
if w.failFast {
w.failFastTriggered = true
w.allCancel()
w.cancelAll()
} else {
// Cancel *all* descendants if the node failed
for _, dep := range w.graph.GetDescendants(node) {
w.cancelNode(dep)
}
}
return
}
// Iterate over all dependants and send a ready message
// if their deps are satisfied
for _, dependant := range w.graph.outEdges[node.GetLabel()] {
// Check if dependant deps are satisfied
depsDone := true
for _, dep := range w.graph.inEdges[dependant.GetLabel()] {
depCompletion, ok := w.completions[dep.GetLabel()]
if !ok || !depCompletion.IsSuccess {
depsDone = false
}
}
// If yes, send ready message to dependant
if depsDone {
w.startNode(dependant)
}
}
}
func (w *Walker) cancelAll() {
for _, node := range w.graph.nodes {
go func(v model.BuildNode) {
w.cancelNode(v)
}(node)
}
}
func (w *Walker) nodeRoutine(
ctx context.Context,
node model.BuildNode,
info *nodeInfo,
) {
// always decrement wait group
defer w.wait.Done()
select {
case <-info.cancel:
return
case <-info.ready:
// call the callback
cacheResult, err := w.walkCallback(ctx, node)
if err != nil {
if errors.Is(err, context.Canceled) {
// Cancelling externally or via failFast leaves target uncompleted
return
}
// don't account for cache hits in errors
w.onComplete(node, Completion{IsSuccess: false, Err: err})
} else {
w.onComplete(node, Completion{IsSuccess: true, Err: nil, CacheResult: cacheResult})
}
return
}
}
package execution
import (
"context"
"fmt"
"sync/atomic"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/output"
"github.com/chrismatix/grog/internal/output/handlers"
"github.com/chrismatix/grog/internal/worker"
)
// CacheWriter bridges the gap between output preparation (which must be synchronous) and
// cache persistence (which can be deferred).
//
// When a target finishes executing, its output handler's Write() method runs on the task
// worker to compute output hashes and stage immutable snapshots of the output data. This
// produces a PreparedTargetResult containing the output hash (needed immediately by the
// DAG walker to unblock downstream targets) and a set of OutputWritePlans that capture
// the actual I/O work — uploading staged files to the CAS, pushing Docker images, etc.
//
// CacheWriter decides where that I/O work runs:
//
// - Sync mode (asyncWrites=false): write plans execute inline on the task worker.
// The worker blocks until all uploads and the target cache entry are written.
// Failures are fatal and propagated to the build.
//
// - Async mode (asyncWrites=true): write plans are submitted to a dedicated I/O worker
// pool via RunFireAndForget. The task worker returns immediately after hash computation,
// freeing it for the next target. I/O workers run with a non-cancellable context so
// cache writes can finish even if the build is interrupted. Failures are logged as
// warnings — a failed cache write only means the next build won't get a cache hit.
//
// After all write plans in a batch succeed, CacheWriter writes the TargetResult proto
// to the target cache (the metadata index that maps input hashes to output hashes).
// Cleanup() is called on every write plan regardless of outcome to remove staged temp files.
type CacheWriter struct {
targetCache *caching.TargetResultCache
ioPool *worker.TaskWorkerPool[struct{}]
asyncWrites bool
ioContext context.Context
pendingCount atomic.Int64
}
func NewCacheWriter(
targetCache *caching.TargetResultCache,
ioPool *worker.TaskWorkerPool[struct{}],
asyncWrites bool,
ioContext context.Context,
) *CacheWriter {
return &CacheWriter{
targetCache: targetCache,
ioPool: ioPool,
asyncWrites: asyncWrites,
ioContext: ioContext,
}
}
func (c *CacheWriter) PersistPreparedTarget(
ctx context.Context,
targetLabel string,
preparedTarget *output.PreparedTargetResult,
update worker.StatusFunc,
) error {
if preparedTarget == nil {
return fmt.Errorf("prepared target result for %s is nil", targetLabel)
}
if !c.asyncWrites || len(preparedTarget.WritePlans) == 0 {
progress := worker.NewProgressTracker(
fmt.Sprintf("%s: writing cache", targetLabel),
0,
update,
)
return c.commit(ctx, targetLabel, preparedTarget, progress, true)
}
c.pendingCount.Add(1)
err := c.ioPool.RunFireAndForget(func(ioUpdate worker.StatusFunc) (struct{}, error) {
defer c.pendingCount.Add(-1)
progress := worker.NewProgressTracker(
fmt.Sprintf("%s: writing cache", targetLabel),
0,
ioUpdate,
)
if commitErr := c.commit(c.ioContext, targetLabel, preparedTarget, progress, false); commitErr != nil {
console.GetLogger(c.ioContext).Warnf("async cache write error for %s (non-fatal): %v", targetLabel, commitErr)
}
return struct{}{}, nil
})
if err != nil {
c.pendingCount.Add(-1)
c.cleanupPlans(ctx, preparedTarget.WritePlans)
return err
}
return nil
}
func (c *CacheWriter) Wait() {
c.ioPool.WaitForCompletion()
}
// HasPendingWrites reports whether any async cache writes are still in flight.
func (c *CacheWriter) HasPendingWrites() bool {
return c.pendingCount.Load() > 0
}
func (c *CacheWriter) commit(
ctx context.Context,
targetLabel string,
preparedTarget *output.PreparedTargetResult,
progress *worker.ProgressTracker,
failureIsFatal bool,
) error {
for _, writePlan := range preparedTarget.WritePlans {
if err := writePlan.Execute(ctx, progress); err != nil {
c.cleanupPlans(ctx, preparedTarget.WritePlans)
if failureIsFatal {
return err
}
return fmt.Errorf("skipping target cache publication for %s: %w", targetLabel, err)
}
}
if err := c.targetCache.Write(ctx, preparedTarget.TargetResult); err != nil {
c.cleanupPlans(ctx, preparedTarget.WritePlans)
return err
}
c.cleanupPlans(ctx, preparedTarget.WritePlans)
return nil
}
func (c *CacheWriter) cleanupPlans(ctx context.Context, writePlans []handlers.OutputWritePlan) {
for _, writePlan := range writePlans {
if err := writePlan.Cleanup(ctx); err != nil {
console.GetLogger(ctx).Warnf("failed to clean up cache write plan: %v", err)
}
}
}
package execution
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/hashing"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output"
"github.com/chrismatix/grog/internal/output/handlers"
"github.com/chrismatix/grog/internal/output/push"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/sync/singleflight"
"google.golang.org/protobuf/encoding/protojson"
)
type CommandError struct {
TargetLabel label.TargetLabel
ExitCode int
Output string
}
func (e *CommandError) Error() string {
return fmt.Sprintf("target %s failed with exit code %d: %s", e.TargetLabel, e.ExitCode, e.Output)
}
type Executor struct {
targetCache *caching.TargetResultCache
taintStore *caching.TaintStore
registry *output.Registry
pusher *push.Pusher
graph *dag.DirectedTargetGraph
failFast bool
enableCache bool
loadOutputsMode config.LoadOutputsMode
targetHasher *hashing.TargetHasher
streamLogsToggle *console.StreamLogsToggle
coordinator *PoolCoordinator
scheduler *Scheduler
cacheWriter *CacheWriter
asyncWaitTime time.Duration
deferAsyncWait bool
asyncDrained bool
rerunGroup singleflight.Group
resourceManager *ResourceManager
}
func NewExecutor(
targetCache *caching.TargetResultCache,
taintStore *caching.TaintStore,
registry *output.Registry,
pusher *push.Pusher,
graph *dag.DirectedTargetGraph,
failFast bool,
streamLogs bool,
enableCache bool,
loadOutputsMode config.LoadOutputsMode,
) *Executor {
return &Executor{
targetCache: targetCache,
taintStore: taintStore,
registry: registry,
pusher: pusher,
graph: graph,
failFast: failFast,
enableCache: enableCache,
loadOutputsMode: loadOutputsMode,
targetHasher: hashing.NewTargetHasher(graph),
streamLogsToggle: console.NewStreamLogsToggle(streamLogs),
resourceManager: NewResourceManager(),
}
}
// Execute executes the targets in the given graph and returns the completion map.
func (e *Executor) Execute(ctx context.Context) (dag.CompletionMap, error) {
numWorkers := config.Global.NumWorkers
numAsyncWriters := config.Global.NumAsyncWriters
if numAsyncWriters < 1 {
numAsyncWriters = numWorkers * 3
}
stdLogger := console.GetLogger(ctx)
ctx = console.WithStreamLogsToggle(ctx, e.streamLogsToggle)
// Include extra args in target hashes so that different "--" flags bust the cache
if extraArgs := ExtraArgsFromContext(ctx); len(extraArgs) > 0 {
e.targetHasher.SetExtraArgs(extraArgs)
}
ctx, program, sendMsg := console.StartTaskUI(ctx)
defer func(p *tea.Program) {
err := p.ReleaseTerminal()
if err != nil {
stdLogger.Errorf("error releasing terminal: %v", err)
}
}(program)
defer program.Quit()
// Get selected nodes and create a ResultLogger covering every selected
// target so that build and test completion lines render as one aligned
// table.
selectedNodes := e.graph.GetSelectedNodes()
selectedNodeCount := len(selectedNodes)
var targetLabels []string
for _, node := range selectedNodes {
if _, ok := node.(*model.Target); ok {
targetLabels = append(targetLabels, node.GetLabel().String())
}
}
resultLogger := console.NewResultLogger(targetLabels, 0) // 0 means use default terminal width
ctx = context.WithValue(ctx, console.ResultLoggerKey{}, resultLogger)
coordinator := NewPoolCoordinator(stdLogger, numWorkers, numAsyncWriters, sendMsg, selectedNodeCount)
coordinator.StartTaskWorkers(ctx)
// The task pool is only used during the DAG walk below; shutting it down
// here (LIFO after the walker returns) is safe. The I/O pool, however,
// must stay alive until drainAsyncWrites runs — callers that set
// DeferAsyncWait (e.g. `grog run` with load_outputs=minimal) may submit
// further cache writes from post-build work after Execute returns.
defer coordinator.TaskPool().Shutdown()
e.coordinator = coordinator
e.scheduler = NewScheduler(coordinator.TaskPool())
ioContext := context.WithoutCancel(ctx)
coordinator.StartIOWorkers(ioContext)
e.cacheWriter = NewCacheWriter(e.targetCache, coordinator.IOPool(), config.Global.AsyncCacheWrites, ioContext)
// walkCallback will be called at max parallelism by the graph walker
walkCallback := func(ctx context.Context, node model.BuildNode) (dag.CacheResult, error) {
target, ok := node.(*model.Target)
if !ok {
// Aliases complete trivially; resources start lazily when the
// first dependent target executes (see ResourceManager), so fully
// cached builds never pay for a resource start.
return dag.CacheHit, nil
}
// get any possible bin tools that the node may use
binTools, err := e.getBinToolPaths(target)
if err != nil {
return dag.CacheMiss, err
}
outputIdentifiers := e.getDependencyOutputIdentifiers(target)
transitiveOutputs := e.getTransitiveOutputs(target)
taggedOutputs := e.getTransitiveOutputsByTag(target)
hashStart := time.Now()
err = e.targetHasher.SetTargetChangeHash(target)
target.HashDuration = time.Since(hashStart)
if err != nil {
return dag.CacheMiss, err
}
queuedAt := time.Now()
// taskFunc will be run in the worker pool, gated by the scheduler on
// any concurrency group membership.
taskFunc := e.getTaskFunc(ctx, target, binTools, outputIdentifiers, transitiveOutputs, taggedOutputs, queuedAt)
return e.scheduler.Schedule(ctx, target, taskFunc)
}
walker := dag.NewWalker(e.graph, walkCallback, e.failFast)
completionMap, err := walker.Walk(ctx)
// Emit any buffered per-target result lines (deterministic mode only;
// otherwise they were streamed as targets completed).
resultLogger.Flush(stdLogger)
// Tear down started resources even when the build was interrupted, hence
// the non-cancellable context.
e.resourceManager.TeardownAll(context.WithoutCancel(ctx))
// Drain the I/O pool before returning, but only if the build was not
// interrupted and the caller hasn't opted to wait itself. Async cache
// writes use a non-cancellable context so they can outlive the build,
// but we must not block an interrupted shutdown on slow remote uploads.
if ctx.Err() == nil && !e.deferAsyncWait {
e.drainAsyncWrites()
} else if ctx.Err() != nil {
// Build was interrupted — release I/O workers so the process can exit.
coordinator.IOPool().Shutdown()
}
return completionMap, err
}
// DeferAsyncWait opts out of the implicit cache-write wait in Execute so the
// caller can run other work in parallel with uploads, then call
// WaitForAsyncWrites when ready.
func (e *Executor) DeferAsyncWait() {
e.deferAsyncWait = true
}
// WaitForAsyncWrites blocks until all outstanding async cache writes complete,
// recording the elapsed time as AsyncWaitTime. Idempotent — safe to call
// whether or not Execute already waited. Logs a status line when writes are
// still in flight so the user understands the wait.
func (e *Executor) WaitForAsyncWrites(ctx context.Context) {
if e.asyncDrained || e.cacheWriter == nil || ctx.Err() != nil {
return
}
if e.cacheWriter.HasPendingWrites() {
console.GetLogger(ctx).Infof("Waiting for cache writes to finish in the background...")
}
e.drainAsyncWrites()
}
func (e *Executor) drainAsyncWrites() {
waitStart := time.Now()
e.cacheWriter.Wait()
// All pending writes are done — release the I/O workers. Shutdown is
// idempotent, so calling this again from a subsequent WaitForAsyncWrites
// is harmless.
e.coordinator.IOPool().Shutdown()
e.asyncWaitTime = time.Since(waitStart)
e.asyncDrained = true
}
// AsyncWaitTime returns the wall-clock time spent waiting for the async I/O
// pool to drain after the DAG walk completed. Zero when async writes are
// disabled or the build was interrupted.
func (e *Executor) AsyncWaitTime() time.Duration {
return e.asyncWaitTime
}
// getBinToolPaths From all the direct dependencies of a target, get their bin_output if defined.
func (e *Executor) getBinToolPaths(target *model.Target) (BinToolMap, error) {
deps := e.graph.GetTargetDependencies(target)
binTools := make(map[string]string, 0)
for _, dep := range deps {
if dep.HasBinOutput() {
// Say a target in pkg foo/bar defines a bin output pointing to ../dist/bin.exe
// then we want to resolve it to /workspace_path/foo/dist/bin.exe
binToolPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(dep.Label.Package, dep.BinOutput.Identifier))
binTools[dep.Label.String()] = binToolPath
// If the dependency is in the same package we want to allow the shorthand
// syntax for invoking the tool, i.e. $(bin :tool)
if dep.Label.Package == target.Label.Package {
binTools[":"+dep.Label.Name] = binToolPath
}
// Likewise allow for the //foo:foo -> //foo shorthand
if dep.Label.CanBeShortened() {
binTools["//"+dep.Label.Package] = binToolPath
}
}
}
return binTools, nil
}
// getDependencyOutputIdentifiers builds the output map available to the shell
// helper functions for a target's direct dependencies.
func (e *Executor) getDependencyOutputIdentifiers(target *model.Target) OutputIdentifierMap {
deps := e.graph.GetTargetDependencies(target)
outputIdentifiers := make(OutputIdentifierMap, 0)
for _, dep := range deps {
depOutputs := getTargetOutputIdentifiers(dep)
if len(depOutputs) == 0 {
continue
}
outputIdentifiers[dep.Label.String()] = depOutputs
if dep.Label.Package == target.Label.Package {
outputIdentifiers[":"+dep.Label.Name] = depOutputs
}
if dep.Label.CanBeShortened() {
outputIdentifiers["//"+dep.Label.Package] = depOutputs
}
}
return outputIdentifiers
}
// getTransitiveOutputs walks the full ancestor graph of the given target and
// returns a deduplicated list of all output identifiers produced by every
// transitive dependency, regardless of tags.
func (e *Executor) getTransitiveOutputs(target *model.Target) []string {
ancestors := e.graph.GetAncestors(target)
var result []string
seen := make(map[string]bool)
for _, ancestor := range ancestors {
ancestorTarget, ok := ancestor.(*model.Target)
if !ok {
continue
}
for _, out := range getTargetOutputIdentifiers(ancestorTarget) {
if !seen[out] {
seen[out] = true
result = append(result, out)
}
}
}
return result
}
// getTransitiveOutputsByTag walks the full ancestor graph of the given target,
// collects outputs from ancestors that carry at least one tag, and returns
// them bucketed by tag. Outputs are deduplicated per tag. Ancestors without
// tags or without outputs are silently skipped.
func (e *Executor) getTransitiveOutputsByTag(target *model.Target) TransitiveTaggedOutputs {
ancestors := e.graph.GetAncestors(target)
result := make(TransitiveTaggedOutputs)
seen := make(map[string]map[string]bool) // tag → set of output identifiers
for _, ancestor := range ancestors {
ancestorTarget, ok := ancestor.(*model.Target)
if !ok {
continue
}
outputs := getTargetOutputIdentifiers(ancestorTarget)
if len(outputs) == 0 {
continue
}
for _, tag := range ancestorTarget.Tags {
if seen[tag] == nil {
seen[tag] = make(map[string]bool)
}
for _, out := range outputs {
if !seen[tag][out] {
seen[tag][out] = true
result[tag] = append(result[tag], out)
}
}
}
}
return result
}
func getTargetOutputIdentifiers(target *model.Target) []string {
var identifiers []string
for _, targetOutput := range target.AllOutputs() {
if !targetOutput.IsSet() {
continue
}
if targetOutput.Type == string(handlers.FileHandler) || targetOutput.Type == string(handlers.DirHandler) {
workspaceRelativePath := filepath.Join(target.Label.Package, targetOutput.Identifier)
identifiers = append(identifiers, config.GetPathAbsoluteToWorkspaceRoot(workspaceRelativePath))
continue
}
identifiers = append(identifiers, targetOutput.Identifier)
}
return identifiers
}
func (e *Executor) getTaskFunc(
ctx context.Context,
target *model.Target,
binToolPaths BinToolMap,
outputIdentifiers OutputIdentifierMap,
transitiveOutputs []string,
taggedOutputs TransitiveTaggedOutputs,
queuedAt time.Time,
) worker.TaskFunc[dag.CacheResult] {
return func(update worker.StatusFunc) (dag.CacheResult, error) {
startTime := time.Now()
target.StartTime = startTime
target.QueueWait = startTime.Sub(queuedAt)
logger := console.GetLogger(ctx)
update(worker.Status(fmt.Sprintf("%s: checking cache", target.Label)))
cacheCheckStart := time.Now()
targetResult, err := e.targetCache.Load(ctx, target.ChangeHash)
if err != nil && logger.DebugEnabled() {
// TODO distinguish between NotFound and cache backend errors.
logger.Debugf("failed to check target %s cache: %v", target.Label, err)
}
target.HasCacheHit = targetResult != nil
if logger.DebugEnabled() {
logger.Debugf("%s: loaded target result %s", target.Label, formatTargetResultForDebug(targetResult))
}
outputCheckErr := runOutputChecks(ctx, target, binToolPaths, outputIdentifiers, nil)
if outputCheckErr != nil {
logger.Debugf("running target due to output check error: %v", outputCheckErr)
}
// Check if the target is tainted
isTainted, taintedErr := e.taintStore.IsTainted(ctx, target.Label)
if taintedErr != nil {
logger.Errorf("Failed to check if target %s is tainted: %v", target.Label, taintedErr)
}
target.CacheCheckTime = time.Since(cacheCheckStart)
if isTainted {
logger.Debugf("running target %s due to being tainted", target.Label)
}
// Process a cache hit if:
// - The target result was loaded (HasCacheHit)
// - The target is not tainted (!isTainted)
// - The target does not have no-cache set (!target.SkipsCache)
// - The cache is enabled (enableCache)
if target.HasCacheHit && !isTainted && !target.SkipsCache() && e.enableCache {
if e.loadOutputsMode == config.LoadOutputsMinimal {
// Important: Set the output hash so that descendants can compute their change hashes
target.OutputHash = targetResult.OutputHash
update(worker.Status(fmt.Sprintf("%s: cache hit, skipping output load (load_outputs=minimal)", target.Label)))
logger.Debugf("%s: cache hit. skipped loading %s because load_ outputs=minimal", target.Label, console.FCountOutputs(len(target.AllOutputs())))
// Pushes read the image from the cache rather than from the
// loaded outputs, so --push still has to run for a target
// whose outputs we deliberately never load.
e.pushCachedTarget(ctx, target, targetResult, worker.NewProgressTracker(
fmt.Sprintf("%s: pushing", target.Label), 0, update,
))
logTargetCached(ctx, logger, target, float64(targetResult.ExecutionDurationMillis)/1000)
return dag.CacheHit, nil
}
update(worker.Status(fmt.Sprintf("%s: cache hit, loading %s", target.Label, console.FCountOutputs(len(target.AllOutputs())))))
progress := worker.NewProgressTracker(
fmt.Sprintf("%s: loading %s", target.Label, console.FCountOutputs(len(target.AllOutputs()))),
0,
update,
)
outputLoadStart := time.Now()
loadingErr := e.registry.LoadOutputs(ctx, target, targetResult, progress)
target.OutputLoadTime = time.Since(outputLoadStart)
target.CacheTime += target.OutputLoadTime
if loadingErr != nil {
// Don't return so that we instead break out and continue executing the target
logger.Errorf("%s re-running due to output loading failure: %v", target.Label, loadingErr)
} else {
e.pushCachedTarget(ctx, target, targetResult, progress)
// Log the cached execution time recorded when the target was
// originally built, not the (near-zero) cache-load time.
logTargetCached(ctx, logger, target, float64(targetResult.ExecutionDurationMillis)/1000)
return dag.CacheHit, nil
}
}
if !target.HasCacheHit {
logger.Debugf("running target %s due to cache miss", target.Label)
}
if outputCheckErr != nil {
logger.Debugf("running target %s due to output check error", target.Label)
}
if e.loadOutputsMode == config.LoadOutputsMinimal {
update(worker.Status(fmt.Sprintf("%s: loading dependency outputs (load_outputs=minimal)", target.Label)))
depLoadStart := time.Now()
if loadDepsErr := e.LoadDependencyOutputs(ctx, target, update); loadDepsErr != nil {
return dag.CacheMiss, fmt.Errorf("failed to load dependency outputs for target %s: %w", target.Label, loadDepsErr)
}
target.DepLoadTime = time.Since(depLoadStart)
}
return e.executeTarget(ctx, target, binToolPaths, outputIdentifiers, transitiveOutputs, taggedOutputs, update, isTainted)
}
}
// pushCachedTarget ships a cache-hit target's oci_push destinations. Push
// failures belong in the end-of-build summary, so they must not turn a cache
// hit into a re-run; only a malformed oci_push map is worth logging here.
func (e *Executor) pushCachedTarget(
ctx context.Context,
target *model.Target,
targetResult *gen.TargetResult,
progress *worker.ProgressTracker,
) {
if err := e.pusher.PushFromCache(ctx, target, targetResult, progress); err != nil {
console.GetLogger(ctx).Errorf("%s: %v", target.Label, err)
}
}
func formatTargetResultForDebug(targetResult *gen.TargetResult) string {
if targetResult == nil {
return "<nil>"
}
marshalledResult, err := protojson.MarshalOptions{
EmitUnpopulated: true,
UseProtoNames: true,
}.Marshal(targetResult)
if err != nil {
return fmt.Sprintf("%v", targetResult)
}
return string(marshalledResult)
}
// logTargetBuilt logs a freshly built/passed target, dispatching to the test
// or build wording based on the target kind.
func logTargetBuilt(ctx context.Context, logger *console.Logger, target *model.Target, seconds float64) {
resultLogger := console.GetResultLogger(ctx)
if resultLogger == nil {
return
}
if target.IsTest() {
resultLogger.LogTestPassed(logger, target.Label.String(), seconds)
} else {
resultLogger.LogBuilt(logger, target.Label.String(), seconds)
}
}
// logTargetCached logs a cache-hit target, dispatching to the test or build
// wording based on the target kind. seconds is the execution time recorded when
// the target was originally built, not the cache-load time.
func logTargetCached(ctx context.Context, logger *console.Logger, target *model.Target, seconds float64) {
resultLogger := console.GetResultLogger(ctx)
if resultLogger == nil {
return
}
if target.IsTest() {
resultLogger.LogTestPassedCached(logger, target.Label.String(), seconds)
} else {
resultLogger.LogBuiltCached(logger, target.Label.String(), seconds)
}
}
func (e *Executor) executeTarget(
ctx context.Context,
target *model.Target,
binToolPaths BinToolMap,
outputIdentifiers OutputIdentifierMap,
transitiveOutputs []string,
taggedOutputs TransitiveTaggedOutputs,
update worker.StatusFunc,
isTainted bool,
) (dag.CacheResult, error) {
logger := console.GetLogger(ctx)
startTime := time.Now()
var err error
var resourceEnvironment []string
if target.Command != "" {
resourceEnvironment, err = e.resourceManager.EnsureResourcesStarted(ctx, e.graph, target, update)
if err == nil {
update(worker.Status(fmt.Sprintf("%s: running \"%s\"", target.Label, target.CommandEllipsis())))
logger.Debugf("running target %s: %s", target.Label, target.CommandEllipsis())
ociImagesBefore := e.registry.SnapshotOciImages(ctx, target)
err = executeTarget(ctx, target, binToolPaths, outputIdentifiers, transitiveOutputs, taggedOutputs, resourceEnvironment, e.streamLogsToggle.Enabled())
if err == nil {
e.registry.WarnOnUnproducedOciImages(ctx, target, ociImagesBefore)
}
}
} else {
logger.Debugf("skipped target %s due to no command", target.Label)
}
target.ExecutionTime = time.Since(startTime)
executionTimeSeconds := target.ExecutionTime.Seconds()
if err != nil {
logger.Debugf("target execution returned error %s: %s", target.Label, err)
// Cancellations should just exit the execution without a result line.
if !errors.Is(err, context.Canceled) {
if resultLogger := console.GetResultLogger(ctx); resultLogger != nil {
resultLogger.LogFailed(logger, target.Label.String(), target.ExecutionTime)
}
}
return dag.CacheMiss, err
}
// Run output checks again to see if they match now
if outputCheckErr := runOutputChecks(ctx, target, binToolPaths, outputIdentifiers, resourceEnvironment); outputCheckErr != nil {
return dag.CacheMiss, outputCheckErr
}
// Write outputs to the cache:
logger.Debugf("writing outputs for target %s", target.Label)
update(worker.Status(fmt.Sprintf("%s: writing outputs", target.Label)))
err = e.OnTargetComplete(ctx, target, update)
if err != nil {
return dag.CacheMiss, fmt.Errorf("build completed but failed to write outputs to cache for target %s:\n%w", target.Label, err)
}
// Log the completion line only once the target is fully done (outputs
// written), so the detailed lifecycle reads check → run → write → done.
logTargetBuilt(ctx, logger, target, executionTimeSeconds)
if isTainted {
go func() {
err = e.taintStore.Clear(ctx, target.Label)
if err != nil {
logger.Errorf("Failed to remove taint from target %s: %v", target.Label, err)
}
}()
}
return dag.CacheMiss, nil
}
// OnTargetComplete should be called when a target has completed executing
// - writes the outputs if necessary
// - computes and sets the output hash
// - writes the target result to the cache
// For no-cache targets it will set the OutputHash to the hash of the outputs.
func (e *Executor) OnTargetComplete(ctx context.Context, target *model.Target, update worker.StatusFunc) error {
logger := console.GetLogger(ctx)
var targetResult *gen.TargetResult
var preparedTarget *output.PreparedTargetResult
var err error
if target.SkipsCache() || !e.enableCache {
logger.Debugf("%s: skipping cache write", target.Label)
targetResult, err = e.registry.GetNoCacheOutputHash(ctx, target)
if err == nil {
preparedTarget = &output.PreparedTargetResult{
TargetResult: targetResult,
// Nothing was staged to the cache, so oci_push has to ship the
// image straight out of the local docker daemon.
WritePlans: e.pusher.PlansFromDaemon(target),
}
}
// TODO should we even store this in the cache given that the target
// is no-cache? Probably fine from a user perspective
// since it's the target cache and not the output cache
} else if len(target.AllOutputs()) == 0 {
// NOTE: This is a special and intentional design
// Targets that do not have any outputs expose their own change behavior as an output
// analogous to file_groups
logger.Debugf("%s: no outputs to write", target.Label)
preparedTarget = &output.PreparedTargetResult{
TargetResult: &gen.TargetResult{
ChangeHash: target.ChangeHash,
OutputHash: target.ChangeHash,
ExecutionDurationMillis: target.ExecutionTime.Milliseconds(),
},
}
} else {
logger.Debugf("%s: writing %d outputs", target.Label, len(target.AllOutputs()))
progress := worker.NewProgressTracker(
fmt.Sprintf("%s: writing %s", target.Label, console.FCountOutputs(len(target.AllOutputs()))),
0,
update,
)
outputWriteStart := time.Now()
writeResult, writeErr := e.registry.PrepareOutputs(ctx, target, progress)
target.OutputWriteTime = time.Since(outputWriteStart)
target.CacheTime += target.OutputWriteTime
if writeErr != nil {
return writeErr
}
preparedTarget = writeResult
// Appended after the cache plans so that image_id and manifest_digest
// are populated before a push tries to source them.
pushPlans, pushPlanErr := e.pusher.PlansFromCache(target, preparedTarget.TargetResult.GetOutputs())
if pushPlanErr != nil {
return pushPlanErr
}
preparedTarget.WritePlans = append(preparedTarget.WritePlans, pushPlans...)
}
if err != nil {
return err
}
if preparedTarget == nil {
return fmt.Errorf("prepared target result for %s is nil", target.Label)
}
targetResult = preparedTarget.TargetResult
target.OutputsLoaded = true
target.OutputHash = targetResult.OutputHash
cacheWriteStart := time.Now()
defer func() {
target.CacheWriteTime = time.Since(cacheWriteStart)
target.CacheTime += target.CacheWriteTime
}()
return e.cacheWriter.PersistPreparedTarget(ctx, target.Label.String(), preparedTarget, update)
}
// LoadDependencyOutputs is used to load the outputs of the targets that a target depends on.
// Since there is a chance that the loading will fail it needs to be able to recursively re-run targets.
// Primarily used for the load_outputs=minimal mode which will avoid loading outputs until necessary.
func (e *Executor) LoadDependencyOutputs(
ctx context.Context,
target *model.Target,
update worker.StatusFunc,
) error {
logger := console.GetLogger(ctx)
logger.Debugf(
"loading dependency outputs for target %s.",
target.Label,
)
for _, dep := range e.graph.GetTargetDependencies(target) {
localDep := dep
if localDep.OutputsLoaded {
continue
}
// A dependency without outputs of its own materializes nothing, so
// stopping at it would hide the outputs of everything behind it.
// Depending on such an aggregator has to mean the same as depending on
// the targets it groups.
if len(localDep.AllOutputs()) == 0 {
if passThroughErr := e.LoadDependencyOutputs(ctx, localDep, update); passThroughErr != nil {
return passThroughErr
}
}
targetResult, err := e.targetCache.Load(ctx, localDep.ChangeHash)
var loadErr error
if err == nil {
progress := worker.NewProgressTracker(
fmt.Sprintf("%s: loading %s", target.Label, console.FCountOutputs(len(target.AllOutputs()))),
0,
update,
)
loadErr = e.registry.LoadOutputs(ctx, localDep, targetResult, progress)
} else {
// Target cache not available (e.g. async cache write not yet complete).
// Treat as a load failure so the dependency is re-built below.
loadErr = err
}
if loadErr == nil && !localDep.SkipsCache() {
continue
}
logger.Debugf(
"%s: failed to load output for dependency %s (re-running): loadErr=%v no-cache=%t",
target.Label,
localDep.Label,
loadErr,
localDep.SkipsCache(),
)
// Multiple downstream targets may concurrently hit this branch for the
// same dep. Without dedup, each fires its own executeTarget → its own
// async cache write, racing on the loopback Docker tag (one Cleanup
// removes the tag from under the others) and the GCS target-cache
// object (concurrent writes trip the per-object 429 mutation limit).
_, rerunErr, _ := e.rerunGroup.Do(localDep.Label.String(), func() (any, error) {
if localDep.OutputsLoaded {
return nil, nil
}
if recursiveLoadErr := e.LoadDependencyOutputs(ctx, localDep, update); recursiveLoadErr != nil {
return nil, recursiveLoadErr
}
binTools, binToolErr := e.getBinToolPaths(localDep)
if binToolErr != nil {
return nil, binToolErr
}
outputIdentifiers := e.getDependencyOutputIdentifiers(localDep)
transitiveOutputs := e.getTransitiveOutputs(localDep)
taggedOutputs := e.getTransitiveOutputsByTag(localDep)
update(worker.Status(fmt.Sprintf("%s: re-running dependency %s (load_outputs=minimal)", target.Label, localDep.Label)))
if _, executionErr := e.executeTarget(ctx, localDep, binTools, outputIdentifiers, transitiveOutputs, taggedOutputs, update, false); executionErr != nil {
return nil, executionErr
}
return nil, nil
})
if rerunErr != nil {
return rerunErr
}
}
return nil
}
package execution
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"text/template"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/logs"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/shell"
)
//go:embed run_sh.sh.tmpl
var binTemplate string
// templateData is the data expected by the run_sh.sh.tmpl.
type templateData struct {
BinToolMap BinToolMap
OutputIdentifierMap OutputIdentifierMap
TransitiveOutputs []string
TransitiveTaggedOutputs TransitiveTaggedOutputs
UserCommand string
}
// extraArgsKey is the context key for extra command arguments passed via "--"
// on the grog test command line. These are forwarded to the target's shell
// command as positional parameters ($@).
type extraArgsKey struct{}
// WithExtraArgs returns a child context carrying extra command-line arguments
// that will be forwarded to every target command as shell positional parameters.
func WithExtraArgs(ctx context.Context, args []string) context.Context {
return context.WithValue(ctx, extraArgsKey{}, args)
}
// ExtraArgsFromContext returns the extra command-line arguments stored in ctx,
// or nil if none are set.
func ExtraArgsFromContext(ctx context.Context) []string {
args, _ := ctx.Value(extraArgsKey{}).([]string)
return args
}
// BinToolMap Maps target label to tool a binary path.
type BinToolMap map[string]string
// OutputIdentifierMap maps a target label (and its shorthands) to the list of
// output identifiers produced by that dependency. File and directory outputs
// resolve to absolute paths while other output types retain their identifiers.
// The order matches the dependency's output definition order and includes bin
// outputs as the final element if present.
type OutputIdentifierMap map[string][]string
// TransitiveTaggedOutputs maps a tag name to the deduplicated list of output
// identifiers from all transitive ancestors that carry that tag. This enables
// shell commands to query outputs by semantic role (e.g. "find-links") rather
// than by individual target labels.
type TransitiveTaggedOutputs map[string][]string
func executeTarget(
ctx context.Context,
target *model.Target,
binToolPaths BinToolMap,
outputIdentifiers OutputIdentifierMap,
transitiveOutputs []string,
taggedOutputs TransitiveTaggedOutputs,
resourceEnvironment []string,
streamLogs bool,
) error {
if target.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, target.Timeout)
defer cancel()
}
cmdOut, err := runTargetCommand(ctx, target, binToolPaths, outputIdentifiers, transitiveOutputs, taggedOutputs, resourceEnvironment, target.Command, streamLogs)
if err != nil {
if ctx.Err() != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timeout after %s", target.Timeout)
}
// bubble up cancellation error
return ctx.Err()
}
if exitError, isExitError := errors.AsType[*exec.ExitError](err); isExitError {
return &CommandError{
TargetLabel: target.Label,
ExitCode: exitError.ExitCode(),
Output: string(cmdOut),
}
}
return fmt.Errorf("target %s failed: %w - output: %s", target.Label, err, string(cmdOut))
}
return nil
}
// runTargetCommand runs a single shell command in the context of a target.
func runTargetCommand(
ctx context.Context,
target *model.Target,
binToolPaths BinToolMap,
outputIdentifiers OutputIdentifierMap,
transitiveOutputs []string,
taggedOutputs TransitiveTaggedOutputs,
resourceEnvironment []string,
command string,
streamLogs bool,
) ([]byte, error) {
executionPath := config.GetPathAbsoluteToWorkspaceRoot(target.Label.Package)
templatedCommand, err := getCommand(binToolPaths, outputIdentifiers, transitiveOutputs, taggedOutputs, command)
if err != nil {
return nil, err
}
shellCommand, cleanup, err := shell.NewCommand(ctx, templatedCommand, ExtraArgsFromContext(ctx)...)
if err != nil {
return nil, err
}
defer cleanup()
// Attach env variables to the existing environment
shellCommand.Env = append(GetExtendedTargetEnv(ctx, target), resourceEnvironment...)
shellCommand.Dir = executionPath
targetLogs := logs.NewTargetLogFile(*target)
logWriter, err := targetLogs.Open()
if err != nil {
return nil, err
}
var buffer bytes.Buffer
var teaWriter *console.TeaWriter
if program := console.GetTeaProgram(ctx); program != nil {
toggle := console.GetStreamLogsToggle(ctx)
if toggle != nil {
teaWriter = console.NewTeaWriter(program)
toggleWriter := console.NewStreamToggleWriter(teaWriter, toggle)
multiOut := io.MultiWriter(logWriter, toggleWriter, &buffer)
shellCommand.Stdout = multiOut
shellCommand.Stderr = multiOut
} else if streamLogs {
teaWriter = console.NewTeaWriter(program)
multiOut := io.MultiWriter(logWriter, teaWriter, &buffer)
shellCommand.Stdout = multiOut
shellCommand.Stderr = multiOut
} else {
multiOut := io.MultiWriter(logWriter, &buffer)
shellCommand.Stdout = multiOut
shellCommand.Stderr = multiOut
}
} else {
multiOut := io.MultiWriter(logWriter, &buffer)
shellCommand.Stdout = multiOut
shellCommand.Stderr = multiOut
}
cmdErr := shellCommand.Run()
// Emit any buffered partial line now that the stream has closed.
if teaWriter != nil {
teaWriter.Flush()
}
return buffer.Bytes(), errors.Join(cmdErr, logWriter.Close())
}
func GetExtendedTargetEnv(ctx context.Context, target *model.Target) []string {
gitHash, err := config.GetGitHash()
if err != nil {
console.GetLogger(ctx).Debugf("failed to get git hash: %v", err)
}
env := append([]string{}, os.Environ()...)
for k, v := range config.Global.EnvironmentVariables {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
for k, v := range target.EnvironmentVariables {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
return append(env,
"GROG_TARGET="+target.Label.String(),
"GROG_OS="+config.Global.OS,
"GROG_ARCH="+config.Global.Arch,
"GROG_PLATFORM="+config.Global.GetPlatform(),
"GROG_PLATFORM_TAGS="+strings.Join(config.Global.PlatformTags, ","),
"GROG_PACKAGE="+target.Label.Package,
"GROG_WORKSPACE_ROOT="+config.Global.WorkspaceRoot,
"GROG_GIT_HASH="+gitHash,
)
}
func getCommand(toolMap BinToolMap, outputMap OutputIdentifierMap, transitiveOutputs []string, taggedOutputs TransitiveTaggedOutputs, command string) (string, error) {
tmpl, err := template.New("binCommand").Parse(binTemplate)
if err != nil {
return "", fmt.Errorf("failed to parse run template: %w", err)
}
userCommand := shell.WithDefaultFlags(command)
data := templateData{
BinToolMap: toolMap,
OutputIdentifierMap: outputMap,
TransitiveOutputs: transitiveOutputs,
TransitiveTaggedOutputs: taggedOutputs,
UserCommand: userCommand,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute bin function template: %w", err)
}
return buf.String(), nil
}
package execution
import (
"context"
"fmt"
"strings"
"github.com/chrismatix/grog/internal/model"
)
func runOutputChecks(
ctx context.Context,
target *model.Target,
binToolPaths BinToolMap,
outputIdentifiers OutputIdentifierMap,
resourceEnvironment []string,
) error {
for _, check := range target.OutputChecks {
output, err := runTargetCommand(ctx, target, binToolPaths, outputIdentifiers, nil, nil, resourceEnvironment, check.Command, false)
if err != nil {
return fmt.Errorf("output check failed for target %s: %w\ncommand %s",
target.Label, err, check.Command)
}
expected := strings.TrimSpace(check.ExpectedOutput)
actual := strings.TrimSpace(string(output))
if check.ExpectedOutput != "" && expected != actual {
return fmt.Errorf("output check failed: expected '%s', got '%s'\ncommand %s",
expected, actual, check.Command)
}
}
return nil
}
package execution
import (
"context"
"fmt"
"maps"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/fatih/color"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/worker"
)
// PoolCoordinator composes a task worker pool and an I/O worker pool,
// merging their UI state into a single tea UI.
type PoolCoordinator struct {
taskPool *worker.TaskWorkerPool[dag.CacheResult]
ioPool *worker.TaskWorkerPool[struct{}]
sendMsg func(tea.Msg)
mu sync.Mutex
totalBuildTasks int
}
// NewPoolCoordinator creates both pools with onStateChange pointing to the coordinator's flushState.
// Task pool uses workerIdOffset=0, I/O pool uses workerIdOffset=10000.
func NewPoolCoordinator(
logger *console.Logger,
numTaskWorkers int,
numIOWorkers int,
sendMsg func(tea.Msg),
totalBuildTasks int,
) *PoolCoordinator {
pc := &PoolCoordinator{
sendMsg: sendMsg,
totalBuildTasks: totalBuildTasks,
}
taskPool := worker.NewTaskWorkerPool[dag.CacheResult](logger, numTaskWorkers, sendMsg, totalBuildTasks)
taskPool.SetOnStateChange(pc.flushState)
ioPool := worker.NewTaskWorkerPool[struct{}](logger, numIOWorkers, sendMsg, 0)
ioPool.SetWorkerIdOffset(10000)
ioPool.SetOnStateChange(pc.flushState)
pc.taskPool = taskPool
pc.ioPool = ioPool
return pc
}
// flushState merges task and I/O pool states and sends a single TaskStateMsg + HeaderMsg.
func (pc *PoolCoordinator) flushState() {
pc.mu.Lock()
defer pc.mu.Unlock()
taskState := pc.taskPool.GetTaskState()
ioState := pc.ioPool.GetTaskState()
merged := make(console.TaskStateMap, len(taskState)+len(ioState))
maps.Copy(merged, taskState)
maps.Copy(merged, ioState)
pc.sendMsg(console.TaskStateMsg{State: merged})
// Build header
taskCompleted := pc.taskPool.GetCompletedTasks()
total := pc.totalBuildTasks
taskRunning := pc.taskPool.GetRunningCount()
ioRunning := pc.ioPool.GetRunningCount()
green := color.New(color.FgGreen).SprintFunc()
header := green(fmt.Sprintf("[%d/%d]", taskCompleted, total))
if ioRunning > 0 {
header += fmt.Sprintf(" %s, %s",
console.FCount(taskRunning, "action"),
console.FCount(ioRunning, "cache write"))
} else {
header += fmt.Sprintf(" %s running", console.FCount(taskRunning, "action"))
}
pc.sendMsg(console.HeaderMsg(header))
}
// TaskPool returns the task worker pool.
func (pc *PoolCoordinator) TaskPool() *worker.TaskWorkerPool[dag.CacheResult] {
return pc.taskPool
}
// IOPool returns the I/O worker pool.
func (pc *PoolCoordinator) IOPool() *worker.TaskWorkerPool[struct{}] {
return pc.ioPool
}
// StartTaskWorkers starts the task worker pool.
func (pc *PoolCoordinator) StartTaskWorkers(ctx context.Context) {
pc.taskPool.StartWorkers(ctx)
}
// StartIOWorkers starts the I/O worker pool.
func (pc *PoolCoordinator) StartIOWorkers(ctx context.Context) {
pc.ioPool.StartWorkers(ctx)
}
// Shutdown shuts down both pools.
func (pc *PoolCoordinator) Shutdown() {
pc.taskPool.Shutdown()
pc.ioPool.Shutdown()
}
package execution
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"maps"
"os"
"slices"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/hashing"
"github.com/chrismatix/grog/internal/logs"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/shell"
"github.com/chrismatix/grog/internal/worker"
)
// ResourceManager starts resource nodes lazily and at most once per
// invocation: the first executing target that depends on a resource triggers
// its up command, targets that are fully cached never do. Started resources
// are torn down in reverse start order when the build finishes.
type ResourceManager struct {
startGroup singleflight.Group
mutex sync.Mutex
startCondition *sync.Cond
activeStarts int
tearingDown bool
// started holds resources in start order; a resource is registered before
// its up command runs so that partial starts are still torn down.
started []*model.Resource
// exports holds the resolved KEY=VALUE environment pairs per resource,
// including dynamic exports written by the up command.
exports map[string][]string
// dependencyExports holds the exports a resource inherits from the
// resources it depends on. Kept separate from exports so that dependent
// targets only ever see the exports of the resources they declare.
dependencyExports map[string][]string
// startResults memoizes the terminal outcome of each start attempt.
// singleflight only dedupes calls that overlap in time, so consumers
// reaching a resource after its start finished must be served from here to
// keep the at-most-once guarantee.
startResults map[string]error
}
func NewResourceManager() *ResourceManager {
manager := &ResourceManager{
exports: make(map[string][]string),
dependencyExports: make(map[string][]string),
startResults: make(map[string]error),
}
manager.startCondition = sync.NewCond(&manager.mutex)
return manager
}
// EnsureResourcesStarted starts every resource the target directly depends on
// (and, transitively, the resources those depend on) unless already running,
// and returns the exported environment of the target's direct resources.
func (m *ResourceManager) EnsureResourcesStarted(
ctx context.Context,
graph *dag.DirectedTargetGraph,
target *model.Target,
update worker.StatusFunc,
) ([]string, error) {
var combinedExports []string
for _, dependency := range graph.GetDependencies(target) {
resource := resolveResourceDependency(graph, dependency)
if resource == nil {
continue
}
update(worker.Status(fmt.Sprintf("%s: waiting for resource %s", target.Label, resource.Label)))
if err := m.ensureStarted(ctx, graph, resource); err != nil {
return nil, fmt.Errorf("resource %s required by %s: %w", resource.Label, target.Label, err)
}
m.mutex.Lock()
combinedExports = append(combinedExports, m.exports[resource.Label.String()]...)
m.mutex.Unlock()
}
return combinedExports, nil
}
// ensureStarted starts the resource exactly once for the whole invocation:
// singleflight collapses concurrent callers and startResults serves callers
// that arrive after the start already finished.
func (m *ResourceManager) ensureStarted(
ctx context.Context,
graph *dag.DirectedTargetGraph,
resource *model.Resource,
) error {
resourceLabel := resource.Label.String()
if startErr, isStarted := m.startResult(resourceLabel); isStarted {
return startErr
}
_, err, _ := m.startGroup.Do(resourceLabel, func() (any, error) {
// A start may have completed between the check above and this call
// acquiring the singleflight slot.
if startErr, isStarted := m.startResult(resourceLabel); isStarted {
return nil, startErr
}
if beginErr := m.beginStart(); beginErr != nil {
m.mutex.Lock()
m.startResults[resourceLabel] = beginErr
m.mutex.Unlock()
return nil, beginErr
}
startErr := m.startWithDependencies(ctx, graph, resource)
m.finishStart(resourceLabel, startErr)
return nil, startErr
})
return err
}
func (m *ResourceManager) beginStart() error {
m.mutex.Lock()
defer m.mutex.Unlock()
if m.tearingDown {
return context.Canceled
}
m.activeStarts++
return nil
}
func (m *ResourceManager) finishStart(resourceLabel string, startErr error) {
m.mutex.Lock()
m.startResults[resourceLabel] = startErr
m.activeStarts--
m.startCondition.Broadcast()
m.mutex.Unlock()
}
func (m *ResourceManager) startResult(resourceLabel string) (error, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
startErr, isStarted := m.startResults[resourceLabel]
return startErr, isStarted
}
// startWithDependencies starts the resources this resource depends on before
// registering and starting it, so that a resource always comes up after the
// resources it needs and is torn down before them.
func (m *ResourceManager) startWithDependencies(
ctx context.Context,
graph *dag.DirectedTargetGraph,
resource *model.Resource,
) error {
var dependencyExports []string
for _, dependency := range graph.GetDependencies(resource) {
dependencyResource := resolveResourceDependency(graph, dependency)
if dependencyResource == nil {
continue
}
if err := m.ensureStarted(ctx, graph, dependencyResource); err != nil {
return fmt.Errorf("resource dependency %s: %w", dependencyResource.Label, err)
}
m.mutex.Lock()
dependencyExports = append(dependencyExports, m.exports[dependencyResource.Label.String()]...)
m.mutex.Unlock()
}
m.mutex.Lock()
m.started = append(m.started, resource)
m.dependencyExports[resource.Label.String()] = dependencyExports
m.mutex.Unlock()
return m.start(ctx, resource, dependencyExports)
}
func resolveResourceDependency(graph *dag.DirectedTargetGraph, dependency model.BuildNode) *model.Resource {
visitedLabels := make(map[string]struct{})
for dependency != nil {
dependencyLabel := dependency.GetLabel().String()
if _, visited := visitedLabels[dependencyLabel]; visited {
return nil
}
visitedLabels[dependencyLabel] = struct{}{}
switch typedDependency := dependency.(type) {
case *model.Resource:
return typedDependency
case *model.Alias:
dependency = graph.GetNodes()[typedDependency.Actual]
default:
return nil
}
}
return nil
}
func (m *ResourceManager) start(ctx context.Context, resource *model.Resource, dependencyExports []string) error {
logger := console.GetLogger(ctx)
startTime := time.Now()
resourceLabel := resource.Label.String()
m.mutex.Lock()
m.exports[resourceLabel] = exportsEnvironment(resource.Exports)
m.mutex.Unlock()
startContext, cancel := context.WithTimeout(ctx, resource.GetTimeout())
defer cancel()
exportsFile, err := os.CreateTemp("", "grog-resource-exports-*")
if err != nil {
return fmt.Errorf("failed to create exports file: %w", err)
}
exportsFilePath := exportsFile.Name()
_ = exportsFile.Close()
defer func() { _ = os.Remove(exportsFilePath) }()
baseEnvironment := append(m.resourceEnv(resource), dependencyExports...)
upEnvironment := append(append([]string{}, baseEnvironment...), "GROG_RESOURCE_EXPORTS_FILE="+exportsFilePath)
output, upErr := m.runHookCommand(startContext, resource, resource.Up, upEnvironment)
resolvedExports, exportsErr := m.resolveExports(resource, exportsFilePath)
m.mutex.Lock()
m.exports[resourceLabel] = resolvedExports
m.mutex.Unlock()
if upErr != nil {
upCommandErr := fmt.Errorf("up command failed: %w\noutput: %s", upErr, string(output))
if exportsErr != nil {
return errors.Join(upCommandErr, exportsErr)
}
return upCommandErr
}
if exportsErr != nil {
return exportsErr
}
if resource.Ready != "" {
readyEnvironment := append(append([]string{}, baseEnvironment...), resolvedExports...)
if err := m.pollReady(startContext, resource, readyEnvironment); err != nil {
return err
}
}
if config.Global.DisableNonDeterministicLogging {
logger.Infof("Resource %s started.", resource.Label)
} else {
logger.Infof("Resource %s started in %.1fs.", resource.Label, time.Since(startTime).Seconds())
}
return nil
}
// pollReady runs the ready probe until it succeeds or the start deadline is hit.
func (m *ResourceManager) pollReady(ctx context.Context, resource *model.Resource, environment []string) error {
var lastOutput []byte
var lastErr error
for {
lastOutput, lastErr = m.runHookCommand(ctx, resource, resource.Ready, environment)
if lastErr == nil {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("did not become ready within %s: %w\nlast probe output: %s",
resource.GetTimeout(), lastErr, string(lastOutput))
case <-time.After(250 * time.Millisecond):
}
}
}
// TeardownAll runs the down command of every started resource in reverse
// start order. Failures are logged as warnings and do not fail the build.
// Idempotent: a second call is a no-op.
func (m *ResourceManager) TeardownAll(ctx context.Context) {
m.mutex.Lock()
m.tearingDown = true
for m.activeStarts > 0 {
m.startCondition.Wait()
}
started := append([]*model.Resource(nil), m.started...)
m.started = nil
m.mutex.Unlock()
logger := console.GetLogger(ctx)
for _, resource := range slices.Backward(started) {
if resource.Down == "" {
continue
}
downContext, cancel := context.WithTimeout(ctx, resource.GetTimeout())
m.mutex.Lock()
environment := append(m.resourceEnv(resource), m.dependencyExports[resource.Label.String()]...)
environment = append(environment, m.exports[resource.Label.String()]...)
m.mutex.Unlock()
output, err := m.runHookCommand(downContext, resource, resource.Down, environment)
cancel()
if err != nil {
logger.Warnf("Resource %s down command failed: %v\noutput: %s", resource.Label, err, string(output))
continue
}
logger.Infof("Resource %s stopped.", resource.Label)
}
}
// runHookCommand executes a resource lifecycle command in the resource's
// package directory and returns the combined output.
func (m *ResourceManager) runHookCommand(
ctx context.Context,
resource *model.Resource,
command string,
environment []string,
) (output []byte, err error) {
shellCommand, cleanup, err := shell.NewCommand(ctx, shell.WithDefaultFlags(command))
if err != nil {
return nil, err
}
defer cleanup()
shellCommand.Dir = config.GetPathAbsoluteToWorkspaceRoot(resource.Label.Package)
shellCommand.Env = environment
resourceLogs := logs.NewTargetLogFile(model.Target{Label: resource.Label})
var buffer bytes.Buffer
if logWriter, logErr := resourceLogs.Open(); logErr == nil {
defer func() { err = errors.Join(err, logWriter.Close()) }()
multiOut := io.MultiWriter(&buffer, logWriter)
shellCommand.Stdout = multiOut
shellCommand.Stderr = multiOut
} else {
shellCommand.Stdout = &buffer
shellCommand.Stderr = &buffer
}
if err := shellCommand.Run(); err != nil {
return buffer.Bytes(), err
}
return buffer.Bytes(), nil
}
// resolveExports merges the statically declared exports with KEY=VALUE lines
// the up command appended to the exports file (dynamic values win) and
// returns them as a sorted environment slice.
func (m *ResourceManager) resolveExports(resource *model.Resource, exportsFilePath string) ([]string, error) {
merged := make(map[string]string, len(resource.Exports))
maps.Copy(merged, resource.Exports)
content, err := os.ReadFile(exportsFilePath)
if err != nil {
return exportsEnvironment(merged), fmt.Errorf("failed to read exports file: %w", err)
}
for line := range strings.SplitSeq(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || key == "" {
return exportsEnvironment(merged), fmt.Errorf("resource %s wrote an invalid exports line (want KEY=VALUE): %q", resource.Label, line)
}
merged[key] = value
}
return exportsEnvironment(merged), nil
}
func exportsEnvironment(exports map[string]string) []string {
keys := make([]string, 0, len(exports))
for key := range exports {
keys = append(keys, key)
}
sort.Strings(keys)
environment := make([]string, 0, len(keys))
for _, key := range keys {
environment = append(environment, key+"="+exports[key])
}
return environment
}
// resourceEnv builds the base environment for resource lifecycle commands.
func (m *ResourceManager) resourceEnv(resource *model.Resource) []string {
environment := append([]string{}, os.Environ()...)
for key, value := range config.Global.EnvironmentVariables {
environment = append(environment, key+"="+value)
}
return append(environment,
"GROG_RESOURCE="+resource.Label.String(),
"GROG_RESOURCE_ID="+hashing.GetResourceIdentity(*resource),
"GROG_OS="+config.Global.OS,
"GROG_ARCH="+config.Global.Arch,
"GROG_PLATFORM="+config.Global.GetPlatform(),
"GROG_PACKAGE="+resource.Label.Package,
"GROG_WORKSPACE_ROOT="+config.Global.WorkspaceRoot,
)
}
package execution
import (
"context"
"fmt"
"sync"
"golang.org/x/sync/semaphore"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/worker"
)
// Scheduler gates target execution on optional named concurrency groups.
// Targets sharing a group compete for that group's capacity (default 1,
// fully serialized; tunable via grog.toml [concurrency_groups]). The
// global num_workers cap is enforced by the underlying TaskWorkerPool.
type Scheduler struct {
pool *worker.TaskWorkerPool[dag.CacheResult]
groupsMu sync.Mutex
groups map[string]*semaphore.Weighted
}
func NewScheduler(pool *worker.TaskWorkerPool[dag.CacheResult]) *Scheduler {
return &Scheduler{
pool: pool,
groups: make(map[string]*semaphore.Weighted),
}
}
// Schedule runs task once the target's concurrency group permit is held.
// Acquiring the group permit before submitting to the pool prevents a
// queued group-bound task from occupying a worker slot while waiting on
// a contended group.
func (s *Scheduler) Schedule(
ctx context.Context,
target *model.Target,
task worker.TaskFunc[dag.CacheResult],
) (dag.CacheResult, error) {
if target.ConcurrencyGroup != "" {
group := s.groupFor(target.ConcurrencyGroup)
if err := group.Acquire(ctx, 1); err != nil {
return dag.CacheMiss, fmt.Errorf(
"acquiring concurrency group %q for target %s: %w",
target.ConcurrencyGroup, target.Label, err,
)
}
defer group.Release(1)
}
return s.pool.Run(task)
}
func (s *Scheduler) groupFor(name string) *semaphore.Weighted {
s.groupsMu.Lock()
defer s.groupsMu.Unlock()
if sem, ok := s.groups[name]; ok {
return sem
}
capacity := max(config.Global.ConcurrencyGroups[name], 1)
sem := semaphore.NewWeighted(int64(capacity))
s.groups[name] = sem
return sem
}
package hashing
import (
"crypto/sha256"
"fmt"
"hash"
"io"
"github.com/chrismatix/grog/internal/config"
"github.com/zeebo/xxh3"
)
// Hasher defines the hashing interface used throughout grog.
type Hasher interface {
io.Writer
WriteString(value string) (int, error)
SumString() string
}
// GetHasher returns a new hasher instance based on the configured algorithm.
// xxh3 is the default for speed, but sha256 can be selected for a
// cryptographic-grade hash.
func GetHasher() Hasher {
switch config.Global.HashAlgorithm {
case config.HashAlgorithmSHA256:
return newSHA256Hasher()
case "", config.HashAlgorithmXXH3:
fallthrough
default:
return newXXH3Hasher()
}
}
type xxh3Hasher struct {
hasher *xxh3.Hasher
}
func newXXH3Hasher() Hasher {
return &xxh3Hasher{hasher: xxh3.New()}
}
func (h *xxh3Hasher) Write(p []byte) (int, error) {
return h.hasher.Write(p)
}
func (h *xxh3Hasher) WriteString(s string) (int, error) {
return h.hasher.WriteString(s)
}
func (h *xxh3Hasher) SumString() string {
sum128 := h.hasher.Sum128()
return fmt.Sprintf("%016x%016x", sum128.Hi, sum128.Lo)
}
type sha256Hasher struct {
hasher hash.Hash
}
func newSHA256Hasher() Hasher {
return &sha256Hasher{hasher: sha256.New()}
}
func (h *sha256Hasher) Write(p []byte) (int, error) {
return h.hasher.Write(p)
}
func (h *sha256Hasher) WriteString(s string) (int, error) {
return io.WriteString(h.hasher, s)
}
func (h *sha256Hasher) SumString() string {
return fmt.Sprintf("%016x", h.hasher.Sum(nil))
}
// Assert types.
var _ Hasher = (*xxh3Hasher)(nil)
var _ Hasher = (*sha256Hasher)(nil)
package hashing
func HashBytes(bytes []byte) string {
hasher := GetHasher()
_, _ = hasher.Write(bytes)
return hasher.SumString()
}
package hashing
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
)
// HashFile computes the configured hash of a single file.
func HashFile(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
hasher := GetHasher()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
// Return the hash as a hexadecimal string.
return hasher.SumString(), nil
}
// HashFiles computes a combined hash for multiple files relative to packagePath
// Sorts the array to ensure consistent outputs.
func HashFiles(absolutePackagePath string, fileList []string) (string, error) {
combinedHasher := GetHasher()
// Ensure consistent ordering.
sort.Strings(fileList)
for _, file := range fileList {
// Create the full file path.
fullPath := filepath.Join(absolutePackagePath, file)
f, err := os.Open(fullPath)
if err != nil {
if os.IsNotExist(err) {
// NOTE: If a file does not exist in the package, we skip it.
// TODO make this a warning
continue
}
return "", fmt.Errorf("failed opening input file for hashing: %w", err)
}
// Copy file content into the combined hasher.
if _, err := io.Copy(combinedHasher, f); err != nil {
_ = f.Close()
return "", err
}
_ = f.Close()
}
// Return the combined hash as a hexadecimal string.
return combinedHasher.SumString(), nil
}
package hashing
import (
"sort"
"strconv"
"github.com/chrismatix/grog/internal/model"
)
// GetResourceIdentity returns a short stable identifier derived from the
// resource's complete behavior-affecting definition.
func GetResourceIdentity(resource model.Resource) string {
hasher := GetHasher()
writeResourceIdentityValue(hasher, resource.Label.String())
writeResourceIdentityValue(hasher, resource.Up)
writeResourceIdentityValue(hasher, resource.Down)
writeResourceIdentityValue(hasher, resource.Ready)
writeResourceIdentityValue(hasher, resource.GetTimeout().String())
writeResourceIdentityValue(hasher, strconv.Itoa(len(resource.Dependencies)))
for _, dependency := range resource.Dependencies {
writeResourceIdentityValue(hasher, dependency.String())
}
exportKeys := make([]string, 0, len(resource.Exports))
for key := range resource.Exports {
exportKeys = append(exportKeys, key)
}
sort.Strings(exportKeys)
writeResourceIdentityValue(hasher, strconv.Itoa(len(exportKeys)))
for _, key := range exportKeys {
writeResourceIdentityValue(hasher, key)
writeResourceIdentityValue(hasher, resource.Exports[key])
}
sum := hasher.SumString()
if len(sum) > 12 {
return sum[:12]
}
return sum
}
func writeResourceIdentityValue(hasher Hasher, value string) {
_, _ = hasher.WriteString(strconv.Itoa(len(value)))
_, _ = hasher.WriteString(":")
_, _ = hasher.WriteString(value)
}
package hashing
// HashString computes the configured hash of a string.
func HashString(str string) string {
hasher := GetHasher()
_, _ = hasher.WriteString(str)
return hasher.SumString()
}
package hashing
import (
"sort"
"strings"
)
// HashStrings computes the configured hash of a list of strings.
func HashStrings(strList []string) string {
// Sort the strings to ensure consistent outputs.
sort.Strings(strList)
return HashString(strings.Join(strList, ","))
}
package hashing
import (
"fmt"
"slices"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/model"
)
// GetTargetChangeHash computes the hash that tells us if a target has changed.
// dependencyHashes are the change hashes of the direct dependencies.
func GetTargetChangeHash(target model.Target, dependencyHashes []string, extraArgs []string) (string, error) {
targetDefinitionHash, err := hashTargetDefinition(target, dependencyHashes, extraArgs)
if err != nil {
return "", err
}
absolutePackagePath := config.GetPathAbsoluteToWorkspaceRoot(target.Label.Package)
if len(target.Inputs) == 0 {
return targetDefinitionHash, nil
}
inputContentHash, err := HashFiles(absolutePackagePath, target.Inputs)
if err != nil {
return "", fmt.Errorf("failed hashing input files %s for target %s: %w", strings.Join(target.Inputs, ","), target.Label, err)
}
return fmt.Sprintf("%s_%s", targetDefinitionHash, inputContentHash), err
}
// hashTargetDefinition computes the configured hash of a single file.
func hashTargetDefinition(target model.Target, dependencyHashes []string, extraArgs []string) (string, error) {
hasher := GetHasher()
if _, err := hasher.WriteString(target.Label.String()); err != nil {
return "", err
}
if _, err := hasher.WriteString(target.Command); err != nil {
return "", err
}
if _, err := hasher.WriteString(sorted(target.Inputs)); err != nil {
return "", err
}
if _, err := hasher.WriteString(sorted(target.OutputDefinitions())); err != nil {
return "", err
}
if _, err := hasher.WriteString(sorted(dependencyHashes)); err != nil {
return "", err
}
if _, err := hasher.WriteString(sortedKeyValue(target.Fingerprint)); err != nil {
return "", err
}
// Include extra command-line arguments (e.g. from "grog test -- -k foo")
// so that different invocations with different flags bust the cache.
if len(extraArgs) > 0 {
if _, err := hasher.WriteString(strings.Join(extraArgs, "\x00")); err != nil {
return "", err
}
}
// By default, target hashes are separate between platforms unless
// the target has a multiplatform-cache tag
if !target.IsMultiplatformCache() {
if _, err := hasher.WriteString(config.Global.GetPlatform()); err != nil {
return "", err
}
if len(config.Global.PlatformTags) > 0 {
tags := slices.Clone(config.Global.PlatformTags)
if _, err := hasher.WriteString(sorted(tags)); err != nil {
return "", err
}
}
}
// Include the docker backend in the hash for targets with docker outputs
// so that cache results from different backends (fs vs registry) can co-exist
if hasDockerOutput(target) {
if _, err := hasher.WriteString(config.Global.OCI.Backend); err != nil {
return "", err
}
}
// Return the hash as a hexadecimal string.
return hasher.SumString(), nil
}
func hasDockerOutput(target model.Target) bool {
for _, output := range target.AllOutputs() {
if output.Type == "oci" {
return true
}
}
return false
}
func sorted(s []string) string {
slices.Sort(s)
return strings.Join(s, ",")
}
func sortedKeyValue(m map[string]string) string {
if len(m) == 0 {
return ""
}
entries := make([]string, 0, len(m))
for k, v := range m {
entries = append(entries, fmt.Sprintf("%s=%s", k, v))
}
return sorted(entries)
}
package hashing
import (
"fmt"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/maps"
"github.com/chrismatix/grog/internal/model"
)
type TargetHasher struct {
graph *dag.DirectedTargetGraph
// Ensure that we are only ever hashing one target at a time
// to prevent race conditions
targetMutexMap *maps.MutexMap
// extraArgs are additional command-line arguments (from "--") that affect
// the target command and must be included in the cache hash.
extraArgs []string
}
func NewTargetHasher(graph *dag.DirectedTargetGraph) *TargetHasher {
return &TargetHasher{
graph: graph,
targetMutexMap: maps.NewMutexMap(),
}
}
// SetExtraArgs configures additional command-line arguments that will be
// included in every target's definition hash.
func (t *TargetHasher) SetExtraArgs(args []string) {
t.extraArgs = args
}
// SetTargetChangeHash computes and sets the target change hash.
func (t *TargetHasher) SetTargetChangeHash(target *model.Target) error {
t.targetMutexMap.Lock(target.Label.String())
defer func() { _ = t.targetMutexMap.Unlock(target.Label.String()) }()
if target.ChangeHash != "" {
// ChangeHash already set
return nil
}
// Collect the OutputHash values of all dependencies
dependencies := t.graph.GetDependencies(target)
dependencyHashes := make([]string, 0, len(dependencies))
for _, dependency := range dependencies {
targetDependency, ok := dependency.(*model.Target)
if !ok {
// Only consider dependencies that are targets
continue
}
outputHash := targetDependency.OutputHash
if outputHash == "" {
return fmt.Errorf("dependency %s of %s has no output hash", targetDependency.Label, target.Label)
}
dependencyHashes = append(dependencyHashes, targetDependency.OutputHash)
}
changeHash, err := GetTargetChangeHash(*target, dependencyHashes, t.extraArgs)
if err != nil {
return err
}
target.ChangeHash = changeHash
return nil
}
package label
import (
"fmt"
"sort"
"strings"
)
// TargetLabel represents a Bazel target label, e.g. "//package/path:target".
// It supports shorthand notation "//package/path" which is equivalent to "//package/path:basename",
// where "basename" is the last element of the package path.
type TargetLabel struct {
Package string `json:"package"` // e.g. "package/path" (for root package, it may be empty)
Name string `json:"name"` // e.g. "target"
}
// ParseTargetLabel parses a Bazel-style target label relative to the given packagePath.
// It accepts both explicit labels ("//pkg:target"), shorthand labels ("//pkg"),
// and relative labels (":target") for the current packagePath.
// I.e. "path", ":target" -> "//path:target".
func ParseTargetLabel(packagePath, label string) (TargetLabel, error) {
if strings.HasPrefix(label, ":") {
if packagePath == "." {
// For the root package we omit the "."
packagePath = ""
}
// Relative label: use the current package path with the given target name.
targetName := label[1:]
if targetName == "" {
return TargetLabel{}, fmt.Errorf("invalid relative label %q: target name is empty", label)
}
if err := validateName(targetName); err != nil {
return TargetLabel{}, err
}
return TargetLabel{Package: packagePath, Name: targetName}, nil
}
if !strings.HasPrefix(label, "//") {
return TargetLabel{}, fmt.Errorf("invalid label %q: must start with \"//\" or \":\"", label)
}
body := label[2:]
before, after, ok := strings.Cut(body, ":")
var pkg, name string
if !ok {
// Shorthand: infer target name from the last element of the package path.
pkg = body
if pkg == "" {
return TargetLabel{}, fmt.Errorf("invalid shorthand label %q: package path is empty", label)
}
parts := strings.Split(pkg, "/")
name = parts[len(parts)-1]
} else {
pkg = before
name = after
if name == "" {
return TargetLabel{}, fmt.Errorf("invalid label %q: target name is empty", label)
}
}
if err := validateName(name); err != nil {
return TargetLabel{}, err
}
return TargetLabel{Package: pkg, Name: name}, nil
}
func validateName(name string) error {
if name == "" {
return fmt.Errorf("target name is empty")
}
if name == "..." {
return fmt.Errorf("target name %q is reserved for wildcard target patterns", name)
}
for _, c := range name {
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '_' || c == '-' || c == '.':
default:
return fmt.Errorf("invalid character %q in target name %q: only alphanumeric characters, underscores, dots, and dashes are allowed", c, name)
}
}
return nil
}
// TL is a convenience shorthand for tests.
func TL(packagePath, label string) TargetLabel {
return TargetLabel{Package: packagePath, Name: label}
}
// String returns the canonical form "//pkg:target".
func (t TargetLabel) String() string {
return "//" + t.Package + ":" + t.Name
}
// CanBeShortened returns true if the //foo/bar:bar -> //foo/bar shorthand can be used for this label.
func (t TargetLabel) CanBeShortened() bool {
packageDir := strings.Split(t.Package, "/")[len(strings.Split(t.Package, "/"))-1]
return t.Name == packageDir
}
func (t TargetLabel) IsTest() bool {
return strings.HasSuffix(t.Name, "test")
}
func PrintSorted(labels []TargetLabel) {
var result []string
for _, label := range labels {
result = append(result, label.String())
}
sort.Strings(result)
for _, s := range result {
fmt.Println(s)
}
}
package label
import (
"fmt"
"strings"
)
// TargetPattern represents a Bazel target pattern, e.g. "//pkg/..." or "//pkg/...:target".
// It supports recursive (hierarchical) matching using the "..." wildcard.
type TargetPattern struct {
prefix string // package prefix (without trailing slash)
targetPattern string // target name filter (if empty, matches any target)
recursive bool // true if "..." is used for recursive matching
// Used for partial target patterns. true if the package path is incomplete
isPrefixPartial bool
}
// ParseTargetPattern parses a Bazel target pattern.
func ParseTargetPattern(currentPackage string, pattern string) (TargetPattern, error) {
if !strings.HasPrefix(pattern, "//") {
_, after, ok := strings.Cut(pattern, ":")
if !ok {
return TargetPattern{}, fmt.Errorf("invalid pattern %q: relative patterns must use ':'", pattern)
}
targetName := after
if targetName != "..." {
if err := validateName(targetName); err != nil {
return TargetPattern{}, err
}
}
return TargetPattern{prefix: currentPackage, targetPattern: targetName}, nil
}
body := pattern[2:]
var prefix, targetPattern string
recursive := false
before, after, ok := strings.Cut(body, ":")
packagePart := body
if ok {
packagePart = before
targetPattern = after
if targetPattern == "" {
return TargetPattern{}, fmt.Errorf("invalid pattern %q: target pattern is empty", pattern)
}
}
ellipsisIndex := strings.Index(packagePart, "...")
if ellipsisIndex != -1 {
if len(packagePart) > ellipsisIndex+3 {
return TargetPattern{}, fmt.Errorf("invalid pattern %q: unexpected characters after '...'", pattern)
}
recursive = true
prefix = packagePart[:ellipsisIndex]
} else {
prefix = packagePart
if !ok {
// Shorthand: "//foo" is equivalent to "//foo:foo"
targetPattern = packagePart[strings.LastIndex(packagePart, "/")+1:]
if targetPattern == "" {
return TargetPattern{}, fmt.Errorf("invalid pattern %q: target pattern is empty", pattern)
}
}
}
// Normalize the prefix by removing a trailing slash if present.
if len(prefix) > 0 && prefix[len(prefix)-1] == '/' {
prefix = prefix[:len(prefix)-1]
}
return TargetPattern{prefix: prefix, targetPattern: targetPattern, recursive: recursive}, nil
}
type TargetPatternSet = []TargetPattern
func ParsePatternsOrMatchAll(currentPackage string, patterns []string) ([]TargetPattern, error) {
if len(patterns) == 0 {
return []TargetPattern{GetMatchAllTargetPattern()}, nil
}
return ParsePatterns(currentPackage, patterns)
}
func ParsePatterns(currentPackage string, patterns []string) ([]TargetPattern, error) {
var result []TargetPattern
for _, pattern := range patterns {
p, err := ParseTargetPattern(currentPackage, pattern)
if err != nil {
return nil, err
}
result = append(result, p)
}
return result, nil
}
// ParsePatternsOrMatchCurrentPackageAndSubpackages parses provided patterns or,
// when none are provided, defaults to all targets in the current package and
// all of its subpackages.
func ParsePatternsOrMatchCurrentPackageAndSubpackages(currentPackage string, patterns []string) ([]TargetPattern, error) {
if len(patterns) == 0 {
currentPackageRecursivePattern := "//..."
if currentPackage != "" {
currentPackageRecursivePattern = "//" + currentPackage + "/..."
}
parsedPattern, err := ParseTargetPattern(currentPackage, currentPackageRecursivePattern)
if err != nil {
return nil, err
}
return []TargetPattern{parsedPattern}, nil
}
return ParsePatterns(currentPackage, patterns)
}
func PatternSetToString(patterns []TargetPattern) string {
var result []string
for _, p := range patterns {
result = append(result, p.String())
}
return strings.Join(result, " ")
}
func TargetPatternFromLabel(label TargetLabel) TargetPattern {
return TargetPattern{prefix: label.Package, targetPattern: label.Name, recursive: false}
}
func GetMatchAllTargetPattern() TargetPattern {
return TargetPattern{prefix: "", recursive: true}
}
// ParsePartialTargetPattern parses a target pattern but is lenient towards
// incomplete patterns. It is primarily used for shell completions where the
// user might not have typed the full pattern yet. Any missing parts are simply
// returned empty without an error.
func ParsePartialTargetPattern(currentPackage, pattern string) TargetPattern {
if strings.HasPrefix(pattern, ":") {
if currentPackage == "." {
currentPackage = ""
}
return TargetPattern{prefix: currentPackage, targetPattern: pattern[1:]}
}
var colonIndex int
if !strings.HasPrefix(pattern, "//") {
// Relative pattern without explicit ":" or shorthand.
colonIndex = strings.Index(pattern, ":")
var targetName string
if colonIndex == -1 {
targetName = pattern
} else {
targetName = pattern[colonIndex+1:]
}
return TargetPattern{prefix: currentPackage, targetPattern: targetName}
}
body := pattern[2:]
prefix := body
targetPattern := ""
recursive := false
isPrefixPartial := false
colonIndex = strings.Index(body, ":")
packagePart := body
if colonIndex != -1 {
packagePart = body[:colonIndex]
targetPattern = body[colonIndex+1:]
}
if before, _, ok := strings.Cut(packagePart, "..."); ok {
recursive = true
prefix = before
} else {
prefix = packagePart
}
if len(prefix) > 0 {
if prefix[len(prefix)-1] == '/' {
prefix = prefix[:len(prefix)-1]
} else if colonIndex <= 0 && !recursive {
// We are dealing with a partial package path, e.g. //foo
isPrefixPartial = true
}
}
return TargetPattern{prefix: prefix, targetPattern: targetPattern, recursive: recursive, isPrefixPartial: isPrefixPartial}
}
// Matches returns true if the given TargetLabel matches the pattern.
func (p TargetPattern) Matches(t TargetLabel) bool {
// Package matching.
if p.recursive {
// If prefix is non-empty, package must equal prefix or start with "prefix/".
if p.prefix != "" {
if t.Package != p.prefix && !strings.HasPrefix(t.Package, p.prefix+"/") {
return false
}
}
// If prefix is empty, pattern is "//...", which matches any package.
} else {
// Non-recursive: require an exact package match.
if t.Package != p.prefix {
return false
}
}
// Target name matching.
if p.targetPattern == "" {
return true
}
// Allow "all" to match any target.
if p.targetPattern == "all" || p.targetPattern == "..." {
return true
}
return t.Name == p.targetPattern
}
// String returns a string representation of the TargetPattern.
func (p TargetPattern) String() string {
base := "//" + p.prefix
if p.recursive {
if p.prefix != "" {
base += "/..."
} else {
// For root wild-cards
base += "..."
}
}
if p.targetPattern != "" {
base += ":" + p.targetPattern
}
return base
}
// Prefix returns the package prefix of the pattern.
func (p TargetPattern) Prefix() string { return p.prefix }
// Target returns the target pattern portion.
func (p TargetPattern) Target() string { return p.targetPattern }
// Recursive reports whether the pattern matches recursively.
func (p TargetPattern) Recursive() bool { return p.recursive }
// IsPrefixPartial reports whether the package prefix was incomplete for partial patterns.
func (p TargetPattern) IsPrefixPartial() bool { return p.isPrefixPartial }
package loading
// scriptAnnotation represents a grog yaml annotation for the grog scripts feature.
type scriptAnnotation struct {
Name string `yaml:"name"`
Dependencies []string `yaml:"dependencies"`
Inputs []string `yaml:"inputs"`
Tags []string `yaml:"tags"`
Fingerprint map[string]string `yaml:"fingerprint"`
EnvironmentVariables map[string]string `yaml:"environment_variables"`
Timeout string `yaml:"timeout"`
Platforms []string `yaml:"platforms"`
}
// annotationLineRange returns the first and last source line of an annotation
// block, or (0, 0) when the block carries no comment lines at all.
func annotationLineRange(annotationLineNumbers []int) (int, int) {
if len(annotationLineNumbers) == 0 {
return 0, 0
}
return annotationLineNumbers[0], annotationLineNumbers[len(annotationLineNumbers)-1]
}
// grogAnnotation represents the annotation block in a Makefile.
type grogAnnotation struct {
scriptAnnotation `yaml:",inline"`
// script annotations cannot have outputs
Outputs []string `yaml:"outputs"`
OciPush map[string]ociPushDestinations `yaml:"oci_push"`
}
package loading
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/hashing"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/resolvers"
"github.com/chrismatix/grog/internal/shell"
"golang.org/x/sync/errgroup"
)
// CasProvider is only called when the workspace declares resolvers, so a
// broken cache setup cannot fail loads that never run one.
type CasProvider func(context.Context) (*caching.Cas, error)
// DependencyInferrer runs the dependency resolvers a load declares. A nil
// CasProvider disables caching their output.
type DependencyInferrer struct {
CasProvider CasProvider
// GrogVersion keys the cached output of built-in resolvers.
GrogVersion string
}
// inferDependencies runs every declared dependency resolver and appends the
// edges it reports to the target each package registered for it. A reported
// package that registers nothing gets a filegroup synthesized from its inputs.
func (inferrer *DependencyInferrer) inferDependencies(loadContext context.Context, packages []*model.Package) ([]*model.Package, error) {
declarations := make(map[label.TargetLabel]*model.DependencyResolver)
packagesByPath := make(map[string]*model.Package, len(packages))
var targets []*model.Target
for _, loadedPackage := range packages {
maps.Copy(declarations, loadedPackage.DependencyResolvers)
packagesByPath[loadedPackage.Path] = loadedPackage
targets = append(targets, loadedPackage.GetTargets()...)
}
declaredResolvers := slices.SortedFunc(maps.Values(declarations), func(first, second *model.DependencyResolver) int {
return strings.Compare(first.Label.String(), second.Label.String())
})
registrations, operationError := registerTargets(declaredResolvers, targets)
if operationError != nil {
return nil, operationError
}
var cas *caching.Cas
if len(declaredResolvers) > 0 && inferrer.CasProvider != nil {
cas, operationError = inferrer.CasProvider(loadContext)
if operationError != nil {
return nil, fmt.Errorf("could not instantiate cache for dependency resolvers: %w", operationError)
}
}
documents := make([]resolvers.Document, len(declaredResolvers))
resolverGroup, resolverContext := errgroup.WithContext(loadContext)
for index, resolver := range declaredResolvers {
resolverGroup.Go(func() error {
var operationError error
documents[index], operationError = inferrer.runDependencyResolver(resolverContext, resolver, cas)
return operationError
})
}
if operationError := resolverGroup.Wait(); operationError != nil {
return nil, operationError
}
// Synthesize before linking: a reported dependency may itself be synthesized.
for index, resolver := range declaredResolvers {
document := documents[index]
for _, packagePath := range slices.Sorted(maps.Keys(document.Packages)) {
key := registration{resolver.Label, path.Join(resolver.Label.Package, packagePath)}
inputs := document.Packages[packagePath].Inputs
if registrations[key] != nil || len(inputs) == 0 {
continue
}
synthesized, createdPackage, operationError := synthesizeFilegroup(loadContext, resolver, key.packagePath, document.Packages[packagePath], packagesByPath)
if operationError != nil {
return nil, operationError
}
if createdPackage != nil {
packages = append(packages, createdPackage)
}
registrations[key] = synthesized
}
}
if operationError := linkDependencies(loadContext, declaredResolvers, documents, registrations); operationError != nil {
return nil, operationError
}
return packages, nil
}
// linkDependencies appends every reported edge to the target registered for
// the reported package. Path entries resolve through the registrations, label
// entries are parsed as they are.
func linkDependencies(loadContext context.Context, declaredResolvers []*model.DependencyResolver, documents []resolvers.Document, registrations map[registration]*model.Target) error {
for index, resolver := range declaredResolvers {
document := documents[index]
for _, packagePath := range slices.Sorted(maps.Keys(document.Packages)) {
target := registrations[registration{resolver.Label, path.Join(resolver.Label.Package, packagePath)}]
if target == nil {
console.GetLogger(loadContext).Debugf("resolver %s reports unregistered package %s; ignoring", resolver.Label, packagePath)
continue
}
reportedPackage := document.Packages[packagePath]
if len(reportedPackage.Fingerprint) > 0 && target.Fingerprint == nil {
target.Fingerprint = make(map[string]string, len(reportedPackage.Fingerprint))
}
for name, value := range reportedPackage.Fingerprint {
target.Fingerprint[resolver.Label.String()+"#"+name] = value
}
for _, dependency := range reportedPackage.Dependencies {
var dependencyLabel label.TargetLabel
if strings.HasPrefix(dependency, "//") {
parsedLabel, operationError := label.ParseTargetLabel("", dependency)
if operationError != nil {
return fmt.Errorf("resolver %s reports invalid dependency %q: %w", resolver.Label, dependency, operationError)
}
dependencyLabel = parsedLabel
} else {
dependencyTarget := registrations[registration{resolver.Label, path.Join(resolver.Label.Package, dependency)}]
if dependencyTarget == nil {
return fmt.Errorf("resolver %s reports %s depends on %s, which has no target registered for %s and no inputs to synthesize one from", resolver.Label, packagePath, dependency, resolver.Label)
}
dependencyLabel = dependencyTarget.Label
}
if dependencyLabel != target.Label {
target.Dependencies = append(target.Dependencies, dependencyLabel)
}
}
slices.SortFunc(target.Dependencies, func(first, second label.TargetLabel) int { return strings.Compare(first.String(), second.String()) })
target.Dependencies = slices.Compact(target.Dependencies)
}
}
return nil
}
type registration struct {
resolver label.TargetLabel
packagePath string
}
// registerTargets finds the one target per package that lists each resolver
// in dependency_resolvers.
func registerTargets(declaredResolvers []*model.DependencyResolver, targets []*model.Target) (map[registration]*model.Target, error) {
declared := make(map[label.TargetLabel]bool, len(declaredResolvers))
declaredLabels := make([]string, 0, len(declaredResolvers))
for _, resolver := range declaredResolvers {
declared[resolver.Label] = true
declaredLabels = append(declaredLabels, resolver.Label.String())
}
registrations := make(map[registration]*model.Target)
slices.SortFunc(targets, func(first, second *model.Target) int {
return strings.Compare(first.Label.String(), second.Label.String())
})
for _, target := range targets {
for _, resolverLabel := range target.DependencyResolvers {
if !declared[resolverLabel] {
return nil, fmt.Errorf("no dependency resolver at %s (referenced by %s); declared: %s", resolverLabel, target.Label, strings.Join(declaredLabels, ", "))
}
key := registration{resolverLabel, target.Label.Package}
if previous := registrations[key]; previous != nil && previous != target {
return nil, fmt.Errorf("resolver %s registered twice in package %s: %s and %s", resolverLabel, target.Label.Package, previous.Label, target.Label)
}
registrations[key] = target
}
}
return registrations, nil
}
// synthesizeFilegroup gives a package that registers no target a filegroup
// built from the inputs its resolver declared. The resolver's BUILD file is
// recorded as the defining file so `grog changes` and duplicate-label errors
// have one to point at. The second result is the package it had to create
// because no BUILD file exists there, or nil.
func synthesizeFilegroup(loadContext context.Context, resolver *model.DependencyResolver, packagePath string, reportedPackage resolvers.Package, packagesByPath map[string]*model.Package) (*model.Target, *model.Package, error) {
targetLabel := label.TargetLabel{Package: packagePath, Name: resolver.GeneratedTargetName}
var createdPackage *model.Package
owningPackage, exists := packagesByPath[packagePath]
if !exists {
createdPackage = &model.Package{
Path: packagePath,
Targets: make(map[label.TargetLabel]*model.Target),
Aliases: make(map[label.TargetLabel]*model.Alias),
Resources: make(map[label.TargetLabel]*model.Resource),
}
packagesByPath[packagePath] = createdPackage
owningPackage = createdPackage
}
if existing := owningPackage.Targets[targetLabel]; existing != nil {
return nil, nil, fmt.Errorf("resolver %s cannot synthesize %s: a target with that name is defined in %s", resolver.Label, targetLabel, existing.SourceFilePath)
}
if existing := owningPackage.Aliases[targetLabel]; existing != nil {
return nil, nil, fmt.Errorf("resolver %s cannot synthesize %s: an alias with that name is defined in %s", resolver.Label, targetLabel, existing.SourceFilePath)
}
if existing := owningPackage.Resources[targetLabel]; existing != nil {
return nil, nil, fmt.Errorf("resolver %s cannot synthesize %s: a resource with that name is defined in %s", resolver.Label, targetLabel, existing.SourceFilePath)
}
resolvedInputs, operationError := resolveInputs(console.GetLogger(loadContext), config.GetPathAbsoluteToWorkspaceRoot(packagePath), reportedPackage.Inputs, reportedPackage.ExcludeInputs)
if operationError != nil {
return nil, nil, fmt.Errorf("resolver %s: failed to resolve inputs for %s: %w", resolver.Label, targetLabel, operationError)
}
target := &model.Target{
SourceFilePath: resolver.SourceFilePath,
Label: targetLabel,
Inputs: resolvedInputs,
ExcludeInputs: reportedPackage.ExcludeInputs,
UnresolvedInputs: reportedPackage.Inputs,
DependencyResolvers: []label.TargetLabel{resolver.Label},
}
owningPackage.Targets[targetLabel] = target
return target, createdPackage, nil
}
// runDependencyResolver produces one resolver's document and checks it
// against version 1 of the protocol. The document comes from the cache when
// the resolver's inputs are unchanged, from Go for a builtin:: command and
// otherwise from the JSON its shell command prints. Only valid output is cached.
func (inferrer *DependencyInferrer) runDependencyResolver(loadContext context.Context, resolver *model.DependencyResolver, cas *caching.Cas) (resolvers.Document, error) {
resolverContext, cancel := context.WithTimeout(loadContext, resolver.Timeout)
defer cancel()
logger := console.GetLogger(loadContext)
var document resolvers.Document
cacheKey, operationError := resolverCacheKey(resolver, inferrer.GrogVersion)
if operationError != nil {
return document, fmt.Errorf("resolver %s failed: %w", resolver.Label, operationError)
}
logger.Debugf("resolver %s: %s (cache key %s)", resolver.Label, resolver.Command, cacheKey)
var output []byte
cacheHit := false
if cas != nil {
output, operationError = cas.LoadBytes(resolverContext, cacheKey)
cacheHit = operationError == nil
}
if !cacheHit {
if builtin, isBuiltin := resolvers.Builtins[resolver.Command]; isBuiltin {
document, operationError = builtin.Resolve(resolverContext, config.GetPathAbsoluteToWorkspaceRoot(resolver.Label.Package))
if operationError == nil {
output, operationError = json.Marshal(document)
}
} else {
output, operationError = runResolverCommand(resolverContext, resolver)
}
if operationError != nil {
return document, fmt.Errorf("resolver %s failed: %w", resolver.Label, operationError)
}
}
if operationError := json.Unmarshal(output, &document); operationError != nil {
return document, fmt.Errorf("resolver %s returned invalid JSON: %w; stdout: %q", resolver.Label, operationError, output[:min(len(output), 2048)])
}
edgeCount, operationError := validateResolverDocument(resolver.Label, document)
if operationError != nil {
return document, operationError
}
if cas != nil && !cacheHit {
if operationError := cas.WriteBytes(resolverContext, cacheKey, output); operationError != nil {
return document, fmt.Errorf("resolver %s: failed to cache output: %w", resolver.Label, operationError)
}
}
cacheStatus := "cache miss"
if cacheHit {
cacheStatus = "cache hit"
}
logger.Debugf("resolver %s: %s, %s, %d packages, %d edges", resolver.Label, resolver.Command, cacheStatus, len(document.Packages), edgeCount)
if logger.DebugEnabled() {
logger.Debugf("resolver %s mapping: %s", resolver.Label, output)
}
return document, nil
}
// validateResolverDocument checks a document against version 1 of the
// protocol and returns how many edges it reports. Dependencies that are
// labels are parsed when they are linked, so only path entries are checked.
func validateResolverDocument(resolverLabel label.TargetLabel, document resolvers.Document) (int, error) {
if document.Version > 1 {
return 0, fmt.Errorf("resolver %s returned unsupported version %d; upgrade grog", resolverLabel, document.Version)
}
if document.Version != 1 {
return 0, fmt.Errorf("resolver %s must return version 1", resolverLabel)
}
if document.Packages == nil {
return 0, fmt.Errorf("resolver %s must return a packages object", resolverLabel)
}
edgeCount := 0
for _, packagePath := range slices.Sorted(maps.Keys(document.Packages)) {
if operationError := resolvers.ValidatePath(packagePath); operationError != nil {
return 0, fmt.Errorf("resolver %s: %w", resolverLabel, operationError)
}
reportedPackage := document.Packages[packagePath]
slices.Sort(reportedPackage.Dependencies)
edgeCount += len(reportedPackage.Dependencies)
for _, dependency := range reportedPackage.Dependencies {
if strings.HasPrefix(dependency, "//") {
continue
}
if operationError := resolvers.ValidatePath(dependency); operationError != nil {
return 0, fmt.Errorf("resolver %s: %w", resolverLabel, operationError)
}
}
for _, input := range slices.Concat(reportedPackage.Inputs, reportedPackage.ExcludeInputs) {
if operationError := resolvers.ValidatePath(input); operationError != nil {
return 0, fmt.Errorf("resolver %s: inputs of %s: %w", resolverLabel, packagePath, operationError)
}
}
}
return edgeCount, nil
}
// resolverCacheKey hashes the protocol version, the label and command, grog's
// version for a built-in, and the path, size and contents of every resolved
// input. Inputs that do not exist are skipped.
func resolverCacheKey(resolver *model.DependencyResolver, grogVersion string) (string, error) {
// Strings are NUL-terminated and file contents length-prefixed, so distinct inputs never encode to the same bytes.
hasher := hashing.GetHasher()
_, _ = fmt.Fprintf(hasher, "1\x00%s\x00%s\x00", resolver.Label, resolver.Command)
if strings.HasPrefix(resolver.Command, "builtin::") {
_, _ = fmt.Fprintf(hasher, "%s\x00", grogVersion)
}
packageDirectory := config.GetPathAbsoluteToWorkspaceRoot(resolver.Label.Package)
for _, input := range slices.Sorted(slices.Values(resolver.Inputs)) {
file, operationError := os.Open(filepath.Join(packageDirectory, input))
if errors.Is(operationError, os.ErrNotExist) {
continue
}
if operationError != nil {
return "", operationError
}
fileInfo, operationError := file.Stat()
if operationError == nil {
_, _ = fmt.Fprintf(hasher, "%s\x00%d\x00", input, fileInfo.Size())
_, operationError = io.Copy(hasher, file)
}
_ = file.Close()
if operationError != nil {
return "", operationError
}
}
return hasher.SumString(), nil
}
// runResolverCommand runs the resolver's shell command in its package with a
// target's environment and returns what it printed to stdout.
func runResolverCommand(resolverContext context.Context, resolver *model.DependencyResolver) ([]byte, error) {
command, cleanup, operationError := shell.NewCommand(resolverContext, shell.WithDefaultFlags(resolver.Command))
if operationError != nil {
return nil, operationError
}
defer cleanup()
command.Dir = config.GetPathAbsoluteToWorkspaceRoot(resolver.Label.Package)
command.Env = os.Environ()
for name, value := range config.Global.EnvironmentVariables {
command.Env = append(command.Env, name+"="+value)
}
for name, value := range LoaderEnv() {
command.Env = append(command.Env, name+"="+value)
}
command.Env = append(command.Env, "GROG_RESOLVER_LABEL="+resolver.Label.String(), "GROG_TARGET="+resolver.Label.String(), "GROG_PACKAGE="+resolver.Label.Package)
var standardOutput, standardError bytes.Buffer
command.Stdout = &standardOutput
command.Stderr = &standardError
if operationError := command.Run(); operationError != nil {
if errors.Is(resolverContext.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("timed out after %s\n%s", resolver.Timeout, standardError.String())
}
return nil, fmt.Errorf("%w\n%s", operationError, standardError.String())
}
console.GetLogger(resolverContext).Debugf("resolver %s stderr: %s", resolver.Label, standardError.String())
return standardOutput.Bytes(), nil
}
package loading
import (
"encoding/json"
"fmt"
"github.com/chrismatix/grog/internal/model"
"gopkg.in/yaml.v3"
)
// ociPushDestinations holds the value side of TargetDTO.OciPush: one or
// more remote tags. Authors can write a scalar for the common single-
// destination case and a list for multi-destination; both deserialise here.
type ociPushDestinations []string
func (o *ociPushDestinations) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
*o = []string{node.Value}
return nil
case yaml.SequenceNode:
dst := make([]string, 0, len(node.Content))
for _, item := range node.Content {
if item.Kind != yaml.ScalarNode {
return fmt.Errorf("oci_push destination must be a string, got %v", item.Kind)
}
dst = append(dst, item.Value)
}
*o = dst
return nil
default:
return fmt.Errorf("oci_push destination must be a string or list of strings")
}
}
func (o *ociPushDestinations) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*o = []string{single}
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err != nil {
return fmt.Errorf("oci_push destination must be a string or list of strings: %w", err)
}
*o = list
return nil
}
// TargetDTO is used for deserializing a target in a loader.
// The target is used internally is in model.Target.
type TargetDTO struct {
DependencyResolvers []string `json:"dependency_resolvers,omitempty" yaml:"dependency_resolvers,omitempty" pkl:"dependency_resolvers" starlark:"dependency_resolvers"`
Name string `json:"name" yaml:"name" pkl:"name" starlark:"name"`
Command string `json:"command" yaml:"command" pkl:"command" starlark:"command"`
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty" pkl:"dependencies" starlark:"dependencies"`
Inputs []string `json:"inputs,omitempty" yaml:"inputs,omitempty" pkl:"inputs" starlark:"inputs"`
ExcludeInputs []string `json:"exclude_inputs,omitempty" yaml:"exclude_inputs,omitempty" pkl:"exclude_inputs" starlark:"exclude_inputs"`
Outputs []string `json:"outputs,omitempty" yaml:"outputs,omitempty" pkl:"outputs" starlark:"outputs"`
// OciPush is a map from an oci:: output's local name to its remote
// destination(s). A scalar value is normalised to a single-entry slice
// at parse time so YAML and pkl can both write `name: "repo:tag"`.
OciPush map[string]ociPushDestinations `json:"oci_push,omitempty" yaml:"oci_push,omitempty" pkl:"oci_push" starlark:"oci_push"`
BinOutput string `json:"bin_output" yaml:"bin_output" pkl:"bin_output" starlark:"bin_output"`
BinaryRequiresPush bool `json:"binary_requires_push,omitempty" yaml:"binary_requires_push,omitempty" pkl:"binary_requires_push" starlark:"binary_requires_push"`
OutputChecks []model.OutputCheck `json:"output_checks,omitempty" yaml:"output_checks,omitempty" pkl:"output_checks" starlark:"output_checks"`
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty" pkl:"tags" starlark:"tags"`
Fingerprint map[string]string `json:"fingerprint,omitempty" yaml:"fingerprint,omitempty" pkl:"fingerprint" starlark:"fingerprint"`
Platforms []string `json:"platforms,omitempty" yaml:"platforms,omitempty" pkl:"platforms" starlark:"platforms"`
EnvironmentVariables map[string]string `json:"environment_variables,omitempty" yaml:"environment_variables,omitempty" pkl:"environment_variables" starlark:"environment_variables"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty" pkl:"timeout" starlark:"timeout"`
ConcurrencyGroup string `json:"concurrency_group,omitempty" yaml:"concurrency_group,omitempty" pkl:"concurrency_group" starlark:"concurrency_group"`
}
type AliasDTO struct {
Name string `json:"name" yaml:"name" pkl:"name" starlark:"name"`
Actual string `json:"actual" yaml:"actual" pkl:"actual" starlark:"actual"`
}
// ResourceDTO is used for deserializing a resource in a loader.
// The resource used internally is model.Resource.
type ResourceDTO struct {
Name string `json:"name" yaml:"name" pkl:"name" starlark:"name"`
Up string `json:"up" yaml:"up" pkl:"up" starlark:"up"`
Down string `json:"down,omitempty" yaml:"down,omitempty" pkl:"down" starlark:"down"`
Ready string `json:"ready,omitempty" yaml:"ready,omitempty" pkl:"ready" starlark:"ready"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty" pkl:"timeout" starlark:"timeout"`
Exports map[string]string `json:"exports,omitempty" yaml:"exports,omitempty" pkl:"exports" starlark:"exports"`
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty" pkl:"dependencies" starlark:"dependencies"`
}
// DependencyResolverDTO is a load-time dependency resolver declaration.
type DependencyResolverDTO struct {
Name string `json:"name" yaml:"name" pkl:"name" starlark:"name"`
Command string `json:"command" yaml:"command" pkl:"command" starlark:"command"`
Inputs []string `json:"inputs,omitempty" yaml:"inputs,omitempty" pkl:"inputs" starlark:"inputs"`
ExcludeInputs []string `json:"exclude_inputs,omitempty" yaml:"exclude_inputs,omitempty" pkl:"exclude_inputs" starlark:"exclude_inputs"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty" pkl:"timeout" starlark:"timeout"`
// GeneratedTargetName names the filegroup grog creates for a package that
// registers no target. Defaults to _<name>_package.
GeneratedTargetName string `json:"generated_target_name,omitempty" yaml:"generated_target_name,omitempty" pkl:"generated_target_name" starlark:"generated_target_name"`
}
type EnvironmentDTO struct {
Name string `json:"name" yaml:"name" pkl:"name" starlark:"name"`
Type string `json:"type" yaml:"type" pkl:"type" starlark:"type"`
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty" pkl:"dependencies" starlark:"dependencies"`
OCIImage string `json:"oci_image" yaml:"oci_image" pkl:"oci_image" starlark:"oci_image"`
}
// PackageDTO is used for deserializing a package in a loader.
// The package that we use internally is in model.Package.
type PackageDTO struct {
DependencyResolvers []*DependencyResolverDTO `json:"dependency_resolvers" yaml:"dependency_resolvers" pkl:"dependency_resolvers" starlark:"dependency_resolvers"`
// Record the path to the source file that defines this package.
// Note that in the final model package this is stored on the target level not the package
SourceFilePath string
Targets []*TargetDTO `json:"targets" yaml:"targets" pkl:"targets" starlark:"targets"`
Aliases []*AliasDTO `json:"aliases" yaml:"aliases" pkl:"aliases" starlark:"aliases"`
Resources []*ResourceDTO `json:"resources" yaml:"resources" pkl:"resources" starlark:"resources"`
Environments []*EnvironmentDTO `json:"environments" yaml:"environments" pkl:"environments" starlark:"environments"`
// DefaultPlatforms specifies the platform selectors at the package level.
// This serves as the default for target-level platform selectors.
// If a target specifies its own platform selectors, they override this default.
DefaultPlatforms []string `json:"default_platforms,omitempty" yaml:"default_platforms,omitempty" pkl:"default_platforms" starlark:"default_platforms"`
}
package loading
import (
"context"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output"
"github.com/chrismatix/grog/internal/resolvers"
"github.com/bmatcuk/doublestar/v4"
)
// getEnrichedPackage enriches the parsing dto with the following information
// - adds the package path to the target labels
// - resolves the globs in the inputs
// - applies any defaults
// - parses the deps into target labels.
func getEnrichedPackage(loadContext context.Context, logger *console.Logger, packagePath string, pkg PackageDTO) (*model.Package, error) {
targets := make(map[label.TargetLabel]*model.Target)
aliases := make(map[label.TargetLabel]*model.Alias)
absolutePackagePath := config.GetPathAbsoluteToWorkspaceRoot(packagePath)
// root package is always encoded as ""
if packagePath == "." {
packagePath = ""
}
for _, target := range pkg.Targets {
enrichedTarget, err := enrichTarget(logger, packagePath, absolutePackagePath, pkg, target, targets)
if err != nil {
return nil, err
}
targets[enrichedTarget.Label] = enrichedTarget
}
resources, err := enrichResources(packagePath, pkg, targets)
if err != nil {
return nil, err
}
for _, alias := range pkg.Aliases {
actualLabel, err := label.ParseTargetLabel(packagePath, alias.Actual)
if err != nil {
return nil, err
}
aliasLabel := label.TargetLabel{Package: packagePath, Name: alias.Name}
if _, ok := targets[aliasLabel]; ok || aliases[aliasLabel] != nil || resources[aliasLabel] != nil {
return nil, fmt.Errorf("duplicate target label: %s (package file %s)", alias.Name, pkg.SourceFilePath)
}
aliases[aliasLabel] = &model.Alias{
SourceFilePath: pkg.SourceFilePath,
Label: aliasLabel,
Actual: actualLabel,
}
}
dependencyResolvers, err := enrichDependencyResolvers(loadContext, logger, packagePath, absolutePackagePath, pkg, targets, aliases, resources)
if err != nil {
return nil, err
}
return &model.Package{
DependencyResolvers: dependencyResolvers,
Path: packagePath,
Targets: targets,
Aliases: aliases,
Resources: resources,
}, nil
}
// enrichTarget converts a target dto into a model target, rejecting labels already present in targets.
func enrichTarget(
logger *console.Logger,
packagePath string,
absolutePackagePath string,
pkg PackageDTO,
target *TargetDTO,
targets map[label.TargetLabel]*model.Target,
) (*model.Target, error) {
var deps []label.TargetLabel
// parse labels
for _, dep := range target.Dependencies {
depLabel, err := label.ParseTargetLabel(packagePath, dep)
if err != nil {
return nil, err
}
deps = append(deps, depLabel)
}
targetLabel := label.TargetLabel{Package: packagePath, Name: target.Name}
if _, ok := targets[targetLabel]; ok {
return nil, fmt.Errorf("duplicate target label: %s (package file %s)", target.Name, pkg.SourceFilePath)
}
resolvedInputs, err := resolveInputs(logger, absolutePackagePath, target.Inputs, target.ExcludeInputs)
if err != nil {
return nil, fmt.Errorf("failed to resolve inputs for target %s: %w", targetLabel, err)
}
parsedOutputs, err := output.ParseOutputs(target.Outputs)
if err != nil {
return nil, fmt.Errorf("failed to parse outputs for target %s: %w", targetLabel, err)
}
parsedBinOutput := model.Output{}
if target.BinOutput != "" {
parsedBinOutput, err = output.ParseOutput(target.BinOutput)
if err != nil {
return nil, fmt.Errorf("failed to parse bin output for target %s: %w", targetLabel, err)
}
if !parsedBinOutput.IsFile() {
return nil, fmt.Errorf("bin output %s for target %s must be of type file",
target.BinOutput, targetLabel)
}
}
var timeout time.Duration
if target.Timeout != "" {
timeout, err = time.ParseDuration(target.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to parse timeout for target %s: %w", targetLabel, err)
}
}
// Determine the platforms to use
// If target has its own platforms, use those
// Otherwise, use the package default platforms if available
var targetPlatforms []string
switch {
case target.Platforms != nil:
targetPlatforms = append([]string{}, target.Platforms...)
case pkg.DefaultPlatforms != nil:
targetPlatforms = append([]string{}, pkg.DefaultPlatforms...)
}
var ociPush map[string][]string
if len(target.OciPush) > 0 {
ociPush = make(map[string][]string, len(target.OciPush))
for local, dst := range target.OciPush {
ociPush[local] = []string(dst)
}
}
var dependencyResolvers []label.TargetLabel
for _, resolver := range target.DependencyResolvers {
resolverLabel, parseError := label.ParseTargetLabel(packagePath, resolver)
if parseError != nil {
return nil, fmt.Errorf("failed to parse dependency resolver for target %s: %w", targetLabel, parseError)
}
dependencyResolvers = append(dependencyResolvers, resolverLabel)
}
return &model.Target{
DependencyResolvers: dependencyResolvers,
SourceFilePath: pkg.SourceFilePath,
Label: targetLabel,
Command: target.Command,
Dependencies: deps,
Inputs: resolvedInputs,
UnresolvedInputs: target.Inputs,
ExcludeInputs: target.ExcludeInputs,
Outputs: parsedOutputs,
OciPush: ociPush,
BinOutput: parsedBinOutput,
BinaryRequiresPush: target.BinaryRequiresPush,
Platforms: targetPlatforms,
OutputChecks: target.OutputChecks,
Tags: target.Tags,
Fingerprint: target.Fingerprint,
EnvironmentVariables: target.EnvironmentVariables,
Timeout: timeout,
ConcurrencyGroup: target.ConcurrencyGroup,
}, nil
}
// enrichResources converts the package's resource dtos into model resources.
func enrichResources(
packagePath string,
pkg PackageDTO,
targets map[label.TargetLabel]*model.Target,
) (map[label.TargetLabel]*model.Resource, error) {
resources := make(map[label.TargetLabel]*model.Resource)
for _, resource := range pkg.Resources {
var resourceDeps []label.TargetLabel
for _, dep := range resource.Dependencies {
depLabel, err := label.ParseTargetLabel(packagePath, dep)
if err != nil {
return nil, err
}
resourceDeps = append(resourceDeps, depLabel)
}
resourceLabel := label.TargetLabel{Package: packagePath, Name: resource.Name}
if _, ok := targets[resourceLabel]; ok || resources[resourceLabel] != nil {
return nil, fmt.Errorf("duplicate target label: %s (package file %s)", resource.Name, pkg.SourceFilePath)
}
if resource.Up == "" {
return nil, fmt.Errorf("resource %s must define an up command (package file %s)", resourceLabel, pkg.SourceFilePath)
}
var resourceTimeout time.Duration
if resource.Timeout != "" {
var err error
resourceTimeout, err = time.ParseDuration(resource.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to parse timeout for resource %s: %w", resourceLabel, err)
}
}
resources[resourceLabel] = &model.Resource{
SourceFilePath: pkg.SourceFilePath,
Label: resourceLabel,
Up: resource.Up,
Down: resource.Down,
Ready: resource.Ready,
Timeout: resourceTimeout,
Exports: resource.Exports,
Dependencies: resourceDeps,
}
}
return resources, nil
}
// enrichDependencyResolvers converts the package's dependency resolver dtos into model dependency resolvers.
func enrichDependencyResolvers(
loadContext context.Context,
logger *console.Logger,
packagePath string,
absolutePackagePath string,
pkg PackageDTO,
targets map[label.TargetLabel]*model.Target,
aliases map[label.TargetLabel]*model.Alias,
resources map[label.TargetLabel]*model.Resource,
) (map[label.TargetLabel]*model.DependencyResolver, error) {
dependencyResolvers := make(map[label.TargetLabel]*model.DependencyResolver)
for _, resolver := range pkg.DependencyResolvers {
resolverLabel, enrichmentError := label.ParseTargetLabel(packagePath, ":"+resolver.Name)
if enrichmentError != nil {
return nil, fmt.Errorf("invalid dependency resolver name: %w", enrichmentError)
}
if targets[resolverLabel] != nil || aliases[resolverLabel] != nil || resources[resolverLabel] != nil || dependencyResolvers[resolverLabel] != nil {
return nil, fmt.Errorf("duplicate target label: %s (package file %s)", resolver.Name, pkg.SourceFilePath)
}
if resolver.Command == "" {
return nil, fmt.Errorf("dependency resolver %s must define a command (package file %s)", resolverLabel, pkg.SourceFilePath)
}
if resolver.Command == "builtin::node" {
return nil, fmt.Errorf("dependency resolver %s: builtin::node was split; use builtin::pnpm, builtin::npm or builtin::yarn (package file %s)", resolverLabel, pkg.SourceFilePath)
}
timeout := 60 * time.Second
if resolver.Timeout != "" {
timeout, enrichmentError = time.ParseDuration(resolver.Timeout)
if enrichmentError != nil {
return nil, fmt.Errorf("failed to parse timeout for dependency resolver %s: %w", resolverLabel, enrichmentError)
}
}
inputs, excludeInputs := resolver.Inputs, resolver.ExcludeInputs
if len(inputs) == 0 {
var defaultExcludeInputs []string
if builtin, isBuiltin := resolvers.Builtins[resolver.Command]; isBuiltin {
inputs, defaultExcludeInputs = builtin.DefaultInputs(loadContext, absolutePackagePath)
}
excludeInputs = slices.Concat(defaultExcludeInputs, excludeInputs)
}
if enrichmentError := loadContext.Err(); enrichmentError != nil {
return nil, enrichmentError
}
resolvedInputs, enrichmentError := resolveInputs(logger, absolutePackagePath, inputs, excludeInputs)
if enrichmentError != nil {
return nil, fmt.Errorf("failed to resolve inputs for dependency resolver %s: %w", resolverLabel, enrichmentError)
}
synthesizedTarget := resolver.GeneratedTargetName
if synthesizedTarget == "" {
synthesizedTarget = "_" + resolver.Name + "_package"
}
if _, enrichmentError := label.ParseTargetLabel(packagePath, ":"+synthesizedTarget); enrichmentError != nil {
return nil, fmt.Errorf("invalid generated_target_name for dependency resolver %s: %w", resolverLabel, enrichmentError)
}
dependencyResolvers[resolverLabel] = &model.DependencyResolver{
SourceFilePath: pkg.SourceFilePath, Label: resolverLabel, Command: resolver.Command, Inputs: resolvedInputs, Timeout: timeout,
GeneratedTargetName: synthesizedTarget,
}
}
return dependencyResolvers, nil
}
// resolveInputs resolves the glob patterns in the inputs and drops the files matching excludeInputs.
func resolveInputs(
logger *console.Logger,
absolutePackagePath string,
inputs []string,
excludeInputs []string,
) ([]string, error) {
for _, excludePattern := range excludeInputs {
if !doublestar.ValidatePattern(excludePattern) {
return nil, fmt.Errorf("failed to resolve exclusion glob pattern %s: %w", excludePattern, doublestar.ErrBadPattern)
}
}
fsys := excludingFS{FS: os.DirFS(absolutePackagePath), excludeInputs: excludeInputs}
var resolvedInputs []string
for _, input := range inputs {
if !strings.ContainsAny(input, "*?[{") {
// Nothing to resolve - no special glob characters
resolvedInputs = append(resolvedInputs, input)
continue
}
matches, err := doublestar.Glob(fsys, input, doublestar.WithFilesOnly())
if err != nil {
return nil, fmt.Errorf("failed to resolve glob pattern %s: %w", input, err)
}
resolvedInputs = append(resolvedInputs, matches...)
}
if len(excludeInputs) == 0 {
return resolvedInputs, nil
}
var filteredInputs []string
for _, input := range resolvedInputs {
if !slices.ContainsFunc(excludeInputs, func(pattern string) bool { return doublestar.MatchUnvalidated(pattern, input) }) {
filteredInputs = append(filteredInputs, input)
}
}
logger.Debugf("Filtered %d inputs to %d after applying %d exclusions",
len(resolvedInputs), len(filteredInputs), len(excludeInputs))
return filteredInputs, nil
}
package loading
import (
"io/fs"
"path"
"slices"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
// excludingFS hides directories that an exclude pattern ending in "/**" covers
// entirely, so globbing never descends into them.
type excludingFS struct {
fs.FS
excludeInputs []string
}
func (filesystem excludingFS) ReadDir(name string) ([]fs.DirEntry, error) {
entries, err := fs.ReadDir(filesystem.FS, name)
if err != nil {
return nil, err
}
keptEntries := entries[:0]
for _, entry := range entries {
directoryPath := path.Join(name, entry.Name())
if entry.IsDir() && slices.ContainsFunc(filesystem.excludeInputs, func(pattern string) bool {
return strings.HasSuffix(pattern, "/**") && doublestar.MatchUnvalidated(pattern, directoryPath)
}) {
continue
}
keptEntries = append(keptEntries, entry)
}
return keptEntries, nil
}
package loading
import (
"context"
"encoding/json"
"fmt"
"os"
)
// JsonLoader implements the Loader interface for JSON files.
type JsonLoader struct{}
func (j JsonLoader) Matches(fileName string) bool {
return fileName == "BUILD.json"
}
// Load reads the file at the specified filePath and unmarshals its content into a model.Package.
func (j JsonLoader) Load(_ context.Context, filePath string) (PackageDTO, bool, error) {
var pkg PackageDTO
// Open the file.
file, err := os.Open(filePath)
if err != nil {
return pkg, false, err
}
defer func() { _ = file.Close() }()
// Decode JSON content.
decoder := json.NewDecoder(file)
err = decoder.Decode(&pkg)
if err != nil {
return pkg, true, fmt.Errorf(
"failed to decode JSON file %s: %w",
filePath,
err)
}
return pkg, true, nil
}
package loading
import (
"context"
"fmt"
"runtime"
"sync"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
"github.com/boyter/gocodewalker"
)
func LoadAllPackages(ctx context.Context, inferrer *DependencyInferrer) ([]*model.Package, error) {
packages, loadError := LoadPackages(ctx, config.Global.WorkspaceRoot)
if loadError != nil {
return nil, loadError
}
return inferrer.inferDependencies(ctx, packages)
}
// LoadPackages loads all packages in the given directory and its subdirectories.
func LoadPackages(ctx context.Context, startDir string) ([]*model.Package, error) {
logger := console.GetLogger(ctx)
fileListQueue := make(chan *gocodewalker.File, 100)
fileWalker := gocodewalker.NewParallelFileWalker([]string{startDir}, fileListQueue)
fileWalker.IncludeHidden = config.Global.IncludeHidden
go func() { _ = fileWalker.Start() }()
packageLoader := NewPackageLoader(logger)
// Keep track of loaded package paths to error out when there is a collision
// e.g. when a user defines both BUILD.json and BUILD.py in the same directory
// packagePath -> sourceFilePath
loadedPackages := make(map[string]*model.Package)
var loadedMutex sync.Mutex
loadContext, cancel := context.WithCancel(ctx)
defer cancel()
workerCount := config.Global.NumWorkers
if workerCount < 1 {
workerCount = runtime.NumCPU()
}
var errorOnce sync.Once
var loadError error
setError := func(err error) {
if err == nil {
return
}
errorOnce.Do(func() {
loadError = err
fmt.Println(err)
cancel()
})
}
var waitGroup sync.WaitGroup
waitGroup.Add(workerCount)
for workerIndex := 0; workerIndex < workerCount; workerIndex++ {
go func() {
defer waitGroup.Done()
for fileEntry := range fileListQueue {
if loadContext.Err() != nil {
continue
}
packageDTO, matched, err := packageLoader.LoadIfMatched(loadContext, fileEntry.Location, fileEntry.Filename)
if err != nil {
setError(err)
continue
}
if !matched {
continue
}
packagePath, err := config.GetPackagePath(fileEntry.Location)
if err != nil {
setError(err)
continue
}
packageModel, err := getEnrichedPackage(loadContext, logger, packagePath, packageDTO)
if err != nil {
setError(err)
continue
}
// Merge into existing package if it exists or set
loadedMutex.Lock()
existingPackage, ok := loadedPackages[packagePath]
if ok {
// This mutates the existingPackage
mergeError := mergePackages(packageModel, existingPackage)
loadedMutex.Unlock()
if mergeError != nil {
setError(mergeError)
}
continue
}
loadedPackages[packagePath] = packageModel
loadedMutex.Unlock()
}
}()
}
waitGroup.Wait()
if loadError != nil {
return nil, loadError
}
if contextErr := loadContext.Err(); contextErr != nil {
return nil, contextErr
}
packages := make([]*model.Package, 0, len(loadedPackages))
for _, loadedPackage := range loadedPackages {
packages = append(packages, loadedPackage)
}
return packages, nil
}
func mergePackages(from *model.Package, into *model.Package) error {
if into.Targets == nil {
into.Targets = make(map[label.TargetLabel]*model.Target)
}
if into.Aliases == nil {
into.Aliases = make(map[label.TargetLabel]*model.Alias)
}
if into.Resources == nil {
into.Resources = make(map[label.TargetLabel]*model.Resource)
}
if into.DependencyResolvers == nil {
into.DependencyResolvers = make(map[label.TargetLabel]*model.DependencyResolver)
}
for resolverLabel, resolver := range from.DependencyResolvers {
if kind, sourceFilePath, exists := existingDefinition(into, resolverLabel); exists {
return fmt.Errorf("duplicate dependency resolver label: %s (defined in %s and as %s in %s)", resolverLabel, resolver.SourceFilePath, kind, sourceFilePath)
}
into.DependencyResolvers[resolverLabel] = resolver
}
for targetLabel, target := range from.Targets {
if kind, sourceFilePath, exists := existingDefinition(into, targetLabel); exists {
return fmt.Errorf("duplicate target label: %s (defined in %s and as %s in %s)", targetLabel, target.SourceFilePath, kind, sourceFilePath)
}
into.Targets[targetLabel] = target
}
for aliasLabel, alias := range from.Aliases {
if kind, sourceFilePath, exists := existingDefinition(into, aliasLabel); exists {
return fmt.Errorf("duplicate alias label: %s (defined in %s and as %s in %s)", aliasLabel, alias.SourceFilePath, kind, sourceFilePath)
}
into.Aliases[aliasLabel] = alias
}
for resourceLabel, resource := range from.Resources {
if kind, sourceFilePath, exists := existingDefinition(into, resourceLabel); exists {
return fmt.Errorf("duplicate resource label: %s (defined in %s and as %s in %s)", resourceLabel, resource.SourceFilePath, kind, sourceFilePath)
}
into.Resources[resourceLabel] = resource
}
return nil
}
// existingDefinition reports which kind of definition in pkg already uses the label and where it is defined.
func existingDefinition(pkg *model.Package, targetLabel label.TargetLabel) (kind string, sourceFilePath string, exists bool) {
if target, exists := pkg.Targets[targetLabel]; exists {
return "target", target.SourceFilePath, true
}
if alias, exists := pkg.Aliases[targetLabel]; exists {
return "alias", alias.SourceFilePath, true
}
if resource, exists := pkg.Resources[targetLabel]; exists {
return "resource", resource.SourceFilePath, true
}
if resolver, exists := pkg.DependencyResolvers[targetLabel]; exists {
return "dependency resolver", resolver.SourceFilePath, true
}
return "", "", false
}
package loading
import (
"context"
"time"
"github.com/chrismatix/grog/internal/analysis"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/model"
)
func MustLoadGraphForBuild(ctx context.Context, logger *console.Logger, inferrer *DependencyInferrer) *dag.DirectedTargetGraph {
startTime := time.Now()
packages, err := LoadAllPackages(ctx, inferrer)
if err != nil {
logger.Fatalf(
"could not load packages: %v",
err)
}
nodes, err := model.BuildNodeMapFromPackages(packages)
if err != nil {
logger.Fatalf("could not create target map: %v", err)
}
graph, err := analysis.BuildGraph(nodes)
if err != nil {
logger.Fatalf("could not build graph: %v", err)
}
if config.Global.DisableNonDeterministicLogging {
logger.Infof("%s loaded, %s configured.",
console.FCountPkg(len(packages)),
console.FCountTargets(len(nodes.GetTargets())))
} else {
elapsedTime := time.Since(startTime).Seconds()
logger.Infof("%s loaded in %.2fs, %s configured.",
console.FCountPkg(len(packages)),
elapsedTime,
console.FCountTargets(len(nodes.GetTargets())),
)
}
return graph
}
func MustLoadGraphForQuery(ctx context.Context, logger *console.Logger, inferrer *DependencyInferrer) *dag.DirectedTargetGraph {
packages, err := LoadAllPackages(ctx, inferrer)
if err != nil {
logger.Fatalf("could not load packages: %v", err)
}
nodes, err := model.BuildNodeMapFromPackages(packages)
if err != nil {
logger.Fatalf("could not create target map: %v", err)
}
graph, err := analysis.BuildGraph(nodes)
if err != nil {
logger.Fatalf("could not build graph: %v", err)
}
return graph
}
package loading
import (
"strings"
"github.com/chrismatix/grog/internal/config"
"go.starlark.net/starlark"
)
// LoaderEnv returns the GROG_* values exposed to both the pkl and starlark
// loaders. Every entry must also be available to the build command via
// execution.GetExtendedTargetEnv — anything BUILD files can read at load
// time should also be readable at execute time and vice versa.
//
// Per-target values (GROG_TARGET, GROG_PACKAGE) are intentionally excluded;
// the loader does not know which target a value would belong to.
func LoaderEnv() map[string]string {
return map[string]string{
"GROG_OS": config.Global.OS,
"GROG_ARCH": config.Global.Arch,
"GROG_PLATFORM": config.Global.GetPlatform(),
"GROG_PLATFORM_TAGS": strings.Join(config.Global.PlatformTags, ","),
"GROG_ENV_FILE": resolvedEnvironmentVariablesFilePath(),
"GROG_WORKSPACE_ROOT": config.Global.WorkspaceRoot,
"GROG_GIT_HASH": loaderGitHash(),
}
}
// loaderGitHash mirrors execution.GetExtendedTargetEnv: outside a git repo,
// the variable resolves to the empty string instead of failing the load.
func loaderGitHash() string {
hash, _ := config.GetGitHash()
return hash
}
// addLoaderEnvToStarlark mirrors LoaderEnv into a starlark predeclared dict.
// GROG_PLATFORM_TAGS becomes a starlark list rather than the comma-joined
// string used by pkl, matching the existing per-language convention.
func addLoaderEnvToStarlark(dict starlark.StringDict) {
dict["GROG_OS"] = starlark.String(config.Global.OS)
dict["GROG_ARCH"] = starlark.String(config.Global.Arch)
dict["GROG_PLATFORM"] = starlark.String(config.Global.GetPlatform())
dict["GROG_PLATFORM_TAGS"] = platformTagsStarlarkList()
dict["GROG_ENV_FILE"] = starlark.String(resolvedEnvironmentVariablesFilePath())
dict["GROG_WORKSPACE_ROOT"] = starlark.String(config.Global.WorkspaceRoot)
dict["GROG_GIT_HASH"] = starlark.String(loaderGitHash())
}
package loading
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// MakefileLoader implements the Loader interface for Makefiles.
type MakefileLoader struct{}
func (m MakefileLoader) Matches(fileName string) bool {
return fileName == "Makefile"
}
// Load reads the Makefile at filePath parses it to PackageDTO.
func (m MakefileLoader) Load(_ context.Context, filePath string) (PackageDTO, bool, error) {
file, err := os.Open(filePath)
if err != nil {
return PackageDTO{}, false, err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
parser := newMakefileParser(scanner)
packageDto, targetsFound, err := parser.parse()
if err != nil {
return packageDto, targetsFound, fmt.Errorf(
"failed to parse Makefile %s: %w",
filePath,
err)
}
return packageDto, targetsFound, nil
}
// makefileParser encapsulates the scanning logic and state.
type makefileParser struct {
scanner *bufio.Scanner
pkg PackageDTO
}
// newMakefileParser creates a new parser for the given scanner and file path.
func newMakefileParser(scanner *bufio.Scanner) *makefileParser {
return &makefileParser{
scanner: scanner,
pkg: PackageDTO{
Targets: make([]*TargetDTO, 0),
},
}
}
// parse iterates through the file line by line, handling annotations and targets.
// returns the parsed PackageDTO and a bool indicating if targets were found at all.
func (p *makefileParser) parse() (PackageDTO, bool, error) {
targetsFound := false
lineCount := 0
for p.scanner.Scan() {
lineCount++
line := p.scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# @grog") {
targetsFound = true
// Collect the subsequent comment lines.
var annotationLines []string
var annotationLineNumbers []int
for p.scanner.Scan() {
lineCount++
nextLine := p.scanner.Text()
trimmedNext := strings.TrimSpace(nextLine)
if trimmedNext == "" {
continue
}
if strings.HasPrefix(trimmedNext, "#") {
// Remove '#' and any whitespace.
content := trimmedNext[1:]
annotationLines = append(annotationLines, content)
annotationLineNumbers = append(annotationLineNumbers, lineCount)
} else {
// End of annotation: this should be the target definition.
if err := p.handleTarget(annotationLines, annotationLineNumbers, nextLine, lineCount); err != nil {
return p.pkg, targetsFound, err
}
// Break out of the loop.
break
}
}
}
}
return p.pkg, targetsFound, p.scanner.Err()
}
// handleTarget parses the collected annotation lines and the subsequent target definition.
func (p *makefileParser) handleTarget(
annotationLines []string,
annotationLineNumbers []int,
targetLine string,
targetLineNumber int,
) error {
// Combine annotation lines into a YAML snippet.
annotationContent := strings.Join(annotationLines, "\n")
firstLineNum, lastLineNum := annotationLineRange(annotationLineNumbers)
var annotation grogAnnotation
if len(annotationContent) > 0 {
if err := yaml.Unmarshal([]byte(annotationContent), &annotation); err != nil {
return fmt.Errorf("failed to parse annotation block L%d-%d: %w", firstLineNum, lastLineNum, err)
}
}
// Process the target definition.
trimmedTarget := strings.TrimSpace(targetLine)
if !strings.Contains(trimmedTarget, ":") {
return fmt.Errorf("expected a make target definition in L%d ending with ':', got: %s", targetLineNumber, trimmedTarget)
}
// Extract the target name (remove the trailing colon).
targetName, _, _ := strings.Cut(trimmedTarget, ":")
// Create the TargetDTO.
target := &TargetDTO{
Name: targetName,
Command: "make " + targetName,
Dependencies: annotation.Dependencies,
Inputs: annotation.Inputs,
Outputs: annotation.Outputs,
OciPush: annotation.OciPush,
Tags: annotation.Tags,
}
// Use the annotation's name as key if provided, otherwise use the target name.
if annotation.Name != "" {
target.Name = annotation.Name
}
p.pkg.Targets = append(p.pkg.Targets, target)
return nil
}
package loading
import (
"context"
"github.com/chrismatix/grog/internal/console"
)
// Loader Implement this to provide a loader for a user provided BUILD file format.
// Load must be safe for concurrent use by multiple goroutines.
type Loader interface {
// Matches indicates if the loader can load the specified file name
Matches(fileName string) bool
// Load reads the file at the specified filePath and unmarshals its content into a model.Package
// Returns true if the file contains a valid package definition (needed for Makefiles)
Load(ctx context.Context, filePath string) (PackageDTO, bool, error)
}
// PackageLoader facade that delegates to the correct loader based on the pattern.
type PackageLoader struct {
loaders []Loader
fileNames []string
logger *console.Logger
}
func NewPackageLoader(logger *console.Logger) *PackageLoader {
return &PackageLoader{
logger: logger,
// register loaders here
loaders: []Loader{
JsonLoader{},
YamlLoader{},
MakefileLoader{},
&PklLoader{},
StarlarkLoader{},
ScriptLoader{},
},
}
}
// LoadIfMatched loads the package from the specified file name if it matches any of the supported file names.
func (p *PackageLoader) LoadIfMatched(ctx context.Context, filePath string, fileName string) (PackageDTO, bool, error) {
for _, loader := range p.loaders {
if loader.Matches(fileName) {
p.logger.Debugf("Loading package from %s using loader %s", filePath, loader)
packageDTO, matched, err := loader.Load(ctx, filePath)
packageDTO.SourceFilePath = filePath
return packageDTO, matched, err
}
}
return PackageDTO{}, false, nil
}
package loading
import (
"context"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"sync"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/apple/pkl-go/pkl"
)
// PklLoader implements the Loader interface for pkl files.
type PklLoader struct {
evaluator pkl.Evaluator
evaluatorErr error
evaluatorOnce sync.Once
}
func (pl *PklLoader) Matches(fileName string) bool {
return fileName == "BUILD.pkl"
}
// getEvaluator lazily loads and caches the evaluator.
func (pl *PklLoader) getEvaluator(ctx context.Context) (pkl.Evaluator, error) {
pl.evaluatorOnce.Do(func() {
if hasPklProjectFile() {
pl.evaluator, pl.evaluatorErr = pkl.NewProjectEvaluator(ctx,
&url.URL{Scheme: "file", Path: config.Global.WorkspaceRoot},
pkl.PreconfiguredOptions,
withEnv(LoaderEnv()),
withEnv(config.Global.EnvironmentVariables),
)
} else {
pl.evaluator, pl.evaluatorErr = pkl.NewEvaluator(ctx,
pkl.PreconfiguredOptions,
withEnv(LoaderEnv()),
withEnv(config.Global.EnvironmentVariables),
)
}
})
return pl.evaluator, pl.evaluatorErr
}
func hasPklProjectFile() bool {
_, err := os.Stat(filepath.Join(config.Global.WorkspaceRoot, "PklProject"))
return !errors.Is(err, os.ErrNotExist)
}
// withEnv adds or overrides environment variables for the `env:` resource reader.
// Any key in envVars will be set into EvaluatorOptions.Env.
func withEnv(envVars map[string]string) func(*pkl.EvaluatorOptions) {
return func(opts *pkl.EvaluatorOptions) {
if opts.Env == nil {
opts.Env = make(map[string]string, len(envVars))
}
maps.Copy(opts.Env, envVars)
}
}
// Load reads the file at the specified filePath and unmarshals its content into a model.Package.
func (pl *PklLoader) Load(ctx context.Context, filePath string) (PackageDTO, bool, error) {
var pkg PackageDTO
evaluator, err := pl.getEvaluator(ctx)
if err != nil {
console.GetLogger(ctx).Debugf("failed to get evaluator: %v", err)
return pkg, false, fmt.Errorf("found a BUILD.pkl file but the `pkl` cli is not available. " +
"Please install it to use pkl files: https://pkl-lang.org/main/current/pkl-cli/index.html#installation")
}
var evalErr error
// pkl evaluator can panic so we need to be able to recover
func() {
defer func() {
if r := recover(); r != nil {
evalErr = fmt.Errorf("panic occurred while evaluating module: %v", r)
}
}()
evalErr = evaluator.EvaluateModule(ctx, pkl.FileSource(filePath), &pkg)
}()
if evalErr != nil {
return pkg, false, evalErr
}
return pkg, true, nil
}
package loading
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/model"
"gopkg.in/yaml.v3"
)
type ScriptLoader struct{}
func (ScriptLoader) Matches(fileName string) bool {
return strings.HasSuffix(fileName, ".grog.sh") || strings.HasSuffix(fileName, ".grog.py")
}
func (ScriptLoader) Load(_ context.Context, filePath string) (PackageDTO, bool, error) {
file, err := os.Open(filePath)
if err != nil {
return PackageDTO{}, false, err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
parser := newScriptParser(scanner, filePath)
return parser.parse()
}
type scriptParser struct {
scanner *bufio.Scanner
file string
}
func newScriptParser(scanner *bufio.Scanner, file string) *scriptParser {
return &scriptParser{scanner: scanner, file: file}
}
func (p *scriptParser) parse() (PackageDTO, bool, error) {
lineCount := 0
var annotation scriptAnnotation
for p.scanner.Scan() {
lineCount++
line := p.scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# @grog") {
// Collect the subsequent comment lines.
var annotationLines []string
var annotationLineNumbers []int
for p.scanner.Scan() {
lineCount++
nextLine := p.scanner.Text()
trimmedNext := strings.TrimSpace(nextLine)
if trimmedNext == "" {
continue
}
if strings.HasPrefix(trimmedNext, "#") {
// Remove '#'
content := trimmedNext[1:]
annotationLines = append(annotationLines, content)
annotationLineNumbers = append(annotationLineNumbers, lineCount)
} else {
if len(annotationLines) == 0 {
// This means that there is just an empty # @grog start block with no content
// This is fine
break
}
// End of annotation: this should be the target definition.
foundAnnotation, err := p.handleTarget(annotationLines, annotationLineNumbers)
if err != nil {
return PackageDTO{}, true, err
}
annotation = foundAnnotation
// Break out of the loop.
break
}
}
}
}
if err := p.scanner.Err(); err != nil {
return PackageDTO{}, false, fmt.Errorf("failed to scan %s: %w", p.file, err)
}
scriptFileName := filepath.Base(p.file)
targetName := annotation.Name
if targetName == "" {
// Default to the script file name as the target name
targetName = scriptFileName
}
target := &TargetDTO{
Name: targetName,
Dependencies: annotation.Dependencies,
// Prepend the script file name to the inputs to ensure
// that changing it always invalidates it as a target
Inputs: prependUnique(annotation.Inputs, scriptFileName),
// Script files should never be cached
Tags: prependUnique(annotation.Tags, "no-cache"),
Fingerprint: annotation.Fingerprint,
EnvironmentVariables: annotation.EnvironmentVariables,
Timeout: annotation.Timeout,
Platforms: annotation.Platforms,
}
// Script files are always their own bin output
target.BinOutput = scriptFileName
pkg := PackageDTO{
SourceFilePath: p.file,
Targets: []*TargetDTO{target},
}
return pkg, true, nil
}
// handleTarget parses the collected annotation lines and the subsequent target definition.
func (p *scriptParser) handleTarget(
annotationLines []string,
annotationLineNumbers []int,
) (scriptAnnotation, error) {
// Combine annotation lines into a YAML snippet.
annotationContent := strings.Join(annotationLines, "\n")
firstLineNum, lastLineNum := annotationLineRange(annotationLineNumbers)
var annotation scriptAnnotation
if len(annotationContent) > 0 {
if err := yaml.Unmarshal([]byte(annotationContent), &annotation); err != nil {
return scriptAnnotation{}, fmt.Errorf("failed to parse annotation block L%d-%d: %w", firstLineNum, lastLineNum, err)
}
}
return annotation, nil
}
func prependUnique(values []string, element string) []string {
if slices.Contains(values, element) {
return append([]string{}, values...)
}
return append([]string{element}, values...)
}
func LoadScriptTarget(ctx context.Context, logger *console.Logger, filePath string) (*model.Target, error) {
packagePath, err := config.GetPackagePath(filePath)
if err != nil {
return nil, err
}
loader := ScriptLoader{}
pkgDto, matched, err := loader.Load(ctx, filePath)
if err != nil {
return nil, err
}
if !matched {
relativePath, relErr := config.GetPathRelativeToWorkspaceRoot(filePath)
if relErr == nil {
filePath = relativePath
}
return nil, fmt.Errorf("%s does not contain a # @grog annotation", filePath)
}
pkg, err := getEnrichedPackage(ctx, logger, packagePath, pkgDto)
if err != nil {
return nil, err
}
for _, target := range pkg.Targets {
return target, nil
}
return nil, fmt.Errorf("no target could be derived from %s", filePath)
}
package loading
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/model"
"go.starlark.net/lib/json"
"go.starlark.net/lib/math"
"go.starlark.net/lib/time"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"go.starlark.net/syntax"
)
// StarlarkLoader implements the Loader interface for Starlark files.
type StarlarkLoader struct{}
func (sl StarlarkLoader) Matches(fileName string) bool {
return fileName == "BUILD.star" || fileName == "BUILD.bzl"
}
// starlarkPackageCollector holds the collected targets, aliases, resources, and environments.
type starlarkPackageCollector struct {
dependencyResolvers []*DependencyResolverDTO
targets []*TargetDTO
aliases []*AliasDTO
resources []*ResourceDTO
environments []*EnvironmentDTO
defaultPlatforms []string
}
// moduleLoadContext tracks loaded modules and in-progress loads for cycle detection.
type moduleLoadContext struct {
// cache stores already-loaded modules
cache map[string]starlark.StringDict
// loading tracks modules currently being loaded to detect cycles
loading map[string]bool
}
// Load reads the file at the specified filePath and evaluates it as Starlark code.
func (sl StarlarkLoader) Load(ctx context.Context, filePath string) (PackageDTO, bool, error) {
collector := &starlarkPackageCollector{
targets: make([]*TargetDTO, 0),
aliases: make([]*AliasDTO, 0),
resources: make([]*ResourceDTO, 0),
environments: make([]*EnvironmentDTO, 0),
}
// Create module load context for caching and cycle detection
loadContext := &moduleLoadContext{
cache: make(map[string]starlark.StringDict),
loading: make(map[string]bool),
}
// Create predeclared functions and values
predeclared := starlark.StringDict{
"target": starlark.NewBuiltin("target", collector.targetBuiltin),
"alias": starlark.NewBuiltin("alias", collector.aliasBuiltin),
"resource": starlark.NewBuiltin("resource", collector.resourceBuiltin),
"dependency_resolver": starlark.NewBuiltin("dependency_resolver", collector.dependencyResolverBuiltin),
"environment": starlark.NewBuiltin("environment", collector.environmentBuiltin),
"json": json.Module,
"math": math.Module,
"time": time.Module,
}
addLoaderEnvToStarlark(predeclared)
for key, value := range config.Global.EnvironmentVariables {
predeclared[key] = starlark.String(value)
}
thread := &starlark.Thread{
Name: filePath,
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
return sl.loadModule(thread, module, filePath, collector, loadContext)
},
}
// Execute the Starlark file
_, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, filePath, nil, predeclared)
if err != nil {
return PackageDTO{}, false, fmt.Errorf("failed to evaluate Starlark file %s: %w", filePath, err)
}
packageDTO := PackageDTO{
Targets: collector.targets,
Aliases: collector.aliases,
Resources: collector.resources,
DependencyResolvers: collector.dependencyResolvers,
Environments: collector.environments,
DefaultPlatforms: collector.defaultPlatforms,
}
return packageDTO, true, nil
}
// loadModule implements the load() function for importing other Starlark files
// with caching and cycle detection.
func (sl StarlarkLoader) loadModule(thread *starlark.Thread, module string, currentFile string, collector *starlarkPackageCollector, loadContext *moduleLoadContext) (starlark.StringDict, error) {
// Resolve module path relative to workspace root or current file
var modulePath string
if len(module) > 2 && module[:2] == "//" {
// Absolute path from workspace root
relativePath := module[2:]
modulePath = filepath.Join(config.Global.WorkspaceRoot, relativePath)
} else {
// Relative path from current file
currentDirectory := filepath.Dir(currentFile)
modulePath = filepath.Join(currentDirectory, module)
}
// Clean the path to normalize it for cache lookups
modulePath = filepath.Clean(modulePath)
// Check cache first - if already loaded, return cached result
if cached, ok := loadContext.cache[modulePath]; ok {
return cached, nil
}
// Check if currently being loaded - this indicates a cycle
if loadContext.loading[modulePath] {
return nil, fmt.Errorf("cycle detected: module %s is already being loaded", module)
}
// Check if file exists
if _, err := os.Stat(modulePath); os.IsNotExist(err) {
return nil, fmt.Errorf("module not found: %s (resolved to %s)", module, modulePath)
}
// Mark as currently loading to detect cycles
loadContext.loading[modulePath] = true
defer func() {
// Remove from loading set when done
delete(loadContext.loading, modulePath)
}()
// Create predeclared functions for the loaded module
predeclared := starlark.StringDict{
"target": starlark.NewBuiltin("target", collector.targetBuiltin),
"alias": starlark.NewBuiltin("alias", collector.aliasBuiltin),
"resource": starlark.NewBuiltin("resource", collector.resourceBuiltin),
"dependency_resolver": starlark.NewBuiltin("dependency_resolver", collector.dependencyResolverBuiltin),
"environment": starlark.NewBuiltin("environment", collector.environmentBuiltin),
"json": json.Module,
"math": math.Module,
"time": time.Module,
}
addLoaderEnvToStarlark(predeclared)
for key, value := range config.Global.EnvironmentVariables {
predeclared[key] = starlark.String(value)
}
// Create a new thread for the module with the same load function
moduleThread := &starlark.Thread{
Name: modulePath,
Load: func(threadInner *starlark.Thread, moduleInner string) (starlark.StringDict, error) {
return sl.loadModule(threadInner, moduleInner, modulePath, collector, loadContext)
},
}
// Execute the module
globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, moduleThread, modulePath, nil, predeclared)
if err != nil {
return nil, fmt.Errorf("failed to load module %s: %w", module, err)
}
// Cache the result before returning
loadContext.cache[modulePath] = globals
return globals, nil
}
// targetBuiltin implements the target() function in Starlark.
func (c *starlarkPackageCollector) targetBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
var command string
var dependencyResolvers *starlark.List
var dependencies *starlark.List
var inputs *starlark.List
var excludeInputs *starlark.List
var outputs *starlark.List
var binOutput string
var binaryRequiresPush bool
var outputChecks *starlark.List
var tags *starlark.List
var fingerprint *starlark.Dict
var platforms *starlark.List
var envVars *starlark.Dict
var timeout string
var concurrencyGroup string
var ociPush *starlark.Dict
// Parse keyword arguments
if err := starlark.UnpackArgs("target", args, kwargs,
"name", &name,
"command?", &command,
"dependency_resolvers?", &dependencyResolvers,
"dependencies?", &dependencies,
"inputs?", &inputs,
"exclude_inputs?", &excludeInputs,
"outputs?", &outputs,
"bin_output?", &binOutput,
"binary_requires_push?", &binaryRequiresPush,
"output_checks?", &outputChecks,
"tags?", &tags,
"fingerprint?", &fingerprint,
"platforms?", &platforms,
"environment_variables?", &envVars,
"timeout?", &timeout,
"concurrency_group?", &concurrencyGroup,
"oci_push?", &ociPush,
); err != nil {
return nil, err
}
target := &TargetDTO{
Name: name,
Command: command,
BinOutput: binOutput,
BinaryRequiresPush: binaryRequiresPush,
Timeout: timeout,
ConcurrencyGroup: concurrencyGroup,
}
stringListArguments := []struct {
argumentName string
list *starlark.List
destination *[]string
}{
{"dependency_resolvers", dependencyResolvers, &target.DependencyResolvers},
{"dependencies", dependencies, &target.Dependencies},
{"inputs", inputs, &target.Inputs},
{"exclude_inputs", excludeInputs, &target.ExcludeInputs},
{"outputs", outputs, &target.Outputs},
{"tags", tags, &target.Tags},
{"platforms", platforms, &target.Platforms},
}
for _, argument := range stringListArguments {
if argument.list == nil {
continue
}
values, err := starlarkListToStringSlice(argument.list)
if err != nil {
return nil, fmt.Errorf("%s: %w", argument.argumentName, err)
}
*argument.destination = values
}
if outputChecks != nil {
checks, err := starlarkListToOutputChecks(outputChecks)
if err != nil {
return nil, fmt.Errorf("output_checks: %w", err)
}
target.OutputChecks = checks
}
if fingerprint != nil {
fp, err := starlarkDictToStringMap(fingerprint)
if err != nil {
return nil, fmt.Errorf("fingerprint: %w", err)
}
target.Fingerprint = fp
}
if envVars != nil {
ev, err := starlarkDictToStringMap(envVars)
if err != nil {
return nil, fmt.Errorf("environment_variables: %w", err)
}
target.EnvironmentVariables = ev
}
if ociPush != nil {
push, err := starlarkDictToOciPush(ociPush)
if err != nil {
return nil, fmt.Errorf("oci_push: %w", err)
}
target.OciPush = push
}
c.targets = append(c.targets, target)
return starlark.None, nil
}
// aliasBuiltin implements the alias() function in Starlark.
func (c *starlarkPackageCollector) aliasBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
var actual string
if err := starlark.UnpackArgs("alias", args, kwargs,
"name", &name,
"actual", &actual,
); err != nil {
return nil, err
}
alias := &AliasDTO{
Name: name,
Actual: actual,
}
c.aliases = append(c.aliases, alias)
return starlark.None, nil
}
// resourceBuiltin implements the resource() function in Starlark.
func (c *starlarkPackageCollector) resourceBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
var up string
var down string
var ready string
var timeout string
var exports *starlark.Dict
var dependencies *starlark.List
if err := starlark.UnpackArgs("resource", args, kwargs,
"name", &name,
"up", &up,
"down?", &down,
"ready?", &ready,
"timeout?", &timeout,
"exports?", &exports,
"dependencies?", &dependencies,
); err != nil {
return nil, err
}
resource := &ResourceDTO{
Name: name,
Up: up,
Down: down,
Ready: ready,
Timeout: timeout,
}
if exports != nil {
exportMap, err := starlarkDictToStringMap(exports)
if err != nil {
return nil, fmt.Errorf("exports: %w", err)
}
resource.Exports = exportMap
}
if dependencies != nil {
deps, err := starlarkListToStringSlice(dependencies)
if err != nil {
return nil, fmt.Errorf("dependencies: %w", err)
}
resource.Dependencies = deps
}
c.resources = append(c.resources, resource)
return starlark.None, nil
}
// environmentBuiltin implements the environment() function in Starlark.
func (c *starlarkPackageCollector) environmentBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
var envType string
var dependencies *starlark.List
var ociImage string
if err := starlark.UnpackArgs("environment", args, kwargs,
"name", &name,
"type", &envType,
"dependencies?", &dependencies,
"oci_image?", &ociImage,
); err != nil {
return nil, err
}
env := &EnvironmentDTO{
Name: name,
Type: envType,
OCIImage: ociImage,
}
// Convert dependencies
if dependencies != nil {
deps, err := starlarkListToStringSlice(dependencies)
if err != nil {
return nil, fmt.Errorf("dependencies: %w", err)
}
env.Dependencies = deps
}
c.environments = append(c.environments, env)
return starlark.None, nil
}
// Helper functions to convert Starlark types to Go types
func platformTagsStarlarkList() *starlark.List {
values := make([]starlark.Value, 0, len(config.Global.PlatformTags))
for _, tag := range config.Global.PlatformTags {
values = append(values, starlark.String(tag))
}
list := starlark.NewList(values)
list.Freeze()
return list
}
func starlarkListToStringSlice(list *starlark.List) ([]string, error) {
result := make([]string, 0, list.Len())
iter := list.Iterate()
defer iter.Done()
var val starlark.Value
for iter.Next(&val) {
str, ok := val.(starlark.String)
if !ok {
return nil, fmt.Errorf("expected string, got %s", val.Type()) //nolint:nilaway // Iterate assigns val before Next reports true.
}
result = append(result, string(str))
}
return result, nil
}
// starlarkDictToOciPush converts an oci_push dict where each value is either
// a single destination string or a list of strings. The DTO normalises
// scalars to a single-element list so downstream code only deals with []string.
func starlarkDictToOciPush(dict *starlark.Dict) (map[string]ociPushDestinations, error) {
result := make(map[string]ociPushDestinations, dict.Len())
for _, item := range dict.Items() {
key, ok := item[0].(starlark.String)
if !ok {
return nil, fmt.Errorf("oci_push key must be string, got %s", item[0].Type())
}
switch v := item[1].(type) {
case starlark.String:
result[string(key)] = ociPushDestinations{string(v)}
case *starlark.List:
dst, err := starlarkListToStringSlice(v)
if err != nil {
return nil, fmt.Errorf("oci_push[%q]: %w", string(key), err)
}
result[string(key)] = dst
default:
return nil, fmt.Errorf("oci_push[%q] must be string or list of strings, got %s", string(key), v.Type())
}
}
return result, nil
}
func starlarkDictToStringMap(dict *starlark.Dict) (map[string]string, error) {
result := make(map[string]string)
for _, item := range dict.Items() {
key, ok := item[0].(starlark.String)
if !ok {
return nil, fmt.Errorf("dict key must be string, got %s", item[0].Type())
}
val, ok := item[1].(starlark.String)
if !ok {
return nil, fmt.Errorf("dict value must be string, got %s", item[1].Type())
}
result[string(key)] = string(val)
}
return result, nil
}
func starlarkListToOutputChecks(list *starlark.List) ([]model.OutputCheck, error) {
result := make([]model.OutputCheck, 0, list.Len())
iter := list.Iterate()
defer iter.Done()
var val starlark.Value
for iter.Next(&val) {
// Output checks can be dicts or structs
var command, expectedOutput string
if dict, ok := val.(*starlark.Dict); ok {
// Handle dict format
cmdVal, found, err := dict.Get(starlark.String("command"))
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("output_check missing 'command' field")
}
cmdStr, ok := cmdVal.(starlark.String)
if !ok {
return nil, fmt.Errorf("output_check 'command' must be string")
}
command = string(cmdStr)
expVal, found, _ := dict.Get(starlark.String("expected_output"))
if found {
if expStr, ok := expVal.(starlark.String); ok {
expectedOutput = string(expStr)
}
}
} else if structVal, ok := val.(*starlarkstruct.Struct); ok {
// Handle struct format
cmdVal, err := structVal.Attr("command")
if err != nil {
return nil, fmt.Errorf("output_check missing 'command' field")
}
cmdStr, ok := cmdVal.(starlark.String)
if !ok {
return nil, fmt.Errorf("output_check 'command' must be string")
}
command = string(cmdStr)
expVal, err := structVal.Attr("expected_output")
if err == nil {
if expStr, ok := expVal.(starlark.String); ok {
expectedOutput = string(expStr)
}
}
} else {
return nil, fmt.Errorf("output_check must be dict or struct, got %s", val.Type()) //nolint:nilaway // Iterate assigns val before Next reports true.
}
result = append(result, model.OutputCheck{
Command: command,
ExpectedOutput: expectedOutput,
})
}
return result, nil
}
// resolvedEnvironmentVariablesFilePath returns the absolute path to the
// configured environment variables file. If EnvironmentVariablesFile is empty,
// it returns an empty string. Relative paths are resolved against WorkspaceRoot.
func resolvedEnvironmentVariablesFilePath() string {
environmentVariablesFilePath := config.Global.EnvironmentVariablesFile
if environmentVariablesFilePath == "" {
return ""
}
if !filepath.IsAbs(environmentVariablesFilePath) {
environmentVariablesFilePath = filepath.Join(config.Global.WorkspaceRoot, environmentVariablesFilePath)
}
return filepath.Clean(environmentVariablesFilePath)
}
func (collector *starlarkPackageCollector) dependencyResolverBuiltin(thread *starlark.Thread, function *starlark.Builtin, arguments starlark.Tuple, keywordArguments []starlark.Tuple) (starlark.Value, error) {
resolver := &DependencyResolverDTO{}
var inputs *starlark.List
var excludeInputs *starlark.List
if parseError := starlark.UnpackArgs("dependency_resolver", arguments, keywordArguments,
"name", &resolver.Name, "command", &resolver.Command, "inputs?", &inputs, "exclude_inputs?", &excludeInputs,
"timeout?", &resolver.Timeout, "generated_target_name?", &resolver.GeneratedTargetName,
); parseError != nil {
return nil, parseError
}
if inputs != nil {
resolvedInputs, parseError := starlarkListToStringSlice(inputs)
if parseError != nil {
return nil, fmt.Errorf("inputs: %w", parseError)
}
resolver.Inputs = resolvedInputs
}
if excludeInputs != nil {
resolvedExcludeInputs, parseError := starlarkListToStringSlice(excludeInputs)
if parseError != nil {
return nil, fmt.Errorf("exclude_inputs: %w", parseError)
}
resolver.ExcludeInputs = resolvedExcludeInputs
}
collector.dependencyResolvers = append(collector.dependencyResolvers, resolver)
return starlark.None, nil
}
package loading
import (
"context"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// YamlLoader implements the Loader interface for JSON files.
type YamlLoader struct{}
func (j YamlLoader) Matches(fileName string) bool {
return fileName == "BUILD.yaml" || fileName == "BUILD.yml"
}
// Load reads the file at the specified filePath and unmarshals its content into a model.Package.
func (j YamlLoader) Load(_ context.Context, filePath string) (PackageDTO, bool, error) {
var pkg PackageDTO
// Open the file.
file, err := os.Open(filePath)
if err != nil {
return pkg, false, err
}
defer func() { _ = file.Close() }()
// Decode JSON content.
decoder := yaml.NewDecoder(file)
err = decoder.Decode(&pkg)
if err != nil {
return pkg, true, fmt.Errorf(
"failed to decode JSON file %s: %w",
filePath,
err)
}
return pkg, true, nil
}
package locking
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/fatih/color"
)
const lockFileFieldSeparator = "\t"
// WorkspaceLocker ensures that only one grog build is running per host by
// managing a lock file in the workspace root directory.
type WorkspaceLocker struct {
lockFilePath string
printOnce sync.Once
}
// NewWorkspaceLocker creates a locker using the global configuration.
func NewWorkspaceLocker() *WorkspaceLocker {
lockFilePath := filepath.Join(config.Global.GetWorkspaceRootDir(), "lockfile")
return &WorkspaceLocker{lockFilePath: lockFilePath}
}
// Lock blocks until the workspace lock can be acquired.
func (wl *WorkspaceLocker) Lock(ctx context.Context) error {
logger := console.GetLogger(ctx)
lockData := []byte(buildLockFileContents(os.Getpid(), os.Args))
waitPrinted := false
if err := os.MkdirAll(filepath.Dir(wl.lockFilePath), 0755); err != nil {
return fmt.Errorf("could not create workspace lock directory: %w", err)
}
for {
logger.Debugf("Attempting to acquire workspace lock at %s", wl.lockFilePath)
file, err := os.OpenFile(wl.lockFilePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644)
if err == nil {
_, writeErr := file.Write(lockData)
writeErr = errors.Join(writeErr, file.Close())
if writeErr != nil {
_ = os.Remove(wl.lockFilePath)
return writeErr
}
return nil
}
if !errors.Is(err, os.ErrExist) {
return err
}
// Read the lock file which contains the PID of the other process
data, readError := os.ReadFile(wl.lockFilePath)
if readError != nil {
_ = os.Remove(wl.lockFilePath)
continue
}
otherPid, otherCommand, parseError := parseLockFileContents(string(data))
if parseError != nil {
_ = os.Remove(wl.lockFilePath)
continue
}
if !processRunning(otherPid) {
_ = os.Remove(wl.lockFilePath)
continue
}
if !waitPrinted {
green := color.New(color.FgGreen).SprintFunc()
if otherCommand == "" {
fmt.Printf("%s: Another grog build (PID %d) is running. Waiting..", green("INFO"), otherPid)
} else {
fmt.Printf(
"%s: Another grog build (PID %d, command: %s) is running. Waiting..",
green("INFO"),
otherPid,
otherCommand,
)
}
waitPrinted = true
// Ensure that we add a newline when we printed anything
defer fmt.Println()
}
fmt.Print(".")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}
// Unlock releases the workspace lock.
func (wl *WorkspaceLocker) Unlock() error {
return os.Remove(wl.lockFilePath)
}
// ActiveLock describes a workspace lock that is currently held by a live
// process. It is returned by FindActiveLocks so callers (such as the clean
// command) can report which builds would be disrupted by a destructive
// operation on the shared cache.
type ActiveLock struct {
// ProcessID is the PID recorded in the lock file.
ProcessID int
// Command is the command line recorded in the lock file, if any. It may
// be empty for lock files written in the legacy PID-only format.
Command string
// LockFilePath is the absolute path of the lock file on disk.
LockFilePath string
}
// FindActiveLocks scans the grog root directory for workspace lock files held
// by live processes. Each checkout stores its lock file at
// "$GROG_ROOT/<prefix>/lockfile" (see config.WorkspaceConfig.GetWorkspaceRootDir),
// so this walks one level below grogRoot and inspects every "lockfile" it
// finds. Lock files whose recorded process is no longer running are treated as
// stale and skipped, mirroring the stale-lock handling in Lock. The returned
// slice is sorted by lock file path for deterministic output.
func FindActiveLocks(grogRoot string) ([]ActiveLock, error) {
entries, err := os.ReadDir(grogRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("could not read grog root %q: %w", grogRoot, err)
}
var activeLocks []ActiveLock
for _, entry := range entries {
if !entry.IsDir() {
continue
}
lockFilePath := filepath.Join(grogRoot, entry.Name(), "lockfile")
data, readError := os.ReadFile(lockFilePath)
if readError != nil {
// A missing lock file is the common case for non-workspace
// directories (cas, cache, traces, ...); ignore it. Other read
// errors are skipped too so a single unreadable file does not
// abort the whole scan.
continue
}
processID, command, parseError := parseLockFileContents(string(data))
if parseError != nil {
continue
}
if !processRunning(processID) {
continue
}
activeLocks = append(activeLocks, ActiveLock{
ProcessID: processID,
Command: command,
LockFilePath: lockFilePath,
})
}
slices.SortFunc(activeLocks, func(a, b ActiveLock) int {
return strings.Compare(a.LockFilePath, b.LockFilePath)
})
return activeLocks, nil
}
func processRunning(pid int) bool {
if pid <= 0 {
return false
}
p, err := os.FindProcess(pid)
if err != nil {
return false
}
err = p.Signal(syscall.Signal(0))
return err == nil || errors.Is(err, syscall.EPERM)
}
func buildLockFileContents(processID int, commandArguments []string) string {
command := strings.TrimSpace(strings.Join(commandArguments, " "))
if command == "" {
return strconv.Itoa(processID)
}
return fmt.Sprintf("%d%s%s", processID, lockFileFieldSeparator, command)
}
func parseLockFileContents(lockFileContents string) (int, string, error) {
trimmedContents := strings.TrimSpace(lockFileContents)
if trimmedContents == "" {
return 0, "", errors.New("lockfile is empty")
}
processIDField, commandField, hasCommand := strings.Cut(trimmedContents, lockFileFieldSeparator)
processID, conversionError := strconv.Atoi(processIDField)
if conversionError != nil {
return 0, "", conversionError
}
if !hasCommand {
return processID, "", nil
}
return processID, strings.TrimSpace(commandField), nil
}
package logs
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/model"
)
type TargetLogFile struct {
workspaceDirectory string
target model.Target
}
func NewTargetLogFile(target model.Target) *TargetLogFile {
return &TargetLogFile{
workspaceDirectory: filepath.Join(config.Global.GetWorkspaceRootDir(), "logs"),
target: target,
}
}
// Path returns the path of the latest/current log file for a given target
// -> {targetPackagePath}/{targetName}.txt.
func (tl *TargetLogFile) Path() string {
targetPath := fmt.Sprintf(
"%s/%s.txt",
tl.target.Label.Package,
tl.target.Label.Name)
return filepath.Join(tl.workspaceDirectory, targetPath)
}
func (tl *TargetLogFile) Open() (*os.File, error) {
logFilePath := tl.Path()
logFileDir := filepath.Dir(logFilePath)
// Ensure the directory exists
if err := os.MkdirAll(logFileDir, 0755); err != nil {
return nil, err
}
return os.OpenFile(tl.Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
}
func (tl *TargetLogFile) Exists() bool {
_, err := os.Stat(tl.Path())
return err == nil
}
// Print prints out the entire file.
func (tl *TargetLogFile) Print() error {
file, err := os.Open(tl.Path())
if err != nil {
return err
}
defer func() { _ = file.Close() }()
_, err = io.Copy(os.Stdout, file)
return err
}
package maps
import (
"fmt"
"sync"
"sync/atomic"
)
// MutexMap provides a locking mechanism based on the passed in reference name.
type MutexMap struct {
mutex sync.Mutex
locks map[string]*wrappedMutex
}
// NewMutexMap creates a new MutexMap.
func NewMutexMap() *MutexMap {
return &MutexMap{
locks: make(map[string]*wrappedMutex),
}
}
// Lock locks a mutex with the given name. If it doesn't exist, one is created.
func (l *MutexMap) Lock(name string) {
l.mutex.Lock()
nameLock, exists := l.locks[name]
if !exists {
nameLock = &wrappedMutex{}
l.locks[name] = nameLock
}
// increment the nameLock waiters while inside the main mutex
// this makes sure that the wrappedMutex isn't deleted if `Lock` and `Unlock` are called concurrently
nameLock.inc()
l.mutex.Unlock()
// Lock the nameLock outside the main mutex so we don't block other operations
// once locked then we can decrement the number of waiters for this wrappedMutex
nameLock.Lock()
nameLock.dec()
}
// Unlock unlocks the mutex with the given name
// If the given wrappedMutex is not being waited on by any other callers, it is deleted.
func (l *MutexMap) Unlock(name string) error {
l.mutex.Lock()
nameLock, exists := l.locks[name]
if !exists {
l.mutex.Unlock()
return fmt.Errorf("no mutex found for name %s", name)
}
if nameLock.count() == 0 {
delete(l.locks, name)
}
nameLock.Unlock()
l.mutex.Unlock()
return nil
}
// wrappedMutex adds a count of waiters to the wrappedMutex
// so that the map knows when to clean up a wrapped mutex.
type wrappedMutex struct {
mu sync.Mutex
// waiters is the number of waiters waiting to acquire the wrappedMutex
// this is int32 instead of uint32 so we can add `-1` in `dec()`
waiters atomic.Int32
}
func (l *wrappedMutex) inc() {
l.waiters.Add(1)
}
func (l *wrappedMutex) dec() {
l.waiters.Add(-1)
}
func (l *wrappedMutex) count() int32 {
return l.waiters.Load()
}
func (l *wrappedMutex) Lock() {
l.mu.Lock()
}
func (l *wrappedMutex) Unlock() {
l.mu.Unlock()
}
package model
import "github.com/chrismatix/grog/internal/label"
var _ BuildNode = &Alias{}
// Alias is a simple build node that points to another target.
type Alias struct {
// The file in which this alias was defined
SourceFilePath string `json:"-"`
Label label.TargetLabel `json:"label"`
Actual label.TargetLabel `json:"actual"`
IsSelected bool `json:"is_selected,omitempty"`
}
func (a *Alias) GetType() NodeType { return AliasNode }
func (a *Alias) GetLabel() label.TargetLabel { return a.Label }
func (a *Alias) GetDependencies() []label.TargetLabel { return []label.TargetLabel{a.Actual} }
func (a *Alias) Select() { a.IsSelected = true }
func (a *Alias) GetIsSelected() bool { return a.IsSelected }
package model
import "github.com/chrismatix/grog/internal/label"
// NodeType represents the type of a build node.
type NodeType string
const (
TargetNode NodeType = "target"
AliasNode NodeType = "alias"
ResourceNode NodeType = "resource"
)
// BuildNode represents a node in the build graph. It is implemented by
// regular build targets and environment targets.
type BuildNode interface {
GetLabel() label.TargetLabel
GetDependencies() []label.TargetLabel
Select()
GetIsSelected() bool
GetType() NodeType
}
func IsTestTargetNode(node BuildNode) bool {
if target, ok := node.(*Target); ok {
return target.IsTest()
}
return false
}
package model
import (
"fmt"
"maps"
"slices"
"sort"
"github.com/chrismatix/grog/internal/label"
)
type BuildNodeMap map[label.TargetLabel]BuildNode
func BuildNodeMapFromPackages(packages []*Package) (BuildNodeMap, error) {
nodes := make(BuildNodeMap)
for _, pkg := range packages {
for _, t := range pkg.GetTargets() {
if _, ok := nodes[t.Label]; ok {
return nil, fmt.Errorf("duplicate target label: %s", t.Label)
}
nodes[t.Label] = t
}
for _, a := range pkg.GetAliases() {
if _, ok := nodes[a.Label]; ok {
return nil, fmt.Errorf("duplicate target label: %s", a.Label)
}
nodes[a.Label] = a
}
for _, r := range pkg.GetResources() {
if _, ok := nodes[r.Label]; ok {
return nil, fmt.Errorf("duplicate target label: %s", r.Label)
}
nodes[r.Label] = r
}
}
return nodes, nil
}
// BuildNodeMapFromNodes constructs a BuildNodeMap from the provided targets.
func BuildNodeMapFromNodes(nodes ...BuildNode) BuildNodeMap {
nodeMap := make(BuildNodeMap)
for _, node := range nodes {
nodeMap[node.GetLabel()] = node
}
return nodeMap
}
func (m BuildNodeMap) GetTargets() []*Target {
var targets []*Target
for _, node := range m {
if target, ok := node.(*Target); ok {
targets = append(targets, target)
}
}
return targets
}
func (m BuildNodeMap) NodesAlphabetically() []BuildNode {
nodes := slices.Collect(maps.Values(m))
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].GetLabel().String() < nodes[j].GetLabel().String()
})
return nodes
}
// SelectedNodesAlphabetically returns the selected nodes in alphabetical order.
func (m BuildNodeMap) SelectedNodesAlphabetically() []BuildNode {
var nodes []BuildNode
for _, node := range m {
if node.GetIsSelected() {
nodes = append(nodes, node)
}
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].GetLabel().String() < nodes[j].GetLabel().String()
})
return nodes
}
package model
// Output represents a parsed output reference.
type Output struct {
// Type returns the type of the reference
// e.g. oci::image-name -> oci
Type string `json:"type" yaml:"type"`
// Identifier returns the identifying part of the reference (without type)
// e.g. oci::image-name -> image-name
Identifier string `json:"identifier" yaml:"identifier"`
}
func NewOutput(typeName, identifier string) Output {
return Output{
Type: typeName,
Identifier: identifier,
}
}
func (o Output) String() string {
return o.Type + "::" + o.Identifier
}
func (o Output) IsSet() bool {
return o.Identifier != ""
}
func (o Output) IsFile() bool {
return o.Type == "file"
}
package model
import (
"maps"
"slices"
"github.com/chrismatix/grog/internal/label"
)
// Package defines all the information that a package needs to build.
type Package struct {
DependencyResolvers map[label.TargetLabel]*DependencyResolver `json:"dependency_resolvers,omitempty"`
// Record the path to this package relative to the workspace root
Path string
Targets map[label.TargetLabel]*Target `json:"targets"`
Aliases map[label.TargetLabel]*Alias `json:"aliases"`
Resources map[label.TargetLabel]*Resource `json:"resources"`
}
func (p *Package) GetTargets() []*Target {
return slices.Collect(maps.Values(p.Targets))
}
func (p *Package) GetAliases() []*Alias {
return slices.Collect(maps.Values(p.Aliases))
}
func (p *Package) GetResources() []*Resource {
return slices.Collect(maps.Values(p.Resources))
}
package model
import (
"time"
"github.com/chrismatix/grog/internal/label"
)
var _ BuildNode = &Resource{}
// Resource is a build node that manages the lifecycle of a shared external
// helper (e.g. a database container) instead of producing outputs. It is
// started at most once per invocation when the first dependent target
// executes and torn down when the build finishes. Resources are never cached
// and do not contribute to the change hash of their dependents.
type Resource struct {
// The file in which this resource was defined
SourceFilePath string `json:"-"`
Label label.TargetLabel `json:"label"`
// Up is the command that starts the resource. It must return once the
// resource is started (daemonized), not block for its lifetime.
Up string `json:"up"`
// Down is the optional command that tears the resource down.
Down string `json:"down,omitempty"`
// Ready is an optional probe command that is polled until it exits 0
// before dependents may run.
Ready string `json:"ready,omitempty"`
// Timeout bounds the start phase (up + ready polling) and, separately,
// the down command. Zero means DefaultResourceTimeout.
Timeout time.Duration `json:"timeout,omitempty"`
// Exports are environment variables published to the commands of targets
// that directly depend on this resource. The up command can add or
// override entries by appending KEY=VALUE lines to the file at
// $GROG_RESOURCE_EXPORTS_FILE.
Exports map[string]string `json:"exports,omitempty"`
Dependencies []label.TargetLabel `json:"dependencies,omitempty"`
IsSelected bool `json:"is_selected,omitempty"`
}
// DefaultResourceTimeout is used when a resource does not declare a timeout.
const DefaultResourceTimeout = 5 * time.Minute
func (r *Resource) GetType() NodeType { return ResourceNode }
func (r *Resource) GetLabel() label.TargetLabel { return r.Label }
func (r *Resource) GetDependencies() []label.TargetLabel { return r.Dependencies }
func (r *Resource) Select() { r.IsSelected = true }
func (r *Resource) GetIsSelected() bool { return r.IsSelected }
func (r *Resource) GetTimeout() time.Duration {
if r.Timeout > 0 {
return r.Timeout
}
return DefaultResourceTimeout
}
package model
import (
"encoding/json"
"path/filepath"
"slices"
"strings"
"time"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/label"
)
var _ BuildNode = &Target{}
const (
TagNoCache = "no-cache"
TagMultiplatformCache = "multiplatform-cache"
TagTestOnly = "testonly"
)
// Target defines a build step that depends on Dependencies (other targets)
// and Inputs (files) and produces Outputs.
type Target struct {
DependencyResolvers []label.TargetLabel `json:"dependency_resolvers,omitempty"`
Label label.TargetLabel `json:"label"`
// The file in which this target was defined
SourceFilePath string `json:"-"`
Command string `json:"command"`
Dependencies []label.TargetLabel `json:"dependencies,omitempty"`
Inputs []string `json:"inputs,omitempty"`
ExcludeInputs []string `json:"exclude_inputs,omitempty"`
Outputs []Output `json:"outputs,omitempty"`
OciPush map[string][]string `json:"oci_push,omitempty" yaml:"oci_push,omitempty" pkl:"oci_push"`
Platforms []string `json:"platforms,omitempty" yaml:"platforms,omitempty" pkl:"platforms"`
Tags []string `json:"tags,omitempty"`
Fingerprint map[string]string `json:"fingerprint,omitempty"`
EnvironmentVariables map[string]string `json:"environment_variables,omitempty"`
OutputChecks []OutputCheck `json:"output_checks,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
// ConcurrencyGroup is the optional name of a group this target participates
// in. Targets sharing a group compete for the group's capacity (default 1
// means fully serialized). Group capacities are configured in grog.toml.
ConcurrencyGroup string `json:"concurrency_group,omitempty"`
// UnresolvedInputs are the inputs as specified by the user (no glob resolving)
UnresolvedInputs []string `json:"-"`
// BinOutput is always a path to a binary file
BinOutput Output `json:"bin_output"`
// BinaryRequiresPush makes `grog run` of this binary target fail unless --push is set.
BinaryRequiresPush bool `json:"binary_requires_push,omitempty"`
// Whether this target is selected for execution.
IsSelected bool `json:"is_selected,omitempty"`
// Whether the outputs for this target were already loaded in the current execution
OutputsLoaded bool `json:"outputs_loaded,omitempty"`
// ChangeHash is the combined hash of the target definition and its input files
ChangeHash string `json:"change_hash,omitempty"`
// OutputHash is the hash of all target outputs
// For targets that do not produce outputs (aggregators) this is the same as the ChangeHash
// The output hash is used by descendant targets to determine whether they need to be rebuilt
OutputHash string `json:"output_hash,omitempty"`
HasCacheHit bool `json:"has_cache_hit,omitempty"`
ExecutionTime time.Duration `json:"-"`
CacheTime time.Duration `json:"-"`
// Phase-level timing for trace collection.
StartTime time.Time `json:"-"`
QueueWait time.Duration `json:"-"`
HashDuration time.Duration `json:"-"`
CacheCheckTime time.Duration `json:"-"`
OutputWriteTime time.Duration `json:"-"`
OutputLoadTime time.Duration `json:"-"`
CacheWriteTime time.Duration `json:"-"`
DepLoadTime time.Duration `json:"-"`
}
type OutputCheck struct {
Command string `json:"command" yaml:"command" pkl:"command"`
ExpectedOutput string `json:"expected_output,omitempty" yaml:"expected_output,omitempty" pkl:"expected_output"`
}
func (t *Target) AllOutputs() []Output {
if t.HasBinOutput() {
return append(t.Outputs, t.BinOutput)
}
return t.Outputs
}
func (t *Target) HasBinOutput() bool {
return t.BinOutput.IsSet()
}
func (t *Target) HasTag(tagName string) bool {
return slices.Contains(t.Tags, tagName)
}
func (t *Target) SkipsCache() bool {
return t.HasTag(TagNoCache)
}
func (t *Target) IsMultiplatformCache() bool {
return t.HasTag(TagMultiplatformCache)
}
func (t *Target) IsTestOnly() bool {
return t.HasTag(TagTestOnly)
}
func (t *Target) CommandEllipsis() string {
firstLine, _, hasMoreLines := strings.Cut(t.Command, "\n")
firstLine = strings.TrimLeft(firstLine, " ")
if len(firstLine) > 70 {
return firstLine[:67] + "..."
} else if hasMoreLines {
return firstLine + "..."
}
return firstLine
}
func (t *Target) IsTest() bool {
return t.Label.IsTest()
}
func (t *Target) FileOutputs() []string {
var filePaths []string
for _, output := range t.AllOutputs() {
if output.IsFile() {
// Identifier will be the path of the file
filePaths = append(filePaths, output.Identifier)
}
}
return filePaths
}
func (t *Target) OutputDefinitions() []string {
var definitions []string
for _, output := range t.AllOutputs() {
// String() will return the full output definition as specified by the user
definitions = append(definitions, output.String())
}
return definitions
}
// HasOutputChecksOnly checks if this is a special type of target that only had output checks
// and no in or outputs. In that case the output checks are the only thing that should determine
// if a target needs to be run.
func (t *Target) HasOutputChecksOnly() bool {
return len(t.OutputChecks) > 0 && len(t.AllOutputs()) == 0 && len(t.Inputs) == 0
}
func PrintSortedLabels(nodes []BuildNode) {
labels := make([]label.TargetLabel, len(nodes))
for i, target := range nodes {
labels[i] = target.GetLabel()
}
label.PrintSorted(labels)
}
// Alias to avoid infinite recursion in MarshalJSON.
type targetAlias Target
func (t *Target) MarshalJSON() ([]byte, error) {
// embed all fields via alias, override BinOutput to a pointer so omitempty works
wrapper := struct {
*targetAlias
BinOutput *Output `json:"bin_output,omitempty"`
}{
targetAlias: (*targetAlias)(t),
}
if t.HasBinOutput() {
// only include when set
wrapper.BinOutput = &t.BinOutput
}
return json.Marshal(wrapper)
}
// -------------------------------
// BuildNode interface implementation
func (t *Target) GetType() NodeType { return TargetNode }
func (t *Target) GetLabel() label.TargetLabel {
return t.Label
}
func (t *Target) GetDependencies() []label.TargetLabel {
return t.Dependencies
}
func (t *Target) Select() {
t.IsSelected = true
}
func (t *Target) GetIsSelected() bool {
return t.IsSelected
}
func (t *Target) GetAbsOutputPath(output Output) string {
relativePath := output.Identifier
return config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(t.Label.Package, relativePath))
}
// Package oci_push performs daemon-free registry-to-registry image copies
// via go-containerregistry.
package oci_push
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
)
// Options control a single Copy call. Plain-HTTP is opt-in per side; Copy
// never auto-detects insecurity from the URL.
type Options struct {
SourceInsecure bool
DestinationInsecure bool
// MaxAttempts caps total tries (default 3); InitialBackoff is the wait
// before the first retry, doubling per attempt (default 500ms).
MaxAttempts int
InitialBackoff time.Duration
}
// Copy ships an image from source to destination. Returns (skipped, nil) when
// the destination already holds the same manifest digest as the source.
func Copy(ctx context.Context, source, destination string, opts Options) (bool, error) {
if opts.MaxAttempts <= 0 {
opts.MaxAttempts = 3
}
if opts.InitialBackoff <= 0 {
opts.InitialBackoff = 500 * time.Millisecond
}
srcRef, err := parseRef(source, opts.SourceInsecure)
if err != nil {
return false, fmt.Errorf("parse source %q: %w", source, err)
}
dstRef, err := parseRef(destination, opts.DestinationInsecure)
if err != nil {
return false, fmt.Errorf("parse destination %q: %w", destination, err)
}
srcDesc, err := remote.Get(srcRef, remoteOptions(ctx)...)
if err != nil {
return false, fmt.Errorf("fetch source manifest %q: %w", source, err)
}
sourceManifest, err := sourceFor(srcDesc)
if err != nil {
return false, fmt.Errorf("read source %q: %w", source, err)
}
dstDesc, err := remote.Head(dstRef, remoteOptions(ctx)...)
if err == nil && dstDesc != nil && dstDesc.Digest == srcDesc.Digest {
return true, nil
}
if err := retryTransient(ctx, opts, func() error {
return remote.Push(dstRef, sourceManifest, remoteOptions(ctx)...)
}); err != nil {
return false, wrapInsecureHint(destination, opts.DestinationInsecure, err)
}
// The write landed, so a read-back that fails or lags gets the same
// patience the write itself got before the push is called a failure.
return false, retryTransient(ctx, opts, func() error {
return verifyDestination(ctx, dstRef, srcDesc.Digest, destination)
})
}
// retryTransient runs attempt until it succeeds, fails for a reason not worth
// retrying, or runs out of attempts.
func retryTransient(ctx context.Context, opts Options, attempt func() error) error {
backoff := opts.InitialBackoff
var lastErr error
for try := 1; try <= opts.MaxAttempts; try++ {
err := attempt()
if err == nil {
return nil
}
lastErr = err
if !isTransient(err) || try == opts.MaxAttempts {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
}
return lastErr
}
// verifyDestination guards the one failure a green build log cannot reveal: a
// write the registry accepted without making it visible under the tag.
func verifyDestination(ctx context.Context, dst name.Reference, want v1.Hash, destination string) error {
desc, err := remote.Head(dst, remoteOptions(ctx)...)
if err != nil {
return fmt.Errorf("push to %q reported success but the destination could not be read back: %w", destination, err)
}
if desc.Digest != want {
return fmt.Errorf("push to %q reported success but the registry serves %s, not the pushed %s",
destination, desc.Digest, want)
}
return nil
}
func remoteOptions(ctx context.Context) []remote.Option {
return []remote.Option{
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
}
}
// sourceFor keeps a multi-platform index whole. Resolving it to a single child
// image would ship one platform and land a digest the build never produced.
func sourceFor(desc *remote.Descriptor) (remote.Taggable, error) {
if desc.MediaType.IsIndex() {
return desc.ImageIndex()
}
return desc.Image()
}
func parseRef(ref string, insecure bool) (name.Reference, error) {
var opts []name.Option
if insecure {
opts = append(opts, name.Insecure)
}
if strings.Contains(ref, "@") {
return name.NewDigest(ref, opts...)
}
return name.ParseReference(ref, opts...)
}
// isTransient reports whether err is worth retrying: network failures and 5xx
// responses retry; auth (401/403) and manifest validation (4xx) do not.
func isTransient(err error) bool {
if err == nil {
return false
}
if terr, ok := errors.AsType[*transport.Error](err); ok {
return terr.StatusCode >= 500 || terr.StatusCode == http.StatusRequestTimeout || terr.StatusCode == http.StatusTooManyRequests
}
return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
}
// wrapInsecureHint points the user at oci.insecure_registries when the
// failure looks like an HTTPS attempt against a plain-HTTP server.
func wrapInsecureHint(destination string, destinationInsecure bool, err error) error {
if destinationInsecure || !looksLikeTLSToHTTP(err) {
return err
}
return fmt.Errorf(
"push to %q failed: %w (if this is an HTTP-only registry, add its host to oci.insecure_registries in grog.toml)",
destination, err,
)
}
// looksLikeTLSToHTTP matches the canonical TLS-on-HTTP errors.
func looksLikeTLSToHTTP(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "tls: first record does not look like a TLS handshake") ||
strings.Contains(msg, "http: server gave HTTP response to HTTPS client")
}
// Package ociproxy implements a minimal OCI Distribution v2 registry
// that listens on a loopback port and proxies blob/manifest reads and writes
// to grog's content-addressable store.
//
// The point of this package is to let the local Docker daemon push and pull
// images via its normal HTTP code path while we transparently store the
// underlying blobs in the CAS (which itself is backed by S3/GCS/Azure/disk).
// Because the daemon does the streaming, gzip work, and parallelism, grog
// never has to materialise an image in its own memory — matching the
// performance of pushing to a real remote registry.
//
// Only the subset of endpoints needed by `docker push` / `docker pull` is
// implemented:
//
// GET /v2/ version probe
// HEAD /v2/<name>/blobs/<digest> blob existence check
// GET /v2/<name>/blobs/<digest> blob download (streaming)
// POST /v2/<name>/blobs/uploads/[?digest=...] start upload (monolithic if digest set)
// PATCH /v2/<name>/blobs/uploads/<uuid> chunked upload append
// PUT /v2/<name>/blobs/uploads/<uuid>?digest=... finalize chunked upload
// HEAD /v2/<name>/manifests/<reference> manifest existence check
// GET /v2/<name>/manifests/<reference> manifest download
// PUT /v2/<name>/manifests/<reference> manifest upload
//
// The "name" portion of the URL is treated as a routing prefix only — CAS
// keys are content digests, never names. Manifest GETs only support digest
// references (sha256:...) since grog always pulls by digest.
package ociproxy
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/console"
)
// Registry is an in-process OCI Distribution v2 server backed by a CAS.
type Registry struct {
cas *caching.Cas
server *http.Server
listener net.Listener
logger *console.Logger
// sessionCtx is a long-lived context that backs every chunked upload
// session. It is *not* derived from any individual HTTP request — that
// would be wrong, because cas.BeginWrite hands the context to a
// background goroutine that lives across the POST/PATCH/.../PUT
// boundary, and net/http cancels each request's context as soon as the
// handler returns. sessionCtx is cancelled exactly once, by Close, so
// any backend implementation that observes ctx cancellation (S3, GCS,
// Azure) tears its in-flight upload down on shutdown.
sessionCtx context.Context
sessionCancel context.CancelFunc
uploadsMu sync.Mutex
uploads map[string]*pendingUpload
// manifestsByName remembers the digest of the most recent manifest PUT
// per repository name. The DockerOutputHandler reads this back after
// `docker push` to learn the manifest digest the daemon produced.
manifestsMu sync.Mutex
manifestsByName map[string]string
}
// pendingUpload tracks an in-flight chunked blob upload. Incoming PATCH bytes
// are streamed straight into a CAS staged writer (no temp file double copy)
// and a running SHA256 lets us validate the client-supplied digest at PUT
// time without re-reading anything.
type pendingUpload struct {
mu sync.Mutex
writer caching.StagedWriter
digester hash.Hash
written int64
finished bool // true after Commit/Cancel; further Writes are rejected
}
// New starts an OCI Distribution v2 server on a random loopback port.
// The caller is responsible for invoking Close() to shut it down.
func New(ctx context.Context, cas *caching.Cas) (*Registry, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("ociproxy: listen: %w", err)
}
// Derive the session context from the caller's ctx so that cancelling
// the parent (e.g. SIGINT during a build) tears down any in-flight
// staged uploads. The cancel func is called explicitly from Close.
sessionCtx, sessionCancel := context.WithCancel(ctx)
registry := &Registry{
cas: cas,
listener: listener,
logger: console.GetLogger(ctx),
sessionCtx: sessionCtx,
sessionCancel: sessionCancel,
uploads: make(map[string]*pendingUpload),
manifestsByName: make(map[string]string),
}
mux := http.NewServeMux()
mux.HandleFunc("/v2/", registry.handle)
mux.HandleFunc("/v2", registry.handle)
registry.server = &http.Server{
Handler: mux,
ReadHeaderTimeout: 30 * time.Second,
}
go func() {
if err := registry.server.Serve(listener); err != nil && err != http.ErrServerClosed {
registry.logger.Warnf("ociproxy server stopped: %v", err)
}
}()
registry.logger.Debugf("ociproxy listening on %s", listener.Addr())
return registry, nil
}
// Addr returns the host:port of the loopback registry, suitable for use as
// the registry portion of a Docker image reference.
func (r *Registry) Addr() string {
return r.listener.Addr().String()
}
// Close stops the HTTP server and cancels any pending uploads.
// Safe to call multiple times.
func (r *Registry) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = r.server.Shutdown(ctx)
// Cancel the session context first. This propagates to any backend
// staged-writer goroutines that block on network I/O (S3 uploader,
// GCS resumable upload, Azure UploadStream); their failures bubble
// through pending PATCH io.Copy loops and unblock the per-upload
// mutexes so the cleanup loop below isn't held up by a slow PATCH.
if r.sessionCancel != nil {
r.sessionCancel()
}
r.uploadsMu.Lock()
for id, u := range r.uploads {
u.cancel(ctx)
delete(r.uploads, id)
}
r.uploadsMu.Unlock()
return nil
}
// digestPattern accepts the canonical OCI digest form sha256:<64-hex>.
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
// handle is the single ServeMux entry — we route by URL shape ourselves
// because the OCI <name> can itself contain slashes (e.g. "library/ubuntu").
func (r *Registry) handle(w http.ResponseWriter, req *http.Request) {
// net/http gives each request a fresh context that doesn't carry our
// logger, so any downstream CAS call that does console.GetLogger(ctx)
// would hit the "no logger found" fallback and spam warnings. Attach the
// registry's logger up front so every handler and backend call made with
// req.Context() inherits it.
req = req.WithContext(console.WithLogger(req.Context(), r.logger))
r.logger.Tracef("ociproxy: %s %s", req.Method, req.URL.RequestURI())
// Version probe — both GET and HEAD must return 200 with the API version header.
if req.URL.Path == "/v2/" || req.URL.Path == "/v2" {
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
w.WriteHeader(http.StatusOK)
return
}
// Strip /v2/ prefix and find the "blobs" or "manifests" segment;
// everything before it is the repository <name>.
rest := strings.TrimPrefix(req.URL.Path, "/v2/")
parts := strings.Split(rest, "/")
splitIdx := -1
for i, p := range parts {
if p == "blobs" || p == "manifests" {
splitIdx = i
break
}
}
if splitIdx <= 0 || splitIdx == len(parts)-1 {
http.NotFound(w, req)
return
}
name := strings.Join(parts[:splitIdx], "/")
kind := parts[splitIdx]
tail := parts[splitIdx+1:]
switch kind {
case "blobs":
r.handleBlobs(w, req, name, tail)
case "manifests":
r.handleManifests(w, req, name, tail)
default:
http.NotFound(w, req)
}
}
func (r *Registry) handleBlobs(w http.ResponseWriter, req *http.Request, name string, tail []string) {
if tail[0] == "uploads" {
var uploadID string
if len(tail) > 1 {
uploadID = tail[1]
}
switch req.Method {
case http.MethodPost:
r.startBlobUpload(w, req, name)
case http.MethodPatch:
r.patchBlobUpload(w, req, name, uploadID)
case http.MethodPut:
r.finishBlobUpload(w, req, name, uploadID)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
return
}
// /v2/<name>/blobs/<digest>
digest := tail[0]
if !digestPattern.MatchString(digest) {
writeError(w, http.StatusBadRequest, "DIGEST_INVALID", "invalid digest format")
return
}
switch req.Method {
case http.MethodHead:
r.headBlob(w, req, digest)
case http.MethodGet:
r.getBlob(w, req, digest)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (r *Registry) headBlob(w http.ResponseWriter, req *http.Request, digest string) {
exists, err := r.cas.Exists(req.Context(), digest)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
if !exists {
writeError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
return
}
// Docker daemon refuses HEAD responses without Content-Length even though
// the body is empty — it uses the header to learn the blob size up front.
size, err := r.cas.Size(req.Context(), digest)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
}
func (r *Registry) getBlob(w http.ResponseWriter, req *http.Request, digest string) {
// Look up the size first so we can set Content-Length on the response and
// avoid Go's default chunked transfer-encoding (which the daemon rejects).
size, err := r.cas.Size(req.Context(), digest)
if err != nil {
writeError(w, http.StatusNotFound, "BLOB_UNKNOWN", err.Error())
return
}
reader, err := r.cas.Load(req.Context(), digest)
if err != nil {
writeError(w, http.StatusNotFound, "BLOB_UNKNOWN", err.Error())
return
}
defer reader.Close()
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, reader); err != nil {
r.logger.Warnf("ociproxy: blob %s stream error: %v", digest, err)
}
}
// startBlobUpload handles both monolithic POST (with ?digest=) and the start
// of a chunked upload session (no digest, returns a session UUID + Location).
//
// Both code paths stream the body straight into a CAS staged writer — there is
// no temp file in either case. The chunked path opens the staged writer here
// and stores it in the session for subsequent PATCH/PUT to write into; the
// monolithic path opens, writes, verifies the digest, and commits in one go.
func (r *Registry) startBlobUpload(w http.ResponseWriter, req *http.Request, name string) {
digest := req.URL.Query().Get("digest")
if digest != "" {
// Monolithic upload — the entire blob is in this request body.
if !digestPattern.MatchString(digest) {
writeError(w, http.StatusBadRequest, "DIGEST_INVALID", "invalid digest format")
return
}
if err := r.writeBlobMonolithic(req.Context(), digest, req.Body); err != nil {
writeError(w, http.StatusInternalServerError, "BLOB_UPLOAD_INVALID", err.Error())
return
}
w.Header().Set("Location", "/v2/"+name+"/blobs/"+digest)
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
return
}
// Chunked upload — open a staged writer up front so PATCH and PUT can
// stream straight into it without buffering through a temp file. The
// session uses the registry's long-lived ctx, NOT req.Context(): the
// POST handler returns 202 immediately and net/http cancels its
// request context, but the staged writer needs to keep accepting
// bytes through subsequent PATCH/PUT requests.
id, err := r.openUpload()
if err != nil {
writeError(w, http.StatusInternalServerError, "BLOB_UPLOAD_INVALID", err.Error())
return
}
w.Header().Set("Location", "/v2/"+name+"/blobs/uploads/"+id)
w.Header().Set("Docker-Upload-UUID", id)
w.Header().Set("Range", "0-0")
w.WriteHeader(http.StatusAccepted)
}
// writeBlobMonolithic streams a single-request blob upload into the CAS and
// verifies the supplied digest at the end. Used by the rare monolithic POST
// path; chunked PATCH/PUT goes through the upload-session machinery instead.
func (r *Registry) writeBlobMonolithic(ctx context.Context, digest string, body io.Reader) error {
stagedWriter, err := r.cas.BeginWrite(ctx)
if err != nil {
return fmt.Errorf("begin staged write: %w", err)
}
digester := sha256.New()
tee := io.TeeReader(body, digester)
if _, err := io.Copy(stagedWriter, tee); err != nil {
_ = stagedWriter.Cancel(ctx)
return fmt.Errorf("stream body to staged writer: %w", err)
}
actual := "sha256:" + hex.EncodeToString(digester.Sum(nil))
if actual != digest {
_ = stagedWriter.Cancel(ctx)
return fmt.Errorf("digest mismatch: client=%s actual=%s", digest, actual)
}
if err := stagedWriter.Commit(ctx, "cas", digest); err != nil {
return fmt.Errorf("commit blob: %w", err)
}
return nil
}
func (r *Registry) patchBlobUpload(w http.ResponseWriter, req *http.Request, name, uploadId string) {
if uploadId == "" {
writeError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "missing upload id")
return
}
upload := r.getUpload(uploadId)
if upload == nil {
writeError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload session not found")
return
}
upload.mu.Lock()
if upload.finished {
upload.mu.Unlock()
writeError(w, http.StatusConflict, "BLOB_UPLOAD_INVALID", "upload session is closed")
return
}
_, copyErr := io.Copy(upload, req.Body)
written := upload.written
upload.mu.Unlock()
if copyErr != nil {
// On a partial write the session is in an unknown state — cancel
// the staged writer so the daemon starts over instead of trusting
// a stale Range header. Cancel uses the session ctx (not the
// request ctx) so cleanup survives a client disconnect mid-PATCH.
r.cancelUpload(uploadId)
writeError(w, http.StatusInternalServerError, "BLOB_UPLOAD_INVALID", copyErr.Error())
return
}
w.Header().Set("Location", "/v2/"+name+"/blobs/uploads/"+uploadId)
w.Header().Set("Docker-Upload-UUID", uploadId)
if written > 0 {
w.Header().Set("Range", "0-"+strconv.FormatInt(written-1, 10))
} else {
w.Header().Set("Range", "0-0")
}
w.WriteHeader(http.StatusAccepted)
}
func (r *Registry) finishBlobUpload(w http.ResponseWriter, req *http.Request, name, uploadId string) {
if uploadId == "" {
writeError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "missing upload id")
return
}
digest := req.URL.Query().Get("digest")
if !digestPattern.MatchString(digest) {
writeError(w, http.StatusBadRequest, "DIGEST_INVALID", "invalid or missing digest")
return
}
upload := r.getUpload(uploadId)
if upload == nil {
writeError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload session not found")
return
}
// Take ownership of the session for the rest of the request — we either
// commit or cancel before returning, and we don't want a concurrent PATCH
// (which would be a protocol violation anyway) to race with us.
upload.mu.Lock()
if upload.finished {
upload.mu.Unlock()
writeError(w, http.StatusConflict, "BLOB_UPLOAD_INVALID", "upload session is closed")
return
}
upload.finished = true
// The PUT body may carry a final trailing chunk (the spec allows it).
if req.ContentLength != 0 {
if _, err := io.Copy(upload, req.Body); err != nil {
upload.mu.Unlock()
r.cancelUpload(uploadId)
writeError(w, http.StatusInternalServerError, "BLOB_UPLOAD_INVALID", err.Error())
return
}
}
actualDigest := "sha256:" + hex.EncodeToString(upload.digester.Sum(nil))
writer := upload.writer
upload.mu.Unlock()
// Always remove the upload from the registry's session map, regardless
// of which branch we take below.
r.dropUpload(uploadId)
// Use the session ctx for Commit/Cancel — these run on a backend
// goroutine that may outlive the request, and we don't want a client
// disconnect to interrupt a final commit.
if actualDigest != digest {
_ = writer.Cancel(r.sessionCtx)
writeError(w, http.StatusBadRequest, "DIGEST_INVALID",
fmt.Sprintf("digest mismatch: client=%s actual=%s", digest, actualDigest))
return
}
if err := writer.Commit(r.sessionCtx, "cas", digest); err != nil {
writeError(w, http.StatusInternalServerError, "BLOB_UPLOAD_INVALID", err.Error())
return
}
w.Header().Set("Location", "/v2/"+name+"/blobs/"+digest)
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
}
func (r *Registry) handleManifests(w http.ResponseWriter, req *http.Request, name string, tail []string) {
reference := tail[0]
switch req.Method {
case http.MethodHead, http.MethodGet:
r.getOrHeadManifest(w, req, reference, req.Method == http.MethodHead)
case http.MethodPut:
r.putManifest(w, req, name, reference)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (r *Registry) getOrHeadManifest(w http.ResponseWriter, req *http.Request, reference string, headOnly bool) {
if !digestPattern.MatchString(reference) {
// Tags are write-only in this implementation; grog always pulls by digest.
writeError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag references are not supported, use a digest")
return
}
exists, err := r.cas.Exists(req.Context(), reference)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
if !exists {
writeError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
return
}
// Manifests are tiny (a few KB at most) so we can afford to load them
// into memory in order to sniff the media type and report Content-Length.
manifestBytes, err := r.cas.LoadBytes(req.Context(), reference)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
w.Header().Set("Docker-Content-Digest", reference)
w.Header().Set("Content-Type", sniffManifestMediaType(manifestBytes))
w.Header().Set("Content-Length", strconv.Itoa(len(manifestBytes)))
w.WriteHeader(http.StatusOK)
if !headOnly {
_, _ = w.Write(manifestBytes)
}
}
func (r *Registry) putManifest(w http.ResponseWriter, req *http.Request, name, reference string) {
body, err := io.ReadAll(req.Body) // manifests are tiny, OK to buffer
if err != nil {
writeError(w, http.StatusBadRequest, "MANIFEST_INVALID", err.Error())
return
}
sum := sha256.Sum256(body)
digest := "sha256:" + hex.EncodeToString(sum[:])
if err := r.cas.WriteBytes(req.Context(), digest, body); err != nil {
writeError(w, http.StatusInternalServerError, "MANIFEST_INVALID", err.Error())
return
}
r.manifestsMu.Lock()
r.manifestsByName[name] = digest
r.manifestsMu.Unlock()
r.logger.Debugf("ociproxy: stored manifest %s (reference %q under %q)", digest, reference, name)
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Location", "/v2/"+name+"/manifests/"+digest)
w.WriteHeader(http.StatusCreated)
}
// LastManifestDigest returns the digest of the most recent manifest PUT
// against the given repository name, or "" if none has been received.
// Used by the docker output handler to discover the manifest digest the
// daemon produced for a push, since the daemon never tells us directly.
func (r *Registry) LastManifestDigest(name string) string {
r.manifestsMu.Lock()
defer r.manifestsMu.Unlock()
return r.manifestsByName[name]
}
// upload session management ---------------------------------------------------
// openUpload starts a new chunked upload session by opening a CAS staged
// writer up front. Subsequent PATCH calls stream their bodies straight into
// this writer, and PUT either Commits or Cancels it depending on whether the
// daemon-supplied digest matches the running SHA256.
//
// The staged writer is opened with the registry's long-lived sessionCtx so
// it survives the POST handler returning. Backends like S3/GCS/Azure run a
// background goroutine for the upload that observes ctx cancellation; using
// req.Context() here would tear the upload down before the first PATCH.
func (r *Registry) openUpload() (string, error) {
stagedWriter, err := r.cas.BeginWrite(r.sessionCtx)
if err != nil {
return "", fmt.Errorf("open staged writer: %w", err)
}
upload := &pendingUpload{
writer: stagedWriter,
digester: sha256.New(),
}
id := uuid.NewString()
r.uploadsMu.Lock()
r.uploads[id] = upload
r.uploadsMu.Unlock()
return id, nil
}
func (r *Registry) getUpload(id string) *pendingUpload {
r.uploadsMu.Lock()
defer r.uploadsMu.Unlock()
return r.uploads[id]
}
// dropUpload removes the session from the registry's map without cancelling
// the staged writer — used by finishBlobUpload, which has already taken
// ownership of the writer and will Commit or Cancel it itself.
func (r *Registry) dropUpload(id string) {
r.uploadsMu.Lock()
delete(r.uploads, id)
r.uploadsMu.Unlock()
}
// cancelUpload removes the session and cancels the underlying staged writer.
// Used when a PATCH fails partway through and the session can no longer be
// trusted. Cancellation always uses the session ctx so cleanup runs even
// when the triggering request has been cancelled by the client.
func (r *Registry) cancelUpload(id string) {
r.uploadsMu.Lock()
upload, ok := r.uploads[id]
delete(r.uploads, id)
r.uploadsMu.Unlock()
if ok {
upload.cancel(r.sessionCtx)
}
}
// Write implements io.Writer for streaming chunks into the upload session's
// staged writer. The running digester is advanced for the bytes that were
// actually persisted, never for bytes that failed to write.
func (u *pendingUpload) Write(p []byte) (int, error) {
if u.writer == nil {
return 0, errors.New("upload session: write after commit/cancel")
}
n, err := u.writer.Write(p)
if n > 0 {
u.digester.Write(p[:n])
u.written += int64(n)
}
return n, err
}
// cancel discards the staged writer. Safe to call from any goroutine; the
// caller is responsible for serialising with concurrent Writes (the registry
// achieves this by removing the session from its map before calling cancel).
func (u *pendingUpload) cancel(ctx context.Context) {
u.mu.Lock()
defer u.mu.Unlock()
if u.writer == nil {
return
}
_ = u.writer.Cancel(ctx)
u.writer = nil
u.finished = true
}
// helpers --------------------------------------------------------------------
type errorBody struct {
Errors []errorEntry `json:"errors"`
}
type errorEntry struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{
Errors: []errorEntry{{Code: code, Message: message}},
})
}
// sniffManifestMediaType peeks at the manifest JSON to find the mediaType
// field. Defaults to the OCI manifest media type when unset.
func sniffManifestMediaType(body []byte) string {
var probe struct {
MediaType string `json:"mediaType"`
}
if err := json.Unmarshal(body, &probe); err == nil && probe.MediaType != "" {
return probe.MediaType
}
return "application/vnd.oci.image.manifest.v1+json"
}
package output
import (
"fmt"
"sort"
"github.com/chrismatix/grog/internal/hashing"
"github.com/chrismatix/grog/internal/proto/gen"
"google.golang.org/protobuf/proto"
)
func getOutputHash(outputs []*gen.Output) (string, error) {
if len(outputs) == 0 {
return "", nil
}
marshalOptions := proto.MarshalOptions{Deterministic: true}
// Calculate combined hash
digests := make([]string, 0, len(outputs))
for _, output := range outputs {
var digest string
data, err := marshalOptions.Marshal(output)
if err != nil {
return "", fmt.Errorf("failed to marshal output: %w", err)
}
digest = hashing.HashBytes(data)
digests = append(digests, digest)
}
hasher := hashing.GetHasher()
// Sort digests to ensure a consistent order
sort.Strings(digests)
for _, digest := range digests {
_, err := hasher.WriteString(digest)
if err != nil {
return "", err
}
}
return hasher.SumString(), nil
}
package handlers
import (
"context"
"fmt"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/chrismatix/grog/internal/worker"
)
// pushImageFromDaemon is the fallback for images that never reached the cache.
// Unlike the daemon-free copy path it takes credentials and plain-HTTP
// decisions from the daemon's own configuration.
func pushImageFromDaemon(
ctx context.Context,
dockerClient *client.Client,
localTag string,
destination string,
tracker *worker.ProgressTracker,
) error {
if err := dockerClient.ImageTag(ctx, localTag, destination); err != nil {
return fmt.Errorf("failed to tag image %q as %q: %w", localTag, destination, err)
}
auth, err := makeRegistryAuth(destination)
if err != nil {
return err
}
pushReader, err := dockerClient.ImagePush(ctx, destination, image.PushOptions{RegistryAuth: auth})
if err != nil {
return fmt.Errorf("failed to push image %q to %q: %w", localTag, destination, err)
}
defer pushReader.Close()
pushedDigest, err := consumeDockerProgress(pushReader, tracker, fmt.Sprintf("pushing %s", destination))
if err != nil {
return fmt.Errorf("error reading push response: %w", err)
}
// The daemon reports a digest only once the registry has acknowledged the
// manifest, so its absence means nothing is known to have landed.
if pushedDigest == "" {
return fmt.Errorf("push to %q finished without a registry digest, so the image is not known to have landed", destination)
}
return nil
}
package handlers
import (
"bytes"
"context"
"fmt"
"github.com/chrismatix/grog/internal/hashing"
"io"
"os"
"path/filepath"
"sort"
"sync/atomic"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/proto"
)
// DirectoryOutputHandler handles directory outputs by turning them into a merkle tree
// whose definition and file contents are stored in the CAS.
type DirectoryOutputHandler struct {
cas *caching.Cas
}
// directoryWritePlan uploads all files in a staged directory snapshot plus the
// merkle tree definition to the CAS. Files are staged to a temp root during Write()
// so that subsequent target commands cannot mutate them before the upload completes.
type directoryWritePlan struct {
cas *caching.Cas
stagingRoot string
treeBytes []byte
treeDigest string
fileUploads []fileUpload
targetLabel string
outputPath string
totalBytes int64
}
func (d *directoryWritePlan) Execute(ctx context.Context, tracker *worker.ProgressTracker) error {
progress := tracker
if progress != nil {
progress = progress.SubTracker(
fmt.Sprintf("%s: writing directory %s", d.targetLabel, d.outputPath),
d.totalBytes,
)
}
if _, err := uploadFilesToCas(ctx, d.cas, d.fileUploads, progress); err != nil {
return fmt.Errorf("failed to upload directory files to cache: %w", err)
}
if err := d.cas.Write(ctx, d.treeDigest, bytes.NewReader(d.treeBytes)); err != nil {
return fmt.Errorf("failed to write tree to cache: %w", err)
}
if progress != nil {
progress.Complete()
}
return nil
}
func (d *directoryWritePlan) Cleanup(_ context.Context) error {
return os.RemoveAll(d.stagingRoot)
}
// NewDirectoryOutputHandler creates a new DirectoryOutputHandler.
func NewDirectoryOutputHandler(cas *caching.Cas) *DirectoryOutputHandler {
return &DirectoryOutputHandler{
cas: cas,
}
}
func (d *DirectoryOutputHandler) Type() HandlerType {
return DirHandler
}
func (d *DirectoryOutputHandler) Hash(ctx context.Context, target model.Target, output model.Output) (string, error) {
directoryPath := target.GetAbsOutputPath(output)
return d.getDirectoryHash(ctx, target, directoryPath)
}
// getDirectoryHash builds a hash tree for the given directory and returns the digest of the tree.
func (d *DirectoryOutputHandler) getDirectoryHash(ctx context.Context, target model.Target, directoryPath string) (string, error) {
logger := console.GetLogger(ctx)
logger.Debugf("hashing directory %s (target %s)", directoryPath, target.Label)
childrenMap := make(map[string]*gen.Directory)
rootDirectory, _, err := d.writeDirectoryRecursive(ctx, directoryPath, directoryPath, "", childrenMap)
if err != nil {
return "", fmt.Errorf("failed to build hash tree for %s for target %s: %w", directoryPath, target.Label, err)
}
children := getSortedChildren(childrenMap)
tree := &gen.Tree{
Root: rootDirectory,
Children: children,
}
marshalledTree, err := proto.MarshalOptions{Deterministic: true}.Marshal(tree)
if err != nil {
return "", fmt.Errorf("failed to marshal tree: %w", err)
}
hasher := hashing.GetHasher()
if _, err := hasher.Write(marshalledTree); err != nil {
return "", fmt.Errorf("failed to hash tree: %w", err)
}
treeDigest := hasher.SumString()
return treeDigest, nil
}
func getSortedChildren(childrenMap map[string]*gen.Directory) []*gen.Directory {
// Sort children by digest to ensure deterministic tree construction
childDigests := make([]string, 0, len(childrenMap))
for digest := range childrenMap {
childDigests = append(childDigests, digest)
}
sort.Strings(childDigests)
children := make([]*gen.Directory, 0, len(childrenMap))
for _, digest := range childDigests {
children = append(children, childrenMap[digest])
}
return children
}
// Write compresses a directory into <output>.tar.gz and streams it into the cache.
func (d *DirectoryOutputHandler) Write(
ctx context.Context,
target model.Target,
output model.Output,
_ *worker.ProgressTracker,
) (*PreparedOutput, error) {
logger := console.GetLogger(ctx)
directoryPath := target.GetAbsOutputPath(output)
logger.Debugf("compressing %s (target %s → %s)", directoryPath, target.Label, output)
stagingRoot, err := os.MkdirTemp("", "grog-cache-dir-*")
if err != nil {
return nil, fmt.Errorf("failed to create staging directory for %s: %w", directoryPath, err)
}
cleanupStagingRoot := true
defer func() {
if cleanupStagingRoot {
_ = os.RemoveAll(stagingRoot)
}
}()
childrenMap := make(map[string]*gen.Directory)
rootDirectory, fileUploads, err := d.writeDirectoryRecursive(ctx, directoryPath, directoryPath, stagingRoot, childrenMap)
if err != nil {
return nil, fmt.Errorf("failed to build hash tree for %s for target %s: %w", directoryPath, target.Label, err)
}
children := getSortedChildren(childrenMap)
tree := &gen.Tree{
Root: rootDirectory,
Children: children,
}
marshalledTree, err := proto.MarshalOptions{Deterministic: true}.Marshal(tree)
if err != nil {
return nil, fmt.Errorf("failed to marshal tree: %w", err)
}
treeDigest := hashing.HashBytes(marshalledTree)
totalBytes := int64(0)
for _, upload := range fileUploads {
totalBytes += upload.sizeBytes
}
genOutput := &gen.Output{
Kind: &gen.Output_Directory{
Directory: &gen.DirectoryOutput{
Path: output.Identifier,
TreeDigest: &gen.Digest{
Hash: treeDigest,
SizeBytes: totalBytes,
},
},
},
}
writePlan := &directoryWritePlan{
cas: d.cas,
stagingRoot: stagingRoot,
treeBytes: marshalledTree,
treeDigest: treeDigest,
fileUploads: fileUploads,
targetLabel: target.Label.String(),
outputPath: output.Identifier,
totalBytes: totalBytes,
}
cleanupStagingRoot = false
return &PreparedOutput{
Output: genOutput,
WritePlan: writePlan,
}, nil
}
// uploadFilesToCas uploads files to the CAS in parallel, bounding the fan-out
// to the global I/O budget. os.Open runs only once a goroutine is admitted, so
// a directory with thousands of files can't exhaust the process FD limit while
// goroutines wait for a backend slot. The bytes themselves stay bounded inside
// cas.Write by the same global semaphore.
func uploadFilesToCas(ctx context.Context, cas *caching.Cas, fileUploads []fileUpload, progress *worker.ProgressTracker) (int64, error) {
logger := console.GetLogger(ctx)
var sizeBytes atomic.Int64
group, ctx := errgroup.WithContext(ctx)
group.SetLimit(backends.GlobalIOConcurrency())
for _, uploadAction := range fileUploads {
localUploadAction := uploadAction
group.Go(func() error {
logger.Debugf("uploading %s to CAS digest %s", localUploadAction.absolutePath, localUploadAction.digest)
file, err := os.Open(localUploadAction.absolutePath)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
reader := io.Reader(file)
if progress != nil {
reader = progress.WrapReader(file)
}
if err := cas.Write(ctx, localUploadAction.digest, reader); err != nil {
return err
}
sizeBytes.Add(localUploadAction.sizeBytes)
return nil
})
}
if err := group.Wait(); err != nil {
return 0, err
}
return sizeBytes.Load(), nil
}
// Helper struct for recording file that need to be uploaded to the CAS
// in parallel.
type fileUpload struct {
digest string
absolutePath string
sizeBytes int64
}
// writeDirectoryRecursive recursively builds a Directory message for the given path
// and writes everything it encounters to the cas.
func (d *DirectoryOutputHandler) writeDirectoryRecursive(
ctx context.Context,
rootPath string,
path string,
stagingRoot string,
childrenMap map[string]*gen.Directory,
) (*gen.Directory, []fileUpload, error) {
entries, err := os.ReadDir(path)
if err != nil {
return nil, nil, fmt.Errorf("failed to read directory %s: %w", path, err)
}
dir := &gen.Directory{
Files: []*gen.FileNode{},
Directories: []*gen.DirectoryNode{},
Symlinks: []*gen.SymlinkNode{},
}
// Sort entries for deterministic ordering
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var fileUploads []fileUpload
for _, entry := range entries {
entryPath := filepath.Join(path, entry.Name())
info, err := entry.Info()
if err != nil {
return nil, nil, fmt.Errorf("failed to get info for %s: %w", entryPath, err)
}
switch {
case info.Mode()&os.ModeSymlink != 0:
// Handle symlink
target, err := os.Readlink(entryPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read symlink %s: %w", entryPath, err)
}
dir.Symlinks = append(dir.Symlinks, &gen.SymlinkNode{
Name: entry.Name(),
Target: target,
})
case entry.IsDir():
// Handle subdirectory
subDir, recursiveFileUploads, err := d.writeDirectoryRecursive(ctx, rootPath, entryPath, stagingRoot, childrenMap)
if err != nil {
return nil, nil, err
}
fileUploads = append(fileUploads, recursiveFileUploads...)
// Compute digest for the subdirectory
digest, err := computeDirectoryDigest(subDir)
if err != nil {
return nil, nil, fmt.Errorf("failed to compute digest for directory %s: %w", entryPath, err)
}
dir.Directories = append(dir.Directories, &gen.DirectoryNode{
Name: entry.Name(),
Digest: digest,
})
// Add to children map
digestStr := digest.Hash
if _, exists := childrenMap[digestStr]; !exists {
childrenMap[digestStr] = subDir
}
default:
// Handle regular file
var digest *gen.Digest
uploadPath := entryPath
if stagingRoot == "" {
digest, err = computeFileDigest(entryPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to compute digest for file %s: %w", entryPath, err)
}
} else {
relativePath, err := filepath.Rel(rootPath, entryPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to compute relative path for %s: %w", entryPath, err)
}
uploadPath, digest, err = stageFileSnapshot(entryPath, stagingRoot, relativePath)
if err != nil {
return nil, nil, fmt.Errorf("failed to stage file %s: %w", entryPath, err)
}
}
fileUploads = append(fileUploads, fileUpload{digest.Hash, uploadPath, digest.GetSizeBytes()})
dir.Files = append(dir.Files, &gen.FileNode{
Name: entry.Name(),
Digest: digest,
IsExecutable: info.Mode()&0111 != 0,
})
}
}
return dir, fileUploads, nil
}
// computeDirectoryDigest computes the digest for a Directory message.
func computeDirectoryDigest(dir *gen.Directory) (*gen.Digest, error) {
// Serialize the directory to bytes
data, err := proto.MarshalOptions{Deterministic: true}.Marshal(dir)
if err != nil {
return nil, fmt.Errorf("failed to marshal directory: %w", err)
}
return &gen.Digest{
Hash: hashing.HashBytes(data),
SizeBytes: int64(len(data)),
}, nil
}
// computeFileDigest computes the digest for a file.
func computeFileDigest(path string) (*gen.Digest, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}
defer func() { _ = file.Close() }()
hasher := hashing.GetHasher()
size, err := io.Copy(hasher, file)
if err != nil {
return nil, fmt.Errorf("failed to hash file %s: %w", path, err)
}
return &gen.Digest{
Hash: hasher.SumString(),
SizeBytes: size,
}, nil
}
func stageFileSnapshot(sourcePath string, stagingRoot string, relativePath string) (string, *gen.Digest, error) {
stagedPath := filepath.Join(stagingRoot, relativePath)
if err := os.MkdirAll(filepath.Dir(stagedPath), 0755); err != nil {
return "", nil, fmt.Errorf("failed to create staging directory for %s: %w", stagedPath, err)
}
sourceFile, err := os.Open(sourcePath)
if err != nil {
return "", nil, fmt.Errorf("failed to open file %s: %w", sourcePath, err)
}
defer func() { _ = sourceFile.Close() }()
stagedFile, err := os.Create(stagedPath)
if err != nil {
return "", nil, fmt.Errorf("failed to create staged file %s: %w", stagedPath, err)
}
hasher := hashing.GetHasher()
sizeBytes, copyErr := io.Copy(io.MultiWriter(stagedFile, hasher), sourceFile)
closeErr := stagedFile.Close()
if copyErr != nil {
return "", nil, fmt.Errorf("failed to stage file %s: %w", sourcePath, copyErr)
}
if closeErr != nil {
return "", nil, fmt.Errorf("failed to close staged file %s: %w", stagedPath, closeErr)
}
return stagedPath, &gen.Digest{
Hash: hasher.SumString(),
SizeBytes: sizeBytes,
}, nil
}
// Load fetches the tree from the CAS and then fetches all files from the cache.
func (d *DirectoryOutputHandler) Load(
ctx context.Context,
target model.Target,
output *gen.Output,
tracker *worker.ProgressTracker,
) error {
logger := console.GetLogger(ctx)
directoryOutput := output.GetDirectory()
dirPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(target.Label.Package, directoryOutput.GetPath()))
logger.Debugf("loading directory from cache for target %s → %s", target.Label, dirPath)
// Fetch the tree from CAS
treeDigest := directoryOutput.GetTreeDigest().GetHash()
// Check the current directory hash against the cached tree
// so that we can avoid downloading the directory if it hasn't changed
localDirectoryDigest, err := d.getDirectoryHash(ctx, target, dirPath)
if err == nil && treeDigest == localDirectoryDigest {
logger.Debugf("directory %s already exists locally so skipping load", dirPath)
return nil
} else if err != nil {
logger.Debugf("failed to check directory hash: %v", err)
} else {
logger.Debugf("directory %s does not exist locally or has changed so reloading", dirPath)
}
logger.Debugf("loading directory tree digest %s for %s", treeDigest, dirPath)
treeBytes, err := d.cas.LoadBytes(ctx, treeDigest)
if err != nil {
return fmt.Errorf("failed to read tree from cache: %w", err)
}
totalBytes := directoryOutput.GetTreeDigest().GetSizeBytes()
progress := tracker
if progress != nil {
progress = progress.SubTracker(
fmt.Sprintf("%s: loading directory %s", target.Label, directoryOutput.GetPath()),
totalBytes,
)
}
// Unmarshal the tree
tree := &gen.Tree{}
if err := proto.Unmarshal(treeBytes, tree); err != nil {
return fmt.Errorf("failed to unmarshal tree: %w", err)
}
// Build a map of children directories by digest for easy lookup
childrenMap := make(map[string]*gen.Directory)
for _, child := range tree.Children {
digest, err := computeDirectoryDigest(child)
if err != nil {
return fmt.Errorf("failed to compute child directory digest: %w", err)
}
childrenMap[digest.Hash] = child
}
// Remove the directory if it already exists
if err := os.RemoveAll(dirPath); err != nil {
return fmt.Errorf("failed to remove directory %s: %w", dirPath, err)
}
// Create the root directory
if err := os.MkdirAll(dirPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dirPath, err)
}
// Concurrent CAS reads are bounded process-wide by the BoundedBackend
// wrapper inside cas.Load.
group, groupCtx := errgroup.WithContext(ctx)
// Recursively load the directory structure
if err := d.loadDirectoryRecursive(groupCtx, dirPath, tree.Root, childrenMap, progress, group); err != nil {
return fmt.Errorf("failed to load directory structure: %w", err)
}
// Wait for all file downloads to finish
if err := group.Wait(); err != nil {
return err
}
if progress != nil {
progress.Complete()
}
return nil
}
// loadDirectoryRecursive recursively reconstructs a directory from the Directory message.
func (d *DirectoryOutputHandler) loadDirectoryRecursive(
ctx context.Context,
path string,
dir *gen.Directory,
childrenMap map[string]*gen.Directory,
progress *worker.ProgressTracker,
group *errgroup.Group,
) error {
// Create all files
for _, fileNode := range dir.Files {
filePath := filepath.Join(path, fileNode.Name)
digest := fileNode.Digest.Hash
isExecutable := fileNode.IsExecutable
// Fetch file contents from CAS
group.Go(func() error {
console.GetLogger(ctx).Debugf("loading file for directory output %s from digest %s", filePath, digest)
if err := d.downloadFile(ctx, digest, filePath, isExecutable, progress); err != nil {
return fmt.Errorf("failed to download file %s: %w", filePath, err)
}
return nil
})
}
// Create all subdirectories
for _, dirNode := range dir.Directories {
subDirPath := filepath.Join(path, dirNode.Name)
// Create the subdirectory
if err := os.MkdirAll(subDirPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", subDirPath, err)
}
// Get the child directory from the map
childDir, exists := childrenMap[dirNode.Digest.Hash]
if !exists {
return fmt.Errorf("child directory %s not found in children map", dirNode.Name)
}
// Recursively load the subdirectory
if err := d.loadDirectoryRecursive(ctx, subDirPath, childDir, childrenMap, progress, group); err != nil {
return err
}
}
// Create all symlinks
for _, symlinkNode := range dir.Symlinks {
symlinkPath := filepath.Join(path, symlinkNode.Name)
// Create the symlink
if err := os.Symlink(symlinkNode.Target, symlinkPath); err != nil {
return fmt.Errorf("failed to create symlink %s: %w", symlinkPath, err)
}
}
return nil
}
func (d *DirectoryOutputHandler) downloadFile(ctx context.Context, digest, localPath string, isExecutable bool, progress *worker.ProgressTracker) error {
// Fetch file contents from CAS
fileReader, err := d.cas.Load(ctx, digest)
if err != nil {
return fmt.Errorf("failed to read file %s from cache: %w", localPath, err)
}
defer fileReader.Close()
file, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", localPath, err)
}
reader := io.Reader(fileReader)
if progress != nil {
reader = progress.WrapReader(fileReader)
}
if _, err := io.Copy(file, reader); err != nil {
return fmt.Errorf("failed to write file %s: %w", localPath, err)
}
// Write file
mode := os.FileMode(0644)
if isExecutable {
mode = 0755
}
err = file.Chmod(mode)
if err != nil {
return fmt.Errorf("failed to chmod file %s: %w", localPath, err)
}
return nil
}
package handlers
import (
"context"
"encoding/base64"
"fmt"
"strings"
"sync"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/oci_push"
"github.com/chrismatix/grog/internal/ociproxy"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
)
// DockerOutputHandler caches docker images by pushing them to an in-process
// OCI Distribution registry that proxies blob/manifest writes into grog's CAS.
//
// The Docker daemon does the actual pushing — streaming compressed layers from
// its content store directly to our loopback HTTP endpoint, which forwards them
// to the configured remote cache backend (S3/GCS/Azure/disk). Grog never holds
// image bytes in its own memory and never has to recompress layers, so the
// throughput matches what `docker push` to a real registry would achieve.
type DockerOutputHandler struct {
cas *caching.Cas
// dockerClient is created lazily on first use; sync.Once protects construction.
dockerInit sync.Once
dockerClient *client.Client
dockerErr error
// proxy is the loopback OCI registry.
proxyInit sync.Once
proxy *ociproxy.Registry
proxyErr error
insecureRegistries []string
}
// NewDockerOutputHandler creates a new DockerOutputHandler.
func NewDockerOutputHandler(_ context.Context, cas *caching.Cas, insecureRegistries []string) *DockerOutputHandler {
return &DockerOutputHandler{cas: cas, insecureRegistries: insecureRegistries}
}
// Type returns the type of the handler.
func (d *DockerOutputHandler) Type() HandlerType {
return OCIHandler
}
func (d *DockerOutputHandler) ensureClient() (*client.Client, error) {
d.dockerInit.Do(func() {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
d.dockerErr = fmt.Errorf("failed to create docker client: %w", err)
return
}
d.dockerClient = cli
})
return d.dockerClient, d.dockerErr
}
func (d *DockerOutputHandler) ensureProxy(ctx context.Context) (*ociproxy.Registry, error) {
d.proxyInit.Do(func() {
reg, err := ociproxy.New(ctx, d.cas)
if err != nil {
d.proxyErr = fmt.Errorf("failed to start in-process docker registry proxy: %w", err)
return
}
d.proxy = reg
})
return d.proxy, d.proxyErr
}
// Hash returns a stable identifier for the local Docker image. We use the
// Docker image ID (the digest of the image config) which fully identifies the
// image content and matches what DockerRegistryOutputHandler uses, so the two
// backends are interchangeable from a caching standpoint.
func (d *DockerOutputHandler) Hash(ctx context.Context, _ model.Target, output model.Output) (string, error) {
cli, err := d.ensureClient()
if err != nil {
return "", err
}
imageName := output.Identifier
console.GetLogger(ctx).Debugf("hashing Docker image %s via local daemon", imageName)
inspect, err := cli.ImageInspect(ctx, imageName)
if err != nil {
return "", fmt.Errorf("failed to inspect image %q: %w", imageName, err)
}
return inspect.ID, nil
}
// Write tags the local image with a loopback registry reference and stages a
// write plan that will push it via cli.ImagePush during the cache-write phase.
// No image bytes are read in this method — the heavy lifting is deferred until
// Execute, when the daemon streams the layers itself.
func (d *DockerOutputHandler) Write(
ctx context.Context,
target model.Target,
output model.Output,
_ *worker.ProgressTracker,
) (*PreparedOutput, error) {
logger := console.GetLogger(ctx)
imageName := output.Identifier
cli, err := d.ensureClient()
if err != nil {
return nil, err
}
proxy, err := d.ensureProxy(ctx)
if err != nil {
return nil, err
}
inspect, err := cli.ImageInspect(ctx, imageName)
if err != nil {
return nil, fmt.Errorf("%s: image output %s was not created: %w", target.Label.String(), imageName, err)
}
repoName := loopbackRepoName(inspect.ID)
loopbackRef := fmt.Sprintf("%s/%s:%s", proxy.Addr(), repoName, shortID(inspect.ID))
logger.Debugf("tagging Docker image %s as %s for loopback push", imageName, loopbackRef)
if err := cli.ImageTag(ctx, imageName, loopbackRef); err != nil {
return nil, fmt.Errorf("failed to tag image %q as %q: %w", imageName, loopbackRef, err)
}
dockerImageOutput := &gen.OCIImageOutput{
Mode: gen.ImageMode_LAYERS,
LocalTag: imageName,
ImageId: inspect.ID,
// ManifestDigest is filled in by Execute once the daemon has produced one.
}
genOutput := &gen.Output{
Kind: &gen.Output_OciImage{
OciImage: dockerImageOutput,
},
}
plan := &dockerImageWritePlan{
dockerClient: cli,
proxy: proxy,
output: dockerImageOutput,
loopbackRef: loopbackRef,
repoName: repoName,
localImageName: imageName,
targetLabel: target.Label.String(),
}
return &PreparedOutput{Output: genOutput, WritePlan: plan}, nil
}
// Load restores a previously cached image into the local Docker daemon by
// pulling it from the loopback registry by manifest digest. The daemon does
// the actual layer download — grog only translates the cached digests into
// a registry reference and tags the result.
func (d *DockerOutputHandler) Load(
ctx context.Context,
target model.Target,
output *gen.Output,
tracker *worker.ProgressTracker,
) error {
dockerImage := output.GetOciImage()
if dockerImage.GetMode() != gen.ImageMode_LAYERS {
return fmt.Errorf("cannot restore %s docker cache as layer cache is configured", dockerImage.GetMode())
}
logger := console.GetLogger(ctx)
localImageName := dockerImage.GetLocalTag()
imageID := dockerImage.GetImageId()
manifestDigest := dockerImage.GetManifestDigest().GetHash()
if manifestDigest == "" {
return fmt.Errorf("cached docker image %q has no manifest digest", localImageName)
}
cli, err := d.ensureClient()
if err != nil {
return err
}
proxy, err := d.ensureProxy(ctx)
if err != nil {
return err
}
// Fast path: the image content is already present in the daemon. Just make
// sure the requested local tag points at it.
if imageID != "" {
if _, err := cli.ImageInspect(ctx, imageID); err == nil {
if err := cli.ImageTag(ctx, imageID, localImageName); err != nil {
return fmt.Errorf("failed to tag existing image %q as %q: %w", imageID, localImageName, err)
}
logger.Debugf("image %s already present locally, skipped pull", localImageName)
return nil
}
}
repoName := loopbackRepoName(imageID)
pullRef := fmt.Sprintf("%s/%s@%s", proxy.Addr(), repoName, manifestDigest)
logger.Debugf("pulling Docker image %s from loopback registry", pullRef)
pullReader, err := cli.ImagePull(ctx, pullRef, image.PullOptions{
RegistryAuth: emptyRegistryAuth,
})
if err != nil {
return fmt.Errorf("failed to pull image %q from loopback registry: %w", pullRef, err)
}
defer pullReader.Close()
if _, err := consumeDockerProgress(pullReader, tracker, fmt.Sprintf("%s: pulling cache for %s", target.Label, localImageName)); err != nil {
return fmt.Errorf("error reading pull response: %w", err)
}
// After pull, the daemon stores the image under the loopback @digest reference.
// Re-tag it as the original local image name and discard the loopback ref.
if err := cli.ImageTag(ctx, pullRef, localImageName); err != nil {
return fmt.Errorf("failed to tag pulled image %q as %q: %w", pullRef, localImageName, err)
}
if _, err := cli.ImageRemove(ctx, pullRef, image.RemoveOptions{}); err != nil && !errdefs.IsNotFound(err) {
logger.Warnf("failed to remove loopback ref %q from local daemon: %v", pullRef, err)
}
logger.Debugf("successfully loaded Docker image %s from cache", localImageName)
return nil
}
// PushImage copies the cached image from the loopback proxy to the destination
// registry. The proxy is started on demand by Write/Load and stays up until
// the registry's Close. Source is always loopback (insecure HTTP).
func (d *DockerOutputHandler) PushImage(ctx context.Context, image *gen.OCIImageOutput, destination string, _ *worker.ProgressTracker) (bool, error) {
proxy, err := d.ensureProxy(ctx)
if err != nil {
return false, err
}
manifestDigest := image.GetManifestDigest().GetHash()
imageID := image.GetImageId()
if manifestDigest == "" || imageID == "" {
return false, fmt.Errorf("cache write did not populate image identity for push to %s", destination)
}
src := fmt.Sprintf("%s/%s@%s", proxy.Addr(), loopbackRepoName(imageID), manifestDigest)
return oci_push.Copy(ctx, src, destination, oci_push.Options{
// Source is grog's own loopback proxy — structurally HTTP, not
// something the user opts in to.
SourceInsecure: true,
DestinationInsecure: matchesInsecureRegistry(destination, d.insecureRegistries),
})
}
func (d *DockerOutputHandler) PushLocalImage(ctx context.Context, localTag, destination string, tracker *worker.ProgressTracker) (bool, error) {
cli, err := d.ensureClient()
if err != nil {
return false, err
}
return false, pushImageFromDaemon(ctx, cli, localTag, destination, tracker)
}
// dockerImageWritePlan defers the actual `docker push` to the cache-write phase.
// Execute pushes to the loopback registry, then mutates the embedded Output proto
// to record the manifest digest the daemon produced — that's how Load later knows
// which manifest to pull.
type dockerImageWritePlan struct {
dockerClient *client.Client
proxy *ociproxy.Registry
// output is the same pointer that lives in PreparedTargetResult.TargetResult.Outputs[i].
// Execute mutates its ManifestDigest field; the cache writer persists the
// proto AFTER all write plans succeed, so the mutation is captured.
output *gen.OCIImageOutput
loopbackRef string // host:port/repo:tag — the local tag we put on the image
repoName string // path part of loopbackRef, used to look up the manifest digest
localImageName string
targetLabel string
}
func (d *dockerImageWritePlan) Execute(ctx context.Context, tracker *worker.ProgressTracker) error {
logger := console.GetLogger(ctx)
baseStatus := fmt.Sprintf("%s: caching docker image %s", d.targetLabel, d.localImageName)
// Surface the plan's phase immediately so the UI stops saying
// "writing cache" before the daemon has sent its first JSON message. For
// big overlay2-backed images the gzip/prepare phase alone can take
// several minutes, and the user should see *something* during that wait.
tracker.SetStatus(baseStatus)
logger.Debugf("pushing Docker image %s to loopback registry", d.loopbackRef)
pushReader, err := d.dockerClient.ImagePush(ctx, d.loopbackRef, image.PushOptions{
RegistryAuth: emptyRegistryAuth,
})
if err != nil {
return fmt.Errorf("failed to push image %q to loopback registry: %w", d.loopbackRef, err)
}
defer pushReader.Close()
if _, err := consumeDockerProgress(pushReader, tracker, baseStatus); err != nil {
return fmt.Errorf("error reading push response: %w", err)
}
// The stream has drained but the plan still has finalisation work (reading
// the manifest digest back and mutating the output proto). Call it out so
// the user knows we've moved on from the daemon push itself.
tracker.SetStatus(baseStatus + ": finalizing")
manifestDigest := d.proxy.LastManifestDigest(d.repoName)
if manifestDigest == "" {
return fmt.Errorf("docker push for %q completed but the loopback registry never received a manifest", d.loopbackRef)
}
d.output.ManifestDigest = &gen.Digest{Hash: manifestDigest}
logger.Debugf("successfully cached Docker image %s (manifest %s)", d.localImageName, manifestDigest)
return nil
}
// Close shuts down the in-process loopback registry, if one was started.
// Must be called only after all async cache writes that push through the
// proxy have drained — otherwise the daemon's mid-push HTTP requests will
// fail with "connection refused" once the listener is torn down.
func (d *DockerOutputHandler) Close() error {
if d.proxy == nil {
return nil
}
return d.proxy.Close()
}
func (d *dockerImageWritePlan) Cleanup(ctx context.Context) error {
if _, err := d.dockerClient.ImageRemove(ctx, d.loopbackRef, image.RemoveOptions{}); err != nil && !errdefs.IsNotFound(err) {
console.GetLogger(ctx).Warnf("failed to remove loopback tag %q: %v", d.loopbackRef, err)
}
return nil
}
// loopbackRepoName builds a repository path component from an image ID. The
// path is stable for a given image so concurrent pushes of the same content
// converge on the same name (which is fine — the manifest digest they record
// is identical).
func loopbackRepoName(imageID string) string {
return "grog-cache/" + shortID(imageID)
}
// shortID strips the sha256: prefix from a digest and returns the first 32
// hex characters. Long enough to be effectively unique within a build, short
// enough to keep log lines readable.
func shortID(imageID string) string {
id := strings.TrimPrefix(imageID, "sha256:")
if len(id) > 32 {
id = id[:32]
}
if id == "" {
id = "unknown"
}
return id
}
// emptyRegistryAuth is a base64-encoded empty AuthConfig. The Docker daemon
// requires *some* RegistryAuth header on push/pull even when the target is a
// non-authenticated localhost registry; an empty JSON object satisfies it.
var emptyRegistryAuth = base64.URLEncoding.EncodeToString([]byte("{}"))
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/oci_push"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
"github.com/docker/docker/client"
)
// DockerRegistryOutputHandler writes Docker images to and loads them from a registry specified by configuration.
type DockerRegistryOutputHandler struct {
cas *caching.Cas
config config.OCIConfig
dockerClient *client.Client
}
// dockerRegistryWritePlan pushes a pre-tagged Docker image to the configured remote
// registry. The image is tagged with a cache-specific name during Write(); this plan
// pushes that tag and Cleanup removes it from the local daemon to avoid polluting it.
type dockerRegistryWritePlan struct {
dockerClient *client.Client
remoteImageName string
localImageName string
targetLabel string
}
func (d *dockerRegistryWritePlan) Execute(ctx context.Context, tracker *worker.ProgressTracker) error {
baseStatus := fmt.Sprintf("%s: pushing cache for %s", d.targetLabel, d.localImageName)
// Surface the plan's phase immediately so the UI doesn't dwell on
// "writing cache" while the daemon prepares (gzips, serializes) layers.
tracker.SetStatus(baseStatus)
auth, err := makeRegistryAuth(d.remoteImageName)
if err != nil {
return err
}
pushReader, err := d.dockerClient.ImagePush(ctx, d.remoteImageName, image.PushOptions{
RegistryAuth: auth,
})
if err != nil {
return fmt.Errorf("failed to push image %q to registry: %w", d.remoteImageName, err)
}
defer pushReader.Close()
if _, err := consumeDockerProgress(pushReader, tracker, baseStatus); err != nil {
return fmt.Errorf("error reading push response: %w", err)
}
console.GetLogger(ctx).Debugf("successfully pushed Docker image %s to registry", d.remoteImageName)
return nil
}
func (d *dockerRegistryWritePlan) Cleanup(ctx context.Context) error {
_, err := d.dockerClient.ImageRemove(ctx, d.remoteImageName, image.RemoveOptions{})
if err != nil && !cerrdefs.IsNotFound(err) {
console.GetLogger(ctx).Warnf("failed to remove image %q from local Docker daemon: %v", d.remoteImageName, err)
}
return nil
}
// NewDockerRegistryOutputHandler creates a new DockerRegistryOutputHandler.
func NewDockerRegistryOutputHandler(
cas *caching.Cas,
cfg config.OCIConfig,
) *DockerRegistryOutputHandler {
return &DockerRegistryOutputHandler{
cas: cas,
config: cfg,
}
}
func (d *DockerRegistryOutputHandler) Type() HandlerType {
return OCIHandler
}
func (d *DockerRegistryOutputHandler) Hash(ctx context.Context, target model.Target, output model.Output) (string, error) {
cli, err := d.lazyClient()
if err != nil {
return "", err
}
localImageName := output.Identifier
inspect, err := cli.ImageInspect(ctx, localImageName)
if err != nil {
return "", fmt.Errorf("%s: image output %s was not created: %w", target.Label.String(), localImageName, err)
}
return inspect.ID, nil
}
// lazyClient creates a new Docker client on demand.
func (d *DockerRegistryOutputHandler) lazyClient() (*client.Client, error) {
if d.dockerClient != nil {
return d.dockerClient, nil
}
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create docker client: %w", err)
}
d.dockerClient = dockerClient
return d.dockerClient, nil
}
// PushImage copies the cached image from the configured cache registry to
// destination. Auth flows through the ambient docker keychain; either side
// can be tagged plain-HTTP via oci.insecure_registries.
func (d *DockerRegistryOutputHandler) PushImage(ctx context.Context, image *gen.OCIImageOutput, destination string, _ *worker.ProgressTracker) (bool, error) {
imageID := image.GetImageId()
if imageID == "" {
return false, fmt.Errorf("cache write did not populate image_id for push to %s", destination)
}
src := d.cacheImageName(imageID)
return oci_push.Copy(ctx, src, destination, oci_push.Options{
SourceInsecure: matchesInsecureRegistry(src, d.config.InsecureRegistries),
DestinationInsecure: matchesInsecureRegistry(destination, d.config.InsecureRegistries),
})
}
func (d *DockerRegistryOutputHandler) PushLocalImage(ctx context.Context, localTag, destination string, tracker *worker.ProgressTracker) (bool, error) {
cli, err := d.lazyClient()
if err != nil {
return false, err
}
return false, pushImageFromDaemon(ctx, cli, localTag, destination, tracker)
}
func (d *DockerRegistryOutputHandler) cacheImageName(digest string) string {
workspaceDir := config.Global.WorkspaceRoot
workspacePrefix := config.GetWorkspaceCachePrefix(workspaceDir)
// strip the leading sha256: prefix from the digest
if strings.Contains(digest, ":") {
digest = strings.Split(digest, ":")[1]
}
return fmt.Sprintf("%s/%s-%s", d.config.Registry,
workspacePrefix, digest)
}
// Write inspects and tags the Docker image synchronously, deferring the push to the registry.
func (d *DockerRegistryOutputHandler) Write(
ctx context.Context,
target model.Target,
output model.Output,
_ *worker.ProgressTracker,
) (*PreparedOutput, error) {
logger := console.GetLogger(ctx)
localImageName := output.Identifier
cli, err := d.lazyClient()
if err != nil {
return nil, err
}
inspect, err := cli.ImageInspect(ctx, localImageName)
if err != nil {
return nil, fmt.Errorf("%s: image output %s was not created: %w", target.Label.String(), localImageName, err)
}
remoteCacheImageName := d.cacheImageName(inspect.ID)
logger.Debugf("tagging Docker image %s as %s for deferred push", localImageName, remoteCacheImageName)
if err := cli.ImageTag(ctx, localImageName, remoteCacheImageName); err != nil {
return nil, fmt.Errorf("failed to tag image %q as %q: %w", localImageName, remoteCacheImageName, err)
}
genOutput := &gen.Output{
Kind: &gen.Output_OciImage{
OciImage: &gen.OCIImageOutput{
LocalTag: localImageName,
RemoteTag: remoteCacheImageName,
ImageId: inspect.ID,
Mode: gen.ImageMode_REGISTRY,
},
},
}
writePlan := &dockerRegistryWritePlan{
dockerClient: cli,
remoteImageName: remoteCacheImageName,
localImageName: localImageName,
targetLabel: target.Label.String(),
}
return &PreparedOutput{Output: genOutput, WritePlan: writePlan}, nil
}
// makeRegistryAuth builds the base64 auth header the daemon expects for ref.
// Credentials resolve through the same keychain as the daemon-free push path,
// which is what maps a shorthand reference like "alice/app" onto Docker Hub —
// splitting the string on "/" would look up a registry named "alice".
func makeRegistryAuth(ref string) (string, error) {
parsed, err := name.ParseReference(ref)
if err != nil {
return "", fmt.Errorf("parsing reference %q: %w", ref, err)
}
registry := parsed.Context()
authenticator, err := authn.DefaultKeychain.Resolve(registry)
if err != nil {
return "", fmt.Errorf("getting auth config for registry %q: %w", registry.RegistryStr(), err)
}
authConfig, err := authenticator.Authorization()
if err != nil {
return "", fmt.Errorf("reading credentials for registry %q: %w", registry.RegistryStr(), err)
}
raw, err := json.Marshal(authConfig)
if err != nil {
return "", fmt.Errorf("marshaling auth config: %w", err)
}
return base64.URLEncoding.EncodeToString(raw), nil
}
// Load pulls the Docker image from the remote registry and writes it into the local Docker daemon.
func (d *DockerRegistryOutputHandler) Load(
ctx context.Context,
target model.Target,
output *gen.Output,
tracker *worker.ProgressTracker,
) error {
dockerImage := output.GetOciImage()
if dockerImage.GetMode() != gen.ImageMode_REGISTRY {
return fmt.Errorf("cannot restore %s docker cache as registry cache is configured", dockerImage.GetMode())
}
localImageName := output.GetOciImage().GetLocalTag()
imageId := output.GetOciImage().GetImageId()
logger := console.GetLogger(ctx)
cli, err := d.lazyClient()
if err != nil {
return err
}
// check if the image exists in the local Docker daemon
if _, err = cli.ImageInspect(ctx, imageId); err == nil {
// The image content exists, but we must ensure the requested tag points to it.
// If we don't do this, the ID might exist but the tag (localImageName) might be missing.
if err := cli.ImageTag(ctx, imageId, localImageName); err != nil {
return fmt.Errorf("failed to tag existing image %q as %q: %w", imageId, localImageName, err)
}
logger.Debugf("image %s already exists in local Docker daemon, skipping pull", localImageName)
return nil
}
remoteImageName := output.GetOciImage().GetRemoteTag()
logger.Debugf("pulling Docker image %s from registry", remoteImageName)
// Build the RegistryAuth header from ~/.docker/config.json / helpers
auth, err := makeRegistryAuth(remoteImageName)
if err != nil {
return err
}
pull, err := cli.ImagePull(ctx, remoteImageName, image.PullOptions{
RegistryAuth: auth,
})
if err != nil {
return fmt.Errorf("failed to pull image %q from registry: %w", remoteImageName, err)
}
defer pull.Close()
// Clean up the image tag so that it does not pollute the user's docker machine
defer func() {
if _, err := cli.ImageRemove(ctx, remoteImageName, image.RemoveOptions{}); err != nil {
logger.Warnf("failed to remove image %q from local Docker daemon: %v", remoteImageName, err)
}
}()
// Read and bridge the Docker JSON progress from pull before tagging
if _, err := consumeDockerProgress(pull, tracker, fmt.Sprintf("%s: pulling cache for %s", target.Label, localImageName)); err != nil {
return fmt.Errorf("error reading pull response: %w", err)
}
if err := cli.ImageTag(ctx, remoteImageName, localImageName); err != nil {
return fmt.Errorf("failed to tag cache image %q as %q: %w", remoteImageName, localImageName, err)
}
logger.Debugf("successfully loaded Docker image %s from registry tag %s", localImageName, remoteImageName)
return nil
}
// statusDigestPattern matches the trailing "1.0.0: digest: sha256:… size: 1917"
// line the daemon emits once the registry has acknowledged a manifest.
var statusDigestPattern = regexp.MustCompile(`digest:\s+(sha256:[0-9a-f]{64})`)
// The classic image store reports the acknowledged digest as a structured aux
// payload; the containerd store reports only the status line, so both shapes
// have to be read.
func registryConfirmedDigest(jsonMessage jsonmessage.JSONMessage) string {
if jsonMessage.Aux != nil {
var pushResult struct {
Digest string `json:"Digest"`
}
if err := json.Unmarshal(*jsonMessage.Aux, &pushResult); err == nil && pushResult.Digest != "" {
return pushResult.Digest
}
}
if match := statusDigestPattern.FindStringSubmatch(jsonMessage.Status); match != nil {
return match[1]
}
return ""
}
// dockerLayerProgress holds per-layer tracking state for bridging JSON progress.
type dockerLayerProgress struct {
tracker *worker.ProgressTracker
lastCurrent int64
total int64
}
// consumeDockerProgress decodes Docker CLI JSON messages and bridges them into
// our ProgressTracker model. It does two things:
//
// 1. For every layer that emits "Pushing"/"Downloading" messages with a known
// total, it creates a child sub-tracker and updates it as bytes flow.
// 2. It updates the parent tracker's *status text* (via SetStatus) as the
// daemon reports non-progress phase transitions like "Preparing",
// "Waiting", "Pushed" or "Layer already exists". This gives the user a
// visible signal that work is happening even during phases where the
// daemon is not shipping bytes through our HTTP endpoint (e.g. gzipping
// layers on an overlay2-backed daemon, or waiting for our own
// finishBlobUpload to flush a temp file into the cache).
//
// The base status string is set on the parent immediately so the UI reflects
// the current plan even before the first daemon message arrives. If parent is
// nil, the stream is still drained but no progress is emitted.
//
// The returned digest is the one the registry confirmed for a push; it is
// empty for pulls and for pushes the daemon never got a digest for.
func consumeDockerProgress(
reader io.Reader,
parent *worker.ProgressTracker,
status string,
) (string, error) {
if reader == nil {
return "", nil
}
// Seed the parent status so the UI shows something meaningful immediately,
// before the daemon has sent its first JSON message.
parent.SetStatus(status)
dec := json.NewDecoder(reader)
layers := make(map[string]*dockerLayerProgress)
// layerStates tracks the most recent non-empty daemon status for each
// layer ID. lastPhaseSummary remembers what we last pushed to
// parent.SetSubStatus so we don't flood the UI with identical updates.
layerStates := make(map[string]string)
lastPhaseSummary := ""
pushedDigest := ""
for {
var jsonMessage jsonmessage.JSONMessage
if err := dec.Decode(&jsonMessage); err != nil {
if err == io.EOF {
break
}
return "", err
}
if jsonMessage.Error != nil {
return "", jsonMessage.Error
}
if digest := registryConfirmedDigest(jsonMessage); digest != "" {
pushedDigest = digest
}
// Update the plan-level phase summary whenever a layer transitions to
// a new named state. This runs even for messages that have no
// Progress field, so "Preparing" and "Waiting" phases become visible.
// The summary is shown as a sub-status line below the main status.
if jsonMessage.ID != "" && jsonMessage.Status != "" {
if layerStates[jsonMessage.ID] != jsonMessage.Status {
layerStates[jsonMessage.ID] = jsonMessage.Status
phaseSummary := formatPhaseSummary(layerStates)
if phaseSummary != lastPhaseSummary {
parent.SetSubStatus(phaseSummary)
lastPhaseSummary = phaseSummary
}
}
}
// Only handle progress-bearing messages for per-layer sub-trackers.
if jsonMessage.ID == "" || jsonMessage.Progress == nil {
continue
}
trackLayerProgress(layers, parent, status, jsonMessage.ID, jsonMessage.Progress)
}
// Ensure all trackers are completed at end of stream
for _, st := range layers {
st.tracker.Complete()
}
return pushedDigest, nil
}
// trackLayerProgress feeds one layer's byte progress into its sub-tracker, creating it on first sight.
func trackLayerProgress(
layers map[string]*dockerLayerProgress,
parent *worker.ProgressTracker,
status string,
layerID string,
progress *jsonmessage.JSONProgress,
) {
current := progress.Current
total := progress.Total
state, ok := layers[layerID]
if !ok && total > 0 && parent != nil {
child := parent.SubTracker(status, total)
if child != nil {
state = &dockerLayerProgress{tracker: child, total: total}
layers[layerID] = state
}
}
if state == nil {
return
}
// Guard against out-of-order or resetting currents
delta := current - state.lastCurrent
if delta < 0 {
// treat as absolute if we observe a reset
delta = current
}
if delta > 0 {
state.tracker.Add(delta)
state.lastCurrent = current
}
if total > 0 && current >= total {
state.tracker.Complete()
}
}
// layerPhaseLabels maps the verbose docker daemon status strings to short,
// noun-shaped labels that read correctly with any count: "1 cached" /
// "5 cached", "1 pushing" / "5 pushing". The previous version simply
// lowercased the daemon's status, which produced ungrammatical phrases like
// "2 layer already exists".
//
// Order matters: when multiple phases are active we list them in this order
// so the most action-relevant ("pushing") comes first.
var layerPhaseLabels = []struct {
state string
label string
}{
{"Pushing", "pushing"},
{"Downloading", "downloading"},
{"Extracting", "extracting"},
{"Verifying Checksum", "verifying"},
{"Preparing", "preparing"},
{"Waiting", "waiting"},
{"Mounted from", "mounted"},
{"Pushed", "pushed"},
{"Download complete", "downloaded"},
{"Pull complete", "pulled"},
{"Layer already exists", "cached"},
{"Already exists", "cached"},
}
// formatPhaseSummary builds a human-readable summary of the phases a
// docker push/pull is currently in, grouped by state. Returns just the
// parenthetical summary intended for rendering as a sub-status line.
//
// Example output with five layers:
//
// "3 pushing, 1 preparing, 1 cached"
//
// States are grouped in a fixed order so the line stays readable as layers
// transition. Unknown daemon states are appended in alphabetical order.
func formatPhaseSummary(layerStates map[string]string) string {
if len(layerStates) == 0 {
return ""
}
counts := make(map[string]int)
for _, state := range layerStates {
counts[state]++
}
seen := make(map[string]bool, len(layerPhaseLabels))
var parts []string
for _, ph := range layerPhaseLabels {
seen[ph.state] = true
if n := counts[ph.state]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, ph.label))
}
}
// Catch-all: any daemon state we don't know about, in stable alphabetical
// order. We lower-case it but otherwise leave it untouched — these are
// rare enough that grammatical-perfection isn't worth the mapping table.
var extras []string
for state := range counts {
if !seen[state] {
extras = append(extras, state)
}
}
sort.Strings(extras)
for _, state := range extras {
parts = append(parts, fmt.Sprintf("%d %s", counts[state], strings.ToLower(state)))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ", ")
}
package handlers
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/hashing"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
)
// fileWritePlan uploads a single staged file to the CAS.
// The original output file is copied to a temp path during Write() so the upload
// reads from an immutable snapshot, not the live workspace file.
type fileWritePlan struct {
cas *caching.Cas
stagedPath string
digest string
sizeBytes int64
targetLabel string
relativePath string
}
func (f *fileWritePlan) Execute(ctx context.Context, tracker *worker.ProgressTracker) error {
file, err := os.Open(f.stagedPath)
if err != nil {
return fmt.Errorf("failed to open staged file %s for cache write: %w", f.stagedPath, err)
}
defer func() { _ = file.Close() }()
progress := tracker
if progress != nil {
progress = progress.SubTracker(fmt.Sprintf("%s: writing %s", f.targetLabel, f.relativePath), f.sizeBytes)
}
reader := io.Reader(file)
if progress != nil {
reader = progress.WrapReader(file)
}
console.GetLogger(ctx).Debugf("writing staged file output %s with digest %s", f.stagedPath, f.digest)
if err := f.cas.Write(ctx, f.digest, reader); err != nil {
return err
}
if progress != nil {
progress.Complete()
}
return nil
}
func (f *fileWritePlan) Cleanup(_ context.Context) error {
return os.Remove(f.stagedPath)
}
// FileOutputHandler is the default output handler that writes files to the file system.
// mostly passes directly through to the target cache which handles files.
type FileOutputHandler struct {
cas *caching.Cas
}
func NewFileOutputHandler(cas *caching.Cas) *FileOutputHandler {
return &FileOutputHandler{
cas: cas,
}
}
func (f *FileOutputHandler) Type() HandlerType {
return "file"
}
func (f *FileOutputHandler) Hash(_ context.Context, target model.Target, output model.Output) (string, error) {
absOutputPath := target.GetAbsOutputPath(output)
fileHash, err := hashing.HashFile(absOutputPath)
if err != nil {
return "", fmt.Errorf("failed to hash file %s: %w", absOutputPath, err)
}
return fileHash, nil
}
func (f *FileOutputHandler) Write(
ctx context.Context,
target model.Target,
output model.Output,
_ *worker.ProgressTracker,
) (*PreparedOutput, error) {
relativePath := output.Identifier
absOutputPath := target.GetAbsOutputPath(output)
file, err := os.Open(absOutputPath)
if err != nil {
return nil, fmt.Errorf("declared output %s for target %s was not created", output, target.Label)
}
defer func() { _ = file.Close() }()
stagedFile, err := os.CreateTemp("", "grog-cache-file-*")
if err != nil {
return nil, fmt.Errorf("failed to create staged file for %s: %w", absOutputPath, err)
}
stagedPath := stagedFile.Name()
cleanupStagedFile := true
defer func() {
if cleanupStagedFile {
_ = os.Remove(stagedPath)
}
}()
hasher := hashing.GetHasher()
sizeBytes, err := io.Copy(io.MultiWriter(stagedFile, hasher), file)
if closeErr := stagedFile.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
return nil, fmt.Errorf("failed to stage file %s for cache write: %w", absOutputPath, err)
}
fileHash := hasher.SumString()
genOutput := &gen.Output{
Kind: &gen.Output_File{
File: &gen.FileOutput{
Path: relativePath,
Digest: &gen.Digest{
Hash: fileHash,
SizeBytes: sizeBytes,
},
},
},
}
writePlan := &fileWritePlan{
cas: f.cas,
stagedPath: stagedPath,
digest: fileHash,
sizeBytes: sizeBytes,
targetLabel: target.Label.String(),
relativePath: relativePath,
}
cleanupStagedFile = false
return &PreparedOutput{
Output: genOutput,
WritePlan: writePlan,
}, nil
}
func (f *FileOutputHandler) Load(
ctx context.Context,
target model.Target,
output *gen.Output,
tracker *worker.ProgressTracker,
) error {
absOutputPath := config.GetPathAbsoluteToWorkspaceRoot(filepath.Join(target.Label.Package, output.GetFile().GetPath()))
existingHash, err := hashing.HashFile(absOutputPath)
// If the local hash is the same as the cached one we don't need to
// load the file from the CAS
if err == nil && existingHash == output.GetFile().GetDigest().GetHash() {
return nil
}
progress := tracker
if progress != nil {
progress = progress.SubTracker(
fmt.Sprintf("%s: loading %s", target.Label, output.GetFile().GetPath()),
output.GetFile().GetDigest().GetSizeBytes(),
)
}
console.GetLogger(ctx).Debugf("loading file output %s with digest %s", absOutputPath, output.GetFile().GetDigest().GetHash())
contentReader, err := f.cas.Load(ctx, output.GetFile().GetDigest().GetHash())
if err != nil {
return err
}
defer contentReader.Close()
reader := io.Reader(contentReader)
if progress != nil {
reader = progress.WrapReader(contentReader)
}
outputFile, err := os.Create(absOutputPath)
if err != nil {
return err
}
if _, err := io.Copy(outputFile, reader); err != nil {
return err
}
if progress != nil {
progress.Complete()
}
if err := outputFile.Close(); err != nil {
return err
}
return nil
}
package handlers
import (
"net"
"strings"
)
// matchesInsecureRegistry reports whether ref's registry host is enumerated
// in the user's oci.insecure_registries list. Host-only entries match any
// port ("localhost" → "localhost:5000"); host:port entries match exactly.
// IPv6 addresses may be bare (`::1`) or bracketed (`[::1]:5000`).
func matchesInsecureRegistry(ref string, insecureRegistries []string) bool {
host := registryHost(ref)
if host == "" || len(insecureRegistries) == 0 {
return false
}
refHost, refPort := splitHostPort(host)
for _, entry := range insecureRegistries {
entryHost, entryPort := splitHostPort(entry)
if entryHost != refHost {
continue
}
if entryPort == "" || entryPort == refPort {
return true
}
}
return false
}
// registryHost returns the registry portion of an OCI reference (everything
// before the first "/"). Empty for malformed refs.
func registryHost(ref string) string {
if i := strings.IndexByte(ref, '/'); i > 0 {
return ref[:i]
}
return ""
}
// splitHostPort tolerates entries without a port (the host-only case the
// matcher supports) and bracketed IPv6 literals — both shapes that
// net.SplitHostPort itself rejects.
func splitHostPort(s string) (host, port string) {
if h, p, err := net.SplitHostPort(s); err == nil {
return h, p
}
return strings.Trim(s, "[]"), ""
}
package handlers
import (
"context"
"fmt"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
)
// OciPushPlan ships an image to a user-facing destination via the oci handler.
// The image is sourced either from the cache (NewOciPushPlan) or straight from
// the local docker daemon (NewLocalOciPushPlan).
type OciPushPlan struct {
push func(context.Context, *worker.ProgressTracker) (bool, error)
destination string
targetLabel string
reporter *PushReporter
}
func NewOciPushPlan(pusher ImagePusher, image *gen.OCIImageOutput, destination, targetLabel string, reporter *PushReporter) *OciPushPlan {
return &OciPushPlan{
push: func(ctx context.Context, tracker *worker.ProgressTracker) (bool, error) {
return pusher.PushImage(ctx, image, destination, tracker)
},
destination: destination,
targetLabel: targetLabel,
reporter: reporter,
}
}
// NewLocalOciPushPlan pushes an image that only exists in the local docker
// daemon. Targets that skip the cache never stage their oci outputs, so there
// is no cached copy for the daemon-free path to read.
func NewLocalOciPushPlan(pusher ImagePusher, localTag, destination, targetLabel string, reporter *PushReporter) *OciPushPlan {
return &OciPushPlan{
push: func(ctx context.Context, tracker *worker.ProgressTracker) (bool, error) {
return pusher.PushLocalImage(ctx, localTag, destination, tracker)
},
destination: destination,
targetLabel: targetLabel,
reporter: reporter,
}
}
func (p *OciPushPlan) Execute(ctx context.Context, tracker *worker.ProgressTracker) error {
logger := console.GetLogger(ctx)
if p.reporter.Aborted() {
err := fmt.Errorf("aborted after earlier push failure (--fail-fast)")
p.reporter.Record(PushReport{TargetLabel: p.targetLabel, Destination: p.destination, Err: err})
return err
}
tracker.SetStatus(fmt.Sprintf("%s: pushing %s", p.targetLabel, p.destination))
skipped, err := p.push(ctx, tracker)
p.reporter.Record(PushReport{
TargetLabel: p.targetLabel,
Destination: p.destination,
Skipped: skipped,
Err: err,
})
if err != nil {
logger.Warnf("%s: push to %s failed: %v", p.targetLabel, p.destination, err)
}
return err
}
func (p *OciPushPlan) Cleanup(_ context.Context) error { return nil }
package handlers
import (
"sort"
"sync"
"sync/atomic"
"github.com/chrismatix/grog/internal/console"
)
// PushReport is the outcome of one push. Skipped means the HEAD probe matched.
type PushReport struct {
TargetLabel string
Destination string
Skipped bool
Err error
}
// PushReporter aggregates PushReport entries and carries the --fail-fast abort.
type PushReporter struct {
failFast func() bool
mu sync.Mutex
entries []PushReport
aborted atomic.Bool
}
func NewPushReporter(failFast func() bool) *PushReporter {
if failFast == nil {
failFast = func() bool { return false }
}
return &PushReporter{failFast: failFast}
}
func (p *PushReporter) Record(report PushReport) {
if p == nil {
return
}
p.mu.Lock()
p.entries = append(p.entries, report)
p.mu.Unlock()
if report.Err != nil && p.failFast() {
p.aborted.Store(true)
}
}
// Aborted is true once a fail-fast failure has tripped the abort.
func (p *PushReporter) Aborted() bool {
if p == nil {
return false
}
return p.aborted.Load()
}
func (p *PushReporter) Reports() []PushReport {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
out := make([]PushReport, len(p.entries))
copy(out, p.entries)
sort.Slice(out, func(i, j int) bool {
if out[i].TargetLabel != out[j].TargetLabel {
return out[i].TargetLabel < out[j].TargetLabel
}
return out[i].Destination < out[j].Destination
})
return out
}
func (p *PushReporter) HasFailures() bool {
if p == nil {
return false
}
p.mu.Lock()
defer p.mu.Unlock()
for _, r := range p.entries {
if r.Err != nil {
return true
}
}
return false
}
// RenderSummary prints the per-push counts plus one line per entry and
// returns whether any push failed.
func (p *PushReporter) RenderSummary(logger *console.Logger) bool {
if p == nil {
return false
}
reports := p.Reports()
if len(reports) == 0 {
return false
}
var pushed, skipped, failed int
for _, r := range reports {
switch {
case r.Err != nil:
failed++
case r.Skipped:
skipped++
default:
pushed++
}
}
logger.Infof("Pushes: %d pushed, %d already current, %d failed.", pushed, skipped, failed)
for _, r := range reports {
switch {
case r.Err != nil:
logger.Errorf(" FAILED %s -> %s: %v", r.TargetLabel, r.Destination, r.Err)
case r.Skipped:
logger.Infof(" CURRENT %s -> %s", r.TargetLabel, r.Destination)
default:
logger.Infof(" PUSHED %s -> %s", r.TargetLabel, r.Destination)
}
}
return failed > 0
}
package handlers
import (
"context"
"github.com/chrismatix/grog/internal/worker"
)
type nopWritePlan struct{}
func (nopWritePlan) Execute(context.Context, *worker.ProgressTracker) error {
return nil
}
func (nopWritePlan) Cleanup(context.Context) error {
return nil
}
// NewNopWritePlan returns a plan that does not perform any persistence work.
func NewNopWritePlan() OutputWritePlan {
return nopWritePlan{}
}
package output
import (
"fmt"
"slices"
"strings"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output/handlers"
)
func ParseOutputs(outputs []string) ([]model.Output, error) {
var parsedOutputs []model.Output
for _, outputDefinition := range outputs {
parsedOutput, err := ParseOutput(outputDefinition)
if err != nil {
return nil, err
}
parsedOutputs = append(parsedOutputs, parsedOutput)
}
return parsedOutputs, nil
}
// ParseOutput takes a raw output string and determines its type and reference.
func ParseOutput(outputStr string) (model.Output, error) {
// Handle the default case (regular file) if no :: is present
if !strings.Contains(outputStr, "::") {
return model.NewOutput(string(handlers.FileHandler), outputStr), nil
}
// Parse for output with explicit type
parts := strings.SplitN(outputStr, "::", 2)
if len(parts) != 2 {
return model.Output{}, fmt.Errorf("invalid output format: %s", outputStr)
}
outputType := handlers.HandlerType(parts[0])
identifier := parts[1]
isKnownType := slices.Contains(handlers.KnownHandlerTypes, outputType)
if !isKnownType {
return model.Output{}, fmt.Errorf("unknown output type: %s", outputType)
}
return model.NewOutput(string(outputType), identifier), nil
}
// Package push turns a target's oci_push map into the work that ships its
// images. Keeping it out of the output registry means a push is triggered by
// the completion path a target took, not by the fact that its outputs were
// touched — loading a dependency's outputs is not a completion.
package push
import (
"context"
"fmt"
"maps"
"slices"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output/handlers"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
)
// Pusher owns everything --push: which images ship where, and the summary the
// build prints once it is done.
type Pusher struct {
images handlers.ImagePusher
reporter *handlers.PushReporter
enabled func() bool
}
// New wires a Pusher to the oci output handler. An oci handler that cannot
// push is a wiring mistake in grog itself, not a user error.
func New(ociHandler handlers.Handler, enabled, failFast func() bool) (*Pusher, error) {
images, ok := ociHandler.(handlers.ImagePusher)
if !ok {
return nil, fmt.Errorf("oci handler %T does not implement handlers.ImagePusher", ociHandler)
}
if enabled == nil {
enabled = func() bool { return false }
}
return &Pusher{
images: images,
reporter: handlers.NewPushReporter(failFast),
enabled: enabled,
}, nil
}
// Reporter aggregates every push outcome for the end-of-build summary.
func (p *Pusher) Reporter() *handlers.PushReporter {
if p == nil {
return nil
}
return p.reporter
}
// Enabled reports whether --push is on. A nil Pusher never pushes, so callers
// that do not wire one keep working.
func (p *Pusher) Enabled() bool {
return p != nil && p.enabled()
}
// PlansFromCache returns one plan per (oci output, destination), each sourcing
// the image grog staged in the cache. A key matching no oci:: output is a
// recipe bug, so it fails the target rather than landing in the push summary.
func (p *Pusher) PlansFromCache(target *model.Target, outputs []*gen.Output) ([]handlers.OutputWritePlan, error) {
if !p.Enabled() {
return nil, nil
}
var plans []handlers.OutputWritePlan
for _, destination := range destinationsOf(target) {
image := findOciImageByLocalTag(outputs, destination.localName)
if image == nil {
return nil, fmt.Errorf("%s: oci_push key %q does not match any oci:: output", target.Label, destination.localName)
}
plans = append(plans, handlers.NewOciPushPlan(
p.images, image, destination.reference, target.Label.String(), p.reporter,
))
}
return plans, nil
}
// PlansFromDaemon sources straight from the local docker daemon: targets that
// skip the cache never stage their oci outputs, so the plans from
// PlansFromCache would have nothing to read.
func (p *Pusher) PlansFromDaemon(target *model.Target) []handlers.OutputWritePlan {
if !p.Enabled() {
return nil
}
var plans []handlers.OutputWritePlan
for _, destination := range destinationsOf(target) {
plans = append(plans, handlers.NewLocalOciPushPlan(
p.images, destination.localName, destination.reference, target.Label.String(), p.reporter,
))
}
return plans
}
// PushFromCache ships a cache-hit target's destinations inline. The image comes
// from the cache, so this runs whether or not the outputs were loaded. A
// registry hiccup lands in the reporter rather than invalidating the cache
// restore, so only a malformed oci_push map returns an error.
func (p *Pusher) PushFromCache(
ctx context.Context,
target *model.Target,
targetResult *gen.TargetResult,
progress *worker.ProgressTracker,
) error {
plans, err := p.PlansFromCache(target, targetResult.GetOutputs())
if err != nil {
return err
}
for _, plan := range plans {
_ = plan.Execute(ctx, progress)
}
return nil
}
type destination struct {
localName string
reference string
}
// destinationsOf flattens target.OciPush in a stable order, so neither the
// order pushes run in nor the summary that reports them depends on map
// iteration order.
func destinationsOf(target *model.Target) []destination {
var destinations []destination
for _, localName := range slices.Sorted(maps.Keys(target.OciPush)) {
for _, reference := range target.OciPush[localName] {
destinations = append(destinations, destination{localName: localName, reference: reference})
}
}
return destinations
}
func findOciImageByLocalTag(outputs []*gen.Output, localTag string) *gen.OCIImageOutput {
for _, output := range outputs {
image := output.GetOciImage()
if image == nil {
continue
}
if image.GetLocalTag() == localTag {
return image
}
}
return nil
}
package output
import (
"context"
"errors"
"fmt"
stdmaps "maps"
"github.com/chrismatix/grog/internal/caching"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
"github.com/chrismatix/grog/internal/hashing"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"sync"
"github.com/chrismatix/grog/internal/maps"
"github.com/chrismatix/grog/internal/model"
"github.com/chrismatix/grog/internal/output/handlers"
"github.com/chrismatix/grog/internal/proto/gen"
"github.com/chrismatix/grog/internal/worker"
"github.com/alitto/pond/v2"
)
// PreparedTargetResult contains the target result and any prepared write plans.
type PreparedTargetResult struct {
TargetResult *gen.TargetResult
WritePlans []handlers.OutputWritePlan
}
// Registry manages the available output handlers.
type Registry struct {
handlers map[string]handlers.Handler
cas *caching.Cas
pool pond.Pool
handlerMutex sync.RWMutex
// Features like load_outputs=minimal may load outputs concurrently
// In this case we want to make sure that only happens once per target
targetMutexMap *maps.MutexMap
hashMutex sync.RWMutex
hashCache map[string]string
outputHashMutex sync.RWMutex
outputHashCache map[string]map[model.Output]string
}
// NewRegistry creates a new registry with default handlers.
func NewRegistry(
ctx context.Context,
cas *caching.Cas,
) *Registry {
r := &Registry{
handlers: make(map[string]handlers.Handler),
targetMutexMap: maps.NewMutexMap(),
hashCache: make(map[string]string),
outputHashCache: make(map[string]map[model.Output]string),
pool: pond.NewPool(
runtime.NumCPU() * 2,
),
}
r.Register(handlers.NewFileOutputHandler(cas))
r.Register(handlers.NewDirectoryOutputHandler(cas))
if config.Global.OCI.Backend == "registry" {
r.Register(handlers.NewDockerRegistryOutputHandler(cas, config.Global.OCI))
} else {
r.Register(handlers.NewDockerOutputHandler(ctx, cas, config.Global.OCI.InsecureRegistries))
}
return r
}
// SnapshotOciImages records the image id the local docker daemon holds for
// each of the target's oci:: outputs. Outputs the daemon cannot resolve are
// left out entirely.
func (r *Registry) SnapshotOciImages(ctx context.Context, target *model.Target) map[string]string {
snapshot := make(map[string]string)
for _, outputRef := range target.AllOutputs() {
if outputRef.Type != string(handlers.OCIHandler) {
continue
}
imageID, err := r.mustGetHandler(outputRef.Type).Hash(ctx, *target, outputRef)
if err != nil {
continue
}
snapshot[outputRef.Identifier] = imageID
}
return snapshot
}
// WarnOnUnproducedOciImages flags oci:: outputs the command left exactly as it
// found them. Grog would go on to cache — and under --push ship — an image the
// build never produced, which is near-impossible to spot from the outside.
func (r *Registry) WarnOnUnproducedOciImages(ctx context.Context, target *model.Target, before map[string]string) {
after := r.SnapshotOciImages(ctx, target)
logger := console.GetLogger(ctx)
for _, identifier := range slices.Sorted(stdmaps.Keys(before)) {
if after[identifier] != before[identifier] {
continue
}
logger.Warnf(
"%s: oci output %s is unchanged after running the command. "+
"If the command does not load its result into the local Docker daemon "+
"(e.g. buildx with a docker-container or cloud driver and no --load), "+
"grog caches and pushes the image that was already there.",
target.Label, identifier,
)
}
}
// Close releases resources held by output handlers (notably the in-process
// loopback Docker registry). Must be called only after all async cache writes
// have drained — otherwise an in-flight `docker push` may race the proxy
// shutdown and fail with "connection refused".
func (r *Registry) Close() error {
r.handlerMutex.RLock()
defer r.handlerMutex.RUnlock()
var errs []error
for _, h := range r.handlers {
closer, ok := h.(io.Closer)
if !ok {
continue
}
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Register adds a new output handler to the registry.
func (r *Registry) Register(handler handlers.Handler) {
r.handlerMutex.Lock()
defer r.handlerMutex.Unlock()
if _, ok := r.handlers[string(handler.Type())]; ok {
panic(fmt.Sprintf("handler for type %s already registered", handler.Type()))
}
r.handlers[string(handler.Type())] = handler
}
// Handler exposes a registered handler so callers can use capabilities the
// registry itself has no business driving, such as pushing oci images.
func (r *Registry) Handler(outputType handlers.HandlerType) (handlers.Handler, bool) {
r.handlerMutex.RLock()
defer r.handlerMutex.RUnlock()
handler, exists := r.handlers[string(outputType)]
return handler, exists
}
// GetHandler retrieves a handler by type.
func (r *Registry) mustGetHandlerFromProto(output *gen.Output) handlers.Handler {
var outputType string
switch output.Kind.(type) {
case *gen.Output_File:
outputType = string(handlers.FileHandler)
case *gen.Output_Directory:
outputType = string(handlers.DirHandler)
case *gen.Output_OciImage:
outputType = string(handlers.OCIHandler)
default:
panic(fmt.Errorf("unknown output kind: %T", output.Kind))
}
return r.mustGetHandler(outputType)
}
// GetHandler retrieves a handler by type.
func (r *Registry) mustGetHandler(outputType string) handlers.Handler {
r.handlerMutex.RLock()
defer r.handlerMutex.RUnlock()
handler, exists := r.handlers[outputType]
if !exists {
panic(fmt.Sprintf("handler for type %s not registered", outputType))
}
return handler
}
func (r *Registry) PrepareOutputs(
ctx context.Context,
target *model.Target,
progress *worker.ProgressTracker,
) (*PreparedTargetResult, error) {
r.targetMutexMap.Lock(target.Label.String())
defer func() { _ = r.targetMutexMap.Unlock(target.Label.String()) }()
logger := console.GetLogger(ctx)
outputs := target.AllOutputs()
logger.Debugf("%s: writing outputs", target.Label)
var tasks []pond.Task
var preparedOutputs []*handlers.PreparedOutput
var outputsMutex sync.Mutex
for _, outputRef := range outputs {
localOutputRef := outputRef
task := r.pool.SubmitErr(func() error {
result, err := r.mustGetHandler(localOutputRef.Type).Write(ctx, *target, localOutputRef, progress)
if err != nil {
return err
}
outputsMutex.Lock()
preparedOutputs = append(preparedOutputs, result)
outputsMutex.Unlock()
logger.Debugf("%s: output %s written", target.Label, localOutputRef.Type)
return nil
})
tasks = append(tasks, task)
}
for _, task := range tasks {
if err := task.Wait(); err != nil {
return nil, err
}
}
// Extract outputs and collect write plans.
targetOutputs := make([]*gen.Output, 0, len(preparedOutputs))
var writePlans []handlers.OutputWritePlan
for _, preparedOutput := range preparedOutputs {
targetOutputs = append(targetOutputs, preparedOutput.Output)
if preparedOutput.WritePlan != nil {
writePlans = append(writePlans, preparedOutput.WritePlan)
}
}
outputHash, err := getOutputHash(targetOutputs)
if err != nil {
return nil, err
}
if err := ensureBinOutputExecutable(target); err != nil {
return nil, err
}
logger.Debugf("%s: outputs written", target.Label)
return &PreparedTargetResult{
TargetResult: &gen.TargetResult{
ChangeHash: target.ChangeHash,
OutputHash: outputHash,
Outputs: targetOutputs,
ExecutionDurationMillis: target.ExecutionTime.Milliseconds(),
},
WritePlans: writePlans,
}, nil
}
// GetNoCacheOutputHash computes the output hash for a target when target caching is disabled
// using handler.GetHash() on local resources only.
func (r *Registry) GetNoCacheOutputHash(ctx context.Context, target *model.Target) (*gen.TargetResult, error) {
outputs := target.AllOutputs()
var tasks []pond.Task
var digests []string
var outputsMutex sync.Mutex
for _, outputRef := range outputs {
localOutputRef := outputRef
task := r.pool.SubmitErr(func() error {
outputDigest, err := r.mustGetHandler(localOutputRef.Type).Hash(ctx, *target, localOutputRef)
if err != nil {
return err
}
outputsMutex.Lock()
digests = append(digests, outputDigest)
outputsMutex.Unlock()
return nil
})
tasks = append(tasks, task)
}
for _, task := range tasks {
if err := task.Wait(); err != nil {
return nil, err
}
}
if err := ensureBinOutputExecutable(target); err != nil {
return nil, err
}
return &gen.TargetResult{
ChangeHash: target.ChangeHash,
OutputHash: hashing.HashStrings(digests),
ExecutionDurationMillis: target.ExecutionTime.Milliseconds(),
}, nil
}
// LoadOutputs loads the outputs for a target once using the cached targetResult.
func (r *Registry) LoadOutputs(
ctx context.Context,
target *model.Target,
targetResult *gen.TargetResult,
progress *worker.ProgressTracker,
) error {
r.targetMutexMap.Lock(target.Label.String())
defer func() { _ = r.targetMutexMap.Unlock(target.Label.String()) }()
if target.OutputsLoaded {
// Outputs are already loaded, nothing to do
return nil
}
if err := validateTargetResultOutputs(target, targetResult); err != nil {
return err
}
logger := console.GetLogger(ctx)
logger.Debugf("%s: loading outputs", target.Label)
var tasks []pond.Task
for _, outputRef := range targetResult.Outputs {
localOutputRef := outputRef
logger.Debugf("%s: loading output %s", target.Label, localOutputRef.GetKind())
task := r.pool.SubmitErr(func() error {
err := r.mustGetHandlerFromProto(localOutputRef).Load(ctx, *target, localOutputRef, progress)
if err != nil {
return err
}
return nil
})
tasks = append(tasks, task)
}
for _, task := range tasks {
if err := task.Wait(); err != nil {
return err
}
}
if err := ensureBinOutputExecutable(target); err != nil {
return err
}
logger.Debugf("%s: outputs loaded", target.Label)
target.OutputsLoaded = true
target.OutputHash = targetResult.OutputHash
return nil
}
// ensureBinOutputExecutable marks a target's bin_output 0755 on disk.
// The CAS doesn't track file mode, and a user's build command may not chmod
// the binary itself, so the Registry guarantees this post-condition at every
// boundary that produces outputs (fresh build, no-cache build, cache restore)
// — see issue #155.
func ensureBinOutputExecutable(target *model.Target) error {
if !target.HasBinOutput() {
return nil
}
binOutputPath := config.GetPathAbsoluteToWorkspaceRoot(
filepath.Join(target.Label.Package, target.BinOutput.Identifier),
)
if err := os.Chmod(binOutputPath, 0755); err != nil {
return fmt.Errorf("failed to mark bin_output executable for %s: %w", target.Label, err)
}
return nil
}
func validateTargetResultOutputs(target *model.Target, targetResult *gen.TargetResult) error {
if targetResult == nil {
return fmt.Errorf("%s: cached target result is nil", target.Label)
}
expectedOutputDefinitions := target.OutputDefinitions()
loadedOutputDefinitions, err := getOutputDefinitionsFromProto(targetResult.Outputs)
if err != nil {
return fmt.Errorf("%s: invalid output entry in cached target result: %w", target.Label, err)
}
if len(expectedOutputDefinitions) != len(loadedOutputDefinitions) {
return fmt.Errorf(
"%s: cached outputs mismatch: expected %d outputs %v, got %d outputs %v",
target.Label,
len(expectedOutputDefinitions),
expectedOutputDefinitions,
len(loadedOutputDefinitions),
loadedOutputDefinitions,
)
}
sortedExpectedOutputDefinitions := slices.Clone(expectedOutputDefinitions)
sortedLoadedOutputDefinitions := slices.Clone(loadedOutputDefinitions)
slices.Sort(sortedExpectedOutputDefinitions)
slices.Sort(sortedLoadedOutputDefinitions)
if !slices.Equal(sortedExpectedOutputDefinitions, sortedLoadedOutputDefinitions) {
return fmt.Errorf(
"%s: cached outputs mismatch: expected outputs %v, got outputs %v",
target.Label,
expectedOutputDefinitions,
loadedOutputDefinitions,
)
}
return nil
}
func getOutputDefinitionsFromProto(outputs []*gen.Output) ([]string, error) {
outputDefinitions := make([]string, 0, len(outputs))
for outputIndex, output := range outputs {
outputDefinition, err := getOutputDefinitionFromProto(output)
if err != nil {
return nil, fmt.Errorf("entry %d: %w", outputIndex, err)
}
outputDefinitions = append(outputDefinitions, outputDefinition)
}
return outputDefinitions, nil
}
func getOutputDefinitionFromProto(output *gen.Output) (string, error) {
if output == nil {
return "", fmt.Errorf("output is nil")
}
switch outputKind := output.Kind.(type) {
case *gen.Output_File:
return model.NewOutput(string(handlers.FileHandler), outputKind.File.GetPath()).String(), nil
case *gen.Output_Directory:
return model.NewOutput(string(handlers.DirHandler), outputKind.Directory.GetPath()).String(), nil
case *gen.Output_OciImage:
return model.NewOutput(string(handlers.OCIHandler), outputKind.OciImage.GetLocalTag()).String(), nil
default:
return "", fmt.Errorf("unknown output kind: %T", output.Kind)
}
}
package resolvers
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"github.com/pelletier/go-toml/v2"
)
type cargoDependencyTables struct {
Dependencies map[string]any `toml:"dependencies"`
BuildDependencies map[string]any `toml:"build-dependencies"`
}
type cargoManifest struct {
Workspace struct {
Members []string `toml:"members"`
Exclude []string `toml:"exclude"`
Dependencies map[string]any `toml:"dependencies"`
} `toml:"workspace"`
Package struct {
Name string `toml:"name"`
Include []string `toml:"include"`
} `toml:"package"`
Lib struct {
Path string `toml:"path"`
} `toml:"lib"`
Bin []struct {
Path string `toml:"path"`
} `toml:"bin"`
Dependencies map[string]any `toml:"dependencies"`
BuildDependencies map[string]any `toml:"build-dependencies"`
Target map[string]cargoDependencyTables `toml:"target"`
}
func cargoDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
workspaceManifest, operationError := readCargoManifest(resolverContext, filepath.Join(workspaceDirectory, "Cargo.toml"))
if operationError != nil {
return document, operationError
}
excluded := make(map[string]bool)
for _, pattern := range workspaceManifest.Workspace.Exclude {
matches, operationError := filepath.Glob(filepath.Join(workspaceDirectory, pattern))
if operationError != nil {
return document, fmt.Errorf("invalid cargo workspace exclude glob %q: %w", pattern, operationError)
}
for _, excludedDirectory := range matches {
excluded[filepath.Clean(excludedDirectory)] = true
}
}
members := make(map[string]string)
// A root manifest with a [package] table is a member without being listed.
if workspaceManifest.Package.Name != "" {
members[filepath.Clean(workspaceDirectory)] = ""
}
for _, pattern := range workspaceManifest.Workspace.Members {
matches, operationError := filepath.Glob(filepath.Join(workspaceDirectory, pattern))
if operationError != nil {
return document, fmt.Errorf("invalid cargo workspace member glob %q: %w", pattern, operationError)
}
for _, memberDirectory := range matches {
if excluded[filepath.Clean(memberDirectory)] {
continue
}
relativePath, operationError := filepath.Rel(workspaceDirectory, memberDirectory)
if operationError != nil {
return document, fmt.Errorf("resolve cargo workspace member %s: %w", memberDirectory, operationError)
}
if relativePath == "." {
relativePath = ""
}
members[filepath.Clean(memberDirectory)] = filepath.ToSlash(relativePath)
}
}
for memberDirectory, memberPath := range members {
manifest, operationError := readCargoManifest(resolverContext, filepath.Join(memberDirectory, "Cargo.toml"))
if operationError != nil {
return document, operationError
}
reportedPackage := Package{Dependencies: []string{}, Inputs: cargoInputs(manifest), ExcludeInputs: cargoExcludeInputs}
dependencyTables := []map[string]any{manifest.Dependencies, manifest.BuildDependencies}
for _, conditional := range manifest.Target {
dependencyTables = append(dependencyTables, conditional.Dependencies, conditional.BuildDependencies)
}
for _, dependencies := range dependencyTables {
for dependencyName, dependency := range dependencies {
dependencyDirectory, isPathDependency := cargoDependencyDirectory(dependencyName, dependency, memberDirectory, workspaceManifest, workspaceDirectory)
if !isPathDependency {
continue
}
if dependencyMember, isMember := members[dependencyDirectory]; isMember && dependencyMember != memberPath {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyMember)
}
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[memberPath] = reportedPackage
}
return document, resolverContext.Err()
}
// cargoDependencyDirectory returns the directory a path dependency points at.
// `workspace = true` entries take their path from the root manifest's
// [workspace.dependencies] table, relative to the workspace root.
func cargoDependencyDirectory(dependencyName string, dependency any, memberDirectory string, workspaceManifest cargoManifest, workspaceDirectory string) (string, bool) {
declaration, isTable := dependency.(map[string]any)
if !isTable {
return "", false
}
baseDirectory := memberDirectory
if inherited, isInherited := declaration["workspace"].(bool); isInherited && inherited {
declaration, isTable = workspaceManifest.Workspace.Dependencies[dependencyName].(map[string]any)
if !isTable {
return "", false
}
baseDirectory = workspaceDirectory
}
dependencyPath, hasPath := declaration["path"].(string)
if !hasPath {
return "", false
}
return filepath.Clean(filepath.Join(baseDirectory, dependencyPath)), true
}
// cargoDefaultInputs are the files the built-in reads: the root manifest and
// lockfile plus a manifest under every member pattern, however deep it is.
func cargoDefaultInputs(_ context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"Cargo.toml", "Cargo.lock"}
contents, operationError := os.ReadFile(filepath.Join(workspaceDirectory, "Cargo.toml"))
if operationError != nil {
return inputs, cargoExcludeInputs
}
var manifest cargoManifest
if operationError := toml.Unmarshal(contents, &manifest); operationError != nil {
return inputs, cargoExcludeInputs
}
for _, pattern := range manifest.Workspace.Members {
inputs = append(inputs, path.Join(pattern, "Cargo.toml"))
}
return inputs, cargoExcludeInputs
}
// cargoExcludeInputs keeps the globs out of the crate's or workspace's output directory.
var cargoExcludeInputs = []string{"target/**"}
// cargoInputs over-approximates on purpose: a listed file the crate lacks is
// skipped when hashing, while one left out would under-invalidate silently.
func cargoInputs(manifest cargoManifest) []string {
inputs := []string{"Cargo.toml", "build.rs", "src/**/*", "tests/**/*", "benches/**/*", "examples/**/*"}
if manifest.Lib.Path != "" {
inputs = append(inputs, manifest.Lib.Path)
}
for _, binary := range manifest.Bin {
if binary.Path != "" {
inputs = append(inputs, binary.Path)
}
}
inputs = append(inputs, manifest.Package.Include...)
slices.Sort(inputs)
return slices.Compact(inputs)
}
func readCargoManifest(resolverContext context.Context, manifestPath string) (cargoManifest, error) {
var manifest cargoManifest
if operationError := resolverContext.Err(); operationError != nil {
return manifest, operationError
}
contents, operationError := os.ReadFile(manifestPath)
if operationError != nil {
return manifest, fmt.Errorf("read cargo manifest %s: %w", manifestPath, operationError)
}
if operationError := toml.Unmarshal(contents, &manifest); operationError != nil {
return manifest, fmt.Errorf("parse cargo manifest %s: %w", manifestPath, operationError)
}
return manifest, nil
}
package resolvers
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
"maps"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
type dotnetProjectFile struct {
Imports []dotnetImport
ProjectReferences []string
}
type dotnetImport struct {
Project string `xml:"Project,attr"`
}
type dotnetSolutionFolder struct {
Projects []struct {
Path string `xml:"Path,attr"`
} `xml:"Project"`
Folders []dotnetSolutionFolder `xml:"Folder"`
}
var dotnetSolutionProjectLine = regexp.MustCompile(`^Project\("\{[^"]*\}"\)\s*=\s*"[^"]*",\s*"([^"]+)"`)
var dotnetProjectExtensions = []string{".csproj", ".fsproj", ".vbproj"}
// dotnetConfigurationFiles are imported by every project below their
// directory, which a member's own inputs cannot reach.
var dotnetConfigurationFiles = []string{"directory.build.props", "directory.build.targets", "directory.packages.props", "global.json", "nuget.config"}
var dotnetConfigurationFileNames = []string{"Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json", "nuget.config", "NuGet.Config"}
// dotnetExcludeInputs keeps every glob out of build outputs.
var dotnetExcludeInputs = []string{"**/bin/**", "**/obj/**"}
// dotnetDependencies maps every project to the projects its ProjectReference
// items name, following references to projects the solution does not list.
// Every project also depends on each ancestor directory holding configuration
// files MSBuild imports from above the project, the root included.
func dotnetDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
projectFiles, operationError := dotnetProjectFiles(resolverContext, workspaceDirectory)
if operationError != nil {
return document, operationError
}
configurationDirectories, operationError := dotnetConfigurationDirectories(resolverContext, workspaceDirectory)
if operationError != nil {
return document, operationError
}
for directory, files := range configurationDirectories {
document.Packages[directory] = Package{Dependencies: []string{}, Inputs: files, ExcludeInputs: dotnetExcludeInputs}
}
visited := make(map[string]bool)
projectDirectories := make(map[string]bool)
configurationDependenciesByProject := make(map[string][]string)
for queue := projectFiles; len(queue) > 0; queue = queue[1:] {
projectFile := queue[0]
if visited[projectFile] {
continue
}
visited[projectFile] = true
if operationError := resolverContext.Err(); operationError != nil {
return document, operationError
}
directory := dotnetPackagePath(path.Dir(projectFile))
reportedPackage, exists := document.Packages[directory]
if _, isConfigurationDirectory := configurationDirectories[directory]; !exists || isConfigurationDirectory {
reportedPackage = Package{Dependencies: reportedPackage.Dependencies, Inputs: []string{"**/*"}, ExcludeInputs: dotnetExcludeInputs, Fingerprint: reportedPackage.Fingerprint}
}
projectDirectories[directory] = true
configurationDependencies, configurationFiles := dotnetConfigurationReferences(directory, configurationDirectories)
configurationDependenciesByProject[directory] = configurationDependencies
reportedPackage.Dependencies = append(reportedPackage.Dependencies, configurationDependencies...)
importedFiles, references, _, operationError := dotnetImportedFiles(resolverContext, workspaceDirectory, append([]string{projectFile}, configurationFiles...))
if operationError != nil {
return document, operationError
}
fingerprintFiles := append([]string{projectFile}, configurationFiles...)
fingerprintFiles = append(fingerprintFiles, importedFiles...)
slices.Sort(fingerprintFiles)
fingerprintFiles = slices.Compact(fingerprintFiles)
if len(fingerprintFiles) > 0 {
fingerprint, operationError := dotnetFilesFingerprint(workspaceDirectory, fingerprintFiles)
if operationError != nil {
return document, operationError
}
if reportedPackage.Fingerprint == nil {
reportedPackage.Fingerprint = make(map[string]string)
}
reportedPackage.Fingerprint["imports:"+path.Base(projectFile)] = fingerprint
}
for _, reference := range references {
referencedFiles, operationError := dotnetReferencedFiles(workspaceDirectory, path.Join(path.Dir(projectFile), reference))
if operationError != nil {
return document, operationError
}
for _, referencedFile := range referencedFiles {
queue = append(queue, referencedFile)
if referencedDirectory := dotnetPackagePath(path.Dir(referencedFile)); referencedDirectory != directory {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, referencedDirectory)
}
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[directory] = reportedPackage
}
if projectDirectory, descendantDirectory, found := dotnetConfigurationCycle(document, projectDirectories, configurationDependenciesByProject); found {
return document, fmt.Errorf("dotnet project package %q references descendant package %q, creating a cycle through shared configuration", projectDirectory, descendantDirectory)
}
return document, nil
}
// dotnetConfigurationReferences returns effective configuration directories
// and source files above a project directory.
func dotnetConfigurationReferences(directory string, configurationDirectories map[string][]string) (dependencies []string, sources []string) {
directories := slices.Collect(maps.Keys(configurationDirectories))
slices.SortFunc(directories, func(first, second string) int { return len(second) - len(first) })
selected := make(map[string]bool)
for _, configurationDirectory := range directories {
if configurationDirectory != directory && configurationDirectory != "" && !strings.HasPrefix(directory, configurationDirectory+"/") {
continue
}
usesDirectory := false
for _, file := range configurationDirectories[configurationDirectory] {
lowerName := strings.ToLower(file)
if !slices.Contains(dotnetConfigurationFiles, lowerName) || (lowerName != "nuget.config" && selected[lowerName]) {
continue
}
selected[lowerName] = true
usesDirectory = true
if lowerName == "directory.build.props" || lowerName == "directory.build.targets" || lowerName == "directory.packages.props" {
sources = append(sources, path.Join(configurationDirectory, file))
}
}
if usesDirectory && configurationDirectory != directory {
dependencies = append(dependencies, configurationDirectory)
}
}
slices.Sort(dependencies)
return slices.Compact(dependencies), sources
}
func dotnetConfigurationCycle(document Document, projectDirectories map[string]bool, configurationDependenciesByProject map[string][]string) (string, string, bool) {
for _, directory := range slices.Sorted(maps.Keys(document.Packages)) {
for _, dependency := range configurationDependenciesByProject[directory] {
if !projectDirectories[dependency] {
continue
}
visited := map[string]bool{dependency: true}
for queue := []string{dependency}; len(queue) > 0; queue = queue[1:] {
for _, reachable := range document.Packages[queue[0]].Dependencies {
if reachable == directory {
return dependency, directory, true
}
if !visited[reachable] {
visited[reachable] = true
queue = append(queue, reachable)
}
}
}
}
}
return "", "", false
}
// dotnetReferencedFiles resolves one reference, which may be a glob, to the
// project files it names inside the workspace. A reference to a project that
// does not exist is dropped, as MSBuild's Condition="Exists(...)" would.
func dotnetReferencedFiles(workspaceDirectory string, reference string) ([]string, error) {
reference = path.Clean(reference)
if reference == ".." || strings.HasPrefix(reference, "../") {
return nil, nil
}
if strings.ContainsAny(reference, "*?") {
return doublestar.Glob(os.DirFS(workspaceDirectory), reference, doublestar.WithFilesOnly())
}
if _, operationError := os.Stat(filepath.Join(workspaceDirectory, filepath.FromSlash(reference))); operationError != nil {
if errors.Is(operationError, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("stat project reference %s: %w", reference, operationError)
}
return []string{reference}, nil
}
func dotnetPackagePath(directory string) string {
if directory == "." {
return ""
}
return directory
}
// dotnetConfigurationDirectories maps every directory holding a file MSBuild
// imports into the projects below it to those files. The root is always
// present, since it also holds the solution files.
func dotnetConfigurationDirectories(resolverContext context.Context, workspaceDirectory string) (map[string][]string, error) {
directories := map[string][]string{"": {"*.sln", "*.slnx"}}
operationError := filepath.WalkDir(workspaceDirectory, func(filePath string, entry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if operationError := resolverContext.Err(); operationError != nil {
return operationError
}
if entry.IsDir() && (entry.Name() == "bin" || entry.Name() == "obj") {
return filepath.SkipDir
}
if entry.IsDir() || !slices.Contains(dotnetConfigurationFileNames, entry.Name()) {
return nil
}
relativePath, operationError := filepath.Rel(workspaceDirectory, filePath)
if operationError != nil {
return operationError
}
directory := dotnetPackagePath(path.Dir(filepath.ToSlash(relativePath)))
directories[directory] = append(directories[directory], entry.Name())
return nil
})
for _, files := range directories {
slices.Sort(files)
}
return directories, operationError
}
func readDotnetProjectFile(projectPath string) (dotnetProjectFile, error) {
var project dotnetProjectFile
contents, operationError := os.ReadFile(projectPath)
if operationError != nil {
return project, fmt.Errorf("read %s: %w", projectPath, operationError)
}
if operationError := xml.Unmarshal(contents, &project); operationError != nil {
return project, fmt.Errorf("parse %s: %w", projectPath, operationError)
}
return project, nil
}
func (project *dotnetProjectFile) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error {
for {
token, operationError := decoder.Token()
if operationError != nil {
return operationError
}
switch element := token.(type) {
case xml.StartElement:
for _, attribute := range element.Attr {
switch {
case element.Name.Local == "Import" && attribute.Name.Local == "Project":
project.Imports = append(project.Imports, dotnetImport{Project: attribute.Value})
case element.Name.Local == "ProjectReference" && attribute.Name.Local == "Include":
project.ProjectReferences = append(project.ProjectReferences, attribute.Value)
}
}
case xml.EndElement:
if element.Name == start.Name {
return nil
}
}
}
}
func dotnetImportedFiles(resolverContext context.Context, workspaceDirectory string, sourceFiles []string) ([]string, []string, []string, error) {
visited := make(map[string]bool, len(sourceFiles))
queue := slices.Clone(sourceFiles)
for _, sourceFile := range sourceFiles {
visited[sourceFile] = true
}
var importedFiles []string
var references []string
var importPatterns []string
for len(queue) > 0 {
if operationError := resolverContext.Err(); operationError != nil {
return nil, nil, nil, operationError
}
sourceFile := queue[0]
queue = queue[1:]
project, operationError := readDotnetProjectFile(filepath.Join(workspaceDirectory, filepath.FromSlash(sourceFile)))
if operationError != nil {
return nil, nil, nil, operationError
}
for _, projectReference := range project.ProjectReferences {
for include := range strings.SplitSeq(projectReference, ";") {
include = strings.TrimSpace(strings.ReplaceAll(include, "\\", "/"))
if include != "" && !strings.Contains(include, "$(") {
references = append(references, include)
}
}
}
for _, imported := range project.Imports {
matches, importPattern := dotnetImportFiles(workspaceDirectory, sourceFile, imported.Project)
if importPattern != "" {
importPatterns = append(importPatterns, importPattern)
}
for _, match := range matches {
if operationError := resolverContext.Err(); operationError != nil {
return nil, nil, nil, operationError
}
if visited[match] {
continue
}
visited[match] = true
importedFiles = append(importedFiles, match)
queue = append(queue, match)
}
}
}
slices.Sort(importedFiles)
slices.Sort(importPatterns)
return importedFiles, references, slices.Compact(importPatterns), nil
}
func dotnetImportFiles(workspaceDirectory, sourceFile, projectValue string) ([]string, string) {
importPath := strings.TrimSpace(strings.ReplaceAll(projectValue, "\\", "/"))
if importPath == "" || strings.Contains(importPath, "$(") {
return nil, ""
}
importPath = path.Clean(path.Join(path.Dir(sourceFile), importPath))
if importPath == ".." || strings.HasPrefix(importPath, "../") {
return nil, ""
}
matches := []string{importPath}
importPattern := ""
if strings.ContainsAny(importPath, "*?[") {
importPattern = importPath
matches, _ = doublestar.Glob(os.DirFS(workspaceDirectory), importPath, doublestar.WithFilesOnly())
}
return slices.DeleteFunc(matches, func(match string) bool {
if ValidatePath(match) != nil {
return true
}
fileInfo, operationError := os.Stat(filepath.Join(workspaceDirectory, filepath.FromSlash(match)))
return operationError != nil || fileInfo.IsDir()
}), importPattern
}
func dotnetFilesFingerprint(workspaceDirectory string, files []string) (string, error) {
hasher := sha256.New()
for _, file := range files {
contents, operationError := os.ReadFile(filepath.Join(workspaceDirectory, filepath.FromSlash(file)))
if operationError != nil {
return "", fmt.Errorf("read %s: %w", file, operationError)
}
_, _ = fmt.Fprintf(hasher, "%s\x00%d\x00", file, len(contents))
_, _ = hasher.Write(contents)
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
// dotnetProjectFiles lists the projects every solution file in the workspace
// root names, or, without one, every project file in the tree.
func dotnetProjectFiles(resolverContext context.Context, workspaceDirectory string) ([]string, error) {
if operationError := resolverContext.Err(); operationError != nil {
return nil, operationError
}
fileSystem := os.DirFS(workspaceDirectory)
solutions, operationError := doublestar.Glob(fileSystem, "*.{sln,slnx}")
if operationError != nil {
return nil, operationError
}
var projectFiles []string
for _, solution := range solutions {
if operationError := resolverContext.Err(); operationError != nil {
return nil, operationError
}
contents, operationError := os.ReadFile(filepath.Join(workspaceDirectory, solution))
if operationError != nil {
return nil, fmt.Errorf("read %s: %w", solution, operationError)
}
var entries []string
if strings.HasSuffix(solution, ".slnx") {
entries, operationError = dotnetSlnxProjects(contents)
} else {
entries, operationError = dotnetSlnProjects(contents)
}
if operationError != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Join(workspaceDirectory, solution), operationError)
}
for _, entry := range entries {
entry = path.Clean(strings.ReplaceAll(entry, "\\", "/"))
if !slices.Contains(dotnetProjectExtensions, path.Ext(entry)) || entry == ".." || strings.HasPrefix(entry, "../") {
continue
}
projectFiles = append(projectFiles, entry)
}
}
if len(solutions) == 0 {
projectFiles, operationError = dotnetProjectFilesInTree(resolverContext, workspaceDirectory)
if operationError != nil {
return nil, operationError
}
}
slices.Sort(projectFiles)
return slices.Compact(projectFiles), nil
}
func dotnetProjectFilesInTree(resolverContext context.Context, workspaceDirectory string) ([]string, error) {
var projectFiles []string
operationError := filepath.WalkDir(workspaceDirectory, func(filePath string, entry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if operationError := resolverContext.Err(); operationError != nil {
return operationError
}
if entry.IsDir() && (entry.Name() == "bin" || entry.Name() == "obj") {
return filepath.SkipDir
}
if entry.IsDir() || !slices.Contains(dotnetProjectExtensions, filepath.Ext(entry.Name())) {
return nil
}
relativePath, operationError := filepath.Rel(workspaceDirectory, filePath)
if operationError != nil {
return operationError
}
projectFiles = append(projectFiles, filepath.ToSlash(relativePath))
return nil
})
return projectFiles, operationError
}
func dotnetSlnProjects(contents []byte) ([]string, error) {
var projects []string
scanner := bufio.NewScanner(bytes.NewReader(contents))
for scanner.Scan() {
if match := dotnetSolutionProjectLine.FindStringSubmatch(strings.TrimSpace(scanner.Text())); match != nil {
projects = append(projects, match[1])
}
}
return projects, scanner.Err()
}
func dotnetSlnxProjects(contents []byte) ([]string, error) {
var solution dotnetSolutionFolder
if operationError := xml.Unmarshal(contents, &solution); operationError != nil {
return nil, operationError
}
var projects []string
var collect func(folder dotnetSolutionFolder)
collect = func(folder dotnetSolutionFolder) {
for _, project := range folder.Projects {
projects = append(projects, project.Path)
}
for _, nested := range folder.Folders {
collect(nested)
}
}
collect(solution)
return projects, nil
}
// dotnetDefaultInputs are the files the built-in reads: the solution files,
// every project file, since references lead to projects outside the
// solution, and every configuration file, since each creates a package.
func dotnetDefaultInputs(resolverContext context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"*.sln", "*.slnx", "**/*.csproj", "**/*.fsproj", "**/*.vbproj", "**/Directory.Build.props", "**/Directory.Build.targets", "**/Directory.Packages.props", "**/global.json", "**/nuget.config", "**/NuGet.Config"}
var sources []string
operationError := filepath.WalkDir(workspaceDirectory, func(filePath string, entry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if operationError := resolverContext.Err(); operationError != nil {
return operationError
}
if entry.IsDir() && (entry.Name() == "bin" || entry.Name() == "obj") {
return filepath.SkipDir
}
extension := strings.ToLower(filepath.Ext(entry.Name()))
if entry.IsDir() || (!slices.Contains(dotnetProjectExtensions, extension) && extension != ".props" && extension != ".targets") {
return nil
}
relativePath, operationError := filepath.Rel(workspaceDirectory, filePath)
if operationError != nil {
return operationError
}
sources = append(sources, filepath.ToSlash(relativePath))
return nil
})
if operationError != nil {
return inputs, dotnetExcludeInputs
}
var importedFiles []string
var followedFiles []string
visited := make(map[string]bool)
for len(sources) > 0 {
source := sources[0]
sources = sources[1:]
if visited[source] {
continue
}
visited[source] = true
if resolverContext.Err() != nil {
return inputs, dotnetExcludeInputs
}
imports, references, importPatterns, operationError := dotnetImportedFiles(resolverContext, workspaceDirectory, []string{source})
if operationError == nil {
importedFiles = append(importedFiles, imports...)
importedFiles = append(importedFiles, importPatterns...)
}
for _, reference := range references {
referencedFiles, operationError := dotnetReferencedFiles(workspaceDirectory, path.Join(path.Dir(source), reference))
if operationError != nil {
continue
}
for _, referencedFile := range referencedFiles {
if !slices.Contains(dotnetProjectExtensions, strings.ToLower(path.Ext(referencedFile))) {
followedFiles = append(followedFiles, referencedFile)
}
}
sources = append(sources, referencedFiles...)
}
}
additionalInputs := append(importedFiles, followedFiles...)
slices.Sort(additionalInputs)
inputs = append(inputs, slices.Compact(additionalInputs)...)
return inputs, dotnetExcludeInputs
}
package resolvers
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"go/parser"
"go/token"
"io/fs"
"maps"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"golang.org/x/mod/modfile"
"golang.org/x/sync/errgroup"
)
// goDependencies maps every Go package under the workspace to the packages
// it imports from any module found there. Imports are parsed from the source
// files directly, so no toolchain is needed and every build constraint
// counts. Test files are left out: Go allows import cycles through them.
func goDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
modules, pending, operationError := goWorkspaceLayout(resolverContext, workspaceDirectory)
if operationError != nil {
return document, operationError
}
if len(modules) == 0 {
return document, fmt.Errorf("no go.mod under %s", workspaceDirectory)
}
// A directory the walk skips, such as _foo, is still a package once
// something imports it, so parsing repeats for newly imported directories.
localImports := make(map[string][]string)
for len(pending) > 0 {
var resultLock sync.Mutex
parseGroup, parseContext := errgroup.WithContext(resolverContext)
parseGroup.SetLimit(runtime.GOMAXPROCS(0))
for _, packageDirectory := range pending {
parseGroup.Go(func() error {
if operationError := parseContext.Err(); operationError != nil {
return operationError
}
imports, embedPatterns, operationError := goPackageSources(filepath.Join(workspaceDirectory, packageDirectory))
if operationError != nil {
return operationError
}
var dependencyDirectories []string
for _, importPath := range imports {
for _, dependencyDirectory := range goImportDirectories(importPath, modules) {
if dependencyDirectory != packageDirectory {
dependencyDirectories = append(dependencyDirectories, dependencyDirectory)
}
}
}
resultLock.Lock()
defer resultLock.Unlock()
localImports[packageDirectory] = dependencyDirectories
reportedPackage := Package{Inputs: goInputs(filepath.Join(workspaceDirectory, packageDirectory), embedPatterns)}
if fingerprint := goModuleFingerprint(packageDirectory, modules); fingerprint != "" {
reportedPackage.Fingerprint = map[string]string{"module": fingerprint}
}
document.Packages[packageDirectory] = reportedPackage
return nil
})
}
if operationError := parseGroup.Wait(); operationError != nil {
return document, operationError
}
pending = nil
for _, dependencyDirectories := range localImports {
for _, dependencyDirectory := range dependencyDirectories {
if _, isParsed := document.Packages[dependencyDirectory]; !isParsed && !slices.Contains(pending, dependencyDirectory) && goHasSources(filepath.Join(workspaceDirectory, dependencyDirectory)) {
pending = append(pending, dependencyDirectory)
}
}
}
}
for packageDirectory, dependencyDirectories := range localImports {
reportedPackage := document.Packages[packageDirectory]
reportedPackage.Dependencies = []string{}
for _, dependencyDirectory := range dependencyDirectories {
if _, isPackage := document.Packages[dependencyDirectory]; isPackage {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyDirectory)
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[packageDirectory] = reportedPackage
}
return document, nil
}
// goHasSources reports whether a directory holds a Go file the go tool reads.
func goHasSources(directory string) bool {
entries, operationError := os.ReadDir(directory)
if operationError != nil {
return false
}
return slices.ContainsFunc(entries, func(entry fs.DirEntry) bool { return !entry.IsDir() && isGoSourceFile(entry.Name()) })
}
type goModule struct {
Path string
Directory string
Fingerprint string
}
// goWorkspaceLayout walks the workspace the way the go tool does, skipping
// vendor, testdata and hidden or underscore directories, and returns every
// module and every directory holding Go files, both workspace-relative.
func goWorkspaceLayout(walkContext context.Context, workspaceDirectory string) ([]goModule, []string, error) {
modules, replacements, operationError := goReadWorkspaceModules(walkContext, workspaceDirectory)
if operationError != nil {
return nil, nil, operationError
}
packageDirectories := make(map[string]bool)
operationError = filepath.WalkDir(workspaceDirectory, func(currentPath string, entry fs.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if operationError := walkContext.Err(); operationError != nil {
return operationError
}
relativePath, operationError := filepath.Rel(workspaceDirectory, currentPath)
if operationError != nil {
return operationError
}
relativePath = filepath.ToSlash(relativePath)
if relativePath == "." {
relativePath = ""
}
if entry.IsDir() {
name := entry.Name()
if relativePath != "" && (name == "vendor" || name == "testdata" || name == "node_modules" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
return fs.SkipDir
}
return nil
}
directory := path.Dir(relativePath)
if directory == "." {
directory = ""
}
switch {
case entry.Name() == "go.mod":
module, localReplacements, operationError := goReadModuleFile(workspaceDirectory, currentPath, directory)
if operationError != nil {
return operationError
}
modules = append(modules, module)
replacements = append(replacements, localReplacements...)
case isGoSourceFile(entry.Name()):
packageDirectories[directory] = true
}
return nil
})
if operationError != nil {
return nil, nil, operationError
}
modules = append(modules, replacements...)
if operationError := goFingerprintModules(workspaceDirectory, modules); operationError != nil {
return nil, nil, operationError
}
// Longest module path first, so a nested module wins over its parent.
slices.SortFunc(modules, func(first, second goModule) int {
if difference := len(second.Path) - len(first.Path); difference != 0 {
return difference
}
return strings.Compare(first.Directory, second.Directory)
})
return modules, slices.Sorted(maps.Keys(packageDirectories)), nil
}
func goFingerprintModules(workspaceDirectory string, modules []goModule) error {
for index := range modules {
fingerprint, operationError := goModuleMetadataFingerprint(workspaceDirectory, modules[index].Directory)
if operationError != nil {
return operationError
}
modules[index].Fingerprint = fingerprint
}
return nil
}
func goReadWorkspaceModules(resolverContext context.Context, workspaceDirectory string) ([]goModule, []goModule, error) {
if operationError := resolverContext.Err(); operationError != nil {
return nil, nil, operationError
}
workspaceFilePath := filepath.Join(workspaceDirectory, "go.work")
contents, operationError := os.ReadFile(workspaceFilePath)
if errors.Is(operationError, os.ErrNotExist) {
return nil, nil, nil
}
if operationError != nil {
return nil, nil, fmt.Errorf("read %s: %w", workspaceFilePath, operationError)
}
workspaceFile, operationError := modfile.ParseWork(workspaceFilePath, contents, nil)
if operationError != nil {
return nil, nil, fmt.Errorf("parse %s: %w", workspaceFilePath, operationError)
}
var modules []goModule
var replacements []goModule
for _, use := range workspaceFile.Use {
if operationError := resolverContext.Err(); operationError != nil {
return nil, nil, operationError
}
modulePath := filepath.FromSlash(use.Path)
if !filepath.IsAbs(modulePath) {
modulePath = filepath.Join(workspaceDirectory, modulePath)
}
moduleDirectory, operationError := filepath.Rel(workspaceDirectory, modulePath)
if operationError != nil {
return nil, nil, operationError
}
moduleDirectory = filepath.ToSlash(moduleDirectory)
if moduleDirectory == "." {
moduleDirectory = ""
}
if moduleDirectory == ".." || strings.HasPrefix(moduleDirectory, "../") {
continue
}
module, localReplacements, operationError := goReadModuleFile(workspaceDirectory, filepath.Join(workspaceDirectory, filepath.FromSlash(moduleDirectory), "go.mod"), moduleDirectory)
if operationError != nil {
return nil, nil, operationError
}
modules = append(modules, module)
replacements = append(replacements, localReplacements...)
}
workspaceReplacements, operationError := goLocalReplacements(workspaceDirectory, "", workspaceFile.Replace)
return modules, append(replacements, workspaceReplacements...), operationError
}
func goReadModuleFile(workspaceDirectory, moduleFilePath, moduleDirectory string) (goModule, []goModule, error) {
contents, operationError := os.ReadFile(moduleFilePath)
if operationError != nil {
return goModule{}, nil, fmt.Errorf("read %s: %w", moduleFilePath, operationError)
}
moduleFile, operationError := modfile.Parse(moduleFilePath, contents, nil)
if operationError != nil {
return goModule{}, nil, fmt.Errorf("parse %s: %w", moduleFilePath, operationError)
}
if moduleFile.Module == nil || moduleFile.Module.Mod.Path == "" {
return goModule{}, nil, fmt.Errorf("parse %s: no module directive", moduleFilePath)
}
replacements, operationError := goLocalReplacements(workspaceDirectory, moduleDirectory, moduleFile.Replace)
if operationError != nil {
return goModule{}, nil, operationError
}
return goModule{Path: moduleFile.Module.Mod.Path, Directory: moduleDirectory}, replacements, nil
}
func goLocalReplacements(workspaceDirectory, baseDirectory string, replacements []*modfile.Replace) ([]goModule, error) {
var localReplacements []goModule
for _, replacement := range replacements {
if replacement.New.Version != "" {
continue
}
replacementPath := filepath.FromSlash(replacement.New.Path)
if !filepath.IsAbs(replacementPath) {
replacementPath = filepath.Join(workspaceDirectory, filepath.FromSlash(baseDirectory), replacementPath)
}
replacementDirectory, operationError := filepath.Rel(workspaceDirectory, replacementPath)
if operationError != nil {
return nil, operationError
}
replacementDirectory = filepath.ToSlash(replacementDirectory)
if replacementDirectory == "." {
replacementDirectory = ""
}
if replacementDirectory != ".." && !strings.HasPrefix(replacementDirectory, "../") {
localReplacements = append(localReplacements, goModule{Path: replacement.Old.Path, Directory: replacementDirectory})
}
}
return localReplacements, nil
}
func goModuleMetadataFingerprint(workspaceDirectory, moduleDirectory string) (string, error) {
hasher := sha256.New()
for _, metadataPath := range []string{"go.work", "go.work.sum", path.Join(moduleDirectory, "go.mod"), path.Join(moduleDirectory, "go.sum")} {
contents, operationError := os.ReadFile(filepath.Join(workspaceDirectory, filepath.FromSlash(metadataPath)))
if errors.Is(operationError, os.ErrNotExist) {
continue
}
if operationError != nil {
return "", fmt.Errorf("read %s: %w", metadataPath, operationError)
}
_, _ = fmt.Fprintf(hasher, "%s\x00%d\x00", metadataPath, len(contents))
_, _ = hasher.Write(contents)
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
func goModuleFingerprint(packageDirectory string, modules []goModule) string {
fingerprint := ""
matchedLength := -1
for _, module := range modules {
isWithin := module.Directory == "" || packageDirectory == module.Directory || strings.HasPrefix(packageDirectory, module.Directory+"/")
if module.Fingerprint != "" && isWithin && len(module.Directory) > matchedLength {
fingerprint = module.Fingerprint
matchedLength = len(module.Directory)
}
}
return fingerprint
}
// goImportDirectories maps an import path through the longest matching module
// path. Multiple results conservatively cover version-specific replacements.
func goImportDirectories(importPath string, modules []goModule) []string {
matchedLength := -1
var directories []string
for _, module := range modules {
var directory string
matches := false
if importPath == module.Path {
directory, matches = module.Directory, true
}
if suffix, isPrefixed := strings.CutPrefix(importPath, module.Path+"/"); isPrefixed {
directory, matches = path.Join(module.Directory, suffix), true
}
if !matches || len(module.Path) < matchedLength {
continue
}
if len(module.Path) > matchedLength {
directories = nil
matchedLength = len(module.Path)
}
directories = append(directories, directory)
}
slices.Sort(directories)
return slices.Compact(directories)
}
// goPackageSources returns the imports of the non-test Go files in a directory
// and the patterns of every //go:embed directive, test files included.
func goPackageSources(packageDirectory string) (imports []string, embedPatterns []string, operationError error) {
entries, operationError := os.ReadDir(packageDirectory)
if operationError != nil {
return nil, nil, operationError
}
fileSet := token.NewFileSet()
for _, entry := range entries {
if entry.IsDir() || !isGoSourceFile(entry.Name()) {
continue
}
filePath := filepath.Join(packageDirectory, entry.Name())
file, operationError := parser.ParseFile(fileSet, filePath, nil, parser.ParseComments)
if operationError != nil {
return nil, nil, fmt.Errorf("parse %s: %w", filePath, operationError)
}
for _, commentGroup := range file.Comments {
for _, comment := range commentGroup.List {
embedPatterns = append(embedPatterns, goEmbedPatterns(comment.Text)...)
}
}
if strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
for _, importSpec := range file.Imports {
importPath, operationError := strconv.Unquote(importSpec.Path.Value)
if operationError != nil {
return nil, nil, fmt.Errorf("parse %s: invalid import %s", filePath, importSpec.Path.Value)
}
imports = append(imports, importPath)
}
}
return imports, embedPatterns, nil
}
// isGoSourceFile applies the go tool's rule of ignoring files whose names
// start with a dot or an underscore.
func isGoSourceFile(name string) bool {
return strings.HasSuffix(name, ".go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_")
}
// goEmbedPatterns reads the bare, quoted or backquoted patterns of one
// //go:embed comment. An all: prefix changes traversal, not the path.
func goEmbedPatterns(comment string) []string {
line, isEmbed := strings.CutPrefix(comment, "//go:embed")
if !isEmbed || (line != "" && line[0] != ' ' && line[0] != '\t') {
return nil
}
var patterns []string
for line = strings.TrimSpace(line); line != ""; line = strings.TrimSpace(line) {
var pattern string
switch line[0] {
case '"':
quoted, operationError := strconv.QuotedPrefix(line)
if operationError != nil {
return patterns
}
pattern, _ = strconv.Unquote(quoted)
line = line[len(quoted):]
case '`':
end := strings.IndexByte(line[1:], '`')
if end < 0 {
return patterns
}
pattern = line[1 : end+1]
line = line[end+2:]
default:
end := strings.IndexAny(line, " \t")
if end < 0 {
end = len(line)
}
pattern = line[:end]
line = line[end:]
}
patterns = append(patterns, strings.TrimPrefix(pattern, "all:"))
}
return patterns
}
func goInputs(_ string, embedPatterns []string) []string {
inputs := []string{"*", "testdata/**"}
for _, pattern := range embedPatterns {
globPattern := strings.NewReplacer("{", `\{`, "}", `\}`).Replace(pattern)
if strings.ContainsAny(pattern, "*?[") {
inputs = append(inputs, globPattern, path.Join(globPattern, "**"))
continue
}
inputs = append(inputs, globPattern+"{,/**}")
}
slices.Sort(inputs)
return slices.Compact(inputs)
}
// goDefaultInputs are the files the built-in reads: every module file and
// every Go source file an import path can reach.
func goDefaultInputs(resolverContext context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"go.work", "go.work.sum", "**/go.mod", "**/go.sum", "**/*.go"}
excludeInputs = []string{"**/vendor/**", "**/node_modules/**"}
modules, _, operationError := goWorkspaceLayout(resolverContext, workspaceDirectory)
if operationError != nil {
return inputs, excludeInputs
}
for _, module := range modules {
segments := strings.Split(module.Directory, "/")
if slices.Contains(segments, "vendor") {
excludeInputs = slices.DeleteFunc(excludeInputs, func(pattern string) bool { return pattern == "**/vendor/**" })
}
if slices.Contains(segments, "node_modules") {
excludeInputs = slices.DeleteFunc(excludeInputs, func(pattern string) bool { return pattern == "**/node_modules/**" })
}
}
return inputs, excludeInputs
}
package resolvers
import (
"context"
"errors"
"fmt"
"maps"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"unicode"
)
var (
gradleIncludePattern = regexp.MustCompile(`\binclude\s*(?:\(([^)]*)\)|((?:["'][^"']*["']\s*,?\s*)+))`)
gradleQuotedPattern = regexp.MustCompile(`["']([^"']*)["']`)
gradleProjectDirPattern = regexp.MustCompile(`\bproject\(\s*["'](:[^"']*)["']\s*\)\.projectDir\s*=\s*(?:new\s+)?[fF]ile\(\s*(?:(?:rootDir|settingsDir|rootProject\.projectDir)\s*,\s*)?["']([^"']+)["']`)
gradleIncludeBuildPattern = regexp.MustCompile(`\bincludeBuild\s*\(?\s*["']([^"']+)["']`)
gradleProjectReferencePattern = regexp.MustCompile(`\bproject\(\s*(?:path\s*[:=]\s*)?["'](:[^"']*)["']`)
gradleAccessorPattern = regexp.MustCompile(`\bprojects((?:\.[A-Za-z_][A-Za-z0-9_]*)+)`)
gradleBuildFileNamePattern = regexp.MustCompile(`\b(?:project\(\s*["'](:[^"']*)["']\s*\)|(rootProject))\.buildFileName\s*=\s*["']([^"']+)["']`)
gradleProjectNamePattern = regexp.MustCompile(`\bproject\(\s*["'](:[^"']*)["']\s*\)\.name\s*=\s*["']([^:"'/]+)["']`)
)
var gradleExcludeInputs = []string{"build/**", ".gradle/**"}
// gradleDependencies reads the settings file for the project tree and every
// project's build file for project(":x") and projects.x references. Every
// project depends on the root, which owns the version catalogs and buildSrc.
func gradleDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
settings, operationError := readGradleFile(workspaceDirectory, "settings.gradle", "settings.gradle.kts")
if operationError != nil {
return document, operationError
}
if settings == "" {
// Without a settings file Gradle runs a single-project build.
rootBuildFile, operationError := readGradleFile(workspaceDirectory, gradleBuildFiles(settings, ":")...)
if operationError != nil {
return document, operationError
}
if rootBuildFile == "" {
return document, fmt.Errorf("no settings.gradle, settings.gradle.kts or build file in %s", workspaceDirectory)
}
}
settings = gradleWithoutComments(settings)
directoryByProject := gradleProjectDirectories(settings)
accessorByProject := make(map[string]string, len(directoryByProject))
for projectPath := range directoryByProject {
accessorByProject[gradleAccessor(projectPath)] = projectPath
}
root := Package{
Dependencies: []string{},
Inputs: slices.Concat([]string{"settings.gradle", "settings.gradle.kts", "gradle.properties", "gradle/**", "buildSrc/**", "src/**/*"}, gradleBuildFiles(settings, ":")),
ExcludeInputs: []string{"build/**", ".gradle/**", "buildSrc/build/**", "buildSrc/.gradle/**"},
}
for _, match := range gradleIncludeBuildPattern.FindAllStringSubmatch(settings, -1) {
includedBuild := path.Clean(filepath.ToSlash(match[1]))
if ValidatePath(includedBuild) != nil || includedBuild == "." {
continue
}
root.Dependencies = append(root.Dependencies, includedBuild)
document.Packages[includedBuild] = Package{Dependencies: []string{}, Inputs: []string{"**/*"}, ExcludeInputs: []string{"**/build/**", "**/.gradle/**"}}
}
slices.Sort(root.Dependencies)
root.Dependencies = slices.Compact(root.Dependencies)
slices.Sort(root.Inputs)
document.Packages[""] = root
for projectPath, directory := range directoryByProject {
if operationError := resolverContext.Err(); operationError != nil {
return document, operationError
}
buildFile, operationError := readGradleFile(filepath.Join(workspaceDirectory, directory), gradleBuildFiles(settings, projectPath)...)
if operationError != nil {
return document, operationError
}
reportedPackage := Package{
Dependencies: []string{""},
Inputs: slices.Concat(gradleBuildFiles(settings, projectPath), []string{"gradle.properties", "src/**/*"}),
ExcludeInputs: gradleExcludeInputs,
}
for _, referenced := range gradleProjectReferences(buildFile, directoryByProject, accessorByProject) {
if referenced != projectPath {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, directoryByProject[referenced])
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
slices.Sort(reportedPackage.Inputs)
document.Packages[directory] = reportedPackage
}
return document, nil
}
// gradleProjectDirectories maps every included project path (":a:b") to its
// directory, honouring projectDir overrides. The root project is left out.
// Including ":a:b" also creates ":a"; it is kept when its directory exists.
func gradleProjectDirectories(settings string) map[string]string {
settings = gradleWithoutComments(settings)
directories := make(map[string]string)
for _, match := range gradleIncludePattern.FindAllStringSubmatch(settings, -1) {
for _, quoted := range gradleQuotedPattern.FindAllStringSubmatch(match[1]+match[2], -1) {
projectPath := ":" + strings.Trim(quoted[1], ":")
if projectPath == ":" {
continue
}
directories[projectPath] = strings.ReplaceAll(strings.TrimPrefix(projectPath, ":"), ":", "/")
}
}
for projectPath := range directories {
for parent := projectPath[:strings.LastIndex(projectPath, ":")]; parent != ""; parent = parent[:strings.LastIndex(parent, ":")] {
directories[parent] = strings.ReplaceAll(strings.TrimPrefix(parent, ":"), ":", "/")
}
}
for _, match := range gradleProjectDirPattern.FindAllStringSubmatch(settings, -1) {
isAncestor := slices.ContainsFunc(slices.Collect(maps.Keys(directories)), func(projectPath string) bool { return strings.HasPrefix(projectPath, match[1]+":") })
if _, included := directories[match[1]]; included || isAncestor {
directories[match[1]] = path.Clean(filepath.ToSlash(match[2]))
}
}
for _, match := range gradleProjectNamePattern.FindAllStringSubmatch(settings, -1) {
if _, included := directories[match[1]]; !included {
continue
}
parentPath := match[1][:strings.LastIndex(match[1], ":")]
newPrefix := parentPath + ":" + match[2]
for _, projectPath := range slices.Sorted(maps.Keys(directories)) {
if projectPath != match[1] && !strings.HasPrefix(projectPath, match[1]+":") {
continue
}
directories[newPrefix+strings.TrimPrefix(projectPath, match[1])] = directories[projectPath]
delete(directories, projectPath)
}
}
for projectPath, directory := range directories {
if ValidatePath(directory) != nil || directory == "." {
delete(directories, projectPath)
}
}
return directories
}
func gradleWithoutComments(contents string) string {
var result strings.Builder
result.Grow(len(contents))
var quote byte
lineComment := false
blockComment := false
escaped := false
for index := 0; index < len(contents); index++ {
character := contents[index]
if lineComment {
if character == '\n' {
lineComment = false
result.WriteByte(character)
} else {
result.WriteByte(' ')
}
continue
}
if blockComment {
if character == '*' && index+1 < len(contents) && contents[index+1] == '/' {
result.WriteString(" ")
index++
blockComment = false
} else if character == '\n' {
result.WriteByte(character)
} else {
result.WriteByte(' ')
}
continue
}
if quote != 0 {
result.WriteByte(character)
if escaped {
escaped = false
} else if character == '\\' {
escaped = true
} else if character == quote {
quote = 0
}
continue
}
if character == '\'' || character == '"' {
quote = character
result.WriteByte(character)
continue
}
if character == '/' && index+1 < len(contents) {
switch contents[index+1] {
case '/':
result.WriteString(" ")
index++
lineComment = true
continue
case '*':
result.WriteString(" ")
index++
blockComment = true
continue
}
}
result.WriteByte(character)
}
return result.String()
}
// gradleBuildFiles is the build file to read for a project, ":" for the
// root: the last buildFileName settings assign to it, or the defaults.
func gradleBuildFiles(settings string, projectPath string) []string {
buildFiles := []string{"build.gradle", "build.gradle.kts"}
for _, match := range gradleBuildFileNamePattern.FindAllStringSubmatch(settings, -1) {
if match[1] == projectPath || (match[2] != "" && projectPath == ":") {
buildFiles = []string{match[3]}
}
}
return buildFiles
}
// gradleProjectReferences lists the project paths a build file mentions,
// through project(":x") or the type-safe accessor projects.x. An accessor
// chain may continue into a property, so its longest known prefix wins.
func gradleProjectReferences(buildFile string, directoryByProject map[string]string, accessorByProject map[string]string) []string {
buildFile = gradleWithoutTestDependencies(gradleWithoutComments(buildFile))
var references []string
for _, match := range gradleProjectReferencePattern.FindAllStringSubmatch(buildFile, -1) {
if _, included := directoryByProject[match[1]]; included {
references = append(references, match[1])
}
}
for _, match := range gradleAccessorPattern.FindAllStringSubmatch(buildFile, -1) {
segments := strings.Split(strings.TrimPrefix(match[1], "."), ".")
for length := len(segments); length > 0; length-- {
if projectPath, included := accessorByProject[strings.Join(segments[:length], ".")]; included {
references = append(references, projectPath)
break
}
}
}
return references
}
func gradleWithoutTestDependencies(contents string) string {
result := []byte(contents)
for index := 0; index < len(contents); index++ {
if !unicode.IsLetter(rune(contents[index])) && contents[index] != '_' {
continue
}
start := index
for index < len(contents) && (unicode.IsLetter(rune(contents[index])) || unicode.IsDigit(rune(contents[index])) || contents[index] == '_') {
index++
}
name := contents[start:index]
if !gradleTestConfiguration(name) {
index--
continue
}
for index < len(contents) && (contents[index] == ' ' || contents[index] == '\t') {
index++
}
if index >= len(contents) || (contents[index] != '(' && !strings.HasPrefix(contents[index:], "project")) {
index--
continue
}
var end int
if index < len(contents) && contents[index] == '(' {
end = gradleCallEnd(contents, index)
} else if newline := strings.IndexByte(contents[index:], '\n'); newline >= 0 {
end = index + newline
} else {
end = len(contents)
}
for position := start; position < end; position++ {
if result[position] != '\n' {
result[position] = ' '
}
}
index = end - 1
}
return string(result)
}
func gradleTestConfiguration(name string) bool {
lowerName := strings.ToLower(name)
if !strings.Contains(lowerName, "test") {
return false
}
return slices.ContainsFunc([]string{"implementation", "api", "compileonly", "runtimeonly", "annotationprocessor"}, func(suffix string) bool {
return strings.HasSuffix(lowerName, suffix)
})
}
func gradleCallEnd(contents string, openParenthesis int) int {
depth := 0
var quote byte
escaped := false
for index := openParenthesis; index < len(contents); index++ {
character := contents[index]
if quote != 0 {
if escaped {
escaped = false
} else if character == '\\' {
escaped = true
} else if character == quote {
quote = 0
}
continue
}
if character == '\'' || character == '"' {
quote = character
continue
}
switch character {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return index + 1
}
}
}
return len(contents)
}
// gradleAccessor is the type-safe accessor for a project path: each segment
// has its "-", "_" and "." separators folded into camelCase.
func gradleAccessor(projectPath string) string {
segments := strings.Split(strings.TrimPrefix(projectPath, ":"), ":")
for index, segment := range segments {
var accessor strings.Builder
capitalizeNext := false
for _, character := range segment {
if character == '-' || character == '_' || character == '.' {
capitalizeNext = true
continue
}
if capitalizeNext {
character = unicode.ToUpper(character)
capitalizeNext = false
}
accessor.WriteRune(character)
}
segments[index] = accessor.String()
}
return strings.Join(segments, ".")
}
// readGradleFile reads the first named script that exists.
func readGradleFile(directory string, names ...string) (string, error) {
for _, name := range names {
fileContents, operationError := os.ReadFile(filepath.Join(directory, name))
if errors.Is(operationError, os.ErrNotExist) {
continue
}
if operationError != nil {
return "", fmt.Errorf("read %s: %w", filepath.Join(directory, name), operationError)
}
return string(fileContents), nil
}
return "", nil
}
// gradleDefaultInputs are the files the built-in reads: the settings file
// and every included project's build file.
func gradleDefaultInputs(_ context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"settings.gradle", "settings.gradle.kts"}
settings, operationError := readGradleFile(workspaceDirectory, "settings.gradle", "settings.gradle.kts")
if operationError != nil {
return inputs, gradleExcludeInputs
}
settings = gradleWithoutComments(settings)
inputs = append(inputs, gradleBuildFiles(settings, ":")...)
for projectPath, directory := range gradleProjectDirectories(settings) {
for _, buildFile := range gradleBuildFiles(settings, projectPath) {
inputs = append(inputs, path.Join(directory, buildFile))
}
}
slices.Sort(inputs[2:])
inputs = slices.Compact(inputs)
return inputs, gradleExcludeInputs
}
package resolvers
import (
"context"
"crypto/sha256"
"encoding/xml"
"errors"
"fmt"
"maps"
"os"
"path"
"path/filepath"
"slices"
"strings"
)
type mavenCoordinates struct {
GroupId string `xml:"groupId"`
ArtifactId string `xml:"artifactId"`
Version string `xml:"version"`
Scope string `xml:"scope"`
}
// mavenModel is the part of a pom a profile can override as well.
type mavenModel struct {
Modules []string `xml:"modules>module"`
Dependencies []mavenCoordinates `xml:"dependencies>dependency"`
DependencyManagement []mavenCoordinates `xml:"dependencyManagement>dependencies>dependency"`
Build struct {
Plugins []struct {
mavenCoordinates
Dependencies []mavenCoordinates `xml:"dependencies>dependency"`
} `xml:"plugins>plugin"`
Extensions []mavenCoordinates `xml:"extensions>extension"`
} `xml:"build"`
Properties struct {
Entries []struct {
XMLName xml.Name
Value string `xml:",chardata"`
} `xml:",any"`
} `xml:"properties"`
}
// references lists everything a module needs from other modules: its
// dependencies, imported BOMs, build plugins with their dependencies, and
// build extensions.
func (model mavenModel) references() []mavenCoordinates {
references := slices.Clone(model.Dependencies)
for _, managed := range model.DependencyManagement {
if strings.TrimSpace(managed.Scope) == "import" {
references = append(references, managed)
}
}
for _, plugin := range model.Build.Plugins {
if strings.TrimSpace(plugin.GroupId) == "" {
plugin.GroupId = "org.apache.maven.plugins"
}
references = append(references, plugin.mavenCoordinates)
references = append(references, plugin.Dependencies...)
}
return append(references, model.Build.Extensions...)
}
type mavenProject struct {
mavenCoordinates
mavenModel
Parent struct {
mavenCoordinates
RelativePath *string `xml:"relativePath"`
} `xml:"parent"`
Profiles []mavenModel `xml:"profiles>profile"`
}
type mavenResolvedModel struct {
model mavenModel
properties map[string]string
profile bool
}
type mavenMember struct {
project mavenProject
models []mavenResolvedModel
pomFiles []string
parentDirectory string
hasParent bool
}
func mavenInterpolate(value string, properties map[string]string) string {
value = strings.TrimSpace(value)
names := slices.Sorted(maps.Keys(properties))
for range len(names) + 1 {
previous := value
for _, name := range names {
value = strings.ReplaceAll(value, "${"+name+"}", properties[name])
}
if value == previous {
break
}
}
return value
}
func mavenModelProperties(project mavenProject, model mavenModel, inherited map[string]string) map[string]string {
properties := make(map[string]string, len(inherited)+len(model.Properties.Entries)+9)
maps.Copy(properties, inherited)
for _, entry := range model.Properties.Entries {
properties[entry.XMLName.Local] = strings.TrimSpace(entry.Value)
}
groupId := mavenInterpolate(mavenEffectiveGroupId(project), properties)
artifactId := mavenInterpolate(project.ArtifactId, properties)
version := mavenInterpolate(mavenEffectiveVersion(project), properties)
for _, name := range []string{"project.groupId", "pom.groupId", "groupId"} {
properties[name] = groupId
}
for _, name := range []string{"project.artifactId", "pom.artifactId", "artifactId"} {
properties[name] = artifactId
}
for _, name := range []string{"project.version", "pom.version", "version"} {
properties[name] = version
}
properties["project.parent.groupId"] = mavenInterpolate(project.Parent.GroupId, properties)
properties["project.parent.artifactId"] = mavenInterpolate(project.Parent.ArtifactId, properties)
properties["project.parent.version"] = mavenInterpolate(project.Parent.Version, properties)
return properties
}
func mavenCoordinateKey(groupId, artifactId, version string) string {
key := groupId + ":" + artifactId
if version != "" {
key += ":" + version
}
return key
}
func mavenReferenceKey(groupId, artifactId, version string) string {
version = strings.TrimSpace(version)
if strings.HasPrefix(version, "[") || strings.HasPrefix(version, "(") {
version = ""
}
return mavenCoordinateKey(groupId, artifactId, version)
}
func mavenParentMatches(project, parent mavenProject) bool {
projectProperties := mavenModelProperties(project, project.mavenModel, nil)
parentProperties := mavenModelProperties(parent, parent.mavenModel, nil)
return mavenInterpolate(project.Parent.GroupId, projectProperties) == mavenInterpolate(mavenEffectiveGroupId(parent), parentProperties) &&
mavenInterpolate(project.Parent.ArtifactId, projectProperties) == mavenInterpolate(parent.ArtifactId, parentProperties) &&
mavenInterpolate(project.Parent.Version, projectProperties) == mavenInterpolate(mavenEffectiveVersion(parent), parentProperties)
}
// mavenDependencies maps every module reachable from the root pom.xml to the
// modules it references, in any profile, and to its parent.
func mavenDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
members, operationError := mavenMembers(resolverContext, workspaceDirectory)
if operationError != nil {
return document, operationError
}
directoriesByCoordinates := make(map[string][]string, len(members))
for directory, member := range members {
for _, resolvedModel := range member.models {
groupId := mavenInterpolate(mavenEffectiveGroupId(member.project), resolvedModel.properties)
artifactId := mavenInterpolate(member.project.ArtifactId, resolvedModel.properties)
version := mavenInterpolate(mavenEffectiveVersion(member.project), resolvedModel.properties)
keys := []string{mavenCoordinateKey(groupId, artifactId, "")}
if version != "" {
keys = append(keys, mavenCoordinateKey(groupId, artifactId, version))
}
for _, key := range keys {
directoriesByCoordinates[key] = append(directoriesByCoordinates[key], directory)
}
}
}
for key, directories := range directoriesByCoordinates {
slices.Sort(directories)
directoriesByCoordinates[key] = slices.Compact(directories)
}
profileDependencies := make(map[string][]string)
for directory, member := range members {
project := member.project
inputs := append(slices.Clone(member.pomFiles), "src/**/*")
slices.Sort(inputs)
reportedPackage := Package{Dependencies: []string{}, Inputs: inputs, ExcludeInputs: mavenExcludeInputs}
if directory == "" {
reportedPackage.Inputs = slices.Clone(member.pomFiles)
}
for _, resolvedModel := range member.models {
references := append([]mavenCoordinates{project.Parent.mavenCoordinates}, resolvedModel.model.references()...)
for _, reference := range references {
groupId := mavenInterpolate(reference.GroupId, resolvedModel.properties)
artifactId := mavenInterpolate(reference.ArtifactId, resolvedModel.properties)
version := mavenInterpolate(reference.Version, resolvedModel.properties)
if groupId == "" || artifactId == "" {
continue
}
for _, dependencyDirectory := range directoriesByCoordinates[mavenReferenceKey(groupId, artifactId, version)] {
if dependencyDirectory != directory {
if resolvedModel.profile {
profileDependencies[directory] = append(profileDependencies[directory], dependencyDirectory)
} else {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyDirectory)
}
}
}
}
}
if member.hasParent && member.parentDirectory != directory {
if _, isMember := members[member.parentDirectory]; isMember {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, member.parentDirectory)
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[directory] = reportedPackage
}
if operationError := mavenApplyProfileDependencies(resolverContext, workspaceDirectory, members, profileDependencies, &document); operationError != nil {
return document, operationError
}
return document, nil
}
func mavenApplyProfileDependencies(resolverContext context.Context, workspaceDirectory string, members map[string]mavenMember, profileDependencies map[string][]string, document *Document) error {
profileCycleFingerprint := ""
for _, directory := range slices.Sorted(maps.Keys(profileDependencies)) {
dependencies := profileDependencies[directory]
slices.Sort(dependencies)
for _, dependencyDirectory := range slices.Compact(dependencies) {
if mavenAddsCycle(*document, directory, dependencyDirectory) {
if profileCycleFingerprint == "" {
fingerprint, operationError := mavenInputsFingerprint(resolverContext, workspaceDirectory, members)
if operationError != nil {
return operationError
}
profileCycleFingerprint = fingerprint
}
reportedPackage := document.Packages[directory]
if reportedPackage.Fingerprint == nil {
reportedPackage.Fingerprint = make(map[string]string)
}
reportedPackage.Fingerprint["profile-cycle"] = profileCycleFingerprint
document.Packages[directory] = reportedPackage
continue
}
reportedPackage := document.Packages[directory]
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyDirectory)
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[directory] = reportedPackage
}
}
return nil
}
func mavenAddsCycle(document Document, directory, dependencyDirectory string) bool {
visited := map[string]bool{dependencyDirectory: true}
for queue := []string{dependencyDirectory}; len(queue) > 0; queue = queue[1:] {
if queue[0] == directory {
return true
}
for _, dependency := range document.Packages[queue[0]].Dependencies {
if !visited[dependency] {
visited[dependency] = true
queue = append(queue, dependency)
}
}
}
return false
}
func mavenInputsFingerprint(resolverContext context.Context, workspaceDirectory string, members map[string]mavenMember) (string, error) {
var files []string
for directory, member := range members {
for _, pomFile := range member.pomFiles {
files = append(files, path.Join(directory, pomFile))
}
sourceDirectory := filepath.Join(workspaceDirectory, filepath.FromSlash(directory), "src")
operationError := filepath.WalkDir(sourceDirectory, func(filePath string, entry os.DirEntry, walkError error) error {
if errors.Is(walkError, os.ErrNotExist) {
return nil
}
if walkError != nil {
return walkError
}
if operationError := resolverContext.Err(); operationError != nil {
return operationError
}
if entry.IsDir() {
return nil
}
relativePath, operationError := filepath.Rel(workspaceDirectory, filePath)
if operationError != nil {
return operationError
}
files = append(files, filepath.ToSlash(relativePath))
return nil
})
if operationError != nil {
return "", operationError
}
}
slices.Sort(files)
hasher := sha256.New()
for _, file := range files {
if operationError := resolverContext.Err(); operationError != nil {
return "", operationError
}
contents, operationError := os.ReadFile(filepath.Join(workspaceDirectory, filepath.FromSlash(file)))
if operationError != nil {
return "", operationError
}
_, _ = fmt.Fprintf(hasher, "%s\x00%d\x00", file, len(contents))
_, _ = hasher.Write(contents)
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
// mavenMembers reads the root pom.xml and every module it lists, directly or
// through a profile, recursively. Modules outside the workspace are skipped.
type mavenWorkspace struct {
context context.Context
directory string
projects map[string]mavenProject
models map[string][]mavenResolvedModel
parentProject map[string]string
resolving map[string]bool
}
func (workspace *mavenWorkspace) readProject(directory string) (mavenProject, error) {
return workspace.readProjectFile(directory, "pom.xml")
}
func (workspace *mavenWorkspace) readProjectFile(directory, projectFile string) (mavenProject, error) {
if operationError := workspace.context.Err(); operationError != nil {
return mavenProject{}, operationError
}
projectKey := path.Join(directory, projectFile)
if project, exists := workspace.projects[projectKey]; exists {
return project, nil
}
project, operationError := readMavenProject(filepath.Join(workspace.directory, filepath.FromSlash(directory), filepath.FromSlash(projectFile)))
if operationError == nil {
workspace.projects[projectKey] = project
}
return project, operationError
}
func (workspace *mavenWorkspace) modelsFor(directory string) ([]mavenResolvedModel, error) {
return workspace.modelsForFile(directory, "pom.xml")
}
func (workspace *mavenWorkspace) modelsForFile(directory, projectFile string) ([]mavenResolvedModel, error) {
projectKey := path.Join(directory, projectFile)
if models, exists := workspace.models[projectKey]; exists {
return models, nil
}
if workspace.resolving[projectKey] {
return nil, fmt.Errorf("maven parent cycle at %q", projectKey)
}
workspace.resolving[projectKey] = true
defer delete(workspace.resolving, projectKey)
project, operationError := workspace.readProjectFile(directory, projectFile)
if operationError != nil {
return nil, operationError
}
inheritedModels := []mavenResolvedModel{{properties: map[string]string{}}}
parentProjectKey := ""
hasParent := false
if relativeParentDirectory, relativeParentFile, inside := mavenParentLocation(directory, project); inside && relativeParentDirectory != directory {
parentProject, operationError := workspace.readProjectFile(relativeParentDirectory, relativeParentFile)
if operationError == nil && mavenParentMatches(project, parentProject) {
parentProjectKey = path.Join(relativeParentDirectory, relativeParentFile)
hasParent = true
} else if operationError != nil && !errors.Is(operationError, os.ErrNotExist) {
return nil, operationError
}
}
if !hasParent {
parentProjectKey, hasParent = workspace.coordinateParentProject(projectKey, project)
}
if hasParent {
parentProjectDirectory, parentProjectFile := mavenProjectLocation(parentProjectKey)
inheritedModels, operationError = workspace.modelsForFile(parentProjectDirectory, parentProjectFile)
if operationError != nil {
return nil, operationError
}
workspace.parentProject[projectKey] = parentProjectKey
}
var models []mavenResolvedModel
for _, inheritedModel := range mavenUniquePropertyModels(inheritedModels) {
baseProperties := mavenModelProperties(project, project.mavenModel, inheritedModel.properties)
variants := []mavenProfileVariant{{properties: baseProperties}}
for _, profile := range project.Profiles {
for index, count := 0, len(variants); index < count; index++ {
profiles := append(slices.Clone(variants[index].profiles), profile)
variants = append(variants, mavenProfileVariant{properties: mavenModelProperties(project, profile, variants[index].properties), profiles: profiles})
}
}
for _, variant := range variants {
profileActive := inheritedModel.profile || len(variant.profiles) > 0
models = append(models, mavenResolvedModel{model: project.mavenModel, properties: variant.properties, profile: profileActive})
for _, profile := range variant.profiles {
models = append(models, mavenResolvedModel{model: profile, properties: variant.properties, profile: true})
}
}
}
workspace.models[projectKey] = models
return models, nil
}
type mavenProfileVariant struct {
properties map[string]string
profiles []mavenModel
}
func mavenUniquePropertyModels(models []mavenResolvedModel) []mavenResolvedModel {
seen := make(map[string]bool)
unique := make([]mavenResolvedModel, 0, len(models))
for _, model := range models {
var identity strings.Builder
for _, name := range slices.Sorted(maps.Keys(model.properties)) {
_, _ = fmt.Fprintf(&identity, "%s\x00%s\x00", name, model.properties[name])
}
if !seen[identity.String()] {
seen[identity.String()] = true
unique = append(unique, model)
}
}
return unique
}
func (workspace *mavenWorkspace) coordinateParentProject(projectKey string, project mavenProject) (string, bool) {
if project.Parent.ArtifactId == "" {
return "", false
}
for _, candidateKey := range slices.Sorted(maps.Keys(workspace.projects)) {
if candidateKey != projectKey && mavenParentMatches(project, workspace.projects[candidateKey]) {
return candidateKey, true
}
}
return "", false
}
func mavenProjectLocation(projectKey string) (string, string) {
directory := path.Dir(projectKey)
if directory == "." {
directory = ""
}
return directory, path.Base(projectKey)
}
func (workspace *mavenWorkspace) addParentMember(members map[string]mavenMember, directory, projectFile string) error {
project, operationError := workspace.readProjectFile(directory, projectFile)
if operationError != nil {
return operationError
}
models, operationError := workspace.modelsForFile(directory, projectFile)
if operationError != nil {
return operationError
}
projectKey := path.Join(directory, projectFile)
parentProjectKey, hasParent := workspace.parentProject[projectKey]
parentDirectory, parentFile := mavenProjectLocation(parentProjectKey)
member, exists := members[directory]
if !exists {
member = mavenMember{project: project, models: models, parentDirectory: parentDirectory, hasParent: hasParent}
}
if !slices.Contains(member.pomFiles, projectFile) {
member.pomFiles = append(member.pomFiles, projectFile)
slices.Sort(member.pomFiles)
}
members[directory] = member
if hasParent {
return workspace.addParentMember(members, parentDirectory, parentFile)
}
return nil
}
func mavenMembers(resolverContext context.Context, workspaceDirectory string) (map[string]mavenMember, error) {
members := make(map[string]mavenMember)
workspace := mavenWorkspace{
context: resolverContext,
directory: workspaceDirectory,
projects: make(map[string]mavenProject),
models: make(map[string][]mavenResolvedModel),
parentProject: make(map[string]string),
resolving: make(map[string]bool),
}
reactorVisited := make(map[string]bool)
var visit func(directory string) error
visit = func(directory string) error {
if operationError := resolverContext.Err(); operationError != nil {
return operationError
}
if reactorVisited[directory] {
return nil
}
project, operationError := workspace.readProject(directory)
if operationError != nil {
return operationError
}
models, operationError := workspace.modelsFor(directory)
if operationError != nil {
return operationError
}
projectKey := path.Join(directory, "pom.xml")
parentProjectKey, hasParent := workspace.parentProject[projectKey]
parentDirectory, parentFile := mavenProjectLocation(parentProjectKey)
member := members[directory]
member.project = project
member.models = models
member.parentDirectory = parentDirectory
member.hasParent = hasParent
if !slices.Contains(member.pomFiles, "pom.xml") {
member.pomFiles = append(member.pomFiles, "pom.xml")
slices.Sort(member.pomFiles)
}
members[directory] = member
reactorVisited[directory] = true
if hasParent {
if operationError := workspace.addParentMember(members, parentDirectory, parentFile); operationError != nil {
return operationError
}
}
moduleRequired := make(map[string]bool)
for _, resolvedModel := range member.models {
for _, module := range resolvedModel.model.Modules {
moduleDirectory, inside := mavenRelativeDirectory(directory, mavenInterpolate(module, resolvedModel.properties))
if !inside {
continue
}
if !resolvedModel.profile {
moduleRequired[moduleDirectory] = true
} else if _, exists := moduleRequired[moduleDirectory]; !exists {
moduleRequired[moduleDirectory] = false
}
}
}
moduleDirectories := slices.Sorted(maps.Keys(moduleRequired))
availableModules := make([]string, 0, len(moduleDirectories))
for _, moduleDirectory := range moduleDirectories {
if _, operationError := workspace.readProject(moduleDirectory); operationError != nil {
if !moduleRequired[moduleDirectory] && errors.Is(operationError, os.ErrNotExist) {
continue
}
return operationError
}
availableModules = append(availableModules, moduleDirectory)
}
for _, moduleDirectory := range availableModules {
if operationError := visit(moduleDirectory); operationError != nil {
return operationError
}
}
return nil
}
return members, visit("")
}
// mavenRelativeDirectory resolves a module or parent reference, which may
// name a directory or a pom file, against the referencing module's directory.
func mavenRelativeDirectory(fromDirectory, reference string) (string, bool) {
reference = filepath.ToSlash(strings.TrimSpace(reference))
if strings.HasSuffix(reference, ".xml") {
reference = path.Dir(reference)
}
directory := path.Join(fromDirectory, reference)
if directory == "." {
directory = ""
}
if directory == ".." || strings.HasPrefix(directory, "../") {
return "", false
}
return directory, true
}
func mavenParentLocation(directory string, project mavenProject) (string, string, bool) {
if project.Parent.ArtifactId == "" {
return "", "", false
}
relativePath := "../pom.xml"
if project.Parent.RelativePath != nil {
relativePath = *project.Parent.RelativePath
}
if strings.TrimSpace(relativePath) == "" {
return "", "", false
}
projectPath := path.Join(directory, filepath.ToSlash(strings.TrimSpace(relativePath)))
if projectPath == ".." || strings.HasPrefix(projectPath, "../") {
return "", "", false
}
parentDirectory := path.Dir(projectPath)
if parentDirectory == "." {
parentDirectory = ""
}
return parentDirectory, path.Base(projectPath), true
}
func mavenEffectiveGroupId(project mavenProject) string {
if project.GroupId != "" {
return project.GroupId
}
return project.Parent.GroupId
}
func mavenEffectiveVersion(project mavenProject) string {
if project.Version != "" {
return project.Version
}
return project.Parent.Version
}
// mavenExcludeInputs keeps the globs out of the module's build output.
var mavenExcludeInputs = []string{"target/**"}
// mavenDefaultInputs are the files the built-in reads: the root pom and the
// pom of every module reachable from it.
func mavenDefaultInputs(resolverContext context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"pom.xml"}
members, operationError := mavenMembers(resolverContext, workspaceDirectory)
if operationError != nil {
return inputs, mavenExcludeInputs
}
for directory, member := range members {
for _, pomFile := range member.pomFiles {
input := path.Join(directory, pomFile)
if input != "pom.xml" {
inputs = append(inputs, input)
}
}
}
if document, operationError := mavenDependencies(resolverContext, workspaceDirectory); operationError == nil {
for _, reportedPackage := range document.Packages {
if reportedPackage.Fingerprint["profile-cycle"] != "" {
inputs = append(inputs, "**/src/**/*")
break
}
}
}
slices.Sort(inputs)
return inputs, mavenExcludeInputs
}
func readMavenProject(projectPath string) (mavenProject, error) {
var project mavenProject
contents, operationError := os.ReadFile(projectPath)
if operationError != nil {
return project, fmt.Errorf("read %s: %w", projectPath, operationError)
}
if operationError := xml.Unmarshal(contents, &project); operationError != nil {
return project, fmt.Errorf("parse %s: %w", projectPath, operationError)
}
return project, nil
}
package resolvers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/bmatcuk/doublestar/v4"
"gopkg.in/yaml.v3"
)
type nodeManifest struct {
Name string `json:"name"`
Workspaces json.RawMessage `json:"workspaces"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
OptionalDependencies map[string]string `json:"optionalDependencies"`
}
// nodePackageManager names the files one package manager keeps its
// workspace in. Without a workspaceFile the member globs come from
// "workspaces" in package.json.
type nodePackageManager struct {
workspaceFile string
lockfile string
}
var (
npmPackageManager = nodePackageManager{}
pnpmPackageManager = nodePackageManager{workspaceFile: "pnpm-workspace.yaml", lockfile: "pnpm-lock.yaml"}
// aube writes its workspace and lockfile in pnpm's v9 format.
aubePackageManager = nodePackageManager{workspaceFile: "aube-workspace.yaml", lockfile: "aube-lock.yaml"}
)
// dependencies maps the root and every workspace member to the members its
// dependencies, devDependencies, peerDependencies and optionalDependencies
// point at, plus the members the lockfile links it to.
func (packageManager nodePackageManager) dependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
directories, operationError := packageManager.memberDirectories(workspaceDirectory)
if operationError != nil {
return document, operationError
}
directories = append(directories, "")
linkedDirectories, operationError := packageManager.linkedDirectories(workspaceDirectory)
if operationError != nil {
return document, operationError
}
manifests := make(map[string]nodeManifest, len(directories))
directoryByName := make(map[string]string, len(directories))
for _, directory := range directories {
if operationError := resolverContext.Err(); operationError != nil {
return document, operationError
}
manifest, operationError := readNodeManifest(filepath.Join(workspaceDirectory, directory, "package.json"))
if operationError != nil {
return document, operationError
}
manifests[directory] = manifest
if manifest.Name != "" {
directoryByName[manifest.Name] = directory
}
}
for directory, manifest := range manifests {
reportedPackage := Package{Dependencies: []string{}, Inputs: []string{"**/*"}, ExcludeInputs: nodeExcludeInputs}
if directory == "" {
// Everything else under the root belongs to a member or to another tool.
reportedPackage.Inputs = []string{"package.json"}
}
dependencyDirectories := slices.Clone(linkedDirectories[directory])
for _, dependencies := range []map[string]string{manifest.Dependencies, manifest.DevDependencies, manifest.PeerDependencies, manifest.OptionalDependencies} {
for name, specifier := range dependencies {
if dependencyDirectory, isNamed := directoryByName[nodeDependencyName(name, specifier)]; isNamed {
dependencyDirectories = append(dependencyDirectories, dependencyDirectory)
}
for _, protocol := range []string{"link:", "file:"} {
if linkPath, isLink := strings.CutPrefix(specifier, protocol); isLink {
dependencyDirectories = append(dependencyDirectories, path.Join(directory, linkPath))
}
}
}
}
for _, dependencyDirectory := range dependencyDirectories {
if _, isMember := manifests[dependencyDirectory]; isMember && dependencyDirectory != directory {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyDirectory)
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[directory] = reportedPackage
}
return document, nil
}
// nodeDependencyName is the package a dependency installs: the aliased name
// for "workspace:<name>@<range>" and "npm:<name>@<range>", else its key.
func nodeDependencyName(key string, specifier string) string {
if alias, isAlias := strings.CutPrefix(specifier, "npm:"); isAlias {
if separator := strings.LastIndex(alias, "@"); separator > 0 {
return alias[:separator]
}
return alias
}
if alias, isAlias := strings.CutPrefix(specifier, "workspace:"); isAlias {
if separator := strings.LastIndex(alias, "@"); separator > 0 {
return alias[:separator]
}
}
return key
}
// linkedDirectories reads the lockfile's importers and maps each to the
// directories its dependencies resolve to as "link:<path>", which catches
// aliases and overrides. Nothing is linked before the first install.
func (packageManager nodePackageManager) linkedDirectories(workspaceDirectory string) (map[string][]string, error) {
if packageManager.lockfile == "" {
return nil, nil
}
lockfilePath := filepath.Join(workspaceDirectory, packageManager.lockfile)
contents, operationError := os.ReadFile(lockfilePath)
if errors.Is(operationError, os.ErrNotExist) {
return nil, nil
}
if operationError != nil {
return nil, operationError
}
type lockedDependencies map[string]struct {
Version string `yaml:"version"`
}
var lockfile struct {
Importers map[string]struct {
Dependencies lockedDependencies `yaml:"dependencies"`
DevDependencies lockedDependencies `yaml:"devDependencies"`
PeerDependencies lockedDependencies `yaml:"peerDependencies"`
OptionalDependencies lockedDependencies `yaml:"optionalDependencies"`
} `yaml:"importers"`
}
if operationError := yaml.Unmarshal(contents, &lockfile); operationError != nil {
return nil, fmt.Errorf("parse %s: %w", lockfilePath, operationError)
}
linkedDirectories := make(map[string][]string, len(lockfile.Importers))
for importer, importerDependencies := range lockfile.Importers {
directory := path.Clean(importer)
if directory == "." {
directory = ""
}
for _, dependencies := range []lockedDependencies{importerDependencies.Dependencies, importerDependencies.DevDependencies, importerDependencies.PeerDependencies, importerDependencies.OptionalDependencies} {
for _, dependency := range dependencies {
if linkPath, isLink := strings.CutPrefix(dependency.Version, "link:"); isLink {
linkedDirectories[directory] = append(linkedDirectories[directory], path.Join(directory, linkPath))
}
}
}
}
return linkedDirectories, nil
}
// memberDirectories resolves the workspace's member globs to the
// directories holding a package.json. A "!" pattern removes its matches.
func (packageManager nodePackageManager) memberDirectories(workspaceDirectory string) ([]string, error) {
patterns, operationError := packageManager.memberPatterns(workspaceDirectory)
if operationError != nil {
return nil, operationError
}
fileSystem := os.DirFS(workspaceDirectory)
var directories, excluded []string
for _, pattern := range patterns {
negated := strings.HasPrefix(pattern, "!")
pattern = strings.TrimSuffix(strings.TrimPrefix(pattern, "!"), "/")
matches, operationError := doublestar.Glob(fileSystem, path.Join(pattern, "package.json"))
if operationError != nil {
return nil, fmt.Errorf("resolve workspace pattern %q: %w", pattern, operationError)
}
for _, match := range matches {
directory := path.Dir(match)
if directory == "." || slices.Contains(strings.Split(directory, "/"), "node_modules") {
continue
}
if negated {
excluded = append(excluded, directory)
} else {
directories = append(directories, directory)
}
}
}
directories = slices.DeleteFunc(directories, func(directory string) bool { return slices.Contains(excluded, directory) })
slices.Sort(directories)
return slices.Compact(directories), nil
}
// memberPatterns reads the member globs from the package manager's
// workspace file, else from "workspaces" in package.json, which npm writes
// as a list and yarn may wrap in an object.
func (packageManager nodePackageManager) memberPatterns(workspaceDirectory string) ([]string, error) {
if packageManager.workspaceFile != "" {
workspacePath := filepath.Join(workspaceDirectory, packageManager.workspaceFile)
contents, operationError := os.ReadFile(workspacePath)
if errors.Is(operationError, os.ErrNotExist) {
return nil, fmt.Errorf("no %s in %s", packageManager.workspaceFile, workspaceDirectory)
}
if operationError != nil {
return nil, operationError
}
var workspace struct {
Packages []string `yaml:"packages"`
}
if operationError := yaml.Unmarshal(contents, &workspace); operationError != nil {
return nil, fmt.Errorf("parse %s: %w", workspacePath, operationError)
}
return workspace.Packages, nil
}
manifest, operationError := readNodeManifest(filepath.Join(workspaceDirectory, "package.json"))
if operationError != nil {
return nil, operationError
}
var patterns []string
if len(manifest.Workspaces) > 0 && json.Unmarshal(manifest.Workspaces, &patterns) != nil {
var workspaces struct {
Packages []string `json:"packages"`
}
if operationError := json.Unmarshal(manifest.Workspaces, &workspaces); operationError != nil {
return nil, fmt.Errorf("parse workspaces in %s: %w", filepath.Join(workspaceDirectory, "package.json"), operationError)
}
patterns = workspaces.Packages
}
if len(patterns) == 0 {
return nil, fmt.Errorf("no workspaces in %s", filepath.Join(workspaceDirectory, "package.json"))
}
return patterns, nil
}
func readNodeManifest(manifestPath string) (nodeManifest, error) {
var manifest nodeManifest
contents, operationError := os.ReadFile(manifestPath)
if operationError != nil {
return manifest, fmt.Errorf("read %s: %w", manifestPath, operationError)
}
if operationError := json.Unmarshal(contents, &manifest); operationError != nil {
return manifest, fmt.Errorf("parse %s: %w", manifestPath, operationError)
}
return manifest, nil
}
// nodeExcludeInputs keeps every glob out of installed packages.
var nodeExcludeInputs = []string{"**/node_modules/**"}
// defaultInputs are the files the built-in reads: package.json, the package
// manager's workspace file and lockfile, and a package.json under every
// member glob, so a new member is seen without a re-declaration. Negated
// member globs become exclusions.
func (packageManager nodePackageManager) defaultInputs(_ context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"package.json"}
for _, fileName := range []string{packageManager.workspaceFile, packageManager.lockfile} {
if fileName != "" {
inputs = append(inputs, fileName)
}
}
excludeInputs = nodeExcludeInputs
patterns, operationError := packageManager.memberPatterns(workspaceDirectory)
if operationError != nil {
return inputs, excludeInputs
}
for _, pattern := range patterns {
if negated := strings.HasPrefix(pattern, "!"); negated {
excludeInputs = append(excludeInputs, strings.TrimSuffix(strings.TrimPrefix(pattern, "!"), "/**")+"/**")
} else {
inputs = append(inputs, path.Join(pattern, "package.json"))
}
}
return inputs, excludeInputs
}
// Package resolvers holds the dependency resolver protocol document and the
// built-in resolvers that produce it without shelling out.
package resolvers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"slices"
"strings"
)
// Document is version 1 of the output a dependency resolver prints.
type Document struct {
Version int `json:"version"`
Packages map[string]Package `json:"packages"`
}
// Package is one directory a resolver reports, relative to its declaring package.
type Package struct {
Dependencies []string `json:"dependencies"`
// Inputs let the resolver stand in for a package that registers no target:
// grog synthesizes a filegroup from them. Ignored when a target registers.
Inputs []string `json:"inputs"`
ExcludeInputs []string `json:"exclude_inputs"`
// Fingerprint values become part of a registered or synthesized target's hash.
Fingerprint map[string]string `json:"fingerprint,omitempty"`
}
// UnmarshalJSON requires each package entry to be an object.
func (reportedPackage *Package) UnmarshalJSON(contents []byte) error {
if bytes.Equal(bytes.TrimSpace(contents), []byte("null")) {
return fmt.Errorf("package must be an object")
}
type packageFields Package
return json.Unmarshal(contents, (*packageFields)(reportedPackage))
}
// Builtin is a resolver shipped with grog, selected by its builtin:: command.
type Builtin struct {
Resolve func(resolverContext context.Context, workspaceDirectory string) (Document, error)
// DefaultInputs are used when a declaration lists no inputs of its own.
DefaultInputs func(resolverContext context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string)
}
// Builtins maps each builtin:: command to its resolver.
var Builtins = map[string]Builtin{
"builtin::aube": {aubePackageManager.dependencies, aubePackageManager.defaultInputs},
"builtin::cargo": {cargoDependencies, cargoDefaultInputs},
"builtin::dotnet": {dotnetDependencies, dotnetDefaultInputs},
"builtin::go": {goDependencies, goDefaultInputs},
"builtin::gradle": {gradleDependencies, gradleDefaultInputs},
"builtin::maven": {mavenDependencies, mavenDefaultInputs},
"builtin::npm": {npmPackageManager.dependencies, npmPackageManager.defaultInputs},
"builtin::pnpm": {pnpmPackageManager.dependencies, pnpmPackageManager.defaultInputs},
"builtin::uv": {uvDependencies, uvDefaultInputs},
"builtin::yarn": {npmPackageManager.dependencies, npmPackageManager.defaultInputs},
}
// ValidatePath checks a path a resolver reports against the protocol.
func ValidatePath(entry string) error {
if strings.HasPrefix(entry, "/") || strings.HasPrefix(entry, "./") || strings.HasSuffix(entry, "/") || strings.Contains(entry, "\\") || (len(entry) > 1 && entry[1] == ':') || slices.Contains(strings.Split(entry, "/"), "..") {
return fmt.Errorf("invalid path %q: paths must be relative, slash-separated, without a leading ./, trailing /, or .. segment", entry)
}
return nil
}
package resolvers
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/pelletier/go-toml/v2"
)
type uvLock struct {
Manifest struct {
Members []string `toml:"members"`
} `toml:"manifest"`
Packages []uvLockPackage `toml:"package"`
}
// memberDirectories maps workspace members to their directories. A local
// path dependency outside the workspace also has a directory source, so
// membership comes from [manifest].members, not from the source kind.
func (lock uvLock) memberDirectories() map[string]string {
directories := make(map[string]string)
for _, lockPackage := range lock.Packages {
if directory := lockPackage.memberDirectory(); directory != "" && slices.Contains(lock.Manifest.Members, lockPackage.Name) {
directories[lockPackage.Name] = directory
}
}
return directories
}
type uvLockPackage struct {
Name string `toml:"name"`
Source struct {
Editable string `toml:"editable"`
Directory string `toml:"directory"`
Virtual string `toml:"virtual"`
} `toml:"source"`
Dependencies []uvLockDependency `toml:"dependencies"`
OptionalDependencies map[string][]uvLockDependency `toml:"optional-dependencies"`
}
type uvLockDependency struct {
Name string `toml:"name"`
}
// memberDirectory is the workspace-relative directory of a member, or "" for
// index packages and the root project, which has no directory of its own.
func (lockPackage uvLockPackage) memberDirectory() string {
for _, directory := range []string{lockPackage.Source.Editable, lockPackage.Source.Directory, lockPackage.Source.Virtual} {
if directory != "" && directory != "." {
return path.Clean(filepath.ToSlash(directory))
}
}
return ""
}
type uvPyproject struct {
Project struct {
Name string `toml:"name"`
} `toml:"project"`
BuildSystem struct {
BuildBackend string `toml:"build-backend"`
} `toml:"build-system"`
Tool struct {
Uv struct {
BuildBackend *struct {
ModuleName any `toml:"module-name"`
ModuleRoot *string `toml:"module-root"`
} `toml:"build-backend"`
} `toml:"uv"`
} `toml:"tool"`
}
func uvDependencies(resolverContext context.Context, workspaceDirectory string) (Document, error) {
document := Document{Version: 1, Packages: make(map[string]Package)}
lock, operationError := readUvLock(workspaceDirectory)
if operationError != nil {
return document, operationError
}
directoryByName := lock.memberDirectories()
for _, lockPackage := range lock.Packages {
if operationError := resolverContext.Err(); operationError != nil {
return document, operationError
}
directory, isMember := directoryByName[lockPackage.Name]
if !isMember {
continue
}
// Dev groups are left out: uv permits member cycles, and a test-only
// edge back to a dependant is the common way to get one.
dependencies := slices.Clone(lockPackage.Dependencies)
for _, optional := range lockPackage.OptionalDependencies {
dependencies = append(dependencies, optional...)
}
reportedPackage := Package{Dependencies: []string{}, Inputs: uvInputs(filepath.Join(workspaceDirectory, directory)), ExcludeInputs: uvExcludeInputs}
for _, dependency := range dependencies {
if dependencyDirectory, isMember := directoryByName[dependency.Name]; isMember && dependencyDirectory != directory {
reportedPackage.Dependencies = append(reportedPackage.Dependencies, dependencyDirectory)
}
}
slices.Sort(reportedPackage.Dependencies)
reportedPackage.Dependencies = slices.Compact(reportedPackage.Dependencies)
document.Packages[directory] = reportedPackage
}
return document, nil
}
// uvInputs is the package's manifest plus its module tree. [tool.uv.build-backend]
// enumerates the modules exactly; any other backend may package more than the
// conventional src/<name> or <name> directory, so every Python file is added
// on top of it to keep the inputs a superset.
func uvInputs(memberDirectory string) []string {
inputs := []string{"pyproject.toml"}
contents, operationError := os.ReadFile(filepath.Join(memberDirectory, "pyproject.toml"))
var pyproject uvPyproject
if operationError == nil {
operationError = toml.Unmarshal(contents, &pyproject)
}
if operationError != nil {
return append(inputs, "**/*.py", "**/*.pyi")
}
normalizedName := strings.NewReplacer("-", "_", ".", "_").Replace(strings.ToLower(pyproject.Project.Name))
var moduleDirectories []string
backend := pyproject.Tool.Uv.BuildBackend
if backend == nil && pyproject.BuildSystem.BuildBackend == "uv_build" {
backend = new(struct {
ModuleName any `toml:"module-name"`
ModuleRoot *string `toml:"module-root"`
})
}
if backend != nil {
moduleRoot := "src"
if backend.ModuleRoot != nil {
moduleRoot = *backend.ModuleRoot
}
moduleNames := []string{normalizedName}
switch names := backend.ModuleName.(type) {
case string:
moduleNames = []string{names}
case []any:
moduleNames = nil
for _, name := range names {
if nameString, isString := name.(string); isString {
moduleNames = append(moduleNames, nameString)
}
}
}
for _, moduleName := range moduleNames {
moduleDirectories = append(moduleDirectories, path.Join(moduleRoot, strings.ReplaceAll(moduleName, ".", "/")))
}
} else {
inputs = append(inputs, "**/*.py", "**/*.pyi")
for _, candidate := range []string{path.Join("src", normalizedName), normalizedName} {
if info, statError := os.Stat(filepath.Join(memberDirectory, filepath.FromSlash(candidate))); statError == nil && info.IsDir() {
moduleDirectories = append(moduleDirectories, candidate)
}
}
}
for _, moduleDirectory := range moduleDirectories {
inputs = append(inputs, path.Join(moduleDirectory, "**/*"))
}
slices.Sort(inputs)
return slices.Compact(inputs)
}
// uvExcludeInputs keeps every glob out of virtual environments and bytecode.
var uvExcludeInputs = []string{"**/.venv/**", "**/__pycache__/**"}
// uvDefaultInputs are the files the built-in reads: the lock, the root
// manifest, and every member's manifest, which decides its module layout.
func uvDefaultInputs(_ context.Context, workspaceDirectory string) (inputs []string, excludeInputs []string) {
inputs = []string{"uv.lock", "pyproject.toml"}
lock, operationError := readUvLock(workspaceDirectory)
if operationError != nil {
return inputs, uvExcludeInputs
}
for _, directory := range lock.memberDirectories() {
inputs = append(inputs, path.Join(directory, "pyproject.toml"))
}
slices.Sort(inputs)
return slices.Compact(inputs), uvExcludeInputs
}
func readUvLock(workspaceDirectory string) (uvLock, error) {
var lock uvLock
lockPath := filepath.Join(workspaceDirectory, "uv.lock")
contents, operationError := os.ReadFile(lockPath)
if operationError != nil {
return lock, fmt.Errorf("read uv lock %s: %w", lockPath, operationError)
}
if operationError := toml.Unmarshal(contents, &lock); operationError != nil {
return lock, fmt.Errorf("parse uv lock %s: %w", lockPath, operationError)
}
return lock, nil
}
package selection
import (
"fmt"
"strings"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/model"
)
/*
When selecting targets for a build/test/run we want to additionally check the platform
selectors when querying.
This could (should?) conceivably live in the analysis package so that queries and build share the same
target selection.
*/
// SelectTargetsForBuild sets targets as selected
// returns the number of selected targets, the number of targets skipped due to platform mismatch
// and an error if a selected target depends on a target that does not match the platform.
func (s *Selector) SelectTargetsForBuild(
graph *dag.DirectedTargetGraph,
) (int, int, error) {
platformSkipped := 0
for _, node := range graph.GetNodes() {
// Match pattern and test flag
if s.nodeMatchesFilters(node) {
if !nodeMatchesPlatform(node) {
platformSkipped += 1
continue // Skip targets that don't match the platform
}
node.Select()
if err := s.selectAllAncestorsForBuild(graph, []string{node.GetLabel().String()}, node); err != nil {
return 0, 0, err
}
}
}
// Doing it all in one loop would be faster, but this is easier to reason about
selectedCount := 0
for _, node := range graph.GetNodes() {
if node.GetIsSelected() && node.GetType() == model.TargetNode {
selectedCount++
}
}
return selectedCount, platformSkipped, nil
}
// selectAllAncestorsForBuild recursively selects all ancestors of the given node
// and returns the number of selected targets.
func (s *Selector) selectAllAncestorsForBuild(
graph *dag.DirectedTargetGraph,
depChain []string,
node model.BuildNode,
) error {
for _, ancestor := range graph.GetDependencies(node) {
nextChain := append(append([]string{}, depChain...), ancestor.GetLabel().String())
if !nodeMatchesPlatform(ancestor) {
depChainStr := strings.Join(nextChain[1:], " -> ")
if len(config.Global.PlatformTags) > 0 {
return fmt.Errorf("could not select node %s because it depends on %s, which does not match the platform %s (active platform tags: %v)",
depChain[0], depChainStr, config.Global.GetPlatform(), config.Global.PlatformTags)
}
return fmt.Errorf("could not select node %s because it depends on %s, which does not match the platform %s",
depChain[0], depChainStr, config.Global.GetPlatform())
}
ancestor.Select()
if err := s.selectAllAncestorsForBuild(graph, nextChain, ancestor); err != nil {
return err
}
}
return nil
}
package selection
import (
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/model"
)
// SelectTargets sets targets as selected.
func (s *Selector) SelectTargets(
graph *dag.DirectedTargetGraph,
) {
for _, node := range graph.GetNodes() {
if s.nodeMatchesFilters(node) && nodeMatchesPlatform(node) {
node.Select()
}
}
}
func (s *Selector) FilterNodes(nodes []model.BuildNode) []model.BuildNode {
var filteredLabels []model.BuildNode
for _, node := range nodes {
if s.Match(node) {
filteredLabels = append(filteredLabels, node)
}
}
return filteredLabels
}
func (s *Selector) Match(node model.BuildNode) bool {
return s.nodeMatchesFilters(node) && nodeMatchesPlatform(node)
}
package selection
import (
"fmt"
"slices"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
)
type TargetTypeSelection string
const (
TestOnly TargetTypeSelection = "test_only"
NonTestOnly TargetTypeSelection = "non_test_only"
BinOutput TargetTypeSelection = "bin_output"
AllTargets TargetTypeSelection = "all_targets"
)
// StringToTargetTypeSelection converts a string to a TargetTypeSelection.
func StringToTargetTypeSelection(targetType string) (TargetTypeSelection, error) {
switch targetType {
case "test":
return TestOnly, nil
case "no_test":
return NonTestOnly, nil
case "bin_output":
return BinOutput, nil
case "all":
return AllTargets, nil
default:
return AllTargets, fmt.Errorf("invalid target type: %s", targetType)
}
}
// Selector bundles the default selection options into a single interface.
type Selector struct {
Patterns []label.TargetPattern
Tags []string
ExcludeTags []string
TargetType TargetTypeSelection
}
func New(
patterns []label.TargetPattern,
tags []string,
excludeTags []string,
targetType TargetTypeSelection,
) *Selector {
return &Selector{Patterns: patterns, Tags: tags, ExcludeTags: excludeTags, TargetType: targetType}
}
func (s *Selector) nodeMatchesFilters(
node model.BuildNode,
) bool {
target, ok := node.(*model.Target)
if !ok {
// For non-Target nodes (like Alias, Environment), still check pattern matching
return s.nodeMatchesPatterns(node)
}
return s.targetMatchesTypeSelection(target) &&
s.targetMatchesPatterns(target) &&
s.targetTagsMatch(target) &&
!s.targetExcludeTagsMatch(target)
}
func (s *Selector) nodeMatchesPatterns(node model.BuildNode) bool {
for _, pattern := range s.Patterns {
if pattern.Matches(node.GetLabel()) {
return true
}
}
return len(s.Patterns) == 0
}
func (s *Selector) targetMatchesPatterns(target *model.Target) bool {
for _, pattern := range s.Patterns {
if pattern.Matches(target.Label) {
return true
}
}
return len(s.Patterns) == 0
}
func (s *Selector) targetTagsMatch(target *model.Target) bool {
hasTag := false
for _, tag := range s.Tags {
if slices.Contains(target.Tags, tag) {
hasTag = true
break
}
}
return hasTag || len(s.Tags) == 0
}
func (s *Selector) targetExcludeTagsMatch(target *model.Target) bool {
hasTag := false
for _, tag := range s.ExcludeTags {
if slices.Contains(target.Tags, tag) {
hasTag = true
break
}
}
return hasTag
}
func (s *Selector) targetMatchesTypeSelection(target *model.Target) bool {
return TargetMatchesTypeSelection(target, s.TargetType)
}
package selection
import (
"slices"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/model"
)
func nodeMatchesPlatform(node model.BuildNode) bool {
target, ok := node.(*model.Target)
if !ok {
return true
}
if config.Global.AllPlatforms {
return true
}
if len(target.Platforms) == 0 {
return true
}
if slices.Contains(target.Platforms, config.Global.GetPlatform()) {
return true
}
for _, tag := range config.Global.PlatformTags {
if slices.Contains(target.Platforms, tag) {
return true
}
}
return false
}
func TargetMatchesTypeSelection(target *model.Target, targetType TargetTypeSelection) bool {
switch targetType {
case TestOnly:
return target.IsTest()
case NonTestOnly:
return !target.IsTest()
case BinOutput:
return target.HasBinOutput()
case AllTargets:
return true
}
return false
}
package shell
import (
"context"
"fmt"
"os"
"os/exec"
"time"
"github.com/chrismatix/grog/internal/config"
)
// WithDefaultFlags applies the configured shell failure behavior.
func WithDefaultFlags(command string) string {
if config.Global.DisableDefaultShellFlags {
return command
}
return "set -eu\n" + command
}
// NewCommand runs a temporary script, avoiding the shell argument size limit.
// The caller must call cleanup after the command finishes.
func NewCommand(commandContext context.Context, script string, arguments ...string) (*exec.Cmd, func(), error) {
scriptFile, operationError := os.CreateTemp("", "grog-cmd-*.sh")
if operationError != nil {
return nil, nil, fmt.Errorf("failed to create command script file: %w", operationError)
}
cleanup := func() { _ = os.Remove(scriptFile.Name()) }
if _, operationError := scriptFile.WriteString(script); operationError != nil {
_ = scriptFile.Close()
cleanup()
return nil, nil, fmt.Errorf("failed to write command script file: %w", operationError)
}
if operationError := scriptFile.Close(); operationError != nil {
cleanup()
return nil, nil, fmt.Errorf("failed to close command script file: %w", operationError)
}
command := exec.CommandContext(commandContext, "sh", append([]string{scriptFile.Name()}, arguments...)...)
command.WaitDelay = time.Second
return command, cleanup, nil
}
package tracing
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/dag"
"github.com/chrismatix/grog/internal/execution"
"github.com/chrismatix/grog/internal/label"
"github.com/chrismatix/grog/internal/model"
)
// TraceCollector gathers build metadata and produces a BuildTrace after execution.
type TraceCollector struct {
traceID string
startTime time.Time
command string
requestedPatterns []label.TargetPattern
grogVersion string
}
// NewTraceCollector creates a collector that records metadata at build start.
func NewTraceCollector(
command string,
patterns []label.TargetPattern,
grogVersion string,
) *TraceCollector {
return &TraceCollector{
traceID: uuid.New().String(),
startTime: time.Now(),
command: command,
requestedPatterns: patterns,
grogVersion: grogVersion,
}
}
// githubContextVariables are recorded as github.* build tags so a trace can be
// joined back to the Actions run and job that produced it.
var githubContextVariables = []string{
"GITHUB_REPOSITORY",
"GITHUB_WORKFLOW",
"GITHUB_JOB",
"GITHUB_RUN_ID",
"GITHUB_RUN_ATTEMPT",
"GITHUB_EVENT_NAME",
"GITHUB_REF_NAME",
"GITHUB_HEAD_REF",
}
// collectBuildTags merges the GitHub Actions context with --trace-tag values
// (which win on conflict) into a JSON object, or "" when there are none.
func collectBuildTags() string {
tags := map[string]string{}
for _, variable := range githubContextVariables {
if value := os.Getenv(variable); value != "" {
tags["github."+strings.ToLower(strings.TrimPrefix(variable, "GITHUB_"))] = value
}
}
for _, tag := range config.Global.TraceTags {
key, value, _ := strings.Cut(tag, "=")
tags[key] = value
}
if len(tags) == 0 {
return ""
}
encoded, _ := json.Marshal(tags)
return string(encoded)
}
const maxCommandLen = 1024
func truncateCommand(cmd string) string {
if len(cmd) > maxCommandLen {
return cmd[:maxCommandLen]
}
return cmd
}
// Finalize builds the BuildTrace from the completion map and graph after execution.
func (c *TraceCollector) Finalize(
completionMap dag.CompletionMap,
graph *dag.DirectedTargetGraph,
asyncWaitTime time.Duration,
) *BuildTrace {
totalDuration := time.Since(c.startTime)
gitCommit, _ := config.GetGitHash()
gitBranch, _ := config.GetGitBranch()
workspace := filepath.Base(config.Global.WorkspaceRoot)
platform := fmt.Sprintf("%s/%s", config.Global.OS, config.Global.Arch)
isCI := os.Getenv("CI") == "1"
var patterns []string
for _, p := range c.requestedPatterns {
patterns = append(patterns, p.String())
}
trace := &BuildTrace{
Build: BuildRow{
TraceID: c.traceID,
Workspace: workspace,
GitCommit: gitCommit,
GitBranch: gitBranch,
GrogVersion: c.grogVersion,
Platform: platform,
Command: c.command,
StartTimeUnixMillis: c.startTime.UnixMilli(),
TotalDurationMillis: totalDuration.Milliseconds(),
RequestedPatterns: strings.Join(patterns, ","),
IsCI: isCI,
AsyncCacheWaitMillis: asyncWaitTime.Milliseconds(),
Tags: collectBuildTags(),
},
}
// Compute critical path if available
if criticalPath, ok := graph.GetSelectedSubgraph().FindCriticalPath(); ok && len(criticalPath.Nodes) > 0 {
trace.Build.CriticalPathExecMillis = criticalPath.ExecutionDuration.Milliseconds()
trace.Build.CriticalPathCacheMillis = criticalPath.CacheDuration.Milliseconds()
var labels []string
for _, node := range criticalPath.Nodes {
labels = append(labels, node.GetLabel().String())
}
trace.Build.CriticalPath = strings.Join(labels, ",")
}
// Build spans from completion map
nodes := graph.GetNodes()
for targetLabel, completion := range completionMap {
if completion.NodeType != model.TargetNode {
continue
}
node, ok := nodes[targetLabel]
if !ok {
continue
}
target, ok := node.(*model.Target)
if !ok {
continue
}
span := c.buildSpan(target, &completion)
trace.Spans = append(trace.Spans, span)
trace.Build.TotalTargets++
if completion.IsSuccess {
trace.Build.SuccessCount++
if completion.CacheResult == dag.CacheHit {
trace.Build.CacheHitCount++
}
} else {
trace.Build.FailureCount++
}
}
return trace
}
func (c *TraceCollector) buildSpan(target *model.Target, completion *dag.Completion) SpanRow {
span := SpanRow{
TraceID: c.traceID,
Label: target.Label.String(),
Package: target.Label.Package,
ChangeHash: target.ChangeHash,
OutputHash: target.OutputHash,
Command: truncateCommand(target.Command),
IsTest: target.IsTest(),
Tags: strings.Join(target.Tags, ","),
}
// Status
if completion.IsSuccess {
span.Status = "SUCCESS"
} else if completion.Err != nil {
span.Status = "FAILURE"
if commandError, isCommandError := errors.AsType[*execution.CommandError](completion.Err); isCommandError {
span.ExitCode = int32(commandError.ExitCode)
}
} else {
span.Status = "CANCELLED"
}
// Cache result
switch completion.CacheResult {
case dag.CacheHit:
span.CacheResult = "CACHE_HIT"
case dag.CacheSkip:
span.CacheResult = "CACHE_SKIP"
default:
span.CacheResult = "CACHE_MISS"
}
// Dependencies
var deps []string
for _, dep := range target.Dependencies {
deps = append(deps, dep.String())
}
span.Dependencies = strings.Join(deps, ",")
// Timing
if !target.StartTime.IsZero() {
span.StartTimeUnixMillis = target.StartTime.UnixMilli()
totalDuration := target.QueueWait + target.HashDuration + target.CacheCheckTime +
target.ExecutionTime + target.OutputWriteTime + target.OutputLoadTime +
target.CacheWriteTime + target.DepLoadTime
endTime := target.StartTime.Add(totalDuration)
span.EndTimeUnixMillis = endTime.UnixMilli()
span.TotalDurationMillis = totalDuration.Milliseconds()
}
span.QueueWaitMillis = target.QueueWait.Milliseconds()
span.HashDurationMillis = target.HashDuration.Milliseconds()
span.CacheCheckMillis = target.CacheCheckTime.Milliseconds()
span.CommandDurationMillis = target.ExecutionTime.Milliseconds()
span.OutputWriteMillis = target.OutputWriteTime.Milliseconds()
span.OutputLoadMillis = target.OutputLoadTime.Milliseconds()
span.CacheWriteMillis = target.CacheWriteTime.Milliseconds()
span.DepLoadMillis = target.DepLoadTime.Milliseconds()
return span
}
package tracing
import (
"bufio"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"maps"
"slices"
"strings"
"time"
)
// SpanLoader loads spans for a batch of traces in one query.
// Satisfied by *TraceStore.
type SpanLoader interface {
LoadSpansForTraces(ctx context.Context, traceIDs []string) (map[string][]SpanRow, error)
}
// exportChunkSize bounds how many traces' spans are held in memory at once.
const exportChunkSize = 200
func buildJSONLObj(b BuildRow, spans []SpanRow) map[string]any {
return map[string]any{
"trace_id": b.TraceID,
"workspace": b.Workspace,
"git_commit": b.GitCommit,
"git_branch": b.GitBranch,
"grog_version": b.GrogVersion,
"platform": b.Platform,
"command": b.Command,
"start_time_unix_millis": b.StartTimeUnixMillis,
"total_duration_millis": b.TotalDurationMillis,
"total_targets": b.TotalTargets,
"success_count": b.SuccessCount,
"failure_count": b.FailureCount,
"cache_hit_count": b.CacheHitCount,
"critical_path_exec_millis": b.CriticalPathExecMillis,
"critical_path_cache_millis": b.CriticalPathCacheMillis,
"async_cache_wait_millis": b.AsyncCacheWaitMillis,
"is_ci": b.IsCI,
"requested_patterns": b.RequestedPatterns,
"spans": spans,
}
}
// ExportJSONL streams traces as newline-delimited JSON, loading spans in
// chunks so memory stays bounded regardless of how many builds are exported.
func ExportJSONL(ctx context.Context, loader SpanLoader, builds []BuildRow, w io.Writer) error {
bw := bufio.NewWriter(w)
encoder := json.NewEncoder(bw)
err := forEachChunk(ctx, loader, builds, func(b BuildRow, spans []SpanRow) error {
if err := encoder.Encode(buildJSONLObj(b, spans)); err != nil {
return fmt.Errorf("encode trace %s: %w", b.TraceID, err)
}
return nil
})
if err != nil {
return err
}
return bw.Flush()
}
// ExportOTLP streams traces as an OTLP JSON document. The top-level object
// must be emitted as a single value, so spans are still loaded chunk-by-chunk
// but each resourceSpans entry is written to the buffered writer as it is
// produced to avoid holding every OTLP span in memory.
func ExportOTLP(ctx context.Context, loader SpanLoader, builds []BuildRow, w io.Writer) error {
bw := bufio.NewWriter(w)
if _, err := bw.WriteString("{\n \"resourceSpans\": ["); err != nil {
return err
}
first := true
err := forEachChunk(ctx, loader, builds, func(b BuildRow, spans []SpanRow) error {
rs := buildOTLPResourceSpans(&BuildTrace{Build: b, Spans: spans})
data, err := json.MarshalIndent(rs, " ", " ")
if err != nil {
return fmt.Errorf("encode trace %s: %w", b.TraceID, err)
}
if first {
if _, err := bw.WriteString("\n "); err != nil {
return err
}
first = false
} else {
if _, err := bw.WriteString(",\n "); err != nil {
return err
}
}
_, err = bw.Write(data)
return err
})
if err != nil {
return err
}
if _, err = bw.WriteString("\n ]\n}\n"); err != nil {
return err
}
return bw.Flush()
}
func forEachChunk(ctx context.Context, loader SpanLoader, builds []BuildRow, emit func(BuildRow, []SpanRow) error) error {
for start := 0; start < len(builds); start += exportChunkSize {
end := min(start+exportChunkSize, len(builds))
chunk := builds[start:end]
ids := make([]string, len(chunk))
for i, b := range chunk {
ids[i] = b.TraceID
}
spansByID, err := loader.LoadSpansForTraces(ctx, ids)
if err != nil {
return fmt.Errorf("load spans: %w", err)
}
for _, b := range chunk {
if err := emit(b, spansByID[b.TraceID]); err != nil {
return err
}
}
}
return nil
}
// OTLP JSON structures
type otlpExport struct {
ResourceSpans []otlpResourceSpans `json:"resourceSpans"`
}
type otlpResourceSpans struct {
Resource otlpResource `json:"resource"`
ScopeSpans []otlpScopeSpans `json:"scopeSpans"`
}
type otlpResource struct {
Attributes []otlpKeyValue `json:"attributes"`
}
type otlpScopeSpans struct {
Scope otlpScope `json:"scope"`
Spans []otlpSpan `json:"spans"`
}
type otlpScope struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
}
type otlpSpan struct {
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
ParentSpanID string `json:"parentSpanId,omitempty"`
Name string `json:"name"`
Kind int `json:"kind"`
StartTimeUnixNano string `json:"startTimeUnixNano"`
EndTimeUnixNano string `json:"endTimeUnixNano"`
Attributes []otlpKeyValue `json:"attributes"`
Status *otlpStatus `json:"status,omitempty"`
}
type otlpStatus struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
type otlpKeyValue struct {
Key string `json:"key"`
Value otlpValue `json:"value"`
}
type otlpValue struct {
StringValue *string `json:"stringValue,omitempty"`
IntValue *string `json:"intValue,omitempty"`
BoolValue *bool `json:"boolValue,omitempty"`
ArrayValue *otlpArrayValue `json:"arrayValue,omitempty"`
}
type otlpArrayValue struct {
Values []otlpValue `json:"values"`
}
func stringVal(s string) otlpValue { return otlpValue{StringValue: &s} }
func intVal(i int64) otlpValue { s := fmt.Sprintf("%d", i); return otlpValue{IntValue: &s} }
func boolVal(b bool) otlpValue { return otlpValue{BoolValue: &b} }
// stringListVal turns a comma-joined list (labels, tags) into an OTLP array.
func stringListVal(joined string) otlpValue {
values := []otlpValue{}
if joined != "" {
for item := range strings.SplitSeq(joined, ",") {
values = append(values, stringVal(item))
}
}
return otlpValue{ArrayValue: &otlpArrayValue{Values: values}}
}
func buildOTLPResourceSpans(trace *BuildTrace) otlpResourceSpans {
traceIDBytes := uuidToBytes(trace.Build.TraceID)
traceID := hex.EncodeToString(traceIDBytes)
rootSpanID := generateSpanID(trace.Build.TraceID, "root")
startNano := fmt.Sprintf("%d", time.UnixMilli(trace.Build.StartTimeUnixMillis).UnixNano())
endNano := fmt.Sprintf("%d", time.UnixMilli(trace.Build.StartTimeUnixMillis+trace.Build.TotalDurationMillis).UnixNano())
rootSpan := otlpSpan{
TraceID: traceID,
SpanID: rootSpanID,
Name: fmt.Sprintf("grog %s", trace.Build.Command),
Kind: 1,
StartTimeUnixNano: startNano,
EndTimeUnixNano: endNano,
Attributes: []otlpKeyValue{
{Key: "grog.workspace", Value: stringVal(trace.Build.Workspace)},
{Key: "grog.git_commit", Value: stringVal(trace.Build.GitCommit)},
{Key: "grog.git_branch", Value: stringVal(trace.Build.GitBranch)},
{Key: "grog.total_targets", Value: intVal(int64(trace.Build.TotalTargets))},
{Key: "grog.cache_hit_count", Value: intVal(int64(trace.Build.CacheHitCount))},
{Key: "grog.failure_count", Value: intVal(int64(trace.Build.FailureCount))},
{Key: "grog.is_ci", Value: boolVal(trace.Build.IsCI)},
{Key: "grog.requested_patterns", Value: stringVal(trace.Build.RequestedPatterns)},
{Key: "grog.command", Value: stringVal(trace.Build.Command)},
{Key: "grog.success_count", Value: intVal(int64(trace.Build.SuccessCount))},
{Key: "grog.critical_path_exec_ms", Value: intVal(trace.Build.CriticalPathExecMillis)},
{Key: "grog.critical_path_cache_ms", Value: intVal(trace.Build.CriticalPathCacheMillis)},
{Key: "grog.critical_path", Value: stringListVal(trace.Build.CriticalPath)},
{Key: "grog.async_cache_wait_ms", Value: intVal(trace.Build.AsyncCacheWaitMillis)},
},
Status: &otlpStatus{Code: 1},
}
var tags map[string]string
if err := json.Unmarshal([]byte(trace.Build.Tags), &tags); err == nil {
for _, key := range slices.Sorted(maps.Keys(tags)) {
rootSpan.Attributes = append(rootSpan.Attributes, otlpKeyValue{Key: "grog.tag." + key, Value: stringVal(tags[key])})
}
}
if trace.Build.FailureCount > 0 {
rootSpan.Status = &otlpStatus{Code: 2, Message: fmt.Sprintf("%d targets failed", trace.Build.FailureCount)}
}
spans := []otlpSpan{rootSpan}
for i, s := range trace.Spans {
spanID := generateSpanID(trace.Build.TraceID, fmt.Sprintf("span-%d", i))
sStartNano := fmt.Sprintf("%d", time.UnixMilli(s.StartTimeUnixMillis).UnixNano())
sEndNano := fmt.Sprintf("%d", time.UnixMilli(s.EndTimeUnixMillis).UnixNano())
status := &otlpStatus{Code: 1}
if s.Status == "FAILURE" {
status = &otlpStatus{Code: 2}
}
childSpan := otlpSpan{
TraceID: traceID,
SpanID: spanID,
ParentSpanID: rootSpanID,
Name: s.Label,
Kind: 1,
StartTimeUnixNano: sStartNano,
EndTimeUnixNano: sEndNano,
Attributes: []otlpKeyValue{
{Key: "grog.cache_result", Value: stringVal(s.CacheResult)},
{Key: "grog.change_hash", Value: stringVal(s.ChangeHash)},
{Key: "grog.is_test", Value: boolVal(s.IsTest)},
{Key: "grog.command_duration_ms", Value: intVal(s.CommandDurationMillis)},
{Key: "grog.queue_wait_ms", Value: intVal(s.QueueWaitMillis)},
{Key: "grog.hash_duration_ms", Value: intVal(s.HashDurationMillis)},
{Key: "grog.output_write_ms", Value: intVal(s.OutputWriteMillis)},
{Key: "grog.output_load_ms", Value: intVal(s.OutputLoadMillis)},
{Key: "grog.cache_write_ms", Value: intVal(s.CacheWriteMillis)},
{Key: "grog.cache_check_ms", Value: intVal(s.CacheCheckMillis)},
{Key: "grog.dep_load_ms", Value: intVal(s.DepLoadMillis)},
{Key: "grog.package", Value: stringVal(s.Package)},
{Key: "grog.output_hash", Value: stringVal(s.OutputHash)},
{Key: "grog.exit_code", Value: intVal(int64(s.ExitCode))},
{Key: "grog.tags", Value: stringListVal(s.Tags)},
{Key: "grog.dependencies", Value: stringListVal(s.Dependencies)},
},
Status: status,
}
spans = append(spans, childSpan)
}
return otlpResourceSpans{
Resource: otlpResource{
Attributes: []otlpKeyValue{
{Key: "service.name", Value: stringVal("grog")},
{Key: "service.version", Value: stringVal(trace.Build.GrogVersion)},
{Key: "host.arch", Value: stringVal(trace.Build.Platform)},
},
},
ScopeSpans: []otlpScopeSpans{
{
Scope: otlpScope{Name: "grog", Version: trace.Build.GrogVersion},
Spans: spans,
},
},
}
}
func uuidToBytes(uuidStr string) []byte {
clean := strings.ReplaceAll(uuidStr, "-", "")
b, err := hex.DecodeString(clean)
if err != nil || len(b) != 16 {
b = make([]byte, 16)
for i, c := range uuidStr {
b[i%16] ^= byte(c)
}
}
return b
}
func generateSpanID(traceID string, suffix string) string {
b := make([]byte, 8)
combined := traceID + suffix
for i, c := range combined {
b[i%8] ^= byte(c)
}
return hex.EncodeToString(b)
}
package tracing
import (
"github.com/chrismatix/grog/internal/config"
)
// PathResolver constructs DuckDB-readable glob paths for Parquet trace files.
//
// DuckDB always reads from the local filesystem cache. When a remote backend
// (S3, GCS, Azure) is configured, grog's RemoteWrapper writes to both local
// and remote, so the local cache always has a copy of all traces written by
// this machine.
type PathResolver struct {
buildsBase string
spansBase string
}
// NewPathResolver creates a PathResolver that points at the local filesystem cache.
func NewPathResolver() *PathResolver {
cacheDir := config.Global.GetWorkspaceCacheDirectory()
return &PathResolver{
buildsBase: cacheDir + "/traces/builds",
spansBase: cacheDir + "/traces/spans",
}
}
// BuildsGlob returns a DuckDB-readable glob for all build Parquet files.
func (p *PathResolver) BuildsGlob() string {
return p.buildsBase + "/**/*.parquet"
}
// SpansGlob returns a DuckDB-readable glob for all span Parquet files.
func (p *PathResolver) SpansGlob() string {
return p.spansBase + "/**/*.parquet"
}
package tracing
import (
"bytes"
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/chrismatix/grog/internal/caching/backends"
"github.com/parquet-go/parquet-go"
)
const (
tracesBuildsPath = "traces/builds"
tracesSpansPath = "traces/spans"
)
// TraceWriter writes traces as Parquet files via CacheBackend.
// It does not require DuckDB and is safe to use from the async build goroutine.
type TraceWriter struct {
backend backends.CacheBackend
}
func NewTraceWriter(backend backends.CacheBackend) *TraceWriter {
return &TraceWriter{backend: backend}
}
func dateStr(t time.Time) string {
return t.UTC().Format("2006-01-02")
}
// Write persists a BuildTrace as two Parquet files (builds + spans).
func (w *TraceWriter) Write(ctx context.Context, trace *BuildTrace) error {
traceDate := time.UnixMilli(trace.Build.StartTimeUnixMillis)
date := dateStr(traceDate)
key := trace.Build.TraceID + ".parquet"
// Write builds Parquet
buildsPath := fmt.Sprintf("%s/%s", tracesBuildsPath, date)
buildsBuf, err := writeParquet([]BuildRow{trace.Build})
if err != nil {
return fmt.Errorf("write builds parquet: %w", err)
}
if err := w.backend.Set(ctx, buildsPath, key, bytes.NewReader(buildsBuf)); err != nil {
return fmt.Errorf("store builds parquet: %w", err)
}
// Write spans Parquet
if len(trace.Spans) > 0 {
spansPath := fmt.Sprintf("%s/%s", tracesSpansPath, date)
spansBuf, err := writeParquet(trace.Spans)
if err != nil {
return fmt.Errorf("write spans parquet: %w", err)
}
if err := w.backend.Set(ctx, spansPath, key, bytes.NewReader(spansBuf)); err != nil {
return fmt.Errorf("store spans parquet: %w", err)
}
}
return nil
}
func writeParquet[T any](rows []T) ([]byte, error) {
var buf bytes.Buffer
writer := parquet.NewGenericWriter[T](&buf)
if _, err := writer.Write(rows); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// TraceStore provides both write and query capabilities for traces.
// Query methods use DuckDB via database/sql.
type TraceStore struct {
writer *TraceWriter
resolver *PathResolver
db *sql.DB
}
// NewTraceStore creates a full store with write + query support.
func NewTraceStore(backend backends.CacheBackend, resolver *PathResolver) (*TraceStore, error) {
db, err := sql.Open("duckdb", "")
if err != nil {
return nil, fmt.Errorf("querying traces requires the full grog build with DuckDB (see https://grog.build/get-started): %w", err)
}
return &TraceStore{
writer: NewTraceWriter(backend),
resolver: resolver,
db: db,
}, nil
}
// Close releases the DuckDB connection.
func (s *TraceStore) Close() error {
return s.db.Close()
}
// Write delegates to the TraceWriter.
func (s *TraceStore) Write(ctx context.Context, trace *BuildTrace) error {
return s.writer.Write(ctx, trace)
}
// PullProgress tracks remote trace files downloaded into the local cache.
// It lists keys from the remote backend and triggers a Get() for each missing
// file, which causes the RemoteWrapper to fetch and cache it locally.
// Returns the number of files synced.
// PullProgress is called during Pull to report progress.
// current is the number of files processed so far, total is the total number of
// remote files to process.
type PullProgress func(current, total int)
func (s *TraceStore) Pull(ctx context.Context, onProgress PullProgress) (int, error) {
// First pass: collect all remote keys and filter to those needing sync.
type pullItem struct {
subPath string
fileName string
}
var items []pullItem
for _, table := range []string{tracesBuildsPath, tracesSpansPath} {
remoteKeys, err := s.writer.backend.ListKeys(ctx, table, ".parquet")
if err != nil {
return 0, fmt.Errorf("list remote keys for %s: %w", table, err)
}
for _, key := range remoteKeys {
parts := strings.SplitN(key, "/", 2)
if len(parts) != 2 {
continue
}
subPath := fmt.Sprintf("%s/%s", table, parts[0])
fileName := parts[1]
localExists := false
if rw, ok := s.writer.backend.(*backends.RemoteWrapper); ok {
localExists, _ = rw.GetFS().Exists(ctx, subPath, fileName)
} else {
localExists, _ = s.writer.backend.Exists(ctx, subPath, fileName)
}
if !localExists {
items = append(items, pullItem{subPath: subPath, fileName: fileName})
}
}
}
total := len(items)
if onProgress != nil {
onProgress(0, total)
}
synced := 0
for i, item := range items {
reader, err := s.writer.backend.Get(ctx, item.subPath, item.fileName)
if err == nil {
reader.Close()
synced++
}
if onProgress != nil {
onProgress(i+1, total)
}
}
return synced, nil
}
// List returns recent builds matching optional filters.
func (s *TraceStore) List(ctx context.Context, opts ListOptions) ([]BuildRow, error) {
var conditions []string
if opts.Since != nil {
conditions = append(conditions, fmt.Sprintf("start_time_unix_millis >= %d", opts.Since.UnixMilli()))
}
if opts.Command != "" {
conditions = append(conditions, fmt.Sprintf("command = '%s'", sanitize(opts.Command)))
}
if opts.FailuresOnly {
conditions = append(conditions, "failure_count > 0")
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
limit := opts.Limit
if limit < 0 {
limit = 20
}
limitClause := ""
if limit > 0 {
limitClause = fmt.Sprintf("LIMIT %d", limit)
}
query := fmt.Sprintf(`SELECT trace_id, workspace, git_commit, git_branch, grog_version, platform,
command, start_time_unix_millis, total_duration_millis, total_targets,
success_count, failure_count, cache_hit_count,
critical_path_exec_millis, critical_path_cache_millis, async_cache_wait_millis,
is_ci, requested_patterns, COALESCE(critical_path, ''), COALESCE(tags, '')
FROM %s
%s ORDER BY start_time_unix_millis DESC %s`,
s.buildsSource(), where, limitClause)
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
return nil, nil
}
return nil, fmt.Errorf("list traces: %w", err)
}
defer rows.Close()
return scanBuildRows(rows)
}
// buildsSource reads the builds table, adding the columns that traces written
// by older grog versions lack so that List and LoadBuild can select them.
func (s *TraceStore) buildsSource() string {
return fmt.Sprintf(`(FROM read_parquet('%s', union_by_name=true)
UNION ALL BY NAME SELECT NULL::VARCHAR AS critical_path, NULL::VARCHAR AS tags WHERE false)`,
s.resolver.BuildsGlob())
}
// ListOptions controls filtering for List.
type ListOptions struct {
Limit int
Since *time.Time
Command string
FailuresOnly bool
}
// LoadBuild retrieves a single build by trace ID prefix.
func (s *TraceStore) LoadBuild(ctx context.Context, traceIDPrefix string) (*BuildRow, error) {
query := fmt.Sprintf(`SELECT trace_id, workspace, git_commit, git_branch, grog_version, platform,
command, start_time_unix_millis, total_duration_millis, total_targets,
success_count, failure_count, cache_hit_count,
critical_path_exec_millis, critical_path_cache_millis, async_cache_wait_millis,
is_ci, requested_patterns, COALESCE(critical_path, ''), COALESCE(tags, '')
FROM %s
WHERE starts_with(trace_id, '%s')`,
s.buildsSource(), sanitize(traceIDPrefix))
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
return nil, fmt.Errorf("no trace found matching %q", traceIDPrefix)
}
return nil, err
}
defer rows.Close()
builds, err := scanBuildRows(rows)
if err != nil {
return nil, err
}
if len(builds) == 0 {
return nil, fmt.Errorf("no trace found matching %q", traceIDPrefix)
}
if len(builds) > 1 {
return nil, fmt.Errorf("ambiguous trace ID prefix %q: matches %s and %s",
traceIDPrefix, builds[0].TraceID, builds[1].TraceID)
}
return &builds[0], nil
}
// LoadSpans retrieves all spans for a given trace ID.
func (s *TraceStore) LoadSpans(ctx context.Context, traceID string) ([]SpanRow, error) {
query := fmt.Sprintf(`SELECT trace_id, label, package, change_hash, output_hash,
status, cache_result, command, exit_code, is_test,
start_time_unix_millis, end_time_unix_millis, total_duration_millis,
queue_wait_millis, hash_duration_millis, cache_check_millis,
command_duration_millis, output_write_millis, output_load_millis,
cache_write_millis, dep_load_millis, tags, dependencies
FROM read_parquet('%s', union_by_name=true)
WHERE trace_id = '%s'
ORDER BY total_duration_millis DESC`,
s.resolver.SpansGlob(), sanitize(traceID))
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
return nil, nil
}
return nil, err
}
defer rows.Close()
return scanSpanRows(rows)
}
// LoadSpansForTraces retrieves all spans for the given trace IDs in a single
// parquet scan, returning a map keyed by trace_id. Large ID sets are chunked
// internally to keep the generated SQL bounded.
func (s *TraceStore) LoadSpansForTraces(ctx context.Context, traceIDs []string) (map[string][]SpanRow, error) {
result := make(map[string][]SpanRow, len(traceIDs))
if len(traceIDs) == 0 {
return result, nil
}
const chunkSize = 500
for start := 0; start < len(traceIDs); start += chunkSize {
end := min(start+chunkSize, len(traceIDs))
chunk := traceIDs[start:end]
quoted := make([]string, 0, len(chunk))
for _, id := range chunk {
quoted = append(quoted, "'"+sanitize(id)+"'")
}
query := fmt.Sprintf(`SELECT trace_id, label, package, change_hash, output_hash,
status, cache_result, command, exit_code, is_test,
start_time_unix_millis, end_time_unix_millis, total_duration_millis,
queue_wait_millis, hash_duration_millis, cache_check_millis,
command_duration_millis, output_write_millis, output_load_millis,
cache_write_millis, dep_load_millis, tags, dependencies
FROM read_parquet('%s', union_by_name=true)
WHERE trace_id IN (%s)`,
s.resolver.SpansGlob(), strings.Join(quoted, ","))
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
continue
}
return nil, err
}
spans, err := scanSpanRows(rows)
rows.Close()
if err != nil {
return nil, err
}
for _, sp := range spans {
result[sp.TraceID] = append(result[sp.TraceID], sp)
}
}
return result, nil
}
// FindAndLoad retrieves a full trace by ID prefix.
func (s *TraceStore) FindAndLoad(ctx context.Context, traceIDPrefix string) (*BuildTrace, error) {
build, err := s.LoadBuild(ctx, traceIDPrefix)
if err != nil {
return nil, err
}
spans, err := s.LoadSpans(ctx, build.TraceID)
if err != nil {
return nil, err
}
return &BuildTrace{Build: *build, Spans: spans}, nil
}
// TraceStats contains aggregate statistics over recent traces.
type TraceStats struct {
TraceCount int
AvgDuration float64
CacheHitRate float64
TotalFails int
}
// StatsOptions controls filtering for Stats and Bottlenecks.
type StatsOptions struct {
Limit int
Command string
IsCI *bool
}
func (s *TraceStore) Stats(ctx context.Context, opts StatsOptions) (*TraceStats, error) {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
var conditions []string
if opts.Command != "" {
conditions = append(conditions, fmt.Sprintf("command = '%s'", sanitize(opts.Command)))
}
if opts.IsCI != nil {
conditions = append(conditions, fmt.Sprintf("is_ci = %t", *opts.IsCI))
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(`SELECT
COUNT(*) as n,
AVG(total_duration_millis) as avg_ms,
SUM(cache_hit_count)::FLOAT / NULLIF(SUM(total_targets), 0) * 100 as hit_pct,
COALESCE(SUM(failure_count), 0) as fails
FROM (SELECT * FROM read_parquet('%s', union_by_name=true)
%s
ORDER BY start_time_unix_millis DESC LIMIT %d)`,
s.resolver.BuildsGlob(), where, limit)
row := s.db.QueryRowContext(ctx, query)
var stats TraceStats
var avgMs, hitPct sql.NullFloat64
if err := row.Scan(&stats.TraceCount, &avgMs, &hitPct, &stats.TotalFails); err != nil {
if isNoFilesError(err) {
return &TraceStats{}, nil
}
return nil, err
}
stats.AvgDuration = avgMs.Float64
stats.CacheHitRate = hitPct.Float64
return &stats, nil
}
// TargetBottleneck holds per-target aggregated metrics for bottleneck analysis.
type TargetBottleneck struct {
Label string
Count int
Frequency float64 // fraction of builds that include this target (0-1)
Impact float64 // avg_cmd * frequency — weighted importance score
AvgCmd float64
AvgQueue float64
AvgIO float64
AvgHash float64
MissRate float64
Failures int
AvgOutputWrite float64
AvgOutputLoad float64
AvgCacheWrite float64
}
// BottleneckReport categorizes targets by bottleneck type.
type BottleneckReport struct {
SlowestTargets []TargetBottleneck
QueueSaturated []TargetBottleneck
IOBottlenecks []TargetBottleneck
SlowHashing []TargetBottleneck
FrequentMisses []TargetBottleneck
FlakyTargets []TargetBottleneck
OverallCacheMissRate float64
}
// Bottleneck thresholds.
const (
queueSaturationThresholdMs = 500
ioBottleneckThresholdMs = 1000
slowHashThresholdMs = 200
missRateExcessPct = 30 // miss rate must exceed overall avg by this many pp
maxBottlenecksPerCategory = 10
)
func (s *TraceStore) Bottlenecks(ctx context.Context, opts StatsOptions) (*BottleneckReport, error) {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
var conditions []string
if opts.Command != "" {
conditions = append(conditions, fmt.Sprintf("command = '%s'", sanitize(opts.Command)))
}
if opts.IsCI != nil {
conditions = append(conditions, fmt.Sprintf("is_ci = %t", *opts.IsCI))
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
// Subquery: get the trace IDs and count of recent builds
query := fmt.Sprintf(`WITH recent_builds AS (
SELECT trace_id FROM read_parquet('%s', union_by_name=true)
%s
ORDER BY start_time_unix_millis DESC LIMIT %d
),
build_count AS (SELECT COUNT(*) as total FROM recent_builds)
SELECT
label,
COUNT(*) as n,
COUNT(*)::FLOAT / (SELECT total FROM build_count) as frequency,
AVG(command_duration_millis) * COUNT(*)::FLOAT / (SELECT total FROM build_count) as impact,
AVG(command_duration_millis) as avg_cmd,
AVG(queue_wait_millis) as avg_queue,
AVG(output_write_millis + output_load_millis + cache_write_millis) as avg_io,
AVG(hash_duration_millis) as avg_hash,
SUM(CASE WHEN cache_result = 'CACHE_MISS' THEN 1 ELSE 0 END)::FLOAT / COUNT(*) * 100 as miss_rate,
SUM(CASE WHEN status = 'FAILURE' THEN 1 ELSE 0 END) as failures,
AVG(output_write_millis) as avg_output_write,
AVG(output_load_millis) as avg_output_load,
AVG(cache_write_millis) as avg_cache_write
FROM read_parquet('%s', union_by_name=true)
WHERE trace_id IN (SELECT trace_id FROM recent_builds)
GROUP BY label
HAVING n > 1
ORDER BY impact DESC`,
s.resolver.BuildsGlob(), where, limit, s.resolver.SpansGlob())
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
return &BottleneckReport{}, nil
}
return nil, err
}
defer rows.Close()
var all []TargetBottleneck
var totalMissRate float64
for rows.Next() {
var t TargetBottleneck
if err := rows.Scan(&t.Label, &t.Count, &t.Frequency, &t.Impact,
&t.AvgCmd, &t.AvgQueue, &t.AvgIO, &t.AvgHash, &t.MissRate, &t.Failures,
&t.AvgOutputWrite, &t.AvgOutputLoad, &t.AvgCacheWrite); err != nil {
return nil, err
}
all = append(all, t)
totalMissRate += t.MissRate
}
if err := rows.Err(); err != nil {
return nil, err
}
return categorizeBottlenecks(all, totalMissRate), nil
}
// categorizeBottlenecks sorts impact-ordered targets into the report's bottleneck categories.
func categorizeBottlenecks(all []TargetBottleneck, totalMissRate float64) *BottleneckReport {
report := &BottleneckReport{}
if len(all) > 0 {
report.OverallCacheMissRate = totalMissRate / float64(len(all))
}
missThreshold := report.OverallCacheMissRate + missRateExcessPct
for _, t := range all {
if len(report.SlowestTargets) < maxBottlenecksPerCategory {
report.SlowestTargets = append(report.SlowestTargets, t)
}
if t.AvgQueue > queueSaturationThresholdMs && len(report.QueueSaturated) < maxBottlenecksPerCategory {
report.QueueSaturated = append(report.QueueSaturated, t)
}
if t.AvgIO > ioBottleneckThresholdMs && len(report.IOBottlenecks) < maxBottlenecksPerCategory {
report.IOBottlenecks = append(report.IOBottlenecks, t)
}
if t.AvgHash > slowHashThresholdMs && len(report.SlowHashing) < maxBottlenecksPerCategory {
report.SlowHashing = append(report.SlowHashing, t)
}
if t.MissRate > missThreshold && len(report.FrequentMisses) < maxBottlenecksPerCategory {
report.FrequentMisses = append(report.FrequentMisses, t)
}
if t.Failures > 0 && len(report.FlakyTargets) < maxBottlenecksPerCategory {
report.FlakyTargets = append(report.FlakyTargets, t)
}
}
// Sort secondary categories by their primary metric (query already sorts by avg_cmd)
sortByQueue := func(a []TargetBottleneck) {
sort.Slice(a, func(i, j int) bool { return a[i].AvgQueue > a[j].AvgQueue })
}
sortByIO := func(a []TargetBottleneck) {
sort.Slice(a, func(i, j int) bool { return a[i].AvgIO > a[j].AvgIO })
}
sortByHash := func(a []TargetBottleneck) {
sort.Slice(a, func(i, j int) bool { return a[i].AvgHash > a[j].AvgHash })
}
sortByMissRate := func(a []TargetBottleneck) {
sort.Slice(a, func(i, j int) bool { return a[i].MissRate > a[j].MissRate })
}
sortByFailures := func(a []TargetBottleneck) {
sort.Slice(a, func(i, j int) bool { return a[i].Failures > a[j].Failures })
}
sortByQueue(report.QueueSaturated)
sortByIO(report.IOBottlenecks)
sortByHash(report.SlowHashing)
sortByMissRate(report.FrequentMisses)
sortByFailures(report.FlakyTargets)
return report
}
// Prune deletes traces older than the given time.
func (s *TraceStore) Prune(ctx context.Context, olderThan time.Time) (int, error) {
cutoffMillis := olderThan.UnixMilli()
query := fmt.Sprintf(`SELECT trace_id, start_time_unix_millis
FROM read_parquet('%s', union_by_name=true)
WHERE start_time_unix_millis < %d`,
s.resolver.BuildsGlob(), cutoffMillis)
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
if isNoFilesError(err) {
return 0, nil
}
return 0, err
}
defer rows.Close()
pruned := 0
for rows.Next() {
var traceID string
var startMillis int64
if err := rows.Scan(&traceID, &startMillis); err != nil {
return pruned, err
}
date := dateStr(time.UnixMilli(startMillis))
key := traceID + ".parquet"
_ = s.writer.backend.Delete(ctx, fmt.Sprintf("%s/%s", tracesBuildsPath, date), key)
_ = s.writer.backend.Delete(ctx, fmt.Sprintf("%s/%s", tracesSpansPath, date), key)
pruned++
}
return pruned, rows.Err()
}
// helpers
func scanBuildRows(rows *sql.Rows) ([]BuildRow, error) {
var result []BuildRow
for rows.Next() {
var b BuildRow
if err := rows.Scan(
&b.TraceID, &b.Workspace, &b.GitCommit, &b.GitBranch, &b.GrogVersion, &b.Platform,
&b.Command, &b.StartTimeUnixMillis, &b.TotalDurationMillis, &b.TotalTargets,
&b.SuccessCount, &b.FailureCount, &b.CacheHitCount,
&b.CriticalPathExecMillis, &b.CriticalPathCacheMillis, &b.AsyncCacheWaitMillis,
&b.IsCI, &b.RequestedPatterns, &b.CriticalPath, &b.Tags,
); err != nil {
return nil, err
}
result = append(result, b)
}
return result, rows.Err()
}
func scanSpanRows(rows *sql.Rows) ([]SpanRow, error) {
var result []SpanRow
for rows.Next() {
var s SpanRow
if err := rows.Scan(
&s.TraceID, &s.Label, &s.Package, &s.ChangeHash, &s.OutputHash,
&s.Status, &s.CacheResult, &s.Command, &s.ExitCode, &s.IsTest,
&s.StartTimeUnixMillis, &s.EndTimeUnixMillis, &s.TotalDurationMillis,
&s.QueueWaitMillis, &s.HashDurationMillis, &s.CacheCheckMillis,
&s.CommandDurationMillis, &s.OutputWriteMillis, &s.OutputLoadMillis,
&s.CacheWriteMillis, &s.DepLoadMillis, &s.Tags, &s.Dependencies,
); err != nil {
return nil, err
}
result = append(result, s)
}
return result, rows.Err()
}
// sanitize prevents SQL injection for string values interpolated into queries.
// Only allows alphanumeric, hyphens, underscores, and dots.
func sanitize(s string) string {
var b strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
b.WriteRune(c)
}
}
return b.String()
}
// isNoFilesError checks if a DuckDB error is due to no matching Parquet files.
func isNoFilesError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "No files found") ||
strings.Contains(msg, "does not exist") ||
strings.Contains(msg, "Cannot open file")
}
package worker
import (
"io"
"sync"
"time"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
// ProgressTracker aggregates progress updates and throttles UI refreshes.
// The tracker is safe for concurrent use across goroutines working on the
// same logical unit of work. Progress can be subdivided into child trackers
// when multiple operations contribute to the same overall status.
type ProgressTracker struct {
status string
subStatus string
update StatusFunc
// statusSet is flipped to true the first time a caller explicitly calls
// SetStatus on this tracker. Once set, statusForChildStatusLocked always
// returns pt.status — even when there is exactly one child — so phase
// summaries and other caller-provided labels aren't masked by a single
// child inheriting the base status. Before any SetStatus call, the
// default "single child inherits" behaviour is preserved (and tested).
statusSet bool
mu sync.Mutex
total int64
current int64
lastSent int64
step int64
// Record the time at which the tracker was created
// so that the UI can decide whether to show a progress bar
startedAtSec int64
parent *ProgressTracker
children map[*ProgressTracker]*progressState
}
type progressState struct {
status string
total int64
current int64
}
// NewProgressTracker builds a root tracker for a status message. When total is
// unknown upfront, pass 0 and provide totals through child trackers.
func NewProgressTracker(status string, total int64, update StatusFunc) *ProgressTracker {
if update == nil {
return nil
}
if config.Global.DisableProgressTracker {
return nil
}
return &ProgressTracker{
status: status,
total: total,
update: update,
startedAtSec: time.Now().Unix(),
step: computeStep(total),
}
}
// SubTracker creates a child tracker whose progress contributes to this
// tracker. Child updates are aggregated and emitted through the root tracker.
func (pt *ProgressTracker) SubTracker(status string, total int64) *ProgressTracker {
if pt == nil || total <= 0 {
return nil
}
child := &ProgressTracker{
status: status,
total: total,
update: pt.update,
step: computeStep(total),
parent: pt,
startedAtSec: pt.startedAtSec,
}
pt.mu.Lock()
if pt.children == nil {
pt.children = make(map[*ProgressTracker]*progressState)
}
pt.children[child] = &progressState{status: status, total: total}
current, totalProgress := pt.aggregateLocked()
pt.step = computeStep(totalProgress)
trackerStatus := pt.statusForChildStatusLocked(status)
pt.mu.Unlock()
pt.maybeSend(trackerStatus, current, totalProgress)
return child
}
func (pt *ProgressTracker) Add(delta int64) {
if pt == nil || delta == 0 {
return
}
pt.mu.Lock()
pt.current += delta
if pt.current > pt.total {
pt.current = pt.total
}
parent := pt.parent
current, total := pt.aggregateLocked()
pt.step = computeStep(total)
send := parent == nil && pt.shouldSendLocked(current, total)
status := pt.status
subStatus := pt.subStatus
if parent != nil {
pt.mu.Unlock()
parent.onChildDelta(pt, delta)
return
}
if send {
pt.send(status, subStatus, current, total)
}
pt.mu.Unlock()
}
// WrapReader returns a reader that updates the tracker on each read.
// Wrapping multiple readers is safe, because Add() is thread-safe.
func (pt *ProgressTracker) WrapReader(reader io.Reader) io.Reader {
if pt == nil {
return reader
}
return &progressReader{reader: reader, tracker: pt}
}
func (pt *ProgressTracker) WrapReadCloser(readCloser io.ReadCloser) io.ReadCloser {
if pt == nil {
return readCloser
}
return &progressReadCloser{readCloser: readCloser, tracker: pt}
}
func (pt *ProgressTracker) Complete() {
if pt == nil {
return
}
pt.mu.Lock()
remaining := pt.total - pt.current
pt.mu.Unlock()
pt.Add(remaining)
}
// SetStatus updates the tracker's status text and pushes a refresh to the UI,
// even if no bytes of progress have been recorded. This is useful when a long
// operation has distinct phases (preparing, streaming, flushing, finalizing)
// and the caller wants the user to see phase transitions without needing a
// progress bar.
//
// Calling SetStatus also flips the tracker into "explicit status" mode: from
// now on, statusForChildStatusLocked will always return pt.status — even when
// a single child exists. This matters for docker pushes where the daemon's
// progress stream creates per-layer sub-trackers with the same base status as
// the parent; without this override the parent's phase-summary updates would
// be masked by the single in-flight child. Callers that do not call SetStatus
// keep the default behaviour (single child inherits the parent's display).
//
// Status changes on a child tracker do not propagate; callers updating a
// plan's top-level status should target the tracker they own.
//
// An update is emitted only when the *displayed* text actually changes, so
// re-calling SetStatus with the same string is cheap and won't flood the UI.
func (pt *ProgressTracker) SetStatus(status string) {
if pt == nil {
return
}
pt.mu.Lock()
prevDisplayed := pt.statusForChildStatusLocked(pt.status)
pt.status = status
pt.statusSet = true
newDisplayed := pt.statusForChildStatusLocked(status)
current, total := pt.aggregateLocked()
subStatus := pt.subStatus
pt.mu.Unlock()
if prevDisplayed == newDisplayed {
return
}
pt.send(newDisplayed, subStatus, current, total)
}
// SetSubStatus sets a secondary detail line on the task without changing the
// main status text. This is useful for contextual information that would
// otherwise crowd the primary status line (e.g., Docker layer phase summaries).
func (pt *ProgressTracker) SetSubStatus(subStatus string) {
if pt == nil {
return
}
pt.mu.Lock()
if pt.subStatus == subStatus {
pt.mu.Unlock()
return
}
pt.subStatus = subStatus
current, total := pt.aggregateLocked()
status := pt.statusForChildStatusLocked(pt.status)
pt.mu.Unlock()
pt.update(StatusUpdate{
Status: status,
SubStatus: subStatus,
Progress: &console.Progress{Current: current, Total: total, StartedAtSec: pt.startedAtSec},
})
}
func (pt *ProgressTracker) onChildDelta(child *ProgressTracker, delta int64) {
if pt == nil || delta == 0 {
return
}
pt.mu.Lock()
state, ok := pt.children[child]
if !ok {
pt.mu.Unlock()
return
}
state.current += delta
if state.current > state.total {
state.current = state.total
}
current, total := pt.aggregateLocked()
pt.step = computeStep(total)
send := pt.shouldSendLocked(current, total)
status := pt.statusForChildStatusLocked(child.status)
subStatus := pt.subStatus
if send {
pt.send(status, subStatus, current, total)
}
pt.mu.Unlock()
}
func (pt *ProgressTracker) aggregateLocked() (int64, int64) {
current := pt.current
total := pt.total
for _, child := range pt.children {
current += child.current
total += child.total
}
return current, total
}
func (pt *ProgressTracker) shouldSendLocked(current, total int64) bool {
if total <= 0 {
return false
}
if current > total {
current = total
}
if current >= total {
pt.lastSent = current
return true
}
if current-pt.lastSent >= pt.step {
pt.lastSent = current
return true
}
return false
}
func (pt *ProgressTracker) send(status, subStatus string, current, total int64) {
pt.update(StatusUpdate{
Status: status,
SubStatus: subStatus,
Progress: &console.Progress{Current: current, Total: total, StartedAtSec: pt.startedAtSec},
})
}
func computeStep(total int64) int64 {
const minStep = 32 * 1024
step := max(total/100, minStep)
return step
}
func (pt *ProgressTracker) maybeSend(status string, current, total int64) {
pt.mu.Lock()
shouldSend := pt.shouldSendLocked(current, total)
subStatus := pt.subStatus
pt.mu.Unlock()
if shouldSend {
pt.send(status, subStatus, current, total)
}
}
// statusForChildStatusLocked picks the status string to display for aggregated
// progress updates. Child status lines are shown only when the tracker has a
// single child and the tracker's own status has not been explicitly set — the
// latter case lets callers like consumeDockerProgress push phase summaries
// onto the parent without being silently overridden by a single child.
func (pt *ProgressTracker) statusForChildStatusLocked(childStatus string) string {
if pt.statusSet {
return pt.status
}
if len(pt.children) == 1 {
return childStatus
}
return pt.status
}
type progressReader struct {
reader io.Reader
tracker *ProgressTracker
}
func (p *progressReader) Read(buf []byte) (int, error) {
n, err := p.reader.Read(buf)
if n > 0 {
p.tracker.Add(int64(n))
}
return n, err
}
type progressReadCloser struct {
readCloser io.ReadCloser
tracker *ProgressTracker
}
func (p *progressReadCloser) Read(buf []byte) (int, error) {
n, err := p.readCloser.Read(buf)
if n > 0 {
p.tracker.Add(int64(n))
}
return n, err
}
func (p *progressReadCloser) Close() error {
return p.readCloser.Close()
}
package worker
import (
"context"
"fmt"
"maps"
"runtime"
"sync"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/fatih/color"
"github.com/chrismatix/grog/internal/config"
"github.com/chrismatix/grog/internal/console"
)
type StatusFunc func(StatusUpdate)
type StatusUpdate struct {
Status string
SubStatus string
Progress *console.Progress
}
func Status(status string) StatusUpdate {
return StatusUpdate{Status: status}
}
func StatusWithProgress(status string, progress *console.Progress) StatusUpdate {
return StatusUpdate{Status: status, Progress: progress}
}
type TaskResult[T any] struct {
Return T
Error error
}
type TaskFunc[T any] func(update StatusFunc) (T, error)
type job[T any] struct {
id int
task TaskFunc[T]
result chan TaskResult[T]
}
type TaskWorkerPool[T any] struct {
logger *console.Logger
maxWorkers int
totalTasks int
jobCh chan job[T]
sendMsg func(msg tea.Msg)
taskState console.TaskStateMap
nextTaskId int
completedTasks int
workerIdOffset int // offset applied to worker IDs in taskState keys
onStateChange func() // if set, called instead of sending messages directly
activeJobs sync.WaitGroup
closed atomic.Bool
shutdownOnce sync.Once
watcherOnce sync.Once
mu sync.Mutex
}
func NewTaskWorkerPool[T any](
logger *console.Logger,
maxWorkers int,
sendMsg func(msg tea.Msg),
totalTasks int,
) *TaskWorkerPool[T] {
if maxWorkers < 1 {
maxWorkers = runtime.NumCPU()
}
return &TaskWorkerPool[T]{
logger: logger,
maxWorkers: maxWorkers,
totalTasks: totalTasks,
jobCh: make(chan job[T], maxWorkers),
sendMsg: sendMsg,
taskState: make(console.TaskStateMap),
}
}
// SetWorkerIdOffset sets an offset applied to worker IDs in taskState keys.
// Must be called before StartWorkers.
func (twp *TaskWorkerPool[T]) SetWorkerIdOffset(offset int) {
twp.workerIdOffset = offset
}
// SetOnStateChange sets a callback to be invoked instead of sending messages directly.
// Must be called before StartWorkers.
func (twp *TaskWorkerPool[T]) SetOnStateChange(fn func()) {
twp.onStateChange = fn
}
func (twp *TaskWorkerPool[T]) StartWorkers(ctx context.Context) {
// start workers
for i := 0; i < twp.maxWorkers; i++ {
workerId := i + 1
go twp.worker(ctx, workerId)
}
// schedule shutdown once
twp.watcherOnce.Do(func() {
go func() {
<-ctx.Done()
twp.Shutdown()
}()
})
}
func (twp *TaskWorkerPool[T]) worker(ctx context.Context, workerId int) {
isDebug := config.Global.IsDebug()
for {
select {
case <-ctx.Done():
console.GetLogger(ctx).Debugf("Worker %d context cancelled, exiting", workerId)
return
case j, ok := <-twp.jobCh:
if !ok {
return
}
twp.logger.Debugf("Starting task %d on worker %d", j.id+1, workerId)
res, err := j.task(func(status StatusUpdate) {
taskStatus := status.Status
if isDebug {
taskStatus = fmt.Sprintf("%s (worker %d)", status.Status, workerId)
}
twp.setTaskState(workerId, StatusUpdate{Status: taskStatus, SubStatus: status.SubStatus, Progress: status.Progress})
})
if j.result != nil {
j.result <- TaskResult[T]{Return: res, Error: err}
close(j.result)
}
twp.completeTask(workerId)
twp.activeJobs.Done()
}
}
}
func (twp *TaskWorkerPool[T]) setTaskState(workerId int, status StatusUpdate) {
twp.mu.Lock()
if logToStdout() {
// In detailed mode, surface discrete lifecycle steps (checking cache,
// running, writing outputs, …) at info. Byte/file progress updates
// carry a Progress payload and would be repetitive, so keep those at
// debug. In terse mode (the default) all transient status is debug and
// CI shows only the ResultLogger completion lines.
if config.Global.GetOutputMode() == config.OutputModeDetailed && status.Progress == nil {
twp.logger.Infof("%s", status.Status)
} else {
twp.logger.Debugf("%s", status.Status)
}
twp.mu.Unlock()
return
}
key := workerId + twp.workerIdOffset
state, exists := twp.taskState[key]
if !exists {
twp.taskState[key] = console.TaskState{Status: status.Status, SubStatus: status.SubStatus, Progress: status.Progress, StartedAtSec: time.Now().Unix()}
} else {
state.Status = status.Status
state.SubStatus = status.SubStatus
state.Progress = status.Progress
twp.taskState[key] = state
}
twp.mu.Unlock()
twp.flushState()
}
func (twp *TaskWorkerPool[T]) completeTask(workerId int) {
twp.mu.Lock()
delete(twp.taskState, workerId+twp.workerIdOffset)
twp.completedTasks++
twp.mu.Unlock()
twp.flushState()
}
// flushState sends the current state to the UI. Must be called WITHOUT twp.mu held
// to avoid deadlock when onStateChange calls back into GetTaskState.
func (twp *TaskWorkerPool[T]) flushState() {
if twp.closed.Load() {
return
}
if twp.onStateChange != nil {
twp.onStateChange()
return
}
twp.mu.Lock()
// copy
mapCopy := make(console.TaskStateMap, len(twp.taskState))
maps.Copy(mapCopy, twp.taskState)
total := twp.nextTaskId
if twp.totalTasks > 0 {
total = twp.totalTasks
}
running := len(twp.taskState)
completed := twp.completedTasks
twp.mu.Unlock()
twp.sendMsg(console.TaskStateMsg{State: mapCopy})
green := color.New(color.FgGreen).SprintFunc()
twp.sendMsg(console.HeaderMsg(
green(fmt.Sprintf("[%d/%d]", completed, total)) +
fmt.Sprintf(" %s running", console.FCount(running, "action")),
))
}
func logToStdout() bool {
return !console.UseTea() && !config.Global.DisableNonDeterministicLogging
}
func (twp *TaskWorkerPool[T]) NumWorkers() int {
return twp.maxWorkers
}
func (twp *TaskWorkerPool[T]) Run(task TaskFunc[T]) (T, error) {
var zero T
if twp.closed.Load() {
return zero, fmt.Errorf("worker pool is closed")
}
twp.mu.Lock()
id := twp.nextTaskId
twp.nextTaskId++
twp.mu.Unlock()
resultCh := make(chan TaskResult[T], 1)
job := job[T]{id: id, task: task, result: resultCh}
if err := twp.enqueue(job); err != nil {
return zero, err
}
res := <-resultCh
return res.Return, res.Error
}
func (twp *TaskWorkerPool[T]) Shutdown() {
twp.shutdownOnce.Do(func() {
twp.closed.Store(true)
close(twp.jobCh)
})
}
func (twp *TaskWorkerPool[T]) enqueue(job job[T]) (err error) {
twp.activeJobs.Add(1)
defer func() {
if r := recover(); r != nil {
twp.activeJobs.Done() // undo the Add if enqueue fails
if twp.closed.Load() {
err = fmt.Errorf("worker pool is closed")
return
}
panic(r)
}
}()
// enqueue or bail on context cancel
select {
case twp.jobCh <- job:
case <-time.After(time.Second): // backstop so we don't hang if closed
if twp.closed.Load() {
twp.activeJobs.Done()
return fmt.Errorf("worker pool is closed")
}
twp.jobCh <- job
}
return nil
}
// WaitForCompletion blocks until all enqueued jobs have finished.
func (twp *TaskWorkerPool[T]) WaitForCompletion() {
twp.activeJobs.Wait()
}
// GetTaskState returns a copy of the current task state map.
func (twp *TaskWorkerPool[T]) GetTaskState() console.TaskStateMap {
twp.mu.Lock()
defer twp.mu.Unlock()
mapCopy := make(console.TaskStateMap, len(twp.taskState))
maps.Copy(mapCopy, twp.taskState)
return mapCopy
}
// GetCompletedTasks returns the number of completed tasks.
func (twp *TaskWorkerPool[T]) GetCompletedTasks() int {
twp.mu.Lock()
defer twp.mu.Unlock()
return twp.completedTasks
}
// GetRunningCount returns the number of currently running tasks.
func (twp *TaskWorkerPool[T]) GetRunningCount() int {
twp.mu.Lock()
defer twp.mu.Unlock()
return len(twp.taskState)
}
// RunFireAndForget enqueues a task without waiting for its result.
func (twp *TaskWorkerPool[T]) RunFireAndForget(task TaskFunc[T]) error {
if twp.closed.Load() {
return fmt.Errorf("worker pool is closed")
}
twp.mu.Lock()
id := twp.nextTaskId
twp.nextTaskId++
twp.mu.Unlock()
job := job[T]{id: id, task: task, result: nil}
return twp.enqueue(job)
}
package main
import (
"fmt"
"os"
"github.com/chrismatix/grog/internal/cmd"
)
// Provisioned by ldflags.
var (
version string
commit string
buildDate string
)
func main() {
cmd.Stamp(version, commit, buildDate)
if err := cmd.RootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}