diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0d334d6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,43 @@ +name: main + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.20' + - name: Run CI checks + run: 'make ci' + tag: + if: github.ref == 'refs/heads/main' + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Tag for release + run: 'make tag-release' + release: + if: github.ref == 'refs/heads/main' + needs: tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.20' + - name: Package binaries + run: 'make package' + - name: Create GitHub Release + run: gh release create "v$(grep -v '#' ./VERSION)" --generate-notes --verify-tag --latest ./dist/zipped/* + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index da631e4..3d4ec61 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ *cache* build/ deps/ -dist/ diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..e69de29 diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..135ad78 --- /dev/null +++ b/Containerfile @@ -0,0 +1,14 @@ +# This Containerfile isn't mean for creating artifacts etc., it's just a way to +# perform portable, local CI checks in case there are workstation-specific +# issues a developer faces. +FROM docker.io/library/golang:1.20 + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + make \ + sudo + +COPY . /go/app +WORKDIR /go/app + +RUN make ci diff --git a/Makefile b/Makefile index 714aaab..37cc469 100644 --- a/Makefile +++ b/Makefile @@ -2,48 +2,35 @@ SHELL = /usr/bin/env bash -euo pipefail BINNAME := vdm -all: test clean - .PHONY: % -test: clean - go vet ./... - go test -cover -coverprofile=./cover.out ./... - staticcheck ./... - make -s clean +all: ci package package-debian + +ci: clean + @bash ./scripts/ci.sh + +# test is just an alias for ci +test: ci + +ci-container: + @docker build -f ./Containerfile -t vdm-test:latest . test-coverage: test go tool cover -html=./cover.out -o cover.html xdg-open ./cover.html +# builds for the current platform only build: clean - @mkdir -p build/$$(go env GOOS)-$$(go env GOARCH) - @go build -o build/$$(go env GOOS)-$$(go env GOARCH)/$(BINNAME) + @go build -buildmode=pie -o build/$$(go env GOOS)-$$(go env GOARCH)/$(BINNAME) -ldflags "-s -w" xbuild: clean - @for target in \ - darwin-amd64 \ - linux-amd64 \ - linux-arm \ - linux-arm64 \ - windows-amd64 \ - ; \ - do \ - GOOS=$$(echo "$${target}" | cut -d'-' -f1) ; \ - GOARCH=$$(echo "$${target}" | cut -d'-' -f2) ; \ - outdir=build/"$${GOOS}-$${GOARCH}" ; \ - mkdir -p "$${outdir}" ; \ - printf "Building for %s-%s into build/ ...\n" "$${GOOS}" "$${GOARCH}" ; \ - GOOS="$${GOOS}" GOARCH="$${GOARCH}" go build -o "$${outdir}"/$(BINNAME) ; \ - done + @bash ./scripts/xbuild.sh package: xbuild - @mkdir -p dist - @cd build || exit 1; \ - for built in * ; do \ - printf 'Packaging for %s into dist/ ...\n' "$${built}" ; \ - cd $${built} && tar -czf ../../dist/$(BINNAME)_$${built}.tar.gz * && cd - >/dev/null ; \ - done + @bash ./scripts/package.sh + +package-debian: build + @bash ./scripts/package-debian.sh clean: @rm -rf \ @@ -51,9 +38,24 @@ clean: *cache* \ .*cache* \ ./build/ \ - ./dist/ \ - ./deps/ \ - ./testdata/deps/ + ./dist/zipped/*.tar.gz \ + ./dist/zipped/*.zip \ + ./dist/debian/vdm.deb \ + *.out + @sudo rm -rf ./dist/debian/vdm/usr +# TODO: until I sort out the tests to write test data consistently, these deps/ +# directories etc. can kind of show up anywhere + @find . -type d -name '*deps*' -exec rm -rf {} + + @find . -type f -name '*VDMMETA*' -delete + +bump-versions: clean + @bash ./scripts/bump-versions.sh "$${old_version:-}" + +tag-release: clean + @bash ./scripts/tag-release.sh + +pre-commit-hook: + cp ./scripts/ci.sh ./.git/hooks/pre-commit # Some targets that help set up local workstations with rhad tooling. Assumes # ~/.local/bin is on $PATH diff --git a/README.md b/README.md index dc7b164..a6b1ff6 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,116 @@ -# vdm: Versioned Dependency Manager +# vdm: A General-Purpose Versioned-Dependency Manager -`vdm` is an alternative to git submodules for managing external dependencies for -the same reasons, in a more sane way. +`vdm` is an alternative to e.g. Git Submodules for managing arbitrary external +dependencies for the same reasons, in a more sane way. Unlike some other tools +that try to solve this problem, `vdm` is language-agnostic, and can be used for +any purpose that you would need remote development resources. -To get started, you'll need a `vdm` spec file, which is just a JSON array of all -your external dependencies along with their revisions & where you want them to -live in your repo: +`vdm` can be used for many different purposes, but most commonly as a way to +track external dependencies that your own code might need, but that you don't +have a language-native way to specify. Some examples might be: - [ - { - "remote": "https://github.com/opensourcecorp/go-common", - "version": "v0.2.0", // tag; can also be a branch, short or long commit hash, or the word 'latest' - "local_path": "./deps/go-common" - } - ] +- You have a shared CI repo from which you need to access common shell scripts, + hosted build tasks, etc. -You can have as many dependency specifications in that array as you want. By -default, this spec file is called `vdm.json` and lives at the calling location -(which is probably your repo's root), but you can call it whatever you want and -point to it using the `-spec-file` flag to `vdm`. +- You're building & testing a backend application and need to test serving + frontend code from it, and your team has that frontend code in another + repository. + +- Your team uses protocol buffers and you need to be able to import other loose + `.proto` files to generate your own code. + +`vdm` lets you clearly specify all those remote dependencies & more, and +retrieve them whenever you need them. + +## Getting Started + +### Installation + +`vdm` can be installed from [its GitHub Releases +page](https://github.com/opensourcecorp/vdm/releases). There is a zipped binary +for major platforms & architectures, and those are indicated in the Asset file +name. For example, if you have an M2 macOS laptop, you would download the +`vdm_darwin-arm64.tar.gz` file, and extract it to somewhere on your `$PATH`. + +If you have a recent version of the Go toolchain available, you can also install +or run `vdm` using `go`: + +```sh +go install github.com/opensourcecorp/vdm@ +# or +go run github.com/opensourcecorp/vdm@ ... +``` + +### Usage + +To get started, you'll need a `vdm` spec file, which is just a YAML (or JSON) +file specifying all your external dependencies along with (usually) their +revisions & where you want them to live on your filesystem: + +```yaml +remotes: + + - type: "git" # the default, and so can be omitted if desired + remote: "https://github.com/opensourcecorp/go-common" # can specify as 'git@...' to use SSH instead + local_path: "./deps/go-common" + version: "v0.2.0" # tag example; can also be a branch, commit hash, or the word 'latest' + + - type: "file" # the 'file' type assumes the version is in the remote field itself somehow, so 'version' can be omitted + remote: "https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/http.proto" + local_path: "./deps/proto/http/http.proto" +``` + +You can have as many dependency specifications in that array as you want, and +they can be stored wherever you want. By default, this spec file is called +`vdm.yaml` and lives at the calling location (which is probably your repo's +root), but you can call it whatever you want and point to it using the +`--spec-file` flag to `vdm`. Once you have a spec file, just run: - vdm sync +```sh +vdm sync +``` + +and `vdm` will process the spec file, retrieve your dependencies as specified, +and put them where you told them to go. By default, `vdm sync` also removes the +local `.git` directories for each `git` remote, so as to not upset your local +Git tree. If you want to change the version/revision of a remote, just update +your spec file and run `vdm sync` again. + +After running `vdm sync` with the above example spec file, your directory tree +would look something like this: -and `vdm` will process the spec file, grab your dependencies, put them where -they belong, and check out the right versions. By default, `vdm sync` also -removes the local `.git` directories for each remote, so as to not upset your -local Git tree. If you want to change the version/revision of a remote, just -update your spec file and run `vdm sync` again. +```txt +./vdm.yaml +./deps/ + go-common/ + + http.proto +``` -If for any reason you want all the deps in the spec file to retain their `.git` -directories (such as if you're using `vdm` to initialize a new computer with -actual repos you'd be working in), you can pass the `-keep-git-dir` flag to `vdm -sync`. +## Dependencies + +`vdm` is distributed as a statically-linked binary per platform that has no +language-specific dependencies. However, note that at the time of this writing, +`vdm` *does* depends on `git` being installed if you specify any `git` remote +types. `vdm` will fail with an informative error if it can't find `git` on your +`$PATH`. + +## A note about auth + +`vdm` has zero goals to be an authN/authZ manager. If a remote in your spec file +depends on a certain auth setup (an SSH key, something for HTTP basic auth like +a `.netrc` file, an `.npmrc` config file, etc.), that setup is out of `vdm`'s +scope. If required, you will need to ensure proper auth is configured before +running `vdm` commands. ## Future work - Make the sync mechanism more robust, such that if your spec file changes to remove remotes, they'll get cleaned up automatically. -- Support more than just Git -- but I really don't care that much about this - right now. +- Add `--keep-git-dir` flag so that `git` remote types don't wipe the `.git` + directory at clone-time. + +- Support more than just `git` and `file` types, and make `file` better diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b9bbca9 --- /dev/null +++ b/VERSION @@ -0,0 +1,3 @@ +# Version number used as an anchor for listed versions in various files in the +# tree, orchestrated by scripts/bump-versions.sh +0.2.0 diff --git a/cmd/doc.go b/cmd/doc.go new file mode 100644 index 0000000..669e3cf --- /dev/null +++ b/cmd/doc.go @@ -0,0 +1,4 @@ +/* +Package cmd calls the implementation logic for vdm. +*/ +package cmd diff --git a/cmd/flagsupport.go b/cmd/flagsupport.go new file mode 100644 index 0000000..31a122e --- /dev/null +++ b/cmd/flagsupport.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "os" + + "github.com/opensourcecorp/vdm/internal/message" + "github.com/spf13/viper" +) + +// MaybeSetDebug sets the DEBUG environment variable if it was set as a flag by +// the caller. +func MaybeSetDebug() { + if viper.GetBool(debugFlagKey) { + err := os.Setenv("DEBUG", "true") + if err != nil { + message.Fatalf("internal error: unable to set environment variable DEBUG") + } + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..680283d --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/opensourcecorp/vdm/internal/message" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// !!! DO NOT TOUCH, the version-bumper script handles updating this !!! +const vdmVersion string = "v0.2.0" + +var rootCmd = cobra.Command{ + Use: "vdm", + Short: "vdm -- a Versioned-Dependency Manager", + Long: "vdm is used to manage arbitrary remote dependencies", + TraverseChildren: true, + Version: vdmVersion, + Run: func(cmd *cobra.Command, args []string) { + MaybeSetDebug() + if len(args) == 0 { + err := cmd.Help() + if err != nil { + message.Fatalf("failed to print help message, somehow") + } + os.Exit(0) + } + }, +} + +type rootFlags struct { + SpecFilePath string + Debug bool +} + +// RootFlagValues contains an initalized [rootFlags] struct with populated +// values. +var RootFlagValues rootFlags + +// Flag name keys +const ( + specFilePathFlagKey string = "specfile-path" + debugFlagKey string = "debug" +) + +func init() { + var err error + + rootCmd.PersistentFlags().StringVar(&RootFlagValues.SpecFilePath, specFilePathFlagKey, "./vdm.yaml", "Path to vdm specfile") + err = viper.BindPFlag(specFilePathFlagKey, rootCmd.PersistentFlags().Lookup(specFilePathFlagKey)) + if err != nil { + message.Fatalf("internal error: unable to bind state of flag --%s", specFilePathFlagKey) + } + + rootCmd.PersistentFlags().BoolVar(&RootFlagValues.Debug, debugFlagKey, false, "Show debug messages during runtime") + err = viper.BindPFlag(debugFlagKey, rootCmd.PersistentFlags().Lookup(debugFlagKey)) + if err != nil { + message.Fatalf("internal error: unable to bind state of flag --%s", debugFlagKey) + } + + rootCmd.AddCommand(syncCmd) +} + +// Execute wraps the primary execution logic for vdm's root command, and returns +// any errors encountered to the caller. +func Execute() error { + if err := rootCmd.Execute(); err != nil { + return fmt.Errorf("executing root command: %w", err) + } + + return nil +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..cbf43ea --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + + "github.com/opensourcecorp/vdm/internal/message" + "github.com/opensourcecorp/vdm/internal/remotes" + "github.com/opensourcecorp/vdm/internal/vdmspec" + "github.com/spf13/cobra" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync remotes based on specfile", + RunE: syncExecute, +} + +func syncExecute(_ *cobra.Command, _ []string) error { + MaybeSetDebug() + if err := sync(); err != nil { + return fmt.Errorf("executing sync command: %w", err) + } + return nil +} + +// sync does the heavy lifting to ensure that the local directory tree(s) match +// the desired state as defined in the specfile. +func sync() error { + spec, err := vdmspec.GetSpecFromFile(RootFlagValues.SpecFilePath) + if err != nil { + return fmt.Errorf("getting specs from spec file: %w", err) + } + + err = spec.Validate() + if err != nil { + return fmt.Errorf("your vdm spec file is malformed: %w", err) + } + +SpecLoop: + for _, remote := range spec.Remotes { + // process stored vdm metafile so we know what operations to actually + // perform for existing directories + vdmMeta, err := remote.GetVDMMeta() + if err != nil { + return fmt.Errorf("getting vdm metadata file for sync: %w", err) + } + + if vdmMeta == (vdmspec.Remote{}) { + message.Infof("%s: %s not found at local path, will be created", remote.OpMsg(), vdmspec.MetaFileName) + } else { + if vdmMeta.Version != remote.Version && vdmMeta.Remote != remote.Remote { + message.Infof("%s: Will change '%s' from current local version spec '%s' to '%s'...", remote.OpMsg(), remote.Remote, vdmMeta.Version, remote.Version) + panic("not implemented") + } + message.Infof("%s: version unchanged in spec file, skipping", remote.OpMsg()) + continue SpecLoop + } + + switch remote.Type { + case vdmspec.GitType, "": + if err := remotes.SyncGit(remote); err != nil { + return fmt.Errorf("syncing git remote: %w", err) + } + case vdmspec.FileType: + if err := remotes.SyncFile(remote); err != nil { + return fmt.Errorf("syncing file remote: %w", err) + } + default: + return fmt.Errorf("unrecognized remote type '%s'", remote.Type) + } + + err = remote.WriteVDMMeta() + if err != nil { + return fmt.Errorf("could not write %s file to disk: %w", vdmspec.MetaFileName, err) + } + + message.Infof("%s: Done.", remote.OpMsg()) + } + + message.Infof("All done!") + return nil +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go new file mode 100644 index 0000000..8708f01 --- /dev/null +++ b/cmd/sync_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/opensourcecorp/vdm/internal/vdmspec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testVDMRoot = "../testdata" + +var ( + testSpecFilePath = filepath.Join(testVDMRoot, "vdm.yaml") +) + +func TestSync(t *testing.T) { + spec, err := vdmspec.GetSpecFromFile(testSpecFilePath) + require.NoError(t, err) + + // Need to override for test + RootFlagValues.SpecFilePath = testSpecFilePath + err = sync() + require.NoError(t, err) + + // defer t.Cleanup(func() { + // for _, remote := range spec.Remotes { + // err := os.RemoveAll(remote.LocalPath) + // require.NoError(t, err) + // } + // }) + + t.Run("SyncGit", func(t *testing.T) { + t.Run("spec[0] used a tag", func(t *testing.T) { + vdmMeta, err := spec.Remotes[0].GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, "v0.2.0", vdmMeta.Version) + }) + + t.Run("spec[1] used 'latest'", func(t *testing.T) { + vdmMeta, err := spec.Remotes[1].GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, "latest", vdmMeta.Version) + }) + + t.Run("spec[2] used a branch", func(t *testing.T) { + vdmMeta, err := spec.Remotes[2].GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, "main", vdmMeta.Version) + }) + + t.Run("spec[3] used a hash", func(t *testing.T) { + vdmMeta, err := spec.Remotes[3].GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, "2e6657f5ac013296167c4dd92fbb46f0e3dbdc5f", vdmMeta.Version) + }) + }) + + t.Run("SyncFile", func(t *testing.T) { + t.Run("spec[4] had an implicit version", func(t *testing.T) { + vdmMeta, err := spec.Remotes[4].GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, "", vdmMeta.Version) + }) + }) +} diff --git a/common.go b/common.go deleted file mode 100644 index 62cc3e6..0000000 --- a/common.go +++ /dev/null @@ -1,114 +0,0 @@ -// Common and/or initialization consts, vars, and functions -package main - -import ( - "context" - "flag" - "fmt" - "log" - "os" - "regexp" -) - -const ( - // ANSI color codes for log messages - colorErr = "\033[31m" // red - colorDebug = "\033[33m" // yellow - colorInfo = "\033[36m" // cyan - colorHappy = "\033[32m" // green - colorReset = "\033[0m" -) - -var ( - // Subcommands - syncCmd = flag.NewFlagSet("sync", flag.ExitOnError) - - subcommands = map[string]*flag.FlagSet{ - syncCmd.Name(): syncCmd, - } - - // CLI args common to each subcommand - debug bool - specFilePath string - - // sync CLI flags - keepGitDir bool - - // Loggers, which include embedded ANSI color codes - infoLogger = log.New(os.Stderr, fmt.Sprintf("%s%s[vdm]%s ", colorReset, colorInfo, colorReset), 0) - errLogger = log.New(os.Stderr, fmt.Sprintf("%s%s[vdm]%s ", colorReset, colorErr, colorReset), 0) - debugLogger = log.New(os.Stderr, fmt.Sprintf("%s%s[vdm]%s ", colorReset, colorDebug, colorReset), 0) - happyLogger = log.New(os.Stderr, fmt.Sprintf("%s%s[vdm]%s ", colorReset, colorHappy, colorReset), 0) -) - -// registerFlags assigns values to flags that should belong to each and/or all -// command(s) -func registerFlags() { - // common - for _, cmd := range subcommands { - cmd.StringVar(&specFilePath, "spec-file", "./vdm.json", "Path to vdm spec file") - cmd.BoolVar(&debug, "debug", false, "Print debug logs") - } - - // sync - syncCmd.BoolVar(&keepGitDir, "keep-git-dir", false, "should vdm keep the .git directory within git-sourced directories? Most useful if you're using vdm to initialize groups of actual repositories you intend to work in") -} - -// Linter is mad about using string keys for context.Context, so define empty -// struct types for each usable key here -type debugKey struct{} -type specFilePathKey struct{} -type keepGitDirKey struct{} - -// registerContextKeys assigns common values to the context that is passed -// around, such as CLI flags -func registerContextKeys() context.Context { - ctx := context.Background() - ctx = context.WithValue(ctx, debugKey{}, debug) - ctx = context.WithValue(ctx, specFilePathKey{}, specFilePath) - ctx = context.WithValue(ctx, keepGitDirKey{}, keepGitDir) - - return ctx -} - -// isDebug checks against the passed context to determine if the debug CLI flag -// was set by the user -func isDebug(ctx context.Context) bool { - debugVal := ctx.Value(debugKey{}) - if debugVal == nil { - return false - } - - return debugVal.(bool) -} - -// shouldKeepGitDir checks against the passed context to determine if the -// keepGitDir CLI flag was set by the user -func shouldKeepGitDir(ctx context.Context) bool { - keepGitDirVal := ctx.Value(keepGitDirKey{}) - if keepGitDirVal == nil { - return false - } - - return keepGitDirVal.(bool) -} - -// rootUsage has help text for the root command, so that users don't get an -// unhelpful error when forgetting to specify a subcommand -func showRootUsage() { - fmt.Printf(`vdm declaratively manages remote dependencies as local directories. - -Subcommands: - sync sync local paths based on your vdm spec file - -`) -} - -// checkRootUsage prints usage information if a user doesn't specify a subcommand -func checkRootUsage(args []string) { - helpFlagRegex := regexp.MustCompile(`\-?h(elp)?`) - if len(args) == 1 || (len(args) == 2 && helpFlagRegex.MatchString(args[1])) { - showRootUsage() - errLogger.Fatal("You must provide a command to vdm") - } -} diff --git a/dist/.gitignore b/dist/.gitignore new file mode 100644 index 0000000..2ff6fd3 --- /dev/null +++ b/dist/.gitignore @@ -0,0 +1,10 @@ +# Packaged binaries for e.g. GitHub +*.tar.gz +*.zip + +# The possible locations of the app binary itself, and its debfile +vdm +*.deb +debian/vdm/usr/bin/vdm +# ... but NOT its Debian packaging root directory, which must have the same name +!debian/vdm/ diff --git a/dist/debian/vdm/DEBIAN/control b/dist/debian/vdm/DEBIAN/control new file mode 100644 index 0000000..f57724c --- /dev/null +++ b/dist/debian/vdm/DEBIAN/control @@ -0,0 +1,6 @@ +Package: vdm +Version: 0.2.0 +Architecture: amd64 +Maintainer: Ryan Price +Description: Versioned Dependency Manager + vdm manages arbitrary remote dependencies for your own code. diff --git a/dist/man/man.1.md b/dist/man/man.1.md new file mode 100644 index 0000000..55f177d --- /dev/null +++ b/dist/man/man.1.md @@ -0,0 +1,12 @@ +% VDM(1) vdm 0.2.0 +% Ryan J. Price +% 2023 + +# NAME +vdm - Manage arbitary remote dependencies for your code + +# SYNOPSIS +**vdm** [*OPTIONS*] COMMAND [*OPTIONS*] + +# DESCRIPTION +**vdm** does some stuff. diff --git a/go.mod b/go.mod index fc53a8a..b1b5ed1 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,34 @@ module github.com/opensourcecorp/vdm -go 1.19 +go 1.20 -require github.com/stretchr/testify v1.8.2 +require ( + github.com/spf13/cobra v1.7.0 + github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 6a56e69..365ddea 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,71 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/message/message.go b/internal/message/message.go new file mode 100644 index 0000000..f0f4f9c --- /dev/null +++ b/internal/message/message.go @@ -0,0 +1,41 @@ +// Package message controls message printing. THis isn't a "logging" package per +// se, but adds some niceties for log-like needs. +package message + +import ( + "fmt" + "os" +) + +// Debugf prints out debug-level information messages with a formatting +// directive. +func Debugf(format string, args ...any) { + if os.Getenv("DEBUG") != "" { + fmt.Printf("DEBUG: "+format+"\n", args...) + } +} + +// Infof prints out debug-level information messages with a formatting +// directive. +func Infof(format string, args ...any) { + fmt.Printf(format+"\n", args...) +} + +// Warnf prints out debug-level information messages with a formatting +// directive. +func Warnf(format string, args ...any) { + fmt.Printf("WARNING: "+format+"\n", args...) +} + +// Errorf prints out debug-level information messages with a formatting +// directive. +func Errorf(format string, args ...any) { + fmt.Printf("ERROR: "+format+"\n", args...) +} + +// Fatalf prints out debug-level information messages with a formatting +// directive, and then exits with code 1. +func Fatalf(format string, args ...any) { + fmt.Printf("ERROR: "+format+"\n", args...) + os.Exit(1) +} diff --git a/internal/remotes/doc.go b/internal/remotes/doc.go new file mode 100644 index 0000000..cc7572b --- /dev/null +++ b/internal/remotes/doc.go @@ -0,0 +1,4 @@ +/* +Package remotes defines logic for the various types of remotes that vdm supports. +*/ +package remotes diff --git a/internal/remotes/file.go b/internal/remotes/file.go new file mode 100644 index 0000000..ed9bd59 --- /dev/null +++ b/internal/remotes/file.go @@ -0,0 +1,107 @@ +package remotes + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/opensourcecorp/vdm/internal/message" + "github.com/opensourcecorp/vdm/internal/vdmspec" +) + +// SyncFile is the root of the sync operations for "file" remote types. +func SyncFile(remote vdmspec.Remote) error { + fileExists, err := checkFileExists(remote) + if err != nil { + return fmt.Errorf("checking if file exists locally: %w", err) + } + + if !fileExists { + message.Infof("File '%s' does not exist locally, retrieving", remote.LocalPath) + err = retrieveFile(remote) + if err != nil { + return fmt.Errorf("retrieving file: %w", err) + } + } else { + message.Infof("File '%s' already exists locally, skipping", remote.LocalPath) + } + + return nil +} + +func checkFileExists(remote vdmspec.Remote) (bool, error) { + fullPath, err := filepath.Abs(remote.LocalPath) + if err != nil { + return false, fmt.Errorf("determining abspath for file '%s': %w", remote.LocalPath, err) + } + + _, err = os.Stat(remote.LocalPath) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } else if err != nil { + return false, fmt.Errorf("couldn't check if %s exists at '%s': %w", remote.LocalPath, fullPath, err) + } + + return true, nil +} + +func retrieveFile(remote vdmspec.Remote) (err error) { + resp, err := http.Get(remote.Remote) + if err != nil { + return fmt.Errorf("retrieving remote file '%s': %w", remote.Remote, err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + err = errors.Join(fmt.Errorf("closing response body after remote file '%s' retrieval: %w", remote.Remote, err)) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unsuccessful status code '%d' from server when retrieving remote file '%s'", resp.StatusCode, remote.Remote) + } + + err = ensureParentDirs(remote.LocalPath) + if err != nil { + return fmt.Errorf("creating parent directories for file: %w", err) + } + + // Note: I would normally use os.WriteFile() using the returned bytes + // directly, but the internet says this os.Create()/io.Copy() approach + // appears to be idiomatic + outFile, err := os.Create(remote.LocalPath) + if err != nil { + return fmt.Errorf("creating landing file '%s' for remote file: %w", remote.LocalPath, err) + } + defer func() { + if closeErr := outFile.Close(); closeErr != nil { + err = errors.Join(fmt.Errorf("closing local file '%s' after remote file '%s' retrieval: %w", remote.LocalPath, remote.Remote, err)) + } + }() + + bytesWritten, err := io.Copy(outFile, resp.Body) + if err != nil { + return fmt.Errorf("copying HTTP response to disk: ") + } + message.Debugf("wrote %d bytes to '%s'", bytesWritten, remote.LocalPath) + + return nil +} + +func ensureParentDirs(path string) error { + fullPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("determining abspath for file '%s': %w", path, err) + } + message.Debugf("absolute filepath for '%s' determined to be '%s'", path, fullPath) + dir := filepath.Dir(fullPath) + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + return fmt.Errorf("making directories: %w", err) + } + message.Debugf("created director(ies): %s", dir) + + return nil +} diff --git a/internal/remotes/file_test.go b/internal/remotes/file_test.go new file mode 100644 index 0000000..a558bee --- /dev/null +++ b/internal/remotes/file_test.go @@ -0,0 +1 @@ +package remotes diff --git a/internal/remotes/git.go b/internal/remotes/git.go new file mode 100644 index 0000000..940a25c --- /dev/null +++ b/internal/remotes/git.go @@ -0,0 +1,77 @@ +package remotes + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/opensourcecorp/vdm/internal/message" + "github.com/opensourcecorp/vdm/internal/vdmspec" +) + +// SyncGit is the root of the sync operations for "git" remote types. +func SyncGit(remote vdmspec.Remote) error { + err := gitClone(remote) + if err != nil { + return fmt.Errorf("cloing remote: %w", err) + } + + if remote.Version != "latest" { + message.Infof("%s: Setting specified version...", remote.OpMsg()) + checkoutCmd := exec.Command("git", "-C", remote.LocalPath, "checkout", remote.Version) + checkoutOutput, err := checkoutCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error checking out specified revision: exec error '%w', with output: %s", err, string(checkoutOutput)) + } + } + + message.Debugf("removing .git dir for local path '%s'", remote.LocalPath) + dotGitPath := filepath.Join(remote.LocalPath, ".git") + err = os.RemoveAll(dotGitPath) + if err != nil { + return fmt.Errorf("removing directory %s: %w", dotGitPath, err) + } + + return nil +} + +func checkGitAvailable() error { + cmd := exec.Command("git", "--version") + sysOutput, err := cmd.CombinedOutput() + if err != nil { + message.Debugf("%s: %s", err.Error(), string(sysOutput)) + return errors.New("git does not seem to be available on your PATH, so cannot continue") + } + message.Debugf("git was found on PATH") + return nil +} + +func gitClone(remote vdmspec.Remote) error { + err := checkGitAvailable() + if err != nil { + return fmt.Errorf("remote '%s' is a git type, but git may not installed/available on PATH: %w", remote.Remote, err) + } + + // If users want "latest", then we can just do a depth-one clone and + // skip the checkout operation. But if they want non-latest, we need the + // full history to be able to find a specified revision + var cloneCmdArgs []string + if remote.Version == "latest" { + message.Debugf("%s: version specified as 'latest', so making shallow clone and skipping separate checkout operation", remote.OpMsg()) + cloneCmdArgs = []string{"clone", "--depth=1", remote.Remote, remote.LocalPath} + } else { + message.Debugf("%s: version specified as NOT latest, so making regular clone and will make separate checkout operation", remote.OpMsg()) + cloneCmdArgs = []string{"clone", remote.Remote, remote.LocalPath} + } + + message.Infof("%s: Retrieving...", remote.OpMsg()) + cloneCmd := exec.Command("git", cloneCmdArgs...) + cloneOutput, err := cloneCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cloning remote: exec error '%w', with output: %s", err, string(cloneOutput)) + } + + return nil +} diff --git a/internal/remotes/git_test.go b/internal/remotes/git_test.go new file mode 100644 index 0000000..6b2bde4 --- /dev/null +++ b/internal/remotes/git_test.go @@ -0,0 +1,80 @@ +package remotes + +import ( + "os" + "testing" + + "github.com/opensourcecorp/vdm/internal/vdmspec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestGitSpec() vdmspec.Remote { + specLocalPath := "./deps/go-common" + return vdmspec.Remote{ + Type: "git", + Remote: "https://github.com/opensourcecorp/go-common", + Version: "v0.2.0", + LocalPath: specLocalPath, + } +} + +func TestSyncGit(t *testing.T) { + spec := getTestGitSpec() + err := SyncGit(spec) + require.NoError(t, err) + + defer t.Cleanup(func() { + if cleanupErr := os.RemoveAll(spec.LocalPath); cleanupErr != nil { + t.Fatalf("removing specLocalPath: %v", cleanupErr) + } + }) + + t.Run(".git directory was removed", func(t *testing.T) { + _, err := os.Stat("./deps/go-common-tag/.git") + assert.ErrorIs(t, err, os.ErrNotExist) + }) +} + +func TestCheckGitAvailable(t *testing.T) { + t.Run("checkGitAvailable", func(t *testing.T) { + t.Run("no error when git is available", func(t *testing.T) { + // Host of this test better have git available lol + gitAvailable := checkGitAvailable() + require.NoError(t, gitAvailable) + }) + + t.Run("error when git is NOT available", func(t *testing.T) { + t.Setenv("PATH", "") + gitAvailable := checkGitAvailable() + assert.Error(t, gitAvailable) + }) + }) +} + +func TestGitClone(t *testing.T) { + spec := getTestGitSpec() + cloneErr := gitClone(spec) + + defer t.Cleanup(func() { + if cleanupErr := os.RemoveAll(spec.LocalPath); cleanupErr != nil { + t.Fatalf("removing specLocalPath: %v", cleanupErr) + } + }) + + t.Run("no error on success", func(t *testing.T) { + require.NoError(t, cloneErr) + }) + + t.Run("LocalPath is a directory, not a file", func(t *testing.T) { + outDir, err := os.Stat("./deps/go-common") + require.NoError(t, err) + assert.True(t, outDir.IsDir()) + }) + + t.Run("a known file in the remote exists, and is a file", func(t *testing.T) { + sampleFile, err := os.Stat("./deps/go-common/go.mod") + require.NoError(t, err) + assert.False(t, sampleFile.IsDir()) + }) +} diff --git a/internal/vdmspec/doc.go b/internal/vdmspec/doc.go new file mode 100644 index 0000000..0f9316c --- /dev/null +++ b/internal/vdmspec/doc.go @@ -0,0 +1,5 @@ +/* +Package vdmspec defines the [Spec] and [Remote] struct types, and their +associated methods. +*/ +package vdmspec diff --git a/internal/vdmspec/spec.go b/internal/vdmspec/spec.go new file mode 100644 index 0000000..bfe2e3b --- /dev/null +++ b/internal/vdmspec/spec.go @@ -0,0 +1,141 @@ +package vdmspec + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/opensourcecorp/vdm/internal/message" + "gopkg.in/yaml.v3" +) + +// Spec defines the overall structure of the vmd specfile. +type Spec struct { + Remotes []Remote `json:"remotes" yaml:"remotes"` +} + +// Remote defines the structure of each remote configuration in the vdm +// specfile. +type Remote struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Remote string `json:"remote" yaml:"remote"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + LocalPath string `json:"local_path" yaml:"local_path"` +} + +const ( + // MetaFileName is the name of the tracking file that vdm uses to record & + // track remote statuses on disk. + MetaFileName string = "VDMMETA" + + // GitType represents the string to match against for git remote types. + GitType string = "git" + // FileType represents the string to match against for file remote types. + FileType string = "file" +) + +// MakeMetaFilePath constructs the metafile path that vdm will use to track a +// remote's state on disk. +func (r Remote) MakeMetaFilePath() string { + metaFilePath := filepath.Join(r.LocalPath, MetaFileName) + // TODO: this is brittle, but it's the best I can think of right now + if r.Type == FileType { + fileDir := filepath.Dir(r.LocalPath) + fileName := filepath.Base(r.LocalPath) + // converts to e.g. 'VDMMETA_http.proto' + metaFilePath = filepath.Join(fileDir, fmt.Sprintf("%s_%s", MetaFileName, fileName)) + } + + return metaFilePath +} + +// WriteVDMMeta writes the metafile contents to disk, the path of which is +// determined by [Remote.MakeMetaFilePath]. +func (r Remote) WriteVDMMeta() error { + metaFilePath := r.MakeMetaFilePath() + vdmMetaContent, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("writing %s: %w", metaFilePath, err) + } + + vdmMetaContent = append(vdmMetaContent, []byte("\n")...) + + message.Debugf("writing metadata file to '%s'", metaFilePath) + err = os.WriteFile(metaFilePath, vdmMetaContent, 0644) + if err != nil { + return fmt.Errorf("writing metadata file: %w", err) + } + + return nil +} + +// GetVDMMeta reads the metafile from disk, and returns it for further +// processing. +func (r Remote) GetVDMMeta() (Remote, error) { + metaFilePath := r.MakeMetaFilePath() + _, err := os.Stat(metaFilePath) + if errors.Is(err, os.ErrNotExist) { + return Remote{}, nil // this is ok, because it might literally not exist yet + } else if err != nil { + return Remote{}, fmt.Errorf("couldn't check if %s exists at '%s': %w", MetaFileName, metaFilePath, err) + } + + vdmMetaFile, err := os.ReadFile(metaFilePath) + if err != nil { + message.Debugf("error reading VMDMMETA from disk: %w", err) + return Remote{}, fmt.Errorf("there was a problem reading the %s file from '%s': %w", MetaFileName, metaFilePath, err) + } + message.Debugf("%s contents read:\n%s", MetaFileName, string(vdmMetaFile)) + + var vdmMeta Remote + err = yaml.Unmarshal(vdmMetaFile, &vdmMeta) + if err != nil { + message.Debugf("error during %s unmarshal: w", MetaFileName, err) + return Remote{}, fmt.Errorf("there was a problem reading the contents of the %s file at '%s': %w", MetaFileName, metaFilePath, err) + } + message.Debugf("file %s unmarshalled: %+v", MetaFileName, vdmMeta) + + return vdmMeta, nil +} + +// GetSpecFromFile reads the specfile from disk (the path of which is determined +// by the user-supplied flag value), and returns it for further processing of +// remotes. +func GetSpecFromFile(specFilePath string) (Spec, error) { + specFile, err := os.ReadFile(specFilePath) + if err != nil { + message.Debugf("error reading specfile from disk: %w", err) + return Spec{}, fmt.Errorf( + strings.Join([]string{ + "there was a problem reading your vdm file from '%s' -- does it not exist?", + "Either pass the --spec-file flag, or create one in the default location (details in the README).", + "Error details: %w"}, + " ", + ), + specFilePath, + err, + ) + } + message.Debugf("specfile contents read:\n%s", string(specFile)) + + var spec Spec + err = yaml.Unmarshal(specFile, &spec) + if err != nil { + message.Debugf("error during specfile unmarshal: w", err) + return Spec{}, fmt.Errorf("there was a problem reading the contents of your vdm spec file: %w", err) + } + message.Debugf("vdmSpecs unmarshalled: %+v", spec) + + return spec, nil +} + +// OpMsg constructs a loggable message outlining the specific operation being +// performed at the moment +func (r Remote) OpMsg() string { + if r.Version != "" { + return fmt.Sprintf("%s@%s --> %s", r.Remote, r.Version, r.LocalPath) + } + return fmt.Sprintf("%s --> %s", r.Remote, r.LocalPath) +} diff --git a/internal/vdmspec/spec_test.go b/internal/vdmspec/spec_test.go new file mode 100644 index 0000000..70918cc --- /dev/null +++ b/internal/vdmspec/spec_test.go @@ -0,0 +1,75 @@ +package vdmspec + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testVDMRoot = "../../testdata" + +var ( + testVDMMetaFilePath = filepath.Join(testVDMRoot, MetaFileName) + + testRemote = Remote{ + Remote: "https://some-remote", + Version: "v1.0.0", + LocalPath: testVDMRoot, + } + + testSpecFilePath = filepath.Join(testVDMRoot, "vdm.yaml") + + testVDMMetaContents = fmt.Sprintf( + `{"remote": "https://some-remote", "version": "v1.0.0", "local_path": "%s"}`, + testVDMRoot, + ) +) + +func TestVDMMeta(t *testing.T) { + t.Run("GetVDMMeta", func(t *testing.T) { + err := os.WriteFile(testVDMMetaFilePath, []byte(testVDMMetaContents), 0644) + require.NoError(t, err) + + defer t.Cleanup(func() { + err := os.RemoveAll(testVDMMetaFilePath) + require.NoError(t, err) + }) + + got, err := testRemote.GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, testRemote, got) + }) + + t.Run("WriteVDMMeta", func(t *testing.T) { + defer t.Cleanup(func() { + err := os.RemoveAll(testVDMMetaFilePath) + require.NoError(t, err) + }) + + // Needs to have parent dir(s) exist for write to work + err := os.MkdirAll(testRemote.LocalPath, 0644) + require.NoError(t, err) + + err = testRemote.WriteVDMMeta() + require.NoError(t, err) + + got, err := testRemote.GetVDMMeta() + require.NoError(t, err) + assert.Equal(t, testRemote, got) + }) + + t.Run("GetSpecsFromFile", func(t *testing.T) { + defer t.Cleanup(func() { + err := os.RemoveAll(testVDMMetaFilePath) + require.NoError(t, err) + }) + + spec, err := GetSpecFromFile(testSpecFilePath) + require.NoError(t, err) + assert.Equal(t, 5, len(spec.Remotes)) + }) +} diff --git a/internal/vdmspec/validate.go b/internal/vdmspec/validate.go new file mode 100644 index 0000000..2724244 --- /dev/null +++ b/internal/vdmspec/validate.go @@ -0,0 +1,64 @@ +package vdmspec + +import ( + "errors" + "fmt" + "regexp" + + "github.com/opensourcecorp/vdm/internal/message" +) + +// Validate performs runtime validations on the vdm specfile, and informs the +// caller of any failures encountered. +func (spec Spec) Validate() error { + var allErrors []error + + for remoteIndex, remote := range spec.Remotes { + // Remote field + message.Debugf("Index #%d: validating field 'Remote' for %+v", remoteIndex, remote) + if len(remote.Remote) == 0 { + allErrors = append(allErrors, errors.New("all 'remote' fields must be non-zero length")) + } + protocolRegex := regexp.MustCompile(`(http(s?)://|git://|git@)`) + if !protocolRegex.MatchString(remote.Remote) { + allErrors = append( + allErrors, + fmt.Errorf("remote #%d provided as '%s', but all 'remote' fields must begin with a protocol specifier or other valid prefix (e.g. 'https://', '(user|git)@', etc.)", remoteIndex, remote.Remote), + ) + } + + // Version field + message.Debugf("Index #%d: validating field 'Version' for %+v", remoteIndex, remote) + if remote.Type == GitType && len(remote.Version) == 0 { + allErrors = append(allErrors, errors.New("all 'version' fields for the 'git' remote type must be non-zero length. If you don't care about the version (even though you probably should), then use 'latest'")) + } + if remote.Type == FileType && len(remote.Version) > 0 { + message.Warnf("NOTE: Remote #%d '%s' specified as type '%s', which does not take explicit version info (you provided '%s'); ignoring version field", remoteIndex, remote.Remote, remote.Type, remote.Version) + } + + // LocalPath field + message.Debugf("Index #%d: validating field 'LocalPath' for %+v", remoteIndex, remote) + if len(remote.LocalPath) == 0 { + allErrors = append(allErrors, errors.New("all 'local_path' fields must be non-zero length")) + } + + // Type field + message.Debugf("Index #%d: validating field 'Type' for %+v", remoteIndex, remote) + typeMap := map[string]int{ + GitType: 1, + "": 2, // also git + FileType: 3, + } + if _, ok := typeMap[remote.Type]; !ok { + allErrors = append(allErrors, fmt.Errorf("unrecognized remote type '%s'", remote.Type)) + } + } + + if len(allErrors) > 0 { + for _, err := range allErrors { + message.Errorf("validation failure: %s", err.Error()) + } + return fmt.Errorf("%d validation failure(s) found in your vdm spec file", len(allErrors)) + } + return nil +} diff --git a/internal/vdmspec/validate_test.go b/internal/vdmspec/validate_test.go new file mode 100644 index 0000000..7428cee --- /dev/null +++ b/internal/vdmspec/validate_test.go @@ -0,0 +1,84 @@ +package vdmspec + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + t.Run("passes", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "https://some-remote", + Version: "v1.0.0", + LocalPath: "./deps/some-remote", + }}, + } + err := spec.Validate() + require.NoError(t, err) + }) + + t.Run("fails on zero-length remote", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "", + Version: "v1.0.0", + LocalPath: "./deps/some-remote", + }}, + } + err := spec.Validate() + assert.Error(t, err) + }) + + t.Run("fails on remote without valid protocol", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "some-remote", + Version: "v1.0.0", + LocalPath: "./deps/some-remote", + }}, + } + err := spec.Validate() + assert.Error(t, err) + }) + + t.Run("fails on zero-length version for git remote type", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "https://some-remote", + Version: "", + LocalPath: "./deps/some-remote", + Type: GitType, + }}, + } + err := spec.Validate() + assert.Error(t, err) + }) + + t.Run("fails on unrecognized remote type", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "https://some-remote", + Version: "", + LocalPath: "./deps/some-remote", + Type: "bad", + }}, + } + err := spec.Validate() + assert.Error(t, err) + }) + + t.Run("fails on zero-length local path", func(t *testing.T) { + spec := Spec{ + Remotes: []Remote{{ + Remote: "https://some-remote", + Version: "v1.0.0", + LocalPath: "", + }}, + } + err := spec.Validate() + assert.Error(t, err) + }) +} diff --git a/main.go b/main.go index 3977d25..7076e96 100644 --- a/main.go +++ b/main.go @@ -1,42 +1,13 @@ +// Package main provides the entrypoint into vdm's subcommands. package main import ( - "os" + "github.com/opensourcecorp/vdm/cmd" + "github.com/opensourcecorp/vdm/internal/message" ) func main() { - checkRootUsage(os.Args) - cmd, ok := subcommands[os.Args[1]] // length-guarded already by checkRootUsage() above - if !ok { - showRootUsage() - errLogger.Fatalf("Unrecognized vdm subcommand '%s'", os.Args[1]) + if err := cmd.Execute(); err != nil { + message.Fatalf("running vdm: %v", err) } - registerFlags() - cmd.Parse(os.Args[2:]) - - ctx := registerContextKeys() - - err := checkGitAvailable(ctx) - if err != nil { - os.Exit(1) - } - - specs := getSpecsFromFile(ctx, specFilePath) - - for _, spec := range specs { - err := spec.Validate(ctx) - if err != nil { - errLogger.Fatalf("Your vdm spec file is malformed: %v", err) - } - } - - switch cmd.Name() { - case syncCmd.Name(): - sync(ctx, specs) - default: // should never get here since we check above, but still - showRootUsage() - errLogger.Fatalf("Unrecognized vdm subcommand '%s'", cmd.Name()) - } - - happyLogger.Print("All done!") } diff --git a/scripts/bump-versions.sh b/scripts/bump-versions.sh new file mode 100755 index 0000000..79318bd --- /dev/null +++ b/scripts/bump-versions.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +################################################################################ +# A singular, consistent version needs to match across several places across the +# codebase tree, such as the Debian Control file & man pages, the embedded +# version listed for the CLI command, etc. This script is used to bump version +# identifiers in any locations listed in the loop at the bottom. +################################################################################ + +root="$(git rev-parse --show-toplevel)" + +old_version="${1:-}" +new_version="$(grep -v '#' "${root}"/VERSION)" + +if [[ -z "${old_version:-}" ]] ; then + printf 'ERROR: you must specify old_version as the first script argument\n' + exit 1 +fi + +if [[ -z "${new_version:-}" ]] ; then + printf 'ERROR: version unable to be determined from ./VERSION file; possible malformed?\n' + exit 1 +fi + +for f in \ + dist/debian/vdm/DEBIAN/control \ + dist/man/* \ + cmd/root.go \ +; do + if grep -q "${old_version}" "${root}/${f}" ; then + printf 'Updating version in %s: %s -> %s\n' "${f}" "${old_version}" "${new_version}" + sed -i "s/${old_version}/${new_version}/g" "${root}/${f}" + fi +done diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..9f3f582 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +failures=() + +printf '> Running CI checks\n' + +printf '>> Go vet\n' +if ! go vet ./... ; then + printf '>>> Failed go-vet check\n' > /dev/stderr + failures+=('go-vet') +fi + +printf '>> Go linter\n' +if ! go run github.com/mgechev/revive@latest --set_exit_status ./... ; then + printf '>>> Failed go-lint\n' > /dev/stderr + failures+=('go-lint') +fi + +printf '>> Go error checker\n' +if ! go run github.com/kisielk/errcheck@latest ./... ; then + printf '>>> Failed go-error-check\n' > /dev/stderr + failures+=('go-error-check') +fi + +printf '>> Go test\n' +if ! go test -cover -coverprofile=./cover.out ./... ; then + printf '>>> Failed go-test check\n' > /dev/stderr + failures+=('go-test') +fi + +printf '>> Packaging checker\n' +if ! make -s package ; then + printf '>>> Failed packaging check\n' > /dev/stderr + failures+=('packaging') +fi + +printf '>> Version tag checker\n' +if ! make -s tag-release ; then + printf '>>> Failed verion tag checker\n' > /dev/stderr + failures+=('version-tag') +fi + +if [[ "${#failures[@]}" -gt 0 ]] ; then + printf '> One or more checks failed, see output above\n' > /dev/stderr + exit 1 +fi + +printf '> All checks passed!\n' diff --git a/scripts/package-debian.sh b/scripts/package-debian.sh new file mode 100755 index 0000000..e2ba507 --- /dev/null +++ b/scripts/package-debian.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# TODO: figure out cross-arch distribution for Debian at some point +printf 'Note: only supporting Debian builds for amd64 at the moment\n' + +# We need several directories to exist, and lintian says they need to be owned +# by root, so instead of keeping them in the repo, we create them (and populate +# their contents) on the fly +mkdir -p \ + ./dist/debian/vdm/usr/bin \ + ./dist/debian/vdm/usr/share/doc/vdm \ + ./dist/debian/vdm/usr/share/man/man1 +sudo chown -R 0:0 ./dist/debian/vdm/usr + +sudo cp ./build/linux-amd64/vdm ./dist/debian/vdm/usr/bin/vdm +sudo cp ./LICENSE ./dist/debian/vdm/usr/share/doc/vdm/copyright +sudo sh -c 'gzip -9 -n -c ./CHANGELOG > ./dist/debian/vdm/usr/share/doc/vdm/changelog.gz' +sudo sh -c 'pandoc ./dist/man/man.1.md -s -t man | gzip -9 -n -c - > ./dist/debian/vdm/usr/share/man/man1/vdm.1.gz' + +# Actually build the debfile +dpkg-deb --build ./dist/debian/vdm + +# Ask lintian (the Debian package linter) to scream as loudly as it can about +# anything it finds +lintian --info --tag-display-limit=0 ./dist/debian/vdm.deb diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..13daeda --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# cd-jumps because it makes logs cleaner, not sorry +mkdir -p dist/zipped +cd build || exit 1 +for built in * ; do + printf 'Packaging for %s into dist/zipped/\n' "${built}" + cd "${built}" || exit 1 + # Windows might like .zips better, otherwise make .tar.gzs + if [[ "${built}" =~ 'windows' ]] ; then + zip -r9 ../../dist/zipped/vdm_"${built}".zip ./* + else + tar -czf ../../dist/zipped/vdm_"${built}".tar.gz ./* + fi + cd - > /dev/null +done diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 0000000..f03728c --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +################################################################################ +# This script portably manages tagging of the HEAD commit based on the contents +# of the VERSION file. It performs some heuristic checks to make sure tagging +# will behave as expected. +################################################################################ + +root="$(git rev-parse --show-toplevel)" + +current_git_branch="$(git rev-parse --abbrev-ref HEAD)" +latest_git_tag="$(git tag --list | tail -n1)" +current_listed_version="$(grep -v '#' "${root:-}"/VERSION)" + +printf 'Current git branch: %s\n' "${current_git_branch:-}" +printf 'Latest git tag: %s\n' "${latest_git_tag:-}" +printf 'Current version listed in VERSION file: %s\n' "${current_listed_version:-}" + +failures=() + +if [[ "${current_git_branch}" == 'main' ]] ; then + # Fail if we forgot to bump VERSION + if [[ "${latest_git_tag}" == "${current_listed_version}" ]] ; then + printf 'ERROR: Identifier in VERSION still matches what is tagged on the main branch -- did you forget to update?\n' > /dev/stderr + failures+=('forgot-to-bump-VERSION') + fi + + # Fail if we forgot to bump versions across files + old_git_status="$(git status | grep -i -E 'modified')" + make -s bump-versions old_version="${current_listed_version}" + new_git_status="$(git status | grep -i -E 'modified')" + if [[ "$(diff <(echo "${old_git_status}") <(echo "${new_git_status}") | wc -l)" -gt 0 ]] ; then + printf 'ERROR: Files modified by version-bump check -- did you forget to update versions across the repo to match VERSION?\n' > /dev/stderr + failures+=('forgot-to-bump-other-versions') + fi + + if [[ "${#failures[@]}" -gt 0 ]] ; then + exit 1 + else + printf 'All checks passed, tagging & pushing new version: %s --> %s\n' "${latest_git_tag}" "${current_listed_version}" + git tag --force "v${current_listed_version}" + git push --tags + fi + +else + printf 'Not on main branch, nothing to do\n' + exit 0 +fi diff --git a/scripts/xbuild.sh b/scripts/xbuild.sh new file mode 100755 index 0000000..e5606b7 --- /dev/null +++ b/scripts/xbuild.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +targets=$(go tool dist list | grep -E 'linux|windows|darwin' | grep -E 'amd64|arm64') +printf 'Will build for:\n' +while read -r line ; do + printf '\t%s\n' "${line}" +done <<< "${targets}" + +for target in ${targets} ; do + GOOS=$(echo "${target}" | cut -d'/' -f1) + GOARCH=$(echo "${target}" | cut -d'/' -f2) + export GOOS GOARCH + + # Windows needs an .exe suffix to actually run + suffix='' + if [[ "${GOOS}" == 'windows' ]] ; then + suffix='.exe' + fi + + outdir=build/"${GOOS}-${GOARCH}" + mkdir -p "${outdir}" + printf "Building for %s-%s into build/\n" "${GOOS}" "${GOARCH}" + go build -o "${outdir}/vdm${suffix}" +done diff --git a/spec.go b/spec.go deleted file mode 100644 index 34fff7a..0000000 --- a/spec.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" -) - -type vdmSpec struct { - Remote string `json:"remote"` - Version string `json:"version"` - LocalPath string `json:"local_path"` -} - -func (spec vdmSpec) writeVDMMeta() error { - metaFilePath := filepath.Join(spec.LocalPath, "VDMMETA") - vdmMetaContent, err := json.MarshalIndent(spec, "", " ") - if err != nil { - return err - } - - vdmMetaContent = append(vdmMetaContent, []byte("\n")...) - os.WriteFile(metaFilePath, vdmMetaContent, 0644) - - return nil -} - -func (spec vdmSpec) getVDMMeta() vdmSpec { - metaFilePath := filepath.Join(spec.LocalPath, "VDMMETA") - _, err := os.Stat(metaFilePath) - if errors.Is(err, os.ErrNotExist) { - return vdmSpec{} - } else if err != nil { - errLogger.Fatalf("Couldn't check if VDMMETA exists at '%s': %v", metaFilePath, err) - } - - vdmMetaFile, err := os.ReadFile(filepath.Join(spec.LocalPath, "VDMMETA")) - if err != nil { - if debug { - debugLogger.Printf("error reading VMDMMETA from disk: %v", err) - } - errLogger.Fatalf("There was a problem reading the VDMMETA file from '%s': %v", metaFilePath, err) - } - if debug { - debugLogger.Printf("VDMMETA contents read:\n%s", string(vdmMetaFile)) - } - - var vdmMeta vdmSpec - err = json.Unmarshal(vdmMetaFile, &vdmMeta) - if err != nil { - if debug { - debugLogger.Printf("error during VDMMETA unmarshal: %v", err) - } - errLogger.Fatalf("There was a problem reading the contents of the VDMMETA file at '%s': %v", metaFilePath, err) - } - if debug { - debugLogger.Printf("VDMMETA unmarshalled: %+v", vdmMeta) - } - - return vdmMeta -} - -func getSpecsFromFile(ctx context.Context, specFilePath string) []vdmSpec { - specFile, err := os.ReadFile(specFilePath) - if err != nil { - if isDebug(ctx) { - debugLogger.Printf("error reading specFile from disk: %v", err) - } - errLogger.Fatalf("There was a problem reading your vdm file from '%s' -- does it not exist? Either pass the -spec-file flag, or create one in the default location (details in the README)", specFilePath) - } - if debug { - debugLogger.Printf("specFile contents read:\n%s", string(specFile)) - } - - var specs []vdmSpec - err = json.Unmarshal(specFile, &specs) - if err != nil { - if isDebug(ctx) { - debugLogger.Printf("error during specFile unmarshal: %v", err) - } - errLogger.Fatal("There was a problem reading the contents of your vdm spec file") - } - if isDebug(ctx) { - debugLogger.Printf("vdmSpecs unmarshalled: %+v", specs) - } - - return specs -} diff --git a/spec_test.go b/spec_test.go deleted file mode 100644 index 7c8bccc..0000000 --- a/spec_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSpecGetVDMMeta(t *testing.T) { - const testVDMRoot = "./testdata" - testVDMMetaFilePath := filepath.Join(testVDMRoot, "VDMMETA") - - t.Run("getVDMMeta", func(t *testing.T) { - spec := vdmSpec{ - Remote: "https://some-remote", - Version: "v1.0.0", - LocalPath: "./testdata", - } - vdmMetaContents := ` - { - "remote": "https://some-remote", - "version": "v1.0.0", - "local_path": "./testdata" - }` - err := os.WriteFile(testVDMMetaFilePath, []byte(vdmMetaContents), 0644) - if err != nil { - t.Fatal(err) - } - - got := spec.getVDMMeta() - assert.Equal(t, spec, got) - - t.Cleanup(func() { - os.RemoveAll(testVDMMetaFilePath) - }) - }) - - t.Run("writeVDMMeta", func(t *testing.T) { - spec := vdmSpec{ - Remote: "https://some-remote", - Version: "v1.0.0", - LocalPath: "./testdata", - } - err := spec.writeVDMMeta() - assert.NoError(t, err) - - got := spec.getVDMMeta() - assert.Equal(t, spec, got) - - t.Cleanup(func() { - os.RemoveAll(testVDMMetaFilePath) - }) - }) - - t.Run("getSpecsFromFile", func(t *testing.T) { - specFilePath := "./testdata/vdm.json" - - specs := getSpecsFromFile(context.Background(), specFilePath) - assert.Equal(t, 4, len(specs)) - - t.Cleanup(func() { - os.RemoveAll(testVDMMetaFilePath) - }) - }) -} diff --git a/staticcheck.conf b/staticcheck.conf deleted file mode 100644 index e30d13f..0000000 --- a/staticcheck.conf +++ /dev/null @@ -1,3 +0,0 @@ -checks = [ - "all", -] diff --git a/sync.go b/sync.go deleted file mode 100644 index eecb914..0000000 --- a/sync.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" -) - -// sync ensures that the only local dependencies are ones defined in the specfile -func sync(ctx context.Context, specs []vdmSpec) { - for _, spec := range specs { - // Common log line prefix - operationMsg := fmt.Sprintf("%s@%s --> %s", spec.Remote, spec.Version, spec.LocalPath) - - // process stored VDMMETA so we know what operations to actually perform for existing directories - vdmMeta := spec.getVDMMeta() - if vdmMeta == (vdmSpec{}) { - infoLogger.Printf("VDMMETA not found under local path '%s' -- will be created", spec.LocalPath) - } else { - if vdmMeta.Version != spec.Version { - infoLogger.Printf("Changing '%s' from current local version spec '%s' to '%s'...", spec.Remote, vdmMeta.Version, spec.Version) - } else { - if isDebug(ctx) { - debugLogger.Printf("Version unchanged (%s) in spec file for '%s' --> '%s'", spec.Version, spec.Remote, spec.LocalPath) - } - } - } - - // TODO: pull this up so that it only runs if the version changed or the user requested a wipe - if !shouldKeepGitDir(ctx) { - if isDebug(ctx) { - debugLogger.Printf("removing any old data for '%s'", spec.LocalPath) - } - os.RemoveAll(spec.LocalPath) - } - - gitClone(ctx, spec, operationMsg) - - if spec.Version != "latest" { - infoLogger.Printf("%s -- Setting specified version...", operationMsg) - checkoutCmd := exec.Command("git", "-C", spec.LocalPath, "checkout", spec.Version) - checkoutOutput, err := checkoutCmd.CombinedOutput() - if err != nil { - errLogger.Fatalf("error checking out specified revision: exec error '%v', with output: %s", err, string(checkoutOutput)) - } - } - - if !shouldKeepGitDir(ctx) { - if isDebug(ctx) { - debugLogger.Printf("removing .git dir for local path '%s'", spec.LocalPath) - } - os.RemoveAll(filepath.Join(spec.LocalPath, ".git")) - } - - err := spec.writeVDMMeta() - if err != nil { - errLogger.Fatalf("Could not write VDMMETA file to disk: %v", err) - } - - infoLogger.Printf("%s -- Done.", operationMsg) - } -} - -func gitClone(ctx context.Context, spec vdmSpec, operationMsg string) { - // If users want "latest", then we can just do a depth-one clone and - // skip the checkout operation. But if they want non-latest, we need the - // full history to be able to find a specified revision - var cloneCmdArgs []string - if spec.Version == "latest" { - if isDebug(ctx) { - debugLogger.Printf("%s -- version specified as 'latest', so making shallow clone and skipping separate checkout operation", operationMsg) - } - cloneCmdArgs = []string{"clone", "--depth=1", spec.Remote, spec.LocalPath} - } else { - if isDebug(ctx) { - debugLogger.Printf("%s -- version specified as NOT latest, so making regular clone and will make separate checkout operation", operationMsg) - } - cloneCmdArgs = []string{"clone", spec.Remote, spec.LocalPath} - } - - infoLogger.Printf("%s -- Retrieving...", operationMsg) - cloneCmd := exec.Command("git", cloneCmdArgs...) - cloneOutput, err := cloneCmd.CombinedOutput() - if err != nil { - errLogger.Fatalf("error cloning remote: exec error '%v', with output: %s", err, string(cloneOutput)) - } -} diff --git a/sync_test.go b/sync_test.go deleted file mode 100644 index bc5741b..0000000 --- a/sync_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSync(t *testing.T) { - ctx := context.Background() - - const testVDMRoot = "./testdata" - specFilePath := filepath.Join(testVDMRoot, "vdm.json") - - specs := getSpecsFromFile(ctx, specFilePath) - - sync(ctx, specs) - - t.Run("spec[0] used a tag", func(t *testing.T) { - vdmMeta := specs[0].getVDMMeta() - assert.Equal(t, vdmMeta.Version, "v0.2.0") - }) - - t.Run("spec[1] used 'latest'", func(t *testing.T) { - vdmMeta := specs[1].getVDMMeta() - assert.Equal(t, vdmMeta.Version, "latest") - }) - - t.Run("spec[2] used a branch", func(t *testing.T) { - vdmMeta := specs[2].getVDMMeta() - assert.Equal(t, vdmMeta.Version, "main") - }) - - t.Run("spec[4] used a hash", func(t *testing.T) { - vdmMeta := specs[3].getVDMMeta() - assert.Equal(t, vdmMeta.Version, "2e6657f5ac013296167c4dd92fbb46f0e3dbdc5f") - }) - - t.Cleanup(func() { - for _, spec := range specs { - os.RemoveAll(spec.LocalPath) - } - }) -} - -func TestShouldKeepGitDir(t *testing.T) { - ctx := context.Background() - ctx = context.WithValue(ctx, keepGitDirKey{}, true) - - const testVDMRoot = "./testdata" - specFilePath := filepath.Join(testVDMRoot, "vdm.json") - - specs := getSpecsFromFile(ctx, specFilePath) - - sync(ctx, specs) - - for _, spec := range specs { - assert.DirExists(t, filepath.Join(spec.LocalPath, ".git")) - } - - t.Cleanup(func() { - for _, spec := range specs { - os.RemoveAll(spec.LocalPath) - } - }) -} diff --git a/system.go b/system.go deleted file mode 100644 index 684972a..0000000 --- a/system.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "context" - "errors" - "os/exec" -) - -func checkGitAvailable(ctx context.Context) error { - cmd := exec.Command("git", "--version") - sysOutput, err := cmd.CombinedOutput() - if err != nil { - if isDebug(ctx) { - debugLogger.Printf("%s: %s", err.Error(), string(sysOutput)) - } - return errors.New("git does not seem to be available on your PATH, so cannot continue") - } - if isDebug(ctx) { - debugLogger.Print("git was found on PATH") - } - return nil -} diff --git a/system_test.go b/system_test.go deleted file mode 100644 index 47d3edb..0000000 --- a/system_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestCheckAvailable(t *testing.T) { - ctx := context.Background() - - t.Run("git", func(t *testing.T) { - // Host of this test better have git available lol - gitAvailable := checkGitAvailable(ctx) - assert.NoError(t, gitAvailable) - - t.Setenv("PATH", "") - gitAvailable = checkGitAvailable(ctx) - assert.Error(t, gitAvailable) - }) -} diff --git a/testdata/vdm.json b/testdata/vdm.json deleted file mode 100644 index 0739190..0000000 --- a/testdata/vdm.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "remote": "https://github.com/opensourcecorp/go-common", - "version": "v0.2.0", - "local_path": "./deps/go-common-tag" - }, - { - "remote": "https://github.com/opensourcecorp/go-common", - "version": "latest", - "local_path": "./deps/go-common-latest" - }, - { - "remote": "https://github.com/opensourcecorp/go-common", - "version": "main", - "local_path": "./deps/go-common-branch" - }, - { - "remote": "https://github.com/opensourcecorp/go-common", - "version": "2e6657f5ac013296167c4dd92fbb46f0e3dbdc5f", - "local_path": "./deps/go-common-hash" - } -] diff --git a/testdata/vdm.yaml b/testdata/vdm.yaml new file mode 100644 index 0000000..039d1e7 --- /dev/null +++ b/testdata/vdm.yaml @@ -0,0 +1,16 @@ +remotes: + - remote: "https://github.com/opensourcecorp/go-common" + version: "v0.2.0" + local_path: "./deps/go-common-tag" + - remote: "https://github.com/opensourcecorp/go-common" + version: "latest" + local_path: "./deps/go-common-latest" + - remote: "https://github.com/opensourcecorp/go-common" + version: "main" + local_path: "./deps/go-common-branch" + - remote: "https://github.com/opensourcecorp/go-common" + version: "2e6657f5ac013296167c4dd92fbb46f0e3dbdc5f" + local_path: "./deps/go-common-hash" + - type: "file" + remote: "https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/http.proto" + local_path: "./deps/proto/http/http.proto" diff --git a/validate.go b/validate.go deleted file mode 100644 index 6accf84..0000000 --- a/validate.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "regexp" -) - -func (spec vdmSpec) Validate(ctx context.Context) error { - var allErrors []error - - if isDebug(ctx) { - debugLogger.Printf("validating field 'Remote' for %+v", spec) - } - if len(spec.Remote) == 0 { - allErrors = append(allErrors, errors.New("all 'remote' fields must be non-zero length")) - } - protocolRegex := regexp.MustCompile(`(https://|git://|git@)`) - if !protocolRegex.MatchString(spec.Remote) { - allErrors = append( - allErrors, - fmt.Errorf("remote provided as '%s', but all 'remote' fields must begin with a protocol specifier or other valid prefix (e.g. 'https://', 'git@', etc.)", spec.Remote), - ) - } - - if isDebug(ctx) { - debugLogger.Printf("validating field 'Version' for %+v", spec) - } - if len(spec.Version) == 0 { - allErrors = append(allErrors, errors.New("all 'version' fields must be non-zero length. If you don't care about the version (even though you should), then use 'latest'")) - } - - if isDebug(ctx) { - debugLogger.Printf("validating field 'LocalPath' for %+v", spec) - } - if len(spec.LocalPath) == 0 { - allErrors = append(allErrors, errors.New("all 'local_path' fields must be non-zero length")) - } - - if len(allErrors) > 0 { - for _, err := range allErrors { - errLogger.Printf("validation failure: %s", err.Error()) - } - return fmt.Errorf("%d validation failure(s) found in your vdm spec file", len(allErrors)) - } - - return nil -} diff --git a/validate_test.go b/validate_test.go deleted file mode 100644 index 17ba1f3..0000000 --- a/validate_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestValidate(t *testing.T) { - ctx := context.Background() - - t.Run("passes", func(t *testing.T) { - spec := vdmSpec{ - Remote: "https://some-remote", - Version: "v1.0.0", - LocalPath: "./deps/some-remote", - } - err := spec.Validate(ctx) - assert.NoError(t, err) - }) - - t.Run("fails on zero-length remote", func(t *testing.T) { - spec := vdmSpec{ - Remote: "", - Version: "v1.0.0", - LocalPath: "./deps/some-remote", - } - err := spec.Validate(ctx) - assert.Error(t, err) - }) - - t.Run("fails on remote without valid protocol", func(t *testing.T) { - spec := vdmSpec{ - Remote: "some-remote", - Version: "v1.0.0", - LocalPath: "./deps/some-remote", - } - err := spec.Validate(ctx) - assert.Error(t, err) - }) - - t.Run("fails on zero-length version", func(t *testing.T) { - spec := vdmSpec{ - Remote: "https://some-remote", - Version: "", - LocalPath: "./deps/some-remote", - } - err := spec.Validate(ctx) - assert.Error(t, err) - }) - - t.Run("fails on zero-length local path", func(t *testing.T) { - spec := vdmSpec{ - Remote: "https://some-remote", - Version: "v1.0.0", - LocalPath: "", - } - err := spec.Validate(ctx) - assert.Error(t, err) - }) -}