Skip to content

Commit 9b06bac

Browse files
authored
fix: API inconsistencies and silent failure fixes (#2)
1 parent 753d1ec commit 9b06bac

4 files changed

Lines changed: 122 additions & 22 deletions

File tree

cmd/admin.go

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"strconv"
8+
"strings"
9+
"time"
710

811
"github.com/spf13/cobra"
912
"github.com/portainer/portainerctl/internal/client"
@@ -88,13 +91,8 @@ func backupCmd() *cobra.Command {
8891
RunE: func(cmd *cobra.Command, args []string) error {
8992
c, err := client.MustClient(); if err != nil { return err }
9093
body := map[string]interface{}{"Password": backupPass}
91-
data, err := c.RawGet("/backup")
92-
if err != nil {
93-
// POST for backup
94-
_ = c.Post("/backup", body, nil)
95-
output.Success("Backup initiated.")
96-
return nil
97-
}
94+
data, err := c.RawPost("/backup", body)
95+
if err != nil { return err }
9896
outFile := backupOutput
9997
if outFile == "" { outFile = "portainer-backup.tar.gz" }
10098
if err := os.WriteFile(outFile, data, 0600); err != nil { return err }
@@ -297,27 +295,41 @@ func userActivityCmd() *cobra.Command {
297295
},
298296
}
299297

298+
var authCsvRange, authCsvOutput string
300299
authLogsCsvCmd := &cobra.Command{
301-
Use: "auth-logs-csv", Short: "Download authentication logs as CSV",
300+
Use: "auth-logs-csv",
301+
Short: "Download authentication logs as CSV",
302302
RunE: func(cmd *cobra.Command, args []string) error {
303+
after, err := parseDayRange(authCsvRange)
304+
if err != nil { return err }
303305
c, err := client.MustClient(); if err != nil { return err }
304-
data, err := c.RawGet("/useractivity/authlogs.csv")
306+
path := "/useractivity/authlogs.csv"
307+
if after > 0 { path += fmt.Sprintf("?after=%d", after) }
308+
data, err := c.RawGet(path)
305309
if err != nil { return err }
306-
fmt.Print(string(data))
307-
return nil
310+
return writeCsvOutput(data, authCsvOutput)
308311
},
309312
}
313+
authLogsCsvCmd.Flags().StringVar(&authCsvRange, "range", "", "Time range: 1d–7d (e.g. 2d = last 2 days)")
314+
authLogsCsvCmd.Flags().StringVar(&authCsvOutput, "output-file", "", "Save CSV to file instead of printing")
310315

316+
var logsCsvRange, logsCsvOutput string
311317
logsCsvCmd := &cobra.Command{
312-
Use: "logs-csv", Short: "Download user activity logs as CSV",
318+
Use: "logs-csv",
319+
Short: "Download user activity logs as CSV",
313320
RunE: func(cmd *cobra.Command, args []string) error {
321+
after, err := parseDayRange(logsCsvRange)
322+
if err != nil { return err }
314323
c, err := client.MustClient(); if err != nil { return err }
315-
data, err := c.RawGet("/useractivity/logs.csv")
324+
path := "/useractivity/logs.csv"
325+
if after > 0 { path += fmt.Sprintf("?after=%d", after) }
326+
data, err := c.RawGet(path)
316327
if err != nil { return err }
317-
fmt.Print(string(data))
318-
return nil
328+
return writeCsvOutput(data, logsCsvOutput)
319329
},
320330
}
331+
logsCsvCmd.Flags().StringVar(&logsCsvRange, "range", "", "Time range: 1d–7d (e.g. 2d = last 2 days)")
332+
logsCsvCmd.Flags().StringVar(&logsCsvOutput, "output-file", "", "Save CSV to file instead of printing")
321333

322334
cmd.AddCommand(authLogsCmd, logsCmd, authLogsCsvCmd, logsCsvCmd)
323335
return cmd
@@ -717,3 +729,29 @@ func supportCmd() *cobra.Command {
717729
cmd.AddCommand(downloadCmd, debugLogCmd)
718730
return cmd
719731
}
732+
733+
func parseDayRange(r string) (int64, error) {
734+
if r == "" {
735+
return 0, nil
736+
}
737+
if !strings.HasSuffix(r, "d") {
738+
return 0, fmt.Errorf("invalid range %q: use format like 1d, 2d, up to 7d", r)
739+
}
740+
n, err := strconv.Atoi(strings.TrimSuffix(r, "d"))
741+
if err != nil || n < 1 || n > 7 {
742+
return 0, fmt.Errorf("invalid range %q: must be between 1d and 7d", r)
743+
}
744+
return time.Now().Add(-time.Duration(n) * 24 * time.Hour).Unix(), nil
745+
}
746+
747+
func writeCsvOutput(data []byte, outputFile string) error {
748+
if outputFile != "" {
749+
if err := os.WriteFile(outputFile, data, 0600); err != nil {
750+
return err
751+
}
752+
output.Success("Saved to " + outputFile)
753+
return nil
754+
}
755+
fmt.Print(string(data))
756+
return nil
757+
}

cmd/registries_webhooks.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ func registryTypeLabel(t int) string {
5050
return "dockerhub"
5151
case 7:
5252
return "ecr"
53+
case 8:
54+
return "github"
5355
default:
5456
return strconv.Itoa(t)
5557
}
@@ -183,6 +185,7 @@ func registryCmd() *cobra.Command {
183185
}
184186

185187
var pingURL, pingUser, pingPass string
188+
var pingType int
186189
pingCmd := &cobra.Command{
187190
Use: "ping",
188191
Short: "Test connectivity to a registry",
@@ -194,7 +197,7 @@ func registryCmd() *cobra.Command {
194197
if err != nil {
195198
return err
196199
}
197-
body := map[string]interface{}{"URL": pingURL, "Username": pingUser, "Password": pingPass}
200+
body := map[string]interface{}{"URL": pingURL, "Username": pingUser, "Password": pingPass, "Type": pingType}
198201
if err := c.Post("/registries/ping", body, nil); err != nil {
199202
return err
200203
}
@@ -205,6 +208,7 @@ func registryCmd() *cobra.Command {
205208
pingCmd.Flags().StringVar(&pingURL, "url", "", "Registry URL")
206209
pingCmd.Flags().StringVar(&pingUser, "user", "", "Username")
207210
pingCmd.Flags().StringVar(&pingPass, "pass", "", "Password")
211+
pingCmd.Flags().IntVar(&pingType, "type", 3, "Registry type: 1=quay, 2=azure, 3=custom, 4=gitlab, 5=proget, 6=dockerhub, 7=ecr, 8=github")
208212

209213
cmd.AddCommand(listCmd, getCmd, createCmd, deleteCmd, reposCmd, pingCmd)
210214
return cmd

cmd/stack.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ func stackCmd() *cobra.Command {
344344
deployK8sGitCmd.Flags().StringVar(&deployBranch, "branch", "main", "Git branch")
345345
deployK8sGitCmd.Flags().StringVar(&deployPath, "path", "manifest.yaml", "Manifest file path in repo")
346346

347+
var redeployEnvID int
347348
redeployCmd := &cobra.Command{
348349
Use: "redeploy <id>",
349350
Short: "Redeploy a GitOps-backed stack (pull latest from Git)",
@@ -353,49 +354,66 @@ func stackCmd() *cobra.Command {
353354
if err != nil {
354355
return err
355356
}
357+
path := "/stacks/" + args[0] + "/git/redeploy"
358+
if redeployEnvID > 0 {
359+
path += fmt.Sprintf("?endpointId=%d", redeployEnvID)
360+
}
356361
var result interface{}
357-
if err := c.Put("/stacks/"+args[0]+"/git/redeploy", map[string]interface{}{}, &result); err != nil {
362+
if err := c.Put(path, map[string]interface{}{}, &result); err != nil {
358363
return err
359364
}
360365
output.Success("Stack " + args[0] + " redeployed from Git.")
361366
return nil
362367
},
363368
}
369+
redeployCmd.Flags().IntVar(&redeployEnvID, "env", 0, "Environment ID (required for stacks created before v1.18.0)")
364370

371+
var startEnvID int
365372
startCmd := &cobra.Command{
366373
Use: "start <id>",
367374
Short: "Start a stopped stack",
368375
Args: cobra.ExactArgs(1),
369376
RunE: func(cmd *cobra.Command, args []string) error {
377+
if startEnvID == 0 {
378+
return fmt.Errorf("--env is required")
379+
}
370380
c, err := client.MustClient()
371381
if err != nil {
372382
return err
373383
}
374384
var result interface{}
375-
if err := c.Post("/stacks/"+args[0]+"/start", nil, &result); err != nil {
385+
path := fmt.Sprintf("/stacks/%s/start?endpointId=%d", args[0], startEnvID)
386+
if err := c.Post(path, nil, &result); err != nil {
376387
return err
377388
}
378389
output.Success("Stack " + args[0] + " started.")
379390
return nil
380391
},
381392
}
393+
startCmd.Flags().IntVar(&startEnvID, "env", 0, "Environment ID (required)")
382394

395+
var stopEnvID int
383396
stopCmd := &cobra.Command{
384397
Use: "stop <id>",
385398
Short: "Stop a running stack",
386399
Args: cobra.ExactArgs(1),
387400
RunE: func(cmd *cobra.Command, args []string) error {
401+
if stopEnvID == 0 {
402+
return fmt.Errorf("--env is required")
403+
}
388404
c, err := client.MustClient()
389405
if err != nil {
390406
return err
391407
}
392-
if err := c.Post("/stacks/"+args[0]+"/stop", nil, nil); err != nil {
408+
path := fmt.Sprintf("/stacks/%s/stop?endpointId=%d", args[0], stopEnvID)
409+
if err := c.Post(path, nil, nil); err != nil {
393410
return err
394411
}
395412
output.Success("Stack " + args[0] + " stopped.")
396413
return nil
397414
},
398415
}
416+
stopCmd.Flags().IntVar(&stopEnvID, "env", 0, "Environment ID (required)")
399417

400418
var deleteEnvID int
401419
deleteCmd := &cobra.Command{

internal/client/client.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,58 @@ func (c *Client) DeleteWithBody(path string, body interface{}) error {
136136
return c.do(req, nil)
137137
}
138138

139-
// RawGet returns the raw response body — used for passthrough commands (kubectl proxy, docker proxy).
139+
// RawPost sends a POST and returns the raw response body — used for binary responses like backup.
140+
func (c *Client) RawPost(path string, body interface{}) ([]byte, error) {
141+
req, err := c.newRequest("POST", path, body)
142+
if err != nil {
143+
return nil, err
144+
}
145+
resp, err := c.http.Do(req)
146+
if err != nil {
147+
return nil, fmt.Errorf("request failed: %w", err)
148+
}
149+
defer resp.Body.Close()
150+
data, err := io.ReadAll(resp.Body)
151+
if err != nil {
152+
return nil, fmt.Errorf("reading response: %w", err)
153+
}
154+
if resp.StatusCode >= 400 {
155+
var apiErr APIError
156+
apiErr.Status = resp.StatusCode
157+
_ = json.Unmarshal(data, &apiErr)
158+
if apiErr.Message == "" {
159+
apiErr.Message = string(data)
160+
}
161+
return nil, &apiErr
162+
}
163+
return data, nil
164+
}
165+
166+
// RawGet returns the raw response body — used for binary/passthrough responses.
140167
func (c *Client) RawGet(path string) ([]byte, error) {
141168
req, err := c.newRequest("GET", path, nil)
142169
if err != nil {
143170
return nil, err
144171
}
145172
resp, err := c.http.Do(req)
146173
if err != nil {
147-
return nil, err
174+
return nil, fmt.Errorf("request failed: %w", err)
148175
}
149176
defer resp.Body.Close()
150-
return io.ReadAll(resp.Body)
177+
data, err := io.ReadAll(resp.Body)
178+
if err != nil {
179+
return nil, fmt.Errorf("reading response: %w", err)
180+
}
181+
if resp.StatusCode >= 400 {
182+
var apiErr APIError
183+
apiErr.Status = resp.StatusCode
184+
_ = json.Unmarshal(data, &apiErr)
185+
if apiErr.Message == "" {
186+
apiErr.Message = string(data)
187+
}
188+
return nil, &apiErr
189+
}
190+
return data, nil
151191
}
152192

153193
// ProxyRequest forwards an arbitrary method+path+body through the Portainer API proxy.

0 commit comments

Comments
 (0)