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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cmd/wolfictl.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ A CLI helper for developing Wolfi
* [wolfictl gh](wolfictl_gh.md) - Commands used to interact with GitHub
* [wolfictl image](wolfictl_image.md) - (Experimental) Commands for working with container images that use Wolfi
* [wolfictl lint](wolfictl_lint.md) - Lint the code
* [wolfictl restore](wolfictl_restore.md) - Restore withdrawn packages in apk.cgr.dev
* [wolfictl ruby](wolfictl_ruby.md) - Work with ruby packages
* [wolfictl scan](wolfictl_scan.md) - Scan a package for vulnerabilities
* [wolfictl version](wolfictl_version.md) - Prints the version
Expand Down
2 changes: 1 addition & 1 deletion docs/man/man1/wolfictl.1
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ A CLI helper for developing Wolfi

.SH SEE ALSO
.PP
\fBwolfictl\-advisory(1)\fP, \fBwolfictl\-apk(1)\fP, \fBwolfictl\-bump(1)\fP, \fBwolfictl\-check(1)\fP, \fBwolfictl\-dot(1)\fP, \fBwolfictl\-gh(1)\fP, \fBwolfictl\-image(1)\fP, \fBwolfictl\-lint(1)\fP, \fBwolfictl\-ruby(1)\fP, \fBwolfictl\-scan(1)\fP, \fBwolfictl\-version(1)\fP, \fBwolfictl\-vex(1)\fP, \fBwolfictl\-withdraw(1)\fP
\fBwolfictl\-advisory(1)\fP, \fBwolfictl\-apk(1)\fP, \fBwolfictl\-bump(1)\fP, \fBwolfictl\-check(1)\fP, \fBwolfictl\-dot(1)\fP, \fBwolfictl\-gh(1)\fP, \fBwolfictl\-image(1)\fP, \fBwolfictl\-lint(1)\fP, \fBwolfictl\-restore(1)\fP, \fBwolfictl\-ruby(1)\fP, \fBwolfictl\-scan(1)\fP, \fBwolfictl\-version(1)\fP, \fBwolfictl\-vex(1)\fP, \fBwolfictl\-withdraw(1)\fP
1 change: 1 addition & 0 deletions pkg/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func New() *cobra.Command {
cmdLint(),
cmdRuby(),
cmdLs(),
cmdRestore(),
cmdSVG(),
cmdText(),
cmdSBOM(),
Expand Down
213 changes: 213 additions & 0 deletions pkg/cli/restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"

"github.com/chainguard-dev/clog"
"github.com/spf13/cobra"
)

type BulkRestoreRequest struct {
APKs []string `json:"apks"`
}

type BulkRestoreResponse struct {
RestoredAPKs []string `json:"restored_apks"`
FailedRestores []FailedRestore `json:"failed_restores"`
}

type FailedRestore struct {
Name string `json:"name"`
ErrorMessage string `json:"error_message"`
}

func cmdRestore() *cobra.Command {
var arch string
var packagesFile string

cmd := &cobra.Command{
Use: "restore [packages...]",
Short: "Restore withdrawn packages in apk.cgr.dev",
Example: "restore example-pkg-1.2.3-r4 another-pkg-2.0.0-r1",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
log := clog.FromContext(ctx)

var packages []string

packages = append(packages, args...)
if packagesFile != "" {
filePackages, err := readPackagesFromFile(packagesFile)
if err != nil {
return fmt.Errorf("reading packages file: %w", err)
}
packages = append(packages, filePackages...)
}

if len(packages) == 0 {
return fmt.Errorf("no packages specified")
}

// Validate package format
for _, pkg := range packages {
if !isValidPackageFormat(pkg) {
return fmt.Errorf("invalid package format: %s (expected format: package-name-version, e.g., example-pkg-1.2.3-r4)", pkg)
}
}

// Determine architectures to process, default means both
var architectures []string
if arch == "" {
architectures = []string{"x86_64", "aarch64"}
} else {
architectures = []string{arch}
}

// Process each architecture
for _, targetArch := range architectures {
log.Infof("Processing architecture: %s", targetArch)
if err := restorePackages(ctx, targetArch, packages); err != nil {
return fmt.Errorf("failed to restore packages for %s: %w", targetArch, err)
}
}

return nil
},
}

cmd.Flags().StringVar(&arch, "arch", "", "architecture to restore packages for (x86_64 or aarch64, defaults to both if not specified)")
cmd.Flags().StringVar(&packagesFile, "packages-file", "", "file containing list of packages to restore (one per line, supports comments with #)")

return cmd
}

func restorePackages(ctx context.Context, arch string, packages []string) error {
// Get authentication token from environment
authToken := os.Getenv("HTTP_AUTH")
if authToken == "" {
return fmt.Errorf("HTTP_AUTH environment variable is required")
}

if len(packages) == 1 {
// Single package: use PATCH request
url := fmt.Sprintf("https://apk.cgr.dev/chainguard/%s/%s", arch, packages[0])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to be able to restore chainguard (aka wolfi), chainguard-private (enterprise-package) and extra-packages so the first part of the path needs to be driven from a command line option.

return makePatchRequest(ctx, url, authToken)
}
// Multiple packages: use POST request (bulk restore)
url := fmt.Sprintf("https://apk.cgr.dev/chainguard/%s/restore", arch)
return makePostRequest(ctx, url, authToken, packages)
}

func makePatchRequest(ctx context.Context, url, authToken string) error {
log := clog.FromContext(ctx)

req, err := http.NewRequestWithContext(ctx, "PATCH", url, nil)
if err != nil {
return fmt.Errorf("creating PATCH request: %w", err)
}

req.SetBasicAuth("user", authToken)

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("making PATCH request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("PATCH request failed with status %d and failed to read response body: %w", resp.StatusCode, err)
}
return fmt.Errorf("PATCH request failed with status %d: %s", resp.StatusCode, string(body))
}

log.Infof("Successfully restored package via PATCH")
return nil
}

func makePostRequest(ctx context.Context, url, authToken string, packages []string) error {
log := clog.FromContext(ctx)

requestBody := BulkRestoreRequest{
APKs: packages,
}

jsonBody, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("marshaling request body: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("creating POST request: %w", err)
}

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

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("making POST request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("POST request failed with status %d: %s", resp.StatusCode, string(body))
}

// Parse and display response
var response BulkRestoreResponse
if err := json.Unmarshal(body, &response); err != nil {
return fmt.Errorf("unmarshaling response: %w", err)
}

log.Infof("Restore operation completed:")
log.Infof("Successfully restored: %v", response.RestoredAPKs)
if len(response.FailedRestores) > 0 {
for _, failed := range response.FailedRestores {
log.Warnf("Failed to restore package %s: %s", failed.Name, failed.ErrorMessage)
}
}

return nil
}

// isValidPackageFormat checks if the package name follows the expected format: package-name-version
// where version typically ends with -r<number>
func isValidPackageFormat(pkg string) bool {
// Basic validation: should contain at least one hyphen and end with something like -r<number>
parts := strings.Split(pkg, "-")
if len(parts) < 3 {
return false
}

// Check if the last part looks like a revision (starts with 'r' followed by numbers)
lastPart := parts[len(parts)-1]
if len(lastPart) < 2 || lastPart[0] != 'r' {
return false
}

for i := 1; i < len(lastPart); i++ {
if lastPart[i] < '0' || lastPart[i] > '9' {
return false
}
}

return true
}
Loading
Loading