Phases 11, 12: mcrctl CLI tool and mcr-web UI
Phase 11 implements the admin CLI with dual REST/gRPC transport, global flags (--server, --grpc, --token, --ca-cert, --json), and all commands: status, repo list/delete, policy CRUD, audit tail, gc trigger/status/reconcile, and snapshot. Phase 12 implements the HTMX web UI with chi router, session-based auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection (HMAC-SHA256 signed double-submit), and pages for dashboard, repositories, manifest detail, policy management, and audit log. Security: CSRF via signed double-submit cookie, session cookies with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all connections, form body size limits via http.MaxBytesReader. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
cmd/mcrctl/client.go
Normal file
145
cmd/mcrctl/client.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
)
|
||||
|
||||
// apiClient wraps both REST and gRPC transports. When grpcAddr is set
|
||||
// the gRPC service clients are used; otherwise requests go via REST.
|
||||
type apiClient struct {
|
||||
serverURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
|
||||
// gRPC (nil when --grpc is not set).
|
||||
grpcConn *grpc.ClientConn
|
||||
registry mcrv1.RegistryServiceClient
|
||||
policy mcrv1.PolicyServiceClient
|
||||
audit mcrv1.AuditServiceClient
|
||||
admin mcrv1.AdminServiceClient
|
||||
}
|
||||
|
||||
// newClient builds an apiClient from the resolved flags.
|
||||
func newClient(serverURL, grpcAddr, token, caCertFile string) (*apiClient, error) {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if caCertFile != "" {
|
||||
pem, err := os.ReadFile(caCertFile) //nolint:gosec // CA cert path is operator-supplied
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("ca-cert file contains no valid certificates")
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: serverURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if grpcAddr != "" {
|
||||
creds := credentials.NewTLS(tlsCfg)
|
||||
cc, err := grpc.NewClient(grpcAddr,
|
||||
grpc.WithTransportCredentials(creds),
|
||||
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpc dial: %w", err)
|
||||
}
|
||||
c.grpcConn = cc
|
||||
c.registry = mcrv1.NewRegistryServiceClient(cc)
|
||||
c.policy = mcrv1.NewPolicyServiceClient(cc)
|
||||
c.audit = mcrv1.NewAuditServiceClient(cc)
|
||||
c.admin = mcrv1.NewAdminServiceClient(cc)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// close shuts down the gRPC connection if open.
|
||||
func (c *apiClient) close() {
|
||||
if c.grpcConn != nil {
|
||||
_ = c.grpcConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// useGRPC returns true when the client should use gRPC transport.
|
||||
func (c *apiClient) useGRPC() bool {
|
||||
return c.grpcConn != nil
|
||||
}
|
||||
|
||||
// apiError is the JSON error envelope returned by the REST API.
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// restDo performs an HTTP request and returns the response body. If the
|
||||
// response status is >= 400 it reads the JSON error body and returns a
|
||||
// descriptive error.
|
||||
func (c *apiClient) restDo(method, path string, body any) ([]byte, error) {
|
||||
url := c.serverURL + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var ae apiError
|
||||
if json.Unmarshal(data, &ae) == nil && ae.Error != "" {
|
||||
return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, ae.Error)
|
||||
}
|
||||
return nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
267
cmd/mcrctl/client_test.go
Normal file
267
cmd/mcrctl/client_test.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
{2684354560, "2.5 GB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := formatSize(tt.bytes)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
// Capture stdout.
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
data := map[string]string{"status": "ok"}
|
||||
if err := printJSON(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("printJSON output is not valid JSON: %v\nOutput: %s", err, buf.String())
|
||||
}
|
||||
if parsed["status"] != "ok" {
|
||||
t.Errorf("expected status=ok, got %q", parsed["status"])
|
||||
}
|
||||
|
||||
// Verify indentation.
|
||||
if !strings.Contains(buf.String(), " ") {
|
||||
t.Error("expected indented JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
headers := []string{"NAME", "SIZE", "COUNT"}
|
||||
rows := [][]string{
|
||||
{"alpha", "1.0 MB", "5"},
|
||||
{"beta", "2.5 GB", "12"},
|
||||
}
|
||||
printTable(&buf, headers, rows)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), output)
|
||||
}
|
||||
|
||||
// Header should contain all column names.
|
||||
for _, h := range headers {
|
||||
if !strings.Contains(lines[0], h) {
|
||||
t.Errorf("header missing %q: %s", h, lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Rows should contain the data.
|
||||
if !strings.Contains(lines[1], "alpha") {
|
||||
t.Errorf("row 1 missing 'alpha': %s", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[2], "beta") {
|
||||
t.Errorf("row 2 missing 'beta': %s", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFromEnv(t *testing.T) {
|
||||
t.Setenv("MCR_TOKEN", "test-env-token")
|
||||
|
||||
// Build client with empty token flag (should fall back to env).
|
||||
c, err := newClient("https://example.com", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
// The newClient does not resolve env; the main() PersistentPreRunE does.
|
||||
// So here we test the pattern manually.
|
||||
token := ""
|
||||
if token == "" {
|
||||
token = os.Getenv("MCR_TOKEN")
|
||||
}
|
||||
if token != "test-env-token" {
|
||||
t.Errorf("expected token from env, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFlagOverridesEnv(t *testing.T) {
|
||||
t.Setenv("MCR_TOKEN", "env-token")
|
||||
|
||||
flagVal := "flag-token"
|
||||
token := flagVal
|
||||
if token == "" {
|
||||
token = os.Getenv("MCR_TOKEN")
|
||||
}
|
||||
if token != "flag-token" {
|
||||
t.Errorf("expected flag token to override env, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientREST(t *testing.T) {
|
||||
c, err := newClient("https://example.com", "", "mytoken", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
if c.useGRPC() {
|
||||
t.Error("expected REST client (no gRPC)")
|
||||
}
|
||||
if c.serverURL != "https://example.com" {
|
||||
t.Errorf("unexpected serverURL: %s", c.serverURL)
|
||||
}
|
||||
if c.token != "mytoken" {
|
||||
t.Errorf("unexpected token: %s", c.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientBadCACert(t *testing.T) {
|
||||
_, err := newClient("https://example.com", "", "", "/nonexistent/ca.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent CA cert")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CA cert") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientInvalidCACert(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/bad.pem"
|
||||
if err := os.WriteFile(tmpFile, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := newClient("https://example.com", "", "", tmpFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CA cert")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no valid certificates") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Errorf("missing or wrong Authorization header: %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "test-token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
data, err := c.restDo("GET", "/v1/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
t.Errorf("expected status=ok, got %q", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"admin role required"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "bad-token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := c.restDo("GET", "/v1/repositories", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "admin role required") {
|
||||
t.Errorf("error should contain server message: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should contain status code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoPostBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json content type, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("failed to decode body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"gc-123"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
data, err := c.restDo("POST", "/v1/gc", map[string]string{"test": "value"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "gc-123") {
|
||||
t.Errorf("unexpected response: %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// Global flags, resolved in PersistentPreRunE.
|
||||
var (
|
||||
flagServer string
|
||||
flagGRPC string
|
||||
flagToken string
|
||||
flagCACert string
|
||||
flagJSON bool
|
||||
|
||||
client *apiClient
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "mcrctl",
|
||||
Short: "Metacircular Container Registry admin CLI",
|
||||
Use: "mcrctl",
|
||||
Short: "Metacircular Container Registry admin CLI",
|
||||
Version: version,
|
||||
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
// Resolve token: flag overrides env.
|
||||
token := flagToken
|
||||
if token == "" {
|
||||
token = os.Getenv("MCR_TOKEN")
|
||||
}
|
||||
|
||||
var err error
|
||||
client, err = newClient(flagServer, flagGRPC, token, flagCACert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
if client != nil {
|
||||
client.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
root.PersistentFlags().StringVar(&flagServer, "server", "", "REST API base URL (e.g. https://registry.example.com)")
|
||||
root.PersistentFlags().StringVar(&flagGRPC, "grpc", "", "gRPC server address (e.g. registry.example.com:9443)")
|
||||
root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env)")
|
||||
root.PersistentFlags().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file")
|
||||
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table")
|
||||
|
||||
root.AddCommand(statusCmd())
|
||||
root.AddCommand(repoCmd())
|
||||
root.AddCommand(gcCmd())
|
||||
@@ -25,128 +71,681 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- status ----------
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Query server health",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
RunE: runStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func runStatus(_ *cobra.Command, _ []string) error {
|
||||
if client.useGRPC() {
|
||||
resp, err := client.admin.Health(context.Background(), &mcrv1.HealthRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
if flagJSON {
|
||||
return printJSON(resp)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Status: %s\n", resp.GetStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := client.restDo("GET", "/v1/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
// Pass through raw JSON with re-indent.
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Status: %s\n", resp.Status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- repo ----------
|
||||
|
||||
func repoCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "repo",
|
||||
Short: "Repository management",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
cmd.AddCommand(repoListCmd())
|
||||
cmd.AddCommand(repoDeleteCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func repoListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List repositories",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
})
|
||||
RunE: runRepoList,
|
||||
}
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
func runRepoList(_ *cobra.Command, _ []string) error {
|
||||
type repoRow struct {
|
||||
Name string `json:"name"`
|
||||
TagCount int `json:"tag_count"`
|
||||
ManifestCount int `json:"manifest_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
if client.useGRPC() {
|
||||
resp, err := client.registry.ListRepositories(context.Background(), &mcrv1.ListRepositoriesRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list repositories: %w", err)
|
||||
}
|
||||
repos := resp.GetRepositories()
|
||||
if flagJSON {
|
||||
return printJSON(repos)
|
||||
}
|
||||
rows := make([][]string, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
rows = append(rows, []string{
|
||||
r.GetName(),
|
||||
strconv.Itoa(int(r.GetTagCount())),
|
||||
strconv.Itoa(int(r.GetManifestCount())),
|
||||
formatSize(r.GetTotalSize()),
|
||||
r.GetCreatedAt(),
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"NAME", "TAGS", "MANIFESTS", "SIZE", "CREATED"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := client.restDo("GET", "/v1/repositories", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list repositories: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var repos []repoRow
|
||||
if err := json.Unmarshal(data, &repos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
rows = append(rows, []string{
|
||||
r.Name,
|
||||
strconv.Itoa(r.TagCount),
|
||||
strconv.Itoa(r.ManifestCount),
|
||||
formatSize(r.TotalSize),
|
||||
r.CreatedAt,
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"NAME", "TAGS", "MANIFESTS", "SIZE", "CREATED"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func repoDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete [name]",
|
||||
Short: "Delete a repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
RunE: runRepoDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func gcCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "gc",
|
||||
Short: "Trigger garbage collection",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
func runRepoDelete(_ *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
if !confirmPrompt(fmt.Sprintf("Are you sure you want to delete repository %q?", name)) {
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check GC status",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
})
|
||||
if client.useGRPC() {
|
||||
_, err := client.registry.DeleteRepository(context.Background(), &mcrv1.DeleteRepositoryRequest{Name: name})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete repository: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Repository %q deleted.\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
_, err := client.restDo("DELETE", "/v1/repositories/"+name, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete repository: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Repository %q deleted.\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- policy ----------
|
||||
|
||||
func policyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Short: "Policy rule management",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
cmd.AddCommand(policyListCmd())
|
||||
cmd.AddCommand(policyCreateCmd())
|
||||
cmd.AddCommand(policyUpdateCmd())
|
||||
cmd.AddCommand(policyDeleteCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func policyListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List policy rules",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
})
|
||||
RunE: runPolicyList,
|
||||
}
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
func runPolicyList(_ *cobra.Command, _ []string) error {
|
||||
type policyRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
Effect string `json:"effect"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
if client.useGRPC() {
|
||||
resp, err := client.policy.ListPolicyRules(context.Background(), &mcrv1.ListPolicyRulesRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list policy rules: %w", err)
|
||||
}
|
||||
rules := resp.GetRules()
|
||||
if flagJSON {
|
||||
return printJSON(rules)
|
||||
}
|
||||
rows := make([][]string, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatInt(r.GetId(), 10),
|
||||
strconv.Itoa(int(r.GetPriority())),
|
||||
r.GetDescription(),
|
||||
r.GetEffect(),
|
||||
strconv.FormatBool(r.GetEnabled()),
|
||||
r.GetCreatedAt(),
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"ID", "PRIORITY", "DESCRIPTION", "EFFECT", "ENABLED", "CREATED"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := client.restDo("GET", "/v1/policy/rules", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list policy rules: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var rules []policyRow
|
||||
if err := json.Unmarshal(data, &rules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatInt(r.ID, 10),
|
||||
strconv.Itoa(r.Priority),
|
||||
r.Description,
|
||||
r.Effect,
|
||||
strconv.FormatBool(r.Enabled),
|
||||
r.CreatedAt,
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"ID", "PRIORITY", "DESCRIPTION", "EFFECT", "ENABLED", "CREATED"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func policyCreateCmd() *cobra.Command {
|
||||
var ruleBody string
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a policy rule",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runPolicyCreate(cmd, ruleBody)
|
||||
},
|
||||
})
|
||||
}
|
||||
cmd.Flags().StringVar(&ruleBody, "rule", "", "policy rule as JSON string")
|
||||
_ = cmd.MarkFlagRequired("rule")
|
||||
return cmd
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
func runPolicyCreate(_ *cobra.Command, ruleBody string) error {
|
||||
if client.useGRPC() {
|
||||
var req mcrv1.CreatePolicyRuleRequest
|
||||
if err := json.Unmarshal([]byte(ruleBody), &req); err != nil {
|
||||
return fmt.Errorf("invalid rule JSON: %w", err)
|
||||
}
|
||||
rule, err := client.policy.CreatePolicyRule(context.Background(), &req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create policy rule: %w", err)
|
||||
}
|
||||
if flagJSON {
|
||||
return printJSON(rule)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule created (ID: %d)\n", rule.GetId())
|
||||
return nil
|
||||
}
|
||||
|
||||
var body any
|
||||
if err := json.Unmarshal([]byte(ruleBody), &body); err != nil {
|
||||
return fmt.Errorf("invalid rule JSON: %w", err)
|
||||
}
|
||||
|
||||
data, err := client.restDo("POST", "/v1/policy/rules", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create policy rule: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule created (ID: %d)\n", created.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func policyUpdateCmd() *cobra.Command {
|
||||
var ruleBody string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update [id]",
|
||||
Short: "Update a policy rule",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPolicyUpdate(cmd, args, ruleBody)
|
||||
},
|
||||
})
|
||||
}
|
||||
cmd.Flags().StringVar(&ruleBody, "rule", "", "partial update as JSON string")
|
||||
_ = cmd.MarkFlagRequired("rule")
|
||||
return cmd
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
func runPolicyUpdate(_ *cobra.Command, args []string, ruleBody string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid rule ID: %w", err)
|
||||
}
|
||||
|
||||
if client.useGRPC() {
|
||||
var req mcrv1.UpdatePolicyRuleRequest
|
||||
if err := json.Unmarshal([]byte(ruleBody), &req); err != nil {
|
||||
return fmt.Errorf("invalid rule JSON: %w", err)
|
||||
}
|
||||
req.Id = id
|
||||
rule, err := client.policy.UpdatePolicyRule(context.Background(), &req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update policy rule: %w", err)
|
||||
}
|
||||
if flagJSON {
|
||||
return printJSON(rule)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule %d updated.\n", rule.GetId())
|
||||
return nil
|
||||
}
|
||||
|
||||
var body any
|
||||
if err := json.Unmarshal([]byte(ruleBody), &body); err != nil {
|
||||
return fmt.Errorf("invalid rule JSON: %w", err)
|
||||
}
|
||||
|
||||
data, err := client.restDo("PATCH", "/v1/policy/rules/"+strconv.FormatInt(id, 10), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update policy rule: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule %d updated.\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func policyDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete [id]",
|
||||
Short: "Delete a policy rule",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
RunE: runPolicyDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runPolicyDelete(_ *cobra.Command, args []string) error {
|
||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid rule ID: %w", err)
|
||||
}
|
||||
|
||||
if !confirmPrompt(fmt.Sprintf("Are you sure you want to delete policy rule %d?", id)) {
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if client.useGRPC() {
|
||||
_, err := client.policy.DeletePolicyRule(context.Background(), &mcrv1.DeletePolicyRuleRequest{Id: id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete policy rule: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule %d deleted.\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = client.restDo("DELETE", "/v1/policy/rules/"+strconv.FormatInt(id, 10), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete policy rule: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Policy rule %d deleted.\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- audit ----------
|
||||
|
||||
func auditCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "Audit log management",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
cmd.AddCommand(auditTailCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func auditTailCmd() *cobra.Command {
|
||||
var n int
|
||||
var eventType string
|
||||
cmd := &cobra.Command{
|
||||
Use: "tail",
|
||||
Short: "Print recent audit events",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
return runAuditTail(n, eventType)
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "n", "n", 50, "number of events to retrieve")
|
||||
cmd.Flags().StringVar(&eventType, "event-type", "", "filter by event type")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runAuditTail(n int, eventType string) error {
|
||||
type auditRow struct {
|
||||
ID int64 `json:"id"`
|
||||
EventTime string `json:"event_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ActorID string `json:"actor_id"`
|
||||
Repository string `json:"repository"`
|
||||
Digest string `json:"digest"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Details map[string]string `json:"details"`
|
||||
}
|
||||
|
||||
if client.useGRPC() {
|
||||
req := &mcrv1.ListAuditEventsRequest{
|
||||
Pagination: &mcrv1.PaginationRequest{Limit: int32(n)}, //nolint:gosec // n is user-provided flag with small values
|
||||
}
|
||||
if eventType != "" {
|
||||
req.EventType = eventType
|
||||
}
|
||||
resp, err := client.audit.ListAuditEvents(context.Background(), req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
events := resp.GetEvents()
|
||||
if flagJSON {
|
||||
return printJSON(events)
|
||||
}
|
||||
rows := make([][]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatInt(e.GetId(), 10),
|
||||
e.GetEventTime(),
|
||||
e.GetEventType(),
|
||||
e.GetActorId(),
|
||||
e.GetRepository(),
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"ID", "TIME", "TYPE", "ACTOR", "REPOSITORY"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/v1/audit?n=%d", n)
|
||||
if eventType != "" {
|
||||
path += "&event_type=" + eventType
|
||||
}
|
||||
data, err := client.restDo("GET", path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var events []auditRow
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, []string{
|
||||
strconv.FormatInt(e.ID, 10),
|
||||
e.EventTime,
|
||||
e.EventType,
|
||||
e.ActorID,
|
||||
e.Repository,
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, []string{"ID", "TIME", "TYPE", "ACTOR", "REPOSITORY"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- gc ----------
|
||||
|
||||
func gcCmd() *cobra.Command {
|
||||
var reconcile bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "gc",
|
||||
Short: "Trigger garbage collection",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runGC(reconcile)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&reconcile, "reconcile", false, "run reconciliation instead of normal GC")
|
||||
|
||||
cmd.AddCommand(gcStatusCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runGC(reconcile bool) error {
|
||||
if client.useGRPC() {
|
||||
resp, err := client.registry.GarbageCollect(context.Background(), &mcrv1.GarbageCollectRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger gc: %w", err)
|
||||
}
|
||||
if flagJSON {
|
||||
return printJSON(resp)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Garbage collection started (ID: %s)\n", resp.GetId())
|
||||
return nil
|
||||
}
|
||||
|
||||
path := "/v1/gc"
|
||||
if reconcile {
|
||||
path += "?reconcile=true"
|
||||
}
|
||||
data, err := client.restDo("POST", path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger gc: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Garbage collection started (ID: %s)\n", resp.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func gcStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check GC status",
|
||||
RunE: runGCStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func runGCStatus(_ *cobra.Command, _ []string) error {
|
||||
if client.useGRPC() {
|
||||
resp, err := client.registry.GetGCStatus(context.Background(), &mcrv1.GetGCStatusRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gc status: %w", err)
|
||||
}
|
||||
if flagJSON {
|
||||
return printJSON(resp)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Running: %v\n", resp.GetRunning())
|
||||
if lr := resp.GetLastRun(); lr != nil {
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Last run: %s - %s\n", lr.GetStartedAt(), lr.GetCompletedAt())
|
||||
_, _ = fmt.Fprintf(os.Stdout, " Blobs removed: %d\n", lr.GetBlobsRemoved())
|
||||
_, _ = fmt.Fprintf(os.Stdout, " Bytes freed: %s\n", formatSize(lr.GetBytesFreed()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := client.restDo("GET", "/v1/gc/status", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gc status: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Running bool `json:"running"`
|
||||
LastRun *struct {
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
BlobsRemoved int `json:"blobs_removed"`
|
||||
BytesFreed int64 `json:"bytes_freed"`
|
||||
} `json:"last_run"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Running: %v\n", resp.Running)
|
||||
if resp.LastRun != nil {
|
||||
_, _ = fmt.Fprintf(os.Stdout, "Last run: %s - %s\n", resp.LastRun.StartedAt, resp.LastRun.CompletedAt)
|
||||
_, _ = fmt.Fprintf(os.Stdout, " Blobs removed: %d\n", resp.LastRun.BlobsRemoved)
|
||||
_, _ = fmt.Fprintf(os.Stdout, " Bytes freed: %s\n", formatSize(resp.LastRun.BytesFreed))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- snapshot ----------
|
||||
|
||||
func snapshotCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "snapshot",
|
||||
Short: "Trigger database backup via VACUUM INTO",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
RunE: runSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
func runSnapshot(_ *cobra.Command, _ []string) error {
|
||||
data, err := client.restDo("POST", "/v1/snapshot", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger snapshot: %w", err)
|
||||
}
|
||||
|
||||
if flagJSON {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
// Snapshot may return 204 with no body.
|
||||
return nil
|
||||
}
|
||||
return printJSON(v)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Snapshot triggered.")
|
||||
_ = data // may be empty
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
// confirmPrompt displays msg and waits for y/n from stdin.
|
||||
func confirmPrompt(msg string) bool {
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", msg)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if scanner.Scan() {
|
||||
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
65
cmd/mcrctl/output.go
Normal file
65
cmd/mcrctl/output.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// formatSize returns a human-friendly representation of a byte count.
|
||||
func formatSize(bytes int64) string {
|
||||
const (
|
||||
kb = 1024
|
||||
mb = kb * 1024
|
||||
gb = mb * 1024
|
||||
tb = gb * 1024
|
||||
)
|
||||
|
||||
switch {
|
||||
case bytes >= tb:
|
||||
return fmt.Sprintf("%.1f TB", float64(bytes)/float64(tb))
|
||||
case bytes >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb))
|
||||
case bytes >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||||
case bytes >= kb:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON marshals v as indented JSON and writes it to stdout.
|
||||
func printJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// printTable writes a table with the given headers and rows.
|
||||
// Each row must have the same number of columns as headers.
|
||||
func printTable(w io.Writer, headers []string, rows [][]string) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
// Print header.
|
||||
for i, h := range headers {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(tw, "\t")
|
||||
}
|
||||
_, _ = fmt.Fprint(tw, h)
|
||||
}
|
||||
_, _ = fmt.Fprintln(tw)
|
||||
|
||||
// Print rows.
|
||||
for _, row := range rows {
|
||||
for i, col := range row {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(tw, "\t")
|
||||
}
|
||||
_, _ = fmt.Fprint(tw, col)
|
||||
}
|
||||
_, _ = fmt.Fprintln(tw)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
Reference in New Issue
Block a user