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", 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()) root.AddCommand(policyCmd()) root.AddCommand(auditCmd()) root.AddCommand(snapshotCmd()) if err := root.Execute(); err != nil { os.Exit(1) } } // ---------- status ---------- func statusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Query server health", 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(repoListCmd()) cmd.AddCommand(repoDeleteCmd()) return cmd } func repoListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List repositories", RunE: runRepoList, } } 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: runRepoDelete, } } 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 } 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 } _, 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(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: runPolicyList, } } 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(cmd *cobra.Command, _ []string) error { return runPolicyCreate(cmd, ruleBody) }, } cmd.Flags().StringVar(&ruleBody, "rule", "", "policy rule as JSON string") _ = cmd.MarkFlagRequired("rule") return cmd } 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(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 } 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: 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(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 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: 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 }