From 0977fe8127438bd856ad427126a7d0761df53724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20=27sil2100=27=20Zemczak?= Date: Fri, 29 Aug 2025 17:55:55 +0200 Subject: [PATCH 1/2] restore: add command to restore withdrawn packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz 'sil2100' Zemczak --- pkg/cli/commands.go | 1 + pkg/cli/restore.go | 211 +++++++++++++++++++++++++++++++++++++ pkg/cli/restore_test.go | 223 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 pkg/cli/restore.go create mode 100644 pkg/cli/restore_test.go diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 6ec39de46..bebf319bb 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -34,6 +34,7 @@ func New() *cobra.Command { cmdLint(), cmdRuby(), cmdLs(), + cmdRestore(), cmdSVG(), cmdText(), cmdSBOM(), diff --git a/pkg/cli/restore.go b/pkg/cli/restore.go new file mode 100644 index 000000000..98918822d --- /dev/null +++ b/pkg/cli/restore.go @@ -0,0 +1,211 @@ +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]) + return makePatchRequest(ctx, url, authToken) + } else { + // 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, _ := io.ReadAll(resp.Body) + 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 +func isValidPackageFormat(pkg string) bool { + // Basic validation: should contain at least one hyphen and end with something like -r + 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 +} diff --git a/pkg/cli/restore_test.go b/pkg/cli/restore_test.go new file mode 100644 index 000000000..193cfbd31 --- /dev/null +++ b/pkg/cli/restore_test.go @@ -0,0 +1,223 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMakePatchRequest(t *testing.T) { + tests := []struct { + name string + serverResponse func(w http.ResponseWriter, r *http.Request) + authToken string + expectError bool + errorContains string + }{ + { + name: "successful PATCH request", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + // Verify request method + assert.Equal(t, "PATCH", r.Method) + + // Verify authentication + username, password, ok := r.BasicAuth() + assert.True(t, ok) + assert.Equal(t, "user", username) + assert.Equal(t, "test-token", password) + + // Verify no body content for PATCH + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Empty(t, body) + + w.WriteHeader(http.StatusOK) + }, + authToken: "test-token", + expectError: false, + }, + { + name: "server returns 404", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Package not found")) + }, + authToken: "test-token", + expectError: true, + errorContains: "PATCH request failed with status 404: Package not found", + }, + { + name: "server returns 500", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + }, + authToken: "test-token", + expectError: true, + errorContains: "PATCH request failed with status 500: Internal server error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(tt.serverResponse)) + defer server.Close() + + // Test the makePatchRequest function + ctx := context.Background() + err := makePatchRequest(ctx, server.URL, tt.authToken) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestMakePostRequest(t *testing.T) { + tests := []struct { + name string + packages []string + serverResponse func(w http.ResponseWriter, r *http.Request) + authToken string + expectError bool + errorContains string + }{ + { + name: "successful POST request", + packages: []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2"}, + serverResponse: func(w http.ResponseWriter, r *http.Request) { + // Verify request method + assert.Equal(t, "POST", r.Method) + + // Verify authentication + username, password, ok := r.BasicAuth() + assert.True(t, ok) + assert.Equal(t, "user", username) + assert.Equal(t, "test-token", password) + + // Verify content type + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Verify request body + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var request BulkRestoreRequest + err = json.Unmarshal(body, &request) + require.NoError(t, err) + assert.Equal(t, []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2"}, request.APKs) + + // Send successful response + response := BulkRestoreResponse{ + RestoredAPKs: []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2"}, + FailedRestores: []FailedRestore{}, + } + responseBytes, _ := json.Marshal(response) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(responseBytes) + }, + authToken: "test-token", + expectError: false, + }, + { + name: "response with partial failures", + packages: []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2", "pkg3-3.0.0-r3"}, + serverResponse: func(w http.ResponseWriter, r *http.Request) { + // Send response with some failures + response := BulkRestoreResponse{ + RestoredAPKs: []string{"pkg1-1.0.0-r1", "pkg3-3.0.0-r3"}, + FailedRestores: []FailedRestore{ + {Name: "pkg2-2.0.0-r2", ErrorMessage: "Package not found in repository"}, + }, + } + responseBytes, _ := json.Marshal(response) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(responseBytes) + }, + authToken: "test-token", + expectError: false, + }, + { + name: "server returns 400", + packages: []string{"invalid-package"}, + serverResponse: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid package format")) + }, + authToken: "test-token", + expectError: true, + errorContains: "POST request failed with status 400: Invalid package format", + }, + { + name: "server returns invalid JSON", + packages: []string{"pkg1-1.0.0-r1"}, + serverResponse: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json")) + }, + authToken: "test-token", + expectError: true, + errorContains: "unmarshaling response:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(tt.serverResponse)) + defer server.Close() + + // Test the makePostRequest function + ctx := context.Background() + err := makePostRequest(ctx, server.URL, tt.authToken, tt.packages) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestIsValidPackageFormat(t *testing.T) { + tests := []struct { + name string + pkg string + expected bool + }{ + {"valid package format", "test-pkg-1.2.3-r4", true}, + {"valid with complex name", "my-complex-package-name-2.1.0-r1", true}, + {"invalid - no revision", "test-pkg-1.2.3", false}, + {"invalid - too few parts", "pkg-1.2", false}, + {"invalid - revision not starting with r", "test-pkg-1.2.3-a4", false}, + {"invalid - revision with non-numeric", "test-pkg-1.2.3-r4a", false}, + {"valid - zero revision", "test-1.2.3-r0", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidPackageFormat(tt.pkg) + assert.Equal(t, tt.expected, result) + }) + } +} From 69d86d5325eab1c9df35099f98b5bf3e8222d2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20=27sil2100=27=20Zemczak?= Date: Fri, 29 Aug 2025 18:51:52 +0200 Subject: [PATCH 2/2] Fix linter issues. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz 'sil2100' Zemczak --- docs/cmd/wolfictl.md | 1 + docs/man/man1/wolfictl.1 | 2 +- pkg/cli/restore.go | 12 +++++++----- pkg/cli/restore_test.go | 34 +++++++++++++++++++++------------- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/docs/cmd/wolfictl.md b/docs/cmd/wolfictl.md index 85ce4b626..d03dc739f 100644 --- a/docs/cmd/wolfictl.md +++ b/docs/cmd/wolfictl.md @@ -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 diff --git a/docs/man/man1/wolfictl.1 b/docs/man/man1/wolfictl.1 index c2b396fa7..33589f150 100644 --- a/docs/man/man1/wolfictl.1 +++ b/docs/man/man1/wolfictl.1 @@ -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 diff --git a/pkg/cli/restore.go b/pkg/cli/restore.go index 98918822d..54bff609e 100644 --- a/pkg/cli/restore.go +++ b/pkg/cli/restore.go @@ -100,11 +100,10 @@ func restorePackages(ctx context.Context, arch string, packages []string) error // Single package: use PATCH request url := fmt.Sprintf("https://apk.cgr.dev/chainguard/%s/%s", arch, packages[0]) return makePatchRequest(ctx, url, authToken) - } else { - // Multiple packages: use POST request (bulk restore) - url := fmt.Sprintf("https://apk.cgr.dev/chainguard/%s/restore", arch) - return makePostRequest(ctx, url, authToken, packages) } + // 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 { @@ -125,7 +124,10 @@ func makePatchRequest(ctx context.Context, url, authToken string) error { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + 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)) } diff --git a/pkg/cli/restore_test.go b/pkg/cli/restore_test.go index 193cfbd31..6d5825058 100644 --- a/pkg/cli/restore_test.go +++ b/pkg/cli/restore_test.go @@ -44,9 +44,9 @@ func TestMakePatchRequest(t *testing.T) { }, { name: "server returns 404", - serverResponse: func(w http.ResponseWriter, r *http.Request) { + serverResponse: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) - w.Write([]byte("Package not found")) + _, _ = w.Write([]byte("Package not found")) }, authToken: "test-token", expectError: true, @@ -54,9 +54,9 @@ func TestMakePatchRequest(t *testing.T) { }, { name: "server returns 500", - serverResponse: func(w http.ResponseWriter, r *http.Request) { + serverResponse: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) + _, _ = w.Write([]byte("Internal server error")) }, authToken: "test-token", expectError: true, @@ -125,10 +125,14 @@ func TestMakePostRequest(t *testing.T) { RestoredAPKs: []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2"}, FailedRestores: []FailedRestore{}, } - responseBytes, _ := json.Marshal(response) + responseBytes, err := json.Marshal(response) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(responseBytes) + _, _ = w.Write(responseBytes) }, authToken: "test-token", expectError: false, @@ -136,7 +140,7 @@ func TestMakePostRequest(t *testing.T) { { name: "response with partial failures", packages: []string{"pkg1-1.0.0-r1", "pkg2-2.0.0-r2", "pkg3-3.0.0-r3"}, - serverResponse: func(w http.ResponseWriter, r *http.Request) { + serverResponse: func(w http.ResponseWriter, _ *http.Request) { // Send response with some failures response := BulkRestoreResponse{ RestoredAPKs: []string{"pkg1-1.0.0-r1", "pkg3-3.0.0-r3"}, @@ -144,10 +148,14 @@ func TestMakePostRequest(t *testing.T) { {Name: "pkg2-2.0.0-r2", ErrorMessage: "Package not found in repository"}, }, } - responseBytes, _ := json.Marshal(response) + responseBytes, err := json.Marshal(response) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(responseBytes) + _, _ = w.Write(responseBytes) }, authToken: "test-token", expectError: false, @@ -155,9 +163,9 @@ func TestMakePostRequest(t *testing.T) { { name: "server returns 400", packages: []string{"invalid-package"}, - serverResponse: func(w http.ResponseWriter, r *http.Request) { + serverResponse: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Invalid package format")) + _, _ = w.Write([]byte("Invalid package format")) }, authToken: "test-token", expectError: true, @@ -166,10 +174,10 @@ func TestMakePostRequest(t *testing.T) { { name: "server returns invalid JSON", packages: []string{"pkg1-1.0.0-r1"}, - serverResponse: func(w http.ResponseWriter, r *http.Request) { + serverResponse: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte("invalid json")) + _, _ = w.Write([]byte("invalid json")) }, authToken: "test-token", expectError: true,