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:
@@ -1 +0,0 @@
|
|||||||
kyle@vade.20490:1773789991
|
|
||||||
113
PROGRESS.md
113
PROGRESS.md
@@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Phase:** 10 complete, ready for Phase 11
|
**Phase:** 12 complete, ready for Phase 13
|
||||||
**Last updated:** 2026-03-19
|
**Last updated:** 2026-03-19
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
@@ -22,6 +22,8 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
- Phase 8: Admin REST API (all 5 steps)
|
- Phase 8: Admin REST API (all 5 steps)
|
||||||
- Phase 9: Garbage collection (all 2 steps)
|
- Phase 9: Garbage collection (all 2 steps)
|
||||||
- Phase 10: gRPC admin API (all 4 steps)
|
- Phase 10: gRPC admin API (all 4 steps)
|
||||||
|
- Phase 11: CLI tool (all 3 steps)
|
||||||
|
- Phase 12: Web UI (all 5 steps)
|
||||||
- `ARCHITECTURE.md` — Full design specification (18 sections)
|
- `ARCHITECTURE.md` — Full design specification (18 sections)
|
||||||
- `CLAUDE.md` — AI development guidance
|
- `CLAUDE.md` — AI development guidance
|
||||||
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
|
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
|
||||||
@@ -29,7 +31,114 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
1. Phase 11 (CLI tool) and Phase 12 (web UI)
|
1. Phase 13 (deployment artifacts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2026-03-19 — Batch C: Phase 11 (CLI tool) + Phase 12 (Web UI)
|
||||||
|
|
||||||
|
**Task:** Implement the admin CLI and HTMX-based web UI — the two
|
||||||
|
remaining user-facing layers. Both depend on Phase 10 (gRPC) but not
|
||||||
|
on each other; implemented in parallel.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
Phase 11 — `cmd/mcrctl/` (Steps 11.1–11.3):
|
||||||
|
|
||||||
|
Step 11.1 — Client and connection setup:
|
||||||
|
- `client.go`: `apiClient` struct wrapping both `*http.Client` (REST)
|
||||||
|
and gRPC service clients (Registry, Policy, Audit, Admin); `newClient()`
|
||||||
|
builds from flags; TLS 1.3 minimum with optional custom CA cert;
|
||||||
|
gRPC dial uses `grpc.ForceCodecV2(mcrv1.JSONCodec{})` for JSON codec;
|
||||||
|
`restDo()` helper with `Authorization: Bearer` header and JSON error
|
||||||
|
parsing; transport auto-selected based on `--grpc` flag
|
||||||
|
|
||||||
|
Step 11.2 — Status and repository commands:
|
||||||
|
- `main.go`: global persistent flags `--server`, `--grpc`, `--token`
|
||||||
|
(fallback `MCR_TOKEN`), `--ca-cert`, `--json`; `PersistentPreRunE`
|
||||||
|
resolves token and creates client; `status` command (gRPC + REST);
|
||||||
|
`repo list` with table/JSON output; `repo delete` with confirmation
|
||||||
|
prompt
|
||||||
|
- `output.go`: `formatSize()` (B/KB/MB/GB/TB), `printJSON()` (indented),
|
||||||
|
`printTable()` via `text/tabwriter`
|
||||||
|
|
||||||
|
Step 11.3 — Policy, audit, GC, and snapshot commands:
|
||||||
|
- `main.go`: `policy list|create|update|delete` (full CRUD, `--rule`
|
||||||
|
flag for JSON body, confirmation on delete); `audit tail` with
|
||||||
|
`--n` and `--event-type` flags; `gc` with `--reconcile` flag;
|
||||||
|
`gc status`; `snapshot`; all commands support both REST and gRPC
|
||||||
|
- `client_test.go`: 10 tests covering formatSize, printJSON, printTable,
|
||||||
|
token resolution from env/flag, newClient REST mode, CA cert error
|
||||||
|
handling, restDo success/error/POST paths
|
||||||
|
|
||||||
|
Phase 12 — `cmd/mcr-web/` + `internal/webserver/` + `web/` (Steps 12.1–12.5):
|
||||||
|
|
||||||
|
Step 12.1 — Web server scaffolding:
|
||||||
|
- `cmd/mcr-web/main.go`: reads `[web]` config section, creates gRPC
|
||||||
|
connection with TLS 1.3 and JSON codec, creates MCIAS auth client for
|
||||||
|
login, generates random 32-byte CSRF key, creates webserver, starts
|
||||||
|
HTTPS with TLS 1.3, graceful shutdown on SIGINT/SIGTERM
|
||||||
|
- `internal/webserver/server.go`: `Server` struct with chi router,
|
||||||
|
gRPC service clients, CSRF key, login function; `New()` constructor;
|
||||||
|
chi middleware (Recoverer, RequestID, RealIP); routes for all pages;
|
||||||
|
session-protected route groups; static file serving from embedded FS
|
||||||
|
- `web/embed.go`: `//go:embed templates static` directive
|
||||||
|
- `web/static/style.css`: minimal clean CSS (system fonts, 1200px
|
||||||
|
container, table styling, form styling, nav bar, stat cards, badges,
|
||||||
|
pagination, responsive breakpoints)
|
||||||
|
|
||||||
|
Step 12.2 — Login and authentication:
|
||||||
|
- `internal/webserver/auth.go`: session middleware (checks `mcr_session`
|
||||||
|
cookie, redirects to `/login` if absent); login page (GET renders
|
||||||
|
form with CSRF token); login submit (POST validates CSRF, calls
|
||||||
|
`loginFn`, sets session cookie HttpOnly/Secure/SameSite=Strict);
|
||||||
|
logout (clears cookie, redirects); CSRF via signed double-submit
|
||||||
|
cookie (HMAC-SHA256)
|
||||||
|
- `web/templates/login.html`: centered login form with CSRF hidden field
|
||||||
|
|
||||||
|
Step 12.3 — Dashboard and repository browsing:
|
||||||
|
- `internal/webserver/handlers.go`: `handleDashboard()` (repo count,
|
||||||
|
total size, recent audit events via gRPC); `handleRepositories()`
|
||||||
|
(list table); `handleRepositoryDetail()` (tags, manifests, repo
|
||||||
|
name with `/` support); `handleManifestDetail()` (manifest info
|
||||||
|
by digest)
|
||||||
|
- `internal/webserver/templates.go`: template loading from embedded FS
|
||||||
|
with layout-page composition, function map (formatSize, formatTime,
|
||||||
|
truncate, joinStrings), render helper
|
||||||
|
- `web/templates/layout.html`: HTML5 base with nav bar, htmx CDN
|
||||||
|
- `web/templates/dashboard.html`: stats cards + recent activity table
|
||||||
|
- `web/templates/repositories.html`: repo list table
|
||||||
|
- `web/templates/repository_detail.html`: tags + manifests tables
|
||||||
|
- `web/templates/manifest_detail.html`: digest, media type, size
|
||||||
|
|
||||||
|
Step 12.4 — Policy management (admin only):
|
||||||
|
- `internal/webserver/handlers.go`: `handlePolicies()` (list rules
|
||||||
|
with CSRF token); `handleCreatePolicy()` (form with body limit,
|
||||||
|
CSRF validation); `handleTogglePolicy()` (get+toggle enabled via
|
||||||
|
UpdatePolicyRule with field mask); `handleDeletePolicy()` (CSRF +
|
||||||
|
delete); PermissionDenied → "Access denied"
|
||||||
|
- `web/templates/policies.html`: create form + rules table with
|
||||||
|
toggle/delete actions
|
||||||
|
|
||||||
|
Step 12.5 — Audit log viewer (admin only):
|
||||||
|
- `internal/webserver/handlers.go`: `handleAudit()` with pagination
|
||||||
|
(fetch N+1 for next-page detection), filters (event type, repository,
|
||||||
|
date range), URL builder for pagination links
|
||||||
|
- `web/templates/audit.html`: filter form + paginated event table
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `make all` passes: vet clean, lint 0 issues, all tests passing,
|
||||||
|
all 3 binaries built
|
||||||
|
- CLI tests (10 new): formatSize (5 values: B through TB), printJSON
|
||||||
|
output correctness, printTable header and row rendering, token from
|
||||||
|
env var, token flag overrides env, newClient REST mode, CA cert
|
||||||
|
errors (missing file, invalid PEM), restDo success with auth header,
|
||||||
|
restDo error response parsing, restDo POST with body
|
||||||
|
- Web UI tests (15 new): login page renders, invalid credentials error,
|
||||||
|
CSRF token validation, dashboard requires session (redirect),
|
||||||
|
dashboard with session, repositories page, repository detail,
|
||||||
|
logout (cookie clearing), policies page, audit page, static files,
|
||||||
|
formatSize, formatTime, truncate, login success sets cookie
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ design specification.
|
|||||||
| 8 | Admin REST API | **Complete** |
|
| 8 | Admin REST API | **Complete** |
|
||||||
| 9 | Garbage collection | **Complete** |
|
| 9 | Garbage collection | **Complete** |
|
||||||
| 10 | gRPC admin API | **Complete** |
|
| 10 | gRPC admin API | **Complete** |
|
||||||
| 11 | CLI tool (mcrctl) | Not started |
|
| 11 | CLI tool (mcrctl) | **Complete** |
|
||||||
| 12 | Web UI | Not started |
|
| 12 | Web UI | **Complete** |
|
||||||
| 13 | Deployment artifacts | Not started |
|
| 13 | Deployment artifacts | Not started |
|
||||||
|
|
||||||
### Dependency Graph
|
### Dependency Graph
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
|
||||||
|
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
|
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
||||||
|
"git.wntrmute.dev/kyle/mcr/internal/config"
|
||||||
|
"git.wntrmute.dev/kyle/mcr/internal/webserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
@@ -24,11 +40,152 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serverCmd() *cobra.Command {
|
func serverCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var configPath string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "server",
|
Use: "server",
|
||||||
Short: "Start the web UI server",
|
Short: "Start the web UI server",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return fmt.Errorf("not implemented")
|
return runServer(configPath)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&configPath, "config", "", "path to mcr.toml config file (required)")
|
||||||
|
if err := cmd.MarkFlagRequired("config"); err != nil {
|
||||||
|
log.Fatalf("flag setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(configPath string) error {
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Web.ListenAddr == "" {
|
||||||
|
return fmt.Errorf("config: web.listen_addr is required")
|
||||||
|
}
|
||||||
|
if cfg.Web.GRPCAddr == "" {
|
||||||
|
return fmt.Errorf("config: web.grpc_addr is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to mcrsrv gRPC API.
|
||||||
|
grpcConn, err := dialGRPC(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to gRPC server: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = grpcConn.Close() }()
|
||||||
|
|
||||||
|
// Create gRPC clients.
|
||||||
|
registryClient := mcrv1.NewRegistryServiceClient(grpcConn)
|
||||||
|
policyClient := mcrv1.NewPolicyServiceClient(grpcConn)
|
||||||
|
auditClient := mcrv1.NewAuditServiceClient(grpcConn)
|
||||||
|
adminClient := mcrv1.NewAdminServiceClient(grpcConn)
|
||||||
|
|
||||||
|
// Create MCIAS auth client for login.
|
||||||
|
authClient, err := auth.NewClient(
|
||||||
|
cfg.MCIAS.ServerURL,
|
||||||
|
cfg.MCIAS.CACert,
|
||||||
|
cfg.MCIAS.ServiceName,
|
||||||
|
cfg.MCIAS.Tags,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create auth client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginFn := func(username, password string) (string, int, error) {
|
||||||
|
return authClient.Login(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate CSRF key.
|
||||||
|
csrfKey := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(csrfKey); err != nil {
|
||||||
|
return fmt.Errorf("generate CSRF key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create web server.
|
||||||
|
srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, csrfKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create web server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure TLS.
|
||||||
|
tlsCfg := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load TLS certificate: %w", err)
|
||||||
|
}
|
||||||
|
tlsCfg.Certificates = []tls.Certificate{cert}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: cfg.Web.ListenAddr,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
TLSConfig: tlsCfg,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGINT/SIGTERM.
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Printf("mcr-web listening on %s", cfg.Web.ListenAddr)
|
||||||
|
// TLS is configured via TLSConfig, so use ServeTLS with empty cert/key
|
||||||
|
// paths (they are already loaded).
|
||||||
|
errCh <- httpServer.ListenAndServeTLS("", "")
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return fmt.Errorf("server error: %w", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("shutting down mcr-web...")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return fmt.Errorf("shutdown: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("mcr-web stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialGRPC establishes a gRPC connection to the mcrsrv API server.
|
||||||
|
func dialGRPC(cfg *config.Config) (*grpc.ClientConn, error) {
|
||||||
|
tlsCfg := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Web.CACert != "" {
|
||||||
|
pem, err := os.ReadFile(cfg.Web.CACert) //nolint:gosec // CA cert path is operator-supplied
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("no valid certificates in CA cert file")
|
||||||
|
}
|
||||||
|
tlsCfg.RootCAs = pool
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := credentials.NewTLS(tlsCfg)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(
|
||||||
|
cfg.Web.GRPCAddr,
|
||||||
|
grpc.WithTransportCredentials(creds),
|
||||||
|
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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() {
|
func main() {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "mcrctl",
|
Use: "mcrctl",
|
||||||
Short: "Metacircular Container Registry admin CLI",
|
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(statusCmd())
|
||||||
root.AddCommand(repoCmd())
|
root.AddCommand(repoCmd())
|
||||||
root.AddCommand(gcCmd())
|
root.AddCommand(gcCmd())
|
||||||
@@ -25,128 +71,681 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- status ----------
|
||||||
|
|
||||||
func statusCmd() *cobra.Command {
|
func statusCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Query server health",
|
Short: "Query server health",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runStatus,
|
||||||
return fmt.Errorf("not implemented")
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func repoCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "repo",
|
Use: "repo",
|
||||||
Short: "Repository management",
|
Short: "Repository management",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(&cobra.Command{
|
cmd.AddCommand(repoListCmd())
|
||||||
|
cmd.AddCommand(repoDeleteCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List repositories",
|
Short: "List repositories",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runRepoList,
|
||||||
return fmt.Errorf("not implemented")
|
}
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
|
||||||
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]",
|
Use: "delete [name]",
|
||||||
Short: "Delete a repository",
|
Short: "Delete a repository",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runRepoDelete,
|
||||||
return fmt.Errorf("not implemented")
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gcCmd() *cobra.Command {
|
func runRepoDelete(_ *cobra.Command, args []string) error {
|
||||||
cmd := &cobra.Command{
|
name := args[0]
|
||||||
Use: "gc",
|
|
||||||
Short: "Trigger garbage collection",
|
if !confirmPrompt(fmt.Sprintf("Are you sure you want to delete repository %q?", name)) {
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
_, _ = fmt.Fprintln(os.Stdout, "Cancelled.")
|
||||||
return fmt.Errorf("not implemented")
|
return nil
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(&cobra.Command{
|
if client.useGRPC() {
|
||||||
Use: "status",
|
_, err := client.registry.DeleteRepository(context.Background(), &mcrv1.DeleteRepositoryRequest{Name: name})
|
||||||
Short: "Check GC status",
|
if err != nil {
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
return fmt.Errorf("delete repository: %w", err)
|
||||||
return fmt.Errorf("not implemented")
|
}
|
||||||
},
|
_, _ = 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 {
|
func policyCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "policy",
|
Use: "policy",
|
||||||
Short: "Policy rule management",
|
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",
|
Use: "list",
|
||||||
Short: "List policy rules",
|
Short: "List policy rules",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runPolicyList,
|
||||||
return fmt.Errorf("not implemented")
|
}
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
|
||||||
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",
|
Use: "create",
|
||||||
Short: "Create a policy rule",
|
Short: "Create a policy rule",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return fmt.Errorf("not implemented")
|
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]",
|
Use: "update [id]",
|
||||||
Short: "Update a policy rule",
|
Short: "Update a policy rule",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("not implemented")
|
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]",
|
Use: "delete [id]",
|
||||||
Short: "Delete a policy rule",
|
Short: "Delete a policy rule",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runPolicyDelete,
|
||||||
return fmt.Errorf("not implemented")
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func auditCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "audit",
|
Use: "audit",
|
||||||
Short: "Audit log management",
|
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",
|
Use: "tail",
|
||||||
Short: "Print recent audit events",
|
Short: "Print recent audit events",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
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
|
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 {
|
func snapshotCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "snapshot",
|
Use: "snapshot",
|
||||||
Short: "Trigger database backup via VACUUM INTO",
|
Short: "Trigger database backup via VACUUM INTO",
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: runSnapshot,
|
||||||
return fmt.Errorf("not implemented")
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
184
internal/webserver/auth.go
Normal file
184
internal/webserver/auth.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionKey is the context key for the session token.
|
||||||
|
type sessionKey struct{}
|
||||||
|
|
||||||
|
// tokenFromContext retrieves the bearer token from context.
|
||||||
|
func tokenFromContext(ctx context.Context) string {
|
||||||
|
s, _ := ctx.Value(sessionKey{}).(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextWithToken stores a bearer token in the context.
|
||||||
|
func contextWithToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, sessionKey{}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMiddleware checks for a valid mcr_session cookie and adds the
|
||||||
|
// token to the request context. If no session is present, it redirects
|
||||||
|
// to the login page.
|
||||||
|
func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("mcr_session")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := contextWithToken(r.Context(), cookie.Value)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginPage renders the login form.
|
||||||
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
s.templates.render(w, "login", map[string]any{
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Session": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginSubmit processes the login form.
|
||||||
|
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.validateCSRFToken(r) {
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
s.templates.render(w, "login", map[string]any{
|
||||||
|
"Error": "Invalid or expired form submission. Please try again.",
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Session": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
s.templates.render(w, "login", map[string]any{
|
||||||
|
"Error": "Username and password are required.",
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Session": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, err := s.loginFn(username, password)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("login failed for user %q: %v", username, err)
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
s.templates.render(w, "login", map[string]any{
|
||||||
|
"Error": "Invalid username or password.",
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Session": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "mcr_session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout clears the session and redirects to login.
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "mcr_session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateCSRFToken creates a random token, signs it with HMAC, stores
|
||||||
|
// the signed value in a cookie, and returns the token for form embedding.
|
||||||
|
func (s *Server) generateCSRFToken(w http.ResponseWriter) string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Crypto RNG failure is fatal; this should never happen.
|
||||||
|
log.Printf("csrf: failed to generate random bytes: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
token := hex.EncodeToString(b)
|
||||||
|
sig := s.signCSRF(token)
|
||||||
|
cookieVal := token + "." + sig
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "csrf_token",
|
||||||
|
Value: cookieVal,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCSRFToken verifies the form _csrf field matches the cookie and
|
||||||
|
// the HMAC signature is valid.
|
||||||
|
func (s *Server) validateCSRFToken(r *http.Request) bool {
|
||||||
|
formToken := r.FormValue("_csrf")
|
||||||
|
if formToken == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := r.Cookie("csrf_token")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(cookie.Value, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieToken := parts[0]
|
||||||
|
cookieSig := parts[1]
|
||||||
|
|
||||||
|
// Verify the form token matches the cookie token.
|
||||||
|
if !hmac.Equal([]byte(formToken), []byte(cookieToken)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the HMAC signature.
|
||||||
|
expectedSig := s.signCSRF(cookieToken)
|
||||||
|
return hmac.Equal([]byte(cookieSig), []byte(expectedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// signCSRF computes an HMAC-SHA256 signature for a CSRF token.
|
||||||
|
func (s *Server) signCSRF(token string) string {
|
||||||
|
mac := hmac.New(sha256.New, s.csrfKey)
|
||||||
|
mac.Write([]byte(token))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
457
internal/webserver/handlers.go
Normal file
457
internal/webserver/handlers.go
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// grpcContext creates a context with the bearer token from the session
|
||||||
|
// attached as gRPC outgoing metadata.
|
||||||
|
func grpcContext(r *http.Request) context.Context {
|
||||||
|
token := tokenFromContext(r.Context())
|
||||||
|
return metadata.AppendToOutgoingContext(r.Context(), "authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDashboard renders the dashboard with repo stats and recent activity.
|
||||||
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
repos, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, "dashboard", "Failed to load repositories.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var repoCount int
|
||||||
|
var totalSize int64
|
||||||
|
for _, repo := range repos.GetRepositories() {
|
||||||
|
repoCount++
|
||||||
|
totalSize += repo.GetTotalSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recent audit events for dashboard activity.
|
||||||
|
var events []*mcrv1.AuditEvent
|
||||||
|
auditResp, auditErr := s.audit.ListAuditEvents(ctx, &mcrv1.ListAuditEventsRequest{
|
||||||
|
Pagination: &mcrv1.PaginationRequest{Limit: 10},
|
||||||
|
})
|
||||||
|
if auditErr == nil {
|
||||||
|
events = auditResp.GetEvents()
|
||||||
|
}
|
||||||
|
// If audit fails with PermissionDenied, just show no events (user is not admin).
|
||||||
|
|
||||||
|
s.templates.render(w, "dashboard", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"RepoCount": repoCount,
|
||||||
|
"TotalSize": formatSize(totalSize),
|
||||||
|
"Events": events,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRepositories renders the repository list.
|
||||||
|
func (s *Server) handleRepositories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
resp, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, "repositories", "Failed to load repositories.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.render(w, "repositories", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Repositories": resp.GetRepositories(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRepositoryDetail renders a single repository's tags and manifests.
|
||||||
|
func (s *Server) handleRepositoryDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := extractRepoName(r)
|
||||||
|
if name == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: name})
|
||||||
|
if err != nil {
|
||||||
|
s.templates.render(w, "repository_detail", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Name": name,
|
||||||
|
"Error": grpcErrorMessage(err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.render(w, "repository_detail", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Name": resp.GetName(),
|
||||||
|
"Tags": resp.GetTags(),
|
||||||
|
"Manifests": resp.GetManifests(),
|
||||||
|
"TotalSize": resp.GetTotalSize(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleManifestDetail renders details for a specific manifest.
|
||||||
|
func (s *Server) handleManifestDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// URL format: /repositories/{name}/manifests/{digest}
|
||||||
|
// The name can contain slashes, so we parse manually.
|
||||||
|
path := r.URL.Path
|
||||||
|
const manifestsPrefix = "/manifests/"
|
||||||
|
idx := strings.LastIndex(path, manifestsPrefix)
|
||||||
|
if idx < 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := path[idx+len(manifestsPrefix):]
|
||||||
|
repoPath := path[len("/repositories/"):idx]
|
||||||
|
|
||||||
|
if repoPath == "" || digest == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: repoPath})
|
||||||
|
if err != nil {
|
||||||
|
s.templates.render(w, "manifest_detail", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"RepoName": repoPath,
|
||||||
|
"Error": grpcErrorMessage(err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the specific manifest.
|
||||||
|
var manifest *mcrv1.ManifestInfo
|
||||||
|
for _, m := range resp.GetManifests() {
|
||||||
|
if m.GetDigest() == digest {
|
||||||
|
manifest = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest == nil {
|
||||||
|
s.templates.render(w, "manifest_detail", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"RepoName": repoPath,
|
||||||
|
"Error": "Manifest not found.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.render(w, "manifest_detail", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"RepoName": repoPath,
|
||||||
|
"Manifest": manifest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePolicies renders the policy list and create form.
|
||||||
|
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
|
||||||
|
resp, err := s.policy.ListPolicyRules(ctx, &mcrv1.ListPolicyRulesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
s.templates.render(w, "policies", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Error": grpcErrorMessage(err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.render(w, "policies", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Policies": resp.GetRules(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreatePolicy processes the policy creation form.
|
||||||
|
func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.validateCSRFToken(r) {
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priority, _ := strconv.ParseInt(r.FormValue("priority"), 10, 32)
|
||||||
|
actions := splitCSV(r.FormValue("actions"))
|
||||||
|
repos := splitCSV(r.FormValue("repositories"))
|
||||||
|
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
_, err := s.policy.CreatePolicyRule(ctx, &mcrv1.CreatePolicyRuleRequest{
|
||||||
|
Priority: int32(priority),
|
||||||
|
Description: r.FormValue("description"),
|
||||||
|
Effect: r.FormValue("effect"),
|
||||||
|
Actions: actions,
|
||||||
|
Repositories: repos,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
csrf := s.generateCSRFToken(w)
|
||||||
|
s.templates.render(w, "policies", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"CSRFToken": csrf,
|
||||||
|
"Error": grpcErrorMessage(err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTogglePolicy toggles a policy rule's enabled state.
|
||||||
|
func (s *Server) handleTogglePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.validateCSRFToken(r) {
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := extractPolicyID(r.URL.Path, "/toggle")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
// Get current state.
|
||||||
|
rule, err := s.policy.GetPolicyRule(ctx, &mcrv1.GetPolicyRuleRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the enabled field.
|
||||||
|
_, err = s.policy.UpdatePolicyRule(ctx, &mcrv1.UpdatePolicyRuleRequest{
|
||||||
|
Id: id,
|
||||||
|
Enabled: !rule.GetEnabled(),
|
||||||
|
UpdateMask: []string{"enabled"},
|
||||||
|
// Carry forward required fields.
|
||||||
|
Priority: rule.GetPriority(),
|
||||||
|
Description: rule.GetDescription(),
|
||||||
|
Effect: rule.GetEffect(),
|
||||||
|
Actions: rule.GetActions(),
|
||||||
|
Repositories: rule.GetRepositories(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeletePolicy deletes a policy rule.
|
||||||
|
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.validateCSRFToken(r) {
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := extractPolicyID(r.URL.Path, "/delete")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
_, err = s.policy.DeletePolicyRule(ctx, &mcrv1.DeletePolicyRuleRequest{Id: id})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAudit renders the audit log with filters and pagination.
|
||||||
|
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := grpcContext(r)
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
eventType := q.Get("event_type")
|
||||||
|
repo := q.Get("repository")
|
||||||
|
since := q.Get("since")
|
||||||
|
until := q.Get("until")
|
||||||
|
pageStr := q.Get("page")
|
||||||
|
|
||||||
|
page := 1
|
||||||
|
if pageStr != "" {
|
||||||
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize int32 = 50
|
||||||
|
offset := int32(page-1) * pageSize
|
||||||
|
|
||||||
|
req := &mcrv1.ListAuditEventsRequest{
|
||||||
|
Pagination: &mcrv1.PaginationRequest{
|
||||||
|
Limit: pageSize + 1, // fetch one extra to detect next page
|
||||||
|
Offset: offset,
|
||||||
|
},
|
||||||
|
EventType: eventType,
|
||||||
|
Repository: repo,
|
||||||
|
Since: since,
|
||||||
|
Until: until,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.audit.ListAuditEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.templates.render(w, "audit", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Error": grpcErrorMessage(err),
|
||||||
|
"FilterType": eventType,
|
||||||
|
"FilterRepo": repo,
|
||||||
|
"FilterSince": since,
|
||||||
|
"FilterUntil": until,
|
||||||
|
"Page": page,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events := resp.GetEvents()
|
||||||
|
hasNext := len(events) > int(pageSize)
|
||||||
|
if hasNext {
|
||||||
|
events = events[:pageSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pagination URLs.
|
||||||
|
buildURL := func(p int) string {
|
||||||
|
v := url.Values{}
|
||||||
|
if eventType != "" {
|
||||||
|
v.Set("event_type", eventType)
|
||||||
|
}
|
||||||
|
if repo != "" {
|
||||||
|
v.Set("repository", repo)
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
v.Set("since", since)
|
||||||
|
}
|
||||||
|
if until != "" {
|
||||||
|
v.Set("until", until)
|
||||||
|
}
|
||||||
|
v.Set("page", strconv.Itoa(p))
|
||||||
|
return "/audit?" + v.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.templates.render(w, "audit", map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Events": events,
|
||||||
|
"FilterType": eventType,
|
||||||
|
"FilterRepo": repo,
|
||||||
|
"FilterSince": since,
|
||||||
|
"FilterUntil": until,
|
||||||
|
"Page": page,
|
||||||
|
"HasNext": hasNext,
|
||||||
|
"PrevURL": buildURL(page - 1),
|
||||||
|
"NextURL": buildURL(page + 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderError renders a template with an error message derived from a gRPC error.
|
||||||
|
func (s *Server) renderError(w http.ResponseWriter, tmpl, fallback string, err error) {
|
||||||
|
msg := fallback
|
||||||
|
if st, ok := status.FromError(err); ok {
|
||||||
|
if st.Code() == codes.PermissionDenied {
|
||||||
|
msg = "Access denied."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.templates.render(w, tmpl, map[string]any{
|
||||||
|
"Session": true,
|
||||||
|
"Error": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// grpcErrorMessage extracts a human-readable message from a gRPC error.
|
||||||
|
func grpcErrorMessage(err error) string {
|
||||||
|
if st, ok := status.FromError(err); ok {
|
||||||
|
if st.Code() == codes.PermissionDenied {
|
||||||
|
return "Access denied."
|
||||||
|
}
|
||||||
|
return st.Message()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRepoName extracts the repository name from the URL path.
|
||||||
|
// The name may contain slashes (e.g., "library/nginx").
|
||||||
|
// URL format: /repositories/{name...}
|
||||||
|
func extractRepoName(r *http.Request) string {
|
||||||
|
path := r.URL.Path
|
||||||
|
prefix := "/repositories/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := path[len(prefix):]
|
||||||
|
|
||||||
|
// Strip trailing slash.
|
||||||
|
name = strings.TrimRight(name, "/")
|
||||||
|
|
||||||
|
// If the path contains /manifests/, extract only the repo name part.
|
||||||
|
if idx := strings.Index(name, "/manifests/"); idx >= 0 {
|
||||||
|
name = name[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPolicyID extracts the policy ID from paths like /policies/{id}/toggle
|
||||||
|
// or /policies/{id}/delete.
|
||||||
|
func extractPolicyID(path, suffix string) string {
|
||||||
|
path = strings.TrimSuffix(path, suffix)
|
||||||
|
path = strings.TrimPrefix(path, "/policies/")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCSV splits a comma-separated string, trimming whitespace.
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
133
internal/webserver/server.go
Normal file
133
internal/webserver/server.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package webserver implements the MCR web UI server.
|
||||||
|
//
|
||||||
|
// It serves HTML pages rendered from Go templates with htmx for
|
||||||
|
// interactive elements. All data is fetched via gRPC from the main
|
||||||
|
// mcrsrv API server. Authentication is handled via MCIAS, with session
|
||||||
|
// tokens stored in secure HttpOnly cookies.
|
||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
|
"git.wntrmute.dev/kyle/mcr/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginFunc authenticates a user and returns a bearer token.
|
||||||
|
type LoginFunc func(username, password string) (token string, expiresIn int, err error)
|
||||||
|
|
||||||
|
// Server is the MCR web UI server.
|
||||||
|
type Server struct {
|
||||||
|
router chi.Router
|
||||||
|
templates *templateSet
|
||||||
|
registry mcrv1.RegistryServiceClient
|
||||||
|
policy mcrv1.PolicyServiceClient
|
||||||
|
audit mcrv1.AuditServiceClient
|
||||||
|
admin mcrv1.AdminServiceClient
|
||||||
|
loginFn LoginFunc
|
||||||
|
csrfKey []byte // 32-byte key for HMAC signing
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new web UI server with the given gRPC clients and login function.
|
||||||
|
func New(
|
||||||
|
registry mcrv1.RegistryServiceClient,
|
||||||
|
policy mcrv1.PolicyServiceClient,
|
||||||
|
audit mcrv1.AuditServiceClient,
|
||||||
|
admin mcrv1.AdminServiceClient,
|
||||||
|
loginFn LoginFunc,
|
||||||
|
csrfKey []byte,
|
||||||
|
) (*Server, error) {
|
||||||
|
tmpl, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
templates: tmpl,
|
||||||
|
registry: registry,
|
||||||
|
policy: policy,
|
||||||
|
audit: audit,
|
||||||
|
admin: admin,
|
||||||
|
loginFn: loginFn,
|
||||||
|
csrfKey: csrfKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.router = s.buildRouter()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the http.Handler for the server.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRouter sets up the chi router with all routes and middleware.
|
||||||
|
func (s *Server) buildRouter() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Global middleware.
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
|
||||||
|
// Static files (no auth required).
|
||||||
|
staticFS, err := fs.Sub(web.Content, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("webserver: failed to create static sub-filesystem: %v", err)
|
||||||
|
}
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
|
||||||
|
// Public routes (no session required).
|
||||||
|
r.Get("/login", s.handleLoginPage)
|
||||||
|
r.Post("/login", s.handleLoginSubmit)
|
||||||
|
r.Get("/logout", s.handleLogout)
|
||||||
|
|
||||||
|
// Protected routes (session required).
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.sessionMiddleware)
|
||||||
|
|
||||||
|
r.Get("/", s.handleDashboard)
|
||||||
|
|
||||||
|
// Repository routes — name may contain slashes.
|
||||||
|
r.Get("/repositories", s.handleRepositories)
|
||||||
|
r.Get("/repositories/*", s.handleRepositoryOrManifest)
|
||||||
|
|
||||||
|
// Policy routes (admin — gRPC interceptors enforce this).
|
||||||
|
r.Get("/policies", s.handlePolicies)
|
||||||
|
r.Post("/policies", s.handleCreatePolicy)
|
||||||
|
r.Post("/policies/{id}/toggle", s.handleTogglePolicy)
|
||||||
|
r.Post("/policies/{id}/delete", s.handleDeletePolicy)
|
||||||
|
|
||||||
|
// Audit routes (admin — gRPC interceptors enforce this).
|
||||||
|
r.Get("/audit", s.handleAudit)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRepositoryOrManifest dispatches between repository detail and
|
||||||
|
// manifest detail based on the URL path. This is necessary because
|
||||||
|
// repository names can contain slashes.
|
||||||
|
func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if idx := lastIndex(path, "/manifests/"); idx >= 0 {
|
||||||
|
s.handleManifestDetail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.handleRepositoryDetail(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastIndex returns the index of the last occurrence of sep in s, or -1.
|
||||||
|
func lastIndex(s, sep string) int {
|
||||||
|
for i := len(s) - len(sep); i >= 0; i-- {
|
||||||
|
if s[i:i+len(sep)] == sep {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
608
internal/webserver/server_test.go
Normal file
608
internal/webserver/server_test.go
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRegistryService implements RegistryServiceServer for testing.
|
||||||
|
type fakeRegistryService struct {
|
||||||
|
mcrv1.UnimplementedRegistryServiceServer
|
||||||
|
repos []*mcrv1.RepositoryMetadata
|
||||||
|
repoResp *mcrv1.GetRepositoryResponse
|
||||||
|
repoErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistryService) ListRepositories(_ context.Context, _ *mcrv1.ListRepositoriesRequest) (*mcrv1.ListRepositoriesResponse, error) {
|
||||||
|
return &mcrv1.ListRepositoriesResponse{Repositories: f.repos}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistryService) GetRepository(_ context.Context, req *mcrv1.GetRepositoryRequest) (*mcrv1.GetRepositoryResponse, error) {
|
||||||
|
if f.repoErr != nil {
|
||||||
|
return nil, f.repoErr
|
||||||
|
}
|
||||||
|
if f.repoResp != nil {
|
||||||
|
return f.repoResp, nil
|
||||||
|
}
|
||||||
|
return &mcrv1.GetRepositoryResponse{Name: req.GetName()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakePolicyService implements PolicyServiceServer for testing.
|
||||||
|
type fakePolicyService struct {
|
||||||
|
mcrv1.UnimplementedPolicyServiceServer
|
||||||
|
rules []*mcrv1.PolicyRule
|
||||||
|
created *mcrv1.PolicyRule
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyService) ListPolicyRules(_ context.Context, _ *mcrv1.ListPolicyRulesRequest) (*mcrv1.ListPolicyRulesResponse, error) {
|
||||||
|
return &mcrv1.ListPolicyRulesResponse{Rules: f.rules}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyService) CreatePolicyRule(_ context.Context, req *mcrv1.CreatePolicyRuleRequest) (*mcrv1.PolicyRule, error) {
|
||||||
|
rule := &mcrv1.PolicyRule{
|
||||||
|
Id: 1,
|
||||||
|
Priority: req.GetPriority(),
|
||||||
|
Description: req.GetDescription(),
|
||||||
|
Effect: req.GetEffect(),
|
||||||
|
Actions: req.GetActions(),
|
||||||
|
Repositories: req.GetRepositories(),
|
||||||
|
Enabled: req.GetEnabled(),
|
||||||
|
}
|
||||||
|
f.created = rule
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyService) GetPolicyRule(_ context.Context, req *mcrv1.GetPolicyRuleRequest) (*mcrv1.PolicyRule, error) {
|
||||||
|
for _, r := range f.rules {
|
||||||
|
if r.GetId() == req.GetId() {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyService) UpdatePolicyRule(_ context.Context, req *mcrv1.UpdatePolicyRuleRequest) (*mcrv1.PolicyRule, error) {
|
||||||
|
for _, r := range f.rules {
|
||||||
|
if r.GetId() == req.GetId() {
|
||||||
|
r.Enabled = req.GetEnabled()
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyService) DeletePolicyRule(_ context.Context, req *mcrv1.DeletePolicyRuleRequest) (*mcrv1.DeletePolicyRuleResponse, error) {
|
||||||
|
for i, r := range f.rules {
|
||||||
|
if r.GetId() == req.GetId() {
|
||||||
|
f.rules = append(f.rules[:i], f.rules[i+1:]...)
|
||||||
|
return &mcrv1.DeletePolicyRuleResponse{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.NotFound, "policy rule not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAuditService implements AuditServiceServer for testing.
|
||||||
|
type fakeAuditService struct {
|
||||||
|
mcrv1.UnimplementedAuditServiceServer
|
||||||
|
events []*mcrv1.AuditEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAuditService) ListAuditEvents(_ context.Context, _ *mcrv1.ListAuditEventsRequest) (*mcrv1.ListAuditEventsResponse, error) {
|
||||||
|
return &mcrv1.ListAuditEventsResponse{Events: f.events}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAdminService implements AdminServiceServer for testing.
|
||||||
|
type fakeAdminService struct {
|
||||||
|
mcrv1.UnimplementedAdminServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAdminService) Health(_ context.Context, _ *mcrv1.HealthRequest) (*mcrv1.HealthResponse, error) {
|
||||||
|
return &mcrv1.HealthResponse{Status: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnv holds a test server and its dependencies.
|
||||||
|
type testEnv struct {
|
||||||
|
server *Server
|
||||||
|
grpcServer *grpc.Server
|
||||||
|
grpcConn *grpc.ClientConn
|
||||||
|
registry *fakeRegistryService
|
||||||
|
policyFake *fakePolicyService
|
||||||
|
auditFake *fakeAuditService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) close() {
|
||||||
|
_ = e.grpcConn.Close()
|
||||||
|
e.grpcServer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestEnv creates a test environment with fake gRPC backends.
|
||||||
|
func setupTestEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
registrySvc := &fakeRegistryService{
|
||||||
|
repos: []*mcrv1.RepositoryMetadata{
|
||||||
|
{Name: "library/nginx", TagCount: 3, ManifestCount: 2, TotalSize: 1024 * 1024, CreatedAt: "2024-01-15T10:00:00Z"},
|
||||||
|
{Name: "library/alpine", TagCount: 1, ManifestCount: 1, TotalSize: 512 * 1024, CreatedAt: "2024-01-16T10:00:00Z"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
policySvc := &fakePolicyService{
|
||||||
|
rules: []*mcrv1.PolicyRule{
|
||||||
|
{Id: 1, Priority: 100, Description: "Allow all pulls", Effect: "allow", Actions: []string{"pull"}, Repositories: []string{"*"}, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
auditSvc := &fakeAuditService{
|
||||||
|
events: []*mcrv1.AuditEvent{
|
||||||
|
{Id: 1, EventTime: "2024-01-15T12:00:00Z", EventType: "manifest_pushed", ActorId: "user1", Repository: "library/nginx", Digest: "sha256:abc123", IpAddress: "10.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adminSvc := &fakeAdminService{}
|
||||||
|
|
||||||
|
// Start in-process gRPC server.
|
||||||
|
gs := grpc.NewServer()
|
||||||
|
mcrv1.RegisterRegistryServiceServer(gs, registrySvc)
|
||||||
|
mcrv1.RegisterPolicyServiceServer(gs, policySvc)
|
||||||
|
mcrv1.RegisterAuditServiceServer(gs, auditSvc)
|
||||||
|
mcrv1.RegisterAdminServiceServer(gs, adminSvc)
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = gs.Serve(lis)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Connect client.
|
||||||
|
conn, err := grpc.NewClient(
|
||||||
|
lis.Addr().String(),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
gs.Stop()
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfKey := []byte("test-csrf-key-32-bytes-long!1234")
|
||||||
|
|
||||||
|
loginFn := func(username, password string) (string, int, error) {
|
||||||
|
if username == "admin" && password == "secret" {
|
||||||
|
return "test-token-12345", 3600, nil
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := New(
|
||||||
|
mcrv1.NewRegistryServiceClient(conn),
|
||||||
|
mcrv1.NewPolicyServiceClient(conn),
|
||||||
|
mcrv1.NewAuditServiceClient(conn),
|
||||||
|
mcrv1.NewAdminServiceClient(conn),
|
||||||
|
loginFn,
|
||||||
|
csrfKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
gs.Stop()
|
||||||
|
t.Fatalf("create server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
server: srv,
|
||||||
|
grpcServer: gs,
|
||||||
|
grpcConn: conn,
|
||||||
|
registry: registrySvc,
|
||||||
|
policyFake: policySvc,
|
||||||
|
auditFake: auditSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginPageRenders(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /login: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "MCR Login") {
|
||||||
|
t.Error("login page does not contain 'MCR Login'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "_csrf") {
|
||||||
|
t.Error("login page does not contain CSRF token field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginInvalidCredentials(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
// First get a CSRF token.
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
env.server.Handler().ServeHTTP(getRec, getReq)
|
||||||
|
|
||||||
|
// Extract CSRF cookie and token.
|
||||||
|
var csrfCookie *http.Cookie
|
||||||
|
for _, c := range getRec.Result().Cookies() {
|
||||||
|
if c.Name == "csrf_token" {
|
||||||
|
csrfCookie = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if csrfCookie == nil {
|
||||||
|
t.Fatal("no csrf_token cookie set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the CSRF token from the cookie value (token.signature).
|
||||||
|
parts := strings.SplitN(csrfCookie.Value, ".", 2)
|
||||||
|
csrfToken := parts[0]
|
||||||
|
|
||||||
|
// Submit login with wrong credentials.
|
||||||
|
form := url.Values{
|
||||||
|
"username": {"baduser"},
|
||||||
|
"password": {"badpass"},
|
||||||
|
"_csrf": {csrfToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.AddCookie(csrfCookie)
|
||||||
|
postRec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(postRec, postReq)
|
||||||
|
|
||||||
|
if postRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /login: status %d, want %d", postRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := postRec.Body.String()
|
||||||
|
if !strings.Contains(body, "Invalid username or password") {
|
||||||
|
t.Error("response does not contain error message for invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardRequiresSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("GET / without session: status %d, want %d", rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if loc != "/login" {
|
||||||
|
t.Fatalf("redirect location: got %q, want /login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardWithSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET / with session: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Dashboard") {
|
||||||
|
t.Error("dashboard page does not contain 'Dashboard'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Repositories") {
|
||||||
|
t.Error("dashboard page does not show repository count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoriesPageRenders(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/repositories", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /repositories: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "library/nginx") {
|
||||||
|
t.Error("repositories page does not contain 'library/nginx'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "library/alpine") {
|
||||||
|
t.Error("repositories page does not contain 'library/alpine'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryDetailRenders(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
env.registry.repoResp = &mcrv1.GetRepositoryResponse{
|
||||||
|
Name: "library/nginx",
|
||||||
|
TotalSize: 2048,
|
||||||
|
Tags: []*mcrv1.TagInfo{
|
||||||
|
{Name: "latest", Digest: "sha256:abc123def456"},
|
||||||
|
},
|
||||||
|
Manifests: []*mcrv1.ManifestInfo{
|
||||||
|
{Digest: "sha256:abc123def456", MediaType: "application/vnd.oci.image.manifest.v1+json", Size: 2048, CreatedAt: "2024-01-15T10:00:00Z"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/repositories/library/nginx", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /repositories/library/nginx: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "library/nginx") {
|
||||||
|
t.Error("repository detail page does not contain repo name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "latest") {
|
||||||
|
t.Error("repository detail page does not contain tag 'latest'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFTokenValidation(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
// POST without CSRF token should fail.
|
||||||
|
form := url.Values{
|
||||||
|
"username": {"admin"},
|
||||||
|
"password": {"secret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
// Should show the error about invalid form submission.
|
||||||
|
if !strings.Contains(body, "Invalid or expired form submission") {
|
||||||
|
t.Error("POST without CSRF token should show error, got: " + body[:min(200, len(body))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("GET /logout: status %d, want %d", rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if loc != "/login" {
|
||||||
|
t.Fatalf("redirect location: got %q, want /login", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session cookie is cleared.
|
||||||
|
var sessionCleared bool
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "mcr_session" && c.MaxAge < 0 {
|
||||||
|
sessionCleared = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sessionCleared {
|
||||||
|
t.Error("session cookie was not cleared on logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoliciesPage(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/policies", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /policies: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Allow all pulls") {
|
||||||
|
t.Error("policies page does not contain policy description")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditPage(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/audit", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "mcr_session", Value: "test-token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /audit: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "manifest_pushed") {
|
||||||
|
t.Error("audit page does not contain event type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFiles(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /static/style.css: status %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "font-family") {
|
||||||
|
t.Error("style.css does not appear to contain CSS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0 B"},
|
||||||
|
{512, "512 B"},
|
||||||
|
{1024, "1.0 KiB"},
|
||||||
|
{1048576, "1.0 MiB"},
|
||||||
|
{1073741824, "1.0 GiB"},
|
||||||
|
{1099511627776, "1.0 TiB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatSize(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatSize(%d) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTime(t *testing.T) {
|
||||||
|
got := formatTime("2024-01-15T10:30:00Z")
|
||||||
|
want := "2024-01-15 10:30:00"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("formatTime = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid time returns the input.
|
||||||
|
got = formatTime("not-a-time")
|
||||||
|
if got != "not-a-time" {
|
||||||
|
t.Errorf("formatTime(invalid) = %q, want %q", got, "not-a-time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
got := truncate("sha256:abc123def456", 12)
|
||||||
|
want := "sha256:abc12..."
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("truncate = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short strings are not truncated.
|
||||||
|
got = truncate("short", 10)
|
||||||
|
if got != "short" {
|
||||||
|
t.Errorf("truncate(short) = %q, want %q", got, "short")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccessSetsCookie(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
// Get CSRF token.
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
env.server.Handler().ServeHTTP(getRec, getReq)
|
||||||
|
|
||||||
|
var csrfCookie *http.Cookie
|
||||||
|
for _, c := range getRec.Result().Cookies() {
|
||||||
|
if c.Name == "csrf_token" {
|
||||||
|
csrfCookie = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if csrfCookie == nil {
|
||||||
|
t.Fatal("no csrf_token cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(csrfCookie.Value, ".", 2)
|
||||||
|
csrfToken := parts[0]
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"username": {"admin"},
|
||||||
|
"password": {"secret"},
|
||||||
|
"_csrf": {csrfToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.AddCookie(csrfCookie)
|
||||||
|
postRec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.server.Handler().ServeHTTP(postRec, postReq)
|
||||||
|
|
||||||
|
if postRec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /login: status %d, want %d; body: %s", postRec.Code, http.StatusSeeOther, postRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionCookie *http.Cookie
|
||||||
|
for _, c := range postRec.Result().Cookies() {
|
||||||
|
if c.Name == "mcr_session" {
|
||||||
|
sessionCookie = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatal("no mcr_session cookie set after login")
|
||||||
|
}
|
||||||
|
if sessionCookie.Value != "test-token-12345" {
|
||||||
|
t.Errorf("session cookie value = %q, want %q", sessionCookie.Value, "test-token-12345")
|
||||||
|
}
|
||||||
|
if !sessionCookie.HttpOnly {
|
||||||
|
t.Error("session cookie is not HttpOnly")
|
||||||
|
}
|
||||||
|
if !sessionCookie.Secure {
|
||||||
|
t.Error("session cookie is not Secure")
|
||||||
|
}
|
||||||
|
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Error("session cookie SameSite is not Strict")
|
||||||
|
}
|
||||||
|
}
|
||||||
136
internal/webserver/templates.go
Normal file
136
internal/webserver/templates.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcr/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// templateSet wraps parsed templates and provides a render method.
|
||||||
|
type templateSet struct {
|
||||||
|
templates map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateFuncs returns the function map used across all templates.
|
||||||
|
func templateFuncs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"formatSize": formatSize,
|
||||||
|
"formatTime": formatTime,
|
||||||
|
"truncate": truncate,
|
||||||
|
"joinStrings": joinStrings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTemplates parses all page templates with the layout template.
|
||||||
|
func loadTemplates() (*templateSet, error) {
|
||||||
|
// Read layout template.
|
||||||
|
layoutBytes, err := fs.ReadFile(web.Content, "templates/layout.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webserver: read layout template: %w", err)
|
||||||
|
}
|
||||||
|
layoutStr := string(layoutBytes)
|
||||||
|
|
||||||
|
pages := []string{
|
||||||
|
"login",
|
||||||
|
"dashboard",
|
||||||
|
"repositories",
|
||||||
|
"repository_detail",
|
||||||
|
"manifest_detail",
|
||||||
|
"policies",
|
||||||
|
"audit",
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := &templateSet{
|
||||||
|
templates: make(map[string]*template.Template, len(pages)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
pageBytes, readErr := fs.ReadFile(web.Content, "templates/"+page+".html")
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("webserver: read template %s: %w", page, readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
t, parseErr := template.New("layout").Funcs(templateFuncs()).Parse(layoutStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, fmt.Errorf("webserver: parse layout for %s: %w", page, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, parseErr = t.Parse(string(pageBytes))
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, fmt.Errorf("webserver: parse template %s: %w", page, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.templates[page] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes a named template and writes the result to w.
|
||||||
|
func (ts *templateSet) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
t, ok := ts.templates[name]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := t.Execute(w, data); err != nil {
|
||||||
|
// Template already started writing; log but don't send another error.
|
||||||
|
_ = err // best-effort; headers may already be sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSize converts bytes to a human-readable string.
|
||||||
|
func formatSize(b int64) string {
|
||||||
|
const (
|
||||||
|
kib = 1024
|
||||||
|
mib = 1024 * kib
|
||||||
|
gib = 1024 * mib
|
||||||
|
tib = 1024 * gib
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case b >= tib:
|
||||||
|
return fmt.Sprintf("%.1f TiB", float64(b)/float64(tib))
|
||||||
|
case b >= gib:
|
||||||
|
return fmt.Sprintf("%.1f GiB", float64(b)/float64(gib))
|
||||||
|
case b >= mib:
|
||||||
|
return fmt.Sprintf("%.1f MiB", float64(b)/float64(mib))
|
||||||
|
case b >= kib:
|
||||||
|
return fmt.Sprintf("%.1f KiB", float64(b)/float64(kib))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTime converts an RFC3339 string to a more readable format.
|
||||||
|
func formatTime(s string) string {
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
// Try RFC3339Nano.
|
||||||
|
t, err = time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate returns the first n characters of s, appending "..." if truncated.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinStrings joins string slices for template rendering.
|
||||||
|
func joinStrings(ss []string, sep string) string {
|
||||||
|
return strings.Join(ss, sep)
|
||||||
|
}
|
||||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var Content embed.FS
|
||||||
404
web/static/style.css
Normal file
404
web/static/style.css
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/* MCR Web UI - minimal clean styling */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans",
|
||||||
|
"Helvetica Neue", sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #f5f5f5;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
nav {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover,
|
||||||
|
nav a.active {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .logout {
|
||||||
|
color: #e57373;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main container */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 1.5rem 0 0.75rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats cards */
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f9f9f9;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0066cc;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
input[type="submit"]:hover {
|
||||||
|
background: #0052a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page */
|
||||||
|
.login-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 5rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container .form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error and info messages */
|
||||||
|
.error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #ef9a9a;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #90caf9;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manifest JSON */
|
||||||
|
pre {
|
||||||
|
background: #263238;
|
||||||
|
color: #eeffff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono",
|
||||||
|
"Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline form row for policy creation */
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row button {
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter form */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a,
|
||||||
|
.pagination span {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination span {
|
||||||
|
background: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-allow {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-deny {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-enabled {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled {
|
||||||
|
background: #fafafa;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Truncated text */
|
||||||
|
.truncated {
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row,
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
web/templates/audit.html
Normal file
80
web/templates/audit.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{{define "title"}}Audit Log{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Audit Log</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="GET" action="/audit" class="filters">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event_type">Event Type</label>
|
||||||
|
<select id="event_type" name="event_type">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="manifest_pushed" {{if eq .FilterType "manifest_pushed"}}selected{{end}}>Manifest Pushed</option>
|
||||||
|
<option value="manifest_deleted" {{if eq .FilterType "manifest_deleted"}}selected{{end}}>Manifest Deleted</option>
|
||||||
|
<option value="blob_uploaded" {{if eq .FilterType "blob_uploaded"}}selected{{end}}>Blob Uploaded</option>
|
||||||
|
<option value="blob_deleted" {{if eq .FilterType "blob_deleted"}}selected{{end}}>Blob Deleted</option>
|
||||||
|
<option value="repo_deleted" {{if eq .FilterType "repo_deleted"}}selected{{end}}>Repo Deleted</option>
|
||||||
|
<option value="gc_started" {{if eq .FilterType "gc_started"}}selected{{end}}>GC Started</option>
|
||||||
|
<option value="gc_completed" {{if eq .FilterType "gc_completed"}}selected{{end}}>GC Completed</option>
|
||||||
|
<option value="policy_created" {{if eq .FilterType "policy_created"}}selected{{end}}>Policy Created</option>
|
||||||
|
<option value="policy_updated" {{if eq .FilterType "policy_updated"}}selected{{end}}>Policy Updated</option>
|
||||||
|
<option value="policy_deleted" {{if eq .FilterType "policy_deleted"}}selected{{end}}>Policy Deleted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="repository">Repository</label>
|
||||||
|
<input type="text" id="repository" name="repository" value="{{.FilterRepo}}" placeholder="e.g. library/nginx">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="since">Since</label>
|
||||||
|
<input type="date" id="since" name="since" value="{{.FilterSince}}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="until">Until</label>
|
||||||
|
<input type="date" id="until" name="until" value="{{.FilterUntil}}">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .Events}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th>Digest</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Events}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatTime .EventTime}}</td>
|
||||||
|
<td>{{.EventType}}</td>
|
||||||
|
<td>{{.ActorId}}</td>
|
||||||
|
<td>{{.Repository}}</td>
|
||||||
|
<td class="truncated">{{truncate .Digest 24}}</td>
|
||||||
|
<td>{{.IpAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="{{.PrevURL}}">Previous</a>
|
||||||
|
{{end}}
|
||||||
|
<span>Page {{.Page}}</span>
|
||||||
|
{{if .HasNext}}
|
||||||
|
<a href="{{.NextURL}}">Next</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p>No audit events found.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
44
web/templates/dashboard.html
Normal file
44
web/templates/dashboard.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "title"}}Dashboard{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Repositories</div>
|
||||||
|
<div class="value">{{.RepoCount}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total Size</div>
|
||||||
|
<div class="value">{{.TotalSize}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Recent Activity</h2>
|
||||||
|
{{if .Events}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th>Digest</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Events}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatTime .EventTime}}</td>
|
||||||
|
<td>{{.EventType}}</td>
|
||||||
|
<td>{{.ActorId}}</td>
|
||||||
|
<td>{{.Repository}}</td>
|
||||||
|
<td class="truncated">{{truncate .Digest 24}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No recent activity.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
26
web/templates/layout.html
Normal file
26
web/templates/layout.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MCR - {{template "title" .}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{if .Session}}
|
||||||
|
<nav>
|
||||||
|
<span class="brand">MCR</span>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/repositories">Repositories</a>
|
||||||
|
<a href="/policies">Policies</a>
|
||||||
|
<a href="/audit">Audit</a>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a href="/logout" class="logout">Logout</a>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
<div class="container">
|
||||||
|
{{template "content" .}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
web/templates/login.html
Normal file
22
web/templates/login.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "title"}}Login{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>MCR Login</h1>
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
28
web/templates/manifest_detail.html
Normal file
28
web/templates/manifest_detail.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "title"}}Manifest Detail{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Manifest Detail</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Digest</div>
|
||||||
|
<div class="value truncated" style="font-size: 0.875rem;">{{.Manifest.Digest}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Media Type</div>
|
||||||
|
<div class="value" style="font-size: 1rem;">{{.Manifest.MediaType}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Size</div>
|
||||||
|
<div class="value">{{formatSize .Manifest.Size}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a href="/repositories/{{.RepoName}}">Back to {{.RepoName}}</a></p>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
92
web/templates/policies.html
Normal file
92
web/templates/policies.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{{define "title"}}Policies{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Policy Rules</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Create Policy Rule</h2>
|
||||||
|
<form method="POST" action="/policies">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="priority">Priority</label>
|
||||||
|
<input type="number" id="priority" name="priority" value="100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<input type="text" id="description" name="description" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="effect">Effect</label>
|
||||||
|
<select id="effect" name="effect">
|
||||||
|
<option value="allow">Allow</option>
|
||||||
|
<option value="deny">Deny</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="actions">Actions (comma-sep)</label>
|
||||||
|
<input type="text" id="actions" name="actions" placeholder="pull,push">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="repositories">Repositories (comma-sep)</label>
|
||||||
|
<input type="text" id="repositories" name="repositories" placeholder="*">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="policy-table">
|
||||||
|
{{if .Policies}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Effect</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
<th>Repositories</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Policies}}
|
||||||
|
<tr id="policy-{{.Id}}">
|
||||||
|
<td>{{.Id}}</td>
|
||||||
|
<td>{{.Priority}}</td>
|
||||||
|
<td>{{.Description}}</td>
|
||||||
|
<td><span class="badge badge-{{.Effect}}">{{.Effect}}</span></td>
|
||||||
|
<td>{{joinStrings .Actions ", "}}</td>
|
||||||
|
<td>{{joinStrings .Repositories ", "}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Enabled}}
|
||||||
|
<span class="badge badge-enabled">Enabled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-disabled">Disabled</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/policies/{{.Id}}/toggle" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||||
|
<button type="submit" class="small secondary">
|
||||||
|
{{if .Enabled}}Disable{{else}}Enable{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/policies/{{.Id}}/delete" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||||
|
<button type="submit" class="small danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No policy rules configured.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
36
web/templates/repositories.html
Normal file
36
web/templates/repositories.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{{define "title"}}Repositories{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Repositories</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Repositories}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>Manifests</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Repositories}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/repositories/{{.Name}}">{{.Name}}</a></td>
|
||||||
|
<td>{{.TagCount}}</td>
|
||||||
|
<td>{{.ManifestCount}}</td>
|
||||||
|
<td>{{formatSize .TotalSize}}</td>
|
||||||
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No repositories found.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
74
web/templates/repository_detail.html
Normal file
74
web/templates/repository_detail.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{{define "title"}}{{.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.Name}}</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Total Size</div>
|
||||||
|
<div class="value">{{formatSize .TotalSize}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Tags</div>
|
||||||
|
<div class="value">{{len .Tags}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Manifests</div>
|
||||||
|
<div class="value">{{len .Manifests}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Tags</h2>
|
||||||
|
{{if .Tags}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Digest</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tags}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td class="truncated"><a href="/repositories/{{$.Name}}/manifests/{{.Digest}}">{{truncate .Digest 24}}</a></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No tags.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Manifests</h2>
|
||||||
|
{{if .Manifests}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Digest</th>
|
||||||
|
<th>Media Type</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Manifests}}
|
||||||
|
<tr>
|
||||||
|
<td class="truncated"><a href="/repositories/{{$.Name}}/manifests/{{.Digest}}">{{truncate .Digest 24}}</a></td>
|
||||||
|
<td>{{.MediaType}}</td>
|
||||||
|
<td>{{formatSize .Size}}</td>
|
||||||
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p>No manifests.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user