-
Couldn't load subscription status.
- Fork 20
feat: cache artifact signing command with Sigstore integration #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
leodido
merged 10 commits into
feature/in-flight-checksumming
from
feature/cache-artifact-signing
Oct 23, 2025
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
a02950f
feat: implement integrated SLSA signing architecture
leodido aa92849
feat: add sign-cache plumbing command for CI/CD integration
leodido 0a13265
build: add Sigstore dependencies for integrated signing
leodido 322bb54
fix: replace placeholder Sigstore implementation with production API
leodido 3265c9b
refactor: simplify sign-cache command interface
leodido 5ec8422
refactor: remove unnecessary tempMu mutex from sign-cache
leodido ec055d1
refactor: extract magic number to maxAcceptableFailureRate constant
leodido e9bbb2c
refactor: use GITHUB_WORKFLOW_REF for builderID instead of hardcoded …
leodido 5fd9169
fix: set build timestamps to nil in SLSA attestations
leodido cdd471a
refactor: add UploadFile method to RemoteCache interface
leodido File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| log "github.com/sirupsen/logrus" | ||
| "github.com/spf13/cobra" | ||
|
|
||
| "github.com/gitpod-io/leeway/pkg/leeway/cache" | ||
| "github.com/gitpod-io/leeway/pkg/leeway/signing" | ||
| ) | ||
|
|
||
| // signCacheCmd represents the sign-cache command | ||
| var signCacheCmd = &cobra.Command{ | ||
| Use: "sign-cache --from-manifest <path>", | ||
| Short: "Signs and uploads cache artifacts using manifest (CI use only)", | ||
| Long: `Reads artifact paths from manifest file, generates SLSA attestations, | ||
| and uploads to remote cache with write-only credentials. | ||
|
|
||
| This command is designed for CI environments where build and signing are | ||
| separated for security. The build job creates a manifest of artifacts to sign, | ||
| and this command consumes that manifest to generate cryptographic attestations. | ||
|
|
||
| Example: | ||
| leeway plumbing sign-cache --from-manifest artifacts-to-sign.txt | ||
| leeway plumbing sign-cache --from-manifest artifacts.txt --dry-run`, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| manifestPath, _ := cmd.Flags().GetString("from-manifest") | ||
| dryRun, _ := cmd.Flags().GetBool("dry-run") | ||
|
|
||
| if manifestPath == "" { | ||
| return fmt.Errorf("--from-manifest flag is required") | ||
| } | ||
|
|
||
| // Validate manifest file exists | ||
| if _, err := os.Stat(manifestPath); os.IsNotExist(err) { | ||
| return fmt.Errorf("manifest file does not exist: %s", manifestPath) | ||
| } | ||
|
|
||
| return runSignCache(cmd.Context(), cmd, manifestPath, dryRun) | ||
| }, | ||
| } | ||
|
|
||
| func init() { | ||
| plumbingCmd.AddCommand(signCacheCmd) | ||
| signCacheCmd.Flags().String("from-manifest", "", "Path to newline-separated artifact paths file") | ||
| signCacheCmd.Flags().Bool("dry-run", false, "Log actions without signing or uploading") | ||
| signCacheCmd.MarkFlagRequired("from-manifest") | ||
| } | ||
|
|
||
| // runSignCache implements the main signing logic | ||
| func runSignCache(ctx context.Context, cmd *cobra.Command, manifestPath string, dryRun bool) error { | ||
| log.WithFields(log.Fields{ | ||
| "manifest": manifestPath, | ||
| "dry_run": dryRun, | ||
| }).Info("Starting cache artifact signing process") | ||
|
|
||
| if dryRun { | ||
| log.Info("DRY-RUN MODE: No actual signing or uploading will occur") | ||
| } | ||
|
|
||
| // Get workspace configuration using existing Leeway patterns | ||
| ws, err := getWorkspace() | ||
| if err != nil { | ||
| return fmt.Errorf("failed to get workspace: %w", err) | ||
| } | ||
|
|
||
| // Get remote cache using existing Leeway patterns | ||
| remoteCache := getRemoteCacheFromEnv() | ||
| if remoteCache == nil { | ||
| return fmt.Errorf("remote cache not configured - set LEEWAY_REMOTE_CACHE_BUCKET and LEEWAY_REMOTE_CACHE_STORAGE") | ||
| } | ||
|
|
||
| log.WithFields(log.Fields{ | ||
| "workspace": ws.Origin, | ||
| "cache_type": fmt.Sprintf("%T", remoteCache), | ||
| }).Info("Initialized workspace and remote cache") | ||
|
|
||
| // Validate GitHub context for CI environment | ||
| githubCtx := signing.GetGitHubContext() | ||
| if err := githubCtx.Validate(); err != nil { | ||
| return fmt.Errorf("invalid GitHub context - this command must run in GitHub Actions: %w", err) | ||
| } | ||
|
|
||
| shaDisplay := githubCtx.SHA | ||
| if len(shaDisplay) > 8 { | ||
| shaDisplay = shaDisplay[:8] + "..." | ||
| } | ||
|
|
||
| log.WithFields(log.Fields{ | ||
| "repository": githubCtx.Repository, | ||
| "run_id": githubCtx.RunID, | ||
| "sha": shaDisplay, | ||
| }).Info("Validated GitHub Actions context") | ||
|
|
||
| // Parse and validate manifest | ||
| artifacts, err := parseManifest(manifestPath) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to parse manifest: %w", err) | ||
| } | ||
|
|
||
| if len(artifacts) == 0 { | ||
| log.Warn("No artifacts found in manifest") | ||
| return nil | ||
| } | ||
|
|
||
| log.WithField("artifacts", len(artifacts)).Info("Found artifacts to sign") | ||
|
|
||
| // Process artifacts with bounded concurrency to avoid overwhelming Sigstore | ||
| const maxConcurrency = 5 // Reasonable limit for Sigstore API | ||
| const maxAcceptableFailureRate = 0.5 // Fail command if more than 50% of artifacts fail | ||
| semaphore := make(chan struct{}, maxConcurrency) | ||
|
|
||
| var successful []string | ||
| var failed []*signing.SigningError | ||
| var mu sync.Mutex | ||
| var wg sync.WaitGroup | ||
|
|
||
| // Track temporary files for cleanup | ||
| var tempFiles []string | ||
| defer func() { | ||
| // Clean up all temporary files | ||
| for _, tempFile := range tempFiles { | ||
| if err := os.Remove(tempFile); err != nil && !os.IsNotExist(err) { | ||
| log.WithError(err).WithField("file", tempFile).Warn("Failed to clean up temporary file") | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| for _, artifact := range artifacts { | ||
| wg.Add(1) | ||
| go func(artifactPath string) { | ||
| defer wg.Done() | ||
|
|
||
| // Acquire semaphore | ||
| semaphore <- struct{}{} | ||
| defer func() { <-semaphore }() | ||
|
|
||
| log.WithField("artifact", artifactPath).Debug("Starting artifact processing") | ||
|
|
||
| if err := processArtifact(ctx, artifactPath, githubCtx, remoteCache, dryRun); err != nil { | ||
| signingErr := signing.CategorizeError(artifactPath, err) | ||
|
|
||
| mu.Lock() | ||
| failed = append(failed, signingErr) | ||
| mu.Unlock() | ||
|
|
||
| log.WithFields(log.Fields{ | ||
| "artifact": artifactPath, | ||
| "error_type": signingErr.Type, | ||
| }).WithError(err).Error("Failed to process artifact") | ||
| } else { | ||
| mu.Lock() | ||
| successful = append(successful, artifactPath) | ||
| mu.Unlock() | ||
|
|
||
| log.WithField("artifact", artifactPath).Debug("Successfully processed artifact") | ||
| } | ||
| }(artifact) | ||
| } | ||
|
|
||
| // Wait for all goroutines to complete | ||
| wg.Wait() | ||
|
|
||
| // Report final results | ||
| log.WithFields(log.Fields{ | ||
| "successful": len(successful), | ||
| "failed": len(failed), | ||
| "total": len(artifacts), | ||
| }).Info("Artifact signing process completed") | ||
|
|
||
| // Determine exit strategy based on failure ratio | ||
| if len(failed) > 0 { | ||
| failureRate := float64(len(failed)) / float64(len(artifacts)) | ||
|
|
||
| // Log detailed failure information | ||
| for _, failure := range failed { | ||
| log.WithFields(log.Fields{ | ||
| "type": failure.Type, | ||
| "artifact": failure.Artifact, | ||
| }).Error(failure.Message) | ||
| } | ||
|
|
||
| if failureRate > maxAcceptableFailureRate { | ||
| return fmt.Errorf("signing failed for %d/%d artifacts (%.1f%% failure rate)", | ||
| len(failed), len(artifacts), failureRate*100) | ||
| } else { | ||
| log.WithField("failure_rate", fmt.Sprintf("%.1f%%", failureRate*100)). | ||
| Warn("Partial signing failure - continuing with available artifacts") | ||
| } | ||
| } | ||
|
|
||
| log.Info("Cache artifact signing process completed") | ||
| return nil | ||
| } | ||
|
|
||
| // processArtifact handles signing and uploading of a single artifact using integrated SLSA signing | ||
| func processArtifact(ctx context.Context, artifactPath string, githubCtx *signing.GitHubContext, remoteCache cache.RemoteCache, dryRun bool) error { | ||
| log.WithFields(log.Fields{ | ||
| "artifact": artifactPath, | ||
| "dry_run": dryRun, | ||
| }).Debug("Processing artifact with integrated SLSA signing") | ||
|
|
||
| if dryRun { | ||
| log.WithField("artifact", artifactPath).Info("DRY-RUN: Would generate signed SLSA attestation and upload") | ||
| return nil | ||
| } | ||
|
|
||
| // Single step: generate and sign SLSA attestation using integrated approach | ||
| signedAttestation, err := signing.GenerateSignedSLSAAttestation(ctx, artifactPath, githubCtx) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to generate signed attestation: %w", err) | ||
| } | ||
|
|
||
| log.WithFields(log.Fields{ | ||
| "artifact": artifactPath, | ||
| "artifact_name": signedAttestation.ArtifactName, | ||
| "checksum": signedAttestation.Checksum[:16] + "...", | ||
| "attestation_size": len(signedAttestation.AttestationBytes), | ||
| }).Info("Successfully generated signed SLSA attestation") | ||
|
|
||
| // Upload artifact + .att file using existing RemoteCache patterns | ||
| uploader := signing.NewArtifactUploader(remoteCache) | ||
| if err := uploader.UploadArtifactWithAttestation(ctx, artifactPath, signedAttestation.AttestationBytes); err != nil { | ||
| return fmt.Errorf("failed to upload to remote cache: %w", err) | ||
| } | ||
|
|
||
| log.WithField("artifact", artifactPath).Info("Successfully uploaded signed artifact and attestation to remote cache") | ||
| return nil | ||
| } | ||
|
|
||
| // parseManifest reads and validates the manifest file | ||
| func parseManifest(manifestPath string) ([]string, error) { | ||
| log.WithField("manifest", manifestPath).Debug("Parsing manifest file") | ||
|
|
||
| content, err := os.ReadFile(manifestPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read manifest file: %w", err) | ||
| } | ||
|
|
||
| if len(content) == 0 { | ||
| return nil, fmt.Errorf("manifest file is empty") | ||
| } | ||
|
|
||
| // Split by newlines and filter empty lines | ||
| lines := strings.Split(string(content), "\n") | ||
| var artifacts []string | ||
| var validationErrors []string | ||
|
|
||
| for i, line := range lines { | ||
| line = strings.TrimSpace(line) | ||
| if line == "" { | ||
| continue // Skip empty lines | ||
| } | ||
|
|
||
| // Validate artifact path exists and is readable | ||
| if stat, err := os.Stat(line); os.IsNotExist(err) { | ||
| validationErrors = append(validationErrors, fmt.Sprintf("line %d: artifact not found: %s", i+1, line)) | ||
| continue | ||
| } else if err != nil { | ||
| validationErrors = append(validationErrors, fmt.Sprintf("line %d: cannot access artifact: %s (%v)", i+1, line, err)) | ||
| continue | ||
| } else if stat.IsDir() { | ||
| validationErrors = append(validationErrors, fmt.Sprintf("line %d: path is a directory, not a file: %s", i+1, line)) | ||
| continue | ||
| } | ||
|
|
||
| // Validate it looks like a cache artifact (basic heuristic) | ||
| if !strings.HasSuffix(line, ".tar.gz") && !strings.HasSuffix(line, ".tar") { | ||
| log.WithField("artifact", line).Warn("Artifact does not have expected extension (.tar.gz or .tar)") | ||
| } | ||
|
|
||
| artifacts = append(artifacts, line) | ||
| } | ||
|
|
||
| // Report validation errors if any | ||
| if len(validationErrors) > 0 { | ||
| return nil, fmt.Errorf("manifest validation failed:\n%s", strings.Join(validationErrors, "\n")) | ||
| } | ||
|
|
||
| log.WithFields(log.Fields{ | ||
| "total_lines": len(lines), | ||
| "artifacts": len(artifacts), | ||
| }).Debug("Successfully parsed manifest") | ||
|
|
||
| return artifacts, nil | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧡