mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 13:56:33 +02:00
467 lines
12 KiB
Go
467 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// runExtCommand dispatches `zot ext ...` subcommands. Returns
|
|
// (handled=true, err) if rawArgs starts with "ext"; otherwise
|
|
// (handled=false, nil) so the main router falls through to the
|
|
// regular flag parser.
|
|
func runExtCommand(rawArgs []string) (handled bool, err error) {
|
|
if len(rawArgs) == 0 || rawArgs[0] != "ext" {
|
|
return false, nil
|
|
}
|
|
if len(rawArgs) == 1 {
|
|
printExtHelp()
|
|
return true, nil
|
|
}
|
|
switch rawArgs[1] {
|
|
case "list":
|
|
return true, extList()
|
|
case "logs":
|
|
return true, extLogs(rawArgs[2:])
|
|
case "enable":
|
|
return true, extToggle(rawArgs[2:], true)
|
|
case "disable":
|
|
return true, extToggle(rawArgs[2:], false)
|
|
case "remove", "rm":
|
|
return true, extRemove(rawArgs[2:])
|
|
case "install":
|
|
return true, extInstall(rawArgs[2:])
|
|
case "help", "-h", "--help":
|
|
printExtHelp()
|
|
return true, nil
|
|
default:
|
|
printExtHelp()
|
|
return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1])
|
|
}
|
|
}
|
|
|
|
func printExtHelp() {
|
|
fmt.Fprintln(os.Stderr, `zot ext — manage extensions
|
|
|
|
usage:
|
|
zot ext list list installed extensions and their state
|
|
zot ext logs <name> [-f] cat / tail an extension's stderr log
|
|
zot ext enable <name> re-enable a disabled extension
|
|
zot ext disable <name> disable without removing
|
|
zot ext remove <name> delete an extension directory
|
|
zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/
|
|
|
|
extensions live under:
|
|
$ZOT_HOME/extensions/<name>/extension.json (global)
|
|
./.zot/extensions/<name>/extension.json (project-local)`)
|
|
}
|
|
|
|
// extList walks both the global and project-local extension dirs and
|
|
// prints a one-row-per-extension table.
|
|
func extList() error {
|
|
type row struct {
|
|
Scope string
|
|
Name string
|
|
Version string
|
|
Enabled string
|
|
Language string
|
|
Dir string
|
|
}
|
|
var rows []row
|
|
for scope, dir := range extensionDirs() {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
extDir := filepath.Join(dir, e.Name())
|
|
mfPath := filepath.Join(extDir, "extension.json")
|
|
raw, err := os.ReadFile(mfPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var m struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Language string `json:"language"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
continue
|
|
}
|
|
enabled := "yes"
|
|
if m.Enabled != nil && !*m.Enabled {
|
|
enabled = "no"
|
|
}
|
|
rows = append(rows, row{
|
|
Scope: scope, Name: m.Name, Version: m.Version,
|
|
Enabled: enabled, Language: m.Language, Dir: extDir,
|
|
})
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no extensions installed")
|
|
fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`")
|
|
return nil
|
|
}
|
|
fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir")
|
|
for _, r := range rows {
|
|
fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n",
|
|
r.Scope, r.Name, dashIfEmpty(r.Version),
|
|
r.Enabled, dashIfEmpty(r.Language), r.Dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extLogs locates the named extension's log file and either cats or
|
|
// tails it (-f).
|
|
func extLogs(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: zot ext logs <name> [-f]")
|
|
}
|
|
name := args[0]
|
|
follow := false
|
|
for _, a := range args[1:] {
|
|
if a == "-f" || a == "--follow" {
|
|
follow = true
|
|
}
|
|
}
|
|
logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log")
|
|
if _, err := os.Stat(logPath); err != nil {
|
|
return fmt.Errorf("no log for %q at %s", name, logPath)
|
|
}
|
|
if !follow {
|
|
f, err := os.Open(logPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(os.Stdout, f)
|
|
return err
|
|
}
|
|
cmd := exec.Command("tail", "-F", logPath)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// extToggle flips the enabled flag in an extension's manifest.
|
|
func extToggle(args []string, enabled bool) error {
|
|
if len(args) == 0 {
|
|
verb := "enable"
|
|
if !enabled {
|
|
verb = "disable"
|
|
}
|
|
return fmt.Errorf("usage: zot ext %s <name>", verb)
|
|
}
|
|
name := args[0]
|
|
dir, err := findExtensionDir(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mfPath := filepath.Join(dir, "extension.json")
|
|
raw, err := os.ReadFile(mfPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var generic map[string]any
|
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
|
return fmt.Errorf("parse manifest: %w", err)
|
|
}
|
|
generic["enabled"] = enabled
|
|
out, err := json.MarshalIndent(generic, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil {
|
|
return err
|
|
}
|
|
state := "enabled"
|
|
if !enabled {
|
|
state = "disabled"
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", state, name)
|
|
return nil
|
|
}
|
|
|
|
// extRemove deletes an extension's directory after a confirmation
|
|
// prompt (skip with --yes).
|
|
func extRemove(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: zot ext remove <name> [--yes]")
|
|
}
|
|
name := args[0]
|
|
yes := false
|
|
for _, a := range args[1:] {
|
|
if a == "--yes" || a == "-y" {
|
|
yes = true
|
|
}
|
|
}
|
|
dir, err := findExtensionDir(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !yes {
|
|
fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir)
|
|
var resp string
|
|
_, _ = fmt.Scanln(&resp)
|
|
if !strings.EqualFold(strings.TrimSpace(resp), "y") {
|
|
fmt.Fprintln(os.Stderr, "aborted")
|
|
return nil
|
|
}
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "removed %s\n", dir)
|
|
return nil
|
|
}
|
|
|
|
// extInstall copies a local directory or shallow-clones a git URL
|
|
// into $ZOT_HOME/extensions/. Validates the destination contains an
|
|
// extension.json before reporting success.
|
|
func extInstall(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: zot ext install <path|git-url>")
|
|
}
|
|
src := args[0]
|
|
dest := filepath.Join(ZotHome(), "extensions")
|
|
if err := os.MkdirAll(dest, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") {
|
|
// Git clone path. Pick the destination name from the repo basename.
|
|
name := strings.TrimSuffix(filepath.Base(src), ".git")
|
|
out := filepath.Join(dest, name)
|
|
if _, err := os.Stat(out); err == nil {
|
|
return fmt.Errorf("destination %s already exists; remove it first", out)
|
|
}
|
|
cmd := exec.Command("git", "clone", "--depth", "1", src, out)
|
|
cmd.Stdout = os.Stderr
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("git clone: %w", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
|
|
_ = os.RemoveAll(out)
|
|
return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back")
|
|
}
|
|
fmt.Fprintf(os.Stderr, "installed %s\n", out)
|
|
return nil
|
|
}
|
|
|
|
// Local path: must be a directory containing extension.json.
|
|
info, err := os.Stat(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("not a directory: %s", src)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
|
|
return fmt.Errorf("source lacks extension.json")
|
|
}
|
|
// Resolve to an absolute, cleaned path before deriving the install
|
|
// name. Otherwise relative sources like "." or "./" collapse to a
|
|
// basename of ".", and the destination wrongly resolves to the
|
|
// extensions/ parent directory (which zot creates on first run),
|
|
// triggering a false "already exists" failure.
|
|
absSrc, err := filepath.Abs(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := filepath.Base(absSrc)
|
|
if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
|
|
return fmt.Errorf("cannot derive extension name from %q", src)
|
|
}
|
|
out := filepath.Join(dest, name)
|
|
if _, err := os.Stat(out); err == nil {
|
|
return fmt.Errorf("destination %s already exists; remove it first", out)
|
|
}
|
|
if err := copyDir(absSrc, out); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "installed %s\n", out)
|
|
return nil
|
|
}
|
|
|
|
func extensionDirs() map[string]string {
|
|
out := map[string]string{}
|
|
if h := ZotHome(); h != "" {
|
|
out["global"] = filepath.Join(h, "extensions")
|
|
}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
out["project"] = filepath.Join(cwd, ".zot", "extensions")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findExtensionDir(name string) (string, error) {
|
|
for _, dir := range extensionDirs() {
|
|
candidate := filepath.Join(dir, name)
|
|
if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("extension %q not found", name)
|
|
}
|
|
|
|
func dashIfEmpty(s string) string {
|
|
if s == "" {
|
|
return "-"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// copyDir does a recursive copy of src to dst preserving file mode
|
|
// bits. Used by `zot ext install <local-path>`.
|
|
//
|
|
// Entries matched by the source's root .gitignore are skipped, and
|
|
// .git itself is always skipped. This keeps non-portable, regeneratable
|
|
// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
|
|
// out of the installed copy so the extension stays functional at its new
|
|
// location.
|
|
func copyDir(src, dst string) error {
|
|
ig := loadGitignore(src)
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel != "." {
|
|
name := filepath.Base(rel)
|
|
if info.IsDir() && name == ".git" {
|
|
return filepath.SkipDir
|
|
}
|
|
if ig.match(filepath.ToSlash(rel), info.IsDir()) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, info.Mode())
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// gitignore is a minimal .gitignore matcher. It supports the common
|
|
// patterns used in real extension repos: blank lines, comments (#),
|
|
// negation (!), directory-only patterns (trailing /), anchored
|
|
// patterns (leading /), and the * / ? / [..] wildcards via
|
|
// filepath.Match. It intentionally does not implement ** globstar or
|
|
// nested per-directory .gitignore files; the goal is to drop obvious
|
|
// non-portable directories, not to be a faithful git reimplementation.
|
|
type gitignore struct {
|
|
rules []gitignoreRule
|
|
}
|
|
|
|
type gitignoreRule struct {
|
|
pattern string
|
|
negate bool
|
|
dirOnly bool
|
|
anchored bool
|
|
}
|
|
|
|
func loadGitignore(root string) *gitignore {
|
|
data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
|
|
if err != nil {
|
|
return &gitignore{}
|
|
}
|
|
return loadGitignoreFromString(string(data))
|
|
}
|
|
|
|
func loadGitignoreFromString(data string) *gitignore {
|
|
g := &gitignore{}
|
|
for _, line := range strings.Split(data, "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
r := gitignoreRule{pattern: trimmed}
|
|
if strings.HasPrefix(r.pattern, "!") {
|
|
r.negate = true
|
|
r.pattern = r.pattern[1:]
|
|
}
|
|
if strings.HasSuffix(r.pattern, "/") {
|
|
r.dirOnly = true
|
|
r.pattern = strings.TrimSuffix(r.pattern, "/")
|
|
}
|
|
if strings.HasPrefix(r.pattern, "/") {
|
|
r.anchored = true
|
|
r.pattern = strings.TrimPrefix(r.pattern, "/")
|
|
}
|
|
if r.pattern == "" {
|
|
continue
|
|
}
|
|
g.rules = append(g.rules, r)
|
|
}
|
|
return g
|
|
}
|
|
|
|
// match reports whether the slash-separated relative path should be
|
|
// ignored. Later rules win, so a trailing negation can re-include a
|
|
// previously ignored path.
|
|
func (g *gitignore) match(rel string, isDir bool) bool {
|
|
ignored := false
|
|
for _, r := range g.rules {
|
|
if r.dirOnly && !isDir {
|
|
continue
|
|
}
|
|
if r.matchPath(rel) {
|
|
ignored = !r.negate
|
|
}
|
|
}
|
|
return ignored
|
|
}
|
|
|
|
func (r gitignoreRule) matchPath(rel string) bool {
|
|
if r.anchored || strings.Contains(r.pattern, "/") {
|
|
if ok, _ := filepath.Match(r.pattern, rel); ok {
|
|
return true
|
|
}
|
|
// Anchored directory pattern also matches everything beneath it.
|
|
return strings.HasPrefix(rel, r.pattern+"/")
|
|
}
|
|
// Unanchored: match the basename of any path component.
|
|
base := rel
|
|
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
|
base = rel[i+1:]
|
|
}
|
|
if ok, _ := filepath.Match(r.pattern, base); ok {
|
|
return true
|
|
}
|
|
// Match a directory component anywhere in the path.
|
|
for _, part := range strings.Split(rel, "/") {
|
|
if ok, _ := filepath.Match(r.pattern, part); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|