Skip to content

Commit cd2d76e

Browse files
authored
feat: audit log export support (#465)
1 parent b01028d commit cd2d76e

9 files changed

Lines changed: 137 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Object storage user access key management with `object-storage create-access-key`, `object-storage delete-access-key`, and `object-storage list-access-keys` commands
1818
- Expose GPU limits in `account show` command
1919
- Expose GPU model and amount in `server plans` command
20+
- Add `audit-log export` command.
2021

2122
## [3.21.0] - 2025-07-15
2223

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package auditlog
2+
3+
import (
4+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
5+
)
6+
7+
// BaseAuditLogCommand creates the base "audit-log" command
8+
func BaseAuditLogCommand() commands.Command {
9+
return &auditLogCommand{
10+
commands.New("audit-log", "Manage audit logs"),
11+
}
12+
}
13+
14+
type auditLogCommand struct {
15+
*commands.BaseCommand
16+
}
17+
18+
// InitCommand implements [commands.BaseCommand.InitCommand].
19+
func (c *auditLogCommand) InitCommand() {
20+
c.Cobra().Aliases = []string{"auditlog", "al"}
21+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package auditlog
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
8+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
9+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
10+
"github.com/spf13/pflag"
11+
)
12+
13+
var formats = []string{
14+
request.ExportAuditLogFormatJSON,
15+
request.ExportAuditLogFormatCSV,
16+
}
17+
18+
// ExportCommand creates the "audit-log export" command
19+
func ExportCommand() commands.Command {
20+
return &exportCommand{
21+
BaseCommand: commands.New("export", "Export audit logs", "upctl audit-log export", "upctl audit-log export --output csv >audit-log.csv"),
22+
}
23+
}
24+
25+
type exportCommand struct {
26+
*commands.BaseCommand
27+
params request.ExportAuditLogRequest
28+
}
29+
30+
func (c *exportCommand) InitCommand() {
31+
fs := &pflag.FlagSet{}
32+
fs.StringVar(&c.params.Format, "output", formats[0], fmt.Sprintf("Export format (typically %s). Note that this overrides the global --output flag.", strings.Join(formats, "|")))
33+
c.AddFlags(fs)
34+
}
35+
36+
// ExecuteWithoutArguments implements [commands.NoArgumentCommand].
37+
func (c *exportCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
38+
r, err := exec.All().ExportAuditLog(exec.Context(), &c.params)
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
return output.Raw{Source: r}, nil
44+
}

internal/commands/base/base.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/account/permissions"
77
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/account/token"
88
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/all"
9+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/auditlog"
910
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/database"
1011
databaseindex "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/database/index"
1112
databaseproperties "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/database/properties"
@@ -269,6 +270,10 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
269270
commands.BuildCommand(partneraccount.CreateCommand(), partnerAccountCommand.Cobra(), conf)
270271
commands.BuildCommand(partneraccount.ListCommand(), partnerAccountCommand.Cobra(), conf)
271272

273+
// Audit log operations
274+
auditlogCommand := commands.BuildCommand(auditlog.BaseAuditLogCommand(), rootCmd, conf)
275+
commands.BuildCommand(auditlog.ExportCommand(), auditlogCommand.Cobra(), conf)
276+
272277
// Operations for managing all resources at once
273278
allCommand := commands.BuildCommand(all.BaseAllCommand(), rootCmd, conf)
274279
commands.BuildCommand(all.PurgeCommand(), allCommand.Cobra(), conf)

internal/mock/mock.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mock
33

44
import (
55
"context"
6+
"io"
67

78
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
89
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
@@ -96,6 +97,7 @@ var (
9697
_ service.ManagedObjectStorage = &Service{}
9798
_ service.Gateway = &Service{}
9899
_ service.Token = &Service{}
100+
_ service.AuditLog = &Service{}
99101
)
100102

101103
// GetServerConfigurations implements service.Server.GetServerConfigurations
@@ -1556,3 +1558,12 @@ func (m *Service) GetBillingSummary(_ context.Context, r *request.GetBillingSumm
15561558
}
15571559
return args[0].(*upcloud.BillingSummary), args.Error(1)
15581560
}
1561+
1562+
// ExportAuditLog implements service.AuditLog.ExportAuditLog
1563+
func (m *Service) ExportAuditLog(_ context.Context, r *request.ExportAuditLogRequest) (io.ReadCloser, error) {
1564+
args := m.Called(r)
1565+
if args[0] == nil {
1566+
return nil, args.Error(1)
1567+
}
1568+
return args[0].(io.ReadCloser), args.Error(1)
1569+
}

internal/output/raw.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
package output
22

3-
import "fmt"
3+
import "io"
44

5-
// Raw is a way to output raw data to the user. It is *only* supported in humanized output and used for generating shell completion scripts.
6-
type Raw []byte
5+
// Raw is a way to output raw data from a source reader.
6+
type Raw struct {
7+
Source io.ReadCloser
8+
}
79

810
// MarshalJSON implements output.Output
911
func (s Raw) MarshalJSON() ([]byte, error) {
10-
return nil, fmt.Errorf("json output not supported")
12+
return []byte{}, nil
1113
}
1214

1315
// MarshalHuman implements output.Output
1416
func (s Raw) MarshalHuman() ([]byte, error) {
15-
return s, nil
17+
return []byte{}, nil
1618
}
1719

1820
// MarshalRawMap implements output.Output
1921
func (s Raw) MarshalRawMap() (map[string]interface{}, error) {
20-
return nil, fmt.Errorf("raw mao output not supported")
22+
return map[string]interface{}{}, nil
23+
}
24+
25+
// Read implements io.ReadCloser.
26+
func (s Raw) Read(p []byte) (n int, err error) {
27+
return s.Source.Read(p)
28+
}
29+
30+
// Close implements io.ReadCloser.
31+
func (s Raw) Close() error {
32+
return s.Source.Close()
2133
}

internal/output/render.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package output
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89

@@ -28,9 +29,13 @@ func Render(writer io.Writer, outputFormat string, commandOutputs ...Output) (er
2829
var b []byte
2930
switch outputFormat {
3031
case formatHuman:
31-
b, err = withNewline(toHuman(commandOutputs...))
32+
if b, err = toHuman(commandOutputs...); err == nil && len(b) != 0 {
33+
b = append(b, '\n')
34+
}
3235
case formatJSON:
33-
b, err = withNewline(toJSON(commandOutputs...))
36+
if b, err = toJSON(commandOutputs...); err == nil && len(b) != 0 {
37+
b = append(b, '\n')
38+
}
3439
case formatYAML:
3540
b, err = toYAML(commandOutputs...)
3641
default:
@@ -45,14 +50,20 @@ func Render(writer io.Writer, outputFormat string, commandOutputs ...Output) (er
4550
return err
4651
}
4752

48-
// Count failed outputs
53+
// Render streaming outputs and count failed ones
4954
failedCount := 0
5055
for _, commandOutput := range commandOutputs {
51-
if _, ok := commandOutput.(Error); ok {
56+
if rawOutput, ok := commandOutput.(Raw); ok {
57+
_, cErr := io.Copy(writer, rawOutput)
58+
err = errors.Join(err, cErr, rawOutput.Close())
59+
} else if _, ok := commandOutput.(Error); ok {
5260
failedCount++
5361
}
5462
}
5563

64+
if err != nil {
65+
return err
66+
}
5667
if failedCount > 0 {
5768
return &clierrors.CommandFailedError{
5869
FailedCount: failedCount,
@@ -84,7 +95,9 @@ func toJSON(commandOutputs ...Output) ([]byte, error) {
8495
if err != nil {
8596
return nil, err
8697
}
87-
jsonOutput = append(jsonOutput, outBytes)
98+
if len(outBytes) > 0 {
99+
jsonOutput = append(jsonOutput, outBytes)
100+
}
88101
}
89102
}
90103

@@ -109,7 +122,3 @@ func toYAML(commandOutputs ...Output) ([]byte, error) {
109122

110123
return JSONToYAML(b)
111124
}
112-
113-
func withNewline(b []byte, err error) ([]byte, error) {
114-
return append(b, "\n"...), err
115-
}

internal/output/render_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package output_test
33
import (
44
"bytes"
55
"errors"
6+
"io"
7+
"strings"
68
"testing"
79

810
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"
911
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
1012

1113
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
1215
)
1316

1417
type failWriter struct{}
@@ -26,22 +29,30 @@ func TestRenderFailingWriter(t *testing.T) {
2629
}
2730

2831
func TestRender(t *testing.T) {
32+
rr := strings.NewReader("raw hello")
2933
renderTests := []outputTestCase{
3034
{
3135
name: "none",
3236
input: output.None{},
33-
expectedHumanResult: "\n",
34-
expectedJSONResult: "\n",
37+
expectedHumanResult: "",
38+
expectedJSONResult: "",
3539
expectedYAMLResult: "",
3640
},
3741
{
3842
name: "marshaled",
3943
input: output.OnlyMarshaled{Value: "hello"},
40-
expectedHumanResult: "\n", // marshaled should not output in human mode
44+
expectedHumanResult: "", // marshaled should not output in human mode
4145
expectedJSONResult: `"hello"
4246
`,
4347
expectedYAMLResult: "hello\n",
4448
},
49+
{
50+
name: "raw",
51+
input: output.Raw{Source: io.NopCloser(rr)},
52+
expectedHumanResult: "raw hello",
53+
expectedJSONResult: "raw hello",
54+
expectedYAMLResult: "raw hello",
55+
},
4556
}
4657
for _, test := range renderTests {
4758
t.Run(test.name, func(t *testing.T) {
@@ -51,11 +62,15 @@ func TestRender(t *testing.T) {
5162
cfg.Viper().Set(config.KeyOutput, "human")
5263
err := output.Render(out, cfg.Output(), test.input)
5364
validateOutput(t, test.expectedHumanResult, test.expectedErrorMessage, out.Bytes(), err)
65+
_, err = rr.Seek(0, io.SeekStart)
66+
require.NoError(t, err)
5467
out.Truncate(0)
5568

5669
cfg.Viper().Set(config.KeyOutput, "json")
5770
err = output.Render(out, cfg.Output(), test.input)
5871
validateOutput(t, test.expectedJSONResult, test.expectedErrorMessage, out.Bytes(), err)
72+
_, err = rr.Seek(0, io.SeekStart)
73+
require.NoError(t, err)
5974
out.Truncate(0)
6075

6176
cfg.Viper().Set(config.KeyOutput, "yaml")

internal/service/service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ type AllServices interface {
2525
service.Partner
2626
service.Token
2727
service.Tag
28+
service.AuditLog
2829
}

0 commit comments

Comments
 (0)