blob: 44b73171d3c892082c262c8be936b8acdcc76f81 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dubbo
import (
"errors"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"
)
import (
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/storage/memory"
"gopkg.in/yaml.v2"
)
import (
"github.com/apache/dubbo-kubernetes/app/dubboctl/internal/filesystem"
)
const (
repositoryManifest = "manifest.yaml"
templateManifest = "manifest.yaml"
)
const (
DefaultTemplatesPath = "."
)
// Repository represents a collection of runtimes, each containing templates.
type Repository struct {
// Name of the repository
// This can be for instance:
// directory name on FS or last part of git URL or arbitrary value defined by the Template author.
Name string
// Runtimes containing Templates loaded from the repo
Runtimes []Runtime
fs filesystem.Filesystem
uri string // URI which was used when initially creating
}
type repositoryConfig struct {
// DefaultName is the name indicated by the repository author.
// Stored in the yaml attribute "name", it is only consulted during initial
// addition of the repo as the default option.
DefaultName string `yaml:"name,omitempty"`
// Version of the repository.
Version string `yaml:"version,omitempty"`
// TemplatesPath defines an optional path within the repository at which
// templates are stored. By default this is the repository root.
TemplatesPath string `yaml:"templates,omitempty"`
}
// Runtime is a division of templates within a repository of templates for a
// given runtime (source language plus environmentally available services
// and libraries)
type Runtime struct {
// Name of the runtime
Name string
// Templates defined for the runtime
Templates []Template
}
func NewRepository(name, uri string) (r Repository, err error) {
r = Repository{
uri: uri,
}
fs, err := filesystemFromURI(uri) // Get a Filesystem from the URI
if err != nil {
return Repository{}, fmt.Errorf("failed to get repository from URI (%q): %w", uri, err)
}
r.fs = fs // needed for Repository.Write()
repoConfig := repositoryConfig{}
repoConfig, err = applyRepositoryManifest(fs, repoConfig) // apply optional manifest to r
if err != nil {
return
}
// Validate custom path if defined
if repoConfig.TemplatesPath != "" {
if err = checkDir(r.fs, repoConfig.TemplatesPath); err != nil {
err = fmt.Errorf("templates path '%v' does not exist in repo '%v'. %v",
repoConfig.TemplatesPath, r.Name, err)
return
}
} else {
repoConfig.TemplatesPath = DefaultTemplatesPath
}
r.Name, err = repositoryDefaultName(repoConfig.DefaultName, uri)
if err != nil {
return
}
if name != "" {
r.Name = name
}
r.Runtimes, err = repositoryRuntimes(fs, r.Name, repoConfig)
return
}
// repositoryDefaultName returns the given name, which if empty falls back to
// deriving a name from the URI, which if empty then falls back to the
// statically defined default DefaultRepositoryName.
func repositoryDefaultName(name, uri string) (string, error) {
// explicit name takes precedence
if name != "" {
return name, nil
}
// URI-derived is second precedence
if uri != "" {
parsed, err := url.Parse(uri)
if err != nil {
return "", err
}
ss := strings.Split(parsed.Path, "/")
if len(ss) > 0 {
// name is the last token with optional '.git' suffix removed
return strings.TrimSuffix(ss[len(ss)-1], ".git"), nil
}
}
// static default
return DefaultRepositoryName, nil
}
// repositoryRuntimes returns runtimes defined in this repository's filesystem.
// The views are denormalized, using the parent repository's values
// for inherited fields BuildConfig and HealthEndpoints as the default values
// for the runtimes and templates. The runtimes and templates themselves can
// override these values by specifying new values in thir config files.
func repositoryRuntimes(fs filesystem.Filesystem, repoName string, repoConfig repositoryConfig) (runtimes []Runtime, err error) {
runtimes = []Runtime{}
// Load runtimes
fis, err := fs.ReadDir(repoConfig.TemplatesPath)
if err != nil {
return
}
for _, fi := range fis {
// ignore files and hidden dirs
if !fi.IsDir() || strings.HasPrefix(fi.Name(), ".") {
continue
}
// Runtime, defaulted to values inherited from the repository
runtime := Runtime{
Name: fi.Name(),
}
// Runtime Templates
// Load from repo filesystem for runtime. Will inherit values from the
// runtime such as BuildConfig, HealthEndpoints etc.
runtime.Templates, err = runtimeTemplates(fs, repoConfig.TemplatesPath, repoName, runtime.Name)
if err != nil {
return
}
runtimes = append(runtimes, runtime)
}
return
}
// runtimeTemplates returns templates currently defined in the given runtime's
// filesystem. The view is denormalized, using the inherited fields from the
// runtime for defaults of BuildConfig andHealthEndpoints. The template itself
// can override these by including a manifest.
func runtimeTemplates(fs filesystem.Filesystem, templatesPath, repoName, runtimeName string) (templates []Template, err error) {
// Validate runtime directory exists and is a directory
runtimePath := path.Join(templatesPath, runtimeName)
if err = checkDir(fs, runtimePath); err != nil {
err = fmt.Errorf("runtime path '%v' not found. %v", runtimePath, err)
return
}
// Read the directory, loading each template.
fis, err := fs.ReadDir(runtimePath)
if err != nil {
return
}
for _, fi := range fis {
// ignore files and hidden dirs
if !fi.IsDir() || strings.HasPrefix(fi.Name(), ".") {
continue
}
// Template, defaulted to values inherited from the runtime
t := template{
name: fi.Name(),
repository: repoName,
runtime: runtimeName,
fs: filesystem.NewSubFS(path.Join(runtimePath, fi.Name()), fs),
}
// Template Manifest
// Load manifest file if it exists, which may override values inherited from
// the runtime/repo.
t, err = applyTemplateManifest(fs, templatesPath, t)
if err != nil {
return
}
templates = append(templates, t)
}
return
}
// applyTemplateManifest from the directory specified (template root). Returned
// is the template with values from the manifest populated preferentially. An
// error is not returned for a missing manifest file (the passed template is
// returned), but errors decoding the file are.
func applyTemplateManifest(fs filesystem.Filesystem, templatesPath string, t template) (template, error) {
file, err := fs.Open(path.Join(templatesPath, t.runtime, t.Name(), templateManifest))
if err != nil {
if os.IsNotExist(err) {
return t, nil
}
return t, err
}
defer file.Close()
return t, nil
}
// check that the given path is an accessible directory or error.
// this checks within the given filesystem, which may have its own root.
func checkDir(fs filesystem.Filesystem, path string) error {
fi, err := fs.Stat(path)
if err != nil && os.IsNotExist(err) {
err = fmt.Errorf("path '%v` not found", path)
} else if err == nil && !fi.IsDir() {
err = fmt.Errorf("path '%v' is not a directory", path)
}
return err
}
// filesystemFromURI returns a filesystem from the data located at the
// given URI. If URI is not provided, indicates the embedded repo should
// be loaded. URI can be a remote git repository (http:// https:// etc.),
// or a local file path (file://) which can be a git repo or a plain directory.
func filesystemFromURI(uri string) (f filesystem.Filesystem, err error) {
// If not provided, indicates embedded.
if uri == "" {
return EmbeddedTemplatesFS, nil
}
if isNonBareGitRepo(uri) {
return filesystemFromPath(uri)
}
// Attempt to get a filesystem from the uri as a remote repo.
f, err = FilesystemFromRepo(uri)
if f != nil || err != nil {
return
}
// Attempt to get a filesystem from the uri as a file path.
return filesystemFromPath(uri)
}
func isNonBareGitRepo(uri string) bool {
parsed, err := url.Parse(uri)
if err != nil {
return false
}
if parsed.Scheme != "file" {
return false
}
p := filepath.Join(filepath.FromSlash(uri[7:]), ".git")
fi, err := os.Stat(p)
if err != nil {
return false
}
return fi.IsDir()
}
// filesystemFromPath attempts to return a filesystem from a URI as a file:// path
func filesystemFromPath(uri string) (f filesystem.Filesystem, err error) {
parsed, err := url.Parse(uri)
if err != nil {
return
}
if parsed.Scheme != "file" {
return nil, fmt.Errorf("only file scheme is supported")
}
path := filepath.FromSlash(uri[7:])
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("path does not exist: %v", path)
}
return filesystem.NewOsFilesystem(path), nil
}
// FilesystemFromRepo attempts to fetch a filesystem from a git repository
// indicated by the given URI. Returns nil if there is not a repo at the URI.
func FilesystemFromRepo(uri string) (filesystem.Filesystem, error) {
clone, err := git.Clone(memory.NewStorage(),
memfs.New(),
getGitCloneOptions(uri),
)
if err != nil {
if isRepoNotFoundError(err) {
return nil, nil
}
if isBranchNotFoundError(err) {
return nil, fmt.Errorf("failed to clone repository: branch not found for uri %s", uri)
}
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
wt, err := clone.Worktree()
if err != nil {
return nil, err
}
return filesystem.NewBillyFilesystem(wt.Filesystem), nil
}
func getGitCloneOptions(uri string) *git.CloneOptions {
branch := ""
splitUri := strings.Split(uri, "#")
if len(splitUri) > 1 {
uri = splitUri[0]
branch = splitUri[1]
}
opt := &git.CloneOptions{
URL: uri, Depth: 1, Tags: git.NoTags,
RecurseSubmodules: git.NoRecurseSubmodules,
}
if branch != "" {
opt.ReferenceName = plumbing.NewBranchReferenceName(branch)
}
return opt
}
// isRepoNotFoundError returns true if the error is a
// "repository not found" error.
func isRepoNotFoundError(err error) bool {
// This would be better if the error being tested for was typed, but it is
// currently a simple string value comparison.
return err != nil && err.Error() == "repository not found"
}
func isBranchNotFoundError(err error) bool {
// This would be better if the error being tested for was typed, but it is
// currently a simple string value comparison.
return err != nil && err.Error() == "reference not found"
}
// applyRepositoryManifest from the root of the repository's filesystem if it
// exists. Returned is the repository with any values from the manifest
// set to those of the manifest.
func applyRepositoryManifest(fs filesystem.Filesystem, repoConfig repositoryConfig) (repositoryConfig, error) {
file, err := fs.Open(repositoryManifest)
if err != nil {
if os.IsNotExist(err) {
return repoConfig, nil
}
return repoConfig, err
}
defer file.Close()
decoder := yaml.NewDecoder(file)
return repoConfig, decoder.Decode(&repoConfig)
}
// URL attempts to read the remote git origin URL of the repository. Best
// effort; returns empty string if the repository is not a git repo or the repo
// has been mutated beyond recognition on disk (ex: removing the origin remote)
func (r *Repository) URL() string {
uri := r.uri
// The default builtin repository is indicated by an empty URI.
// It has no remote URL, and without this check the current working directory
// would be checked.
if uri == "" {
return ""
}
// git.PlainOpen does not seem to
if strings.HasPrefix(uri, "file://") {
uri = filepath.FromSlash(r.uri[7:])
}
repo, err := git.PlainOpen(uri)
if err != nil {
return "" // not a git repository
}
c, err := repo.Config()
if err != nil {
return "" // Has no .git/config or other error.
}
ref, _ := repo.Head()
if _, ok := c.Remotes["origin"]; ok {
urls := c.Remotes["origin"].URLs
if len(urls) > 0 {
return urls[0] + "#" + ref.Name().Short()
}
}
return ""
}
// Template from repo for given runtime.
func (r *Repository) Template(runtimeName, name string) (t Template, err error) {
runtime, err := r.Runtime(runtimeName)
if err != nil {
return
}
for _, t := range runtime.Templates {
if t.Name() == name {
return t, nil
}
}
return nil, ErrTemplateNotFound
}
// Templates returns the set of all templates for a given runtime.
// If runtime not found, an empty list is returned.
func (r *Repository) Templates(runtimeName string) ([]Template, error) {
for _, runtime := range r.Runtimes {
if runtime.Name == runtimeName {
return runtime.Templates, nil
}
}
return nil, nil
}
// Runtime of the given name within the repository.
func (r *Repository) Runtime(name string) (runtime Runtime, err error) {
if name == "" {
return Runtime{}, ErrRuntimeRequired
}
for _, runtime = range r.Runtimes {
if runtime.Name == name {
return runtime, err
}
}
return Runtime{}, ErrRuntimeNotFound
}
// Write all files in the repository to the given path.
func (r *Repository) Write(dest string) (err error) {
if r.fs == nil {
return errors.New("the write operation is not supported on this repo")
}
fs := r.fs // The FS to copy
// NOTE
// We re-load in-memory git repos via a temp directory to avoid what
// appears to be a missing .git directory in the default worktree FS.
//
// This missing .git dir is usually not an issue when utilizing the
// repository's filesystem (for writing templates, etc), but it does cause
// problems here where we are writing the entire repository to disk (cloning).
// We effectively want a full clone with a working tree. So here we do a
// plain clone first to a temp directory and then copy the files on disk
// using a regular file copy operation which thus includes the repo metadata.
if _, ok := r.fs.(filesystem.BillyFilesystem); ok {
var (
tempDir string
clone *git.Repository
wt *git.Worktree
)
if tempDir, err = os.MkdirTemp("", "dubbo"); err != nil {
return
}
if clone, err = git.PlainClone(tempDir, false, // not bare
getGitCloneOptions(r.uri)); err != nil {
return fmt.Errorf("failed to plain clone repository: %w", err)
}
if wt, err = clone.Worktree(); err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
fs = filesystem.NewBillyFilesystem(wt.Filesystem)
}
return filesystem.CopyFromFS(".", dest, fs)
}